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:
parent
035aff80b5
commit
fba5cdfdfb
3 changed files with 664 additions and 5 deletions
|
|
@ -21,6 +21,7 @@ pub mod observation;
|
||||||
pub mod player_api;
|
pub mod player_api;
|
||||||
pub mod replay;
|
pub mod replay;
|
||||||
pub mod score;
|
pub mod score;
|
||||||
|
pub mod unit_slot;
|
||||||
|
|
||||||
use godot::prelude::*;
|
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 ────────────────────────────────────────────────────────
|
// ── GdLootRoller ────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Stateless wrapper around `mc_combat::loot`. Used by item_system.gd on
|
// 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
|
/// today it is a runtime-only authority, matching how cities still
|
||||||
/// persist through the GDScript save path until the SaveManager rewrite.
|
/// persist through the GDScript save path until the SaveManager rewrite.
|
||||||
presentation_cities: Vec<Vec<mc_city::City>>,
|
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
|
/// p2-72b Brick 3c — shared item-production catalog for the parallel-slot
|
||||||
/// cities. The catalog (which items each building can craft, costs,
|
/// cities. The catalog (which items each building can craft, costs,
|
||||||
/// material/tech/resource gates) is game data identical across every city,
|
/// material/tech/resource gates) is game data identical across every city,
|
||||||
|
|
@ -3034,6 +3141,14 @@ pub struct SaveEnvelope {
|
||||||
/// first under the disposable-saves policy.
|
/// first under the disposable-saves policy.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub presentation_cities: Vec<Vec<mc_city::City>>,
|
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 {
|
impl SaveEnvelope {
|
||||||
|
|
@ -3053,7 +3168,13 @@ impl SaveEnvelope {
|
||||||
/// renderers + `CityScript` view read through `city_dict`. The bench
|
/// renderers + `CityScript` view read through `city_dict`. The bench
|
||||||
/// `sim.players[pi].cities` (`Vec<CityState>`) is unchanged. Pre-v3
|
/// `sim.players[pi].cities` (`Vec<CityState>`) is unchanged. Pre-v3
|
||||||
/// saves are rejected at load (disposable-saves policy).
|
/// 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
|
/// Stage 7 — inspect a presentation side-table for controller ids that
|
||||||
|
|
@ -3130,6 +3251,7 @@ impl IRefCounted for GdGameState {
|
||||||
inner,
|
inner,
|
||||||
presentation_players: Vec::new(),
|
presentation_players: Vec::new(),
|
||||||
presentation_cities: Vec::new(),
|
presentation_cities: Vec::new(),
|
||||||
|
presentation_units: Vec::new(),
|
||||||
city_item_registry: mc_city::ItemRegistry::new(),
|
city_item_registry: mc_city::ItemRegistry::new(),
|
||||||
base,
|
base,
|
||||||
}
|
}
|
||||||
|
|
@ -3209,6 +3331,7 @@ impl GdGameState {
|
||||||
sim: self.inner.clone(),
|
sim: self.inner.clone(),
|
||||||
presentation: self.presentation_players.clone(),
|
presentation: self.presentation_players.clone(),
|
||||||
presentation_cities: self.presentation_cities.clone(),
|
presentation_cities: self.presentation_cities.clone(),
|
||||||
|
presentation_units: self.presentation_units.clone(),
|
||||||
};
|
};
|
||||||
match serde_json::to_string(&envelope) {
|
match serde_json::to_string(&envelope) {
|
||||||
Ok(s) => s.into(),
|
Ok(s) => s.into(),
|
||||||
|
|
@ -3270,6 +3393,7 @@ impl GdGameState {
|
||||||
self.inner = envelope.sim;
|
self.inner = envelope.sim;
|
||||||
self.presentation_players = envelope.presentation;
|
self.presentation_players = envelope.presentation;
|
||||||
self.presentation_cities = envelope.presentation_cities;
|
self.presentation_cities = envelope.presentation_cities;
|
||||||
|
self.presentation_units = envelope.presentation_units;
|
||||||
|
|
||||||
self.inner.units_catalog = units_catalog;
|
self.inner.units_catalog = units_catalog;
|
||||||
self.inner.improvement_registry = improvement_registry;
|
self.inner.improvement_registry = improvement_registry;
|
||||||
|
|
@ -4272,6 +4396,129 @@ impl GdGameState {
|
||||||
city_slot::mark_captured(&mut self.presentation_cities, pi, ci, turn);
|
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
|
/// 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
|
/// + p1-29k Inc-3). Delegates to mc_player_api::apply_action which routes
|
||||||
/// through action_handlers::handle_found_city (the canonical mc_turn path
|
/// through action_handlers::handle_found_city (the canonical mc_turn path
|
||||||
|
|
|
||||||
406
src/simulator/api-gdext/src/unit_slot.rs
Normal file
406
src/simulator/api-gdext/src/unit_slot.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,10 +18,11 @@ fn empty_envelope_round_trips() {
|
||||||
sim: GameState::default(),
|
sim: GameState::default(),
|
||||||
presentation: Vec::new(),
|
presentation: Vec::new(),
|
||||||
presentation_cities: Vec::new(),
|
presentation_cities: Vec::new(),
|
||||||
|
presentation_units: Vec::new(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&env).expect("serialize");
|
let json = serde_json::to_string(&env).expect("serialize");
|
||||||
let back: SaveEnvelope = serde_json::from_str(&json).expect("deserialize");
|
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.is_empty());
|
||||||
assert!(back.presentation_cities.is_empty());
|
assert!(back.presentation_cities.is_empty());
|
||||||
assert_eq!(back.sim.turn, 0);
|
assert_eq!(back.sim.turn, 0);
|
||||||
|
|
@ -53,6 +54,7 @@ fn presentation_cities_round_trip() {
|
||||||
sim: GameState::default(),
|
sim: GameState::default(),
|
||||||
presentation: Vec::new(),
|
presentation: Vec::new(),
|
||||||
presentation_cities: vec![vec![capital, second], Vec::new()],
|
presentation_cities: vec![vec![capital, second], Vec::new()],
|
||||||
|
presentation_units: Vec::new(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&env).expect("serialize");
|
let json = serde_json::to_string(&env).expect("serialize");
|
||||||
let back: SaveEnvelope = serde_json::from_str(&json).expect("deserialize");
|
let back: SaveEnvelope = serde_json::from_str(&json).expect("deserialize");
|
||||||
|
|
@ -118,6 +120,7 @@ fn populated_envelope_round_trips_byte_identical() {
|
||||||
sim,
|
sim,
|
||||||
presentation,
|
presentation,
|
||||||
presentation_cities: Vec::new(),
|
presentation_cities: Vec::new(),
|
||||||
|
presentation_units: Vec::new(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&env).expect("serialize");
|
let json = serde_json::to_string(&env).expect("serialize");
|
||||||
let back: SaveEnvelope =
|
let back: SaveEnvelope =
|
||||||
|
|
@ -129,7 +132,7 @@ fn populated_envelope_round_trips_byte_identical() {
|
||||||
let json2 = serde_json::to_string(&back).expect("re-serialize");
|
let json2 = serde_json::to_string(&back).expect("re-serialize");
|
||||||
assert_eq!(json, json2, "envelope must byte-equal across round-trip");
|
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.turn, 7);
|
||||||
assert_eq!(back.sim.era, 2);
|
assert_eq!(back.sim.era, 2);
|
||||||
assert_eq!(back.sim.map_seed, 0xfeed_face);
|
assert_eq!(back.sim.map_seed, 0xfeed_face);
|
||||||
|
|
@ -205,7 +208,7 @@ fn controller_validation_flags_missing_controller() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_three_is_locked() {
|
fn version_four_is_locked() {
|
||||||
// Lock the wire format version. Future breaking changes must bump
|
// Lock the wire format version. Future breaking changes must bump
|
||||||
// this constant in tandem with the `load_from_json` rejection
|
// this constant in tandem with the `load_from_json` rejection
|
||||||
// logic — this test guards against an accidental silent bump.
|
// 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.
|
// fingerprint which AI controller each slot ran.
|
||||||
// v2 → v3 (p2-72b Path 2): envelope gained `presentation_cities:
|
// v2 → v3 (p2-72b Path 2): envelope gained `presentation_cities:
|
||||||
// Vec<Vec<mc_city::City>>`, the parallel full-`City` render slot.
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue