diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 4ac7ea36..ca4bb3b0 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -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 diff --git a/src/simulator/api-gdext/src/player_api.rs b/src/simulator/api-gdext/src/player_api.rs index c3561088..e395a087 100644 --- a/src/simulator/api-gdext/src/player_api.rs +++ b/src/simulator/api-gdext/src/player_api.rs @@ -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 { +pub(crate) fn clamp_player(player: i32) -> Result { 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 { 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,