diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index 2024cf87..1c735298 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -46,7 +46,7 @@ use mc_state::game_state::GameState; use mc_vision::{compute_vision, PlayerVision, VisionCatalog}; use crate::view::{ - CityView, CivicsView, CultureView, DiplomacyView, LegalActionEntry, + CityView, CivicsView, CultureView, DiplomacyView, EquippedItemView, LegalActionEntry, PendingEventsView, PlayerView, ProductionQueueEntry, ResearchView, ResourceView, ScoreView, TileView, UnitPostureView, UnitView, }; @@ -392,6 +392,21 @@ fn project_units( } else { Vec::new() }, + // Own units expose their equipped gear; enemies do not (no + // loadout leak). Empty vecs are omitted on the wire. + equipped: if is_own { + unit.equipped + .iter() + .map(|it| EquippedItemView { + item_id: it.item_id.clone(), + category: it.category.clone(), + charges_remaining: it.charges_remaining, + triggers_in_combat: it.triggers_in_combat, + }) + .collect() + } else { + Vec::new() + }, }); } } diff --git a/src/simulator/crates/mc-player-api/src/view.rs b/src/simulator/crates/mc-player-api/src/view.rs index 90629cf0..33cb1da0 100644 --- a/src/simulator/crates/mc-player-api/src/view.rs +++ b/src/simulator/crates/mc-player-api/src/view.rs @@ -206,6 +206,22 @@ impl UnitPostureView { } } +/// One equipped item on a unit. Mirrors the subset of +/// [`mc_items::EquippedItem`] the unit panel needs to render gear + remaining +/// charges. Surfaced for **own units only** (no enemy-loadout leak). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EquippedItemView { + /// Item definition id (e.g. `"iron_sword"`). + pub item_id: String, + /// Schema category: `"permanent"` | `"consumable"` | `"tool"`. + pub category: String, + /// Charges left before the item is spent (`0` for permanent gear). + pub charges_remaining: i32, + /// True if charges tick down once per combat resolved (combat-charge mode). + #[serde(default, skip_serializing_if = "core::ops::Not::not")] + pub triggers_in_combat: bool, +} + /// One unit — own or visible enemy. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct UnitView { @@ -243,6 +259,9 @@ pub struct UnitView { /// Per-unit legal actions. Empty on enemy units. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub legal_actions: Vec, + /// Equipped gear (own units only). Omitted on the wire when empty. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub equipped: Vec, } /// One explored tile. @@ -479,6 +498,7 @@ mod tests { formation_id: None, posture: UnitPostureView::default(), legal_actions: Vec::new(), + equipped: Vec::new(), }; let json = serde_json::to_string(&u).unwrap(); assert!(json.contains("\"type\":\"dwarf_warrior\""), "json={}", json); @@ -520,6 +540,7 @@ mod tests { formation_id: Some(7), posture: UnitPostureView { braced: true, ..Default::default() }, legal_actions: Vec::new(), + equipped: Vec::new(), }; let json = serde_json::to_string(&u).unwrap(); assert!(json.contains("\"braced\":true"), "active posture must serialize: {json}"); @@ -531,4 +552,57 @@ mod tests { let json2 = serde_json::to_string(&u).unwrap(); assert!(!json2.contains("braced"), "resting posture omitted: {json2}"); } + + #[test] + fn unit_view_equipped_round_trips_and_omits_when_empty() { + let mut u = UnitView { + id: "u_3".into(), + type_id: "dwarf_warrior".into(), + position: [2, 2], + owner: 0, + hp: 12, + max_hp: 12, + movement_left: 2, + movement_max: 2, + experience: 0, + promotion_available: false, + fortified: false, + sentry: false, + formation_id: None, + posture: UnitPostureView::default(), + legal_actions: Vec::new(), + equipped: Vec::new(), + }; + // Empty loadout → field omitted entirely (wire economy). + let json = serde_json::to_string(&u).unwrap(); + assert!(!json.contains("equipped"), "empty equipped omitted: {json}"); + + // Populate gear: a permanent weapon + a combat-charge charm. + u.equipped = vec![ + EquippedItemView { + item_id: "iron_sword".into(), + category: "permanent".into(), + charges_remaining: 0, + triggers_in_combat: false, + }, + EquippedItemView { + item_id: "war_charm".into(), + category: "consumable".into(), + charges_remaining: 3, + triggers_in_combat: true, + }, + ]; + let json = serde_json::to_string(&u).unwrap(); + assert!(json.contains("\"item_id\":\"iron_sword\""), "json={json}"); + assert!(json.contains("\"charges_remaining\":3"), "json={json}"); + assert!(json.contains("\"triggers_in_combat\":true"), "json={json}"); + // The permanent item's `triggers_in_combat:false` is omitted (default). + assert_eq!( + json.matches("triggers_in_combat").count(), + 1, + "false triggers_in_combat omitted: {json}" + ); + let back: UnitView = serde_json::from_str(&json).unwrap(); + assert_eq!(u, back, "equipped round-trips"); + } }