feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-0 — project real unit movement + tactical posture

First projection-completeness increment toward UI-driven-by-Rust. project_units
stubbed movement_left/max=0, sentry=false, promotion_available=false despite the
bench MapUnit carrying the real data — fix to read movement_remaining/base_moves/
is_sentrying/pending_promotion (same fields the move dispatch + legal-move gate
use). Add UnitPostureView (embarked/deployed/stealthed/ambushing/field_aura/
fire_arrows/pursuing/shield_wall/braced/rage/war_cry) + formation_id, projected
for OWN units only (no stealth/ambush leak); omitted from the wire when resting.
These are the unit_panel.gd entity reads (audit Group B) now available via
view_json. XP stays 0 (not yet on MapUnit — Phase-1 SOT-flip gap, noted).

Additive (serde defaults; no other UnitView constructors in the workspace).
mc-player-api 139/0 incl. 2 new posture round-trip/omission tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 08:00:41 -04:00
parent d78152388a
commit 568e43084b
2 changed files with 111 additions and 8 deletions

View file

@ -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 {

View file

@ -155,6 +155,46 @@ pub struct CityView {
pub legal_actions: Vec<LegalActionEntry>,
}
/// 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<u32>,
/// 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<LegalActionEntry>,
@ -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}");
}
}