magicciv/tools/migrate-deposits-visibility.py
Natalie 3985e07778 feat(@projects): update resource deposits and objectives
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-02 18:53:13 -04:00

451 lines
18 KiB
Python

#!/usr/bin/env python3
"""Migrate public/resources/deposits/*.json to the three-axis visibility model.
Removes: revealed_by_tech
Adds: visibility, yield_gate, improvement_gate, indicator_decorations
Handles two file shapes:
- Single entry: top-level dict with "id", "category", ...
- Multi-entry: top-level dict with "resources": [{...}, ...]
(e.g. magical.json, marine.json, mineral.json, organic.json)
Idempotent: rerunning is safe — already-migrated fields are overwritten with
the same values.
Run from any directory — resolves paths from script location.
"""
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DEPOSITS_DIR = REPO_ROOT / "public" / "resources" / "deposits"
# Files that are not resource entries — skip entirely.
SKIP_FILES: set[str] = {
"deposit_categories.json",
"deposits.schema.json",
"registry.md",
}
# ── Luxury classification table ──────────────────────────────────────────────
# (visibility, yield_gate) keyed by:
# 1. deposit's own `id`
# 2. deposit's `concept_resource` field (for variants like agate → gems)
# Deposit-level IDs take priority over concept_resource lookups.
LUXURY_MAP: dict[str, tuple[str, str | None]] = {
# Core luxuries from RESOURCES.md §3
"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"),
# Deposit-specific IDs not in RESOURCES.md §3 (classified by analogy)
# "fur" deposit → concept_resource=furs → maps via furs key above
# "glimmer_salt" deposit id → maps below; also via concept_resource=salt
"glimmer_salt": ("always", None), # salt surface rock — always visible
# "gold_vein" deposit id → maps below; also via concept_resource=gold
"gold_vein": ("tech_gated", "mining"), # vein gold, subsurface
# "cotton" → luxury, agriculture gate (fabric production)
"cotton": ("always", "agriculture"),
# "citrus" → luxury, agriculture gate
"citrus": ("always", "agriculture"),
# Game-2 specific luxuries — tech_gated with their Game-2 tech
"deep_crystal": ("tech_gated", "high_aether"),
"pressure_crystal": ("tech_gated", "deep_alloys"),
# Marine luxuries (seafaring tech)
"pearl_beds": ("tech_gated", "seafaring"),
"kraken_ink_vent": ("tech_gated", "seafaring"),
}
# Bonus: always visible, no yield gate — regardless of revealed_by_tech
# (RESOURCES.md §3 footnote: cattle/sheep herds are always visible)
# No explicit set needed — handled by category == "bonus" branch.
# Strategic resources: (deposit_id or concept_resource) → yield_gate.
# This table provides canonical idempotent values independent of revealed_by_tech.
# RESOURCES.md §3: all strategic resources are tech_gated.
# Exception: flint → always, null.
STRATEGIC_MAP: dict[str, str | None] = {
"iron": "bronze_working",
"iron_ore": "bronze_working", # deposit ID alias
"horses": "animal_husbandry",
"coal": "metallurgy",
"coal_seam": "metallurgy_advanced", # deposit uses advanced variant
"saltpeter": "alchemy",
"saltpeter_deposit": "alchemy", # deposit ID alias
"hardwood": "engineering",
"hides": "trapping",
"flint": None, # always-visible exception
}
# Game-2 magic resources with explicit tech gates.
# Entries NOT listed here are always-visible (no tech gate).
MAGIC_MAP: dict[str, str] = {
"mana_node_resource": "mysticism",
"ley_crystal": "high_aether",
"void_crystal": "arcane_lore",
}
# Strategic exception: flint is always-visible
FLINT_LIKE_ALWAYS: set[str] = {"flint"}
# ── Indicator decorations ────────────────────────────────────────────────────
# Keyed by CONCEPT RESOURCE ID (the canonical ID from RESOURCES.md §3).
# When a deposit's id differs from its concept_resource, we look up by concept.
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.",
},
],
# Deposit-ID alias — mineral.json entries lack concept_resource
"gold_vein": [
{
"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.",
}
],
# Deposit-ID alias — mineral.json entries lack concept_resource
"iron_ore": [
{
"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.",
}
],
# Deposit-ID alias — mineral.json entries lack concept_resource
"coal_seam": [
{
"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.",
}
],
# Deposit-ID alias — mineral.json entries lack concept_resource
"saltpeter_deposit": [
{
"decoration_id": "white_crystal_efflorescence",
"name": "White crystal efflorescence",
"description": "Chalky white crystal bloom on cave walls and desert margins. Alchemists recognize potassium nitrate.",
}
],
# Fauna/flora always visible at terrain level — no decoration needed
"horses": [],
"hardwood": [],
"hides": [],
}
def _get_decorations(deposit_id: str, concept_resource: str | None, visibility: str) -> list[dict]:
if visibility != "tech_gated":
return []
# Try concept_resource first, then deposit id
for key in [concept_resource, deposit_id]:
if key and key in INDICATOR_DECORATIONS:
return INDICATOR_DECORATIONS[key]
return []
def migrate_entry(entry: dict) -> tuple[dict, str]:
"""Return (migrated_entry, change_description)."""
entry = dict(entry)
deposit_id: str = entry.get("id", "<unknown>")
category: str = entry.get("category", "")
concept_resource: str | None = entry.get("concept_resource") or None
# `already_migrated` is True when revealed_by_tech has been removed in a prior run.
# In that case, entry may already have yield_gate set; we use it as the fallback
# source of truth for categories where the original tech is canonical (strategic, magic).
already_migrated: bool = "revealed_by_tech" not in entry
old_tech: str | None = entry.pop("revealed_by_tech", None)
if isinstance(old_tech, str) and old_tech.strip() == "":
old_tech = None
# For idempotence: if already migrated and old_tech is None, recover from existing yield_gate.
existing_yield_gate: str | None = entry.get("yield_gate") if already_migrated else None
if category == "bonus":
visibility = "always"
yield_gate: str | None = None
improvement_gate: str | None = None
indicator_decorations: list[dict] = []
# Note if we stripped a non-null tech
note = f"bonus→always (stripped rbt={old_tech!r})" if old_tech else "bonus→always"
elif category == "luxury":
# Look up by deposit id first, then by concept_resource
lookup_key = deposit_id if deposit_id in LUXURY_MAP else concept_resource
if lookup_key and lookup_key in LUXURY_MAP:
visibility, yield_gate = LUXURY_MAP[lookup_key]
note = f"luxury via LUXURY_MAP[{lookup_key!r}]"
elif old_tech:
# Has explicit revealed_by_tech — classify as tech_gated (e.g. deep_crystal)
visibility = "tech_gated"
yield_gate = old_tech
note = f"luxury fallback tech_gated rbt={old_tech!r}"
print(
f" NOTE: luxury '{deposit_id}' not in LUXURY_MAP with explicit rbt={old_tech!r}"
f" — classified tech_gated. Review if this is correct.",
file=sys.stderr,
)
else:
# No known mapping, no tech — default always (e.g. unlisted gems variants without rbt)
visibility = "always"
yield_gate = None
note = "luxury fallback always (no mapping, no rbt)"
print(
f" NOTE: luxury '{deposit_id}' not in LUXURY_MAP and no revealed_by_tech"
f" — defaulting to always-visible.",
file=sys.stderr,
)
improvement_gate = yield_gate
indicator_decorations = _get_decorations(deposit_id, concept_resource, visibility)
elif category == "strategic":
if deposit_id in FLINT_LIKE_ALWAYS:
visibility = "always"
yield_gate = None
improvement_gate = None
indicator_decorations = []
note = "strategic flint-exception→always"
else:
visibility = "tech_gated"
# Priority: STRATEGIC_MAP (canonical) → old revealed_by_tech → existing yield_gate.
lookup_key = deposit_id if deposit_id in STRATEGIC_MAP else concept_resource
if lookup_key and lookup_key in STRATEGIC_MAP:
yield_gate = STRATEGIC_MAP[lookup_key]
note = f"strategic via STRATEGIC_MAP[{lookup_key!r}]"
elif old_tech is not None:
yield_gate = old_tech
note = f"strategic tech_gated rbt={old_tech!r}"
else:
# Already migrated — recover from existing yield_gate
yield_gate = existing_yield_gate
note = f"strategic idempotent yg={yield_gate!r}"
if yield_gate is None:
print(
f" WARNING: strategic '{deposit_id}' has no yield_gate source"
" (not in STRATEGIC_MAP, no rbt, no existing yg) — defaulting to null.",
file=sys.stderr,
)
improvement_gate = yield_gate
indicator_decorations = _get_decorations(deposit_id, concept_resource, visibility)
elif category == "magic":
# Game-2 category. Canonical tech stored in MAGIC_MAP for idempotence.
# Entries without a tech gate default to always-visible.
tech = MAGIC_MAP.get(deposit_id) if deposit_id in MAGIC_MAP else (
old_tech if old_tech is not None else existing_yield_gate
)
if tech:
visibility = "tech_gated"
yield_gate = tech
improvement_gate = tech
note = f"magic tech_gated yg={tech!r}"
else:
visibility = "always"
yield_gate = None
improvement_gate = None
note = "magic→always (no tech)"
indicator_decorations = []
else:
# Unknown category — preserve old tech as yield_gate, default always
visibility = "always"
yield_gate = old_tech
improvement_gate = old_tech
indicator_decorations = []
note = f"UNKNOWN cat={category!r} fallback always"
print(
f" WARNING: unknown category '{category}' for '{deposit_id}'"
f" — defaulting to always-visible.",
file=sys.stderr,
)
entry["visibility"] = visibility
entry["yield_gate"] = yield_gate
entry["improvement_gate"] = improvement_gate
entry["indicator_decorations"] = indicator_decorations
return entry, note
def process_file(path: Path) -> dict:
"""Process one deposit JSON file. Returns stats: {migrated, skipped, changed}."""
raw = path.read_text(encoding="utf-8")
data: dict = json.loads(raw)
stats = {"migrated": 0, "changed": 0}
if "resources" in data and isinstance(data["resources"], list):
# Multi-resource file
new_resources: list[dict] = []
for entry in data["resources"]:
if not isinstance(entry, dict) or "category" not in entry:
new_resources.append(entry)
continue
new_entry, note = migrate_entry(entry)
new_resources.append(new_entry)
stats["migrated"] += 1
if new_entry != entry:
stats["changed"] += 1
print(f" {path.name}/{new_entry.get('id', '?')}: {note}")
data = dict(data)
data["resources"] = new_resources
elif "id" in data and "category" in data:
# Single-entry file
new_entry, note = migrate_entry(data)
data = new_entry
stats["migrated"] += 1
if new_entry != data:
stats["changed"] += 1
print(f" {path.name}: {note}")
else:
# Not a resource file (e.g. categories, schema) — skip
stats["skipped"] = 1
return stats
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return stats
def validate_file(path: Path) -> list[str]:
"""Return a list of validation error strings for a file."""
errors: list[str] = []
raw = path.read_text(encoding="utf-8")
data: dict = json.loads(raw)
if "resources" in data and isinstance(data["resources"], list):
entries = data["resources"]
file_label = path.name
elif "id" in data and "category" in data:
entries = [data]
file_label = path.name
else:
return errors # non-resource file
for entry in entries:
if not isinstance(entry, dict) or "category" not in entry:
continue
rid = entry.get("id", "<unknown>")
label = f"{file_label}/{rid}"
if "revealed_by_tech" in entry:
errors.append(f"{label}: revealed_by_tech still present")
if "visibility" not in entry:
errors.append(f"{label}: missing visibility")
if "yield_gate" not in entry:
errors.append(f"{label}: missing yield_gate")
if "improvement_gate" not in entry:
errors.append(f"{label}: missing improvement_gate")
if "indicator_decorations" not in entry:
errors.append(f"{label}: missing indicator_decorations")
vis = entry.get("visibility", "")
if vis not in ("always", "scout", "tech_gated"):
errors.append(f"{label}: invalid visibility '{vis}'")
return errors
def main() -> None:
total_migrated = 0
total_changed = 0
total_files = 0
all_errors: list[str] = []
print(f"Processing deposits in {DEPOSITS_DIR}")
for path in sorted(DEPOSITS_DIR.iterdir()):
if path.name in SKIP_FILES or not path.suffix == ".json":
continue
total_files += 1
stats = process_file(path)
total_migrated += stats.get("migrated", 0)
total_changed += stats.get("changed", 0)
errors = validate_file(path)
all_errors.extend(errors)
print()
print(f"Files processed : {total_files}")
print(f"Entries migrated: {total_migrated}")
print(f"Entries changed : {total_changed}")
if all_errors:
print("\nVALIDATION ERRORS:", file=sys.stderr)
for err in all_errors:
print(f" {err}", file=sys.stderr)
sys.exit(1)
else:
print("\nAll deposits validated successfully — zero revealed_by_tech remaining.")
if __name__ == "__main__":
main()