feat(api): expose player actions via gdextension bridge

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-10 16:56:36 -07:00
parent c237063093
commit df74c9890d
3 changed files with 177 additions and 0 deletions

View file

@ -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"] }

View file

@ -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;

View file

@ -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<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdPlayerApi {
fn init(base: Base<RefCounted>) -> 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::<GameState>(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<PlayerId, ActionError> {
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.
}