feat(simulator): Implement parallel city state tracking for Path 2 in the simulator API

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-06 22:35:31 -07:00
parent 8a7c26ff12
commit ed76612ad2
2 changed files with 218 additions and 5 deletions

View file

@ -2935,9 +2935,37 @@ pub struct GdGameState {
/// GDScript via `set_player_presentation_json` at game-setup time and
/// round-tripped through `serialize_full` / `load_from_json`.
presentation_players: Vec<mc_core::PresentationPlayer>,
/// p2-72b Path 2 — parallel canonical `mc_city::City` slot, indexed
/// `[player_slot][city_idx]`. The bench `inner.players[pi].cities` stays
/// `Vec<CityState>` (9-field bench struct) for MCTS/AI/turn-processor
/// parity; this side-table carries the *full* gameplay `City` (~20 fields:
/// name, position, culture, tiles, focus, queues) that the renderers and
/// the future `CityScript` thin view read back through `city_dict`.
/// Populated by GDScript via `spawn_city` at founding-time; the
/// capture / city-loss hand-off uses `remove_city`. Save-format
/// round-trip (folding this into `SaveEnvelope`) is a follow-up brick —
/// 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>>,
base: Base<RefCounted>,
}
/// p2-72b Path 2 — map a `CityFocus` to the lowercase id string used by
/// `CityFocus::from_id` and the GDScript `CityScript.focus` field. Inverse of
/// `CityFocus::from_id`; kept here as the projection helper for `city_dict`.
#[must_use]
fn city_focus_id(focus: mc_city::CityFocus) -> &'static str {
use mc_city::CityFocus;
match focus {
CityFocus::Default => "default",
CityFocus::Food => "food",
CityFocus::Production => "production",
CityFocus::Gold => "gold",
CityFocus::Culture => "culture",
CityFocus::Science => "science",
}
}
/// p2-72a Stage 3 — canonical on-disk save envelope. Rust now owns
/// serialisation (the GDScript `SaveManager` becomes a thin wrapper around
/// `GdGameState::serialize_full` / `load_from_json`). Holds both the
@ -2953,6 +2981,13 @@ pub struct SaveEnvelope {
pub sim: mc_state::game_state::GameState,
/// Presentation-only per-player metadata. Aligned with `sim.players` by slot.
pub presentation: Vec<mc_core::PresentationPlayer>,
/// p2-72b Path 2 — the parallel canonical `mc_city::City` slot
/// (`[player_slot][city_idx]`). `#[serde(default)]` so pre-v3 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_cities: Vec<Vec<mc_city::City>>,
}
impl SaveEnvelope {
@ -2967,7 +3002,12 @@ impl SaveEnvelope {
/// fingerprint which `AiController` impl each slot ran. Dev-time
/// v1 saves are rejected at load (per existing `load_from_json`
/// policy — disposable saves, regenerate).
pub const CURRENT_VERSION: u32 = 2;
/// - **v3** (p2-72b Path 2) — envelope gains `presentation_cities:
/// Vec<Vec<mc_city::City>>`, the parallel full-`City` slot the
/// 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;
}
/// Stage 7 — inspect a presentation side-table for controller ids that
@ -3034,6 +3074,7 @@ impl IRefCounted for GdGameState {
Self {
inner,
presentation_players: Vec::new(),
presentation_cities: Vec::new(),
base,
}
}
@ -3111,6 +3152,7 @@ impl GdGameState {
save_format_version: SaveEnvelope::CURRENT_VERSION,
sim: self.inner.clone(),
presentation: self.presentation_players.clone(),
presentation_cities: self.presentation_cities.clone(),
};
match serde_json::to_string(&envelope) {
Ok(s) => s.into(),
@ -3171,6 +3213,7 @@ impl GdGameState {
self.inner = envelope.sim;
self.presentation_players = envelope.presentation;
self.presentation_cities = envelope.presentation_cities;
self.inner.units_catalog = units_catalog;
self.inner.improvement_registry = improvement_registry;
@ -4073,6 +4116,123 @@ impl GdGameState {
.unwrap_or(0)
}
// ── p2-72b Path 2 — parallel `mc_city::City` slot ──────────────────────
// `presentation_cities[pi][ci]` is the full gameplay city the renderers +
// the future `CityScript` thin view read. The bench `inner.players[pi].cities`
// (`Vec<CityState>`) is untouched, preserving MCTS/AI/turn-processor parity.
// The two are kept aligned by the GDScript founding/capture hand-off.
/// Number of cities in the Path-2 parallel slot for player `pi`. Distinct
/// from `city_count` (the bench `CityState` slots); a divergence between
/// the two signals a hand-off gap.
#[func]
fn presentation_city_count(&self, pi: i64) -> i64 {
self.presentation_cities
.get(pi as usize)
.map(|row| row.len() as i64)
.unwrap_or(0)
}
/// Append a full `mc_city::City` to player `pi`'s parallel slot and return
/// its index. Grows the per-player row on demand. This is the founding-time
/// hand-off the (future) `CityScript` view + renderers read back via
/// `city_dict`. The synthesised `id` matches the bench's `city_<pi>_<idx>`
/// scheme so replay/save attribution lines up across the two stores.
#[func]
fn spawn_city(
&mut self,
pi: i64,
name: GString,
col: i64,
row: i64,
is_capital: bool,
turn_founded: i64,
) -> i64 {
let pi = pi.max(0) as usize;
if self.presentation_cities.len() <= pi {
self.presentation_cities.resize_with(pi + 1, Vec::new);
}
let idx = self.presentation_cities[pi].len();
let mut city = mc_city::City::new(format!("city_{}_{}", pi, idx));
city.city_name = name.to_string();
city.position = (col as i32, row as i32);
city.is_capital = is_capital;
city.turn_founded = turn_founded.max(0) as u32;
self.presentation_cities[pi].push(city);
idx as i64
}
/// Full `mc_city::City` field projection for player `pi`'s city `ci` from
/// the parallel slot. Empty Dictionary if out of range. The key set matches
/// the fields `CityScript` exposes so the class can collapse to a thin view
/// (BuildingScript pattern) in the Stage-4 follow-up.
#[func]
fn city_dict(&self, pi: i64, ci: i64) -> Dictionary {
let mut d = Dictionary::new();
let Some(city) = self
.presentation_cities
.get(pi as usize)
.and_then(|row| row.get(ci as usize))
else {
return d;
};
d.set("id", GString::from(city.id.as_str()));
d.set("city_name", GString::from(city.city_name.as_str()));
d.set("is_capital", city.is_capital);
d.set("turn_founded", city.turn_founded as i64);
d.set("position", Vector2i::new(city.position.0, city.position.1));
d.set("population", city.population as i64);
d.set("food_stored", city.food_stored);
d.set("production_progress", city.production_progress);
d.set("culture_stored", city.culture_stored);
d.set("culture_expansions", city.culture_expansions as i64);
d.set("hp", city.hp as i64);
d.set("max_hp", city.max_hp as i64);
d.set("focus", GString::from(city_focus_id(city.focus)));
let owned: Array<Vector2i> = city
.owned_tiles
.iter()
.map(|&(c, r)| Vector2i::new(c, r))
.collect();
d.set("owned_tiles", owned);
let worked: Array<Vector2i> = city
.worked_tiles
.iter()
.map(|&(c, r)| Vector2i::new(c, r))
.collect();
d.set("worked_tiles", worked);
let buildings: Array<GString> =
city.buildings.iter().map(|b| GString::from(b.as_str())).collect();
d.set("buildings", buildings);
d
}
/// Set the population of a parallel-slot city. No-op if out of range.
/// Mutators are the write-half of the Path-2 hand-off; `CityScript`
/// mutators route through these + emit `state_changed` in the follow-up.
#[func]
fn set_city_population(&mut self, pi: i64, ci: i64, population: i64) {
if let Some(city) = self
.presentation_cities
.get_mut(pi as usize)
.and_then(|row| row.get_mut(ci as usize))
{
city.population = population.max(0) as u32;
}
}
/// Remove a parallel-slot city by index, shifting the rest down. Used by
/// the capture / city-loss hand-off. No-op if out of range.
#[func]
fn remove_city(&mut self, pi: i64, ci: i64) {
if let Some(row) = self.presentation_cities.get_mut(pi as usize) {
let ci = ci as usize;
if ci < row.len() {
row.remove(ci);
}
}
}
/// Number of units owned by player `pi`.
#[func]
fn unit_count(&self, pi: i64) -> i64 {

View file

@ -7,6 +7,7 @@
//! Mid-game save+load behaviour is covered by the GUT integration tests.
use magic_civ_physics_gdext::{validate_presentation_controllers, SaveEnvelope};
use mc_city::City;
use mc_core::PresentationPlayer;
use mc_state::game_state::GameState;
@ -16,15 +17,64 @@ fn empty_envelope_round_trips() {
save_format_version: SaveEnvelope::CURRENT_VERSION,
sim: GameState::default(),
presentation: Vec::new(),
presentation_cities: 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, 2);
assert_eq!(back.save_format_version, 3);
assert!(back.presentation.is_empty());
assert!(back.presentation_cities.is_empty());
assert_eq!(back.sim.turn, 0);
assert_eq!(back.sim.era, 0);
}
#[test]
fn presentation_cities_round_trip() {
// p2-72b Path 2 — the parallel full-`City` slot must survive the
// envelope round-trip (the bench `sim.players[*].cities` Vec<CityState>
// is a separate store and is exercised by the GameState serde tests).
let mut capital = City::new("city_0_0");
capital.city_name = "Khazad-dûm".into();
capital.position = (4, 7);
capital.is_capital = true;
capital.turn_founded = 1;
capital.population = 9;
capital.culture_stored = 120.0;
capital.owned_tiles = vec![(4, 7), (4, 8), (5, 7)];
capital.buildings = vec!["longhouse".into(), "forge".into()];
let mut second = City::new("city_0_1");
second.city_name = "Erebor".into();
second.position = (12, 3);
second.population = 4;
let env = SaveEnvelope {
save_format_version: SaveEnvelope::CURRENT_VERSION,
sim: GameState::default(),
presentation: Vec::new(),
presentation_cities: vec![vec![capital, second], Vec::new()],
};
let json = serde_json::to_string(&env).expect("serialize");
let back: SaveEnvelope = serde_json::from_str(&json).expect("deserialize");
// Byte-identical re-serialisation guards against non-deterministic field
// ordering in the new slot (City uses BTreeMap-backed queues).
let json2 = serde_json::to_string(&back).expect("re-serialize");
assert_eq!(json, json2, "envelope must byte-equal across round-trip");
assert_eq!(back.presentation_cities.len(), 2);
assert_eq!(back.presentation_cities[0].len(), 2);
assert_eq!(back.presentation_cities[1].len(), 0);
let c0 = &back.presentation_cities[0][0];
assert_eq!(c0.city_name, "Khazad-dûm");
assert_eq!(c0.position, (4, 7));
assert!(c0.is_capital);
assert_eq!(c0.population, 9);
assert_eq!(c0.culture_stored, 120.0);
assert_eq!(c0.owned_tiles, vec![(4, 7), (4, 8), (5, 7)]);
assert_eq!(c0.buildings, vec!["longhouse", "forge"]);
assert_eq!(back.presentation_cities[0][1].city_name, "Erebor");
}
#[test]
fn populated_envelope_round_trips_byte_identical() {
let mut sim = GameState::default();
@ -67,6 +117,7 @@ fn populated_envelope_round_trips_byte_identical() {
save_format_version: SaveEnvelope::CURRENT_VERSION,
sim,
presentation,
presentation_cities: Vec::new(),
};
let json = serde_json::to_string(&env).expect("serialize");
let back: SaveEnvelope =
@ -78,7 +129,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, 2);
assert_eq!(back.save_format_version, 3);
assert_eq!(back.sim.turn, 7);
assert_eq!(back.sim.era, 2);
assert_eq!(back.sim.map_seed, 0xfeed_face);
@ -154,7 +205,7 @@ fn controller_validation_flags_missing_controller() {
}
#[test]
fn version_two_is_locked() {
fn version_three_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.
@ -162,5 +213,7 @@ fn version_two_is_locked() {
// v1 → v2 (Stage 3 of mod-system plan): `PresentationPlayer` gained
// `controller_id: String` and `controller_hash: [u8; 32]` so saves
// fingerprint which AI controller each slot ran.
assert_eq!(SaveEnvelope::CURRENT_VERSION, 2);
// 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);
}