Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

14 changed files with 242 additions and 361 deletions

View File

@ -11,6 +11,7 @@ jobs:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
fetch-depth: 0
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
- run: apt-get update && apt-get install libxml2-utils -qqy - run: apt-get update && apt-get install libxml2-utils -qqy
- name: Fetch main branch - name: Fetch main branch
@ -50,7 +51,6 @@ jobs:
if: github.ref == 'refs/heads/signed' && github.event_name == 'push' if: github.ref == 'refs/heads/signed' && github.event_name == 'push'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
fetch-depth: 0
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
- run: pip3 install requests - run: pip3 install requests
- name: Install minisign - name: Install minisign

View File

@ -16,15 +16,3 @@ repos:
- id: yamllint - id: yamllint
args: args:
- -s - -s
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.1
hooks:
- id: ruff
args:
- --output-format=full
exclude: ^source/tools/webservices/
- id: ruff-format
args:
- --check
- --target-version
- py311

View File

@ -1,11 +1,5 @@
[font="sans-bold-24"] Community-mod changelog [font="sans-bold-24"] Community-mod changelog
[font="sans-bold-20"]
#Version 12 (September 10, 2024)
[font="sans-16"]
- Double the Experience required for Athenian Hoplites to convert into Champion Spearmen.
- Remove walls destroying trees upon completion due to crashes.
[font="sans-bold-20"] [font="sans-bold-20"]
#Version 11 (September 9, 2024) #Version 11 (September 9, 2024)
[font="sans-16"] [font="sans-16"]

View File

