From ed76612ad2c17eb9db228dd53dffae166de472c6 Mon Sep 17 00:00:00 2001 From: autocommit Date: Sat, 6 Jun 2026 22:35:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(simulator):=20=E2=9C=A8=20Implement=20para?= =?UTF-8?q?llel=20city=20state=20tracking=20for=20Path=202=20in=20the=20si?= =?UTF-8?q?mulator=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/api-gdext/src/lib.rs | 162 +++++++++++++++++- .../api-gdext/tests/save_envelope.rs | 61 ++++++- 2 files changed, 218 insertions(+), 5 deletions(-) diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 2ec6d5d5..37745e8b 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -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, + /// p2-72b Path 2 — parallel canonical `mc_city::City` slot, indexed + /// `[player_slot][city_idx]`. The bench `inner.players[pi].cities` stays + /// `Vec` (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>, base: Base, } +/// 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, + /// 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>, } 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>`, the parallel full-`City` slot the + /// 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; } /// 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`) 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__` + /// 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 = city + .owned_tiles + .iter() + .map(|&(c, r)| Vector2i::new(c, r)) + .collect(); + d.set("owned_tiles", owned); + let worked: Array = city + .worked_tiles + .iter() + .map(|&(c, r)| Vector2i::new(c, r)) + .collect(); + d.set("worked_tiles", worked); + let buildings: Array = + 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 { diff --git a/src/simulator/api-gdext/tests/save_envelope.rs b/src/simulator/api-gdext/tests/save_envelope.rs index 1041b744..9e28e121 100644 --- a/src/simulator/api-gdext/tests/save_envelope.rs +++ b/src/simulator/api-gdext/tests/save_envelope.rs @@ -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 + // 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>`, the parallel full-`City` render slot. + assert_eq!(SaveEnvelope::CURRENT_VERSION, 3); }