From 573fdec713be136f17498f8212532277cda2f679 Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 13 May 2026 12:06:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20bridge=20call=20pattern=20test=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-city/src/great_works.rs | 29 ++++++ tools/validate-game-data.py | 88 +++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/src/simulator/crates/mc-city/src/great_works.rs b/src/simulator/crates/mc-city/src/great_works.rs index fa3246c3..cbaa2382 100644 --- a/src/simulator/crates/mc-city/src/great_works.rs +++ b/src/simulator/crates/mc-city/src/great_works.rs @@ -404,6 +404,35 @@ mod tests { assert!(occ.contains_work("b")); } + #[test] + fn bridge_call_pattern_compiles() { + // Mirror of the exact call sequence used by api-gdext::civics:: + // GdGreatWorkOccupation. Lives here so we can catch type drift + // when api-gdext can't build due to unrelated crate blockers. + use crate::city::TileYield; + use crate::harvest::{apply_harvest_policy, HarvestResult}; + use crate::harvest_policy::HarvestPolicyRegistry; + use crate::tile::WorkedTile; + + let tile = WorkedTile::with_policy((0, 0), "extract_sustainable"); + let reg = HarvestPolicyRegistry::default(); + let base = TileYield { + coord: tile.coord, + food: 2.0, + production: 1.0, + gold: 0.0, + culture: 0.0, + science: 0.0, + collectibles: Vec::new(), + food_modifier: 1.0, + }; + // Should not panic on empty registry — falls through to Yield(base). + match apply_harvest_policy(base, &tile, ®) { + HarvestResult::Yield(_) => {} + HarvestResult::Chop(_) => panic!("unexpected chop"), + } + } + #[test] fn test_occupancy_summary() { let mut occ = GreatWorkOccupation::new(); diff --git a/tools/validate-game-data.py b/tools/validate-game-data.py index 5d89ea70..3d687931 100644 --- a/tools/validate-game-data.py +++ b/tools/validate-game-data.py @@ -487,6 +487,93 @@ class GameDataValidator: else: self._ok(label) + def validate_recipes(self): + """p2-57: cross-ref public/resources/recipes/recipes.json against the + known building ids and the union of raw resource ids + (`public/resources/resources.json`) plus processed ids + (`public/resources/typed-resources/processed.json`). Fails on any + undeclared resource or unknown building. + """ + recipes_path = self.resources / "recipes" / "recipes.json" + if not recipes_path.exists(): + return + data, err = load_json_safe(recipes_path) + if err or not isinstance(data, dict): + self._fail("recipes/recipes.json", f"parse error: {err or 'wrong shape'}") + return + + # Known building ids. + bdir = self.resources / "buildings" + building_ids = self._load_id_set_from_split_dir(bdir) if bdir.exists() else set() + if (self.game_data / "buildings").exists(): + building_ids |= self._load_id_set_from_split_dir(self.game_data / "buildings") + + # Known resource ids: raws from resources.json + processed from + # typed-resources/processed.json. + resource_ids: set[str] = set() + raw_path = self.resources / "resources.json" + if raw_path.exists(): + raw_data, _ = load_json_safe(raw_path) + if isinstance(raw_data, dict): + for cat in ("bonus", "luxury", "strategic"): + for entry in raw_data.get(cat, []): + if isinstance(entry, dict) and "id" in entry: + resource_ids.add(entry["id"]) + proc_path = self.resources / "typed-resources" / "processed.json" + if proc_path.exists(): + proc_data, _ = load_json_safe(proc_path) + if isinstance(proc_data, dict): + for entry in proc_data.get("processed", []): + if isinstance(entry, dict) and "id" in entry: + resource_ids.add(entry["id"]) + + print( + f"\n recipe cross-refs ({len(building_ids)} buildings, " + f"{len(resource_ids)} resource ids)" + ) + recipes = data.get("recipes", []) + if not isinstance(recipes, list): + self._fail("recipes/recipes.json", "`recipes` must be a list") + return + for idx, recipe in enumerate(recipes): + if not isinstance(recipe, dict): + self._fail(f"recipes[{idx}]", f"must be object, got {type(recipe).__name__}") + continue + bid = recipe.get("building_id") + label = f"recipes/{bid or f''}" + if not isinstance(bid, str): + self._fail(label, "missing/non-string building_id") + continue + if bid not in building_ids: + self._fail(label, f"building_id='{bid}' is not a known building") + else: + self._ok(f"{label}.building_id") + for edge_kind in ("consumes", "produces"): + edges = recipe.get(edge_kind, []) + if not isinstance(edges, list): + self._fail( + f"{label}.{edge_kind}", + f"must be a list, got {type(edges).__name__}", + ) + continue + for j, edge in enumerate(edges): + elabel = f"{label}.{edge_kind}[{j}]" + if not isinstance(edge, dict): + self._fail(elabel, "edge must be an object") + continue + rid = edge.get("resource") + qty = edge.get("qty_per_turn") + if not isinstance(rid, str): + self._fail(elabel, "missing/non-string resource") + elif rid not in resource_ids: + self._fail(elabel, f"resource='{rid}' not declared in resources.json or typed-resources/processed.json") + else: + self._ok(f"{elabel}.resource") + if not isinstance(qty, int) or qty < 1: + self._fail(elabel, f"qty_per_turn must be int >= 1, got {qty!r}") + else: + self._ok(f"{elabel}.qty_per_turn") + 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 @@ -646,6 +733,7 @@ class GameDataValidator: self.validate_biomes() self.validate_deposit_concept_refs() self.validate_resources_kind() + self.validate_recipes() self.validate_guide_data() self.validate_building_requires_existing() self.validate_cross_refs()