feat(api-gdext): Introduce multi-slot functionality in the Godot extension API with new functions and structs in lib.rs and corresponding tests in save_envelope.rs

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-17 23:59:31 -07:00
parent efdcd23e39
commit 2727d2f249
2 changed files with 90 additions and 5 deletions

View file

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

View file

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