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

View File

@ -16,15 +16,3 @@ repos:
- id: yamllint
args:
- -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-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"]
#Version 11 (September 9, 2024)
[font="sans-16"]

View File

@ -1,6 +1,6 @@
{
"name": "community-mod",
"version": "0.26.12",
"version": "0.26.11",
"label": "0 A.D. Community Mod",
"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.",

View File

@ -81,8 +81,7 @@ BuildRestrictions.prototype.Init = function()
* (template name should be "preview|"+templateName), as otherwise territory
* 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 name = cmpIdentity ? cmpIdentity.GetGenericName() : "Building";
@ -101,8 +100,7 @@ BuildRestrictions.prototype.CheckPlacement = function()
return result; // Fail
// TODO: AI has no visibility info
if (!cmpPlayer.IsAI())
{
if (!cmpPlayer.IsAI()) {
// Check whether it's in a visible or fogged region
var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership);
@ -110,8 +108,7 @@ BuildRestrictions.prototype.CheckPlacement = function()
return result; // Fail
var explored = (cmpRangeManager.GetLosVisibility(this.entity, cmpOwnership.GetOwner()) != "hidden");
if (!explored)
{
if (!explored) {
result.message = markForTranslation("%(name)s cannot be built in unexplored area");
return result; // Fail
}
@ -119,21 +116,20 @@ BuildRestrictions.prototype.CheckPlacement = function()
// Check obstructions and terrain passability
var passClassName = "";
switch (this.template.PlacementType)
{
case "shore":
passClassName = "building-shore";
break;
switch (this.template.PlacementType) {
case "shore":
passClassName = "building-shore";
break;
case "land-shore":
// 'default-terrain-only' is everywhere a normal unit can go, ignoring
// obstructions (i.e. on passable land, and not too deep in the water)
passClassName = "default-terrain-only";
break;
case "land-shore":
// 'default-terrain-only' is everywhere a normal unit can go, ignoring
// obstructions (i.e. on passable land, and not too deep in the water)
passClassName = "default-terrain-only";
break;
case "land":
default:
passClassName = "building-land";
case "land":
default:
passClassName = "building-land";
}
var cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction);
@ -141,30 +137,38 @@ BuildRestrictions.prototype.CheckPlacement = function()
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
var ret = cmpObstruction.CheckFoundation(passClassName, true);
var ret = areWoodEnts ? "success" : cmpObstruction.CheckFoundation(passClassName, true);
}
else
{
else {
var ret = cmpObstruction.CheckFoundation(passClassName, false);
}
if (ret != "success")
{
switch (ret)
{
case "fail_error":
case "fail_no_obstruction":
error("CheckPlacement: Error returned from CheckFoundation");
break;
case "fail_obstructs_foundation":
result.message = markForTranslation("%(name)s cannot be built on another building or resource");
break;
case "fail_terrain_class":
// TODO: be more specific and/or list valid terrain?
result.message = markForTranslation("%(name)s cannot be built on invalid terrain");
if (ret != "success") {
switch (ret) {
case "fail_error":
case "fail_no_obstruction":
error("CheckPlacement: Error returned from CheckFoundation");
break;
case "fail_obstructs_foundation":
result.message = markForTranslation("%(name)s cannot be built on another building or resource");
break;
case "fail_terrain_class":
// TODO: be more specific and/or list valid terrain?
result.message = markForTranslation("%(name)s cannot be built on invalid terrain");
}
return result; // Fail
}
@ -183,8 +187,7 @@ BuildRestrictions.prototype.CheckPlacement = function()
var isNeutral = tileOwner == 0;
var invalidTerritory = "";
if (isOwn)
{
if (isOwn) {
if (!this.HasTerritory("own"))
// Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.".
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.".
invalidTerritory = markForTranslationWithContext("Territory type", "unconnected own");
}
else if (isMutualAlly)
{
else if (isMutualAlly) {
if (!this.HasTerritory("ally"))
// Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.".
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.".
invalidTerritory = markForTranslationWithContext("Territory type", "unconnected allied");
}
else if (isNeutral)
{
else if (isNeutral) {
if (!this.HasTerritory("neutral"))
// Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.".
invalidTerritory = markForTranslationWithContext("Territory type", "neutral");
}
else
{
else {
// consider everything else enemy territory
if (!this.HasTerritory("enemy"))
// Translation: territoryType being displayed in a translated sentence in the form: "House cannot be built in %(territoryType)s territory.".
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.translateParameters.push("territoryType");
result.translateParameters.push("validTerritories");
@ -227,10 +226,8 @@ BuildRestrictions.prototype.CheckPlacement = function()
}
// Check special requirements
if (this.template.PlacementType == "shore")
{
if (!cmpObstruction.CheckShorePlacement())
{
if (this.template.PlacementType == "shore") {
if (!cmpObstruction.CheckShorePlacement()) {
result.message = markForTranslation("%(name)s must be built on a valid shoreline");
return result; // Fail
}
@ -242,22 +239,18 @@ BuildRestrictions.prototype.CheckPlacement = function()
let template = cmpTemplateManager.GetTemplate(removeFiltersFromTemplateName(templateName));
// Check distance restriction
if (this.template.Distance)
{
if (this.template.Distance) {
var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
var cat = this.template.Distance.FromClass;
var filter = function(id)
{
var filter = function (id) {
var cmpIdentity = Engine.QueryInterface(id, IID_Identity);
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);
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(
"%(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",
@ -274,11 +267,9 @@ BuildRestrictions.prototype.CheckPlacement = function()
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);
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(
"%(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",
@ -303,6 +294,11 @@ BuildRestrictions.prototype.CheckPlacement = function()
return result;
};
BuildRestrictions.prototype.IsWoodEntity = function (entity) {
let cmpResource = Engine.QueryInterface(entity, IID_ResourceSupply);
return cmpResource && cmpResource.GetType().generic === "wood";
}
BuildRestrictions.prototype.GetCategory = function()
{
return this.template.Category;

View File

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

View File

@ -4,7 +4,7 @@
<Rank>Elite</Rank>
</Identity>
<Promotion>
<RequiredXp>400</RequiredXp>
<RequiredXp>200</RequiredXp>
<Entity>units/athen/champion_infantry</Entity>
</Promotion>
<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
from pathlib import Path
MOD_PATH = Path("community-mod")
def check_cwd_is_correct():
try:
if not Path(".git").exists():
raise Exception("No .git in current folder.")
with open(MOD_PATH / "mod.json") as f:
mod = json.load(f)
if mod["name"] != "community-mod":
raise Exception("mod.json has incorrect name")
except Exception as e:
raise Exception("scripts.py must be called from the community mod repository") from e
cwd = Path().resolve()
try:
if not Path(".git").exists():
raise Exception("No .git in current folder.")
with open(MOD_PATH / "mod.json", "r") as f:
mod = json.load(f)
if mod['name'] != "community-mod":
raise Exception("mod.json has incorrect name")
except:
raise Exception("scripts.py must be called from the community mod repository")

View File

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

View File

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

View File

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

View File

@ -1,42 +1,36 @@
import json
import os
import requests
from . import MOD_PATH
import subprocess
import requests
from . import MOD_PATH
def run_git_command(command):
"""Run a git command and return its output."""
result = subprocess.run(command, capture_output=True, text=True, check=False)
""" Run a git command and return its output. """
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
raise Exception(f"Git command failed: {result.stderr}")
return result.stdout.strip()
def get_current_commit():
"""Get the current commit hash."""
return run_git_command(["git", "rev-parse", "HEAD"])
""" Get the current commit hash. """
return run_git_command(['git', 'rev-parse', 'HEAD'])
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:
return run_git_command(["git", "rev-list", "-n", "1", tag])
except Exception as e:
print(f"Failed to get commit for tag {tag}: {e}")
commit_hash = run_git_command(['git', 'rev-list', '-n', '1', tag])
return commit_hash
except Exception:
return None
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:
version_array = tag.split(".")
version_array = tag.split('.')
# 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
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
filtered_tags = [t for t in tags if t.startswith(major_minor_version)]
if tag in filtered_tags:
@ -45,30 +39,28 @@ def get_previous_tag_in_series(tag):
return filtered_tags[current_index + 1]
version_array[2] = str(int(version_array[2]) - 1)
manual_tag = ".".join(version_array)
if manual_tag not in filtered_tags:
return None
current_index = filtered_tags.index(manual_tag)
return filtered_tags[current_index]
manual_tag = '.'.join(version_array)
if manual_tag in filtered_tags:
current_index = filtered_tags.index(manual_tag)
return filtered_tags[current_index]
return None
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):
"""Get the changelog between the current commit and the previous tag."""
""" Get the changelog between the current commit and the previous tag. """
try:
if previous_tag is not None:
return run_git_command(
["git", "log", f"{previous_tag}..{current_commit}", "--oneline"]
)
# No previous tag, so get the log from the start of the repository
return run_git_command(["git", "log", current_commit, "--oneline"])
changelog = run_git_command(['git', 'log', f'{previous_tag}..{current_commit}', '--oneline'])
else:
# No previous tag, so get the log from the start of the repository
changelog = run_git_command(['git', 'log', current_commit, '--oneline'])
return changelog
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.
oauth2_token = os.getenv("MODIO_OAUTH2_TOKEN")
@ -80,11 +72,9 @@ mod_name = os.getenv("MOD_NAME")
community_mod_id = 2144305
zeroad_id = 5
mod_json = None
with open(MOD_PATH / "mod.json") as f:
mod_json = json.load(f)
mod_json = json.load(open(MOD_PATH / 'mod.json', 'r'))
tag = mod_json["version"]
tag = mod_json['version']
commit = get_commit_for_tag(tag)
if not commit:
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)
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(
"https://api.mod.io/v1/me/mods", timeout=5, params={"api_key": api_key}, headers=headers
)
r = requests.get(f'https://api.mod.io/v1/me/mods', params={
'api_key': api_key
}, headers = headers)
print(r.json())
with open(mod_file_path, "rb") as f:
files = {"filedata": f}
signature = ""
with open(f"{mod_name}-{mod_version}.zip.minisign", encoding="utf-8") as f:
signature = f.read()
files = {'filedata': open(mod_file_path, 'rb')}
rq = requests.post(
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]}
),
},
)
signature = open(f"{mod_name}-{mod_version}.zip.minisign", 'r', encoding="utf-8").read()
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 decimal import Decimal
from os.path import exists
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:
def __init__(self, vfs_root, logger):
@ -12,11 +12,11 @@ class SimulTemplateEntity:
def get_file(self, base_path, vfs_path, mod):
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):
file = (default_path / "mixins" / vfs_path).with_suffix(".xml")
file = (default_path / "mixins" / vfs_path).with_suffix('.xml')
if not exists(file):
file = (default_path / vfs_path).with_suffix(".xml")
file = (default_path / vfs_path).with_suffix('.xml')
return file
def get_main_mod(self, base_path, vfs_path, mods):
@ -33,110 +33,107 @@ class SimulTemplateEntity:
return main_mod
def apply_layer(self, base_tag, tag):
"""Apply tag layer to base_tag."""
if tag.get("datatype") == "tokens":
base_tokens = split(r"\s+", base_tag.text or "")
tokens = split(r"\s+", tag.text or "")
"""
apply tag layer to base_tag
"""
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()
for token in tokens:
if token.startswith("-"):
if token.startswith('-'):
token_to_remove = token[1:]
if token_to_remove in final_tokens:
final_tokens.remove(token_to_remove)
elif token not in final_tokens:
final_tokens.append(token)
base_tag.text = " ".join(final_tokens)
base_tag.text = ' '.join(final_tokens)
base_tag.set("datatype", "tokens")
elif tag.get("op"):
op = tag.get("op")
op1 = Decimal(base_tag.text or "0")
op2 = Decimal(tag.text or "0")
elif tag.get('op'):
op = tag.get('op')
op1 = Decimal(base_tag.text or '0')
op2 = Decimal(tag.text or '0')
# 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)
elif op == "mul":
elif op == 'mul':
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))
else:
raise ValueError(f"Invalid operator '{op}'")
else:
base_tag.text = tag.text
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))
for child in tag:
base_child = base_tag.find(child.tag)
if "disable" in child.attrib:
if 'disable' in child.attrib:
if base_child is not None:
base_tag.remove(base_child)
elif ("merge" not in child.attrib) or (base_child is not None):
if "replace" in child.attrib and 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:
base_tag.remove(base_child)
base_child = None
if base_child is None:
base_child = ET.Element(child.tag)
base_child = ElementTree.Element(child.tag)
base_tag.append(base_child)
self.apply_layer(base_child, child)
if "replace" in base_child.attrib:
del base_child.attrib["replace"]
if 'replace' in base_child.attrib:
del base_child.attrib['replace']
def load_inherited(self, base_path, vfs_path, mods):
entity = self._load_inherited(base_path, vfs_path, mods)
entity[:] = sorted(entity[:], key=lambda x: x.tag)
return entity
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:
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:
paths = vfs_path.split("|", 1)
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[1], 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)
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():
children = [x.tag for x in el]
duplicates = [x for x, c in Counter(children).items() if c > 1]
if duplicates:
for dup in duplicates:
self.logger.warning(
"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)
self.logger.warning(f"Duplicate child node '{dup}' in tag {el.tag} of {fp}")
if layer.get('parent'):
parent = self._load_inherited(base_path, layer.get('parent'), mods, base)
self.apply_layer(parent, layer)
return parent
if not base:
return layer
self.apply_layer(base, layer)
return base
else:
if not base:
return layer
else:
self.apply_layer(base, layer)
return base
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):
"""(relative Path, full Path) generator."""
"""(relative Path, full Path) generator"""
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():
yield from find_recursive(fp, base)
elif dp.suffix in full_exts:
relative_file_path = dp.relative_to(base)
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)]