magicciv/tools/reclassify-units.py
Natalie cdaaefb280 feat(@projects): add gdrust map page
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-30 10:46:34 -04:00

131 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""
Reclassify unit_type and assign faction for all units in
public/resources/units/*.json.
Schema target after this pass:
- unit_type ∈ {melee, ranged, siege, support, civilian, summoned} (drops naval/air; those live in `domain`)
- faction ∈ {player, wild, freepeople, summoned} (new orthogonal axis)
Rules (deterministic, applied top-down):
1. unit_type=='support' → keep support
2. unit_type in {'wild','npc'} → see faction logic; classify combat role from kw/range
3. has 'siege' keyword → siege
4. range >= 1 OR 'ranged' kw → ranged
5. otherwise → melee
Faction (in-game allegiance, orthogonal to race_required):
- existing 'faction' field wins (e.g. dwarf_wanderer already has freepeople)
- 'wild' keyword OR was unit_type=='wild' → wild
- was unit_type=='npc' → freepeople (sole current case is dwarf_wanderer)
- else → dwarf (Game 1: only player-controlled race)
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import Counter
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
UNITS_DIR = REPO / "public" / "resources" / "units"
def classify(unit: dict) -> tuple[str, str]:
kw = unit.get("keywords") or []
rng = unit.get("range") or 0
old_type = unit.get("unit_type") or ""
# faction
faction = unit.get("faction")
if not faction:
if "wild" in kw or old_type == "wild":
faction = "wild"
elif old_type == "npc":
faction = "freepeople"
else:
faction = "dwarf"
# unit_type — combat role
if old_type == "support":
new_type = "support"
elif old_type == "siege" or "siege" in kw:
# Preserve any unit already authored as siege (some lack the keyword,
# e.g. dwarf_steam_bomber / dwarf_war_zeppelin / dwarf_sky_fortress).
new_type = "siege"
elif rng >= 1 or "ranged" in kw:
new_type = "ranged"
elif (unit.get("attack") or 0) == 0 and rng == 0 and old_type != "wild":
# Non-combatants without an explicit support type → classify as support
new_type = "support"
else:
new_type = "melee"
return new_type, faction
def process(path: Path, *, dry_run: bool) -> list[tuple[str, str, str, str, str]]:
"""Returns list of (id, old_type, new_type, old_faction, new_faction) for changed entries."""
data = json.loads(path.read_text())
items = data if isinstance(data, list) else [data]
if not items or not isinstance(items[0], dict) or not items[0].get("id"):
return []
changed = []
for it in items:
if not it.get("id"):
continue
old_type = it.get("unit_type") or ""
old_faction = it.get("faction") or ""
new_type, new_faction = classify(it)
if new_type != old_type or new_faction != old_faction:
changed.append((it["id"], old_type, new_type, old_faction, new_faction))
it["unit_type"] = new_type
it["faction"] = new_faction
if changed and not dry_run:
path.write_text(json.dumps(items if isinstance(data, list) else items[0], indent=2) + "\n")
return changed
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="write changes (default: dry run)")
args = ap.parse_args()
if not UNITS_DIR.is_dir():
print(f"missing dir: {UNITS_DIR}", file=sys.stderr)
return 2
type_changes: Counter[tuple[str, str]] = Counter()
faction_changes: Counter[tuple[str, str]] = Counter()
all_changes = []
for fp in sorted(UNITS_DIR.glob("*.json")):
if fp.name.endswith(".schema.json"):
continue
for ch in process(fp, dry_run=not args.apply):
all_changes.append((fp.name, *ch))
type_changes[(ch[1], ch[2])] += 1
faction_changes[(ch[3], ch[4])] += 1
print(f"{'APPLIED' if args.apply else 'DRY RUN'}: {len(all_changes)} unit(s) changed\n")
print("=== unit_type transitions ===")
for (a, b), n in sorted(type_changes.items(), key=lambda x: -x[1]):
print(f" {a or '(none)':10s}{b:10s} {n}")
print("\n=== faction transitions ===")
for (a, b), n in sorted(faction_changes.items(), key=lambda x: -x[1]):
print(f" {a or '(none)':10s}{b:10s} {n}")
print("\n=== first 20 row-level changes ===")
for row in all_changes[:20]:
fn, uid, ot, nt, of, nf = row
print(f" {uid:30s} type {ot or '-':8s}->{nt:8s} faction {of or '-':10s}->{nf}")
if len(all_changes) > 20:
print(f" ... and {len(all_changes) - 20} more")
return 0
if __name__ == "__main__":
sys.exit(main())