magicciv/tools/migrate-resources-visibility.py

247 lines
9.3 KiB
Python
Raw Normal View History

#!/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", "<unknown>")
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()