diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index e688ae70..6fe1ea55 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -48,7 +48,7 @@ use mc_vision::{compute_vision, PlayerVision, VisionCatalog}; use crate::view::{ CityView, CivicsView, CultureView, DiplomacyView, LegalActionEntry, PendingEventsView, PlayerView, ProductionQueueEntry, ResearchView, ResourceView, ScoreView, - TileView, UnitView, + TileView, UnitPostureView, UnitView, }; use crate::{PlayerAction, PlayerId}; @@ -351,15 +351,38 @@ fn project_units( owner: p_idx as PlayerId, hp: unit.hp, max_hp: unit.max_hp, - // Bench MapUnit has no per-turn movement counter — the - // turn processor refreshes it transiently. v1 reports - // the unit's `moves_left` if non-zero, else 0. - movement_left: 0, - movement_max: 0, + // Rail-1: project the real per-turn movement the move dispatch + // decrements + the legal-move gate reads (same field the + // tactical projection uses, projection.rs ~1275). `base_moves` + // is the per-turn maximum. + movement_left: unit.movement_remaining, + movement_max: unit.base_moves, + // XP is not yet on the bench MapUnit — surfaced once the SOT + // flip (Phase 1) widens the model. Stays 0 until then. experience: 0, - promotion_available: false, + promotion_available: unit.pending_promotion.is_some(), fortified: unit.is_fortified, - sentry: false, + sentry: unit.is_sentrying, + // Own units carry their formation + full posture; enemies do + // not (no stealth/ambush leak, and the panel only details own). + formation_id: if is_own { unit.formation_id } else { None }, + posture: if is_own { + UnitPostureView { + embarked: unit.is_embarked, + deployed: unit.is_deployed, + stealthed: unit.is_stealthed, + ambushing: unit.is_ambushing, + field_aura: unit.is_field_aura, + fire_arrows: unit.is_fire_arrows, + pursuing: unit.is_pursuing, + shield_wall: unit.is_shield_wall, + braced: unit.is_braced, + rage_turns_remaining: unit.rage_turns_remaining, + war_cry_used: unit.war_cry_used_this_battle, + } + } else { + UnitPostureView::default() + }, legal_actions: if is_own { project_unit_legal_actions(state, p_idx, unit) } else { diff --git a/src/simulator/crates/mc-player-api/src/view.rs b/src/simulator/crates/mc-player-api/src/view.rs index 9894cef2..f28fc97d 100644 --- a/src/simulator/crates/mc-player-api/src/view.rs +++ b/src/simulator/crates/mc-player-api/src/view.rs @@ -155,6 +155,46 @@ pub struct CityView { pub legal_actions: Vec, } +/// Per-unit tactical posture — the combat stances + per-turn flags the unit +/// panel surfaces (Rail-1: projected from `MapUnit` so the UI reads them from +/// `view_json`, never off a GDScript entity). All default to `false`/`0`; the +/// whole struct is omitted from the wire for units in their resting posture +/// (see `UnitView`'s `skip_serializing_if`). Populated for OWN units only — +/// stealth/ambush are not leaked for enemies. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnitPostureView { + /// Land unit embarked on water (halved defence). + pub embarked: bool, + /// Siege unit deployed (can bombard, cannot move). + pub deployed: bool, + /// Scout in stealth (invisible to enemies > 1 hex). + pub stealthed: bool, + /// Scout ambush set on the current hex. + pub ambushing: bool, + /// Medic Field Aura active (co-hex allies regen). + pub field_aura: bool, + /// Archer Fire Arrows stance. + pub fire_arrows: bool, + /// Cavalry pursuing. + pub pursuing: bool, + /// Infantry Shield Wall stance. + pub shield_wall: bool, + /// Spear Braced stance. + pub braced: bool, + /// Berserker rage turns left (`0` = not raging). + pub rage_turns_remaining: u8, + /// War-cry already spent this battle. + pub war_cry_used: bool, +} + +impl UnitPostureView { + /// True when every field is at its resting default — lets the projection + /// omit the posture object for idle units (wire economy). + pub fn is_resting(&self) -> bool { + *self == Self::default() + } +} + /// One unit — own or visible enemy. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct UnitView { @@ -183,6 +223,12 @@ pub struct UnitView { pub fortified: bool, /// Whether the unit is on sentry. pub sentry: bool, + /// Formation this unit belongs to, if any (own units only). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub formation_id: Option, + /// Tactical posture / per-turn stances. Omitted for units at rest. + #[serde(default, skip_serializing_if = "UnitPostureView::is_resting")] + pub posture: UnitPostureView, /// Per-unit legal actions. Empty on enemy units. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub legal_actions: Vec, @@ -419,11 +465,45 @@ mod tests { promotion_available: false, fortified: false, sentry: false, + formation_id: None, + posture: UnitPostureView::default(), legal_actions: Vec::new(), }; let json = serde_json::to_string(&u).unwrap(); assert!(json.contains("\"type\":\"dwarf_warrior\""), "json={}", json); // `type_id` must not leak onto the wire. assert!(!json.contains("type_id"), "json={}", json); + // A resting unit omits the posture object entirely (wire economy). + assert!(!json.contains("posture"), "resting posture must be omitted: {json}"); + } + + #[test] + fn unit_posture_serialized_only_when_active() { + let mut u = UnitView { + id: "u_2".into(), + type_id: "dwarf_spear".into(), + position: [1, 1], + owner: 0, + hp: 10, + max_hp: 10, + movement_left: 1, + movement_max: 2, + experience: 0, + promotion_available: false, + fortified: false, + sentry: false, + formation_id: Some(7), + posture: UnitPostureView { braced: true, ..Default::default() }, + legal_actions: Vec::new(), + }; + let json = serde_json::to_string(&u).unwrap(); + assert!(json.contains("\"braced\":true"), "active posture must serialize: {json}"); + assert!(json.contains("\"formation_id\":7"), "formation id must serialize: {json}"); + let back: UnitView = serde_json::from_str(&json).unwrap(); + assert_eq!(u, back, "posture round-trips"); + // Flip back to resting → posture omitted again. + u.posture = UnitPostureView::default(); + let json2 = serde_json::to_string(&u).unwrap(); + assert!(!json2.contains("braced"), "resting posture omitted: {json2}"); } }