@ -1,6 +1,6 @@
{ {
"name": "community-mod", "name": "community-mod",
"version": "0.26.12", "version": "0.26.11",
"label": "0 A.D. Community Mod", "label": "0 A.D. Community Mod",
"url": "https://gitlab.com/0ad/0ad-community-mod-a26", "url": "https://gitlab.com/0ad/0ad-community-mod-a26",
"description": "The Community Mod is a community-led effort to improve the gameplay of 0 A.D., officially backed by the 0 A.D. team.", "description": "The Community Mod is a community-led effort to improve the gameplay of 0 A.D., officially backed by the 0 A.D. team.",

View File

@ -81,8 +81,7 @@ BuildRestrictions.prototype.Init = function()
* (template name should be "preview|"+templateName), as otherwise territory * (template name should be "preview|"+templateName), as otherwise territory
* checks for buildings with territory influence will not work as expected. * checks for buildings with territory influence will not work as expected.
*/ */
BuildRestrictions.prototype.CheckPlacement = function() BuildRestrictions.prototype.CheckPlacement = function () {
{
var cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity); var cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity);
var name = cmpIdentity ? cmpIdentity.GetGenericName() : "Building"; var name = cmpIdentity ? cmpIdentity.GetGenericName() : "Building";
@ -101,8 +100,7 @@ BuildRestrictions.prototype.CheckPlacement = function()
return result; // Fail return result; // Fail
// TODO: AI has no visibility info // TODO: AI has no visibility info
if (!cmpPlayer.IsAI()) if (!cmpPlayer.IsAI()) {
{
// Check whether it's in a visible or fogged region // Check whether it's in a visible or fogged region
var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership);
@ -110,8 +108,7 @@ BuildRestrictions.prototype.CheckPlacement = function()
return result; // Fail return result; // Fail
var explored = (cmpRangeManager.GetLosVisibility(this.entity, cmpOwnership.GetOwner()) != "hidden"); var explored = (cmpRangeManager.GetLosVisibility(this.entity, cmpOwnership.GetOwner()) != "hidden");
if (!explored) if (!explored) {
{
result.message = markForTranslation("%(name)s cannot be built in unexplored area"); result.message = markForTranslation("%(name)s cannot be built in unexplored area");
return result; // Fail return result; // Fail
} }
@ -119,21 +116,20 @@ BuildRestrictions.prototype.CheckPlacement = function()
// Check obstructions and terrain passability // Check obstructions and terrain passability
var passClassName = ""; var passClassName = "";
switch (this.template.PlacementType) switch (this.template.PlacementType) {
{ case "shore":
case "shore": passClassName = "building-shore";
passClassName = "building-shore"; break;
break;
case "land-shore": case "land-shore":
// 'default-terrain-only' is everywhere a normal unit can go, ignoring // 'default-terrain-only' is everywhere a normal unit can go, ignoring
// obstructions (i.e. on passable land, and not too deep in the water) // obstructions (i.e. on passable land, and not too deep in the water)
passClassName = "default-terrain-only"; passClassName = "default-terrain-only";
break; break;
case "land": case "land":
default: default:
passClassName = "building-land"; passClassName = "building-land";
} }
var cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction); var cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction);
@ -141,30 +137,38 @@ BuildRestrictions.prototype.CheckPlacement = function()
return result; // Fail return result; // Fail
if (this.template.Category == "Wall") if (this.template.Category == "Wall") {
{ // allow walls to be built on top of trees
let collisions = cmpObstruction.GetEntitiesBlockingConstruction();
let areWoodEnts = collisions.length > 0;
for (let ent of collisions) {
// don't let entities with unitmotion prevent us from laying a foundation
let cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion);
let cmpResource = Engine.QueryInterface(ent, IID_ResourceSupply);
if ((!cmpResource || cmpResource.GetType().generic !== "wood") && !cmpUnitMotion) {
areWoodEnts = false;
break;
}
}
// for walls, only test the center point // for walls, only test the center point
var ret = cmpObstruction.CheckFoundation(passClassName, true); var ret = areWoodEnts ? "success" : cmpObstruction.CheckFoundation(passClassName, true);
} }
else else {
{
var ret = cmpObstruction.CheckFoundation(passClassName, false); var ret = cmpObstruction.CheckFoundation(passClassName, false);
} }
if (ret != "success") if (ret != "success") {
{ switch (ret) {
switch (ret) case "fail_error":
{ case "fail_no_obstruction":
case "fail_error": error("CheckPlacement: Error returned from CheckFoundation");
case "fail_no_obstruction": break;
error("CheckPlacement: Error returned from CheckFoundation"); case "fail_obstructs_foundation":
break; result.message = markForTranslation("%(name)s cannot be built on another building or resource");
case "fail_obstructs_foundation": break;
result.message = markForTranslation("%(name)s cannot be built on another building or resource"); case "fail_terrain_class":
break; // TODO: be more specific and/or list valid terrain?
case "fail_terrain_class": result.message = markForTranslation("%(name)s cannot be built on invalid terrain");
// TODO: be more specific and/or list valid terrain?
result.message = markForTranslation("%(name)s cannot be built on invalid terrain");
} }
return result; // Fail return result; // Fail
} }
@ -183,8 +187,7 @@ BuildRestrictions.prototype.CheckPlacement = function()
var isNeutral = tileOwner == 0; var isNeutral = tileOwner == 0;
var invalidTerritory = ""; var invalidTerritory = "";
if (isOwn) if (isOwn) {
{
if (!this.HasTerritory("own")) if (!this.HasTerritory("own"))
// Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.". // Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.".
invalidTerritory = markForTranslationWithContext("Territory type", "own"); invalidTerritory = markForTranslationWithContext("Territory type", "own");
@ -192,8 +195,7 @@ BuildRestrictions.prototype.CheckPlacement = function()
// Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.". // Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.".
invalidTerritory = markForTranslationWithContext("Territory type", "unconnected own"); invalidTerritory = markForTranslationWithContext("Territory type", "unconnected own");
} }
else if (isMutualAlly) else if (isMutualAlly) {
{
if (!this.HasTerritory("ally")) if (!this.HasTerritory("ally"))
// Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.". // Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.".
invalidTerritory = markForTranslationWithContext("Territory type", "allied"); invalidTerritory = markForTranslationWithContext("Territory type", "allied");
@ -201,22 +203,19 @@ BuildRestrictions.prototype.CheckPlacement = function()
// Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.". // Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.".
invalidTerritory = markForTranslationWithContext("Territory type", "unconnected allied"); invalidTerritory = markForTranslationWithContext("Territory type", "unconnected allied");
} }
else if (isNeutral) else if (isNeutral) {
{
if (!this.HasTerritory("neutral")) if (!this.HasTerritory("neutral"))
// Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.". // Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.".
invalidTerritory = markForTranslationWithContext("Territory type", "neutral"); invalidTerritory = markForTranslationWithContext("Territory type", "neutral");
} }
else else {
{
// consider everything else enemy territory // consider everything else enemy territory
if (!this.HasTerritory("enemy")) if (!this.HasTerritory("enemy"))
// Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.". // Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.".
invalidTerritory = markForTranslationWithContext("Territory type", "enemy"); invalidTerritory = markForTranslationWithContext("Territory type", "enemy");
} }
if (invalidTerritory) if (invalidTerritory) {
{
result.message = markForTranslation("%(name)s cannot be built in %(territoryType)s territory. Valid territories: %(validTerritories)s"); result.message = markForTranslation("%(name)s cannot be built in %(territoryType)s territory. Valid territories: %(validTerritories)s");
result.translateParameters.push("territoryType"); result.translateParameters.push("territoryType");
result.translateParameters.push("validTerritories"); result.translateParameters.push("validTerritories");
@ -227,10 +226,8 @@ BuildRestrictions.prototype.CheckPlacement = function()
} }
// Check special requirements // Check special requirements
if (this.template.PlacementType == "shore") if (this.template.PlacementType == "shore") {
{ if (!cmpObstruction.CheckShorePlacement()) {
if (!cmpObstruction.CheckShorePlacement())
{
result.message = markForTranslation("%(name)s must be built on a valid shoreline"); result.message = markForTranslation("%(name)s must be built on a valid shoreline");
return result; // Fail return result; // Fail
} }
@ -242,22 +239,18 @@ BuildRestrictions.prototype.CheckPlacement = function()
let template = cmpTemplateManager.GetTemplate(removeFiltersFromTemplateName(templateName)); let template = cmpTemplateManager.GetTemplate(removeFiltersFromTemplateName(templateName));
// Check distance restriction // Check distance restriction
if (this.template.Distance) if (this.template.Distance) {
{
var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
var cat = this.template.Distance.FromClass; var cat = this.template.Distance.FromClass;
var filter = function(id) var filter = function (id) {
{
var cmpIdentity = Engine.QueryInterface(id, IID_Identity); var cmpIdentity = Engine.QueryInterface(id, IID_Identity);
return cmpIdentity.GetClassesList().indexOf(cat) > -1; return cmpIdentity.GetClassesList().indexOf(cat) > -1;
}; };
if (this.template.Distance.MinDistance !== undefined) if (this.template.Distance.MinDistance !== undefined) {
{
let minDistance = ApplyValueModificationsToTemplate("BuildRestrictions/Distance/MinDistance", +this.template.Distance.MinDistance, cmpPlayer.GetPlayerID(), template); let minDistance = ApplyValueModificationsToTemplate("BuildRestrictions/Distance/MinDistance", +this.template.Distance.MinDistance, cmpPlayer.GetPlayerID(), template);
if (cmpRangeManager.ExecuteQuery(this.entity, 0, minDistance, [cmpPlayer.GetPlayerID()], IID_BuildRestrictions, false).some(filter)) if (cmpRangeManager.ExecuteQuery(this.entity, 0, minDistance, [cmpPlayer.GetPlayerID()], IID_BuildRestrictions, false).some(filter)) {
{
let result = markForPluralTranslation( let result = markForPluralTranslation(
"%(name)s too close to a %(category)s, must be at least %(distance)s meter away", "%(name)s too close to a %(category)s, must be at least %(distance)s meter away",
"%(name)s too close to a %(category)s, must be at least %(distance)s meters away", "%(name)s too close to a %(category)s, must be at least %(distance)s meters away",
@ -274,11 +267,9 @@ BuildRestrictions.prototype.CheckPlacement = function()
return result; // Fail return result; // Fail
} }
} }
if (this.template.Distance.MaxDistance !== undefined) if (this.template.Distance.MaxDistance !== undefined) {
{
let maxDistance = ApplyValueModificationsToTemplate("BuildRestrictions/Distance/MaxDistance", +this.template.Distance.MaxDistance, cmpPlayer.GetPlayerID(), template); let maxDistance = ApplyValueModificationsToTemplate("BuildRestrictions/Distance/MaxDistance", +this.template.Distance.MaxDistance, cmpPlayer.GetPlayerID(), template);
if (!cmpRangeManager.ExecuteQuery(this.entity, 0, maxDistance, [cmpPlayer.GetPlayerID()], IID_BuildRestrictions, false).some(filter)) if (!cmpRangeManager.ExecuteQuery(this.entity, 0, maxDistance, [cmpPlayer.GetPlayerID()], IID_BuildRestrictions, false).some(filter)) {
{
let result = markForPluralTranslation( let result = markForPluralTranslation(
"%(name)s too far from a %(category)s, must be within %(distance)s meter", "%(name)s too far from a %(category)s, must be within %(distance)s meter",
"%(name)s too far from a %(category)s, must be within %(distance)s meters", "%(name)s too far from a %(category)s, must be within %(distance)s meters",
@ -303,6 +294,11 @@ BuildRestrictions.prototype.CheckPlacement = function()
return result; return result;
}; };
BuildRestrictions.prototype.IsWoodEntity = function (entity) {
let cmpResource = Engine.QueryInterface(entity, IID_ResourceSupply);
return cmpResource && cmpResource.GetType().generic === "wood";
}
BuildRestrictions.prototype.GetCategory = function() BuildRestrictions.prototype.GetCategory = function()
{ {
return this.template.Category; return this.template.Category;

View File

@ -5,8 +5,7 @@ Foundation.prototype.Schema =
"<ref name='nonNegativeDecimal'/>" + "<ref name='nonNegativeDecimal'/>" +
"</element>"; "</element>";
Foundation.prototype.Init = function() Foundation.prototype.Init = function () {
{
// Foundations are initially 'uncommitted' and do not block unit movement at all // Foundations are initially 'uncommitted' and do not block unit movement at all
// (to prevent players exploiting free foundations to confuse enemy units). // (to prevent players exploiting free foundations to confuse enemy units).
// The first builder to reach the uncommitted foundation will tell friendly units // The first builder to reach the uncommitted foundation will tell friendly units
@ -21,6 +20,7 @@ Foundation.prototype.Init = function()
this.buildTimeModifier = +this.template.BuildTimeModifier; this.buildTimeModifier = +this.template.BuildTimeModifier;
this.previewEntity = INVALID_ENTITY; this.previewEntity = INVALID_ENTITY;
this.entsToDestroy;
}; };
Foundation.prototype.Serialize = function() Foundation.prototype.Serialize = function()
@ -101,9 +101,12 @@ Foundation.prototype.GetNumBuilders = function()
return this.builders.size; return this.builders.size;
}; };
Foundation.prototype.IsFinished = function() Foundation.prototype.IsFinished = function () {
{ if (this.GetBuildProgress() == 1.0 && this.entsToDestroy)
return (this.GetBuildProgress() == 1.0); for (let ent of this.entsToDestroy)
Engine.DestroyEntity(ent);
return this.GetBuildProgress() == 1.0;
}; };
Foundation.prototype.OnOwnershipChanged = function(msg) Foundation.prototype.OnOwnershipChanged = function(msg)
@ -246,22 +249,18 @@ Foundation.prototype.GetBuildTime = function()
/** /**
* @return {boolean} - Whether the foundation has been committed sucessfully. * @return {boolean} - Whether the foundation has been committed sucessfully.
*/ */
Foundation.prototype.Commit = function() Foundation.prototype.Commit = function () {
{
if (this.committed) if (this.committed)
return false; return false;
let cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction); let cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction);
if (cmpObstruction && cmpObstruction.GetBlockMovementFlag(true)) if (cmpObstruction && cmpObstruction.GetBlockMovementFlag(true)) {
{ this.entsToDestroy = cmpObstruction.GetEntitiesDeletedUponConstruction();
for (let ent of cmpObstruction.GetEntitiesDeletedUponConstruction())
Engine.DestroyEntity(ent);
let collisions = cmpObstruction.GetEntitiesBlockingConstruction(); let collisions = cmpObstruction.GetEntitiesBlockingConstruction();
if (collisions.length) if (collisions.length) {
{ for (let ent of collisions) {
for (let ent of collisions)
{
let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI);
if (cmpUnitAI) if (cmpUnitAI)
cmpUnitAI.LeaveFoundation(this.entity); cmpUnitAI.LeaveFoundation(this.entity);
@ -273,10 +272,10 @@ Foundation.prototype.Commit = function()
// animation to indicate they're waiting for people to get // animation to indicate they're waiting for people to get
// out the way // out the way
return false;
} }
} }
// The obstruction always blocks new foundations/construction, // The obstruction always blocks new foundations/construction,
// but we've temporarily allowed units to walk all over it // but we've temporarily allowed units to walk all over it
// (via CCmpTemplateManager). Now we need to remove that temporary // (via CCmpTemplateManager). Now we need to remove that temporary

View File

@ -4,7 +4,7 @@
<Rank>Elite</Rank> <Rank>Elite</Rank>
</Identity> </Identity>
<Promotion> <Promotion>
<RequiredXp>400</RequiredXp> <RequiredXp>200</RequiredXp>
<Entity>units/athen/champion_infantry</Entity> <Entity>units/athen/champion_infantry</Entity>
</Promotion> </Promotion>
<VisualActor> <VisualActor>

View File

@ -1,56 +0,0 @@
line-length = 99
[format]
line-ending = "lf"
[lint]
select = ["ALL"]
ignore = [
"ANN",
"BLE001",
"C90",
"COM812",
"D10",
"DTZ005",
"EM",
"FA",
"FIX",
"FBT",
"ISC001",
"N817",
"PERF203",
"PERF401",
"PLR0912",
"PLR0913",
"PLR0915",
"PLR2004",
"PLW2901",
"PT",
"PTH",
"RUF012",
"S101",
"S310",
"S314",
"S324",
"S320",
"S603",
"S607",
"T20",
"TD002",
"TD003",
"TRY002",
"TRY003",
"TRY004",
"TRY301",
"UP038",
"W505"
]
[lint.isort]
lines-after-imports = 2
[lint.pycodestyle]
max-doc-length = 72
[lint.pydocstyle]
convention = "pep257"

View File

@ -1,17 +1,16 @@
import json import json
from pathlib import Path from pathlib import Path
MOD_PATH = Path("community-mod") MOD_PATH = Path("community-mod")
def check_cwd_is_correct(): def check_cwd_is_correct():
try: cwd = Path().resolve()
if not Path(".git").exists(): try:
raise Exception("No .git in current folder.") if not Path(".git").exists():
with open(MOD_PATH / "mod.json") as f: raise Exception("No .git in current folder.")
mod = json.load(f) with open(MOD_PATH / "mod.json", "r") as f:
if mod["name"] != "community-mod": mod = json.load(f)
raise Exception("mod.json has incorrect name") if mod['name'] != "community-mod":
except Exception as e: raise Exception("mod.json has incorrect name")
raise Exception("scripts.py must be called from the community mod repository") from e except:
raise Exception("scripts.py must be called from the community mod repository")

View File

@ -1,42 +1,43 @@
import argparse import argparse
import json
import shutil import shutil
import json
from pathlib import Path from pathlib import Path
from . import MOD_PATH, check_cwd_is_correct from . import MOD_PATH, check_cwd_is_correct
PUBLIC_PATH = Path("binaries/data/mods/public/") PUBLIC_PATH = Path("binaries/data/mods/public/")
DEFAULT_COPY = ["simulation/data", "simulation/templates"] DEFAULT_COPY = [
"simulation/data",
"simulation/templates"
]
def validate_path(path: str): def validate_path(path: str):
mod_path = Path(path) / PUBLIC_PATH mod_path = Path(path) / PUBLIC_PATH
try: try:
with open(mod_path / "mod.json", encoding="utf-8") as f: with open(mod_path / "mod.json", "r", encoding="utf-8") as f:
mod = json.load(f) mod = json.load(f)
if mod["name"] != "0ad": if mod['name'] != "0ad":
raise Exception("mod.json has incorrect name") raise Exception("mod.json has incorrect name")
except Exception as e: except:
raise Exception(f"path '{path}' does not point to a 0 A.D. SVN folder.") from e raise Exception(f"path '{path}' does not point to a 0 A.D. SVN folder.")
return mod_path return mod_path
def copy_0ad_files(path_0ad: Path, to_copy=DEFAULT_COPY): def copy_0ad_files(path_0ad: Path, to_copy = DEFAULT_COPY):
for path in to_copy: for path in to_copy:
shutil.copytree(path_0ad / path, MOD_PATH / path, dirs_exist_ok=False) shutil.copytree(path_0ad / path, MOD_PATH / path, dirs_exist_ok=False)
if __name__ == "__main__": if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Copy files from 0 A.D. to the community mod.") parser = argparse.ArgumentParser(description='Copy files from 0 A.D. to the community mod.')
parser.add_argument("-0ad", help="Path to the 0 A.D. folder") parser.add_argument('-0ad', help='Path to the 0 A.D. folder')
parser.add_argument("-p", "--path", nargs="*", help="Optionally, a list of paths to copy.") parser.add_argument('-p','--path', nargs='*', help='Optionally, a list of paths to copy.')
args = parser.parse_args() args = parser.parse_args()
check_cwd_is_correct() check_cwd_is_correct()
path = validate_path(getattr(args, "0ad")) path = validate_path(getattr(args, '0ad'))
copy_0ad_files(path, args.folder or DEFAULT_COPY) copy_0ad_files(path, args.folder or DEFAULT_COPY)
else: else:
raise Exception("Must be called directly") raise Exception("Must be called directly")

View File

@ -1,13 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import logging from os import chdir
from pathlib import Path from pathlib import Path
from subprocess import CalledProcessError, run from subprocess import run, CalledProcessError
from sys import exit from sys import exit
from xml.etree import ElementTree as ET from xml.etree import ElementTree
from .scriptlib import SimulTemplateEntity, find_files from .scriptlib import SimulTemplateEntity, find_files
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -16,54 +15,37 @@ relaxng_schema = root / "scripts" / "entity.rng"
vfs_root = root vfs_root = root
mod = "community-mod" mod = "community-mod"
def main(): def main():
if not relaxng_schema.exists(): if not relaxng_schema.exists():
print(f"""Relax NG schema non existant. print(f"""Relax NG schema non existant.
Please create the file {relaxng_schema.relative_to(root)} Please create the file {relaxng_schema.relative_to(root)}
You can do that by running 'pyrogenesis -dumpSchema' in the 'system' directory""") You can do that by running 'pyrogenesis -dumpSchema' in the 'system' directory""")
exit(1) exit(1)
if run(["xmllint", "--version"], capture_output=True, check=False).returncode != 0: if run(['xmllint', '--version'], capture_output=True).returncode != 0:
print("xmllint not found in your PATH, please install it (usually in libxml2 package)") print("xmllint not found in your PATH, please install it (usually in libxml2 package)")
exit(2) exit(2)
parser = argparse.ArgumentParser(description="Validate templates") parser = argparse.ArgumentParser(description='Validate templates')
parser.add_argument( parser.add_argument('-p','--path', nargs='*', help='Optionally, a list of templates to validate.')
"-p", "--path", nargs="*", help="Optionally, a list of templates to validate."
)
args = parser.parse_args() args = parser.parse_args()
simul_templates_path = Path("simulation/templates") simul_templates_path = Path('simulation/templates')
simul_template_entity = SimulTemplateEntity(vfs_root, logger) simul_template_entity = SimulTemplateEntity(vfs_root, logger)
count = 0 count = 0
failed = 0 failed = 0
templates = sorted([(Path(p), None) for p in args.path]) if args.path else sorted(find_files(vfs_root, [mod], 'simulation/templates', 'xml'))
templates = []
if args.path is not None:
templates = sorted([(Path(p), None) for p in args.path])
else:
templates = sorted(find_files(vfs_root, [mod], "simulation/templates", "xml"))
for fp, _ in templates: for fp, _ in templates:
if fp.stem.startswith("template_"): if fp.stem.startswith('template_'):
continue continue
path = fp.as_posix() path = fp.as_posix()
if path.startswith(("simulation/templates/mixins/", "simulation/templates/special/")): if path.startswith('simulation/templates/mixins/') or path.startswith("simulation/templates/special/"):
continue continue
print(f"# {fp}...") print(f"# {fp}...")
count += 1 count += 1
entity = simul_template_entity.load_inherited( entity = simul_template_entity.load_inherited(simul_templates_path, str(fp.relative_to(simul_templates_path)), [mod])
simul_templates_path, str(fp.relative_to(simul_templates_path)), [mod] xmlcontent = ElementTree.tostring(entity, encoding='unicode')
)
xmlcontent = ET.tostring(entity, encoding="unicode")
try: try:
run( run(['xmllint', '--relaxng', str(relaxng_schema.resolve()), '-'], input=xmlcontent, capture_output=True, text=True, check=True)
["xmllint", "--relaxng", str(relaxng_schema.resolve()), "-"],
input=xmlcontent,
capture_output=True,
text=True,
check=True,
)
except CalledProcessError as e: except CalledProcessError as e:
failed += 1 failed += 1
print(e.stderr) print(e.stderr)
@ -71,5 +53,5 @@ You can do that by running 'pyrogenesis -dumpSchema' in the 'system' directory""
print(f"\nTotal: {count}; failed: {failed}") print(f"\nTotal: {count}; failed: {failed}")
if __name__ == "__main__": if __name__ == '__main__':
main() main()

View File

@ -1,16 +1,13 @@
import json import json
from . import MOD_PATH, check_cwd_is_correct from . import MOD_PATH, check_cwd_is_correct
def get_version(): def get_version():
with open(MOD_PATH / "mod.json") as f: with open(MOD_PATH / "mod.json", "r") as f:
mod = json.load(f) mod = json.load(f)
print(mod["version"]) print(mod['version'])
if __name__ == '__main__':
if __name__ == "__main__": check_cwd_is_correct()
check_cwd_is_correct() get_version()
get_version()
else: else:
raise Exception("Must be called directly") raise Exception("Must be called directly")

View File

@ -1,42 +1,36 @@
import json import json
import os import os
import requests
from . import MOD_PATH
import subprocess import subprocess
import requests
from . import MOD_PATH
def run_git_command(command): def run_git_command(command):
"""Run a git command and return its output.""" """ Run a git command and return its output. """
result = subprocess.run(command, capture_output=True, text=True, check=False) result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0: if result.returncode != 0:
raise Exception(f"Git command failed: {result.stderr}") raise Exception(f"Git command failed: {result.stderr}")
return result.stdout.strip() return result.stdout.strip()
def get_current_commit(): def get_current_commit():
"""Get the current commit hash.""" """ Get the current commit hash. """
return run_git_command(["git", "rev-parse", "HEAD"]) return run_git_command(['git', 'rev-parse', 'HEAD'])
def get_commit_for_tag(tag): def get_commit_for_tag(tag):
"""Get the commit hash for a tag, or return None if the tag doesn't exist.""" """ Get the commit hash for a tag, or return None if the tag doesn't exist. """
try: try:
return run_git_command(["git", "rev-list", "-n", "1", tag]) commit_hash = run_git_command(['git', 'rev-list', '-n', '1', tag])
except Exception as e: return commit_hash
print(f"Failed to get commit for tag {tag}: {e}") except Exception:
return None return None
def get_previous_tag_in_series(tag): def get_previous_tag_in_series(tag):
"""Get the previous tag in the same minor version series (e.g., 0.26.X-1).""" """ Get the previous tag in the same minor version series (e.g., 0.26.X-1). """
try: try:
version_array = tag.split(".") version_array = tag.split('.')
# Extract the major.minor part of the tag (e.g., '0.26' from '0.26.11') # Extract the major.minor part of the tag (e.g., '0.26' from '0.26.11')
major_minor_version = ".".join(version_array[:2]) major_minor_version = '.'.join(version_array[:2])
# List all tags, reverse sorted by version number # List all tags, reverse sorted by version number
tags = run_git_command(["git", "tag", "--sort=-v:refname"]).splitlines() tags = run_git_command(['git', 'tag', '--sort=-v:refname']).splitlines()
# Filter tags that match the major.minor version # Filter tags that match the major.minor version
filtered_tags = [t for t in tags if t.startswith(major_minor_version)] filtered_tags = [t for t in tags if t.startswith(major_minor_version)]
if tag in filtered_tags: if tag in filtered_tags:
@ -45,30 +39,28 @@ def get_previous_tag_in_series(tag):
return filtered_tags[current_index + 1] return filtered_tags[current_index + 1]
version_array[2] = str(int(version_array[2]) - 1) version_array[2] = str(int(version_array[2]) - 1)
manual_tag = ".".join(version_array) manual_tag = '.'.join(version_array)
if manual_tag not in filtered_tags: if manual_tag in filtered_tags:
return None current_index = filtered_tags.index(manual_tag)
return filtered_tags[current_index]
current_index = filtered_tags.index(manual_tag)
return filtered_tags[current_index] return None
except Exception as e: except Exception as e:
raise Exception(f"Failed to get previous tag in series: {e}") from e raise Exception(f"Failed to get previous tag in series: {e}")
def get_changelog(current_commit, previous_tag): def get_changelog(current_commit, previous_tag):
"""Get the changelog between the current commit and the previous tag.""" """ Get the changelog between the current commit and the previous tag. """
try: try:
if previous_tag is not None: if previous_tag is not None:
return run_git_command( changelog = run_git_command(['git', 'log', f'{previous_tag}..{current_commit}', '--oneline'])
["git", "log", f"{previous_tag}..{current_commit}", "--oneline"] else:
) # No previous tag, so get the log from the start of the repository
# No previous tag, so get the log from the start of the repository changelog = run_git_command(['git', 'log', current_commit, '--oneline'])
return run_git_command(["git", "log", current_commit, "--oneline"]) return changelog
except Exception as e: except Exception as e:
raise Exception(f"Failed to generate changelog: {e}") from e raise Exception(f"Failed to generate changelog: {e}")
api_key = os.getenv('MODIO_API_KEY')
api_key = os.getenv("MODIO_API_KEY")
# This must be generated manually from the website's interface. # This must be generated manually from the website's interface.
oauth2_token = os.getenv("MODIO_OAUTH2_TOKEN") oauth2_token = os.getenv("MODIO_OAUTH2_TOKEN")
@ -80,11 +72,9 @@ mod_name = os.getenv("MOD_NAME")
community_mod_id = 2144305 community_mod_id = 2144305
zeroad_id = 5 zeroad_id = 5
mod_json = None mod_json = json.load(open(MOD_PATH / 'mod.json', 'r'))
with open(MOD_PATH / "mod.json") as f:
mod_json = json.load(f)
tag = mod_json["version"] tag = mod_json['version']
commit = get_commit_for_tag(tag) commit = get_commit_for_tag(tag)
if not commit: if not commit:
print(f"Tag {tag} does not exist. Using current commit as fallback.") print(f"Tag {tag} does not exist. Using current commit as fallback.")
@ -93,35 +83,29 @@ if not commit:
previous_tag = get_previous_tag_in_series(tag) previous_tag = get_previous_tag_in_series(tag)
changelog = get_changelog(commit, previous_tag) changelog = get_changelog(commit, previous_tag)
print(changelog) headers = {
'Authorization': f'Bearer {oauth2_token}',
'Accept': 'application/json'
}
headers = {"Authorization": f"Bearer {oauth2_token}", "Accept": "application/json"} r = requests.get(f'https://api.mod.io/v1/me/mods', params={
'api_key': api_key
r = requests.get( }, headers = headers)
"https://api.mod.io/v1/me/mods", timeout=5, params={"api_key": api_key}, headers=headers
)
print(r.json()) print(r.json())
with open(mod_file_path, "rb") as f: files = {'filedata': open(mod_file_path, 'rb')}
files = {"filedata": f}
signature = ""
with open(f"{mod_name}-{mod_version}.zip.minisign", encoding="utf-8") as f:
signature = f.read()
rq = requests.post( signature = open(f"{mod_name}-{mod_version}.zip.minisign", 'r', encoding="utf-8").read()
f"https://api.mod.io/v1/games/{zeroad_id}/mods/{community_mod_id}/files",
files=files,
headers=headers,
timeout=500,
data={
"version": mod_version,
"active": True,
"changelog": changelog,
"metadata_blob": json.dumps(
{"dependencies": mod_json["dependencies"], "minisigs": [signature]}
),
},
)
print(rq.json()) rq = requests.post(f'https://api.mod.io/v1/games/{zeroad_id}/mods/{community_mod_id}/files', files=files, headers=headers, data={
'version': mod_version,
'active': True,
'changelog': changelog,
'metadata_blob' : json.dumps({
'dependencies': mod_json['dependencies'],
'minisigs': [signature]
})
})
print(rq.json())

View File

@ -1,9 +1,9 @@
from collections import Counter from collections import Counter
from decimal import Decimal from decimal import Decimal
from os.path import exists
from re import split from re import split
from xml.etree import ElementTree as ET from sys import stderr
from xml.etree import ElementTree
from os.path import exists
class SimulTemplateEntity: class SimulTemplateEntity:
def __init__(self, vfs_root, logger): def __init__(self, vfs_root, logger):
@ -12,11 +12,11 @@ class SimulTemplateEntity:
def get_file(self, base_path, vfs_path, mod): def get_file(self, base_path, vfs_path, mod):
default_path = self.vfs_root / mod / base_path default_path = self.vfs_root / mod / base_path
file = (default_path / "special" / "filter" / vfs_path).with_suffix(".xml") file = (default_path/ "special" / "filter" / vfs_path).with_suffix('.xml')
if not exists(file): if not exists(file):
file = (default_path / "mixins" / vfs_path).with_suffix(".xml") file = (default_path / "mixins" / vfs_path).with_suffix('.xml')
if not exists(file): if not exists(file):
file = (default_path / vfs_path).with_suffix(".xml") file = (default_path / vfs_path).with_suffix('.xml')
return file return file
def get_main_mod(self, base_path, vfs_path, mods): def get_main_mod(self, base_path, vfs_path, mods):
@ -33,110 +33,107 @@ class SimulTemplateEntity:
return main_mod return main_mod
def apply_layer(self, base_tag, tag): def apply_layer(self, base_tag, tag):
"""Apply tag layer to base_tag.""" """
if tag.get("datatype") == "tokens": apply tag layer to base_tag
base_tokens = split(r"\s+", base_tag.text or "") """
tokens = split(r"\s+", tag.text or "") if tag.get('datatype') == 'tokens':
base_tokens = split(r'\s+', base_tag.text or '')
tokens = split(r'\s+', tag.text or '')
final_tokens = base_tokens.copy() final_tokens = base_tokens.copy()
for token in tokens: for token in tokens:
if token.startswith("-"): if token.startswith('-'):
token_to_remove = token[1:] token_to_remove = token[1:]
if token_to_remove in final_tokens: if token_to_remove in final_tokens:
final_tokens.remove(token_to_remove) final_tokens.remove(token_to_remove)
elif token not in final_tokens: elif token not in final_tokens:
final_tokens.append(token) final_tokens.append(token)
base_tag.text = " ".join(final_tokens) base_tag.text = ' '.join(final_tokens)
base_tag.set("datatype", "tokens") base_tag.set("datatype", "tokens")
elif tag.get("op"): elif tag.get('op'):
op = tag.get("op") op = tag.get('op')
op1 = Decimal(base_tag.text or "0") op1 = Decimal(base_tag.text or '0')
op2 = Decimal(tag.text or "0") op2 = Decimal(tag.text or '0')
# Try converting to integers if possible, to pass validation. # Try converting to integers if possible, to pass validation.
if op == "add": if op == 'add':
base_tag.text = str(int(op1 + op2) if int(op1 + op2) == op1 + op2 else op1 + op2) base_tag.text = str(int(op1 + op2) if int(op1 + op2) == op1 + op2 else op1 + op2)
elif op == "mul": elif op == 'mul':
base_tag.text = str(int(op1 * op2) if int(op1 * op2) == op1 * op2 else op1 * op2) base_tag.text = str(int(op1 * op2) if int(op1 * op2) == op1 * op2 else op1 * op2)
elif op == "mul_round": elif op == 'mul_round':
base_tag.text = str(round(op1 * op2)) base_tag.text = str(round(op1 * op2))
else: else:
raise ValueError(f"Invalid operator '{op}'") raise ValueError(f"Invalid operator '{op}'")
else: else:
base_tag.text = tag.text base_tag.text = tag.text
for prop in tag.attrib: for prop in tag.attrib:
if prop not in ("disable", "replace", "parent", "merge"): if prop not in ('disable', 'replace', 'parent', 'merge'):
base_tag.set(prop, tag.get(prop)) base_tag.set(prop, tag.get(prop))
for child in tag: for child in tag:
base_child = base_tag.find(child.tag) base_child = base_tag.find(child.tag)
if "disable" in child.attrib: if 'disable' in child.attrib:
if base_child is not None: if base_child is not None:
base_tag.remove(base_child) base_tag.remove(base_child)
elif ("merge" not in child.attrib) or (base_child is not None): elif ('merge' not in child.attrib) or (base_child is not None):
if "replace" in child.attrib and base_child is not None: if 'replace' in child.attrib and base_child is not None:
base_tag.remove(base_child) base_tag.remove(base_child)
base_child = None base_child = None
if base_child is None: if base_child is None:
base_child = ET.Element(child.tag) base_child = ElementTree.Element(child.tag)
base_tag.append(base_child) base_tag.append(base_child)
self.apply_layer(base_child, child) self.apply_layer(base_child, child)
if "replace" in base_child.attrib: if 'replace' in base_child.attrib:
del base_child.attrib["replace"] del base_child.attrib['replace']
def load_inherited(self, base_path, vfs_path, mods): def load_inherited(self, base_path, vfs_path, mods):
entity = self._load_inherited(base_path, vfs_path, mods) entity = self._load_inherited(base_path, vfs_path, mods)
entity[:] = sorted(entity[:], key=lambda x: x.tag) entity[:] = sorted(entity[:], key=lambda x: x.tag)
return entity return entity
def _load_inherited(self, base_path, vfs_path, mods, base=None): def _load_inherited(self, base_path, vfs_path, mods, base = None):
"""vfs_path should be relative to base_path in a mod.""" """
if "|" in vfs_path: vfs_path should be relative to base_path in a mod
"""
if '|' in vfs_path:
paths = vfs_path.split("|", 1) paths = vfs_path.split("|", 1)
self._load_inherited(base_path, paths[1], mods, base) base = self._load_inherited(base_path, paths[1], mods, base);
return self._load_inherited(base_path, paths[0], mods, base) base = self._load_inherited(base_path, paths[0], mods, base);
return base
main_mod = self.get_main_mod(base_path, vfs_path, mods) main_mod = self.get_main_mod(base_path, vfs_path, mods)
fp = self.get_file(base_path, vfs_path, main_mod) fp = self.get_file(base_path, vfs_path, main_mod)
layer = ET.parse(fp).getroot() layer = ElementTree.parse(fp).getroot()
for el in layer.iter(): for el in layer.iter():
children = [x.tag for x in el] children = [x.tag for x in el]
duplicates = [x for x, c in Counter(children).items() if c > 1] duplicates = [x for x, c in Counter(children).items() if c > 1]
if duplicates: if duplicates:
for dup in duplicates: for dup in duplicates:
self.logger.warning( self.logger.warning(f"Duplicate child node '{dup}' in tag {el.tag} of {fp}")
"Duplicate child node '%s' in tag %s of %s", dup, el.tag, fp if layer.get('parent'):
) parent = self._load_inherited(base_path, layer.get('parent'), mods, base)
if layer.get("parent"):
parent = self._load_inherited(base_path, layer.get("parent"), mods, base)
self.apply_layer(parent, layer) self.apply_layer(parent, layer)
return parent return parent
else:
if not base: if not base:
return layer return layer
else:
self.apply_layer(base, layer) self.apply_layer(base, layer)
return base return base
def find_files(vfs_root, mods, vfs_path, *ext_list): def find_files(vfs_root, mods, vfs_path, *ext_list):
"""Return a list of 2-size tuples.
Each tuple contains:
- Path relative to the mod base
- full Path
""" """
full_exts = ["." + ext for ext in ext_list] returns a list of 2-size tuple with:
- Path relative to the mod base
- full Path
"""
full_exts = ['.' + ext for ext in ext_list]
def find_recursive(dp, base): def find_recursive(dp, base):
"""(relative Path, full Path) generator.""" """(relative Path, full Path) generator"""
if dp.is_dir(): if dp.is_dir():
if dp.name not in (".svn", ".git") and not dp.name.endswith("~"): if dp.name != '.svn' and dp.name != '.git' and not dp.name.endswith('~'):
for fp in dp.iterdir(): for fp in dp.iterdir():
yield from find_recursive(fp, base) yield from find_recursive(fp, base)
elif dp.suffix in full_exts: elif dp.suffix in full_exts:
relative_file_path = dp.relative_to(base) relative_file_path = dp.relative_to(base)
yield (relative_file_path, dp.resolve()) yield (relative_file_path, dp.resolve())
return [(rp, fp) for mod in mods for (rp, fp) in find_recursive(vfs_root / mod / vfs_path, vfs_root / mod)]
return [
(rp, fp)
for mod in mods
for (rp, fp) in find_recursive(vfs_root / mod / vfs_path, vfs_root / mod)
]