From a71b27e0b958079d991d42e6ac92ba545281d8bf Mon Sep 17 00:00:00 2001 From: Mechanica Date: Thu, 13 Aug 2026 10:58:06 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D1=8C=D0=BA=D1=83=D0=BB?= =?UTF-8?q?=D1=8F=D1=82=D0=BE=D1=80=20=D1=83=D1=80=D0=BE=D0=BD=D0=B0=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-mob-catalog.yml | 63 ++ .github/workflows/pr-checks.yml | 5 + data/mob-catalog.json | 935 ++++++++++++++++++++++++ scripts/build_mob_catalog.py | 163 +++++ scripts/equipment_catalog/core.py | 14 +- scripts/equipment_catalog/prototypes.py | 184 +---- scripts/prototype_resolution.py | 188 +++++ scripts/test_build_mob_catalog.py | 194 +++++ scripts/validate_mob_catalog.py | 102 +++ 9 files changed, 1662 insertions(+), 186 deletions(-) create mode 100644 .github/workflows/build-mob-catalog.yml create mode 100644 data/mob-catalog.json create mode 100644 scripts/build_mob_catalog.py create mode 100644 scripts/prototype_resolution.py create mode 100644 scripts/test_build_mob_catalog.py create mode 100644 scripts/validate_mob_catalog.py diff --git a/.github/workflows/build-mob-catalog.yml b/.github/workflows/build-mob-catalog.yml new file mode 100644 index 0000000..5008914 --- /dev/null +++ b/.github/workflows/build-mob-catalog.yml @@ -0,0 +1,63 @@ +name: Build mob catalog + +on: + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: build-mob-catalog + cancel-in-progress: false + +jobs: + build-catalog: + runs-on: ubuntu-latest + + steps: + - name: Download wiki data repository + uses: actions/checkout@v7.0.1 + + - name: Download mob sources + uses: actions/checkout@v7.0.1 + with: + repository: MetalSage/space-stories-cm14 + ref: master + path: game-source + sparse-checkout: | + Resources/Prototypes + Resources/Locale/ru-RU + sparse-checkout-cone-mode: false + + - name: Install catalog tools + run: | + python3 -m pip install -r requirements.txt + + - name: Build mob data + run: | + GAME_COMMIT=$(git -C game-source rev-parse HEAD) + + python3 scripts/build_mob_catalog.py \ + --game-source game-source \ + --output data/mob-catalog.json \ + --commit "$GAME_COMMIT" \ + --locale ru-RU + + - name: Validate mob catalog + run: | + python3 scripts/validate_mob_catalog.py \ + --catalog data/mob-catalog.json + + - name: Save mob data + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add data/mob-catalog.json + + if git diff --cached --quiet; then + echo "Nothing changed" + else + git commit -m "Build mob catalog" + git push + fi diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index afbfed1..33bc7d3 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -56,3 +56,8 @@ jobs: --index data/equipment-index.json \ --config config/equipment-sources.yml \ --sprites data/equipment-sprites + + - name: Validate mob catalog + run: | + python3 scripts/validate_mob_catalog.py \ + --catalog data/mob-catalog.json diff --git a/data/mob-catalog.json b/data/mob-catalog.json new file mode 100644 index 0000000..f00d6cb --- /dev/null +++ b/data/mob-catalog.json @@ -0,0 +1,935 @@ +{ + "schemaVersion": 1, + "source": "MetalSage/space-stories-cm14", + "gameCommit": "02e284c922077d01b184349e4d4bdc0b9584e026", + "generatedAt": "2026-08-13T05:57:38.114860Z", + "locale": "ru-RU", + "marine": { + "sourcePrototypeId": "RMCBaseMobSpeciesOrganic", + "thresholds": { + "critical": 150, + "dead": 200 + } + }, + "xenoCastes": { + "CMXenoBurrower": { + "id": "CMXenoBurrower", + "name": "Бурильщик", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/burrower.yml", + "parents": [ + "CMXenoDeveloped", + "CMXenoTail", + "CMXenoFlammable", + "RMCXenoSpeechSounds", + "RMCXenoCommendationReceiver", + "RMCXenoMobCollision", + "RMCXenoVentCrawler" + ], + "thresholds": { + "critical": 500, + "dead": 600 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 25, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 40, + "immuneToArmorPiercing": false + } + }, + "CMXenoCarrier": { + "id": "CMXenoCarrier", + "name": "Носитель", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/carrier.yml", + "parents": [ + "RMCXenoCarrierBase" + ], + "thresholds": { + "critical": 650, + "dead": 750 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 20, + "immuneToArmorPiercing": false + } + }, + "CMXenoDefender": { + "id": "CMXenoDefender", + "name": "Защитник", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/defender.yml", + "parents": [ + "CMXenoDefenderBase" + ], + "thresholds": { + "critical": 500, + "dead": 600 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 35, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 70, + "immuneToArmorPiercing": false + } + }, + "CMXenoDefenderSteelcrest": { + "id": "CMXenoDefenderSteelcrest", + "name": "Защитник", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/defender.yml", + "parents": [ + "CMXenoDefenderBase" + ], + "thresholds": { + "critical": 500, + "dead": 600 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 35, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 70, + "immuneToArmorPiercing": false + } + }, + "CMXenoDrone": { + "id": "CMXenoDrone", + "name": "Дрон", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/drone.yml", + "parents": [ + "CMXenoDroneBase" + ], + "thresholds": { + "critical": 500, + "dead": 600 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 0, + "immuneToArmorPiercing": false + } + }, + "CMXenoDroneGardener": { + "id": "CMXenoDroneGardener", + "name": "Дрон", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/drone.yml", + "parents": [ + "CMXenoDroneBase" + ], + "thresholds": { + "critical": 500, + "dead": 600 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 0, + "immuneToArmorPiercing": false + } + }, + "CMXenoDroneHealer": { + "id": "CMXenoDroneHealer", + "name": "Дрон", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/drone.yml", + "parents": [ + "CMXenoDroneBase" + ], + "thresholds": { + "critical": 500, + "dead": 600 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 0, + "immuneToArmorPiercing": false + } + }, + "CMXenoHivelord": { + "id": "CMXenoHivelord", + "name": "Повелитель улья", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/hivelord.yml", + "parents": [ + "CMXenoHivelordBase" + ], + "thresholds": { + "critical": 550, + "dead": 650 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 10, + "immuneToArmorPiercing": false + } + }, + "CMXenoHivelordDesigner": { + "id": "CMXenoHivelordDesigner", + "name": "Повелитель улья", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/hivelord.yml", + "parents": [ + "CMXenoHivelordBase" + ], + "thresholds": { + "critical": 550, + "dead": 650 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 10, + "immuneToArmorPiercing": false + } + }, + "CMXenoHivelordResinWhisperer": { + "id": "CMXenoHivelordResinWhisperer", + "name": "Повелитель улья", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/hivelord.yml", + "parents": [ + "CMXenoHivelordBase" + ], + "thresholds": { + "critical": 550, + "dead": 650 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 10, + "immuneToArmorPiercing": false + } + }, + "CMXenoLarva": { + "id": "CMXenoLarva", + "name": "Личинка", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/larva.yml", + "parents": [ + "CMXenoUndeveloped", + "CMXenoFlammable", + "RMCXenoVentCrawler" + ], + "thresholds": { + "critical": 35, + "dead": 70 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 0, + "immuneToArmorPiercing": false + } + }, + "CMXenoLesserDrone": { + "id": "CMXenoLesserDrone", + "name": "Малый дрон", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/lesser_drone.yml", + "parents": [ + "CMXenoDeveloped", + "CMXenoTail", + "CMXenoFlammable", + "RMCXenoSpeechSounds", + "RMCXenoVentCrawler" + ], + "thresholds": { + "critical": null, + "dead": 160 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 0, + "immuneToArmorPiercing": false + } + }, + "CMXenoLurker": { + "id": "CMXenoLurker", + "name": "Охотник", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/lurker.yml", + "parents": [ + "CMXenoTail", + "RMCXenoLurkerBase" + ], + "thresholds": { + "critical": 450, + "dead": 550 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 20, + "immuneToArmorPiercing": false + } + }, + "CMXenoParasite": { + "id": "CMXenoParasite", + "name": "Лицехват", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/parasite.yml", + "parents": [ + "CMXenoParasiteBase" + ], + "thresholds": { + "critical": null, + "dead": 35 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 0, + "immuneToArmorPiercing": false + } + }, + "CMXenoPraetorian": { + "id": "CMXenoPraetorian", + "name": "Преторианец", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/praetorian.yml", + "parents": [ + "CMXenoPraetorianBase" + ], + "thresholds": { + "critical": 650, + "dead": 750 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 25, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 40, + "immuneToArmorPiercing": false + } + }, + "CMXenoQueen": { + "id": "CMXenoQueen", + "name": "Королева", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/queen.yml", + "parents": [ + "RMCXenoQueenBase" + ], + "thresholds": { + "critical": 500, + "dead": 600 + }, + "maturedThresholds": { + "critical": 1000, + "dead": 1100 + }, + "armor": { + "xenoArmor": 25, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 100, + "immuneToArmorPiercing": false + } + }, + "CMXenoRavager": { + "id": "CMXenoRavager", + "name": "Разрушитель", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/ravager.yml", + "parents": [ + "RMCXenoRavagerBase" + ], + "thresholds": { + "critical": 650, + "dead": 750 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 25, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 80, + "immuneToArmorPiercing": false + } + }, + "CMXenoRunner": { + "id": "CMXenoRunner", + "name": "Бегун", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/runner.yml", + "parents": [ + "CMXenoRunnerBase" + ], + "thresholds": { + "critical": 230, + "dead": 330 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 10, + "immuneToArmorPiercing": false + } + }, + "CMXenoSentinel": { + "id": "CMXenoSentinel", + "name": "Страж", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/sentinel.yml", + "parents": [ + "CMXenoDeveloped", + "CMXenoTail", + "CMXenoFlammable", + "RMCXenoSpeechSounds", + "RMCXenoCommendationReceiver", + "RMCXenoMobCollision", + "RMCXenoVentCrawler" + ], + "thresholds": { + "critical": 500, + "dead": 600 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 10, + "immuneToArmorPiercing": false + } + }, + "CMXenoSpitter": { + "id": "CMXenoSpitter", + "name": "Плеватель", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/spitter.yml", + "parents": [ + "CMXenoDeveloped", + "CMXenoTail", + "CMXenoFlammable", + "RMCXenoSpeechSounds", + "RMCXenoCommendationReceiver", + "RMCXenoMobCollision", + "RMCXenoVentCrawler" + ], + "thresholds": { + "critical": 550, + "dead": 650 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 15, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 20, + "immuneToArmorPiercing": false + } + }, + "CMXenoWarrior": { + "id": "CMXenoWarrior", + "name": "Воин", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/warrior.yml", + "parents": [ + "CMXenoDeveloped", + "CMXenoTail", + "CMXenoFlammable", + "RMCXenoSpeechSounds", + "RMCXenoCommendationReceiver", + "RMCXenoMobCollision" + ], + "thresholds": { + "critical": 500, + "dead": 600 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 20, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 40, + "immuneToArmorPiercing": false + } + }, + "RMCXenoBoiler": { + "id": "RMCXenoBoiler", + "name": "Бойлер", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/boiler.yml", + "parents": [ + "CMXenoBoiler" + ], + "thresholds": { + "critical": 650, + "dead": 750 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 20, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 20, + "immuneToArmorPiercing": false + } + }, + "RMCXenoBoilerSapper": { + "id": "RMCXenoBoilerSapper", + "name": "Бойлер", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/boiler.yml", + "parents": [ + "CMXenoBoiler" + ], + "thresholds": { + "critical": 590, + "dead": 690 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 20, + "immuneToArmorPiercing": false + } + }, + "RMCXenoCarrierEggsac": { + "id": "RMCXenoCarrierEggsac", + "name": "Носитель", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/carrier.yml", + "parents": [ + "RMCXenoCarrierBase" + ], + "thresholds": { + "critical": 650, + "dead": 750 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 20, + "immuneToArmorPiercing": false + } + }, + "RMCXenoCrusher": { + "id": "RMCXenoCrusher", + "name": "Крушитель", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/crusher.yml", + "parents": [ + "CMXenoCrusherBase" + ], + "thresholds": { + "critical": 700, + "dead": 800 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 30, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 100, + "immuneToArmorPiercing": false + } + }, + "RMCXenoCrusherCharger": { + "id": "RMCXenoCrusherCharger", + "name": "Крушитель", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/crusher.yml", + "parents": [ + "CMXenoCrusherBase" + ], + "thresholds": { + "critical": 780, + "dead": 880 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 20, + "frontalArmor": 30, + "sideArmor": 15, + "explosionArmor": 100, + "immuneToArmorPiercing": false + } + }, + "RMCXenoKing": { + "id": "RMCXenoKing", + "name": "Король", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/king.yml", + "parents": [ + "CMXenoDeveloped", + "CMXenoTail", + "CMXenoFlammable", + "RMCXenoSpeechSounds", + "RMCXenoMobCollision" + ], + "thresholds": { + "critical": 1500, + "dead": 1600 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 45, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 70, + "immuneToArmorPiercing": false + } + }, + "RMCXenoLesserCarrier": { + "id": "RMCXenoLesserCarrier", + "name": "Малый Носитель", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/fun.yml", + "parents": [ + "CMXenoDeveloped", + "CMXenoTail", + "CMXenoFlammable", + "RMCXenoSpeechSounds" + ], + "thresholds": { + "critical": null, + "dead": 200 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 10, + "immuneToArmorPiercing": false + } + }, + "RMCXenoLurkerVampire": { + "id": "RMCXenoLurkerVampire", + "name": "Охотник", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/lurker.yml", + "parents": [ + "RMCXenoLurkerBase" + ], + "thresholds": { + "critical": 390, + "dead": 490 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 20, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 20, + "immuneToArmorPiercing": false + } + }, + "RMCXenoParasiteWatcher": { + "id": "RMCXenoParasiteWatcher", + "name": "Лицехват", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/parasite.yml", + "parents": [ + "CMXenoParasiteBase" + ], + "thresholds": { + "critical": null, + "dead": 35 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 0, + "immuneToArmorPiercing": false + } + }, + "RMCXenoPraetorianDancer": { + "id": "RMCXenoPraetorianDancer", + "name": "Преторианец", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/praetorian.yml", + "parents": [ + "CMXenoPraetorianBase" + ], + "thresholds": { + "critical": 650, + "dead": 750 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 20, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 40, + "immuneToArmorPiercing": false + } + }, + "RMCXenoPraetorianOppressor": { + "id": "RMCXenoPraetorianOppressor", + "name": "Преторианец", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/praetorian.yml", + "parents": [ + "CMXenoPraetorianBase" + ], + "thresholds": { + "critical": 650, + "dead": 750 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 25, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 60, + "immuneToArmorPiercing": false + } + }, + "RMCXenoPraetorianValkyrie": { + "id": "RMCXenoPraetorianValkyrie", + "name": "Преторианец", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/praetorian.yml", + "parents": [ + "CMXenoPraetorianBase" + ], + "thresholds": { + "critical": 650, + "dead": 750 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 35, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 40, + "immuneToArmorPiercing": false + } + }, + "RMCXenoPraetorianVanguard": { + "id": "RMCXenoPraetorianVanguard", + "name": "Преторианец", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/praetorian.yml", + "parents": [ + "CMXenoPraetorianBase" + ], + "thresholds": { + "critical": 590, + "dead": 690 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 25, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 40, + "immuneToArmorPiercing": false + } + }, + "RMCXenoRavagerBerserker": { + "id": "RMCXenoRavagerBerserker", + "name": "Разрушитель", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/ravager.yml", + "parents": [ + "RMCXenoRavagerBase" + ], + "thresholds": { + "critical": 590, + "dead": 690 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 30, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 80, + "immuneToArmorPiercing": false + } + }, + "RMCXenoRavagerHedgehog": { + "id": "RMCXenoRavagerHedgehog", + "name": "Разрушитель", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/ravager.yml", + "parents": [ + "RMCXenoRavagerBase" + ], + "thresholds": { + "critical": 650, + "dead": 750 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 25, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 100, + "immuneToArmorPiercing": false + } + }, + "RMCXenoRunnerAcider": { + "id": "RMCXenoRunnerAcider", + "name": "Бегун", + "origin": "rmc14", + "sourceFile": "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/runner.yml", + "parents": [ + "CMXenoRunnerBase" + ], + "thresholds": { + "critical": 345, + "dead": 445 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 15, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 10, + "immuneToArmorPiercing": false + } + }, + "STXenoCrusher": { + "id": "STXenoCrusher", + "name": "Крушитель", + "origin": "stories", + "sourceFile": "Resources/Prototypes/_Stories/Entities/Mobs/Xeno/crusher.yml", + "parents": [ + "STXenoCrusherBase" + ], + "thresholds": { + "critical": 700, + "dead": 800 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 30, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 100, + "immuneToArmorPiercing": false + } + }, + "STXenoDespoiler": { + "id": "STXenoDespoiler", + "name": "Дисбойлер", + "origin": "stories", + "sourceFile": "Resources/Prototypes/_Stories/Entities/Mobs/Xeno/despoiler.yml", + "parents": [ + "CMXenoDeveloped", + "CMXenoTail", + "CMXenoFlammable", + "RMCXenoSpeechSounds", + "RMCXenoCommendationReceiver", + "RMCXenoMobCollision" + ], + "thresholds": { + "critical": 650, + "dead": 750 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 25, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 20, + "immuneToArmorPiercing": false + } + }, + "STXenoWarriorBoxer": { + "id": "STXenoWarriorBoxer", + "name": "Воин", + "origin": "stories", + "sourceFile": "Resources/Prototypes/_Stories/Entities/Mobs/Xeno/warrior.yml", + "parents": [ + "CMXenoDeveloped", + "CMXenoTail", + "CMXenoFlammable", + "RMCXenoSpeechSounds", + "RMCXenoCommendationReceiver", + "RMCXenoMobCollision" + ], + "thresholds": { + "critical": 500, + "dead": 600 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 25, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 45, + "immuneToArmorPiercing": false + } + }, + "STXenoWarriorBulwark": { + "id": "STXenoWarriorBulwark", + "name": "Воин", + "origin": "stories", + "sourceFile": "Resources/Prototypes/_Stories/Entities/Mobs/Xeno/warrior.yml", + "parents": [ + "CMXenoDeveloped", + "CMXenoTail", + "CMXenoFlammable", + "RMCXenoSpeechSounds", + "RMCXenoCommendationReceiver", + "RMCXenoMobCollision" + ], + "thresholds": { + "critical": 600, + "dead": 700 + }, + "maturedThresholds": null, + "armor": { + "xenoArmor": 30, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 50, + "immuneToArmorPiercing": false + } + } + }, + "counts": { + "xenoCastes": 41 + } +} diff --git a/scripts/build_mob_catalog.py b/scripts/build_mob_catalog.py new file mode 100644 index 0000000..939b862 --- /dev/null +++ b/scripts/build_mob_catalog.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from equipment_catalog.localization import Localizer, read_fluent_messages +from prototype_resolution import PrototypeResolver, read_entity_prototypes + +MARINE_BASE_PROTOTYPE_ID = "RMCBaseMobSpeciesOrganic" + +XENO_SOURCE_PREFIXES = ( + "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/", + "Resources/Prototypes/_Stories/Entities/Mobs/Xeno/", +) + +KNOWN_THRESHOLD_STATES = {"Alive", "Critical", "Dead"} + +# Cosmetic/event reskins and admin-only utility entities that share a real +# caste's stat block but aren't part of the normal hive roster on this server. +EXCLUDED_XENO_CASTE_IDS = { + "RMCXenoQueenMagical", + "RMCXenoQueenMaid", + "RMCXenoParasitePrimeHiveAssign", + "RMCXenoRouny", + "RMCXenoWehny", +} + + +def capitalize_first(value: str) -> str: + for index, character in enumerate(value): + if character.isdigit(): + return value + if character.isalpha(): + return value[:index] + character.upper() + value[index + 1 :] + return value + + +def invert_thresholds(thresholds: dict[Any, Any]) -> dict[str, int | None]: + by_state: dict[str, int] = {} + for raw_amount, state in thresholds.items(): + if state not in KNOWN_THRESHOLD_STATES: + raise RuntimeError(f"Unknown MobThresholds state: {state}") + if state in by_state: + raise RuntimeError(f"Duplicate MobThresholds state: {state}") + by_state[state] = int(raw_amount) + + if "Dead" not in by_state: + raise RuntimeError("MobThresholds is missing the required Dead state") + return {"critical": by_state.get("Critical"), "dead": by_state["Dead"]} + + +def is_xeno_mob_source_file(source_file: str) -> bool: + return source_file.startswith(XENO_SOURCE_PREFIXES) + + +def matured_thresholds(component: dict[str, Any] | None) -> dict[str, int] | None: + """XenoMaturingSystem overwrites MobThresholds once a spawn-time timer elapses + (e.g. the queen starts at 500/600 and permanently jumps to 1000/1100).""" + if component is None: + return None + critical = component.get("critThreshold") + dead = component.get("deadThreshold") + if not isinstance(critical, (int, float)) or not isinstance(dead, (int, float)): + raise RuntimeError("XenoMaturing is missing critThreshold/deadThreshold") + return {"critical": int(critical), "dead": int(dead)} + + +def armor_from_component(component: dict[str, Any]) -> dict[str, Any]: + return { + "xenoArmor": component.get("xenoArmor", 0), + "frontalArmor": component.get("frontalArmor", 0), + "sideArmor": component.get("sideArmor", 0), + "explosionArmor": component.get("explosionArmor", 0), + "immuneToArmorPiercing": bool(component.get("immuneToAP", False)), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build the SSMC mob catalog from live game sources") + parser.add_argument("--game-source", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--locale", default="ru-RU") + parser.add_argument("--commit", default="unknown") + args = parser.parse_args() + + prototypes = read_entity_prototypes(args.game_source) + resolver = PrototypeResolver(prototypes) + locale_root = args.game_source / "Resources/Locale" / args.locale + localizer = Localizer(read_fluent_messages(locale_root)) + + marine_resolved = resolver.resolve(MARINE_BASE_PROTOTYPE_ID) + marine_thresholds = marine_resolved["components"].get("MobThresholds") + if marine_thresholds is None: + raise RuntimeError( + f"{MARINE_BASE_PROTOTYPE_ID} has no MobThresholds component" + ) + marine = { + "sourcePrototypeId": MARINE_BASE_PROTOTYPE_ID, + "thresholds": invert_thresholds(marine_thresholds.get("thresholds", {})), + } + + xeno_castes: dict[str, Any] = {} + for prototype in prototypes.values(): + if prototype.abstract: + continue + if prototype.id in EXCLUDED_XENO_CASTE_IDS: + continue + if not is_xeno_mob_source_file(prototype.source_file): + continue + + resolved = resolver.resolve(prototype.id) + components = resolved["components"] + thresholds_component = components.get("MobThresholds") + armor_component = components.get("CMArmor") + if thresholds_component is None or armor_component is None: + continue + + name = capitalize_first( + localizer.entity_text(prototype.id, None, resolved["fields"].get("name")) + ) + + xeno_castes[prototype.id] = { + "id": prototype.id, + "name": name, + "origin": prototype.origin, + "sourceFile": prototype.source_file, + "parents": list(prototype.parents), + "thresholds": invert_thresholds( + thresholds_component.get("thresholds", {}) + ), + "maturedThresholds": matured_thresholds(components.get("XenoMaturing")), + "armor": armor_from_component(armor_component), + } + + if not xeno_castes: + raise RuntimeError("No xeno castes were discovered") + + result = { + "schemaVersion": 1, + "source": "MetalSage/space-stories-cm14", + "gameCommit": args.commit, + "generatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "locale": args.locale, + "marine": marine, + "xenoCastes": dict(sorted(xeno_castes.items())), + "counts": {"xenoCastes": len(xeno_castes)}, + } + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(result, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + print(f'Marine thresholds: {marine["thresholds"]}') + print(f'Xeno castes: {len(xeno_castes)}') + + +if __name__ == "__main__": + main() diff --git a/scripts/equipment_catalog/core.py b/scripts/equipment_catalog/core.py index bce5a23..6f9381e 100644 --- a/scripts/equipment_catalog/core.py +++ b/scripts/equipment_catalog/core.py @@ -1,7 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any +from prototype_resolution import EntityPrototype as EntityPrototype PHYSICAL_CONTENT_RELATION_TYPES = { @@ -181,14 +180,3 @@ "Tag", "Transform", } - - -@dataclass(frozen=True) -class EntityPrototype: - id: str - parents: tuple[str, ...] - abstract: bool - source_file: str - origin: str - fields: dict[str, Any] - components: tuple[dict[str, Any], ...] diff --git a/scripts/equipment_catalog/prototypes.py b/scripts/equipment_catalog/prototypes.py index bf16f0f..c0bd8f3 100644 --- a/scripts/equipment_catalog/prototypes.py +++ b/scripts/equipment_catalog/prototypes.py @@ -1,113 +1,18 @@ from __future__ import annotations -import copy from pathlib import Path -from typing import Any, Iterable +from typing import Any -import yaml - -from .core import EntityPrototype - - -class GameYamlLoader(yaml.SafeLoader): - """YAML loader that preserves SS14 custom tags as plain JSON values.""" - - -def construct_tagged_value( - loader: GameYamlLoader, - tag_suffix: str, - node: yaml.Node, -) -> Any: - if isinstance(node, yaml.MappingNode): - value = loader.construct_mapping(node, deep=True) - elif isinstance(node, yaml.SequenceNode): - value = loader.construct_sequence(node, deep=True) - else: - value = loader.construct_scalar(node) - return {"yamlTag": f"!{tag_suffix}", "value": value} - - -GameYamlLoader.add_multi_constructor("!", construct_tagged_value) - - -def normalize_parents(value: Any) -> tuple[str, ...]: - if isinstance(value, str): - return (value,) - if isinstance(value, list): - return tuple(item for item in value if isinstance(item, str)) - return () - - -def origin_from_path(path: str) -> str: - if "/_Stories/" in path: - return "stories" - if "/_RMC14/" in path: - return "rmc14" - return "upstream" - - -def iter_prototype_documents(path: Path) -> Iterable[dict[str, Any]]: - with path.open("r", encoding="utf-8-sig") as stream: - for document in yaml.load_all(stream, Loader=GameYamlLoader): - if document is None: - continue - values = document if isinstance(document, list) else [document] - for value in values: - if isinstance(value, dict): - yield value - - -def read_entity_prototypes(game_source: Path) -> dict[str, EntityPrototype]: - root = game_source / "Resources/Prototypes" - if not root.is_dir(): - raise FileNotFoundError(f"Missing prototype directory: {root}") - - result: dict[str, EntityPrototype] = {} - files = sorted([*root.rglob("*.yml"), *root.rglob("*.yaml")]) - - for path in files: - source_file = path.relative_to(game_source).as_posix() - for raw in iter_prototype_documents(path): - if raw.get("type") != "entity": - continue - prototype_id = raw.get("id") - if not isinstance(prototype_id, str) or not prototype_id: - continue - if prototype_id in result: - previous = result[prototype_id].source_file - raise RuntimeError( - f"Duplicate entity prototype {prototype_id}: " - f"{previous}, {source_file}" - ) - - raw_components = raw.get("components", []) - components: list[dict[str, Any]] = [] - if isinstance(raw_components, list): - components = [ - copy.deepcopy(component) - for component in raw_components - if isinstance(component, dict) - and isinstance(component.get("type"), str) - ] - - fields = { - key: copy.deepcopy(value) - for key, value in raw.items() - if key not in {"type", "id", "parent", "abstract", "components"} - } - result[prototype_id] = EntityPrototype( - id=prototype_id, - parents=normalize_parents(raw.get("parent")), - abstract=bool(raw.get("abstract", False)), - source_file=source_file, - origin=origin_from_path(source_file), - fields=fields, - components=tuple(components), - ) - - if not result: - raise RuntimeError("No entity prototypes found") - return result +from prototype_resolution import ( + EntityPrototype as EntityPrototype, + GameYamlLoader as GameYamlLoader, + PrototypeResolver as PrototypeResolver, + construct_tagged_value as construct_tagged_value, + iter_prototype_documents as iter_prototype_documents, + normalize_parents as normalize_parents, + origin_from_path as origin_from_path, + read_entity_prototypes as read_entity_prototypes, +) def parse_box2i(value: Any) -> tuple[int, int, int, int] | None: @@ -163,70 +68,3 @@ def read_reagent_colors(game_source: Path) -> dict[str, str]: result[reagent_id] = color return result - -class PrototypeResolver: - """Resolve entity inheritance with component-level SS14 semantics.""" - - def __init__(self, prototypes: dict[str, EntityPrototype]): - self.prototypes = prototypes - self.cache: dict[str, dict[str, Any]] = {} - self.active: list[str] = [] - - def resolve(self, prototype_id: str) -> dict[str, Any]: - cached = self.cache.get(prototype_id) - if cached is not None: - return cached - - prototype = self.prototypes.get(prototype_id) - if prototype is None: - raise RuntimeError(f"Unknown entity prototype: {prototype_id}") - if prototype_id in self.active: - start = self.active.index(prototype_id) - cycle = self.active[start:] + [prototype_id] - raise RuntimeError("Entity inheritance cycle: " + " -> ".join(cycle)) - - self.active.append(prototype_id) - fields: dict[str, Any] = {} - components: dict[str, dict[str, Any]] = {} - - for parent_id in prototype.parents: - if parent_id not in self.prototypes: - raise RuntimeError( - f"Unknown parent {parent_id} used by {prototype_id}" - ) - parent = self.resolve(parent_id) - fields.update(copy.deepcopy(parent["fields"])) - for component_type, component in parent["components"].items(): - merged_parent = copy.deepcopy(components.get(component_type, {})) - merged_parent.update(copy.deepcopy(component)) - components[component_type] = merged_parent - - # Prototype fields and individual component fields replace the matching - # inherited field as a whole. Nested maps are intentionally not merged: - # Empty weapon variants rely on replacing ItemSlots.slots so an inherited - # startingItem does not survive. - fields.update(copy.deepcopy(prototype.fields)) - for raw_component in prototype.components: - component_type = raw_component["type"] - merged = copy.deepcopy(components.get(component_type, {})) - merged.update( - { - key: copy.deepcopy(value) - for key, value in raw_component.items() - if key != "type" - } - ) - components[component_type] = merged - - self.active.pop() - resolved = { - "id": prototype_id, - "parents": list(prototype.parents), - "abstract": prototype.abstract, - "sourceFile": prototype.source_file, - "origin": prototype.origin, - "fields": fields, - "components": components, - } - self.cache[prototype_id] = resolved - return resolved diff --git a/scripts/prototype_resolution.py b/scripts/prototype_resolution.py new file mode 100644 index 0000000..a674e61 --- /dev/null +++ b/scripts/prototype_resolution.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + +import yaml + + +class GameYamlLoader(yaml.SafeLoader): + """YAML loader that preserves SS14 custom tags as plain JSON values.""" + + +def construct_tagged_value( + loader: GameYamlLoader, + tag_suffix: str, + node: yaml.Node, +) -> Any: + if isinstance(node, yaml.MappingNode): + value = loader.construct_mapping(node, deep=True) + elif isinstance(node, yaml.SequenceNode): + value = loader.construct_sequence(node, deep=True) + else: + value = loader.construct_scalar(node) + return {"yamlTag": f"!{tag_suffix}", "value": value} + + +GameYamlLoader.add_multi_constructor("!", construct_tagged_value) + + +@dataclass(frozen=True) +class EntityPrototype: + id: str + parents: tuple[str, ...] + abstract: bool + source_file: str + origin: str + fields: dict[str, Any] + components: tuple[dict[str, Any], ...] + + +def normalize_parents(value: Any) -> tuple[str, ...]: + if isinstance(value, str): + return (value,) + if isinstance(value, list): + return tuple(item for item in value if isinstance(item, str)) + return () + + +def origin_from_path(path: str) -> str: + if "/_Stories/" in path: + return "stories" + if "/_RMC14/" in path: + return "rmc14" + return "upstream" + + +def iter_prototype_documents(path: Path) -> Iterable[dict[str, Any]]: + with path.open("r", encoding="utf-8-sig") as stream: + for document in yaml.load_all(stream, Loader=GameYamlLoader): + if document is None: + continue + values = document if isinstance(document, list) else [document] + for value in values: + if isinstance(value, dict): + yield value + + +def read_entity_prototypes(game_source: Path) -> dict[str, EntityPrototype]: + root = game_source / "Resources/Prototypes" + if not root.is_dir(): + raise FileNotFoundError(f"Missing prototype directory: {root}") + + result: dict[str, EntityPrototype] = {} + files = sorted([*root.rglob("*.yml"), *root.rglob("*.yaml")]) + + for path in files: + source_file = path.relative_to(game_source).as_posix() + for raw in iter_prototype_documents(path): + if raw.get("type") != "entity": + continue + prototype_id = raw.get("id") + if not isinstance(prototype_id, str) or not prototype_id: + continue + if prototype_id in result: + previous = result[prototype_id].source_file + raise RuntimeError( + f"Duplicate entity prototype {prototype_id}: " + f"{previous}, {source_file}" + ) + + raw_components = raw.get("components", []) + components: list[dict[str, Any]] = [] + if isinstance(raw_components, list): + components = [ + copy.deepcopy(component) + for component in raw_components + if isinstance(component, dict) + and isinstance(component.get("type"), str) + ] + + fields = { + key: copy.deepcopy(value) + for key, value in raw.items() + if key not in {"type", "id", "parent", "abstract", "components"} + } + result[prototype_id] = EntityPrototype( + id=prototype_id, + parents=normalize_parents(raw.get("parent")), + abstract=bool(raw.get("abstract", False)), + source_file=source_file, + origin=origin_from_path(source_file), + fields=fields, + components=tuple(components), + ) + + if not result: + raise RuntimeError("No entity prototypes found") + return result + + +class PrototypeResolver: + """Resolve entity inheritance with component-level SS14 semantics.""" + + def __init__(self, prototypes: dict[str, EntityPrototype]): + self.prototypes = prototypes + self.cache: dict[str, dict[str, Any]] = {} + self.active: list[str] = [] + + def resolve(self, prototype_id: str) -> dict[str, Any]: + cached = self.cache.get(prototype_id) + if cached is not None: + return cached + + prototype = self.prototypes.get(prototype_id) + if prototype is None: + raise RuntimeError(f"Unknown entity prototype: {prototype_id}") + if prototype_id in self.active: + start = self.active.index(prototype_id) + cycle = self.active[start:] + [prototype_id] + raise RuntimeError("Entity inheritance cycle: " + " -> ".join(cycle)) + + self.active.append(prototype_id) + fields: dict[str, Any] = {} + components: dict[str, dict[str, Any]] = {} + + for parent_id in prototype.parents: + if parent_id not in self.prototypes: + raise RuntimeError( + f"Unknown parent {parent_id} used by {prototype_id}" + ) + parent = self.resolve(parent_id) + fields.update(copy.deepcopy(parent["fields"])) + for component_type, component in parent["components"].items(): + merged_parent = copy.deepcopy(components.get(component_type, {})) + merged_parent.update(copy.deepcopy(component)) + components[component_type] = merged_parent + + # Prototype fields and individual component fields replace the matching + # inherited field as a whole. Nested maps are intentionally not merged: + # Empty weapon variants rely on replacing ItemSlots.slots so an inherited + # startingItem does not survive. + fields.update(copy.deepcopy(prototype.fields)) + for raw_component in prototype.components: + component_type = raw_component["type"] + merged = copy.deepcopy(components.get(component_type, {})) + merged.update( + { + key: copy.deepcopy(value) + for key, value in raw_component.items() + if key != "type" + } + ) + components[component_type] = merged + + self.active.pop() + resolved = { + "id": prototype_id, + "parents": list(prototype.parents), + "abstract": prototype.abstract, + "sourceFile": prototype.source_file, + "origin": prototype.origin, + "fields": fields, + "components": components, + } + self.cache[prototype_id] = resolved + return resolved diff --git a/scripts/test_build_mob_catalog.py b/scripts/test_build_mob_catalog.py new file mode 100644 index 0000000..74cf814 --- /dev/null +++ b/scripts/test_build_mob_catalog.py @@ -0,0 +1,194 @@ +import pytest + +from build_mob_catalog import ( + EXCLUDED_XENO_CASTE_IDS, + armor_from_component, + capitalize_first, + invert_thresholds, + is_xeno_mob_source_file, + matured_thresholds, +) +from prototype_resolution import EntityPrototype, PrototypeResolver + + +def test_invert_thresholds_basic(): + assert invert_thresholds({0: "Alive", 150: "Critical", 200: "Dead"}) == { + "critical": 150, + "dead": 200, + } + + +def test_invert_thresholds_dead_only_is_allowed(): + # Parasite/larva-style castes have no Critical state, they just die outright. + assert invert_thresholds({0: "Alive", 35: "Dead"}) == { + "critical": None, + "dead": 35, + } + + +def test_invert_thresholds_missing_dead_raises(): + with pytest.raises(RuntimeError, match="Dead"): + invert_thresholds({0: "Alive", 150: "Critical"}) + + +def test_invert_thresholds_unknown_state_raises(): + with pytest.raises(RuntimeError, match="Unknown"): + invert_thresholds({0: "Alive", 100: "Paralyzed", 200: "Dead"}) + + +def test_invert_thresholds_duplicate_state_raises(): + with pytest.raises(RuntimeError, match="Duplicate"): + invert_thresholds({100: "Dead", 200: "Dead"}) + + +def test_is_xeno_mob_source_file_accepts_known_directories(): + assert is_xeno_mob_source_file( + "Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/warrior.yml" + ) + assert is_xeno_mob_source_file( + "Resources/Prototypes/_Stories/Entities/Mobs/Xeno/crusher.yml" + ) + + +def test_is_xeno_mob_source_file_rejects_other_paths(): + assert not is_xeno_mob_source_file( + "Resources/Prototypes/_RMC14/Entities/Mobs/Species/base.yml" + ) + + +def test_armor_from_component_reads_known_fields(): + component = {"xenoArmor": 20, "explosionArmor": 40} + assert armor_from_component(component) == { + "xenoArmor": 20, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 40, + "immuneToArmorPiercing": False, + } + + +def test_armor_from_component_defaults_bare_component(): + # base_xeno.yml sets a bare `type: CMArmor` with no fields at all. + assert armor_from_component({}) == { + "xenoArmor": 0, + "frontalArmor": 0, + "sideArmor": 0, + "explosionArmor": 0, + "immuneToArmorPiercing": False, + } + + +def test_matured_thresholds_none_when_absent(): + assert matured_thresholds(None) is None + + +def test_matured_thresholds_reads_queen_style_component(): + component = {"critThreshold": 1000, "deadThreshold": 1100} + assert matured_thresholds(component) == {"critical": 1000, "dead": 1100} + + +def test_matured_thresholds_missing_fields_raises(): + with pytest.raises(RuntimeError): + matured_thresholds({"critThreshold": 1000}) + + +def test_capitalize_first_uppercases_first_letter(): + assert capitalize_first("воин") == "Воин" + + +def make_prototype(prototype_id, parents=(), abstract=False, components=(), source_file="test.yml"): + return EntityPrototype( + id=prototype_id, + parents=tuple(parents), + abstract=abstract, + source_file=source_file, + origin="rmc14", + fields={}, + components=tuple(components), + ) + + +def test_prototype_resolver_merges_inherited_armor_and_thresholds(): + # Mirrors CMXenoBase (abstract, bare CMArmor default) -> CMXenoWarrior + # (concrete, overrides xenoArmor/explosionArmor and adds its own thresholds). + prototypes = { + "CMXenoBase": make_prototype( + "CMXenoBase", + abstract=True, + components=[{"type": "CMArmor"}], + ), + "CMXenoWarrior": make_prototype( + "CMXenoWarrior", + parents=["CMXenoBase"], + components=[ + {"type": "MobThresholds", "thresholds": {0: "Alive", 500: "Critical", 600: "Dead"}}, + {"type": "CMArmor", "xenoArmor": 20, "explosionArmor": 40}, + ], + ), + } + resolver = PrototypeResolver(prototypes) + + resolved_abstract = resolver.resolve("CMXenoBase") + assert "MobThresholds" not in resolved_abstract["components"] + + resolved_concrete = resolver.resolve("CMXenoWarrior") + assert resolved_concrete["components"]["CMArmor"] == { + "xenoArmor": 20, + "explosionArmor": 40, + } + assert resolved_concrete["components"]["MobThresholds"]["thresholds"] == { + 0: "Alive", + 500: "Critical", + 600: "Dead", + } + + +def test_discovery_filter_includes_concrete_excludes_abstract(): + """The real build_mob_catalog.py discovery loop keeps only non-abstract xeno + prototypes that resolve to having both MobThresholds and CMArmor — this test + exercises exactly that filter against tiny in-memory fixtures instead of a + full game-source checkout.""" + prototypes = { + "CMXenoBase": make_prototype( + "CMXenoBase", + abstract=True, + source_file="Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/base_xeno.yml", + components=[{"type": "CMArmor"}], + ), + "CMXenoWarrior": make_prototype( + "CMXenoWarrior", + parents=["CMXenoBase"], + source_file="Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/warrior.yml", + components=[ + {"type": "MobThresholds", "thresholds": {0: "Alive", 500: "Critical", 600: "Dead"}}, + {"type": "CMArmor", "xenoArmor": 20}, + ], + ), + "CMXenoHive": make_prototype( + "CMXenoHive", + source_file="Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/hive.yml", + components=[{"type": "Structure"}], + ), + "RMCXenoRouny": make_prototype( + "RMCXenoRouny", + parents=["CMXenoBase"], + source_file="Resources/Prototypes/_RMC14/Entities/Mobs/Xeno/rouny.yml", + components=[ + {"type": "MobThresholds", "thresholds": {0: "Alive", 230: "Critical", 330: "Dead"}}, + {"type": "CMArmor"}, + ], + ), + } + resolver = PrototypeResolver(prototypes) + + kept: list[str] = [] + for prototype in prototypes.values(): + if prototype.abstract or prototype.id in EXCLUDED_XENO_CASTE_IDS: + continue + if not is_xeno_mob_source_file(prototype.source_file): + continue + resolved = resolver.resolve(prototype.id) + if "MobThresholds" in resolved["components"] and "CMArmor" in resolved["components"]: + kept.append(prototype.id) + + assert kept == ["CMXenoWarrior"] diff --git a/scripts/validate_mob_catalog.py b/scripts/validate_mob_catalog.py new file mode 100644 index 0000000..0ea5038 --- /dev/null +++ b/scripts/validate_mob_catalog.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +MIN_XENO_CASTES = 20 + +EXPECTED_ARMOR_KEYS = { + "xenoArmor", + "frontalArmor", + "sideArmor", + "explosionArmor", + "immuneToArmorPiercing", +} + + +def read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def validate_thresholds(thresholds: Any, label: str) -> None: + if not isinstance(thresholds, dict): + raise RuntimeError(f"{label}: thresholds must be an object") + dead = thresholds.get("dead") + critical = thresholds.get("critical") + if not isinstance(dead, int) or dead <= 0: + raise RuntimeError(f"{label}: invalid dead threshold: {dead!r}") + if critical is not None: + if not isinstance(critical, int) or critical <= 0: + raise RuntimeError(f"{label}: invalid critical threshold: {critical!r}") + if critical >= dead: + raise RuntimeError( + f"{label}: critical threshold must be below dead: " + f"{critical} >= {dead}" + ) + + +def validate(data: dict[str, Any]) -> None: + if data.get("schemaVersion") != 1: + raise RuntimeError(f"Unexpected schemaVersion: {data.get('schemaVersion')}") + + marine = data.get("marine") + if not isinstance(marine, dict): + raise RuntimeError("Missing marine entry") + if not isinstance(marine.get("sourcePrototypeId"), str): + raise RuntimeError("Marine entry has no sourcePrototypeId") + validate_thresholds(marine.get("thresholds"), "marine") + + xeno_castes = data.get("xenoCastes") + if not isinstance(xeno_castes, dict) or not xeno_castes: + raise RuntimeError("No xeno castes found") + if len(xeno_castes) < MIN_XENO_CASTES: + raise RuntimeError( + f"Suspiciously few xeno castes: {len(xeno_castes)} < {MIN_XENO_CASTES}" + ) + + for caste_id, caste in xeno_castes.items(): + if not isinstance(caste, dict): + raise RuntimeError(f"Xeno caste is not an object: {caste_id}") + if caste.get("id") != caste_id: + raise RuntimeError(f"Xeno caste id mismatch: {caste_id}") + if not isinstance(caste.get("name"), str) or not caste["name"]: + raise RuntimeError(f"Xeno caste has no name: {caste_id}") + source_file = caste.get("sourceFile") + if not isinstance(source_file, str) or "Mobs/Xeno/" not in source_file: + raise RuntimeError(f"Xeno caste has an unexpected sourceFile: {caste_id}") + validate_thresholds(caste.get("thresholds"), caste_id) + if caste.get("maturedThresholds") is not None: + validate_thresholds(caste["maturedThresholds"], f"{caste_id} (matured)") + + armor = caste.get("armor") + if not isinstance(armor, dict) or set(armor) != EXPECTED_ARMOR_KEYS: + raise RuntimeError(f"Xeno caste has invalid armor keys: {caste_id}") + for key in ("xenoArmor", "frontalArmor", "sideArmor", "explosionArmor"): + if not isinstance(armor[key], int) or armor[key] < 0: + raise RuntimeError(f"Xeno caste has invalid {key}: {caste_id}") + if not isinstance(armor["immuneToArmorPiercing"], bool): + raise RuntimeError( + f"Xeno caste has invalid immuneToArmorPiercing: {caste_id}" + ) + + counts = data.get("counts") + expected_counts = {"xenoCastes": len(xeno_castes)} + if counts != expected_counts: + raise RuntimeError(f"Count mismatch: stored={counts}, actual={expected_counts}") + + print(f'Marine thresholds: {marine["thresholds"]}') + print(f"Xeno castes: {len(xeno_castes)}") + print("Mob catalog validation passed") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate mob catalog output") + parser.add_argument("--catalog", type=Path, required=True) + args = parser.parse_args() + validate(read_json(args.catalog)) + + +if __name__ == "__main__": + main()