feat(@projects/@magic-civilization): p3-18 P4a — transport data model (keywords + carrier_id)

Foundation for the transport mechanic (owner: build it):
- catalog UnitStats gains `keywords: Vec<String>` (authored on units/<id>.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<u32>` — 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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 05:56:54 -04:00
parent 416c62da85
commit ba81bcf476
3 changed files with 49 additions and 1 deletions

View file

@ -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<u32>,
/// 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")]

View file

@ -79,8 +79,29 @@ pub struct UnitStats {
/// come online.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logistics: Option<UnitLogistics>,
/// Gameplay keywords authored on `units/<id>.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<String>,
}
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/<id>.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);
}
}

View file

@ -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};