246 lines
9.3 KiB
Python
246 lines
9.3 KiB
Python
#!/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()
|