From 2727d2f24996ae40efcdac0bc3462c1fbcaffb3d Mon Sep 17 00:00:00 2001 From: autocommit Date: Sun, 17 May 2026 23:59:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(api-gdext):=20=E2=9C=A8=20Introduce=20mult?= =?UTF-8?q?i-slot=20functionality=20in=20the=20Godot=20extension=20API=20w?= =?UTF-8?q?ith=20new=20functions=20and=20structs=20in=20lib.rs=20and=20cor?= =?UTF-8?q?responding=20tests=20in=20save=5Fenvelope.rs?= 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 | 79 ++++++++++++++++++- .../api-gdext/tests/save_envelope.rs | 16 +++- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 43c8ff4c..a1134f7c 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -2891,7 +2891,17 @@ pub struct SaveEnvelope { impl SaveEnvelope { /// Current envelope version. Bump on every breaking shape change. - pub const CURRENT_VERSION: u32 = 1; + /// + /// History: + /// - **v1** — initial release: `{sim, presentation}` where + /// `PresentationPlayer = {slot, player_name, race_id, gender_preset, + /// color, is_human}`. + /// - **v2** (Stage 3 of mod-system plan) — `PresentationPlayer` gains + /// `controller_id: String` and `controller_hash: [u8; 32]` so saves + /// fingerprint which `AiController` impl each slot ran. Dev-time + /// v1 saves are rejected at load (per existing `load_from_json` + /// policy — disposable saves, regenerate). + pub const CURRENT_VERSION: u32 = 2; } #[godot_api] @@ -3130,6 +3140,21 @@ impl GdGameState { gender_preset, color, is_human, + // Preserve existing controller_id if already set via + // `set_player_controller` (Stage 3); otherwise leave empty + // and let `drive_ai_slot` fall through to scripted:default. + controller_id: self + .presentation_players + .iter() + .find(|p| p.slot == slot_u8) + .map(|p| p.controller_id.clone()) + .unwrap_or_default(), + controller_hash: self + .presentation_players + .iter() + .find(|p| p.slot == slot_u8) + .map(|p| p.controller_hash) + .unwrap_or([0u8; 32]), }; // Upsert by slot — replace existing entry if present, else // insert in sorted order so the side-table iteration order is @@ -3699,6 +3724,58 @@ impl GdGameState { } } + /// Stage 3 (mod system) — assign an [`AiController`] id to a player + /// slot. The id is looked up in `mc_player_api::controllers` at + /// dispatch time (`drive_ai_slot`). Built-in: `"scripted:default"`; + /// mod-supplied ids (`"learned:duel-v1b"`, …) register through + /// `mc_player_api::controllers::register_controller`. + /// + /// Empty id = fall through to `DEFAULT_CONTROLLER_ID` in dispatch. + /// + /// Also mirrors the id into `PresentationPlayer.controller_id` so + /// the save envelope fingerprints which controller ran each slot + /// (paired with the per-mod hash recorded by the mod loader). + /// + /// Returns `false` (and logs) on out-of-range slot; this is a + /// presentation-layer write that never fails on the simulator side. + #[func] + fn set_player_controller(&mut self, slot: i64, controller_id: GString) -> bool { + let pi = slot as usize; + if pi >= self.inner.players.len() { + godot_error!( + "GdGameState::set_player_controller: slot {slot} out of range ({} players)", + self.inner.players.len() + ); + return false; + } + let id_str = controller_id.to_string(); + self.inner.players[pi].controller_id = id_str.clone(); + let slot_u8 = pi as u8; + if let Some(existing) = self + .presentation_players + .iter_mut() + .find(|p| p.slot == slot_u8) + { + existing.controller_id = id_str; + } else { + // Lazily seed a presentation entry so save round-trip + // includes the controller id even when the GDScript bridge + // hasn't yet called `set_player_presentation_json`. + let entry = mc_core::PresentationPlayer { + slot: slot_u8, + controller_id: id_str, + ..Default::default() + }; + let insert_at = self + .presentation_players + .iter() + .position(|p| p.slot > slot_u8) + .unwrap_or(self.presentation_players.len()); + self.presentation_players.insert(insert_at, entry); + } + true + } + /// Current turn number. #[func] fn turn(&self) -> i64 { diff --git a/src/simulator/api-gdext/tests/save_envelope.rs b/src/simulator/api-gdext/tests/save_envelope.rs index 468a7415..f13b2531 100644 --- a/src/simulator/api-gdext/tests/save_envelope.rs +++ b/src/simulator/api-gdext/tests/save_envelope.rs @@ -19,7 +19,7 @@ fn empty_envelope_round_trips() { }; let json = serde_json::to_string(&env).expect("serialize"); let back: SaveEnvelope = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(back.save_format_version, 1); + assert_eq!(back.save_format_version, 2); assert!(back.presentation.is_empty()); assert_eq!(back.sim.turn, 0); assert_eq!(back.sim.era, 0); @@ -48,6 +48,8 @@ fn populated_envelope_round_trips_byte_identical() { gender_preset: "male".into(), color: [51, 102, 255, 255], is_human: true, + controller_id: String::new(), + controller_hash: [0u8; 32], }, PresentationPlayer { slot: 1, @@ -56,6 +58,8 @@ fn populated_envelope_round_trips_byte_identical() { gender_preset: "female".into(), color: [230, 51, 51, 255], is_human: false, + controller_id: "scripted:default".into(), + controller_hash: [0u8; 32], }, ]; @@ -74,7 +78,7 @@ fn populated_envelope_round_trips_byte_identical() { let json2 = serde_json::to_string(&back).expect("re-serialize"); assert_eq!(json, json2, "envelope must byte-equal across round-trip"); - assert_eq!(back.save_format_version, 1); + assert_eq!(back.save_format_version, 2); assert_eq!(back.sim.turn, 7); assert_eq!(back.sim.era, 2); assert_eq!(back.sim.map_seed, 0xfeed_face); @@ -93,9 +97,13 @@ fn populated_envelope_round_trips_byte_identical() { } #[test] -fn version_one_is_locked() { +fn version_two_is_locked() { // Lock the wire format version. Future breaking changes must bump // this constant in tandem with the `load_from_json` rejection // logic — this test guards against an accidental silent bump. - assert_eq!(SaveEnvelope::CURRENT_VERSION, 1); + // + // v1 → v2 (Stage 3 of mod-system plan): `PresentationPlayer` gained + // `controller_id: String` and `controller_hash: [u8; 32]` so saves + // fingerprint which AI controller each slot ran. + assert_eq!(SaveEnvelope::CURRENT_VERSION, 2); }