test(@projects/@magic-civilization): 🛤️ Rail-1 Phase-1 — end-to-end live-unit-store loop test

Proves the spawn → command → view contract the GdGameState bridge exposes for
the render-gated live flip, at the mc_player_api layer its shims call: a MapUnit
pushed onto inner (as spawn_unit_into_inner produces) appears in project_view;
a Fortify via apply_action is reflected in the next view; a command on a stale
unit id is a typed error, not a panic. Existing integration tests load pre-built
states — none exercised the spawn-then-act-then-view triple a freshly-spawned
live unit goes through. De-risks the foundation before the GDScript flip depends
on it. mc-player-api 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 09:44:39 -04:00
parent b4c402e766
commit 24c0e0c24c

View file

@ -0,0 +1,73 @@
//! Rail-1 Phase-1 — the live unit-store loop, end-to-end.
//!
//! Proves the exact three-call contract the new `GdGameState` bridge exposes for
//! the (later, render-gated) live flip — `spawn_unit_into_inner` →
//! `apply_action_json` → `inner_view_json` — at the `mc_player_api` layer those
//! shims call into. The existing integration tests load pre-built states; none
//! exercise the **spawn → command → view** triple a freshly-spawned live unit
//! goes through. This is that proof: a unit pushed onto `inner` shows up in the
//! projected view and responds to a command, with the command's effect visible
//! in the next view.
use mc_player_api::{apply_action, project_view, PlayerAction};
use mc_state::game_state::{GameState, MapUnit, PlayerState};
/// Build a one-player `GameState` holding a single durable unit at `(2, 2)`,
/// mirroring what `GdGameState::spawn_unit_into_inner` produces (a `MapUnit`
/// pushed onto `players[pi].units`).
fn state_with_spawned_unit() -> GameState {
let mut state = GameState::default();
state.players.push(PlayerState {
units: vec![MapUnit {
id: 1,
col: 2,
row: 2,
hp: 10,
max_hp: 10,
unit_id: "dwarf_warrior".into(),
..Default::default()
}],
..Default::default()
});
state
}
#[test]
fn spawned_unit_appears_in_the_projected_view() {
let state = state_with_spawned_unit();
// `inner_view_json` → project_view(omniscient=true is the harness path; the
// bound player sees its own units regardless of fog).
let view = project_view(&state, 0, true);
let u = view
.units
.iter()
.find(|u| u.id == "1")
.expect("the spawned unit must appear in the owner's view");
assert_eq!(u.position, [2, 2], "view carries the spawn position");
assert_eq!(u.type_id, "dwarf_warrior");
assert!(!u.fortified, "freshly spawned unit is not fortified");
}
#[test]
fn command_via_act_is_reflected_in_the_next_view() {
let mut state = state_with_spawned_unit();
// act(): Fortify the unit (no grid/movement preconditions — isolates the
// act→view round-trip from pathfinding).
let res = apply_action(&mut state, 0, &PlayerAction::Fortify { unit_id: "1".into() });
assert!(res.is_ok(), "Fortify must dispatch on the live store: {res:?}");
// The very next view reflects the command — the contract the live renderer
// relies on (render strictly from getState() after each act()).
let view = project_view(&state, 0, true);
let u = view.units.iter().find(|u| u.id == "1").expect("unit still present");
assert!(u.fortified, "the view must show the unit fortified after the act()");
}
#[test]
fn act_on_a_missing_unit_is_rejected_not_panicking() {
let mut state = state_with_spawned_unit();
// The bridge must surface a clean error (not panic) for a stale unit id —
// the live UI can desync transiently and must get a typed rejection.
let res = apply_action(&mut state, 0, &PlayerAction::Fortify { unit_id: "999".into() });
assert!(res.is_err(), "commanding a non-existent unit must be a typed error");
}