feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-1 — GdGameState act/view/spawn bridge (live unit store foundation)

Gives the live GdGameState the same Rust-driven surface the headless GdPlayerApi
has, on its own inner GameState, so inner.players[].units (rich MapUnit) can
become the live unit store:
- apply_action_json(player, action_json) → mc_player_api::apply_action(&mut inner)
- inner_view_json(player) → mc_player_api::project_view(&inner)
- spawn_unit_into_inner(player, unit_type_id, col, row) → MapUnit::new + push,
  monotonic next_unit_id (same idiom as the AI-faction spawn).
Thin shims over the SAME mc_player_api fns GdPlayerApi calls (no dispatch/
projection duplication; 4 envelope helpers made pub(crate) for reuse). No
GDScript touched; GdPlayerApi + bench path untouched.

Contract for the later (render-gated) live caller: stamp inner.units_catalog
(+ action configs) via the existing set_*_catalog_json setters before relying on
the view — documented inline (lib.rs:4297). cdylib links with all 3 #[func]s
registered (distinct symbols from GdPlayerApi); mc-player-api 0 failed.

Dispatched simulator-infra; verify gate green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 08:57:17 -04:00
parent f5c5d1a410
commit b689f52ccc
2 changed files with 118 additions and 4 deletions

View file

@ -4295,6 +4295,120 @@ impl GdGameState {
}
}
// ── p3-rail1 Phase-1 — live UNIT-store act/view surface ───────────────
// These mirror `GdPlayerApi::{apply_action_json, view_json}` but operate
// on `GdGameState`'s OWN `inner: mc_state::GameState` rather than the
// headless harness's held state. This is what lets the live game treat
// `inner.players[].units` as the authoritative unit store: GDScript will
// spawn units via `spawn_unit_into_inner`, send unit input through
// `apply_action_json`, and render from `inner_view_json`. The dispatch +
// projection LOGIC stays in `mc-player-api` (called here); the envelope /
// clamp / int-float-normalisation shims are reused from `player_api` so
// the wire shape is byte-identical to the `GdPlayerApi` surface.
//
// Projection uses `omniscient = false` (fog-aware per-player view) — the
// live UI is a per-player pure view, unlike golden/debug dumps.
/// Dispatch a `PlayerAction` (JSON) onto `self.inner` for `player` and
/// return the same `{"ok":true,"events":[…],"view":{…}}` /
/// `{"ok":false,"error":{…}}` envelope `GdPlayerApi::apply_action_json`
/// returns. Calls the SAME `mc_player_api::apply_action` +
/// `mc_player_api::project_view` crate fns — no dispatch/projection logic
/// is reimplemented here.
///
/// Caller contract: `view`/dispatch are only correct once `inner` has the
/// `#[serde(skip)]` catalogs/config stamped that the headless surface
/// loads (e.g. `set_units_runtime_catalog_json` for spawn stat-lines,
/// plus any tech/improvement/recipe config the dispatched action reads).
/// The live caller MUST stamp those before relying on the returned view.
#[func]
fn apply_action_json(&mut self, player: i64, action_json: GString) -> GString {
let player_id = match player_api::clamp_player(player as i32) {
Ok(p) => p,
Err(e) => return player_api::error_envelope(&e),
};
// Same Godot-JSON `[2.0,7.0]` → `[2,7]` normalisation the headless
// surface applies (PlayerAction hex coords deserialize as strict
// `[i32; 2]`).
let raw = action_json.to_string();
let normalized = player_api::normalize_int_zero_floats(&raw);
let action: mc_player_api::PlayerAction = match serde_json::from_str(normalized.as_str()) {
Ok(a) => a,
Err(e) => {
return player_api::error_envelope(&mc_player_api::ActionError::ParseError {
message: format!("could not parse action JSON: {e}"),
});
}
};
match mc_player_api::apply_action(&mut self.inner, player_id, &action) {
Ok(events) => {
let view = mc_player_api::project_view(&self.inner, player_id, false);
player_api::ok_envelope(&events, &view)
}
Err(e) => player_api::error_envelope(&e),
}
}
/// Project `self.inner` into a fog-aware `PlayerView` for `player` and
/// return its JSON. Named `inner_view_json` (not `view_json`) to avoid a
/// `#[func]` name clash; the projection is the SAME
/// `mc_player_api::project_view` `GdPlayerApi::view_json` uses.
#[func]
fn inner_view_json(&self, player: i64) -> GString {
let player_id = match player_api::clamp_player(player as i32) {
Ok(p) => p,
Err(e) => return player_api::error_envelope(&e),
};
let view = mc_player_api::project_view(&self.inner, player_id, false);
match serde_json::to_string(&view) {
Ok(s) => s.into(),
Err(e) => {
godot_error!("GdGameState::inner_view_json serialise failed: {e}");
player_api::error_envelope(&mc_player_api::ActionError::Internal {
message: format!("view serialise failed: {e}"),
})
}
}
}
/// Spawn a `MapUnit` of `unit_type_id` at `(col, row)` owned by `player`
/// into `self.inner.players[player].units` and return the new unit id.
/// This is how the live game populates the authoritative unit store.
///
/// Reuses `MapUnit::new` (which reads `base_moves` / AP capacity from the
/// stamped `inner.units_catalog`, exactly like the AI-faction spawn path)
/// and `GameState::next_unit_id` for the monotonic id. Returns `-1` (and
/// logs via `godot_error!`) if `player` is out of range.
#[func]
fn spawn_unit_into_inner(
&mut self,
player: i64,
unit_type_id: GString,
col: i64,
row: i64,
) -> i64 {
if player < 0 || player as usize >= self.inner.players.len() {
godot_error!(
"GdGameState::spawn_unit_into_inner: player {player} out of range ({} players)",
self.inner.players.len()
);
return -1;
}
let pi = player as usize;
let unit_id = self.inner.next_unit_id;
self.inner.next_unit_id = self.inner.next_unit_id.saturating_add(1);
let mut unit = mc_state::game_state::MapUnit::new(
&unit_type_id.to_string(),
col as i32,
row as i32,
pi as u8,
&self.inner.units_catalog,
);
unit.id = unit_id;
self.inner.players[pi].units.push(unit);
unit_id as i64
}
/// Apply city capture mutation on the inner Rust GameState (p1-29j).
/// Stamps cities_lost_total + last_city_lost_turn on defender (feeding
/// refound suppression in try_found / handle_found and last-stand in

