feat(api): ✨ expose player actions via gdextension bridge
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c237063093
commit
df74c9890d
3 changed files with 177 additions and 0 deletions
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
175
src/simulator/api-gdext/src/player_api.rs
Normal file
175
src/simulator/api-gdext/src/player_api.rs
Normal 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.
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue