feat(game-logistics): ✨ Add logistics chain schema fields, unit catalog support, and migration tool for Age of Dwarves game simulator
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8e822d49a2
commit
dd78566fca
4 changed files with 996 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
299
public/games/age-of-dwarves/data/techs/logistics_chain.json
Normal file
299
public/games/age-of-dwarves/data/techs/logistics_chain.json
Normal file
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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<UnitLogistics>` (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<UnitLogistics>,
|
||||
}
|
||||
|
||||
/// 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<BTreeMap<String, u16>>,
|
||||
/// 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<BTreeMap<String, i32>>,
|
||||
/// 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<BTreeMap<String, serde_json::Value>>,
|
||||
/// 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<SubsistenceStats>,
|
||||
/// 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<BTreeMap<String, UnitSlot>>,
|
||||
/// 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<BTreeMap<String, u16>>,
|
||||
/// 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<SupplyParams>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// Free-form capability description (e.g. `"+2 carry-capacity, fodder consumption"`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provides: Option<String>,
|
||||
/// 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<u16>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub decline_rate: Option<f32>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
|||
462
tools/migrate-units-logistics.py
Executable file
462
tools/migrate-units-logistics.py
Executable file
|
|
@ -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())
|
||||
Loading…
Add table
Reference in a new issue