diff --git a/public/games/age-of-dwarves/data/manifests/buildings.json b/public/games/age-of-dwarves/data/manifests/buildings.json index b784b85f..6cbbe844 100644 --- a/public/games/age-of-dwarves/data/manifests/buildings.json +++ b/public/games/age-of-dwarves/data/manifests/buildings.json @@ -3,6 +3,7 @@ "includes": [ "academy_of_sciences", "adamantine_foundry", + "apothecarium", "airfield", "alchemist_bench", "alchemist_workshop", @@ -13,6 +14,7 @@ "armory", "axis_mundi", "barracks", + "bazaar", "brewery", "carved_hall", "castle", @@ -38,10 +40,12 @@ "gathering_hall", "granary", "grand_amphitheater", + "grand_chronicle", "grand_citadel", "grand_gate", "grand_harbor", "grand_orrery", + "gravity_press", "great_barrow", "great_granary", "great_hall", @@ -54,6 +58,7 @@ "herbalist", "high_guild_hall", "hunting_lodge", + "hydroponic_farm", "infirmary", "iron_throne", "library", diff --git a/public/resources/buildings/apothecarium.json b/public/resources/buildings/apothecarium.json new file mode 100644 index 00000000..5e2004a4 --- /dev/null +++ b/public/resources/buildings/apothecarium.json @@ -0,0 +1,45 @@ +{ + "id": "apothecarium", + "name": "Apothecarium", + "description": "A sealed pharmacy and surgical school, with stocks of distilled herb, mineral salt, and bone-set splints. Field surgeons rotate through residencies and march out with a year's worth of supply on their backs.", + "placement": "city", + "category": "infrastructure", + "school": null, + "cost": 360, + "upkeep": 4, + "tech_required": "applied_medicine", + "race_required": null, + "wonder_type": null, + "mana_generated": null, + "tier": 5, + "effects": [ + { "type": "happiness", "value": 5 }, + { "type": "unit_heal_in_city", "value": 35 }, + { "type": "global_unit_heal_bonus", "value": 4 }, + { "type": "plague_resistance", "value": 1 }, + { "type": "great_person_points", "value": 1 } + ], + "requires_existing": "hospital", + "consumes_existing": true, + "upgrade_fee": 50, + "stack_mode": "amplify", + "produces": [ + "battle_medic", + "field_surgeon", + "master_surgeon" + ], + "sprite": "sprites/buildings/apothecarium.png", + "encyclopedia": { + "category": "civilization", + "entry_type": "building", + "detail_route": "/buildings/buildings", + "tags": [ + "medical", + "infrastructure", + "stack", + "tier5" + ] + }, + "balance_note": "Medical T5 chain-extension proof for p1-43a. Effects authored as (barber T1 + clinic T2 + hospital T3) sum + marginal T5 contribution per Q3 author-time inheritance rule.", + "flags": [] +} diff --git a/tools/validate-game-data.py b/tools/validate-game-data.py index 62db5689..a8344288 100644 --- a/tools/validate-game-data.py +++ b/tools/validate-game-data.py @@ -440,6 +440,36 @@ class GameDataValidator: else: self._ok(label) + def validate_building_requires_existing(self): + """p1-43a: every `requires_existing` ladder pointer must resolve to a + real building id. Cross-refs `public/resources/buildings/*.json` only + (post-p1-40 single source of truth).""" + bdir = self.resources / "buildings" + if not bdir.exists(): + return + building_ids = self._load_id_set_from_split_dir(bdir) + # Also accept ids from any game-specific override dir, for completeness + if (self.game_data / "buildings").exists(): + building_ids |= self._load_id_set_from_split_dir(self.game_data / "buildings") + print(f"\n building requires_existing cross-refs ({len(building_ids)} known ids)") + for f in sorted(bdir.glob("*.json")): + if f.name.endswith(".schema.json") or f.name in ("manifest.json", "building_categories.json"): + continue + for label, entry in self._collect_entries_from_file(f): + prereq = entry.get("requires_existing") + if prereq is None: + continue + ref_label = f"{label}.requires_existing" + if not isinstance(prereq, str): + self._fail(ref_label, f"must be string|null, got {type(prereq).__name__}") + elif prereq not in building_ids: + self._fail( + ref_label, + f"requires_existing='{prereq}' does not resolve to a known building id", + ) + else: + self._ok(ref_label) + def validate_cross_refs(self): """Cross-reference checks: collectibles → resources, gates_* → units/buildings.""" resources = self._load_resources() @@ -526,6 +556,7 @@ class GameDataValidator: self.validate_deposit_concept_refs() self.validate_resources_kind() self.validate_guide_data() + self.validate_building_requires_existing() self.validate_cross_refs() def report(self) -> int: @@ -641,6 +672,29 @@ def _run_self_test(): sys.exit(1) print("SELF-TEST PASSED: unknown concept_resource correctly caught by deposit concept ref check") + # Self-test (p1-43a): a building declaring `requires_existing: "nonexistent"` + # must be caught by validate_building_requires_existing. + test_root = Path(tempfile.mkdtemp()) + test_bld_dir = test_root / "public" / "resources" / "buildings" + test_bld_dir.mkdir(parents=True) + (test_bld_dir / "real.json").write_text(json.dumps( + {"id": "real", "name": "Real", "placement": "city", "category": "infrastructure", + "cost": 50, "upkeep": 0} + )) + (test_bld_dir / "broken.json").write_text(json.dumps( + {"id": "broken", "name": "Broken", "placement": "city", "category": "infrastructure", + "cost": 100, "upkeep": 0, "requires_existing": "nonexistent"} + )) + v4 = GameDataValidator(test_root, verbose=False) + v4.validate_building_requires_existing() + if v4.failed == 0: + print("SELF-TEST FAILED: requires_existing='nonexistent' should have been caught") + sys.exit(1) + if not any("nonexistent" in e for e in v4.errors): + print(f"SELF-TEST FAILED: expected nonexistent error, got: {v4.errors}") + sys.exit(1) + print("SELF-TEST PASSED: dangling requires_existing pointer correctly caught (p1-43a)") + def main(): parser = argparse.ArgumentParser(description="Validate Age of Dwarves game pack JSON data")