From 18baeacc032411e8b8529cb33104c8e8ee00098b Mon Sep 17 00:00:00 2001 From: autocommit Date: Mon, 18 May 2026 02:40:53 -0700 Subject: [PATCH] =?UTF-8?q?feat(api-gdext):=20=E2=9C=A8=20Introduce=20Save?= =?UTF-8?q?Envelope=20struct=20and=20serialization/deserialization=20funct?= =?UTF-8?q?ions=20for=20Godot=20Engine=20API=20extension=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/api-gdext/src/lib.rs | 75 +++++++++++++++++++ .../api-gdext/tests/save_envelope.rs | 59 ++++++++++++++- 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 60e351a5..f1328462 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -2904,6 +2904,56 @@ impl SaveEnvelope { pub const CURRENT_VERSION: u32 = 2; } +/// Stage 7 — inspect a presentation side-table for controller ids that +/// are not currently registered in `mc_player_api`'s controller +/// registry. Returns a list of human-readable error messages, one per +/// slot whose `controller_id` is non-empty and unknown. +/// +/// Read-only: the registry is *not* mutated. Empty `controller_id` +/// entries (legacy / pre-Stage-3 saves) are treated as +/// `scripted:default` and skipped. Non-zero `controller_hash` values +/// are recorded in the error string for diagnostics but do not by +/// themselves fail validation at this stage. +/// +/// Pure function so unit tests can call it without instantiating +/// Godot — `GdGameState::validate_save_controllers` is a thin +/// `#[func]` wrapper around this. +pub fn validate_presentation_controllers( + presentation: &[mc_core::PresentationPlayer], +) -> Vec { + let registered = mc_player_api::registered_ids(); + let mut errors: Vec = Vec::new(); + for entry in presentation { + if entry.controller_id.is_empty() { + // Pre-Stage-3 / legacy slot — loader treats empty as + // `scripted:default`, which is always registered. + continue; + } + if !registered.iter().any(|id| id == &entry.controller_id) { + // TODO: Stage 5c — compare against + // ModLoader.installed_hashes(id) once the loader is in + // place. For now we surface the recorded hash purely as + // diagnostic context. + let hash_hint = if entry.controller_hash == [0u8; 32] { + String::from("hash=builtin-sentinel") + } else { + let prefix: String = entry + .controller_hash + .iter() + .take(4) + .map(|b| format!("{:02x}", b)) + .collect(); + format!("hash={}…", prefix) + }; + errors.push(format!( + "missing controller for slot {}: {} ({})", + entry.slot, entry.controller_id, hash_hint + )); + } + } + errors +} + #[godot_api] impl IRefCounted for GdGameState { fn init(base: Base) -> Self { @@ -3776,6 +3826,31 @@ impl GdGameState { true } + /// Stage 7 — after `load_from_json` succeeds, GDScript calls this + /// to ask: "are all the controllers this save references currently + /// registered with `mc_player_api`?" Returns an empty `GString` on + /// all-clear, or a comma-joined error string listing every slot + /// whose `controller_id` is non-empty and unknown. + /// + /// Read-only on `self` — does not mutate state, registry, or + /// presentation table. GDScript checks `!result.is_empty()` to + /// decide whether to surface a friendly dialog and short-circuit + /// the load (otherwise the first AI turn would crash on a missing + /// controller). + /// + /// Empty `controller_id` slots (legacy / pre-Stage-3 saves) are + /// silently treated as `"scripted:default"`, which is guaranteed + /// registered at engine boot. + #[func] + fn validate_save_controllers(&self) -> GString { + let errors = validate_presentation_controllers(&self.presentation_players); + if errors.is_empty() { + GString::new() + } else { + GString::from(errors.join(", ")) + } + } + /// List every registered AI controller id (sorted, ascending). /// Bridges `mc_player_api::registered_ids` to GDScript so the /// game-setup screen (Stage 8) can populate its per-slot diff --git a/src/simulator/api-gdext/tests/save_envelope.rs b/src/simulator/api-gdext/tests/save_envelope.rs index f13b2531..1f6987c8 100644 --- a/src/simulator/api-gdext/tests/save_envelope.rs +++ b/src/simulator/api-gdext/tests/save_envelope.rs @@ -6,7 +6,7 @@ //! which is the same surface those bridge methods serialise / deserialise into. //! Mid-game save+load behaviour is covered by the GUT integration tests. -use magic_civ_physics_gdext::SaveEnvelope; +use magic_civ_physics_gdext::{validate_presentation_controllers, SaveEnvelope}; use mc_core::PresentationPlayer; use mc_turn::game_state::GameState; @@ -96,6 +96,63 @@ fn populated_envelope_round_trips_byte_identical() { assert!(!back.presentation[1].is_human); } +#[test] +fn controller_validation_passes_for_known_controllers() { + // `scripted:default` is auto-registered at engine boot by + // `mc_player_api::controllers::register_builtins()` (invoked via + // the registry's `OnceCell` initialiser). Empty `controller_id` + // is treated as legacy → skip. + let presentation = vec![ + PresentationPlayer { + slot: 0, + controller_id: String::new(), + ..Default::default() + }, + PresentationPlayer { + slot: 1, + controller_id: "scripted:default".into(), + ..Default::default() + }, + ]; + let errors = validate_presentation_controllers(&presentation); + assert!( + errors.is_empty(), + "expected no errors for known/empty controllers, got {errors:?}" + ); +} + +#[test] +fn controller_validation_flags_missing_controller() { + // Use an id that no built-in or test ever registers. The registry + // persists across in-process tests, so we deliberately pick a + // distinctive id that no other test would have registered. + let presentation = vec![ + PresentationPlayer { + slot: 0, + controller_id: "scripted:default".into(), + ..Default::default() + }, + PresentationPlayer { + slot: 2, + controller_id: "definitely:not:registered".into(), + controller_hash: [0xAB; 32], + ..Default::default() + }, + ]; + let errors = validate_presentation_controllers(&presentation); + assert_eq!(errors.len(), 1, "expected exactly one missing-controller error, got {errors:?}"); + let msg = &errors[0]; + assert!(msg.contains("slot 2"), "error must name the slot: {msg}"); + assert!( + msg.contains("definitely:not:registered"), + "error must name the controller id: {msg}" + ); + assert!( + msg.contains("hash=ababab"), + "non-zero controller_hash should appear as a hex prefix hint: {msg}" + ); +} + #[test] fn version_two_is_locked() { // Lock the wire format version. Future breaking changes must bump