From ba81bcf476770852d226240831892142afc4d1bc Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 25 Jun 2026 05:56:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9B=B5=20p3-18=20P4a=20=E2=80=94=20transport=20data=20model?= =?UTF-8?q?=20(keywords=20+=20carrier=5Fid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the transport mechanic (owner: build it): - catalog UnitStats gains `keywords: Vec` (authored on units/.json), + `is_transport()` (keywords.contains("transport")) and a TRANSPORT_CAPACITY=2 constant mirroring the combat.json transport keyword ("carry up to 2 land units"). Data-driven — no unit id hardcoded. - MapUnit gains `carrier_id: Option` — the hull carrying this land unit (None = on map normally). A carried unit rides the carrier's hex, isn't independently attackable, and is lost with the carrier. Test: transport_keyword_detected_data_driven; mc-units lib green. Mechanic (board/carry/unload + combat-loss) lands in P4b/P4c. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../crates/mc-state/src/game_state.rs | 8 ++++ src/simulator/crates/mc-units/src/catalog.rs | 40 +++++++++++++++++++ src/simulator/crates/mc-units/src/lib.rs | 2 +- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index 8d4dfd80..af80c4e1 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -1170,6 +1170,14 @@ pub struct MapUnit { /// embark action. #[serde(default)] pub is_embarked: bool, + /// p3-18 transport — the `id` of the transport hull currently carrying this + /// land unit, or `None` when it is on the map normally. A carried unit rides + /// the transport's hex (its position mirrors the carrier), is not + /// independently attackable, and is lost if the carrier is destroyed. Set by + /// boarding a transport (move onto its hex), cleared by disembarking (move to + /// adjacent land). See `mc-turn::process_one_move`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub carrier_id: Option, /// Rally command to execute when this unit first reaches its rally hex. /// Set at spawn; cleared by `apply_rally_arrival_actions` after firing once. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/simulator/crates/mc-units/src/catalog.rs b/src/simulator/crates/mc-units/src/catalog.rs index 89c6d21e..a2301b25 100644 --- a/src/simulator/crates/mc-units/src/catalog.rs +++ b/src/simulator/crates/mc-units/src/catalog.rs @@ -79,8 +79,29 @@ pub struct UnitStats { /// come online. #[serde(default, skip_serializing_if = "Option::is_none")] pub logistics: Option, + /// Gameplay keywords authored on `units/.json` (e.g. `"transport"`, + /// `"amphibious"`, `"ranged"`). Surfaced so the runtime can gate + /// keyword-driven mechanics (p3-18 transport: `keywords.contains("transport")` + /// marks a hull that can carry land units) without hardcoding unit ids. + #[serde(default)] + pub keywords: Vec, } +impl UnitStats { + /// p3-18 — whether this unit can carry land units across water (the + /// `transport` keyword, e.g. `dwarf_fortress_ship`). Data-driven: no unit id + /// is hardcoded. + pub fn is_transport(&self) -> bool { + self.keywords.iter().any(|k| k == "transport") + } +} + +/// p3-18 — how many land units a `transport`-keyword hull can carry, per the +/// `transport` keyword definition in `combat.json` ("Can carry up to 2 land +/// units across water"). A named constant rather than a magic literal; if a +/// per-unit capacity is ever authored it supersedes this. +pub const TRANSPORT_CAPACITY: usize = 2; + /// Base combat stats authored directly on `units/.json`. Mirrors the /// `mc_combat::resolver::UnitStats` channels that `MapUnit` carries /// (`hp`/`max_hp`/`attack`/`defense`/`ranged_attack`/`range`). `max_hp` @@ -341,6 +362,7 @@ mod tests { ransom_multiplier: 2.0, build_cost: 0, logistics: None, + keywords: Vec::new(), combat: CombatStats::default(), }); assert_eq!(cat.len(), 1); @@ -438,4 +460,22 @@ mod tests { let n = cat.load_json_str("42").expect("parse"); assert_eq!(n, 0); } + + #[test] + fn transport_keyword_detected_data_driven() { + let raw = r#"[ + {"id":"barge","movement":4,"domain":"naval","keywords":["transport"]}, + {"id":"warrior","movement":2,"domain":"land","keywords":["melee"]}, + {"id":"founder","movement":2,"domain":"land"} + ]"#; + let mut cat = UnitsCatalog::new(); + cat.load_json_str(raw).expect("parse"); + assert!(cat.get("barge").unwrap().is_transport(), "transport keyword → is_transport"); + assert!(!cat.get("warrior").unwrap().is_transport()); + assert!( + !cat.get("founder").unwrap().is_transport(), + "missing keywords block → not a transport" + ); + assert_eq!(TRANSPORT_CAPACITY, 2); + } } diff --git a/src/simulator/crates/mc-units/src/lib.rs b/src/simulator/crates/mc-units/src/lib.rs index f1e1ad5d..56ffbdde 100644 --- a/src/simulator/crates/mc-units/src/lib.rs +++ b/src/simulator/crates/mc-units/src/lib.rs @@ -17,4 +17,4 @@ pub mod catalog; pub use action::UnitActionDef; pub use ap::{ActionCtx, ApCostError}; -pub use catalog::{CombatStats, UnitStats, UnitsCatalog}; +pub use catalog::{CombatStats, UnitStats, UnitsCatalog, TRANSPORT_CAPACITY};