diff --git a/public/games/age-of-dwarves/data/schemas/unit.schema.json b/public/games/age-of-dwarves/data/schemas/unit.schema.json index 2d4071d6..da841751 100644 --- a/public/games/age-of-dwarves/data/schemas/unit.schema.json +++ b/public/games/age-of-dwarves/data/schemas/unit.schema.json @@ -175,6 +175,74 @@ }, "encyclopedia": { "type": "object" + }, + "logistics": { + "type": "object", + "description": "Optional logistics block per UNIT_LOGISTICS.md. Populated by tools/migrate-units-logistics.py. All sub-fields optional.", + "additionalProperties": false, + "properties": { + "composition": { + "type": "object", + "description": "Team roster: role-name → headcount.", + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "inventory": { + "type": "object", + "description": "Resource carry capacity: rations, water, fodder, arrows, tool_durability, build_kits, powder_charges, medical_kit_uses, foot_runners, mount_couriers, crag_ravens, hold_falcons.", + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "terrain_movement": { + "type": "object", + "description": "Per-terrain movement cost: number (multiplier) or the string 'blocked'.", + "additionalProperties": { + "oneOf": [ + { "type": "number", "minimum": 0 }, + { "type": "string", "enum": ["blocked"] } + ] + } + }, + "stats": { + "type": "object", + "description": "Subsistence stats — STR/CON/END trained max.", + "additionalProperties": false, + "properties": { + "str": { "type": "integer", "minimum": 0, "maximum": 200 }, + "con": { "type": "integer", "minimum": 0, "maximum": 200 }, + "end": { "type": "integer", "minimum": 0, "maximum": 200 } + } + }, + "slots": { + "type": "object", + "description": "Modular tech×resource gated slots.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabling_tech": { "type": ["string", "null"] }, + "enabling_resources": { + "type": "array", + "items": { "type": "string" } + }, + "provides": { "type": "string" }, + "count": { "type": "integer", "minimum": 0 } + } + } + }, + "carriers": { + "type": "object", + "description": "Built-in courier complement (foot_runners, mount_couriers, crag_ravens, hold_falcons).", + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "supply": { + "type": "object", + "description": "Operational range (turns) + decline rate.", + "additionalProperties": false, + "properties": { + "range_turns": { "type": "integer", "minimum": 0 }, + "decline_rate": { "type": "number", "minimum": 0 } + } + } + } } }, "additionalProperties": true diff --git a/public/games/age-of-dwarves/data/techs/logistics_chain.json b/public/games/age-of-dwarves/data/techs/logistics_chain.json new file mode 100644 index 00000000..4d3e207c --- /dev/null +++ b/public/games/age-of-dwarves/data/techs/logistics_chain.json @@ -0,0 +1,299 @@ +[ + { + "id": "roads", + "name": "Roads", + "description": "Engineered packed-earth thoroughfares between holds. The first deliberate infrastructure. Wagon-passes wear in roads over time; this tech is what makes the wear-curve start.", + "pillar": "engineering", + "era": 2, + "tier": 2, + "cost": 30, + "requires": [], + "unlocks": { + "buildings": [], + "units": [], + "improvements": ["messenger_post"], + "mechanics": [ + { "key": "wear_in_enabled", "label": "Wagon-wear road progression enabled" } + ] + }, + "flavor": "The first road was a memory of where the cart went yesterday." + }, + { + "id": "wheelmaking", + "name": "Wheelmaking", + "description": "Wheels and wagon-axles. The prerequisite for ox-drawn supply trains and pioneer wagons.", + "pillar": "engineering", + "era": 2, + "tier": 2, + "cost": 30, + "requires": [], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "ox_wagon_slot_prerequisite", "label": "Enables ox_wagon slot lineage" } + ] + }, + "flavor": "Round, round, round. The dwarves spent three generations getting that part right." + }, + { + "id": "animal_training", + "name": "Animal Training", + "description": "Domestication of war-boars, mountain-rams, and the first trained ravens. Enables mount slots on cavalry-line units and officer-bird carriers on scouts and pioneers.", + "pillar": "ecology", + "era": 2, + "tier": 2, + "cost": 35, + "requires": ["animal_husbandry"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "slot_enable:mount", "label": "Enables mount slot (boars / mountain_rams)" } + ] + }, + "flavor": "A boar will follow a clan-warden the way a dog follows its master. The trick is convincing the boar." + }, + { + "id": "pastoralism", + "name": "Pastoralism", + "description": "Managed grazing land and mass herding. The economic backbone of cavalry-line units; required for ram and boar breeding programmes.", + "pillar": "ecology", + "era": 4, + "tier": 4, + "cost": 60, + "requires": ["animal_training"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "cavalry_economy", "label": "Mass cavalry production economy" } + ] + } + }, + { + "id": "pioneer_corps", + "name": "Pioneer Corps", + "description": "The first standing engineer-corps of fifteen dwarves with two ox-wagons, five foot-runners, and crag-ravens by default. Establishes outposts and blazes trails.", + "pillar": "engineering", + "era": 3, + "tier": 3, + "cost": 50, + "requires": ["wheelmaking", "roads"], + "unlocks": { + "buildings": [], + "units": ["pioneer"], + "improvements": [], + "mechanics": [ + { "key": "pioneer_line_tier_3", "label": "Unlocks Pioneer Team (T3)" } + ] + }, + "flavor": "Fifteen dwarves and two ox-wagons. The mountain pays attention now." + }, + { + "id": "colonial_charter", + "name": "Colonial Charter", + "description": "Legal and engineering framework for satellite city foundation by pioneers. Era-4 successor to the era-3 Pioneer Corps doctrine.", + "pillar": "civics", + "era": 4, + "tier": 4, + "cost": 70, + "requires": ["pioneer_corps"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "pioneer_line_tier_4", "label": "Pioneers can found satellite cities" } + ] + } + }, + { + "id": "falconry_command", + "name": "Falconry Command", + "description": "Trained crag-ravens (and from era 6, hold-falcons) carried by officer-tier units. Enables the officer_bird carrier slot and the crag_aerie improvement that breeds the birds.", + "pillar": "ecology", + "era": 3, + "tier": 3, + "cost": 55, + "requires": ["animal_training"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": ["crag_aerie"], + "mechanics": [ + { "key": "slot_enable:officer_bird", "label": "Enables officer_bird slot (crag_aerie required)" } + ] + }, + "flavor": "A raven carries no terrain on its wings. It carries only the message." + }, + { + "id": "proto_chemistry", + "name": "Proto-Chemistry", + "description": "Sulfur extraction from volcanic vents, the first systematic acid-and-base reactions. Required alongside saltpeter and coal for the gunpowder slot table.", + "pillar": "knowledge", + "era": 6, + "tier": 6, + "cost": 90, + "requires": ["alchemy"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "sulfur_visible", "label": "Reveals sulfur deposits" } + ] + } + }, + { + "id": "firearms", + "name": "Firearms", + "description": "Umbrella label for the breech-and-revolver bundle. In the JSON canon the slot is enabled when ANY of rifling, marksmanship, or automatic_fire is researched; this tech is the doc-level pointer that the slot exists.", + "pillar": "military", + "era": 7, + "tier": 7, + "cost": 100, + "requires": ["rifling"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "slot_enable:firearm", "label": "Enables firearm slot (umbrella over rifling/marksmanship/automatic_fire)" } + ] + }, + "flavor": "The dwarves named the new craft 'firearms' because nothing in the dwarven language carried the right edge of menace." + }, + { + "id": "steam_engine", + "name": "Steam Engine", + "description": "Pressurised-steam power. Replaces oxen and cattle in the supply line; opens the steam_engine slot for mechanised pioneer and supply units.", + "pillar": "engineering", + "era": 7, + "tier": 7, + "cost": 110, + "requires": ["industrial_construction"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": ["steam_track"], + "mechanics": [ + { "key": "slot_enable:steam_engine", "label": "Enables steam_engine slot (requires coal + iron)" } + ] + }, + "flavor": "When the first valve opened the rig screamed. The chief engineer wrote 'success' in his book and went to find another." + }, + { + "id": "industrial_construction", + "name": "Industrial Construction", + "description": "Mass-production scaffolding, riveted iron frames, the first true factory floor. Prerequisite for steam_engine and industrial-era improvements.", + "pillar": "engineering", + "era": 7, + "tier": 7, + "cost": 95, + "requires": ["civil_engineering"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [] + } + }, + { + "id": "signal_works", + "name": "Signal Works", + "description": "Industrial-era semaphore and telegraph chains. Era-7 backbone tech in the communications chain (roads → signal_works → rune_resonance/radio_basics).", + "pillar": "engineering", + "era": 7, + "tier": 7, + "cost": 100, + "requires": ["industrial_construction"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "comms_tier_7", "label": "Comm-tier 7 (vision_share_latency 1)" } + ] + } + }, + { + "id": "radio_basics", + "name": "Radio Basics", + "description": "Spark-gap transmitters and crystal receivers. The mundane parallel to rune_resonance — same effective comm tier, different lineage. Enables radio_set slot.", + "pillar": "engineering", + "era": 8, + "tier": 8, + "cost": 130, + "requires": ["signal_works"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "slot_enable:radio_set", "label": "Enables radio_set slot (requires copper + coal)" }, + { "key": "comms_tier_8", "label": "Comm-tier 8 (vision_share_latency 0)" } + ] + }, + "flavor": "Two parallel craft, born the same decade in different holds. The runes hummed; the wires sparked." + }, + { + "id": "deep_mantle_engineering", + "name": "Deep Mantle Engineering", + "description": "Apex-tier excavation of the planetary mantle. Opens the Mantle-Choir improvement, the mantle_drill slot, and the era-10 supply backbone (instant cargo across mantle conduits).", + "pillar": "engineering", + "era": 10, + "tier": 10, + "cost": 250, + "requires": ["industrial_construction"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "slot_enable:mantle_drill", "label": "Enables mantle_drill slot (requires adamantine + fusion_cell)" }, + { "key": "comms_tier_10", "label": "Comm-tier 10 (instant mantle-conduit cargo)" } + ] + } + }, + { + "id": "fusion_theory", + "name": "Fusion Theory", + "description": "Controlled fusion of light nuclei. Provides the fusion_cell resource (manufactured, not mined) consumed by mantle_drill and apex weapons.", + "pillar": "knowledge", + "era": 10, + "tier": 10, + "cost": 240, + "requires": ["atomic_theory"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "resource_enable:fusion_cell", "label": "Enables fusion_cell manufacturing" } + ] + } + }, + { + "id": "metallurgy", + "name": "Metallurgy", + "description": "Systematic study of metals beyond the founding bronze/iron chain. Bridges era-3 iron_smelting to era-5 metallurgy_advance; canonical yield_gate for coal in the resource-visibility schema.", + "pillar": "metallurgy", + "era": 3, + "tier": 3, + "cost": 45, + "requires": ["iron_working"], + "unlocks": { + "buildings": [], + "units": [], + "improvements": [], + "mechanics": [ + { "key": "resource_visible:coal", "label": "Reveals coal seams" }, + { "key": "resource_visible:obsidian_glass", "label": "Enables obsidian-glass extraction" } + ] + } + } +] diff --git a/src/simulator/crates/mc-units/src/catalog.rs b/src/simulator/crates/mc-units/src/catalog.rs index 81d315f7..f58ed705 100644 --- a/src/simulator/crates/mc-units/src/catalog.rs +++ b/src/simulator/crates/mc-units/src/catalog.rs @@ -2,14 +2,24 @@ //! `public/resources/units/*.json`. //! //! Today this carries only the fields the bench simulator + Phase-9 move -//! subsystem need (`base_moves`, `domain`). New fields should be added as -//! the consuming Rust paths grow, not preemptively — `mc-city::UnitDef` -//! already owns production economics, and `mc-units` owns runtime stats. +//! subsystem need (`base_moves`, `domain`), the p2-55 ransom fields, the +//! p3-11 specialist AP capacity, and (as of Phase 5 schema migration) an +//! optional `logistics` block mirroring the UNIT_LOGISTICS.md design doc +//! — composition, inventory, subsistence stats, per-terrain movement, +//! supply parameters, modular slots, and carriers. Every logistics field +//! is `Option<...>` with `#[serde(default)]`; existing unit JSON files +//! deserialise unchanged. +//! +//! New consumers of the logistics block live in `mc-pathfinding` +//! (`terrain_movement`) and the forthcoming subsistence/wear-in modules. +//! Until those consumers come online the fields are passthrough — held +//! on `UnitStats` so the catalog round-trips them, not interpreted. //! //! JSON wire fields: //! - `id: String` //! - `movement: i32` (deserialised here as `base_moves`) //! - `domain: String` (`"land"`, `"naval"`, `"flying"`) +//! - `logistics: Option` (see [`UnitLogistics`]) use std::collections::BTreeMap; @@ -49,6 +59,107 @@ pub struct UnitStats { /// JSON wire key is `"cost"`. #[serde(default, rename = "cost")] pub build_cost: u32, + /// Phase 5 schema migration — optional logistics block mirroring + /// `public/games/age-of-dwarves/docs/UNIT_LOGISTICS.md`. `None` for + /// any unit JSON that hasn't been migrated yet; the migration tool + /// `tools/migrate-units-logistics.py` populates this from existing + /// stats. Held passthrough until pathfinding/subsistence consumers + /// come online. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logistics: Option, +} + +/// Optional logistics block per UNIT_LOGISTICS.md. Every field is itself +/// optional so partial migrations are valid and existing files round-trip +/// without growing the block. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct UnitLogistics { + /// Composition — the realistic team roster (counts by role). + /// e.g. `{ "foreman": 1, "labourer": 6, "foot_runner": 2 }`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub composition: Option>, + /// Inventory — resource carry capacity by field name (rations, fodder, + /// water, arrows, tool_durability, build_kits, powder_charges, + /// medical_kit_uses, foot_runners, mount_couriers, crag_ravens, + /// hold_falcons). All integer; floored at 0. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inventory: Option>, + /// Per-terrain movement cost table. Values are multipliers (1.0 = base); + /// the special value `"blocked"` is encoded as the string `"blocked"` + /// inside a `serde_json::Value` to keep the "number-or-blocked" shape + /// from UNIT_LOGISTICS.md §"Per-terrain movement costs". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terrain_movement: Option>, + /// Subsistence stat block — Strength / Constitution / Endurance, + /// trained max per the design doc. Decline curves are class-level + /// data in `combat_balance.json`, not per-unit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stats: Option, + /// Modular slots — keyed by slot id (`ox_wagon`, `mount`, `officer_bird`, + /// `powder_charges`, `firearm`, `steam_engine`, `rune_panel`, + /// `radio_set`, `coilgun`, `mantle_drill`, `hold_falcon`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slots: Option>, + /// Carrier complement — the round-trip / one-way couriers built in at + /// training time, before slot resolution. Same map shape as + /// `inventory` but semantically distinct. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub carriers: Option>, + /// Supply parameters — `range_turns` (operational radius in turns from + /// nearest friendly city) and `decline_rate` (stats lost per turn + /// outside that radius on good terrain). Class-level overrides live + /// in `combat_balance.json`; this block is per-unit fine-tuning. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supply: Option, +} + +/// Subsistence stats — STR/CON/END trained max per UNIT_LOGISTICS.md §"Stats and subsistence". +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct SubsistenceStats { + #[serde(default)] + pub str: u16, + #[serde(default)] + pub con: u16, + #[serde(default)] + pub end: u16, +} + +/// A modular slot — tech + resource(s) that enable it, and the carrier +/// or capability it provides when filled. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct UnitSlot { + /// Tech id that must be researched for the slot to be enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabling_tech: Option, + /// Resource id(s) the producing city must have access to. Multiple + /// resources are treated as AND (all required) — match the doc's + /// `saltpeter + sulfur + coal` for `powder_charges`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub enabling_resources: Vec, + /// Free-form capability description (e.g. `"+2 carry-capacity, fodder consumption"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provides: Option, + /// Count granted when the slot is filled (default 1; e.g. 2 ox-wagons, + /// 40 powder-charges). + #[serde(default = "default_slot_count", skip_serializing_if = "is_one_u16")] + pub count: u16, +} + +fn default_slot_count() -> u16 { + 1 +} +fn is_one_u16(n: &u16) -> bool { + *n == 1 +} + +/// Per-unit supply parameters. Falls back to class defaults in +/// `combat_balance.json` when omitted. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct SupplyParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range_turns: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decline_rate: Option, } fn default_ransom_multiplier() -> f32 { @@ -158,6 +269,7 @@ mod tests { capturable: false, ransom_multiplier: 2.0, build_cost: 0, + logistics: None, }); assert_eq!(cat.len(), 1); assert_eq!(cat.get("dwarf_warrior").unwrap().base_moves, 2); @@ -196,6 +308,58 @@ mod tests { ); } + #[test] + fn logistics_block_round_trips() { + let raw = r#" + [ + { + "id": "pioneer_team", + "movement": 2, + "domain": "land", + "logistics": { + "composition": { "team_lead": 1, "surveyor": 4, "labourer": 6, "warden": 2, "ox_handler": 2 }, + "inventory": { "rations": 60, "fodder": 30, "build_kits": 12 }, + "terrain_movement": { "grass": 1.0, "mountain": 3.0, "river_unfordable": "blocked" }, + "stats": { "str": 70, "con": 80, "end": 75 }, + "slots": { + "ox_wagon": { "enabling_tech": "animal_husbandry", "enabling_resources": ["cattle"], "count": 2 }, + "powder_charges": { "enabling_tech": "gunpowder", "enabling_resources": ["saltpeter", "sulfur", "coal"], "count": 40 } + }, + "carriers": { "foot_runners": 5, "crag_ravens": 2 }, + "supply": { "range_turns": 6, "decline_rate": 0.8 } + } + } + ]"#; + let mut cat = UnitsCatalog::new(); + let n = cat.load_json_str(raw).expect("parse"); + assert_eq!(n, 1); + let pt = cat.get("pioneer_team").expect("present"); + let log = pt.logistics.as_ref().expect("logistics block present"); + assert_eq!(log.composition.as_ref().unwrap().get("labourer"), Some(&6)); + assert_eq!(log.inventory.as_ref().unwrap().get("rations"), Some(&60)); + assert_eq!( + log.terrain_movement.as_ref().unwrap().get("river_unfordable").unwrap(), + &serde_json::Value::String("blocked".to_string()) + ); + let stats = log.stats.as_ref().unwrap(); + assert_eq!((stats.str, stats.con, stats.end), (70, 80, 75)); + let ox = log.slots.as_ref().unwrap().get("ox_wagon").unwrap(); + assert_eq!(ox.enabling_tech.as_deref(), Some("animal_husbandry")); + assert_eq!(ox.enabling_resources, vec!["cattle".to_string()]); + assert_eq!(ox.count, 2); + assert_eq!(log.carriers.as_ref().unwrap().get("crag_ravens"), Some(&2)); + assert_eq!(log.supply.as_ref().unwrap().range_turns, Some(6)); + } + + #[test] + fn logistics_block_absent_deserialises_to_none() { + // Existing unit JSON without a logistics block must still load. + let raw = r#"{"id": "warrior", "movement": 2, "domain": "land", "hp": 80, "attack": 14}"#; + let mut cat = UnitsCatalog::new(); + cat.load_json_str(raw).expect("parse"); + assert!(cat.get("warrior").unwrap().logistics.is_none()); + } + #[test] fn unknown_top_level_returns_zero() { let mut cat = UnitsCatalog::new(); diff --git a/tools/migrate-units-logistics.py b/tools/migrate-units-logistics.py new file mode 100755 index 00000000..30c44def --- /dev/null +++ b/tools/migrate-units-logistics.py @@ -0,0 +1,462 @@ +#!/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())