From fba5cdfdfbbc38ff76d82336603178fbf0dd1c05 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 27 Jun 2026 16:10:27 -0400 Subject: [PATCH] feat(gdext): scaffold live Rust-authoritative unit store (Rail-1 Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the proven `presentation_cities` city store for units: a parallel `presentation_units: Vec>` slot on GdGameState, owned by a new `unit_slot` ops module that is the exact unit-side analogue of `city_slot`. - `api-gdext/src/unit_slot.rs`: bounds-safe `at/at_mut`, stable-u32-id `index_by_id/locate_by_id`, `spawn`, `move_unit`, `transfer_owner` (capture), `remove` (index-shift-safe), `take_damage/heal/set_hp`, posture setters (fortify/sentry/movement), `to_dict` projection, `to_json/load_from_json` serde round-trip. Pure holding + projection; turn/action logic stays in mc-turn / mc-player-api dispatch (no duplication of MapUnit mutation). - GdGameState gains `presentation_units`, initialised parallel to `presentation_cities` (empty rows grow on demand) and folded into the save envelope at every site (init, serialize_full, load_from_json). - SaveEnvelope gains `presentation_units` (#[serde(default)]); CURRENT_VERSION bumped v3 → v4. save_envelope.rs literals + version-lock test updated. - `#[func]` delegators on GdGameState mirror the city_* surface (spawn_unit, move_unit_slot, transfer_unit, remove_unit, unit_take_damage/heal/set_hp, posture setters, unit_dict, unit_index_by_id/locate_by_id, unit_to_json/ load_from_json). - `GdUnit` per-instance wrapper for parity with GdCity (owned MapUnit, to_dict/to_json/field reads). Tests: 7 unit_slot ops tests (spawn→locate, move, remove-shift-id-stable, damage/heal clamp, posture, transfer between rows, json round-trip) + the updated save_envelope suite, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/simulator/api-gdext/src/lib.rs | 249 ++++++++++- src/simulator/api-gdext/src/unit_slot.rs | 406 ++++++++++++++++++ .../api-gdext/tests/save_envelope.rs | 14 +- 3 files changed, 664 insertions(+), 5 deletions(-) create mode 100644 src/simulator/api-gdext/src/unit_slot.rs diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index ca4bb3b0..6950ff5d 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -21,6 +21,7 @@ pub mod observation; pub mod player_api; pub mod replay; pub mod score; +pub mod unit_slot; use godot::prelude::*; @@ -2581,6 +2582,102 @@ impl GdCity { } } +// ── GdUnit ────────────────────────────────────────────────────────────── +// +// Rail-1 spine rewrite, Phase 1 — per-instance wrapper over an owned +// `mc_state::MapUnit`, the unit-side analogue of `GdCity`. Holds a single +// unit's full gameplay state and exposes it to GDScript via `to_dict` + field +// reads. The primary live-game store path is the `(pi, ui)`-addressed +// `presentation_units` slot on `GdGameState` (delegating to `unit_slot`); this +// wrapper exists for parity with `GdCity` and for the cases where GDScript +// holds a detached unit projection (e.g. a combat-preview snapshot). All +// mutation that drives the sim still flows through `mc-turn` / `mc-player-api` +// dispatch — this is a view + holding object, not a simulation surface. + +/// Full map-unit state model — id, position, hp/stats, posture flags, +/// equipped items, promotions, experience. One `GdUnit` per detached unit +/// projection. The owned `MapUnit` is set from JSON via `load_from_json`. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdUnit { + inner: mc_state::game_state::MapUnit, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdUnit { + fn init(base: Base) -> Self { + Self { + inner: mc_state::game_state::MapUnit::default(), + base, + } + } +} + +#[godot_api] +impl GdUnit { + /// Overwrite this wrapper's unit from a `mc_state::MapUnit` JSON dump. + /// Returns false on parse failure (`godot_error!`-logged). + #[func] + fn load_from_json(&mut self, json: GString) -> bool { + match serde_json::from_str::(json.to_string().as_str()) { + Ok(unit) => { + self.inner = unit; + true + } + Err(e) => { + godot_error!("GdUnit::load_from_json failed: {e}"); + false + } + } + } + + /// Serialise this wrapper's unit to JSON (`mc_state::MapUnit`). + #[func] + fn to_json(&self) -> GString { + match serde_json::to_string(&self.inner) { + Ok(s) => s.into(), + Err(e) => { + godot_error!("GdUnit::to_json failed: {e}"); + "{}".into() + } + } + } + + /// Full `MapUnit` field projection (same key set as + /// `GdGameState::unit_dict`, sharing the `unit_slot::to_dict` core via a + /// one-element slot so the projection stays single-sourced / DRY). + #[func] + fn to_dict(&self) -> Dictionary { + let slot: Vec> = vec![vec![self.inner.clone()]]; + crate::unit_slot::to_dict(&slot, 0, 0) + } + + /// Stable monotonic unit id. + #[func] + fn id(&self) -> i64 { + self.inner.id as i64 + } + + /// Unit-type id (e.g. `"dwarf_warrior"`). + #[func] + fn unit_type(&self) -> GString { + GString::from(self.inner.unit_id.as_str()) + } + + /// Current map position as `Vector2i(col, row)`. + #[func] + fn position(&self) -> Vector2i { + Vector2i::new(self.inner.col, self.inner.row) + } + + /// `(hp, max_hp)` as `Vector2i`. + #[func] + fn hp_pair(&self) -> Vector2i { + Vector2i::new(self.inner.hp, self.inner.max_hp) + } +} + // ── GdLootRoller ──────────────────────────────────────────────────────── // // Stateless wrapper around `mc_combat::loot`. Used by item_system.gd on @@ -3002,6 +3099,16 @@ pub struct GdGameState { /// today it is a runtime-only authority, matching how cities still /// persist through the GDScript save path until the SaveManager rewrite. presentation_cities: Vec>, + /// Rail-1 spine rewrite, Phase 1 — the parallel canonical `mc_state::MapUnit` + /// slot (`[player_slot][unit_idx]`), the unit-side analogue of + /// `presentation_cities`. Holds the *full* gameplay `MapUnit` (id, position, + /// hp/stats, equipped, promotions, posture flags) the renderers and the + /// future `UnitScript` thin view read back through `unit_dict`, kept parallel + /// to the bench `inner.players[pi].units` (untouched for MCTS/AI/turn-processor + /// parity). Populated by GDScript via `spawn_unit` at spawn-time; the death / + /// capture hand-off uses `remove_unit` / `transfer_unit`. Folded into + /// `SaveEnvelope` (v4) for the canonical save round-trip. See [`unit_slot`]. + presentation_units: Vec>, /// p2-72b Brick 3c — shared item-production catalog for the parallel-slot /// cities. The catalog (which items each building can craft, costs, /// material/tech/resource gates) is game data identical across every city, @@ -3034,6 +3141,14 @@ pub struct SaveEnvelope { /// first under the disposable-saves policy. #[serde(default)] pub presentation_cities: Vec>, + /// Rail-1 spine rewrite, Phase 1 — the parallel canonical `mc_state::MapUnit` + /// slot (`[player_slot][unit_idx]`), the unit-side analogue of + /// `presentation_cities`. `#[serde(default)]` so pre-v4 envelopes (which + /// lacked this field) deserialise to an empty slot rather than erroring — + /// though the version gate in `load_from_json` rejects them first under the + /// disposable-saves policy. + #[serde(default)] + pub presentation_units: Vec>, } impl SaveEnvelope { @@ -3053,7 +3168,13 @@ impl SaveEnvelope { /// renderers + `CityScript` view read through `city_dict`. The bench /// `sim.players[pi].cities` (`Vec`) is unchanged. Pre-v3 /// saves are rejected at load (disposable-saves policy). - pub const CURRENT_VERSION: u32 = 3; + /// - **v4** (Rail-1 spine rewrite, Phase 1) — envelope gains + /// `presentation_units: Vec>`, the parallel + /// full-`MapUnit` slot the renderers + `UnitScript` view read through + /// `unit_dict`, the unit-side analogue of `presentation_cities`. The bench + /// `sim.players[pi].units` is unchanged. Pre-v4 saves are rejected at load + /// (disposable-saves policy). + pub const CURRENT_VERSION: u32 = 4; } /// Stage 7 — inspect a presentation side-table for controller ids that @@ -3130,6 +3251,7 @@ impl IRefCounted for GdGameState { inner, presentation_players: Vec::new(), presentation_cities: Vec::new(), + presentation_units: Vec::new(), city_item_registry: mc_city::ItemRegistry::new(), base, } @@ -3209,6 +3331,7 @@ impl GdGameState { sim: self.inner.clone(), presentation: self.presentation_players.clone(), presentation_cities: self.presentation_cities.clone(), + presentation_units: self.presentation_units.clone(), }; match serde_json::to_string(&envelope) { Ok(s) => s.into(), @@ -3270,6 +3393,7 @@ impl GdGameState { self.inner = envelope.sim; self.presentation_players = envelope.presentation; self.presentation_cities = envelope.presentation_cities; + self.presentation_units = envelope.presentation_units; self.inner.units_catalog = units_catalog; self.inner.improvement_registry = improvement_registry; @@ -4272,6 +4396,129 @@ impl GdGameState { city_slot::mark_captured(&mut self.presentation_cities, pi, ci, turn); } + // ── Rail-1 spine rewrite, Phase 1 — parallel `mc_state::MapUnit` slot ────── + // `presentation_units[pi][ui]` is the full gameplay unit the renderers + + // the future `UnitScript` thin view read. The bench `inner.players[pi].units` + // is untouched, preserving MCTS/AI/turn-processor parity. The two are kept + // aligned by the GDScript spawn/death/capture hand-off. These thin `#[func]`s + // delegate into [`unit_slot`] (one module, one concern), exactly mirroring + // the `city_*` delegators above. Turn/action mutation logic stays in + // `mc-turn` / `mc-player-api` dispatch — this slot only holds + projects. + + /// Number of units in the parallel slot for player `pi`. Distinct from the + /// bench `inner.players[pi].units` count; a divergence signals a hand-off gap. + #[func] + fn presentation_unit_count(&self, pi: i64) -> i64 { + unit_slot::count(&self.presentation_units, pi) + } + + /// Append a full `mc_state::MapUnit` (parsed from JSON) to player `pi`'s + /// parallel slot and return its index. Grows the per-player row on demand. + /// The bench store assigns the stable `id` + resolves catalog stats; this + /// slot receives the finished unit's JSON. Returns -1 on parse failure. + #[func] + fn spawn_unit(&mut self, pi: i64, unit_json: GString) -> i64 { + match serde_json::from_str::(unit_json.to_string().as_str()) + { + Ok(unit) => unit_slot::spawn(&mut self.presentation_units, pi, unit), + Err(e) => { + godot_error!("GdGameState::spawn_unit parse failed: {e}"); + -1 + } + } + } + + /// Set the addressed parallel-slot unit's map position. + #[func] + fn move_unit_slot(&mut self, pi: i64, ui: i64, col: i64, row: i64) { + unit_slot::move_unit(&mut self.presentation_units, pi, ui, col, row); + } + + /// Move a unit between players' slots (capture hand-off); returns the new + /// index in `to_pi`, or -1 if the source index is out of range. Callers must + /// re-stamp affected `UnitScript` view indices afterward. + #[func] + fn transfer_unit(&mut self, from_pi: i64, from_ui: i64, to_pi: i64) -> i64 { + unit_slot::transfer_owner(&mut self.presentation_units, from_pi, from_ui, to_pi) + } + + /// Remove a parallel-slot unit by index (death / unit-loss hand-off), + /// shifting the rest of the row down. Survivors remain addressable by id. + #[func] + fn remove_unit(&mut self, pi: i64, ui: i64) { + unit_slot::remove(&mut self.presentation_units, pi, ui); + } + + /// Apply combat damage to the addressed parallel-slot unit (clamped at 0). + #[func] + fn unit_take_damage(&mut self, pi: i64, ui: i64, damage: i64) { + unit_slot::take_damage(&mut self.presentation_units, pi, ui, damage); + } + + /// Heal the addressed parallel-slot unit by an amount (clamped to max_hp). + #[func] + fn unit_heal(&mut self, pi: i64, ui: i64, amount: i64) { + unit_slot::heal(&mut self.presentation_units, pi, ui, amount); + } + + /// Set the addressed parallel-slot unit's current HP (clamped `[0, max_hp]`). + #[func] + fn unit_set_hp(&mut self, pi: i64, ui: i64, hp: i64) { + unit_slot::set_hp(&mut self.presentation_units, pi, ui, hp); + } + + /// Set the addressed parallel-slot unit's fortified posture flag. + #[func] + fn unit_set_fortified(&mut self, pi: i64, ui: i64, fortified: bool) { + unit_slot::set_fortified(&mut self.presentation_units, pi, ui, fortified); + } + + /// Set the addressed parallel-slot unit's sentry posture flag. + #[func] + fn unit_set_sentrying(&mut self, pi: i64, ui: i64, sentrying: bool) { + unit_slot::set_sentrying(&mut self.presentation_units, pi, ui, sentrying); + } + + /// Set the addressed parallel-slot unit's remaining movement points. + #[func] + fn unit_set_movement_remaining(&mut self, pi: i64, ui: i64, moves: i64) { + unit_slot::set_movement_remaining(&mut self.presentation_units, pi, ui, moves); + } + + /// Full `MapUnit` field projection for the addressed parallel-slot unit. + /// Empty `Dictionary` if out of range. The key set the `UnitScript` thin + /// view + renderers read. + #[func] + fn unit_dict(&self, pi: i64, ui: i64) -> Dictionary { + unit_slot::to_dict(&self.presentation_units, pi, ui) + } + + /// Resolve player `pi`'s unit index by stable `MapUnit::id`, or -1 if absent. + #[func] + fn unit_index_by_id(&self, pi: i64, id: i64) -> i64 { + unit_slot::index_by_id(&self.presentation_units, pi, id.max(0) as u32) + } + + /// Cross-player resolve of a unit by stable `MapUnit::id`, returning + /// `Vector2i(pi, ui)` or `(-1, -1)` if absent. + #[func] + fn unit_locate_by_id(&self, id: i64) -> Vector2i { + let (pi, ui) = unit_slot::locate_by_id(&self.presentation_units, id.max(0) as u32); + Vector2i::new(pi as i32, ui as i32) + } + + /// Serialise the addressed parallel-slot unit to JSON (`mc_state::MapUnit`). + #[func] + fn unit_to_json(&self, pi: i64, ui: i64) -> GString { + GString::from(unit_slot::to_json(&self.presentation_units, pi, ui)) + } + + /// Overwrite the addressed parallel-slot unit from JSON (save-load). + #[func] + fn unit_load_from_json(&mut self, pi: i64, ui: i64, json: GString) -> bool { + unit_slot::load_from_json(&mut self.presentation_units, pi, ui, &json.to_string()) + } + /// Apply FoundCity for the AI dispatch / replay surface (p1-29j root unblock /// + p1-29k Inc-3). Delegates to mc_player_api::apply_action which routes /// through action_handlers::handle_found_city (the canonical mc_turn path diff --git a/src/simulator/api-gdext/src/unit_slot.rs b/src/simulator/api-gdext/src/unit_slot.rs new file mode 100644 index 00000000..767a5320 --- /dev/null +++ b/src/simulator/api-gdext/src/unit_slot.rs @@ -0,0 +1,406 @@ +//! Rail-1 spine rewrite, Phase 1 — the parallel canonical `mc_state::MapUnit` +//! render slot, mirroring [`crate::city_slot`]. +//! +//! `GdGameState` holds `presentation_units: Vec>` — the +//! full gameplay [`MapUnit`] per `(player, unit)` that the renderers and the +//! (future) `UnitScript` thin view read, kept *parallel* to the bench +//! `inner.players[pi].units` (left untouched for MCTS / AI / turn-processor +//! parity). This module owns the slot's behaviour; the `#[godot_api] impl +//! GdGameState` in `lib.rs` exposes only thin `#[func]` delegators into here — +//! one module, one concern. It is the exact unit-side analogue of +//! [`crate::city_slot`]'s ops over `presentation_cities`. +//! +//! Indexing is uniform: `(pi, ui)` as `i64` from GDScript, bounds-checked on +//! every access; out-of-range reads return empties and out-of-range writes are +//! no-ops (the GDScript view must never panic the engine on a stale index). +//! +//! This module is a **holding + projection** structure only — turn/action +//! mutation logic stays in `mc-turn` / `mc-player-api` (`dispatch`). The ops +//! here move units between rows, set position, apply damage/heal, and toggle +//! posture flags that the renderer/view reads back; they deliberately do NOT +//! duplicate the dispatch action path. + +use godot::prelude::*; +use mc_state::game_state::MapUnit; + +/// Number of units in player `pi`'s parallel slot. +#[must_use] +pub fn count(slot: &[Vec], pi: i64) -> i64 { + slot.get(usize::try_from(pi).unwrap_or(usize::MAX)) + .map_or(0, |row| row.len() as i64) +} + +/// Resolve player `pi`'s unit index by stable [`MapUnit::id`], or `-1` if +/// absent. This is the addressing primitive the `UnitScript` thin view keys +/// on: it caches `(pi, ui)` but re-resolves `ui` from `id` on access, so a +/// death — which shifts a row's later indices via [`remove`] — or a missed +/// capture re-stamp can never silently address the *wrong* unit. `id` is the +/// same stable monotonic `u32` the bench store assigns at spawn +/// (`GameState::next_unit_id`), so resolution is stable across stores. +#[must_use] +pub fn index_by_id(slot: &[Vec], pi: i64, id: u32) -> i64 { + let Ok(pi) = usize::try_from(pi) else { + return -1; + }; + slot.get(pi) + .and_then(|row| row.iter().position(|u| u.id == id)) + .map_or(-1, |ui| ui as i64) +} + +/// Cross-player resolve of a unit by stable [`MapUnit::id`], returning +/// `(pi, ui)` or `(-1, -1)` if absent. Capture ([`transfer_owner`]) moves a +/// unit between players' rows, changing **both** `pi` and `ui`; a `UnitScript` +/// view that missed its re-stamp recovers by locating its `id` across every +/// row rather than addressing a stale slot. First match wins (`id`s are +/// unique, monotonic from `GameState::next_unit_id`). +#[must_use] +pub fn locate_by_id(slot: &[Vec], id: u32) -> (i64, i64) { + for (pi, row) in slot.iter().enumerate() { + if let Some(ui) = row.iter().position(|u| u.id == id) { + return (pi as i64, ui as i64); + } + } + (-1, -1) +} + +/// Push a [`MapUnit`] onto player `pi`'s slot (growing the row on demand) and +/// return its index. The unit is taken pre-built (the bench store assigns the +/// stable `id` and resolves catalog stats; the parallel slot only holds the +/// projection). Mirrors [`crate::city_slot::spawn`] but does not construct — +/// units carry far more spawn-time context (catalog moves, quality, AP) than a +/// city's `City::found`, so construction stays on the bench side and the slot +/// receives the finished unit. +pub fn spawn(slot: &mut Vec>, pi: i64, unit: MapUnit) -> i64 { + let pi = pi.max(0) as usize; + if slot.len() <= pi { + slot.resize_with(pi + 1, Vec::new); + } + let idx = slot[pi].len(); + slot[pi].push(unit); + idx as i64 +} + +/// Set the addressed unit's map position. No-op if out of range. +pub fn move_unit(slot: &mut [Vec], pi: i64, ui: i64, col: i64, row: i64) { + if let Some(u) = at_mut(slot, pi, ui) { + u.col = col as i32; + u.row = row as i32; + } +} + +/// Move a unit from `(from_pi, from_ui)` to the end of player `to_pi`'s row — +/// the capture / ownership-transfer hand-off. Returns the new index in +/// `to_pi`, or -1 if the source index is out of range. NOTE: removing from the +/// source row shifts that row's later indices; callers must re-stamp affected +/// `UnitScript` views. Capture-time posture (fortify/sentry/etc.) is left on +/// the unit; the caller resets what the new owner should not inherit. +pub fn transfer_owner(slot: &mut Vec>, from_pi: i64, from_ui: i64, to_pi: i64) -> i64 { + let (Ok(fpi), Ok(fui), Ok(tpi)) = + (usize::try_from(from_pi), usize::try_from(from_ui), usize::try_from(to_pi)) + else { + return -1; + }; + let Some(src_row) = slot.get(fpi) else { + return -1; + }; + if fui >= src_row.len() { + return -1; + } + let unit = slot[fpi].remove(fui); + if slot.len() <= tpi { + slot.resize_with(tpi + 1, Vec::new); + } + let new_idx = slot[tpi].len(); + slot[tpi].push(unit); + new_idx as i64 +} + +/// Remove a parallel-slot unit by index, shifting the rest of the row down. +/// The death / unit-loss hand-off. No-op if out of range. NOTE: this shifts +/// later indices in the row; survivors are still addressable via +/// [`index_by_id`] / [`locate_by_id`] (id is stable across the shift). +pub fn remove(slot: &mut [Vec], pi: i64, ui: i64) { + if let Some(row) = slot.get_mut(usize::try_from(pi).unwrap_or(usize::MAX)) { + if let Ok(ui) = usize::try_from(ui) { + if ui < row.len() { + row.remove(ui); + } + } + } +} + +/// Apply combat damage to the addressed unit (clamped at 0). No-op if OOR. +pub fn take_damage(slot: &mut [Vec], pi: i64, ui: i64, damage: i64) { + if let Some(u) = at_mut(slot, pi, ui) { + let d = damage.max(0) as i32; + u.hp = (u.hp - d).max(0); + } +} + +/// Heal the addressed unit by an explicit amount (clamped to `max_hp`). +/// No-op if out of range. +pub fn heal(slot: &mut [Vec], pi: i64, ui: i64, amount: i64) { + if let Some(u) = at_mut(slot, pi, ui) { + let a = amount.max(0) as i32; + u.hp = (u.hp + a).min(u.max_hp); + } +} + +/// Set current HP directly (clamped to `[0, max_hp]`). No-op if out of range. +pub fn set_hp(slot: &mut [Vec], pi: i64, ui: i64, hp: i64) { + if let Some(u) = at_mut(slot, pi, ui) { + u.hp = (hp.max(0) as i32).min(u.max_hp); + } +} + +/// Set the fortified posture flag. No-op if out of range. +pub fn set_fortified(slot: &mut [Vec], pi: i64, ui: i64, fortified: bool) { + if let Some(u) = at_mut(slot, pi, ui) { + u.is_fortified = fortified; + } +} + +/// Set the sentry posture flag. No-op if out of range. +pub fn set_sentrying(slot: &mut [Vec], pi: i64, ui: i64, sentrying: bool) { + if let Some(u) = at_mut(slot, pi, ui) { + u.is_sentrying = sentrying; + } +} + +/// Set remaining movement points for the addressed unit (clamped at 0). +/// No-op if out of range. +pub fn set_movement_remaining(slot: &mut [Vec], pi: i64, ui: i64, moves: i64) { + if let Some(u) = at_mut(slot, pi, ui) { + u.movement_remaining = moves.max(0) as i32; + } +} + +/// Convenience query: `(hp, max_hp)` for the addressed unit, `(0, 0)` if OOR. +#[must_use] +pub fn hp_pair(slot: &[Vec], pi: i64, ui: i64) -> (i64, i64) { + at(slot, pi, ui).map_or((0, 0), |u| (u.hp as i64, u.max_hp as i64)) +} + +/// Serialise the addressed unit to JSON (`mc_state::MapUnit` serde). `"{}"` if +/// out of range. +#[must_use] +pub fn to_json(slot: &[Vec], pi: i64, ui: i64) -> String { + at(slot, pi, ui) + .and_then(|u| serde_json::to_string(u).ok()) + .unwrap_or_else(|| "{}".to_string()) +} + +/// Overwrite the addressed unit from JSON (save-load). Returns false on parse +/// error or out-of-range index. +pub fn load_from_json(slot: &mut [Vec], pi: i64, ui: i64, json: &str) -> bool { + let Ok(unit) = serde_json::from_str::(json) else { + return false; + }; + if let Some(dst) = at_mut(slot, pi, ui) { + *dst = unit; + true + } else { + false + } +} + +/// Full [`MapUnit`] field projection for player `pi`'s unit `ui`. Returns an +/// empty `Dictionary` if the index is out of range. The key set covers the +/// fields a `UnitScript` thin view + the renderers read, so the class can +/// collapse to a pure view over this projection. Complex sub-collections +/// (`equipped`, `status_effects`, `promotions`) are projected as their JSON +/// string so the view can lazily parse them without a dedicated `#[func]`. +#[must_use] +pub fn to_dict(slot: &[Vec], pi: i64, ui: i64) -> Dictionary { + let mut d = Dictionary::new(); + let Some(u) = at(slot, pi, ui) else { + return d; + }; + d.set("id", u.id as i64); + d.set("unit_id", GString::from(u.unit_id.as_str())); + d.set("position", Vector2i::new(u.col, u.row)); + d.set("col", u.col as i64); + d.set("row", u.row as i64); + d.set("hp", u.hp as i64); + d.set("max_hp", u.max_hp as i64); + d.set("attack", u.attack as i64); + d.set("defense", u.defense as i64); + d.set("base_moves", u.base_moves as i64); + d.set("movement_remaining", u.movement_remaining as i64); + d.set("experience", u.experience as i64); + d.set("is_fortified", u.is_fortified); + d.set("is_sentrying", u.is_sentrying); + d.set("is_embarked", u.is_embarked); + d.set("is_deployed", u.is_deployed); + d.set("is_stealthed", u.is_stealthed); + d.set("facing_edge", u.facing_edge as i64); + let promotions: Array = + u.promotions.iter().map(|p| GString::from(p.as_str())).collect(); + d.set("promotions", promotions); + d.set("pending_promotion", match &u.pending_promotion { + Some(p) => GString::from(p.as_str()), + None => GString::new(), + }); + d.set( + "equipped_json", + GString::from(serde_json::to_string(&u.equipped).unwrap_or_else(|_| "[]".to_string())), + ); + d.set( + "status_effects_json", + GString::from(serde_json::to_string(&u.status_effects).unwrap_or_else(|_| "[]".to_string())), + ); + d +} + +// ── internals ────────────────────────────────────────────────────────────── + +/// Immutable `(pi, ui)` lookup with i64→usize bounds safety. +fn at(slot: &[Vec], pi: i64, ui: i64) -> Option<&MapUnit> { + let pi = usize::try_from(pi).ok()?; + let ui = usize::try_from(ui).ok()?; + slot.get(pi)?.get(ui) +} + +/// Mutable `(pi, ui)` lookup with i64→usize bounds safety. +fn at_mut(slot: &mut [Vec], pi: i64, ui: i64) -> Option<&mut MapUnit> { + let pi = usize::try_from(pi).ok()?; + let ui = usize::try_from(ui).ok()?; + slot.get_mut(pi)?.get_mut(ui) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal `MapUnit` with a given id/position/hp for slot tests. + /// Goes through `MapUnit::default()` so every serde-defaulted field is + /// present (the save round-trip relies on that). + fn unit(id: u32, col: i32, row: i32, hp: i32) -> MapUnit { + MapUnit { + id, + col, + row, + hp, + max_hp: hp, + attack: 5, + defense: 3, + base_moves: 2, + movement_remaining: 2, + unit_id: "dwarf_warrior".to_string(), + ..MapUnit::default() + } + } + + #[test] + fn spawn_then_locate_by_id_resolves() { + let mut slot: Vec> = Vec::new(); + let idx = spawn(&mut slot, 0, unit(7, 1, 1, 10)); + assert_eq!(idx, 0); + assert_eq!(count(&slot, 0), 1); + assert_eq!(index_by_id(&slot, 0, 7), 0); + assert_eq!(locate_by_id(&slot, 7), (0, 0)); + // Absent id resolves to the sentinel, never a panic. + assert_eq!(index_by_id(&slot, 0, 99), -1); + assert_eq!(locate_by_id(&slot, 99), (-1, -1)); + } + + #[test] + fn move_unit_updates_position() { + let mut slot: Vec> = vec![vec![unit(1, 0, 0, 10)]]; + move_unit(&mut slot, 0, 0, 4, 6); + let u = at(&slot, 0, 0).expect("unit present"); + assert_eq!((u.col, u.row), (4, 6)); + // Out-of-range move is a silent no-op. + move_unit(&mut slot, 9, 9, 1, 1); + assert_eq!(count(&slot, 0), 1); + } + + #[test] + fn remove_shifts_indices_but_id_still_resolves() { + let mut slot: Vec> = + vec![vec![unit(10, 0, 0, 5), unit(20, 1, 1, 5), unit(30, 2, 2, 5)]]; + // Remove the middle unit; later indices shift down by one. + remove(&mut slot, 0, 1); + assert_eq!(count(&slot, 0), 2); + // Survivor 30 moved from index 2 → 1, but its stable id still locates it. + assert_eq!(index_by_id(&slot, 0, 30), 1); + assert_eq!(index_by_id(&slot, 0, 10), 0); + // The removed unit is gone. + assert_eq!(index_by_id(&slot, 0, 20), -1); + assert_eq!(locate_by_id(&slot, 20), (-1, -1)); + } + + #[test] + fn take_damage_and_heal_clamp() { + let mut slot: Vec> = vec![vec![unit(1, 0, 0, 10)]]; + take_damage(&mut slot, 0, 0, 3); + assert_eq!(hp_pair(&slot, 0, 0), (7, 10)); + // Overheal clamps at max_hp. + heal(&mut slot, 0, 0, 100); + assert_eq!(hp_pair(&slot, 0, 0), (10, 10)); + // Overkill clamps at 0, never negative. + take_damage(&mut slot, 0, 0, 999); + assert_eq!(hp_pair(&slot, 0, 0), (0, 10)); + // Out-of-range mutators are no-ops (no panic). + take_damage(&mut slot, 5, 5, 1); + heal(&mut slot, 5, 5, 1); + } + + #[test] + fn posture_setters_toggle() { + let mut slot: Vec> = vec![vec![unit(1, 0, 0, 10)]]; + set_fortified(&mut slot, 0, 0, true); + set_sentrying(&mut slot, 0, 0, true); + set_movement_remaining(&mut slot, 0, 0, 0); + let u = at(&slot, 0, 0).expect("unit present"); + assert!(u.is_fortified); + assert!(u.is_sentrying); + assert_eq!(u.movement_remaining, 0); + } + + #[test] + fn transfer_owner_moves_between_rows() { + // Two players; capture unit 42 from player 0 into player 1's row. + let mut slot: Vec> = + vec![vec![unit(41, 0, 0, 5), unit(42, 1, 1, 5)], vec![unit(50, 9, 9, 5)]]; + let new_idx = transfer_owner(&mut slot, 0, 1, 1); + assert_eq!(new_idx, 1, "appended to end of player 1's row"); + assert_eq!(count(&slot, 0), 1, "removed from source row"); + assert_eq!(count(&slot, 1), 2, "added to dest row"); + // The captured unit is now under player 1, still resolvable by id. + assert_eq!(locate_by_id(&slot, 42), (1, 1)); + // Source-out-of-range transfer is the -1 sentinel, no panic. + assert_eq!(transfer_owner(&mut slot, 0, 9, 1), -1); + } + + #[test] + fn to_json_load_from_json_round_trips() { + let mut u = unit(77, 3, 4, 8); + u.experience = 12; + u.is_fortified = true; + u.promotions = vec!["shock".to_string(), "drill".to_string()]; + let slot: Vec> = vec![vec![u]]; + + let json1 = to_json(&slot, 0, 0); + assert!(json1.contains("\"id\":77")); + assert!(json1.contains("shock")); + + // Restore into a fresh slot via the real load path, then re-serialize. + let mut slot2: Vec> = vec![vec![MapUnit::default()]]; + assert!(load_from_json(&mut slot2, 0, 0, &json1), "load must succeed"); + let json2 = to_json(&slot2, 0, 0); + assert_eq!(json1, json2, "unit save round-trip must be byte-identical"); + + let restored = at(&slot2, 0, 0).expect("restored unit present"); + assert_eq!(restored.id, 77); + assert_eq!(restored.experience, 12); + assert!(restored.is_fortified); + assert_eq!(restored.promotions, vec!["shock", "drill"]); + + // Load into an out-of-range slot fails cleanly. + let mut empty: Vec> = Vec::new(); + assert!(!load_from_json(&mut empty, 0, 0, &json1)); + // Malformed JSON fails cleanly. + assert!(!load_from_json(&mut slot2, 0, 0, "{ not json")); + } +} diff --git a/src/simulator/api-gdext/tests/save_envelope.rs b/src/simulator/api-gdext/tests/save_envelope.rs index 9e28e121..e5b0b05d 100644 --- a/src/simulator/api-gdext/tests/save_envelope.rs +++ b/src/simulator/api-gdext/tests/save_envelope.rs @@ -18,10 +18,11 @@ fn empty_envelope_round_trips() { sim: GameState::default(), presentation: Vec::new(), presentation_cities: Vec::new(), + presentation_units: Vec::new(), }; 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, 3); + assert_eq!(back.save_format_version, 4); assert!(back.presentation.is_empty()); assert!(back.presentation_cities.is_empty()); assert_eq!(back.sim.turn, 0); @@ -53,6 +54,7 @@ fn presentation_cities_round_trip() { sim: GameState::default(), presentation: Vec::new(), presentation_cities: vec![vec![capital, second], Vec::new()], + presentation_units: Vec::new(), }; let json = serde_json::to_string(&env).expect("serialize"); let back: SaveEnvelope = serde_json::from_str(&json).expect("deserialize"); @@ -118,6 +120,7 @@ fn populated_envelope_round_trips_byte_identical() { sim, presentation, presentation_cities: Vec::new(), + presentation_units: Vec::new(), }; let json = serde_json::to_string(&env).expect("serialize"); let back: SaveEnvelope = @@ -129,7 +132,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, 3); + assert_eq!(back.save_format_version, 4); assert_eq!(back.sim.turn, 7); assert_eq!(back.sim.era, 2); assert_eq!(back.sim.map_seed, 0xfeed_face); @@ -205,7 +208,7 @@ fn controller_validation_flags_missing_controller() { } #[test] -fn version_three_is_locked() { +fn version_four_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. @@ -215,5 +218,8 @@ fn version_three_is_locked() { // fingerprint which AI controller each slot ran. // v2 → v3 (p2-72b Path 2): envelope gained `presentation_cities: // Vec>`, the parallel full-`City` render slot. - assert_eq!(SaveEnvelope::CURRENT_VERSION, 3); + // v3 → v4 (Rail-1 spine rewrite, Phase 1): envelope gained + // `presentation_units: Vec>`, the parallel + // full-`MapUnit` render slot (unit-side analogue of presentation_cities). + assert_eq!(SaveEnvelope::CURRENT_VERSION, 4); }