From df74c9890d9f9e3536294b31a8061099fb878e28 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 10 May 2026 16:56:36 -0700 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E2=9C=A8=20expose=20player=20acti?= =?UTF-8?q?ons=20via=20gdextension=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/api-gdext/Cargo.toml | 1 + src/simulator/api-gdext/src/lib.rs | 1 + src/simulator/api-gdext/src/player_api.rs | 175 ++++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 src/simulator/api-gdext/src/player_api.rs diff --git a/src/simulator/api-gdext/Cargo.toml b/src/simulator/api-gdext/Cargo.toml index 4863b547..9d94178d 100644 --- a/src/simulator/api-gdext/Cargo.toml +++ b/src/simulator/api-gdext/Cargo.toml @@ -23,6 +23,7 @@ mc-items = { path = "../crates/mc-items" } mc-trade = { path = "../crates/mc-trade" } mc-tech = { path = "../crates/mc-tech" } mc-turn = { path = "../crates/mc-turn" } +mc-player-api = { path = "../crates/mc-player-api" } mc-replay = { path = "../crates/mc-replay" } mc-score = { path = "../crates/mc-score" } uuid = { version = "1", features = ["serde", "v4"] } diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index c04f7a30..b62b3443 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -14,6 +14,7 @@ pub mod building_action; pub mod capture; pub mod civics; pub mod observation; +pub mod player_api; pub mod replay; pub mod score; diff --git a/src/simulator/api-gdext/src/player_api.rs b/src/simulator/api-gdext/src/player_api.rs new file mode 100644 index 00000000..fad59089 --- /dev/null +++ b/src/simulator/api-gdext/src/player_api.rs @@ -0,0 +1,175 @@ +//! GDExtension bridge for `mc-player-api`. +//! +//! [`GdPlayerApi`] is the Godot-side surface the headless harness drives. +//! Both methods round-trip via JSON so GDScript only needs `String`s: +//! +//! - [`GdPlayerApi::view_json`] — projects the held `GameState` into a +//! fog-aware `PlayerView` for the given player slot and returns +//! serialised JSON. +//! - [`GdPlayerApi::apply_action_json`] — deserialises a `PlayerAction` +//! from JSON, applies it, and returns a JSON object with `events` + +//! updated `view`, mirroring the `Response::Ok` wire envelope. +//! - [`GdPlayerApi::load_state_json`] — replaces the held state from a +//! `GameState` JSON dump (the harness can checkpoint by serialising +//! the held state out and back). +//! +//! State is held internally so the harness doesn't have to pass it on +//! every call. The held `GameState` lives across calls until +//! `load_state_json` replaces it. +//! +//! Error path: on any failure (parse, illegal action, internal) the +//! returned JSON is an error-shaped envelope with `ok: false` and a +//! typed `error` payload — adapters branch on the `ok` field exactly +//! as documented in `CLAUDE_PLAYER_API.md`. + +use godot::prelude::*; +use mc_player_api::{ + apply_action, project_view, ActionError, PlayerAction, PlayerView, PlayerId, +}; +use mc_turn::game_state::GameState; + +/// Godot-visible facade over the player API surface. Holds one +/// `GameState` internally so calls don't have to round-trip it on +/// every action. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdPlayerApi { + state: GameState, + omniscient: bool, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdPlayerApi { + fn init(base: Base) -> Self { + Self { + state: GameState::default(), + omniscient: false, + base, + } + } +} + +#[godot_api] +impl GdPlayerApi { + /// Set the omniscient flag. When `true`, `view_json` returns the + /// full unredacted state for debugging / golden tests. Mirrors + /// `CP_OMNISCIENT=1` on the harness. + #[func] + pub fn set_omniscient(&mut self, on: bool) { + self.omniscient = on; + } + + /// Replace the held `GameState` from a JSON dump. Returns `true` + /// on success; logs and returns `false` on parse failure. + #[func] + pub fn load_state_json(&mut self, json: GString) -> bool { + match serde_json::from_str::(json.to_string().as_str()) { + Ok(state) => { + self.state = state; + true + } + Err(e) => { + godot_error!("GdPlayerApi::load_state_json failed: {e}"); + false + } + } + } + + /// Serialise the held `GameState` back to JSON. Symmetric with + /// `load_state_json` so callers can checkpoint. + #[func] + pub fn dump_state_json(&self) -> GString { + match serde_json::to_string(&self.state) { + Ok(s) => s.into(), + Err(e) => { + godot_error!("GdPlayerApi::dump_state_json failed: {e}"); + "{}".into() + } + } + } + + /// Project the held state into a fog-aware `PlayerView` for + /// `player` and return its JSON form. + #[func] + pub fn view_json(&self, player: i32) -> GString { + let player_id = match clamp_player(player) { + Ok(p) => p, + Err(e) => return error_envelope(&e), + }; + let view = project_view(&self.state, player_id, self.omniscient); + match serde_json::to_string(&view) { + Ok(s) => s.into(), + Err(e) => { + godot_error!("GdPlayerApi::view_json serialise failed: {e}"); + error_envelope(&ActionError::Internal { + message: format!("view serialise failed: {e}"), + }) + } + } + } + + /// Apply a `PlayerAction` (JSON) and return a `Response::Ok` / + /// `Response::Err` envelope (JSON). + #[func] + pub fn apply_action_json(&mut self, player: i32, action_json: GString) -> GString { + let player_id = match clamp_player(player) { + Ok(p) => p, + Err(e) => return error_envelope(&e), + }; + let action: PlayerAction = + match serde_json::from_str(action_json.to_string().as_str()) { + Ok(a) => a, + Err(e) => { + return error_envelope(&ActionError::ParseError { + message: format!("could not parse action JSON: {e}"), + }); + } + }; + match apply_action(&mut self.state, player_id, &action) { + Ok(events) => { + let view = project_view(&self.state, player_id, self.omniscient); + ok_envelope(&events, &view) + } + Err(e) => error_envelope(&e), + } + } +} + +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]"), + }); + } + Ok(player as PlayerId) +} + +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, + "events": events, + "view": view, + }); + serde_json::to_string(&envelope) + .unwrap_or_else(|e| format!(r#"{{"ok":false,"error":{{"code":"internal","message":"{e}"}}}}"#)) + .into() +} + +fn error_envelope(err: &ActionError) -> GString { + let envelope = serde_json::json!({ + "ok": false, + "error": err, + }); + serde_json::to_string(&envelope) + .unwrap_or_else(|e| format!(r#"{{"ok":false,"error":{{"code":"internal","message":"{e}"}}}}"#)) + .into() +} + +#[cfg(test)] +mod tests { + // Rust-side tests live in `mc-player-api`. This module is the GDExtension + // shim; behaviour beyond the JSON round-trip is covered there. End-to-end + // verification happens via the headless harness in Phase 3. +}