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:
parent
8a7c26ff12
commit
ed76612ad2
2 changed files with 218 additions and 5 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue