#!/usr/bin/env python3 """Migrate public/resources/resources.json to the three-axis visibility model. Removes: revealed_by_tech Adds: visibility, yield_gate, improvement_gate, indicator_decorations Run from any directory — resolves paths from the script location. """ import json import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent RESOURCES_JSON = REPO_ROOT / "public" / "resources" / "resources.json" # ── Classification tables ──────────────────────────────────────────────────── # Luxury resources: (visibility, yield_gate) LUXURY_MAP: dict[str, tuple[str, str | None]] = { "furs": ("always", "trapping"), "ivory": ("always", "trapping"), "silk": ("always", "scholarship"), "spices": ("always", "herbalism"), "dyes": ("always", "culture"), "amber": ("tech_gated", "scholarship"), "salt": ("always", None), "wine": ("always", "agriculture"), "incense": ("always", "culture"), "pearls": ("tech_gated", "fishing"), "copper": ("always", "bronze_working"), "marble": ("always", "masonry"), "gems": ("tech_gated", "mining"), "gold": ("tech_gated", "mining"), "obsidian_glass": ("always", "metallurgy"), } # Bonus resources: always visible, no yield gate BONUS_IDS: set[str] = { "deer", "bison", "fish", "crabs", "wheat", "cattle", "sheep", "stone", "timber", } # Strategic resources: tech_gated, yield_gate == old revealed_by_tech # Exception: flint is always-visible strategic. FLINT_ID = "flint" # Indicator decorations for tech_gated resources INDICATOR_DECORATIONS: dict[str, list[dict]] = { "amber": [ { "decoration_id": "fossilized_resin_fragments", "name": "Fossilized resin fragments", "description": "Amber-coloured fragments in riverbank sediment — fossilized tree resin that hardened over millennia. Scholars know its value.", } ], "pearls": [ { "decoration_id": "oyster_bed_shells", "name": "Oyster bed shells", "description": "Clusters of freshwater mussel shells along riverbanks. Fishers know which beds hide pearls.", } ], "gems": [ { "decoration_id": "quartz_outcrop_glint", "name": "Quartz outcrop glint", "description": "Sparkling mineral pockets in mountain cleft rock. Mining skills are needed to extract the gem-bearing strata below.", } ], "gold": [ { "decoration_id": "river_gold_specks", "name": "Placer gold specks", "description": "Glittering specks in stream gravel. Experienced miners recognise the placer-deposit signature of an upstream gold vein.", }, { "decoration_id": "gold_quartz_vein", "name": "Gold-bearing quartz vein", "description": "White quartz bands with metallic inclusions. Mining expertise required to extract and assay the ore.", }, ], "iron": [ { "decoration_id": "rust_red_soil", "name": "Iron-oxide stained soil", "description": "Reddish-brown staining on exposed bedrock — the tell-tale sign of iron-bearing strata beneath.", } ], "coal": [ { "decoration_id": "coal_seam_outcrop", "name": "Coal seam outcrop", "description": "Black streaks in cliff faces where a coal seam breaches the surface. Metallurgists know its fuel value.", } ], "saltpeter": [ { "decoration_id": "white_crystal_efflorescence", "name": "White crystal efflorescence", "description": "Chalky white crystal bloom on cave walls and desert margins. Alchemists recognize potassium nitrate.", } ], "hardwood": [], # Flora always visible at terrain level "hides": [], # Fauna always visible at terrain level "horses": [], # Fauna always visible at terrain level } def _make_indicator_decorations(resource_id: str, visibility: str) -> list[dict]: if visibility != "tech_gated": return [] return INDICATOR_DECORATIONS.get(resource_id, []) def migrate_entry(entry: dict) -> dict: """Return a new dict with the three-axis fields added and revealed_by_tech removed.""" entry = dict(entry) resource_id: str = entry["id"] category: str = entry.get("category", "") old_tech: str | None = entry.pop("revealed_by_tech", None) or None if old_tech == "": old_tech = None if category == "bonus": visibility = "always" yield_gate: str | None = None improvement_gate: str | None = None indicator_decorations: list[dict] = [] elif category == "luxury": if resource_id not in LUXURY_MAP: print( f" WARNING: luxury '{resource_id}' not in LUXURY_MAP — " f"using fallback (always, {old_tech})", file=sys.stderr, ) visibility = "always" yield_gate = old_tech else: visibility, yield_gate = LUXURY_MAP[resource_id] improvement_gate = yield_gate indicator_decorations = _make_indicator_decorations(resource_id, visibility) elif category == "strategic": if resource_id == FLINT_ID: visibility = "always" yield_gate = None improvement_gate = None else: visibility = "tech_gated" yield_gate = old_tech improvement_gate = old_tech indicator_decorations = _make_indicator_decorations(resource_id, visibility) else: # Unknown category — preserve old tech as yield_gate, default always print( f" WARNING: unknown category '{category}' for '{resource_id}' — " "defaulting to always-visible", file=sys.stderr, ) visibility = "always" yield_gate = old_tech improvement_gate = old_tech indicator_decorations = [] entry["visibility"] = visibility entry["yield_gate"] = yield_gate entry["improvement_gate"] = improvement_gate entry["indicator_decorations"] = indicator_decorations return entry def validate(data: dict) -> list[str]: errors: list[str] = [] for section in ("luxury", "bonus", "strategic"): for entry in data.get(section, []): rid = entry.get("id", "") if "revealed_by_tech" in entry: errors.append(f"{section}/{rid}: revealed_by_tech still present") if "visibility" not in entry: errors.append(f"{section}/{rid}: missing visibility") if "yield_gate" not in entry: errors.append(f"{section}/{rid}: missing yield_gate") if "improvement_gate" not in entry: errors.append(f"{section}/{rid}: missing improvement_gate") if "indicator_decorations" not in entry: errors.append(f"{section}/{rid}: missing indicator_decorations") vis = entry.get("visibility", "") if vis not in ("always", "scout", "tech_gated"): errors.append(f"{section}/{rid}: invalid visibility '{vis}'") # Audit all 15 luxuries present luxury_ids = {e["id"] for e in data.get("luxury", [])} for expected_id in LUXURY_MAP: if expected_id not in luxury_ids: errors.append(f"luxury/{expected_id}: missing from data — not migrated") return errors def main() -> None: print(f"Reading {RESOURCES_JSON}") raw = RESOURCES_JSON.read_text(encoding="utf-8") data: dict = json.loads(raw) migrated: dict = {} for key, value in data.items(): if isinstance(value, list): migrated[key] = [migrate_entry(e) if isinstance(e, dict) else e for e in value] else: migrated[key] = value errors = validate(migrated) if errors: print("VALIDATION ERRORS:", file=sys.stderr) for err in errors: print(f" {err}", file=sys.stderr) sys.exit(1) output = json.dumps(migrated, indent=2, ensure_ascii=False) RESOURCES_JSON.write_text(output + "\n", encoding="utf-8") print(f"Wrote {RESOURCES_JSON}") # Summary audit luxury_count = len(migrated.get("luxury", [])) bonus_count = len(migrated.get("bonus", [])) strategic_count = len(migrated.get("strategic", [])) print(f"Migrated: {luxury_count} luxuries, {bonus_count} bonus, {strategic_count} strategic") # Spot-check three entries for section, target_id in [("luxury", "amber"), ("bonus", "cattle"), ("strategic", "iron")]: entry = next((e for e in migrated.get(section, []) if e.get("id") == target_id), None) if entry: print( f" {section}/{target_id}: visibility={entry['visibility']!r}," f" yield_gate={entry['yield_gate']!r}," f" improvement_gate={entry['improvement_gate']!r}," f" decorations={len(entry['indicator_decorations'])}" ) else: print(f" {section}/{target_id}: NOT FOUND", file=sys.stderr) if __name__ == "__main__": main()