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:
parent
f5c5d1a410
commit
b689f52ccc
2 changed files with 118 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue