feat(gdext): scaffold live Rust-authoritative unit store (Rail-1 Phase 1)

Mirror the proven `presentation_cities` city store for units: a parallel
`presentation_units: Vec<Vec<mc_state::MapUnit>>` 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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 16:10:27 -04:00
parent 035aff80b5
commit fba5cdfdfb
3 changed files with 664 additions and 5 deletions

View file

@ -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<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdUnit {
fn init(base: Base<RefCounted>) -> 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::<mc_state::game_state::MapUnit>(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<mc_state::game_state::MapUnit>> = 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<Vec<mc_city::City>>,
/// 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<Vec<mc_state::game_state::MapUnit>>,
/// 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<Vec<mc_city::City>>,
/// 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<Vec<mc_state::game_state::MapUnit>>,
}
impl SaveEnvelope {
@ -3053,7 +3168,13 @@ impl SaveEnvelope {
/// renderers + `CityScript` view read through `city_dict`. The bench
/// `sim.players[pi].cities` (`Vec<CityState>`) 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<Vec<mc_state::MapUnit>>`, 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::<mc_state::game_state::MapUnit>(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

View file

@ -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<Vec<mc_state::MapUnit>>` — 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<MapUnit>], 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<MapUnit>], 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<MapUnit>], 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<Vec<MapUnit>>, 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<MapUnit>], 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<Vec<MapUnit>>, 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<MapUnit>], 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<MapUnit>], 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<MapUnit>], 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<MapUnit>], 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<MapUnit>], 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<MapUnit>], 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<MapUnit>], 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<MapUnit>], 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<MapUnit>], 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<MapUnit>], pi: i64, ui: i64, json: &str) -> bool {
let Ok(unit) = serde_json::from_str::<MapUnit>(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<MapUnit>], 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<GString> =
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<MapUnit>], 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<MapUnit>], 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<MapUnit>> = 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<MapUnit>> = 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<MapUnit>> =
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<MapUnit>> = 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<MapUnit>> = 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<MapUnit>> =
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<MapUnit>> = 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<MapUnit>> = 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<MapUnit>> = 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"));
}
}

View file

@ -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<Vec<mc_city::City>>`, 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<Vec<mc_state::MapUnit>>`, the parallel
// full-`MapUnit` render slot (unit-side analogue of presentation_cities).
assert_eq!(SaveEnvelope::CURRENT_VERSION, 4);
}