View file

@ -387,7 +387,7 @@ impl GdPlayerApi {
/// another digit, elide the `.0`. String literals (delimited by unescaped
/// `"`) pass through untouched so `"unit_id": "1.0"` style payloads
/// — if any ever exist — are not corrupted.
fn normalize_int_zero_floats(src: &str) -> String {
pub(crate) fn normalize_int_zero_floats(src: &str) -> String {
let bytes = src.as_bytes();
let mut out = String::with_capacity(bytes.len());
let mut i = 0;
@ -448,7 +448,7 @@ fn normalize_int_zero_floats(src: &str) -> String {
out
}
fn clamp_player(player: i32) -> Result<PlayerId, ActionError> {
pub(crate) fn clamp_player(player: i32) -> Result<PlayerId, ActionError> {
if !(0..=255).contains(&player) {
return Err(ActionError::Internal {
message: format!("player slot {player} out of range [0, 255]"),
@ -457,7 +457,7 @@ fn clamp_player(player: i32) -> Result<PlayerId, ActionError> {
Ok(player as PlayerId)
}
fn ok_envelope(events: &[mc_player_api::Event], view: &PlayerView) -> GString {
pub(crate) fn ok_envelope(events: &[mc_player_api::Event], view: &PlayerView) -> GString {
// Wire shape matches Response::Ok docstring in mc-player-api::wire.
let envelope = serde_json::json!({
"ok": true,
@ -469,7 +469,7 @@ fn ok_envelope(events: &[mc_player_api::Event], view: &PlayerView) -> GString {
.into()
}
fn error_envelope(err: &ActionError) -> GString {
pub(crate) fn error_envelope(err: &ActionError) -> GString {
let envelope = serde_json::json!({
"ok": false,
"error": err,