magicciv/tools/fix-resource-graph.py

307 lines
9.8 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""One-shot fixer for the Game 1 resource graph (task 1 from
plans/what-are-all-the-gleaming-flurry.md).
Three operations:
1. Rewrite public/games/age-of-dwarves/data/deposits/manifest.json to the
curated Game 1 dwarf-mundane set (drop magic, rename dwarf-flavor names to
existing generic files, create missing-but-mundane files).
2. Create missing deposit files for entries we want to keep (coral_reef, spices,
horses-in-manifest needs no creation since horses.json already exists).
3. Regenerate `gates_units` and `gates_buildings` on every deposit by scanning
the units/ and buildings/ directories for the `requires_resource` field.
Units/buildings are the source of truth; deposit back-pointers are derived.
Run with --dry-run to see what would change without writing files.
"""
from __future__ import annotations
import argparse
import json
from collections import defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DEPOSITS_DIR = REPO_ROOT / "public" / "resources" / "deposits"
UNITS_DIR = REPO_ROOT / "public" / "resources" / "units"
BUILDINGS_DIR = REPO_ROOT / "public" / "resources" / "buildings"
MANIFEST = (
REPO_ROOT
/ "public"
/ "games"
/ "age-of-dwarves"
/ "data"
/ "deposits"
/ "manifest.json"
)
# Curated Game 1 manifest. Order is presentation order.
GAME1_MANIFEST = [
# Strategic
"iron_ore",
"coal_seam",
"saltpeter_deposit",
"obsidian",
"mithril_vein", # kept: referenced by ~10 dwarf units; dwarf-mithril is canon
"horses",
"stone_deposit",
# Luxury — minerals
"gold_vein",
"ancient_marble",
"chalk",
"gypsum",
"selenite",
"calcite",
"pyrite",
"quartz",
"fluorite",
"tourmaline",
"optical_calcite",
"glimmer_salt",
"pressure_crystal",
# Luxury — gems
"agate",
"malachite",
"garnet",
"amethyst",
"turquoise",
"opal",
"jade",
"topaz",
"aquamarine",
"ruby",
"sapphire",
"emerald",
"alexandrite",
"diamond",
# Luxury — flora / fauna
"amber",
"fur",
"ivory",
"dyes",
"silk",
"wine",
"incense",
"spices", # to create
"cotton",
# Bonus food
"wheat",
"cattle",
"sheep",
"deer",
"fish",
"crab",
# Luxury — marine
"pearls",
"coral_reef", # to create
]
# Placer-class assignment for the Game 1 manifest (Gap D / task 2). One of:
# metal, gem, mineral, coal, volcanic, marine, flora_luxury, food.
# Drives DepositPlacer dispatch in mc-mapgen (see plan §4 Gap D).
PLACER_CLASS: dict[str, str] = {
# metal
"iron_ore": "metal", "gold_vein": "metal", "mithril_vein": "metal", "pyrite": "metal",
# gem
"agate": "gem", "alexandrite": "gem", "amber": "gem", "amethyst": "gem",
"aquamarine": "gem", "diamond": "gem", "emerald": "gem", "garnet": "gem",
"jade": "gem", "malachite": "gem", "opal": "gem", "ruby": "gem",
"sapphire": "gem", "topaz": "gem", "turquoise": "gem", "tourmaline": "gem",
# mineral (industrial non-gem)
"chalk": "mineral", "gypsum": "mineral", "selenite": "mineral", "calcite": "mineral",
"fluorite": "mineral", "optical_calcite": "mineral", "quartz": "mineral",
"glimmer_salt": "mineral", "pressure_crystal": "mineral", "ancient_marble": "mineral",
"stone_deposit": "mineral",
# coal — needs its own placer because it forms in-epoch during the swamp biome stage
"coal_seam": "coal",
# volcanic
"obsidian": "volcanic", "saltpeter_deposit": "volcanic",
# marine
"pearls": "marine", "coral_reef": "marine", "fish": "marine", "crab": "marine",
# flora_luxury
"dyes": "flora_luxury", "silk": "flora_luxury", "wine": "flora_luxury",
"incense": "flora_luxury", "spices": "flora_luxury", "cotton": "flora_luxury",
"ivory": "flora_luxury", "fur": "flora_luxury",
# food
"wheat": "food", "cattle": "food", "sheep": "food", "deer": "food", "horses": "food",
}
# Files to author if absent. Schema lifted from amber.json + adjusted.
DEPOSITS_TO_CREATE: dict[str, dict] = {
"spices": {
"id": "spices",
"category": "luxury",
"name": "Spice Groves",
"tier": 3,
"terrains": ["jungle", "savanna", "grassland", "plains"],
"near_start": False,
"min_per_player": 0,
"food_bonus": 1,
"production_bonus": 0,
"trade_bonus": 4,
"culture_bonus": 1,
"sprite": "sprites/resources/spices.png",
"description": "Aromatic spice plants cultivated for trade and preservation.",
"encyclopedia": {
"category": "world",
"entry_type": "deposit",
"detail_route": "/map/resources",
"tags": [],
},
"quality_scale": {
"1": {"multiplier": 0.5, "label": "Marginal"},
"2": {"multiplier": 0.75, "label": "Poor"},
"3": {"multiplier": 1.0, "label": "Standard"},
"4": {"multiplier": 1.25, "label": "Rich"},
"5": {"multiplier": 1.5, "label": "Exceptional"},
},
"gates_units": [],
"gates_buildings": [],
"placer_class": "flora_luxury",
"concept_resource": "spices",
"visibility": "always",
"yield_gate": "herbalism",
"improvement_gate": "herbalism",
"indicator_decorations": [],
},
"coral_reef": {
"id": "coral_reef",
"category": "luxury",
"name": "Coral Reef",
"tier": 3,
"terrains": ["coast", "ocean"],
"near_start": False,
"min_per_player": 0,
"food_bonus": 1,
"production_bonus": 0,
"trade_bonus": 3,
"culture_bonus": 1,
"sprite": "sprites/resources/coral_reef.png",
"description": "Living reef formations rich in food and trade goods harvested by coastal divers.",
"encyclopedia": {
"category": "world",
"entry_type": "deposit",
"detail_route": "/map/resources",
"tags": [],
},
"quality_scale": {
"1": {"multiplier": 0.5, "label": "Marginal"},
"2": {"multiplier": 0.75, "label": "Poor"},
"3": {"multiplier": 1.0, "label": "Standard"},
"4": {"multiplier": 1.25, "label": "Rich"},
"5": {"multiplier": 1.5, "label": "Exceptional"},
},
"gates_units": [],
"gates_buildings": [],
"placer_class": "marine",
"concept_resource": "coral",
"visibility": "always",
"yield_gate": "fishing",
"improvement_gate": "fishing",
"indicator_decorations": [],
},
}
def _load_array(path: Path) -> list[dict]:
with path.open() as f:
data = json.load(f)
if isinstance(data, list):
return data
if isinstance(data, dict):
return [data]
return []
def _scan_requires_resource(directory: Path) -> dict[str, list[str]]:
"""Return resource_id -> sorted list of item ids that require it."""
rev: dict[str, list[str]] = defaultdict(list)
for path in sorted(directory.iterdir()):
if path.suffix != ".json" or path.name.endswith(".schema.json"):
continue
try:
for entry in _load_array(path):
if not isinstance(entry, dict):
continue
rres = entry.get("requires_resource")
if rres:
rev[rres].append(entry["id"])
except Exception:
continue
return {k: sorted(set(v)) for k, v in rev.items()}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
changes: list[str] = []
# Step 1: rewrite manifest.
new_manifest = {
"source": "resources/deposits",
"includes": list(GAME1_MANIFEST),
}
if not args.dry_run:
MANIFEST.write_text(json.dumps(new_manifest, indent=2) + "\n")
changes.append(f"manifest → {len(GAME1_MANIFEST)} entries (was 60)")
# Step 2: create missing deposit files.
for did, spec in DEPOSITS_TO_CREATE.items():
path = DEPOSITS_DIR / f"{did}.json"
if path.exists():
continue
if not args.dry_run:
path.write_text(json.dumps(spec, indent=2) + "\n")
changes.append(f"created deposits/{did}.json")
# Step 3: regenerate gates_units / gates_buildings on every deposit from
# the units/buildings source of truth.
rev_units = _scan_requires_resource(UNITS_DIR)
rev_bldgs = _scan_requires_resource(BUILDINGS_DIR)
rewritten = 0
for path in sorted(DEPOSITS_DIR.iterdir()):
if path.suffix != ".json" or path.name.endswith(".schema.json"):
continue
if path.name in {"deposit_categories.json"}:
continue
try:
with path.open() as f:
dep = json.load(f)
except Exception:
continue
if not isinstance(dep, dict) or "id" not in dep:
continue
did = dep["id"]
new_units = rev_units.get(did, [])
new_bldgs = rev_bldgs.get(did, [])
new_class = PLACER_CLASS.get(did)
unchanged = (
dep.get("gates_units") == new_units
and dep.get("gates_buildings") == new_bldgs
and (new_class is None or dep.get("placer_class") == new_class)
)
if unchanged:
continue
dep["gates_units"] = new_units
dep["gates_buildings"] = new_bldgs
if new_class is not None:
dep["placer_class"] = new_class
if not args.dry_run:
path.write_text(json.dumps(dep, indent=2) + "\n")
rewritten += 1
changes.append(f"regenerated gates_* on {rewritten} deposit files")
for line in changes:
print(("[dry-run] " if args.dry_run else "[applied] ") + line)
return 0
if __name__ == "__main__":
raise SystemExit(main())