451 lines
18 KiB
Python
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()
|