From eea1b3692ecf7f046a9e67ed835da558699411e8 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 4 Nov 2026 15:54:31 +0300 Subject: [PATCH] Share aircraft and item catalog with asset generation Co-Authored-By: Codex --- .gitea/workflows/build.yml | 1 + airdef-mod/src/sim/catalog.rs | 33 +-- airdef-mod/src/sim/catalog_entries.rs | 34 +++ catalog/content.json | 394 ++++++++++++++++++++++++++ docs/REPO-MAP.md | 3 +- tools/airdef_assets/catalog.py | 25 ++ tools/airdef_assets/icons.py | 36 +-- tools/gen_catalog.py | 45 +++ tools/test_gen_assets.py | 12 + 9 files changed, 519 insertions(+), 64 deletions(-) create mode 100644 airdef-mod/src/sim/catalog_entries.rs create mode 100644 catalog/content.json create mode 100644 tools/airdef_assets/catalog.py create mode 100644 tools/gen_catalog.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 09976db..c74315c 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -35,6 +35,7 @@ jobs: - name: Проверка generated assets run: | python3 -m pip install --user -r tools/requirements.txt + python3 tools/gen_catalog.py --check python3 tools/gen_models.py --check python3 -m unittest discover -s tools -p test_model_bounds.py - name: Тесты ядра без SDK diff --git a/airdef-mod/src/sim/catalog.rs b/airdef-mod/src/sim/catalog.rs index 66b0dc4..61313ae 100644 --- a/airdef-mod/src/sim/catalog.rs +++ b/airdef-mod/src/sim/catalog.rs @@ -52,37 +52,8 @@ impl Unit { } use DroneKind::*; -/// The raid entries come first, in the order stored by existing scenario files. -pub const AIRCRAFT: &[Aircraft] = &[ - Aircraft { kind: Gerbera, launch_item: None, raid: true }, - Aircraft { kind: Shahed, launch_item: Some("airdef:launch_shahed"), raid: true }, - Aircraft { kind: Geran3, launch_item: Some("airdef:launch_geran3"), raid: true }, - Aircraft { kind: Lancet, launch_item: Some("airdef:launch_lancet"), raid: true }, - Aircraft { kind: Kh101, launch_item: Some("airdef:launch_kh101"), raid: true }, - Aircraft { kind: Kalibr, launch_item: Some("airdef:launch_kalibr"), raid: true }, - Aircraft { kind: Kh22, launch_item: Some("airdef:launch_kh22"), raid: true }, - Aircraft { kind: Zircon, launch_item: Some("airdef:launch_zircon"), raid: true }, - Aircraft { kind: Iskander, launch_item: Some("airdef:launch_iskander"), raid: true }, - Aircraft { kind: Kinzhal, launch_item: Some("airdef:launch_kinzhal"), raid: true }, - Aircraft { kind: Oreshnik, launch_item: Some("airdef:launch_oreshnik"), raid: true }, - Aircraft { kind: OreshnikBlock, launch_item: None, raid: false }, - Aircraft { kind: OreshnikSub, launch_item: None, raid: false }, -]; - -macro_rules! units { - ($( $kind:ident => $item:literal ),+ $(,)?) => { - pub const UNITS: &[Unit] = &[$(Unit { kind: SamKind::$kind, deploy_item: $item }),+]; - }; -} -units! { - Patriot => "airdef:deploy_patriot", Gepard => "airdef:deploy_gepard", - Pantsir => "airdef:deploy_pantsir", S400 => "airdef:deploy_s400", - S500 => "airdef:deploy_s500", IrisT => "airdef:deploy_irist", - Nasams => "airdef:deploy_nasams", Buk => "airdef:deploy_buk", - Tor => "airdef:deploy_tor", P1sun => "airdef:deploy_p1sun", - Radar => "airdef:deploy_radar", Depot => "airdef:deploy_depot", - Tzm => "airdef:deploy_tzm", Ew => "airdef:deploy_ew", Light => "airdef:deploy_light", -} +// Generated entries keep the historical raid and unit ordering. +include!("catalog_entries.rs"); /// Existing positional serialization makes the first entries' order part of the save format. const fn raid_count() -> usize { diff --git a/airdef-mod/src/sim/catalog_entries.rs b/airdef-mod/src/sim/catalog_entries.rs new file mode 100644 index 0000000..c7b3a7a --- /dev/null +++ b/airdef-mod/src/sim/catalog_entries.rs @@ -0,0 +1,34 @@ +// Generated by tools/gen_catalog.py from catalog/content.json. Do not edit. +pub const AIRCRAFT: &[Aircraft] = &[ + Aircraft { kind: Gerbera, launch_item: None, raid: true }, + Aircraft { kind: Shahed, launch_item: Some("airdef:launch_shahed"), raid: true }, + Aircraft { kind: Geran3, launch_item: Some("airdef:launch_geran3"), raid: true }, + Aircraft { kind: Lancet, launch_item: Some("airdef:launch_lancet"), raid: true }, + Aircraft { kind: Kh101, launch_item: Some("airdef:launch_kh101"), raid: true }, + Aircraft { kind: Kalibr, launch_item: Some("airdef:launch_kalibr"), raid: true }, + Aircraft { kind: Kh22, launch_item: Some("airdef:launch_kh22"), raid: true }, + Aircraft { kind: Zircon, launch_item: Some("airdef:launch_zircon"), raid: true }, + Aircraft { kind: Iskander, launch_item: Some("airdef:launch_iskander"), raid: true }, + Aircraft { kind: Kinzhal, launch_item: Some("airdef:launch_kinzhal"), raid: true }, + Aircraft { kind: Oreshnik, launch_item: Some("airdef:launch_oreshnik"), raid: true }, + Aircraft { kind: OreshnikBlock, launch_item: None, raid: false }, + Aircraft { kind: OreshnikSub, launch_item: None, raid: false }, +]; + +pub const UNITS: &[Unit] = &[ + Unit { kind: SamKind::Patriot, deploy_item: "airdef:deploy_patriot" }, + Unit { kind: SamKind::Gepard, deploy_item: "airdef:deploy_gepard" }, + Unit { kind: SamKind::Pantsir, deploy_item: "airdef:deploy_pantsir" }, + Unit { kind: SamKind::S400, deploy_item: "airdef:deploy_s400" }, + Unit { kind: SamKind::S500, deploy_item: "airdef:deploy_s500" }, + Unit { kind: SamKind::IrisT, deploy_item: "airdef:deploy_irist" }, + Unit { kind: SamKind::Nasams, deploy_item: "airdef:deploy_nasams" }, + Unit { kind: SamKind::Buk, deploy_item: "airdef:deploy_buk" }, + Unit { kind: SamKind::Tor, deploy_item: "airdef:deploy_tor" }, + Unit { kind: SamKind::P1sun, deploy_item: "airdef:deploy_p1sun" }, + Unit { kind: SamKind::Radar, deploy_item: "airdef:deploy_radar" }, + Unit { kind: SamKind::Depot, deploy_item: "airdef:deploy_depot" }, + Unit { kind: SamKind::Tzm, deploy_item: "airdef:deploy_tzm" }, + Unit { kind: SamKind::Ew, deploy_item: "airdef:deploy_ew" }, + Unit { kind: SamKind::Light, deploy_item: "airdef:deploy_light" }, +]; diff --git a/catalog/content.json b/catalog/content.json new file mode 100644 index 0000000..7c72aa8 --- /dev/null +++ b/catalog/content.json @@ -0,0 +1,394 @@ +{ + "aircraft": [ + { + "kind": "Gerbera", + "item": null, + "raid": true + }, + { + "kind": "Shahed", + "item": "launch_shahed", + "raid": true + }, + { + "kind": "Geran3", + "item": "launch_geran3", + "raid": true + }, + { + "kind": "Lancet", + "item": "launch_lancet", + "raid": true + }, + { + "kind": "Kh101", + "item": "launch_kh101", + "raid": true + }, + { + "kind": "Kalibr", + "item": "launch_kalibr", + "raid": true + }, + { + "kind": "Kh22", + "item": "launch_kh22", + "raid": true + }, + { + "kind": "Zircon", + "item": "launch_zircon", + "raid": true + }, + { + "kind": "Iskander", + "item": "launch_iskander", + "raid": true + }, + { + "kind": "Kinzhal", + "item": "launch_kinzhal", + "raid": true + }, + { + "kind": "Oreshnik", + "item": "launch_oreshnik", + "raid": true + }, + { + "kind": "OreshnikBlock", + "item": null, + "raid": false + }, + { + "kind": "OreshnikSub", + "item": null, + "raid": false + } + ], + "units": [ + { + "kind": "Patriot", + "item": "deploy_patriot" + }, + { + "kind": "Gepard", + "item": "deploy_gepard" + }, + { + "kind": "Pantsir", + "item": "deploy_pantsir" + }, + { + "kind": "S400", + "item": "deploy_s400" + }, + { + "kind": "S500", + "item": "deploy_s500" + }, + { + "kind": "IrisT", + "item": "deploy_irist" + }, + { + "kind": "Nasams", + "item": "deploy_nasams" + }, + { + "kind": "Buk", + "item": "deploy_buk" + }, + { + "kind": "Tor", + "item": "deploy_tor" + }, + { + "kind": "P1sun", + "item": "deploy_p1sun" + }, + { + "kind": "Radar", + "item": "deploy_radar" + }, + { + "kind": "Depot", + "item": "deploy_depot" + }, + { + "kind": "Tzm", + "item": "deploy_tzm" + }, + { + "kind": "Ew", + "item": "deploy_ew" + }, + { + "kind": "Light", + "item": "deploy_light" + } + ], + "items": [ + { + "id": "designator", + "title": "Целеуказатель", + "color": [ + 220, + 50, + 50 + ] + }, + { + "id": "launch_shahed", + "title": "Пуск: Шахед-136", + "color": [ + 90, + 92, + 96 + ] + }, + { + "id": "launch_geran3", + "title": "Пуск: Герань-3", + "color": [ + 30, + 30, + 34 + ] + }, + { + "id": "deploy_patriot", + "title": "Развернуть Patriot", + "color": [ + 76, + 86, + 58 + ] + }, + { + "id": "deploy_gepard", + "title": "Развернуть Гепард", + "color": [ + 104, + 84, + 54 + ] + }, + { + "id": "deploy_pantsir", + "title": "Развернуть Панцирь-С1", + "color": [ + 82, + 96, + 60 + ] + }, + { + "id": "deploy_s400", + "title": "Развернуть С-400", + "color": [ + 60, + 80, + 50 + ] + }, + { + "id": "deploy_s500", + "title": "Развернуть С-500", + "color": [ + 108, + 110, + 80 + ] + }, + { + "id": "launch_lancet", + "title": "Пуск: Ланцет-3", + "color": [ + 70, + 78, + 66 + ] + }, + { + "id": "launch_kh101", + "title": "Пуск: Х-101", + "color": [ + 84, + 88, + 92 + ] + }, + { + "id": "launch_kalibr", + "title": "Пуск: Калибр", + "color": [ + 150, + 154, + 156 + ] + }, + { + "id": "launch_kh22", + "title": "Пуск: Х-22", + "color": [ + 226, + 228, + 222 + ] + }, + { + "id": "launch_zircon", + "title": "Пуск: Циркон", + "color": [ + 52, + 54, + 52 + ] + }, + { + "id": "launch_iskander", + "title": "Пуск: Искандер-М", + "color": [ + 108, + 110, + 80 + ] + }, + { + "id": "launch_kinzhal", + "title": "Пуск: Кинжал", + "color": [ + 60, + 62, + 60 + ] + }, + { + "id": "launch_oreshnik", + "title": "Пуск: Орешник", + "color": [ + 230, + 120, + 40 + ] + }, + { + "id": "deploy_irist", + "title": "Развернуть IRIS-T SLM", + "color": [ + 76, + 86, + 58 + ] + }, + { + "id": "deploy_nasams", + "title": "Развернуть NASAMS", + "color": [ + 76, + 86, + 58 + ] + }, + { + "id": "deploy_buk", + "title": "Развернуть Бук-М2", + "color": [ + 82, + 96, + 60 + ] + }, + { + "id": "deploy_tor", + "title": "Развернуть Тор-М2", + "color": [ + 108, + 110, + 80 + ] + }, + { + "id": "deploy_p1sun", + "title": "Развернуть P1-SUN", + "color": [ + 52, + 54, + 52 + ] + }, + { + "id": "deploy_radar", + "title": "Развернуть РЛС дальнего обнаружения", + "color": [ + 108, + 110, + 80 + ] + }, + { + "id": "deploy_depot", + "title": "Развернуть склад боеприпасов", + "color": [ + 92, + 78, + 48 + ] + }, + { + "id": "deploy_tzm", + "title": "Развернуть ТЗМ", + "color": [ + 82, + 96, + 60 + ] + }, + { + "id": "deploy_ew", + "title": "Развернуть станцию РЭБ", + "color": [ + 108, + 110, + 80 + ] + }, + { + "id": "deploy_light", + "title": "Развернуть прожектор", + "color": [ + 52, + 54, + 52 + ] + }, + { + "id": "mover", + "title": "Пульт перемещения", + "color": [ + 60, + 140, + 220 + ] + }, + { + "id": "flare_gun", + "title": "Осветительная ракета", + "color": [ + 255, + 240, + 180 + ] + }, + { + "id": "remover", + "title": "Свернуть ПВО", + "color": [ + 200, + 200, + 200 + ] + } + ] +} diff --git a/docs/REPO-MAP.md b/docs/REPO-MAP.md index f9a0f42..762cd91 100644 --- a/docs/REPO-MAP.md +++ b/docs/REPO-MAP.md @@ -34,6 +34,7 @@ Команды: - `python3 -m pip install -r tools/requirements.txt` — зависимости генератора (закреплён Pillow). +- `python3 tools/gen_catalog.py` — обновить Rust-константы после изменений `catalog/content.json`; `--check` проверяет актуальность. - `python3 tools/gen_models.py` — перегенерировать. - `python3 tools/gen_models.py --check` — только сравнить, выход 1 при различиях. @@ -52,7 +53,7 @@ - `tools/airdef_assets/bounds.py` экспортирует габариты итоговых моделей для headless-навигации; authored overrides входят в расчёт. - `tools/gen_tex.py` отвечает за вывод процедурных текстур и иконок; переиспользуемые примитивы палитры также находятся здесь. - `tools/airdef_assets/texture_catalog.py` связывает стабильную палитру и процедурные функции с текстурами моделей, не смешивая каталог с геометрией. -- `tools/airdef_assets/icons.py` содержит каталог предметов и чистую функцию рендера иконки с явными параметрами `(name, color, seed)`. +- `catalog/content.json` задаёт идентичности техники и предметов и оформление предметов; `tools/airdef_assets/icons.py` читает его и содержит чистую функцию рендера иконки с явными параметрами `(name, color, seed)`. - `tools/airdef_assets/pipeline.py` проверяет manifest, рассчитывает безопасный план обновления и откатывает записи при исключении. - `airdef-mod/source-assets/assets/airdef/` — источник авторских переопределений. Генератор объединяет его с процедурными ресурсами во временном дереве, проверяет ссылки/JSON/PNG, затем устанавливает runtime-файлы. - `airdef-mod/assets/` и `airdef-mod/data/` — упакованные generated output. Не редактируйте их вручную; добавляйте авторские исходники в `source-assets`. diff --git a/tools/airdef_assets/catalog.py b/tools/airdef_assets/catalog.py new file mode 100644 index 0000000..208a02f --- /dev/null +++ b/tools/airdef_assets/catalog.py @@ -0,0 +1,25 @@ +"""Shared content identities and item presentation used by runtime and assets.""" +import json +from pathlib import Path + +PATH = Path(__file__).resolve().parents[2] / "catalog" / "content.json" + + +def load(): + data = json.loads(PATH.read_text(encoding="utf-8")) + items = data["items"] + ids = [item["id"] for item in items] + if len(ids) != len(set(ids)): + raise ValueError("duplicate item ID in content catalog") + referenced = [] + for group in (data["aircraft"], data["units"]): + kinds = [entry["kind"] for entry in group] + if len(kinds) != len(set(kinds)): + raise ValueError("duplicate kind in content catalog") + referenced.extend(entry["item"] for entry in group if entry["item"] is not None) + if len(referenced) != len(set(referenced)) or not set(referenced) <= set(ids): + raise ValueError("duplicate or unregistered content item") + for item in items: + if len(item["color"]) != 3 or any(not isinstance(c, int) or not 0 <= c <= 255 for c in item["color"]): + raise ValueError(f"invalid icon color for {item['id']}") + return data diff --git a/tools/airdef_assets/icons.py b/tools/airdef_assets/icons.py index e534ad2..0227914 100644 --- a/tools/airdef_assets/icons.py +++ b/tools/airdef_assets/icons.py @@ -6,38 +6,10 @@ from PIL import Image from .texture_catalog import clamp -# Предметы: (id, название, цвет иконки) -ITEMS = [ - ("designator", "Целеуказатель", (220, 50, 50)), - ("launch_shahed", "Пуск: Шахед-136", (90, 92, 96)), - ("launch_geran3", "Пуск: Герань-3", (30, 30, 34)), - ("deploy_patriot", "Развернуть Patriot", (76, 86, 58)), - ("deploy_gepard", "Развернуть Гепард", (104, 84, 54)), - ("deploy_pantsir", "Развернуть Панцирь-С1", (82, 96, 60)), - ("deploy_s400", "Развернуть С-400", (60, 80, 50)), - ("deploy_s500", "Развернуть С-500", (108, 110, 80)), - ("launch_lancet", "Пуск: Ланцет-3", (70, 78, 66)), - ("launch_kh101", "Пуск: Х-101", (84, 88, 92)), - ("launch_kalibr", "Пуск: Калибр", (150, 154, 156)), - ("launch_kh22", "Пуск: Х-22", (226, 228, 222)), - ("launch_zircon", "Пуск: Циркон", (52, 54, 52)), - ("launch_iskander", "Пуск: Искандер-М", (108, 110, 80)), - ("launch_kinzhal", "Пуск: Кинжал", (60, 62, 60)), - ("launch_oreshnik", "Пуск: Орешник", (230, 120, 40)), - ("deploy_irist", "Развернуть IRIS-T SLM", (76, 86, 58)), - ("deploy_nasams", "Развернуть NASAMS", (76, 86, 58)), - ("deploy_buk", "Развернуть Бук-М2", (82, 96, 60)), - ("deploy_tor", "Развернуть Тор-М2", (108, 110, 80)), - ("deploy_p1sun", "Развернуть P1-SUN", (52, 54, 52)), - ("deploy_radar", "Развернуть РЛС дальнего обнаружения", (108, 110, 80)), - ("deploy_depot", "Развернуть склад боеприпасов", (92, 78, 48)), - ("deploy_tzm", "Развернуть ТЗМ", (82, 96, 60)), - ("deploy_ew", "Развернуть станцию РЭБ", (108, 110, 80)), - ("deploy_light", "Развернуть прожектор", (52, 54, 52)), - ("mover", "Пульт перемещения", (60, 140, 220)), - ("flare_gun", "Осветительная ракета", (255, 240, 180)), - ("remover", "Свернуть ПВО", (200, 200, 200)), -] +# Presentation follows the shared content catalog; order keeps generated assets stable. +from .catalog import load as load_catalog + +ITEMS = [(item["id"], item["title"], tuple(item["color"])) for item in load_catalog()["items"]] # Иконки 16×16 без сглаживания. Каждый предмет рисуется своей функцией: категория задаёт основу diff --git a/tools/gen_catalog.py b/tools/gen_catalog.py new file mode 100644 index 0000000..d2bd779 --- /dev/null +++ b/tools/gen_catalog.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Generate Rust content entries from the shared catalog; --check detects drift.""" +import argparse +from pathlib import Path +import re + +from airdef_assets.catalog import load + +OUTPUT = Path(__file__).resolve().parents[1] / "airdef-mod/src/sim/catalog_entries.rs" + + +def generate(data): + lines = ["// Generated by tools/gen_catalog.py from catalog/content.json. Do not edit.", + "pub const AIRCRAFT: &[Aircraft] = &["] + for entry in data["aircraft"]: + kind, item = entry["kind"], entry["item"] + if not re.fullmatch(r"[A-Z][A-Za-z0-9]*", kind): + raise ValueError(f"invalid Rust kind {kind!r}") + if item is not None and not re.fullmatch(r"[a-z0-9_]+", item): + raise ValueError(f"invalid item ID {item!r}") + item_expr = f'Some("airdef:{item}")' if item is not None else "None" + lines.append(f' Aircraft {{ kind: {kind}, launch_item: {item_expr}, raid: {str(entry["raid"]).lower()} }},') + lines += ["];", "", "pub const UNITS: &[Unit] = &["] + for entry in data["units"]: + kind, item = entry["kind"], entry["item"] + if not re.fullmatch(r"[A-Z][A-Za-z0-9]*", kind) or not re.fullmatch(r"[a-z0-9_]+", item): + raise ValueError(f"invalid unit {entry!r}") + lines.append(f' Unit {{ kind: SamKind::{kind}, deploy_item: "airdef:{item}" }},') + return "\n".join(lines + ["];", ""]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + generated = generate(load()) + if args.check: + if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != generated: + parser.exit(1, f"{OUTPUT} is stale; run python3 tools/gen_catalog.py\n") + else: + OUTPUT.write_text(generated, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tools/test_gen_assets.py b/tools/test_gen_assets.py index 1072d05..990b4ae 100644 --- a/tools/test_gen_assets.py +++ b/tools/test_gen_assets.py @@ -2,6 +2,8 @@ import hashlib import importlib.util +import json +import subprocess import os import sys import tempfile @@ -22,6 +24,16 @@ gen_models = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(gen_models) +class ContentCatalogTests(unittest.TestCase): + def test_catalog_rust_entries_are_current_and_registered_items_match_assets(self): + root = TOOLS.parent + subprocess.run([sys.executable, str(TOOLS / "gen_catalog.py"), "--check"], check=True) + language = json.loads((root / "airdef-mod/assets/airdef/lang/ru_ru.json").read_text(encoding="utf-8")) + for name, title, _ in icons.ITEMS: + self.assertEqual(language[f"item.airdef.{name}"], title) + self.assertTrue((root / "airdef-mod/data/airdef/horizon/item" / (name + ".json")).is_file()) + + class ManifestTests(unittest.TestCase): def test_manifest_rejects_traversal_and_duplicate_paths(self): with tempfile.TemporaryDirectory() as tmp: