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:
parent
d78152388a
commit
568e43084b
2 changed files with 111 additions and 8 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue