463 lines
16 KiB
Python
463 lines
16 KiB
Python
|
|
#!/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 5–30; hp 20–150 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())
|