magicciv/tools/migrate-units-logistics.py
2026-05-26 02:21:14 -07:00

462 lines
16 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Migrate public/resources/units/*.json with a logistics block per
UNIT_LOGISTICS.md.
The migration is **additive**. Every existing field is preserved verbatim;
each unit gains an optional ``"logistics"`` block populated from a small
mapping table that derives sensible defaults from the unit's current
stats. Files that already carry a ``"logistics"`` block are left untouched
(idempotent — re-running the tool is a no-op).
Mapping rules (also documented in UNIT_LOGISTICS.md):
- ``stats.str = round(attack * 5)`` (capped at 100; floor 10 for civilian)
- ``stats.con = round(hp / 0.6)`` (HP is already 60% of trained max in the
doc's model; so trained max ≈ hp / 0.6)
- ``stats.end = round(movement * 30)`` (move 2 ≈ 60, move 3 ≈ 90)
- ``terrain_movement`` defaults from archetype:
- civilian, support, melee, ranged → ``foot`` table
- cavalry, mounted → ``mounted`` table
- siege → ``siege`` table
- wild → ``foot`` (creatures inherit foot baseline)
- naval / sea / air domains → ``mobile`` (water/air-friendly)
- ``inventory`` defaults from archetype + tier:
- rations = headcount × 10 (headcount estimated from hp / 5)
- tool_durability = 5 for civilians, 10 for siege/engineer, 2 otherwise
- build_kits = 4 for ``can_build_improvements`` civilians, else 0
- arrows = 30 if ``ranged_attack > 0`` else 0
- ``carriers`` defaults: 2 foot_runners for any unit; no birds (slots gate them).
- ``slots`` are seeded empty per unit; the canonical (tech, resource) gates
live in TECH_TREE.md §Slot-enable table and are applied at training time
by the simulator, not stored on the unit catalog entry.
- ``supply.range_turns`` default 4 (civilian 6, siege 3, cavalry 5).
Usage:
python3 tools/migrate-units-logistics.py [--dry-run] [--force]
``--dry-run`` prints what would change without writing.
``--force`` overwrites an existing ``logistics`` block. Default is
idempotent (skip already-migrated files).
Run from any directory — resolves paths from the script location.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
UNITS_DIR = REPO_ROOT / "public" / "resources" / "units"
FOOT_TERRAIN: dict[str, Any] = {
"road": 0.5,
"dirt_road": 0.65,
"wagon_track": 0.85,
"trail": 0.8,
"grass": 1.0,
"hills": 1.5,
"forest_open": 1.5,
"forest_dense": 2.5,
"mountain": 3.0,
"snow": 2.0,
"tundra": 1.5,
"desert": 2.5,
"marsh": 3.0,
"river_ford": 2.0,
"river_unfordable": "blocked",
"deep_water": "blocked",
}
MOUNTED_TERRAIN: dict[str, Any] = {
"road": 0.4,
"dirt_road": 0.55,
"wagon_track": 0.75,
"trail": 0.9,
"grass": 0.75,
"hills": 1.5,
"forest_open": 2.0,
"forest_dense": 3.5,
"mountain": 4.0,
"snow": 2.5,
"tundra": 1.5,
"desert": 2.0,
"marsh": "blocked",
"river_ford": 2.0,
"river_unfordable": "blocked",
"deep_water": "blocked",
}
SIEGE_TERRAIN: dict[str, Any] = {
"road": 0.65,
"dirt_road": 0.85,
"wagon_track": 1.0,
"trail": 1.3,
"grass": 1.5,
"hills": 2.5,
"forest_open": 2.5,
"forest_dense": "blocked",
"mountain": 4.0,
"snow": 3.0,
"tundra": 2.0,
"desert": 3.0,
"marsh": "blocked",
"river_ford": 3.0,
"river_unfordable": "blocked",
"deep_water": "blocked",
}
MOBILE_TERRAIN: dict[str, Any] = {
"deep_water": 1.0,
"shallow_water": 1.0,
"coastal": 1.0,
"river_ford": 1.0,
"river_unfordable": 1.0,
"grass": "blocked",
}
CAVALRY_KEYWORDS: set[str] = {
"cavalry", "mounted", "rider", "lancer", "horseman", "dragoon",
}
SIEGE_KEYWORDS: set[str] = {
"siege", "catapult", "ballista", "trebuchet", "bombard", "artillery",
"cannon", "mortar", "rocket", "coilgun",
}
ENGINEER_KEYWORDS: set[str] = {
"engineer", "pioneer", "builder", "sapper", "founder",
}
def pick_terrain_table(unit: dict[str, Any]) -> dict[str, Any]:
"""Choose the per-terrain movement table from archetype + keywords."""
domain = unit.get("domain") or "land"
if domain in {"sea", "naval"}:
return dict(MOBILE_TERRAIN)
if domain == "air":
# Air units ignore terrain — express as a flat 1.0 for the
# land-typed entries; the pathfinder honours the `air` domain
# gate ahead of the cost table.
return {k: 1.0 for k in FOOT_TERRAIN}
kws = {k.lower() for k in unit.get("keywords", [])}
if kws & CAVALRY_KEYWORDS:
return dict(MOUNTED_TERRAIN)
if kws & SIEGE_KEYWORDS or unit.get("unit_type") == "siege":
return dict(SIEGE_TERRAIN)
return dict(FOOT_TERRAIN)
def estimate_headcount(unit: dict[str, Any]) -> int:
"""Estimate roster headcount from hp. UNIT_LOGISTICS.md compositions
range 530; hp 20150 maps roughly to that with a /5 ratio."""
hp = unit.get("hp") or 20
return max(5, min(30, round(hp / 5)))
def build_inventory(unit: dict[str, Any], headcount: int) -> dict[str, int]:
"""Default carry-inventory by archetype."""
inv: dict[str, int] = {"rations": headcount * 10, "water": headcount * 8}
kws = {k.lower() for k in unit.get("keywords", [])}
if kws & ENGINEER_KEYWORDS or unit.get("can_build_improvements") is True:
inv["tool_durability"] = 20
inv["build_kits"] = 4
elif kws & SIEGE_KEYWORDS or unit.get("unit_type") == "siege":
inv["tool_durability"] = 10
else:
inv["tool_durability"] = 2
if (unit.get("ranged_attack") or 0) > 0:
inv["arrows"] = 30
if kws & CAVALRY_KEYWORDS:
inv["fodder"] = headcount * 6
return inv
def build_stats(unit: dict[str, Any]) -> dict[str, int]:
"""Derive STR/CON/END trained-max from existing attack/hp/movement."""
atk = unit.get("attack") or 0
hp = unit.get("hp") or 20
mv = unit.get("movement") or 2
# Civilians have low STR but the same baseline CON/END as combat units.
base_str = max(10, min(100, round(atk * 5))) if atk > 0 else 30
base_con = max(20, min(100, round(hp / 0.6)))
base_end = max(30, min(100, round(mv * 30)))
return {"str": base_str, "con": base_con, "end": base_end}
def build_carriers(unit: dict[str, Any]) -> dict[str, int]:
"""Default carrier complement. Slot-gated carriers (birds, oxen,
steam-rigs) are populated only when the slot is filled at training
time by the simulator; the unit's BASE carriers are the small
foot-runner complement every roster ships with."""
if unit.get("unit_type") in {"wild", "npc"}:
# Hostile / neutral units don't carry comms — the player can't
# send a message via a wandering basilisk.
return {}
return {"foot_runners": 2}
def build_supply(unit: dict[str, Any]) -> dict[str, Any]:
"""Default supply range + decline rate by archetype."""
kws = {k.lower() for k in unit.get("keywords", [])}
if unit.get("unit_type") in {"civilian", "support"} or kws & ENGINEER_KEYWORDS:
return {"range_turns": 6, "decline_rate": 0.8}
if kws & CAVALRY_KEYWORDS:
return {"range_turns": 5, "decline_rate": 2.5}
if kws & SIEGE_KEYWORDS or unit.get("unit_type") == "siege":
return {"range_turns": 3, "decline_rate": 1.0}
return {"range_turns": 4, "decline_rate": 1.2}
# Slot definitions for the docs — these are canonical (tech, resources)
# pairs from UNIT_LOGISTICS.md §Modular slots. Per-unit, only slots that
# semantically apply are exposed in the json. The migration is
# conservative — civilian/pioneer line gets the `ox_wagon` + `mount` slots,
# cavalry gets `mount`, ranged gets `firearm` (gated by tech), engineers
# get `powder_charges`. The simulator's training-time resolver looks
# at the tech-resource pair before populating the slot at production.
SLOT_TABLE: dict[str, dict[str, Any]] = {
"ox_wagon": {
"enabling_tech": "animal_husbandry",
"enabling_resources": ["cattle"],
"provides": "+2 carry-capacity, fodder consumption",
"count": 1,
},
"mount_boar": {
"enabling_tech": "animal_training",
"enabling_resources": ["boars"],
"provides": "mounted movement profile",
"count": 1,
},
"mount_ram": {
"enabling_tech": "animal_training",
"enabling_resources": ["mountain_rams"],
"provides": "mountain-friendly mounted profile",
"count": 1,
},
"officer_bird": {
"enabling_tech": "falconry_command",
"enabling_resources": ["crag_aerie"],
"provides": "crag-raven one-way carrier",
"count": 2,
},
"powder_charges": {
"enabling_tech": "gunpowder",
"enabling_resources": ["saltpeter", "sulfur", "coal"],
"provides": "blasting kit",
"count": 40,
},
"firearm": {
"enabling_tech": "rifling",
"enabling_resources": ["iron", "coal"],
"provides": "pierce-attack ranged",
"count": 1,
},
"steam_engine": {
"enabling_tech": "steam_engine",
"enabling_resources": ["coal", "iron"],
"provides": "mechanized hauling",
"count": 1,
},
"rune_panel": {
"enabling_tech": "rune_resonance",
"enabling_resources": ["runestone"],
"provides": "resonance-telegraph",
"count": 1,
},
}
def applicable_slots(unit: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Pick the slots that semantically apply to this unit."""
kws = {k.lower() for k in unit.get("keywords", [])}
tier = unit.get("tier") or 0
slots: dict[str, dict[str, Any]] = {}
is_engineer = bool(kws & ENGINEER_KEYWORDS) or unit.get("can_build_improvements") is True
is_cavalry = bool(kws & CAVALRY_KEYWORDS)
is_ranged = (unit.get("ranged_attack") or 0) > 0
is_civilian = unit.get("unit_type") in {"civilian", "support"}
# Pioneer / Builder / Engineer line carries ox-wagons.
if is_engineer or is_civilian:
slots["ox_wagon"] = dict(SLOT_TABLE["ox_wagon"])
slots["officer_bird"] = dict(SLOT_TABLE["officer_bird"])
# Pioneer T5+ also exposes the powder-blasting slot.
if is_engineer and tier and tier >= 5:
slots["powder_charges"] = dict(SLOT_TABLE["powder_charges"])
# Steam Pioneer (T6/T7) opens the steam slot.
if tier >= 6:
slots["steam_engine"] = dict(SLOT_TABLE["steam_engine"])
# Resonance Engineer (T7+) opens the rune-panel.
if tier >= 7:
slots["rune_panel"] = dict(SLOT_TABLE["rune_panel"])
# Cavalry line — mount slot is the defining gate.
if is_cavalry:
slots["mount_boar"] = dict(SLOT_TABLE["mount_boar"])
slots["mount_ram"] = dict(SLOT_TABLE["mount_ram"])
slots["officer_bird"] = dict(SLOT_TABLE["officer_bird"])
# Ranged combatants opt into the firearm slot from gunpowder onward.
if is_ranged and tier and tier >= 5:
slots["firearm"] = dict(SLOT_TABLE["firearm"])
slots["powder_charges"] = dict(SLOT_TABLE["powder_charges"])
return slots
def build_composition(unit: dict[str, Any], headcount: int) -> dict[str, int]:
"""Sketch a default composition by archetype. The breakdowns mirror
the named compositions in UNIT_LOGISTICS.md where the unit matches a
canonical archetype, and fall back to a generic 'leader + members'
split otherwise."""
kws = {k.lower() for k in unit.get("keywords", [])}
if "founder" in kws:
return {"clan_elder": 1, "builder": 4, "warden": 1, "foot_runner": 2}
if kws & ENGINEER_KEYWORDS:
return {
"foreman": 1,
"surveyor": max(1, headcount // 4),
"labourer": max(1, headcount // 2),
"warden": max(1, headcount // 6),
"foot_runner": 2,
}
if kws & CAVALRY_KEYWORDS:
return {
"captain": 1,
"rider": max(1, headcount - 4),
"farrier": 2,
"cook": 1,
}
if kws & SIEGE_KEYWORDS or unit.get("unit_type") == "siege":
return {
"crew_master": 1,
"engineer": max(1, headcount // 3),
"labourer": max(1, headcount // 2),
"warden": max(1, headcount // 6),
}
if unit.get("unit_type") in {"melee", "military"}:
return {
"captain": 1,
"sergeant": max(1, headcount // 10),
"soldier": max(1, headcount - 3),
"horn_blower": 1,
}
if unit.get("unit_type") == "ranged":
return {
"captain": 1,
"marksman": max(1, headcount - 2),
"spotter": 1,
}
# Catch-all: leader + members.
return {"leader": 1, "member": max(1, headcount - 1)}
def build_logistics_block(unit: dict[str, Any]) -> dict[str, Any]:
"""Assemble the full logistics block for one unit."""
headcount = estimate_headcount(unit)
block: dict[str, Any] = {
"composition": build_composition(unit, headcount),
"inventory": build_inventory(unit, headcount),
"stats": build_stats(unit),
"terrain_movement": pick_terrain_table(unit),
"carriers": build_carriers(unit),
"supply": build_supply(unit),
}
slots = applicable_slots(unit)
if slots:
block["slots"] = slots
return block
def migrate_file(path: Path, force: bool) -> tuple[str, dict[str, Any] | list[dict[str, Any]] | None]:
"""Return (status, new-doc-or-none).
Status is one of: 'migrated', 'skipped-existing', 'skipped-unparseable',
'skipped-not-a-unit'."""
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
return f"skipped-read-error:{exc}", None
try:
doc = json.loads(raw)
except json.JSONDecodeError:
return "skipped-unparseable", None
def migrate_entry(entry: Any) -> tuple[bool, dict[str, Any] | Any]:
if not isinstance(entry, dict) or "id" not in entry:
return False, entry
if "logistics" in entry and not force:
return False, entry
block = build_logistics_block(entry)
new_entry = dict(entry)
new_entry["logistics"] = block
return True, new_entry
if isinstance(doc, list):
any_changed = False
new_entries: list[Any] = []
for entry in doc:
changed, new_entry = migrate_entry(entry)
any_changed = any_changed or changed
new_entries.append(new_entry)
if not any_changed:
return "skipped-existing", None
return "migrated", new_entries
if isinstance(doc, dict):
changed, new_doc = migrate_entry(doc)
if not changed:
return "skipped-existing", None
return "migrated", new_doc
return "skipped-not-a-unit", None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
if not UNITS_DIR.is_dir():
print(f"ERROR: units dir not found at {UNITS_DIR}", file=sys.stderr)
return 1
files = sorted(UNITS_DIR.glob("*.json"))
if not files:
print(f"ERROR: no JSON files in {UNITS_DIR}", file=sys.stderr)
return 1
migrated = 0
skipped = 0
other: dict[str, int] = {}
for path in files:
status, new_doc = migrate_file(path, args.force)
if status == "migrated":
migrated += 1
if not args.dry_run:
path.write_text(
json.dumps(new_doc, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
elif status == "skipped-existing":
skipped += 1
else:
other[status] = other.get(status, 0) + 1
print(f"migrated: {migrated}")
print(f"skipped (existing): {skipped}")
for status, count in sorted(other.items()):
print(f"{status:20s}: {count}")
if args.dry_run:
print("(dry-run — no files written)")
return 0
if __name__ == "__main__":
sys.exit(main())