From 0ed21945c1cda04c00edba9209c05f0ed7ede8f4 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 4 Jun 2026 19:16:00 -0700 Subject: [PATCH] =?UTF-8?q?refactor(mc-state):=20=F0=9F=8F=97=EF=B8=8F=20P?= =?UTF-8?q?hase=203b=20=E2=80=94=20move=20GameState=20into=20mc-state=20be?= =?UTF-8?q?hind=20a=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit p2-65 foundation milestone. `game_state.rs` (1356 lines: GameState + PlayerState + MapUnit + TechState + PendingCaptureEvents + 8 action-request structs + RallyCommand/BuildingRallyPoint/CityEcology + custom serde helpers) relocated from mc-turn to mc-state. Decouples the canonical full-simulation state shape from the turn-step mutation logic (Rail 1 cleanup). - `git mv mc-turn/src/game_state.rs → mc-state/src/game_state.rs`; mc-turn's `game_state.rs` is now `pub use mc_state::game_state::*;` so all ~30 consumer sites (mc-ai, mc-player-api, mc-mod-host, api-gdext, mc-sim, mc-replay) + mc-turn's lib.rs `pub use game_state::{GameState,…}` re-export resolve unchanged for one cycle (Phase 4 sweeps them). - Re-paths inside the moved file: `crate::combat_balance::CombatBalance` → `mc_core::CombatBalance` (the only non-sibling code ref); the 5 sibling-module field types (ransom/capture/patrol/combat_event) resolve as `crate::` since they're now co-located in mc-state. Broken `[crate::…]` intra-doc links demoted to plain `mc_turn::…` backtick prose. - `PendingCaptureEvents::drain_into` peeled off into the mc-turn-local `DrainCaptureEvents` extension trait (`capture_drain.rs`): it embeds `mc_replay::TurnEvent` + `mc_turn::combat_event::TurnResult`, neither movable to the data crate without a cycle. Local-trait-for-foreign-type, orphan-rule legal. 4 call sites (processor.rs + 3 tests) add the trait `use`. SAVE-FORMAT GATE (the byte-identical proof): mc-turn serde_roundtrip 6/6 + full_turn_golden 3/3 green — assembled GameState round-trips identically post-move (serde shapes invariant; module paths were never on disk). Parity (apricot): workspace --no-run exit 0; mc-state 12/12 (8 + the 4 game_state unit tests that moved with the file); mc-turn lib 234/234 (1 ignored, pre-existing — was 238 before the 4 moved out); mc-ai 268/268; mc-player-api 126/126. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../crates/mc-state/src/game_state.rs | 1294 ++++++++++++++++ src/simulator/crates/mc-state/src/lib.rs | 1 + .../crates/mc-turn/src/capture_drain.rs | 89 ++ .../crates/mc-turn/src/game_state.rs | 1378 +---------------- src/simulator/crates/mc-turn/src/lib.rs | 1 + src/simulator/crates/mc-turn/src/processor.rs | 1 + .../tests/capture_chronicle_pipeline.rs | 1 + src/simulator/crates/mc-turn/tests/ransom.rs | 1 + 8 files changed, 1404 insertions(+), 1362 deletions(-) create mode 100644 src/simulator/crates/mc-state/src/game_state.rs create mode 100644 src/simulator/crates/mc-turn/src/capture_drain.rs diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs new file mode 100644 index 00000000..7bff3438 --- /dev/null +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -0,0 +1,1294 @@ +//! Game state types — data only, no logic. All mutation happens through +//! `TurnProcessor::step`. + +use mc_core::ScoringWeights; +use mc_replay; +use mc_city::CityState; +use mc_combat::StatusEffect; +use mc_core::building_action::BuildingActionKind; +use mc_core::building_state::BuildingState; +use mc_core::multi_turn_action::MultiTurnAction; +use mc_core::formation::{ + AutoJoinRequest, Formation, FormationCommandRequest, FormationShapeRequest, + RallyPointRequest, SplitFormationRequest, +}; +use mc_core::grid::GridState; +use mc_core::improvement::{TileImprovement, TileImprovementSpec}; +use mc_core::WonderId; +use mc_culture::CulturePool; +use mc_tech::PlayerTechState; +use mc_trade::relation::RelationState; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Serde adapter: round-trips `BTreeMap<(u8, u8), RelationState>` as a +/// `Vec<((u8, u8), RelationState)>`. `serde_json` cannot serialize tuple keys +/// directly ("key must be a string"), so saves with populated diplomacy would +/// otherwise fail. Encoding as pairs keeps the on-disk form deterministic +/// (BTreeMap iteration order) and byte-stable across processes. +mod relations_as_pairs { + use mc_trade::relation::RelationState; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::collections::BTreeMap; + + pub fn serialize( + map: &BTreeMap<(u8, u8), RelationState>, + ser: S, + ) -> Result { + let pairs: Vec<(&(u8, u8), &RelationState)> = map.iter().collect(); + pairs.serialize(ser) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + let pairs: Vec<((u8, u8), RelationState)> = Vec::deserialize(de)?; + Ok(pairs.into_iter().collect()) + } +} + +/// Serde adapter: round-trips `BTreeMap<(u16,u16), TileImprovement>` as a +/// `Vec<((u16,u16), TileImprovement)>` for the same reason as `relations_as_pairs`. +mod improvements_as_pairs { + use mc_core::improvement::TileImprovement; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::collections::BTreeMap; + + pub fn serialize( + map: &BTreeMap<(u16, u16), TileImprovement>, + ser: S, + ) -> Result { + let pairs: Vec<(&(u16, u16), &TileImprovement)> = map.iter().collect(); + pairs.serialize(ser) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + let pairs: Vec<((u16, u16), TileImprovement)> = Vec::deserialize(de)?; + Ok(pairs.into_iter().collect()) + } +} + +/// p3-10b: serde adapter for `BTreeMap<(u16,u16), SiegeState>`. Same +/// rationale as `improvements_as_pairs` — JSON object keys must be strings, +/// so pairs keep the on-disk form deterministic and round-trippable. +mod siege_pressure_as_pairs { + use mc_core::lair::SiegeState; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::collections::BTreeMap; + + pub fn serialize( + map: &BTreeMap<(u16, u16), SiegeState>, + ser: S, + ) -> Result { + let pairs: Vec<(&(u16, u16), &SiegeState)> = map.iter().collect(); + pairs.serialize(ser) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + let pairs: Vec<((u16, u16), SiegeState)> = Vec::deserialize(de)?; + Ok(pairs.into_iter().collect()) + } +} + +/// p3-10c: serde adapter for `BTreeMap<(u16,u16), RaidAftermath>`. Same +/// rationale as `siege_pressure_as_pairs` — JSON object keys must be strings, +/// so pairs keep the on-disk form deterministic and round-trippable. +mod raid_aftermath_as_pairs { + use mc_core::lair::RaidAftermath; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::collections::BTreeMap; + + pub fn serialize( + map: &BTreeMap<(u16, u16), RaidAftermath>, + ser: S, + ) -> Result { + let pairs: Vec<(&(u16, u16), &RaidAftermath)> = map.iter().collect(); + pairs.serialize(ser) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + let pairs: Vec<((u16, u16), RaidAftermath)> = Vec::deserialize(de)?; + Ok(pairs.into_iter().collect()) + } +} + +/// Serde adapter: round-trips `BTreeMap<(usize, String), BuildingState>` as +/// `Vec<((usize, String), BuildingState)>` pairs for JSON-key compat. +mod building_states_as_pairs { + use mc_core::building_state::BuildingState; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::collections::BTreeMap; + + pub fn serialize( + map: &BTreeMap<(usize, String), BuildingState>, + ser: S, + ) -> Result { + let pairs: Vec<(&(usize, String), &BuildingState)> = map.iter().collect(); + pairs.serialize(ser) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + let pairs: Vec<((usize, String), BuildingState)> = Vec::deserialize(de)?; + Ok(pairs.into_iter().collect()) + } +} + +/// A player-vs-player attack request queued by GDScript (click-to-attack path). +/// +/// Drained each turn by `TurnProcessor::process_pvp_combat` before the +/// position-based proximity check runs. This makes the live game's click path +/// and MCTS rollouts use the same `CombatResolver` code route. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttackRequest { + /// Player index of the attacker. + pub attacker_player: u8, + /// Index into `PlayerState::units` for the attacking unit. + pub attacker_unit: usize, + /// Player index of the defender. + pub defender_player: u8, + /// Index into `PlayerState::units` for the defending unit. + pub defender_unit: usize, +} + +/// A bombard request queued by GDScript (player clicks Bombard, selects target hex). +/// +/// Drained each turn by `TurnProcessor::process_bombard_requests`. Uses the same +/// queue-then-drain pattern as `AttackRequest` / `pending_pvp_attacks`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BombardRequest { + /// Player index of the bombarding siege unit's owner. + pub attacker_player: u8, + /// Index into `PlayerState::units` for the bombarding unit. + pub attacker_unit: usize, + /// Target axial hex `(col, row)`. + pub target_col: i32, + pub target_row: i32, + /// True when the unit has the `arcing` keyword (catapult) — bypasses LoS. + /// False for ballistas and cannon (line-trajectory, requires LoS). + pub indirect_fire: bool, +} + +/// A pillage request queued by GDScript (worker clicks Pillage on an improved tile). +/// +/// Drained each turn by `TurnProcessor::process_pillage_requests`. The tile's +/// improvement in `GameState::tile_improvements` is the authoritative state; +/// the GDScript tile entity is a presentation mirror refreshed after drain. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PillageRequest { + /// Player index of the pillaging unit's owner. + pub player_index: u8, + /// Index into `PlayerState::units` for the pillaging unit. + pub unit_index: usize, + /// Target tile axial hex `(col, row)` — must carry an improvement. + pub target_col: i32, + pub target_row: i32, +} + +/// A Volley request queued by GDScript (ranged unit clicks Volley, selects target hex). +/// +/// Drained each turn by `TurnProcessor::process_volley_requests`. AoE: damages +/// units in the target hex and two randomly chosen adjacent edge hexes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolleyRequest { + /// Player index of the volleying unit's owner. + pub attacker_player: u8, + /// Index into `PlayerState::units` for the volleying unit. + pub attacker_unit: usize, + /// Target axial hex centre `(col, row)`. + pub target_col: i32, + pub target_row: i32, +} + +/// A Charge request queued by GDScript (cavalry clicks Charge, selects target hex). +/// +/// Drained each turn by `TurnProcessor::process_charge_requests`. The unit +/// moves up to 2 hexes in a straight line toward the target, then resolves +/// melee combat with the +30% charge attack bonus. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChargeRequest { + /// Player index of the charging unit's owner. + pub attacker_player: u8, + /// Index into `PlayerState::units` for the charging unit. + pub attacker_unit: usize, + /// Target axial hex `(col, row)`. + pub target_col: i32, + pub target_row: i32, +} + +/// A Move request queued by `mc-player-api::dispatch::apply_move` +/// (p2-67 Phase 9 — Proper Move subsystem). +/// +/// Drained by `mc_turn::processor::process_move_requests`, which +/// pathfinds via `mc-pathfinding`, validates the unit's +/// `movement_remaining`, decrements it, and applies the new position. +/// Empties at the end of every drain pass (cleared even on failed +/// requests, matching the other `pending_*` queues). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MoveRequest { + /// Player index of the moving unit. + pub player_idx: usize, + /// Index into `PlayerState::units` for the moving unit. + pub unit_idx: usize, + /// Target tile axial hex `(col, row)`. + pub target_col: i32, + pub target_row: i32, +} + +/// A building action request queued by GDScript, drained by +/// `building_action_handlers::drain_pending_building_actions`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildingActionRequest { + pub player_idx: usize, + pub city_idx: usize, + pub building_id: String, + pub kind: BuildingActionKind, + /// Unit ID for GarrisonIn/Out actions; None for all others. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit_id: Option, +} + +/// Top-level headless game state. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GameState { + pub turn: u32, + pub players: Vec, + /// The world grid. `Some` for full benches, `None` for the headless unit + /// tests in mc-sim that don't need spatial data. + pub grid: Option, + /// p1-60 J — symmetric alliance set. Keyed by canonical `(min_idx, max_idx)` + /// to mirror `PlayerState::relations`. Allied pairs share vision; consumers + /// (`mc_vision::compute_vision`) union the partner's visible set into the + /// active player's. `#[serde(default)]` so old saves load with no alliances. + #[serde(default)] + pub alliances: std::collections::BTreeSet<(u8, u8)>, + /// Explicit PvP attacks queued by GDScript this turn (click-to-attack path). + /// Drained at the start of `process_pvp_combat`, before position-based + /// discovery. Cleared after each turn so stale entries never linger. + #[serde(default)] + pub pending_pvp_attacks: Vec, + /// Bombard requests queued by GDScript this turn (siege unit Bombard action). + /// Drained at the start of `process_bombard_requests`. Cleared each turn. + #[serde(default)] + pub pending_bombard_requests: Vec, + /// Pillage requests queued by GDScript this turn (worker Pillage action). + /// Drained by `TurnProcessor::process_pillage_requests`. Cleared each turn. + #[serde(default)] + pub pending_pillage_requests: Vec, + /// Volley requests queued by GDScript this turn (ranged unit AoE attack). + /// Drained at the start of `process_volley_requests`. Cleared each turn. + #[serde(default)] + pub pending_volley_requests: Vec, + /// Charge requests queued by GDScript this turn (cavalry 2-hex straight-line charge). + /// Drained at the start of `process_charge_requests`. Cleared each turn. + #[serde(default)] + pub pending_charge_requests: Vec, + /// Active formations across all players. BTreeMap for deterministic save order. + #[serde(default)] + pub formations: BTreeMap, + /// Monotonic counter — incremented each time a new Formation is created. + #[serde(default)] + pub next_formation_id: u32, + /// Monotonic counter — incremented each time a new MapUnit is spawned. + #[serde(default)] + pub next_unit_id: u32, + /// Rally point changes queued by GDScript this turn. + #[serde(default)] + pub pending_rally_requests: Vec, + /// p2-67 Phase 9: pending move requests queued by + /// `mc_player_api::dispatch::apply_move`. Drained by + /// `mc_turn::processor::process_move_requests`. + #[serde(default)] + pub pending_move_requests: Vec, + /// Formation command changes queued by GDScript this turn. + #[serde(default)] + pub pending_formation_commands: Vec, + /// Formation shape changes queued by GDScript this turn. + #[serde(default)] + pub pending_formation_shapes: Vec, + /// Split-from-formation requests queued by GDScript this turn. + #[serde(default)] + pub pending_split_requests: Vec, + /// Auto-join toggle requests queued by GDScript this turn. + #[serde(default)] + pub pending_auto_join_requests: Vec, + /// Building action requests queued by GDScript this turn. + /// Drained by `building_action_handlers::drain_pending_building_actions`. + #[serde(default)] + pub pending_building_actions: Vec, + /// Sparse per-hex improvement layer. Keyed by (col, row) as u16; most + /// tiles will be unimproved, so a BTreeMap wastes far less memory than an + /// Option on every TileState. Serialized as pairs to avoid the JSON + /// "key must be a string" restriction on tuple keys. + #[serde(default, with = "improvements_as_pairs")] + pub tile_improvements: BTreeMap<(u16, u16), TileImprovement>, + /// Registry of improvement specs loaded from + /// `public/resources/improvements/*.json`. Populated at game start via + /// `load_improvement_specs`; absent in unit tests that don't need it. + #[serde(skip)] + pub improvement_registry: BTreeMap, + /// p2-67 Phase 9: unit-type stats catalog loaded from + /// `public/resources/units/*.json`. Consumed by `MapUnit::new` (read + /// `base_moves` at spawn) and by future authors that need + /// id → base-stats lookup. `#[serde(skip)]` mirrors + /// `improvement_registry` — the catalog is reloaded at boot, not + /// persisted in save files. + #[serde(skip)] + pub units_catalog: mc_units::UnitsCatalog, + /// p3-05e: civic-axis modifier catalog loaded from + /// `public/resources/civics/*.json`. Used at turn-end to resolve each + /// player's `ResolvedModifiers` (notably `specialist_xp_rate`, consumed by + /// the expertise-XP tick in `process_city_production`). `#[serde(skip)]` + /// mirrors `units_catalog` — boot-loaded, not save-persisted. An empty + /// (Default) catalog resolves to identity modifiers (rate 1.0), so test + /// and pre-load paths see no civic effect. + #[serde(skip)] + pub civic_catalog: mc_civics::CivicCatalog, + /// p2-71: tactical-AI view of the producible-unit catalog. Mirrors + /// `TacticalState::unit_catalog` and is populated once at harness boot + /// by `GdPlayerApi::set_units_catalog_json` (or directly in Rust tests). + /// Read by `mc_player_api::projection::project_tactical` so MCTS sees a + /// non-empty buildable list; empty (default) recovers the legacy + /// tier-1-fallback behaviour. `#[serde(skip)]` because the catalog is + /// boot-loaded, not save-persisted. + #[serde(skip)] + pub ai_unit_catalog: Vec, + /// p2-71: tactical-AI view of the producible-building catalog. Companion + /// to `ai_unit_catalog` — populated by the harness via + /// `GdPlayerApi::set_buildings_catalog_json`. Empty falls back to the + /// no-building behaviour. + #[serde(skip)] + pub ai_building_catalog: Vec, + /// p2-71: difficulty multiplier projected into + /// `TacticalState::difficulty_threshold_mult`. Defaults to 1.0 + /// (normal). Populated by `GdPlayerApi::set_difficulty_threshold_mult`. + #[serde(skip, default = "default_threshold_mult")] + pub ai_difficulty_threshold_mult: f32, + /// p2-67 Phase 8: shared diplomatic-agreement ledger. + /// Holds OpenBorders / SharedMap / LuxurySwap agreements across + /// every player pair. Authoritative single source — every + /// agreement-mutating action (Offer / Accept / Reject, war + /// declarations) reads + writes this field instead of allocating + /// throwaway ledgers. `#[serde(default)]` keeps pre-Phase-8 saves + /// loading with an empty ledger. + #[serde(default)] + pub trade_ledger: mc_trade::TradeLedger, + /// Communications Phase 2: envelopes-in-flight, sighting + /// propagation queue, and per-player pending-war-dec overlays. See + /// `mc-comms` crate docs for the war-declaration asymmetry design. + /// `#[serde(default)]` keeps pre-Phase-2 saves loading with an + /// empty `CommsState`. + #[serde(default)] + pub comms: mc_comms::CommsState, + /// p2-55: pending ransom offers across all players. Ticked at start of + /// turn (`RansomQueue::tick`); offers are added when the resolver returns + /// `CombatOutcome::RansomOffered`. + #[serde(default)] + pub ransom_queue: crate::ransom::RansomQueue, + /// p2-55f: data-driven combat balance config (ransom durations, XP + /// awards, multipliers). Loaded from + /// `public/games/age-of-dwarves/data/combat_balance.json` at game start + /// via `mc_turn::combat_balance::load_combat_balance`. Defaults to the + /// pre-p2-55f hardcoded constants so save migration is no-op. + #[serde(default)] + pub combat_balance: mc_core::CombatBalance, + /// p3-10b: per-lair-tile siege pressure state. Keyed by `(col, row)` + /// of the lair tile. Bridge layer ticks pressure each turn via + /// `mc_combat::lair::tick_siege` / `decay_siege`; entries are removed + /// when the lair is cleared (Surrender) or pressure decays to zero. + /// Serialized as pairs to avoid the JSON "key must be a string" + /// restriction on tuple keys, mirroring `tile_improvements`. + #[serde(default, with = "siege_pressure_as_pairs")] + pub siege_pressure: BTreeMap<(u16, u16), mc_core::lair::SiegeState>, + /// p3-10c: per-lair-tile raid aftermath state. Keyed by `(col, row)` of + /// the lair tile. Populated by the GDExt bridge after a `Caught` raid + /// outcome; entries are ticked by the bridge each turn and removed when + /// `RaidAftermath::is_active(current_turn)` returns false. Serialized as + /// pairs for the same JSON-key reasons as `siege_pressure`. + #[serde(default, with = "raid_aftermath_as_pairs")] + pub raid_aftermath: BTreeMap<(u16, u16), mc_core::lair::RaidAftermath>, + /// p3-13: active fog-bank map. Keyed by flat tile index (`row * width + col` + /// as `u16`). Populated by `mc-sim::event_dispatch::dispatch_world_events` + /// when `AnomalousEvent::FogBank` fires; lazily expires via + /// `mc_observation::fog::is_fogged`. Sparse — zero overhead when no fog is + /// active. Persisted in the save file so fog state survives load/resume. + #[serde(default)] + pub fog_map: std::collections::HashMap, + /// p2-55: scratch buffer for capture / ransom / destroy events produced + /// by `resolve_single_pvp_attack` and the proximity combat path. Drained + /// into `TurnResult` at the end of `process_pvp_combat`. Not persisted + /// across saves. + #[serde(skip)] + pub pending_capture_events: PendingCaptureEvents, + /// p2-48: player indices that submitted a resignation action this turn. + /// Drained by `end_conditions::evaluate_conditions` at turn-end. The field + /// uses `u8` player indices (matching `PlayerState::player_index`) to stay + /// consistent with the rest of `GameState`. `#[serde(default)]` so old + /// save files (without this field) deserialize cleanly. + #[serde(default)] + pub pending_resignations: BTreeSet, + /// p2-72a Stage 2b: NPC buildings on the world map (lairs / villages / + /// ruins). Mirrors `GameState.npc_buildings` from GDScript. Populated by + /// `GdGameState::spawn_npc_building` (Stage 2b) and consumed by Stage 3's + /// `serialize_full` save-format migration. `#[serde(default)]` so saves + /// emitted before Stage 2b deserialize cleanly with an empty list. + #[serde(default)] + pub npc_buildings: Vec, + /// p2-72a Stage 3 — Wall-3 fields absorbed from GDScript `GameState`. + /// Era index into the game pack's `eras.json`. Game-pack-driven; the + /// engine defines no era names. Default `0` matches GDScript's `era: int = 0`. + #[serde(default)] + pub era: u32, + /// World-generation seed (from `game_settings["seed"]`). Stored so + /// climate / RNG-derivation systems can rebuild deterministic per-turn + /// seeds after a load. + #[serde(default)] + pub map_seed: u64, + /// Whose turn it is — index into `players`. `0` at game start; the turn + /// loop advances it after each `EndTurn`. Persisted so mid-game saves + /// resume on the correct player. + #[serde(default)] + pub current_player_index: u8, + /// Seed of the central RNG (`GameState.game_rng` in GDScript). Captured + /// at save so the random trajectory is reproducible. + #[serde(default)] + pub game_rng_seed: u64, + /// Live state of the central RNG, captured at save. Together with + /// `game_rng_seed` lets the engine resume the exact sequence on load. + #[serde(default)] + pub game_rng_state: u64, + /// AI-difficulty modifier axes. All eight axes default to neutral + /// values; the harness writes the active difficulty entry into this + /// field at game-setup time. Persisted so the difficulty applied on + /// turn 1 is the same one applied after a mid-game save+load. + #[serde(default)] + pub ai_difficulty: AiDifficulty, + /// p2-59: explicit escort links, keyed `protected_unit_id -> escort_unit_id` + /// (both `MapUnit::id`). Distinct from the *implicit* adjacency-based + /// protection in `process_fauna_encounters_inner` Step 1b — this map drives + /// **stack movement** (an escort drags its protected unit along at the + /// slower unit's MP rate) and persists until `EscortRelease` or unit death. + /// Lookups are defensive: a dangling id (dead / captured unit) is simply + /// skipped, so no per-death cleanup is required. `BTreeMap` for deterministic + /// save order; `#[serde(default)]` so pre-p2-59 saves load with no links. + #[serde(default)] + pub escort_links: BTreeMap, + /// p2-59: escort assign/release requests queued by the dispatch layer this + /// turn. Drained synchronously by `processor::process_escort_requests`. + /// Not persisted (cleared each turn like every other `pending_*` queue). + #[serde(default)] + pub pending_escort_requests: Vec, +} + +/// p2-59: an escort assign/release request queued by +/// `mc_player_api::dispatch`. Drained by +/// `mc_turn::processor::process_escort_requests`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EscortRequest { + /// Player index of the protected unit issuing the verb. + pub player_idx: usize, + /// `MapUnit::id` of the protected (civilian / pioneer) unit. + pub protected_unit_id: u32, + /// `MapUnit::id` of the chosen escort. `None` for a release request, or + /// for an assign that asks the handler to auto-pick the nearest eligible + /// in-range escort. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub escort_unit_id: Option, + /// `true` = assign, `false` = release. + pub assign: bool, +} + +/// AI difficulty modifier axes (eight values), absorbed wholesale from the +/// `GameState.ai_*` field set in GDScript so a save round-trip preserves the +/// difficulty applied to AI players. +/// +/// Field names mirror the GDScript shape one-for-one — the harness stamps +/// each axis directly without rename. Defaults are neutral (`production_mult` +/// = `research_mult` = `1.0`; zero bonuses; empty per-player maps). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AiDifficulty { + /// Difficulty modifier applied to AI production each turn. `<1.0` = penalty, + /// `>1.0` = bonus. Default `1.0`. + pub production_mult: f32, + /// Difficulty modifier applied to AI research (science) each turn. Default `1.0`. + pub research_mult: f32, + /// Gold added to every AI player at game start for the current difficulty tier. + pub starting_gold_bonus: i32, + /// Extra warrior-class units spawned per AI city at game start. + pub extra_starting_units: u32, + /// ID of the extra starting unit. Default `"warrior"`. + pub extra_unit_id: String, + /// Per-player production multiplier overrides. Key = player index. When + /// non-empty, the per-player value takes precedence over `production_mult`. + pub per_player_production_mult: BTreeMap, + /// Per-player research multiplier overrides — same semantics as + /// `per_player_production_mult`. + pub per_player_research_mult: BTreeMap, +} + +impl Default for AiDifficulty { + fn default() -> Self { + Self { + production_mult: 1.0, + research_mult: 1.0, + starting_gold_bonus: 0, + extra_starting_units: 0, + extra_unit_id: "warrior".to_string(), + per_player_production_mult: BTreeMap::new(), + per_player_research_mult: BTreeMap::new(), + } + } +} + +/// p2-55: scratch staging for capture / ransom / destroy events fired during +/// the PvP combat phase. Lives on `GameState` because the inner combat helpers +/// don't take `&mut TurnResult`. Drained into `TurnResult` at phase end. +#[derive(Debug, Clone, Default)] +pub struct PendingCaptureEvents { + pub units_captured: Vec, + pub ransom_offers_created: Vec, + pub civilians_destroyed: Vec, + /// p2-55e: ransom offers paid out (gold deducted, ownership restored). + /// Bridge accept_ransom_offer pushes here; drain_into surfaces onto + /// `TurnResult.ransom_offers_accepted`. + pub ransom_offers_accepted: Vec, + /// p2-55e: ransom offers refused (manual refuse) or expired (timeout). + /// `process_ransom_expiry` and bridge `refuse_ransom_offer` push here. + pub ransom_offers_expired: Vec, + /// p2-67 Bug 3: kills staged from `resolve_single_pvp_attack` (queued + /// PvP path). The proximity-discovery loop in `process_pvp_combat` + /// has its own inline emit at the kill-removal site and does NOT push + /// here. `drain_into` translates each entry to a + /// `mc_replay::TurnEvent::UnitKilled` on `result.events_emitted`. + pub units_killed: Vec, +} + +impl PendingCaptureEvents { + pub fn is_empty(&self) -> bool { + self.units_captured.is_empty() + && self.ransom_offers_created.is_empty() + && self.civilians_destroyed.is_empty() + && self.ransom_offers_accepted.is_empty() + && self.ransom_offers_expired.is_empty() + && self.units_killed.is_empty() + } +} + +impl GameState { + /// Return the improvement at `(col, row)`, if any. + pub fn improvement_at(&self, col: u16, row: u16) -> Option<&TileImprovement> { + self.tile_improvements.get(&(col, row)) + } + + /// Place `spec`-derived improvement at `(col, row)`, overwriting any + /// existing one. Derives `hp`, `severable`, and `flags` from the spec. + pub fn set_improvement(&mut self, col: u16, row: u16, spec: &TileImprovementSpec) { + self.tile_improvements.insert( + (col, row), + TileImprovement { + id: spec.id.clone(), + hp: spec.hp, + severable: spec.severable, + pillaged: false, + flags: spec.flags.clone(), + }, + ); + } + + /// Remove the improvement at `(col, row)` entirely. + pub fn remove_improvement(&mut self, col: u16, row: u16) { + self.tile_improvements.remove(&(col, row)); + } + + /// Pillage the improvement at `(col, row)`. + /// + /// - If the improvement is `severable`: sets `pillaged = true` and returns + /// `true` (the improvement remains but its route-gating effect is + /// suspended). + /// - If the improvement is not severable (e.g. a Beacon Tower): removes it + /// outright and returns `false`. + /// - If there is no improvement: returns `false`. + pub fn pillage_improvement(&mut self, col: u16, row: u16) -> bool { + match self.tile_improvements.get_mut(&(col, row)) { + Some(imp) if imp.severable => { + imp.pillaged = true; + true + } + Some(_) => { + self.tile_improvements.remove(&(col, row)); + false + } + None => false, + } + } + + /// Populate `improvement_registry` from a slice of already-parsed specs. + /// Call once at game start after loading JSON files. + pub fn load_improvement_specs(&mut self, specs: impl IntoIterator) { + for spec in specs { + self.improvement_registry.insert(spec.id.clone(), spec); + } + } +} + +/// Per-player state. Wide struct by design — every field is read directly by +/// the bench or by mc-ai evaluators, and hiding them behind getters would just +/// add boilerplate without safety benefit. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PlayerState { + /// 0-indexed player slot. `deserialize_with` accepts GDScript's JSON + /// floats (e.g. `1.0` for u8 `1`) — the engine stringifies all numbers + /// as floats, and strict u8 decoding rejects them. See + /// `mc_core::gd_compat::de_u8_flexible` docstring. + #[serde(deserialize_with = "mc_core::gd_compat::de_u8_flexible")] + pub player_index: u8, + /// Treasury. + pub gold: i32, + /// Per-city compact state. Uses `mc_city::CityState` (not the full `City`) + /// because the bench harness doesn't simulate per-building queues. + pub cities: Vec, + /// Per-turn upkeep cost per existing unit, aligned with `units`. + pub unit_upkeep: Vec, + /// Strategic axis weights, e.g. `{"expansion": 5, "production": 3, ...}`. + /// Driven by the strategy profile loaded from JSON or defined inline. + /// `BTreeMap` for deterministic save serialization (byte-equal round-trip). + /// GDScript's `Dictionary.stringify` emits u8 values as JSON floats; the + /// flexible helper accepts both int and float forms. + #[serde(deserialize_with = "mc_core::gd_compat::de_btreemap_string_u8_flexible")] + pub strategic_axes: BTreeMap, + /// AI scoring weights used by the mc-ai evaluator leaf-value function. + #[serde(default)] + pub scoring_weights: ScoringWeights, + /// p2-71: personality / clan id (e.g. `"blackhammer"`) — projected into + /// `TacticalPlayerState::clan_id` so personality-emergent thresholds + /// (`mc_ai::tactical::thresholds`) and the production picker get a + /// non-empty key. Stamped by `GdGameState::set_player_personality_json`. + /// Empty for fixtures predating p2-71; tactical AI treats empty as + /// "neutral / unset" without panic. + #[serde(default)] + pub clan_id: String, + /// p2-71 — Offense-category promotion weight projected into + /// `TacticalPlayerState::promotion_offense_weight`. Defaults to 1.0 + /// (neutral). Stamped by `set_player_personality_json` from the + /// personality JSON entry's optional `promotion_offense_weight`. + #[serde(default = "default_promotion_weight")] + pub promotion_offense_weight: f32, + /// p2-71 — Defense-category promotion weight (companion to + /// `promotion_offense_weight`). + #[serde(default = "default_promotion_weight")] + pub promotion_defense_weight: f32, + /// p2-71 — Mobility / utility promotion weight. + #[serde(default = "default_promotion_weight")] + pub promotion_mobility_weight: f32, + /// p1-42b — Per-clan production-side priors loaded from + /// `ai_personalities.json` (`building_category_weights` + + /// `wonder_priorities`). Projected straight through + /// `mc_player_api::projection::project_tactical` into + /// `TacticalPlayerState::building_priors` so the catalog-driven scorer + /// (`mc_ai::tactical::production::score_building`) sees real + /// per-personality weights instead of the neutral default. + /// + /// Stamped by `GdGameState::set_player_personality_json` from the + /// personality JSON envelope. Empty maps (default) preserve cycle-5 + /// fall-through to axis-driven multipliers — fixtures predating p1-42b + /// keep their current behaviour without any changes. + #[serde(default)] + pub building_priors: mc_core::tactical_types::BuildingPriors, + /// Stage 3 (mod system) — controller registry id used by + /// `mc_player_api::dispatch::drive_ai_slot` to look up which + /// `AiController` impl decides this slot's turn. Defaults to + /// `"scripted:default"` (the in-box MCTS+heuristic). Mod-supplied + /// controllers register their own id (`"learned:duel-v1b"`, + /// `"community:foo"`, ...) and the game-setup UI picks per slot. + /// Empty = fall-through to default at dispatch time. + #[serde(default)] + pub controller_id: String, + /// p1-29h — cross-turn tactical memory: the army-level target-lock + + /// commitment-hysteresis channel that makes the AI's war decisive + /// (capture → press on → elimination) instead of indecisive (capture → + /// disperse → opponent refounds). Borrowed `&mut` by + /// `mc_player_api::dispatch::drive_ai_slot` and threaded into the tactical + /// movement layer. `#[serde(skip)]` — transient; re-acquired from scratch + /// (at most one no-lock turn) after a save/load rather than persisted into + /// the save format, keeping the `mc-save` contract unchanged. + #[serde(skip)] + pub tactical_memory: mc_core::tactical_types::TacticalMemory, + /// Accumulated expansion capacity (earned from the `expansion` axis). + pub expansion_points: u32, + /// Per-city list of constructed building IDs. Aligned with `cities`. + pub city_buildings: Vec>, + /// Per-building runtime state keyed by `(city_idx, building_id)`. Sparse — + /// only present when a building's state deviates from defaults. Lazily + /// inserted on first action; absent entries mean default `BuildingState`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "building_states_as_pairs")] + pub building_states: BTreeMap<(usize, String), BuildingState>, + /// Per-city list of tile improvement IDs active in the city's worked + /// tiles (e.g. `["farm", "mine"]`). Aligned with `cities`. Populated by + /// GDScript; bench tests set this directly. + #[serde(default)] + pub city_improvements: Vec>, + /// Per-city accumulated fauna harassment pressure. Aligned with `cities`. + pub city_ecology: Vec, + /// Optional research state. `None` = no research simulated. + #[serde(default)] + pub tech_state: Option, + /// Per-turn science accumulation rate. + pub science_yield: u32, + /// Cumulative science points accumulated toward the current research target. + /// Incremented by `science_yield` each turn; decremented by `tech.cost` on + /// completion (overflow carries into the next tech). Zero when no research + /// is in progress. Observable by UI/GDScript without querying PlayerTechState. + #[serde(default)] + pub science_pool: i64, + /// Full mc-tech research state, present when a TechWeb has been injected + /// into the TurnProcessor. Drives cost-gated completion via + /// `PlayerTechState::add_science`. The legacy `tech_state` field continues + /// to serve the victory system's science-tech requirement checks. + #[serde(default)] + pub player_tech: Option, + /// Movable units currently on the map. + pub units: Vec, + /// World-space (col, row) positions of each city, aligned with `cities`. + pub city_positions: Vec<(i32, i32)>, + /// Position of this player's original capital (used for domination victory). + #[serde(default)] + pub capital_position: Option<(i32, i32)>, + /// Cumulative culture generated across all cities. Mirrors + /// `culture_pool.culture_total` — kept as a hot field for victory and + /// scoring code that would otherwise have to walk the pool each turn. + pub culture_total: i64, + /// Per-city culture pool (mc-culture). Drives per-turn accumulation and + /// border-expansion readiness via `CulturePool::tick_all`. Lazily populated + /// by `process_culture` — saves written before this field existed + /// deserialize with an empty pool, and the next turn re-registers every + /// city from `cities` with its computed per-turn yield. + #[serde(default)] + pub culture_pool: CulturePool, + /// Currently-researched cultural tradition id, or empty if none. + #[serde(default)] + pub researching_tradition: String, + /// Culture-research progress accumulated toward the current tradition. + /// Resets to 0 on completion; overflow rolls into the next start. + #[serde(default)] + pub culture_research_progress: u32, + /// Set of cultural tradition ids the player has completed. + #[serde(default)] + pub researched_traditions: std::collections::BTreeSet, + /// Bench / MCTS culture-research state. The live-game path drives the + /// flat fields above via `GdCultureWeb.process_culture_research`; this + /// nested state powers the in-process `TurnProcessor::process_culture_research` + /// path used by the bench and rollout snapshots. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_culture: Option, + /// One-time flag: has the arcane-lore population cost already been paid? + pub arcane_lore_pop_deducted: bool, + /// Luxury IDs received via active trade agreements this turn. + /// Merged into `owned_luxuries` before happiness calculation. + /// Cleared and rebuilt each turn by `process_trade_phase`. + #[serde(default)] + pub traded_luxuries: BTreeSet, + /// Diplomatic relation states keyed by canonical pair `(min_idx, max_idx)`. + /// Shared across all players — only player_index 0 carries the authoritative + /// copy; `process_trade_phase` syncs it from the ledger. + #[serde(default, with = "relations_as_pairs")] + pub relations: BTreeMap<(u8, u8), RelationState>, + /// Strategic resource stockpile. Keys are deposit resource IDs (e.g. `"iron_ore"`). + /// Incremented by deposit discovery, decremented on unit build, restored on unit death. + #[serde(default)] + pub strategic_ledger: BTreeMap, + /// Flat tile indices (col * grid_height + row) of deposits already credited to + /// this player's ledger. Prevents double-crediting when fog is refreshed. + #[serde(default)] + pub explored_deposits: BTreeSet, + /// World wonders this player has completed. Maps `WonderId → tier` (1-10). + /// BTreeMap for deterministic iteration order across turn-processor runs. + /// Tier is stored alongside the id so `calculate_score` can weight each + /// wonder without a registry look-up at victory-check time. + #[serde(default)] + pub wonders_built: BTreeMap, + /// Rally points per building slot. Each entry records which hex freshly + /// spawned units from that building should march to and with which standing + /// order. Stored as a flat Vec (not a map) so serde_json serializes cleanly. + #[serde(default)] + pub rally_points: Vec, + /// p2-55: per-relation civilian-capture posture. Keyed by the *defender's* + /// `player_index`. Missing entry falls through to `default_civilian_posture`. + /// `BTreeMap` for deterministic save serialisation (matches `relations` / + /// `building_states` / `wonders_built`). + #[serde(default)] + pub civilian_posture: BTreeMap, + /// p2-55: global default posture used when neither a per-unit override nor + /// a per-relation entry is set. AI seats are seeded with `Capture`; humans + /// are seeded with `Prompt` when the new-player capture-prompt setting is on. + #[serde(default)] + pub default_civilian_posture: crate::capture::CapturePosture, + /// p3-05a: per-player civic axis selections plus the empire-wide anarchy + /// timer. Game 1 default = Chieftainship / LaborPool / Mercantilism with a + /// zero anarchy counter (`mc_core::CivicState::default`). `#[serde(default)]` + /// keeps pre-p3-05a saves loading. + #[serde(default)] + pub civic_state: mc_core::CivicState, + /// p1-29a: cumulative count of cities this player has lost (had captured by + /// another player) over the course of the game. Drives the last-stand + /// defense multiplier: once `cities.len() == 1`, the resolver scales the + /// defender's combat strength and the city's wall HP by + /// `1.0 + LAST_STAND_PER_LOSS × cities_lost_total` (capped at + /// `LAST_STAND_CAP`). Mirrors GDScript's `Player.cities_lost_total` + /// (see `src/game/engine/src/entities/player.gd`); incremented in + /// `process_siege` when a city changes ownership. + #[serde(default)] + pub cities_lost_total: u32, + /// p1-29i: game-turn on which this player most recently LOST a city to + /// capture. Drives the post-capture refound cooldown + /// (`CombatBalance::refound_suppression`): `try_found_city` refuses to found + /// a replacement while `turn - last_city_lost_turn < cooldown_turns`. + /// `None` until the first city loss (and on old saves) → no suppression. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_city_lost_turn: Option, + /// Derived realm-level statistics recomputed at end of every turn by + /// `TurnProcessor::step → recompute_derived_stats`. All future derived + /// scalars land here — single recompute site rule, see `mc_core::derived_stats`. + /// + /// `#[serde(default)]` keeps pre-p3-07 saves loading: fields default to zero, + /// correct for a save that hasn't run the recompute pass yet. + #[serde(default)] + pub derived_stats: mc_core::DerivedStats, +} + +/// Standing order for units that arrive at a rally point. +/// +/// Backward-compat serde: any unrecognised string value (e.g. old saves with +/// `"Defend"` written as a plain string) deserialises as `Defend` via +/// `#[serde(other)]`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RallyCommand { + Hold, + Defend, + Fortify, + JoinFormation, + /// Two-waypoint Patrol: unit PingPongs between spawn hex and `waypoint_2`. + Patrol { waypoint_2: (i32, i32) }, + Advance, + #[serde(other)] + Unknown, +} + +impl Default for RallyCommand { + fn default() -> Self { + RallyCommand::Defend + } +} + +impl RallyCommand { + /// Parse a GDScript string command + optional sentinel waypoint_2. + /// `-1, -1` sentinel means no waypoint_2 (legacy / non-Patrol commands). + pub fn from_str_with_waypoint(cmd: &str, wp2_col: i32, wp2_row: i32) -> Self { + match cmd.to_lowercase().as_str() { + "hold" => RallyCommand::Hold, + "defend" => RallyCommand::Defend, + "fortify" => RallyCommand::Fortify, + "join_formation" | "joinformation" => RallyCommand::JoinFormation, + "patrol" => { + if wp2_col == -1 && wp2_row == -1 { + RallyCommand::Defend + } else { + RallyCommand::Patrol { waypoint_2: (wp2_col, wp2_row) } + } + } + "advance" => RallyCommand::Advance, + _ => RallyCommand::Defend, + } + } +} + +/// A rally point attached to a specific building in a specific city. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildingRallyPoint { + pub city_index: usize, + pub building_id: String, + pub hex: (i32, i32), + /// Standing order for freshly spawned units. + #[serde(default)] + pub command: RallyCommand, +} + +/// Ambient fauna-presence pressure accumulated per city. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CityEcology { + /// Sum of (lair_tier × encounter_probability) within the city's radius. + pub adjacent_lair_pressure: f32, + /// Turn index of the most recent harassment event (0 if none). + pub last_harassment_turn: u32, +} + +/// A unit on the world map. Thin data struct; all movement/combat logic lives +/// in `TurnProcessor`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MapUnit { + /// Stable monotonic ID assigned at spawn (from GameState::next_unit_id). + /// Used for formation membership lookup so vec-index shifts on unit death + /// do not corrupt formation rosters. + #[serde(default)] + pub id: u32, + pub col: i32, + pub row: i32, + pub hp: i32, + pub max_hp: i32, + pub attack: i32, + pub defense: i32, + pub is_fortified: bool, + /// True if the unit is in sentry posture. Cleared automatically when an + /// enemy unit enters within 2 hex (wake-on-vision, processed in + /// `TurnProcessor::wake_sentrying_units`). No stat bonus — distinct from + /// `is_fortified` which grants cumulative defense. + #[serde(default)] + pub is_sentrying: bool, + /// Unit-type ID (e.g. `"dwarf_warrior"`) — matches JSON data `units/*.json`. + pub unit_id: String, + /// Strategic resources this unit holds (from `requires_resource` at build + /// time). Returned to the empire ledger on death. + #[serde(default)] + pub held_resources: Vec, + /// Active patrol standing order, if any. `None` means idle or fortified. + #[serde(default)] + pub patrol_order: Option, + /// Formation this unit currently belongs to, if any. + #[serde(default)] + pub formation_id: Option, + /// When true the unit is eligible to be automatically grouped into a + /// Formation by `aggregate_formations`. Default true for military units; + /// false for workers, scouts, and founders. + #[serde(default = "default_auto_join")] + pub auto_join: bool, + /// Siege unit is in deployed posture. Cannot move; can Bombard. Set by + /// `handle_deploy_siege`, cleared by `handle_pack_siege`. + #[serde(default)] + pub is_deployed: bool, + /// Amphibious unit is currently on a water tile (embarked). Defence -50%; + /// cannot fortify. Set by `handle_embark`, cleared by `handle_disembark`. + #[serde(default)] + pub is_embarked: bool, + /// Rally command to execute when this unit first reaches its rally hex. + /// Set at spawn; cleared by `apply_rally_arrival_actions` after firing once. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rally_on_arrival: Option, + /// The rally destination hex this unit is marching to. Used by + /// `apply_rally_arrival_actions` to detect arrival. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rally_destination: Option<(i32, i32)>, + /// Active status effects (poison/bleed/burn). Ticked in the health phase; + /// cleared by Medic RemoveStatus. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub status_effects: Vec, + /// Multi-turn action currently in progress (Engineer/Pioneer builds). + /// `None` when idle. Ticked in the production phase; completion fires the effect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_action: Option, + /// True if the unit is in stealth posture (Scout). Invisible to enemies + /// further than 1 hex. Cleared on attack or entering Fortify posture. + #[serde(default)] + pub is_stealthed: bool, + /// True if the scout has set an ambush on its current hex. Cleared when + /// triggered or when the unit moves. + #[serde(default)] + pub is_ambushing: bool, + /// True if the medic's Field Aura is active — friendly co-hex units + /// regenerate +5 HP/turn. Toggle off with FieldAura action. + /// Bridge reads this to populate `CombatParams::attacker_field_aura_active`. + #[serde(default)] + pub is_field_aura: bool, + /// True if this unit has an active Stabilise cover from a co-hex medic. + /// Set by the Medic Stabilise action; consumed (cleared) on the first + /// prevented-fatal-blow. Bridge reads this to populate + /// `CombatParams::defender_has_stabilise`. + #[serde(default)] + pub stabilise_pending: bool, + /// True when a Stabilise-covered fatal blow was prevented this battle. + /// Set by the bridge caller when `CombatResult::stabilise_prevented_kill` + /// is true. Cleared at battle end. Read by UI to display the "saved" badge. + #[serde(default)] + pub prevented_fatal_this_battle: bool, + /// Turn number when a Mark Trail tag on *this* unit expires (0 = not tagged). + /// Set by a friendly Scout's MarkTrail action targeting this unit. While + /// `game.turn <= mark_trail_expiry && mark_trail_expiry > 0`, this unit's + /// position is visible to the tagging player even through fog of war. + #[serde(default)] + pub mark_trail_expiry: u32, + // p2-53g: ranged posture + /// True when the unit has taken the AimedShot action; cleared on next attack. + #[serde(default)] + pub aimed_shot_pending: bool, + /// True when the unit is in fire-arrow posture (ranged attacks ignite tile). + #[serde(default)] + pub is_fire_arrows: bool, + // p2-53h: cavalry posture + /// True when the unit is in pursue posture (advance-on-rout follow-through active). + #[serde(default)] + pub is_pursuing: bool, + /// Hex of the pending charge target, set during Charge action. Consumed by combat resolver. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_charge_target: Option<(i32, i32)>, + // p2-53f: infantry posture + /// True when the unit is in shield-wall posture (+50% defence vs ranged). + #[serde(default)] + pub is_shield_wall: bool, + /// True when the unit is braced against a charge (first-strike on incoming melee). + #[serde(default)] + pub is_braced: bool, + /// Remaining turns of Rage (+40% attack). 0 = not raging. + #[serde(default)] + pub rage_turns_remaining: u8, + /// True when WarCry has been used this battle (once-per-battle gate). + #[serde(default)] + pub war_cry_used_this_battle: bool, + /// Remaining turns of WarCry attack debuff on this unit (-10% attack). + /// Set to 1 when an adjacent enemy uses WarCry; ticked down each turn. + /// Bridge reads `> 0` to set `CombatParams::attacker_war_cry_debuff`. + #[serde(default)] + pub war_cry_debuff_turns_remaining: u8, + /// Pending drill XP from Barracks Drill action; consumed by bridge this turn. + #[serde(default, skip_serializing_if = "crate::game_state::is_zero_u32")] + pub pending_drill_xp: u32, + /// Cached capability flag: true when this unit has the `"amphibious"` keyword. + /// Set at spawn / ingest from GDScript dict key `"is_amphibious"`. + /// Drives movement-cost logic in `step_toward_with_terrain` so ocean/coast + /// biomes are passable for amphibious land units. Does NOT grant naval combat; + /// this is the minimal Game 1 shore-crossing support. + #[serde(default)] + pub is_amphibious: bool, + // p2-53h: Wheel facing + /// Current facing direction of the unit (0–5, hex edge indices, flat-top orientation). + /// Default 0 (east). Updated by `ActionKind::Wheel`. Combat resolver consults this + /// for first-strike avoidance: if attacker changed facing away from defender's braced + /// edge via Wheel, the attacker skips the first-strike penalty. + #[serde(default)] + pub facing_edge: u8, + /// p2-55: per-unit civilian-capture posture override. `None` means fall + /// through to the player's per-relation map / global default. Set by the + /// unit-panel UI. Cleared by selecting "Use civ default". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub posture_override: Option, + /// p2-55: when `Some(captor)`, this unit is in ransom-pending state — it + /// stays in its original owner's `PlayerState::units` vec but is pinned + /// (no movement, no actions) until the offer is accepted, refused, or + /// expires. Movement / action handlers must check `captive_of.is_some()` + /// and refuse to act. On expiry / refusal the processor moves the unit + /// into the captor's vec and clears this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub captive_of: Option, + /// p2-67 Phase 9: cached base movement points (from the + /// `UnitsCatalog::get(unit_id).base_moves` lookup at spawn). Lets + /// `refresh_units` recharge `movement_remaining` without needing a + /// catalog reference at refresh time. `0` for tests/fixtures that + /// don't go through `MapUnit::new` — those paths must call + /// `.with_moves(n)` if they intend the unit to be movable. + #[serde(default)] + pub base_moves: i32, + /// p2-67 Phase 8: pending promotion pick. Set by + /// `mc_player_api::dispatch::apply_promote` and consumed by the + /// turn processor (Phase 11 follow-up) which validates the pick + /// against eligibility rules and applies the stat changes. + /// `None` means no promotion is queued. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_promotion: Option, + /// p2-67 Phase 9: movement points remaining this turn. Decremented + /// by `mc_turn::processor::process_move_requests` as the unit + /// pathfinds; reset to `base_moves` at turn start by + /// `mc_turn::refresh_units`. SRP-clean: `0` means **exhausted this + /// turn**, never "uninitialised" — fresh units take `base_moves` + /// directly via the constructor / `.with_moves` builder. + #[serde(default)] + pub movement_remaining: i32, + /// p3-11: action-point pool for Specialist civilians (Pioneer / + /// Engineer progression). `None` for all other unit types — military + /// units, scouts, founders without a configured capacity, etc. + /// + /// Set at spawn from `UnitsCatalog::get(unit_type).action_point_capacity` + /// (only populated for unit JSON that declares `action_point_capacity`). + /// Drained by per-action AP costs resolved through `mc_units::ap::cost_for`. + /// Recharged in full by `mc_turn::recharge_action_points` when the unit + /// ends its turn on a friendly city tile. + /// + /// Serde `default = None` keeps old saves loadable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action_points: Option, + /// p2-57c: production-quality band stamped at unit-completion time, derived + /// from the producing city's stockpile depth of the gating resource + /// (`mc_city::recipes::tick_and_stamp` → `StampedUnit.quality`). `None` for + /// units that were not produced through a quality-bearing recipe (bench / + /// legacy auto-warrior spawns, captured units, old saves). When set, the + /// unit's `attack` / `defense` / `max_hp` were already adjusted via + /// `mc_turn::apply_quality` at spawn — this field records *which* band the + /// adjustment used (for UI badges + replay), it is not re-applied per turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quality: Option, +} + +impl MapUnit { + /// Construct a fresh unit at `(col, row)` for `owner`. Reads + /// `base_moves` from `catalog.get(unit_type).base_moves`; if the + /// unit type is missing from the catalog (test fixtures that + /// didn't populate it), `base_moves` falls back to `0` and the + /// caller is expected to chain `.with_moves(n)`. + /// + /// `owner` is currently retained only for symmetry with the + /// GDScript path (units don't carry an explicit owner field — + /// ownership is implicit in the `PlayerState::units` vec they + /// live in). Kept on the signature so call sites read clearly. + #[must_use] + pub fn new( + unit_type: &str, + col: i32, + row: i32, + _owner: u8, + catalog: &mc_units::UnitsCatalog, + ) -> Self { + let stats = catalog.get(unit_type); + let base_moves = stats.map(|s| s.base_moves).unwrap_or(0); + // p3-11: spawn Specialist civilians with a full AP pool, sized from + // the per-unit JSON capacity. Unit types whose JSON omits + // `action_point_capacity` get `None` (no AP pool). + let action_points = stats + .and_then(|s| s.action_point_capacity) + .map(mc_core::units::ActionPoints::full); + Self { + unit_id: unit_type.to_string(), + col, + row, + base_moves, + movement_remaining: base_moves, + action_points, + ..Self::default() + } + } + + /// Builder override for movement points — used by tests that don't + /// supply a catalog. Sets both `base_moves` and + /// `movement_remaining` so the unit can move immediately and + /// `refresh_units` recharges to the same value next turn. + #[must_use] + pub fn with_moves(mut self, n: i32) -> Self { + self.base_moves = n; + self.movement_remaining = n; + self + } +} + +pub(crate) fn is_zero_u32(v: &u32) -> bool { *v == 0 } + +fn default_auto_join() -> bool { + true +} + +/// p2-71: default for `GameState::ai_difficulty_threshold_mult` — `1.0` is +/// the neutral / normal-difficulty value consumed by +/// `mc_ai::tactical::thresholds` (Easy <1.0, Hard >1.0). +fn default_threshold_mult() -> f32 { + 1.0 +} + +/// p2-71: default for `PlayerState::promotion_*_weight` — `1.0` is the +/// neutral value consumed by `mc_ai::tactical::promotion::pick_promotion` +/// (matches `TacticalPlayerState::default_promotion_weight`). +fn default_promotion_weight() -> f32 { + 1.0 +} + +/// Optional research state for players that simulate tech progression. +/// +/// `progress` uses `BTreeMap` so save files serialize with deterministic key +/// order, enabling byte-equal round-trip verification. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TechState { + pub researched: Vec, + pub progress: BTreeMap, +} + +#[cfg(test)] +mod p2_72a_save_round_trip_tests { + use super::*; + + #[test] + fn default_game_state_round_trips_through_serde() { + let g = GameState::default(); + let json = serde_json::to_string(&g).expect("serialize default GameState"); + let back: GameState = serde_json::from_str(&json).expect("deserialize default GameState"); + // Equality check by re-serialising the round-trip output and comparing + // the JSON — GameState's `#[serde(skip)]` fields make `PartialEq` impractical. + let json2 = serde_json::to_string(&back).expect("re-serialize"); + assert_eq!(json, json2, "default GameState must byte-equal across round-trip"); + } + + #[test] + fn wall3_fields_default_to_zero() { + let g = GameState::default(); + assert_eq!(g.era, 0); + assert_eq!(g.map_seed, 0); + assert_eq!(g.current_player_index, 0); + assert_eq!(g.game_rng_seed, 0); + assert_eq!(g.game_rng_state, 0); + assert_eq!(g.ai_difficulty.production_mult, 1.0); + assert_eq!(g.ai_difficulty.research_mult, 1.0); + assert_eq!(g.ai_difficulty.extra_unit_id, "warrior"); + assert!(g.ai_difficulty.per_player_production_mult.is_empty()); + } + + #[test] + fn wall3_fields_round_trip_with_values() { + let mut g = GameState::default(); + g.era = 3; + g.map_seed = 0xdead_beef_cafe; + g.current_player_index = 2; + g.game_rng_seed = 12345; + g.game_rng_state = 67890; + g.ai_difficulty.production_mult = 1.25; + g.ai_difficulty.research_mult = 0.85; + g.ai_difficulty.starting_gold_bonus = 100; + g.ai_difficulty.extra_starting_units = 1; + g.ai_difficulty.extra_unit_id = "spearman".into(); + g.ai_difficulty.per_player_production_mult.insert(1, 1.5); + g.ai_difficulty.per_player_research_mult.insert(2, 0.9); + + let json = serde_json::to_string(&g).expect("serialize"); + let back: GameState = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.era, 3); + assert_eq!(back.map_seed, 0xdead_beef_cafe); + assert_eq!(back.current_player_index, 2); + assert_eq!(back.game_rng_seed, 12345); + assert_eq!(back.game_rng_state, 67890); + assert_eq!(back.ai_difficulty.production_mult, 1.25); + assert_eq!(back.ai_difficulty.research_mult, 0.85); + assert_eq!(back.ai_difficulty.starting_gold_bonus, 100); + assert_eq!(back.ai_difficulty.extra_starting_units, 1); + assert_eq!(back.ai_difficulty.extra_unit_id, "spearman"); + assert_eq!(back.ai_difficulty.per_player_production_mult.get(&1), Some(&1.5)); + assert_eq!(back.ai_difficulty.per_player_research_mult.get(&2), Some(&0.9)); + } + + #[test] + fn pre_wall3_save_deserializes_with_defaults() { + // Simulate loading a save written before Wall-3 fields were added: + // only the legacy fields are present. `#[serde(default)]` must + // back-fill `era` / `map_seed` / etc. without error. + let legacy_json = r#"{ + "turn": 5, + "players": [], + "grid": null + }"#; + let back: GameState = serde_json::from_str(legacy_json) + .expect("legacy save must deserialize via serde(default)"); + assert_eq!(back.turn, 5); + assert_eq!(back.era, 0); + assert_eq!(back.map_seed, 0); + assert_eq!(back.current_player_index, 0); + assert_eq!(back.ai_difficulty.production_mult, 1.0); + } +} diff --git a/src/simulator/crates/mc-state/src/lib.rs b/src/simulator/crates/mc-state/src/lib.rs index b6e750ba..a94a843a 100644 --- a/src/simulator/crates/mc-state/src/lib.rs +++ b/src/simulator/crates/mc-state/src/lib.rs @@ -17,5 +17,6 @@ pub mod capture; pub mod combat_event; +pub mod game_state; pub mod patrol; pub mod ransom; diff --git a/src/simulator/crates/mc-turn/src/capture_drain.rs b/src/simulator/crates/mc-turn/src/capture_drain.rs new file mode 100644 index 00000000..c3faba35 --- /dev/null +++ b/src/simulator/crates/mc-turn/src/capture_drain.rs @@ -0,0 +1,89 @@ +//! Capture-event drain (p2-65 Phase 3b). +//! +//! `PendingCaptureEvents` is a data shape that lives in [`mc_state::game_state`], +//! but draining it into a [`crate::combat_event::TurnResult`] is turn-step event +//! translation: it builds `mc_replay::TurnEvent` variants and appends them to +//! `result.events_emitted`. Both `TurnResult` and `mc_replay::TurnEvent` are +//! `mc-turn` / replay shapes that cannot move into the data crate without a +//! cycle, so this logic stays in `mc-turn` as an extension trait over the +//! foreign `PendingCaptureEvents` type (local-trait-for-foreign-type — legal +//! under the orphan rule because the trait is local). + +use crate::combat_event::TurnResult; +use mc_state::game_state::PendingCaptureEvents; + +/// Drain staged capture/PvP events into the turn's `TurnResult`. +pub trait DrainCaptureEvents { + /// Move accumulated events into the supplied `TurnResult` and clear the + /// scratch buffer. Called once per `process_pvp_combat`. + /// + /// Also translates each p2-55 event into a `mc_replay::TurnEvent` variant + /// and appends it to `result.events_emitted` so the chronicle pipeline + /// picks them up without an extra pass. + fn drain_into(&mut self, result: &mut TurnResult); +} + +impl DrainCaptureEvents for PendingCaptureEvents { + fn drain_into(&mut self, result: &mut TurnResult) { + // Emit replay variants before moving the vecs (we borrow from them). + for ev in &self.units_captured { + result.events_emitted.push(mc_replay::TurnEvent::UnitCaptured { + turn: ev.turn, + unit_id: ev.unit_id, + captor: mc_replay::ClanId(ev.captor as u32), + prior_owner: mc_replay::ClanId(ev.prior_owner as u32), + hex: mc_replay::TileCoord::new(ev.col, ev.row), + unit_kind: mc_replay::UnitKind(ev.unit_kind.clone()), + }); + } + for ev in &self.ransom_offers_created { + result.events_emitted.push(mc_replay::TurnEvent::UnitRansomOffered { + turn: ev.turn, + offer_id: ev.offer_id, + unit_id: ev.unit_id, + captor: mc_replay::ClanId(ev.captor as u32), + owner: mc_replay::ClanId(ev.owner as u32), + price: ev.price, + expires_turn: ev.expires_turn, + }); + } + for ev in &self.civilians_destroyed { + result.events_emitted.push(mc_replay::TurnEvent::CivilianDestroyed { + turn: ev.turn, + unit_id: ev.unit_id, + destroyer: mc_replay::ClanId(ev.destroyer as u32), + owner: mc_replay::ClanId(ev.owner as u32), + hex: mc_replay::TileCoord::new(ev.col, ev.row), + unit_kind: mc_replay::UnitKind(ev.unit_kind.clone()), + }); + } + // p2-67 Bug 3: queued-PvP kill events were silent before this drain + // existed — the inline `swap_remove` in `resolve_single_pvp_attack` + // happened with no `TurnEvent::UnitKilled` push. Translate each + // staged `UnitKilledEvent` to the chronicle variant now. + for ev in &self.units_killed { + result.events_emitted.push(mc_replay::TurnEvent::UnitKilled { + turn: ev.turn, + attacker: mc_replay::ClanId(ev.attacker as u32), + defender: mc_replay::ClanId(ev.defender as u32), + unit_id: ev.unit_id, + unit_kind: mc_replay::UnitKind(ev.unit_kind.clone()), + hex: mc_replay::TileCoord::new(ev.col, ev.row), + }); + } + // `units_killed` does not have a matching `TurnResult` Vec field — + // the canonical surface is `events_emitted` above. Just clear. + self.units_killed.clear(); + result.units_captured.append(&mut self.units_captured); + result.ransom_offers_created.append(&mut self.ransom_offers_created); + result.civilians_destroyed.append(&mut self.civilians_destroyed); + // p2-55e: drain accepted/expired into TurnResult so chronicle reads + // them directly without prior-turn cross-reference. + result + .ransom_offers_accepted + .append(&mut self.ransom_offers_accepted); + result + .ransom_offers_expired + .append(&mut self.ransom_offers_expired); + } +} diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index da99f545..ac40bda7 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -1,1363 +1,17 @@ -//! Game state types — data only, no logic. All mutation happens through -//! `TurnProcessor::step`. +//! Re-export shim (p2-65 Phase 3b). +//! +//! The canonical full-simulation state struct `GameState` and its pending-queue +//! value types now live in [`mc_state::game_state`]. This module re-exports the +//! whole thing so every `crate::game_state::…` / `mc_turn::game_state::…` import +//! path inside `mc-turn` (and the `mc_turn::lib.rs` re-export of `GameState`, +//! `PlayerState`, `MapUnit`, the action-request structs, …) keeps resolving +//! unchanged for one migration cycle. +//! +//! The turn-step *logic* that mutates `GameState` stays in `mc-turn` +//! (`processor.rs`, `action_handlers/`, victory/capture/ransom resolvers). The +//! `PendingCaptureEvents::drain_into` event-translation method — which embeds +//! `mc_replay::TurnEvent` + `mc_turn::combat_event::TurnResult` (both mc-turn +//! shapes, not movable to the data crate without a cycle) — is re-homed as the +//! `crate::capture_drain::DrainCaptureEvents` extension trait. -use mc_core::ScoringWeights; -use mc_replay; -use mc_city::CityState; -use mc_combat::StatusEffect; -use mc_core::building_action::BuildingActionKind; -use mc_core::building_state::BuildingState; -use mc_core::multi_turn_action::MultiTurnAction; -use mc_core::formation::{ - AutoJoinRequest, Formation, FormationCommandRequest, FormationShapeRequest, - RallyPointRequest, SplitFormationRequest, -}; -use mc_core::grid::GridState; -use mc_core::improvement::{TileImprovement, TileImprovementSpec}; -use mc_core::WonderId; -use mc_culture::CulturePool; -use mc_tech::PlayerTechState; -use mc_trade::relation::RelationState; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet}; - -/// Serde adapter: round-trips `BTreeMap<(u8, u8), RelationState>` as a -/// `Vec<((u8, u8), RelationState)>`. `serde_json` cannot serialize tuple keys -/// directly ("key must be a string"), so saves with populated diplomacy would -/// otherwise fail. Encoding as pairs keeps the on-disk form deterministic -/// (BTreeMap iteration order) and byte-stable across processes. -mod relations_as_pairs { - use mc_trade::relation::RelationState; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use std::collections::BTreeMap; - - pub fn serialize( - map: &BTreeMap<(u8, u8), RelationState>, - ser: S, - ) -> Result { - let pairs: Vec<(&(u8, u8), &RelationState)> = map.iter().collect(); - pairs.serialize(ser) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - de: D, - ) -> Result, D::Error> { - let pairs: Vec<((u8, u8), RelationState)> = Vec::deserialize(de)?; - Ok(pairs.into_iter().collect()) - } -} - -/// Serde adapter: round-trips `BTreeMap<(u16,u16), TileImprovement>` as a -/// `Vec<((u16,u16), TileImprovement)>` for the same reason as `relations_as_pairs`. -mod improvements_as_pairs { - use mc_core::improvement::TileImprovement; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use std::collections::BTreeMap; - - pub fn serialize( - map: &BTreeMap<(u16, u16), TileImprovement>, - ser: S, - ) -> Result { - let pairs: Vec<(&(u16, u16), &TileImprovement)> = map.iter().collect(); - pairs.serialize(ser) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - de: D, - ) -> Result, D::Error> { - let pairs: Vec<((u16, u16), TileImprovement)> = Vec::deserialize(de)?; - Ok(pairs.into_iter().collect()) - } -} - -/// p3-10b: serde adapter for `BTreeMap<(u16,u16), SiegeState>`. Same -/// rationale as `improvements_as_pairs` — JSON object keys must be strings, -/// so pairs keep the on-disk form deterministic and round-trippable. -mod siege_pressure_as_pairs { - use mc_core::lair::SiegeState; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use std::collections::BTreeMap; - - pub fn serialize( - map: &BTreeMap<(u16, u16), SiegeState>, - ser: S, - ) -> Result { - let pairs: Vec<(&(u16, u16), &SiegeState)> = map.iter().collect(); - pairs.serialize(ser) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - de: D, - ) -> Result, D::Error> { - let pairs: Vec<((u16, u16), SiegeState)> = Vec::deserialize(de)?; - Ok(pairs.into_iter().collect()) - } -} - -/// p3-10c: serde adapter for `BTreeMap<(u16,u16), RaidAftermath>`. Same -/// rationale as `siege_pressure_as_pairs` — JSON object keys must be strings, -/// so pairs keep the on-disk form deterministic and round-trippable. -mod raid_aftermath_as_pairs { - use mc_core::lair::RaidAftermath; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use std::collections::BTreeMap; - - pub fn serialize( - map: &BTreeMap<(u16, u16), RaidAftermath>, - ser: S, - ) -> Result { - let pairs: Vec<(&(u16, u16), &RaidAftermath)> = map.iter().collect(); - pairs.serialize(ser) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - de: D, - ) -> Result, D::Error> { - let pairs: Vec<((u16, u16), RaidAftermath)> = Vec::deserialize(de)?; - Ok(pairs.into_iter().collect()) - } -} - -/// Serde adapter: round-trips `BTreeMap<(usize, String), BuildingState>` as -/// `Vec<((usize, String), BuildingState)>` pairs for JSON-key compat. -mod building_states_as_pairs { - use mc_core::building_state::BuildingState; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use std::collections::BTreeMap; - - pub fn serialize( - map: &BTreeMap<(usize, String), BuildingState>, - ser: S, - ) -> Result { - let pairs: Vec<(&(usize, String), &BuildingState)> = map.iter().collect(); - pairs.serialize(ser) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - de: D, - ) -> Result, D::Error> { - let pairs: Vec<((usize, String), BuildingState)> = Vec::deserialize(de)?; - Ok(pairs.into_iter().collect()) - } -} - -/// A player-vs-player attack request queued by GDScript (click-to-attack path). -/// -/// Drained each turn by `TurnProcessor::process_pvp_combat` before the -/// position-based proximity check runs. This makes the live game's click path -/// and MCTS rollouts use the same `CombatResolver` code route. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AttackRequest { - /// Player index of the attacker. - pub attacker_player: u8, - /// Index into `PlayerState::units` for the attacking unit. - pub attacker_unit: usize, - /// Player index of the defender. - pub defender_player: u8, - /// Index into `PlayerState::units` for the defending unit. - pub defender_unit: usize, -} - -/// A bombard request queued by GDScript (player clicks Bombard, selects target hex). -/// -/// Drained each turn by `TurnProcessor::process_bombard_requests`. Uses the same -/// queue-then-drain pattern as `AttackRequest` / `pending_pvp_attacks`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BombardRequest { - /// Player index of the bombarding siege unit's owner. - pub attacker_player: u8, - /// Index into `PlayerState::units` for the bombarding unit. - pub attacker_unit: usize, - /// Target axial hex `(col, row)`. - pub target_col: i32, - pub target_row: i32, - /// True when the unit has the `arcing` keyword (catapult) — bypasses LoS. - /// False for ballistas and cannon (line-trajectory, requires LoS). - pub indirect_fire: bool, -} - -/// A pillage request queued by GDScript (worker clicks Pillage on an improved tile). -/// -/// Drained each turn by `TurnProcessor::process_pillage_requests`. The tile's -/// improvement in `GameState::tile_improvements` is the authoritative state; -/// the GDScript tile entity is a presentation mirror refreshed after drain. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PillageRequest { - /// Player index of the pillaging unit's owner. - pub player_index: u8, - /// Index into `PlayerState::units` for the pillaging unit. - pub unit_index: usize, - /// Target tile axial hex `(col, row)` — must carry an improvement. - pub target_col: i32, - pub target_row: i32, -} - -/// A Volley request queued by GDScript (ranged unit clicks Volley, selects target hex). -/// -/// Drained each turn by `TurnProcessor::process_volley_requests`. AoE: damages -/// units in the target hex and two randomly chosen adjacent edge hexes. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VolleyRequest { - /// Player index of the volleying unit's owner. - pub attacker_player: u8, - /// Index into `PlayerState::units` for the volleying unit. - pub attacker_unit: usize, - /// Target axial hex centre `(col, row)`. - pub target_col: i32, - pub target_row: i32, -} - -/// A Charge request queued by GDScript (cavalry clicks Charge, selects target hex). -/// -/// Drained each turn by `TurnProcessor::process_charge_requests`. The unit -/// moves up to 2 hexes in a straight line toward the target, then resolves -/// melee combat with the +30% charge attack bonus. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChargeRequest { - /// Player index of the charging unit's owner. - pub attacker_player: u8, - /// Index into `PlayerState::units` for the charging unit. - pub attacker_unit: usize, - /// Target axial hex `(col, row)`. - pub target_col: i32, - pub target_row: i32, -} - -/// A Move request queued by `mc-player-api::dispatch::apply_move` -/// (p2-67 Phase 9 — Proper Move subsystem). -/// -/// Drained by [`crate::processor::process_move_requests`], which -/// pathfinds via `mc-pathfinding`, validates the unit's -/// `movement_remaining`, decrements it, and applies the new position. -/// Empties at the end of every drain pass (cleared even on failed -/// requests, matching the other `pending_*` queues). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MoveRequest { - /// Player index of the moving unit. - pub player_idx: usize, - /// Index into `PlayerState::units` for the moving unit. - pub unit_idx: usize, - /// Target tile axial hex `(col, row)`. - pub target_col: i32, - pub target_row: i32, -} - -/// A building action request queued by GDScript, drained by -/// `building_action_handlers::drain_pending_building_actions`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BuildingActionRequest { - pub player_idx: usize, - pub city_idx: usize, - pub building_id: String, - pub kind: BuildingActionKind, - /// Unit ID for GarrisonIn/Out actions; None for all others. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub unit_id: Option, -} - -/// Top-level headless game state. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GameState { - pub turn: u32, - pub players: Vec, - /// The world grid. `Some` for full benches, `None` for the headless unit - /// tests in mc-sim that don't need spatial data. - pub grid: Option, - /// p1-60 J — symmetric alliance set. Keyed by canonical `(min_idx, max_idx)` - /// to mirror `PlayerState::relations`. Allied pairs share vision; consumers - /// (`mc_vision::compute_vision`) union the partner's visible set into the - /// active player's. `#[serde(default)]` so old saves load with no alliances. - #[serde(default)] - pub alliances: std::collections::BTreeSet<(u8, u8)>, - /// Explicit PvP attacks queued by GDScript this turn (click-to-attack path). - /// Drained at the start of `process_pvp_combat`, before position-based - /// discovery. Cleared after each turn so stale entries never linger. - #[serde(default)] - pub pending_pvp_attacks: Vec, - /// Bombard requests queued by GDScript this turn (siege unit Bombard action). - /// Drained at the start of `process_bombard_requests`. Cleared each turn. - #[serde(default)] - pub pending_bombard_requests: Vec, - /// Pillage requests queued by GDScript this turn (worker Pillage action). - /// Drained by `TurnProcessor::process_pillage_requests`. Cleared each turn. - #[serde(default)] - pub pending_pillage_requests: Vec, - /// Volley requests queued by GDScript this turn (ranged unit AoE attack). - /// Drained at the start of `process_volley_requests`. Cleared each turn. - #[serde(default)] - pub pending_volley_requests: Vec, - /// Charge requests queued by GDScript this turn (cavalry 2-hex straight-line charge). - /// Drained at the start of `process_charge_requests`. Cleared each turn. - #[serde(default)] - pub pending_charge_requests: Vec, - /// Active formations across all players. BTreeMap for deterministic save order. - #[serde(default)] - pub formations: BTreeMap, - /// Monotonic counter — incremented each time a new Formation is created. - #[serde(default)] - pub next_formation_id: u32, - /// Monotonic counter — incremented each time a new MapUnit is spawned. - #[serde(default)] - pub next_unit_id: u32, - /// Rally point changes queued by GDScript this turn. - #[serde(default)] - pub pending_rally_requests: Vec, - /// p2-67 Phase 9: pending move requests queued by - /// `mc_player_api::dispatch::apply_move`. Drained by - /// `crate::processor::process_move_requests`. - #[serde(default)] - pub pending_move_requests: Vec, - /// Formation command changes queued by GDScript this turn. - #[serde(default)] - pub pending_formation_commands: Vec, - /// Formation shape changes queued by GDScript this turn. - #[serde(default)] - pub pending_formation_shapes: Vec, - /// Split-from-formation requests queued by GDScript this turn. - #[serde(default)] - pub pending_split_requests: Vec, - /// Auto-join toggle requests queued by GDScript this turn. - #[serde(default)] - pub pending_auto_join_requests: Vec, - /// Building action requests queued by GDScript this turn. - /// Drained by `building_action_handlers::drain_pending_building_actions`. - #[serde(default)] - pub pending_building_actions: Vec, - /// Sparse per-hex improvement layer. Keyed by (col, row) as u16; most - /// tiles will be unimproved, so a BTreeMap wastes far less memory than an - /// Option on every TileState. Serialized as pairs to avoid the JSON - /// "key must be a string" restriction on tuple keys. - #[serde(default, with = "improvements_as_pairs")] - pub tile_improvements: BTreeMap<(u16, u16), TileImprovement>, - /// Registry of improvement specs loaded from - /// `public/resources/improvements/*.json`. Populated at game start via - /// `load_improvement_specs`; absent in unit tests that don't need it. - #[serde(skip)] - pub improvement_registry: BTreeMap, - /// p2-67 Phase 9: unit-type stats catalog loaded from - /// `public/resources/units/*.json`. Consumed by `MapUnit::new` (read - /// `base_moves` at spawn) and by future authors that need - /// id → base-stats lookup. `#[serde(skip)]` mirrors - /// `improvement_registry` — the catalog is reloaded at boot, not - /// persisted in save files. - #[serde(skip)] - pub units_catalog: mc_units::UnitsCatalog, - /// p3-05e: civic-axis modifier catalog loaded from - /// `public/resources/civics/*.json`. Used at turn-end to resolve each - /// player's `ResolvedModifiers` (notably `specialist_xp_rate`, consumed by - /// the expertise-XP tick in `process_city_production`). `#[serde(skip)]` - /// mirrors `units_catalog` — boot-loaded, not save-persisted. An empty - /// (Default) catalog resolves to identity modifiers (rate 1.0), so test - /// and pre-load paths see no civic effect. - #[serde(skip)] - pub civic_catalog: mc_civics::CivicCatalog, - /// p2-71: tactical-AI view of the producible-unit catalog. Mirrors - /// `TacticalState::unit_catalog` and is populated once at harness boot - /// by `GdPlayerApi::set_units_catalog_json` (or directly in Rust tests). - /// Read by `mc_player_api::projection::project_tactical` so MCTS sees a - /// non-empty buildable list; empty (default) recovers the legacy - /// tier-1-fallback behaviour. `#[serde(skip)]` because the catalog is - /// boot-loaded, not save-persisted. - #[serde(skip)] - pub ai_unit_catalog: Vec, - /// p2-71: tactical-AI view of the producible-building catalog. Companion - /// to `ai_unit_catalog` — populated by the harness via - /// `GdPlayerApi::set_buildings_catalog_json`. Empty falls back to the - /// no-building behaviour. - #[serde(skip)] - pub ai_building_catalog: Vec, - /// p2-71: difficulty multiplier projected into - /// `TacticalState::difficulty_threshold_mult`. Defaults to 1.0 - /// (normal). Populated by `GdPlayerApi::set_difficulty_threshold_mult`. - #[serde(skip, default = "default_threshold_mult")] - pub ai_difficulty_threshold_mult: f32, - /// p2-67 Phase 8: shared diplomatic-agreement ledger. - /// Holds OpenBorders / SharedMap / LuxurySwap agreements across - /// every player pair. Authoritative single source — every - /// agreement-mutating action (Offer / Accept / Reject, war - /// declarations) reads + writes this field instead of allocating - /// throwaway ledgers. `#[serde(default)]` keeps pre-Phase-8 saves - /// loading with an empty ledger. - #[serde(default)] - pub trade_ledger: mc_trade::TradeLedger, - /// Communications Phase 2: envelopes-in-flight, sighting - /// propagation queue, and per-player pending-war-dec overlays. See - /// `mc-comms` crate docs for the war-declaration asymmetry design. - /// `#[serde(default)]` keeps pre-Phase-2 saves loading with an - /// empty `CommsState`. - #[serde(default)] - pub comms: mc_comms::CommsState, - /// p2-55: pending ransom offers across all players. Ticked at start of - /// turn (`RansomQueue::tick`); offers are added when the resolver returns - /// `CombatOutcome::RansomOffered`. - #[serde(default)] - pub ransom_queue: crate::ransom::RansomQueue, - /// p2-55f: data-driven combat balance config (ransom durations, XP - /// awards, multipliers). Loaded from - /// `public/games/age-of-dwarves/data/combat_balance.json` at game start - /// via `crate::combat_balance::load_combat_balance`. Defaults to the - /// pre-p2-55f hardcoded constants so save migration is no-op. - #[serde(default)] - pub combat_balance: crate::combat_balance::CombatBalance, - /// p3-10b: per-lair-tile siege pressure state. Keyed by `(col, row)` - /// of the lair tile. Bridge layer ticks pressure each turn via - /// `mc_combat::lair::tick_siege` / `decay_siege`; entries are removed - /// when the lair is cleared (Surrender) or pressure decays to zero. - /// Serialized as pairs to avoid the JSON "key must be a string" - /// restriction on tuple keys, mirroring `tile_improvements`. - #[serde(default, with = "siege_pressure_as_pairs")] - pub siege_pressure: BTreeMap<(u16, u16), mc_core::lair::SiegeState>, - /// p3-10c: per-lair-tile raid aftermath state. Keyed by `(col, row)` of - /// the lair tile. Populated by the GDExt bridge after a `Caught` raid - /// outcome; entries are ticked by the bridge each turn and removed when - /// `RaidAftermath::is_active(current_turn)` returns false. Serialized as - /// pairs for the same JSON-key reasons as `siege_pressure`. - #[serde(default, with = "raid_aftermath_as_pairs")] - pub raid_aftermath: BTreeMap<(u16, u16), mc_core::lair::RaidAftermath>, - /// p3-13: active fog-bank map. Keyed by flat tile index (`row * width + col` - /// as `u16`). Populated by `mc-sim::event_dispatch::dispatch_world_events` - /// when `AnomalousEvent::FogBank` fires; lazily expires via - /// `mc_observation::fog::is_fogged`. Sparse — zero overhead when no fog is - /// active. Persisted in the save file so fog state survives load/resume. - #[serde(default)] - pub fog_map: std::collections::HashMap, - /// p2-55: scratch buffer for capture / ransom / destroy events produced - /// by `resolve_single_pvp_attack` and the proximity combat path. Drained - /// into `TurnResult` at the end of `process_pvp_combat`. Not persisted - /// across saves. - #[serde(skip)] - pub pending_capture_events: PendingCaptureEvents, - /// p2-48: player indices that submitted a resignation action this turn. - /// Drained by `end_conditions::evaluate_conditions` at turn-end. The field - /// uses `u8` player indices (matching `PlayerState::player_index`) to stay - /// consistent with the rest of `GameState`. `#[serde(default)]` so old - /// save files (without this field) deserialize cleanly. - #[serde(default)] - pub pending_resignations: BTreeSet, - /// p2-72a Stage 2b: NPC buildings on the world map (lairs / villages / - /// ruins). Mirrors `GameState.npc_buildings` from GDScript. Populated by - /// `GdGameState::spawn_npc_building` (Stage 2b) and consumed by Stage 3's - /// `serialize_full` save-format migration. `#[serde(default)]` so saves - /// emitted before Stage 2b deserialize cleanly with an empty list. - #[serde(default)] - pub npc_buildings: Vec, - /// p2-72a Stage 3 — Wall-3 fields absorbed from GDScript `GameState`. - /// Era index into the game pack's `eras.json`. Game-pack-driven; the - /// engine defines no era names. Default `0` matches GDScript's `era: int = 0`. - #[serde(default)] - pub era: u32, - /// World-generation seed (from `game_settings["seed"]`). Stored so - /// climate / RNG-derivation systems can rebuild deterministic per-turn - /// seeds after a load. - #[serde(default)] - pub map_seed: u64, - /// Whose turn it is — index into `players`. `0` at game start; the turn - /// loop advances it after each `EndTurn`. Persisted so mid-game saves - /// resume on the correct player. - #[serde(default)] - pub current_player_index: u8, - /// Seed of the central RNG (`GameState.game_rng` in GDScript). Captured - /// at save so the random trajectory is reproducible. - #[serde(default)] - pub game_rng_seed: u64, - /// Live state of the central RNG, captured at save. Together with - /// `game_rng_seed` lets the engine resume the exact sequence on load. - #[serde(default)] - pub game_rng_state: u64, - /// AI-difficulty modifier axes. All eight axes default to neutral - /// values; the harness writes the active difficulty entry into this - /// field at game-setup time. Persisted so the difficulty applied on - /// turn 1 is the same one applied after a mid-game save+load. - #[serde(default)] - pub ai_difficulty: AiDifficulty, - /// p2-59: explicit escort links, keyed `protected_unit_id -> escort_unit_id` - /// (both `MapUnit::id`). Distinct from the *implicit* adjacency-based - /// protection in `process_fauna_encounters_inner` Step 1b — this map drives - /// **stack movement** (an escort drags its protected unit along at the - /// slower unit's MP rate) and persists until `EscortRelease` or unit death. - /// Lookups are defensive: a dangling id (dead / captured unit) is simply - /// skipped, so no per-death cleanup is required. `BTreeMap` for deterministic - /// save order; `#[serde(default)]` so pre-p2-59 saves load with no links. - #[serde(default)] - pub escort_links: BTreeMap, - /// p2-59: escort assign/release requests queued by the dispatch layer this - /// turn. Drained synchronously by `processor::process_escort_requests`. - /// Not persisted (cleared each turn like every other `pending_*` queue). - #[serde(default)] - pub pending_escort_requests: Vec, -} - -/// p2-59: an escort assign/release request queued by -/// `mc_player_api::dispatch`. Drained by -/// `crate::processor::process_escort_requests`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EscortRequest { - /// Player index of the protected unit issuing the verb. - pub player_idx: usize, - /// `MapUnit::id` of the protected (civilian / pioneer) unit. - pub protected_unit_id: u32, - /// `MapUnit::id` of the chosen escort. `None` for a release request, or - /// for an assign that asks the handler to auto-pick the nearest eligible - /// in-range escort. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub escort_unit_id: Option, - /// `true` = assign, `false` = release. - pub assign: bool, -} - -/// AI difficulty modifier axes (eight values), absorbed wholesale from the -/// `GameState.ai_*` field set in GDScript so a save round-trip preserves the -/// difficulty applied to AI players. -/// -/// Field names mirror the GDScript shape one-for-one — the harness stamps -/// each axis directly without rename. Defaults are neutral (`production_mult` -/// = `research_mult` = `1.0`; zero bonuses; empty per-player maps). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AiDifficulty { - /// Difficulty modifier applied to AI production each turn. `<1.0` = penalty, - /// `>1.0` = bonus. Default `1.0`. - pub production_mult: f32, - /// Difficulty modifier applied to AI research (science) each turn. Default `1.0`. - pub research_mult: f32, - /// Gold added to every AI player at game start for the current difficulty tier. - pub starting_gold_bonus: i32, - /// Extra warrior-class units spawned per AI city at game start. - pub extra_starting_units: u32, - /// ID of the extra starting unit. Default `"warrior"`. - pub extra_unit_id: String, - /// Per-player production multiplier overrides. Key = player index. When - /// non-empty, the per-player value takes precedence over `production_mult`. - pub per_player_production_mult: BTreeMap, - /// Per-player research multiplier overrides — same semantics as - /// `per_player_production_mult`. - pub per_player_research_mult: BTreeMap, -} - -impl Default for AiDifficulty { - fn default() -> Self { - Self { - production_mult: 1.0, - research_mult: 1.0, - starting_gold_bonus: 0, - extra_starting_units: 0, - extra_unit_id: "warrior".to_string(), - per_player_production_mult: BTreeMap::new(), - per_player_research_mult: BTreeMap::new(), - } - } -} - -/// p2-55: scratch staging for capture / ransom / destroy events fired during -/// the PvP combat phase. Lives on `GameState` because the inner combat helpers -/// don't take `&mut TurnResult`. Drained into `TurnResult` at phase end. -#[derive(Debug, Clone, Default)] -pub struct PendingCaptureEvents { - pub units_captured: Vec, - pub ransom_offers_created: Vec, - pub civilians_destroyed: Vec, - /// p2-55e: ransom offers paid out (gold deducted, ownership restored). - /// Bridge accept_ransom_offer pushes here; drain_into surfaces onto - /// `TurnResult.ransom_offers_accepted`. - pub ransom_offers_accepted: Vec, - /// p2-55e: ransom offers refused (manual refuse) or expired (timeout). - /// `process_ransom_expiry` and bridge `refuse_ransom_offer` push here. - pub ransom_offers_expired: Vec, - /// p2-67 Bug 3: kills staged from `resolve_single_pvp_attack` (queued - /// PvP path). The proximity-discovery loop in `process_pvp_combat` - /// has its own inline emit at the kill-removal site and does NOT push - /// here. `drain_into` translates each entry to a - /// `mc_replay::TurnEvent::UnitKilled` on `result.events_emitted`. - pub units_killed: Vec, -} - -impl PendingCaptureEvents { - /// Move accumulated events into the supplied `TurnResult` and clear the - /// scratch buffer. Called once per `process_pvp_combat`. - /// - /// Also translates each p2-55 event into a `mc_replay::TurnEvent` variant - /// and appends it to `result.events_emitted` so the chronicle pipeline - /// picks them up without an extra pass. - pub fn drain_into(&mut self, result: &mut crate::combat_event::TurnResult) { - // Emit replay variants before moving the vecs (we borrow from them). - for ev in &self.units_captured { - result.events_emitted.push(mc_replay::TurnEvent::UnitCaptured { - turn: ev.turn, - unit_id: ev.unit_id, - captor: mc_replay::ClanId(ev.captor as u32), - prior_owner: mc_replay::ClanId(ev.prior_owner as u32), - hex: mc_replay::TileCoord::new(ev.col, ev.row), - unit_kind: mc_replay::UnitKind(ev.unit_kind.clone()), - }); - } - for ev in &self.ransom_offers_created { - result.events_emitted.push(mc_replay::TurnEvent::UnitRansomOffered { - turn: ev.turn, - offer_id: ev.offer_id, - unit_id: ev.unit_id, - captor: mc_replay::ClanId(ev.captor as u32), - owner: mc_replay::ClanId(ev.owner as u32), - price: ev.price, - expires_turn: ev.expires_turn, - }); - } - for ev in &self.civilians_destroyed { - result.events_emitted.push(mc_replay::TurnEvent::CivilianDestroyed { - turn: ev.turn, - unit_id: ev.unit_id, - destroyer: mc_replay::ClanId(ev.destroyer as u32), - owner: mc_replay::ClanId(ev.owner as u32), - hex: mc_replay::TileCoord::new(ev.col, ev.row), - unit_kind: mc_replay::UnitKind(ev.unit_kind.clone()), - }); - } - // p2-67 Bug 3: queued-PvP kill events were silent before this drain - // existed — the inline `swap_remove` in `resolve_single_pvp_attack` - // happened with no `TurnEvent::UnitKilled` push. Translate each - // staged `UnitKilledEvent` to the chronicle variant now. - for ev in &self.units_killed { - result.events_emitted.push(mc_replay::TurnEvent::UnitKilled { - turn: ev.turn, - attacker: mc_replay::ClanId(ev.attacker as u32), - defender: mc_replay::ClanId(ev.defender as u32), - unit_id: ev.unit_id, - unit_kind: mc_replay::UnitKind(ev.unit_kind.clone()), - hex: mc_replay::TileCoord::new(ev.col, ev.row), - }); - } - // `units_killed` does not have a matching `TurnResult` Vec field — - // the canonical surface is `events_emitted` above. Just clear. - self.units_killed.clear(); - result.units_captured.append(&mut self.units_captured); - result.ransom_offers_created.append(&mut self.ransom_offers_created); - result.civilians_destroyed.append(&mut self.civilians_destroyed); - // p2-55e: drain accepted/expired into TurnResult so chronicle reads - // them directly without prior-turn cross-reference. - result - .ransom_offers_accepted - .append(&mut self.ransom_offers_accepted); - result - .ransom_offers_expired - .append(&mut self.ransom_offers_expired); - } - - pub fn is_empty(&self) -> bool { - self.units_captured.is_empty() - && self.ransom_offers_created.is_empty() - && self.civilians_destroyed.is_empty() - && self.ransom_offers_accepted.is_empty() - && self.ransom_offers_expired.is_empty() - && self.units_killed.is_empty() - } -} - -impl GameState { - /// Return the improvement at `(col, row)`, if any. - pub fn improvement_at(&self, col: u16, row: u16) -> Option<&TileImprovement> { - self.tile_improvements.get(&(col, row)) - } - - /// Place `spec`-derived improvement at `(col, row)`, overwriting any - /// existing one. Derives `hp`, `severable`, and `flags` from the spec. - pub fn set_improvement(&mut self, col: u16, row: u16, spec: &TileImprovementSpec) { - self.tile_improvements.insert( - (col, row), - TileImprovement { - id: spec.id.clone(), - hp: spec.hp, - severable: spec.severable, - pillaged: false, - flags: spec.flags.clone(), - }, - ); - } - - /// Remove the improvement at `(col, row)` entirely. - pub fn remove_improvement(&mut self, col: u16, row: u16) { - self.tile_improvements.remove(&(col, row)); - } - - /// Pillage the improvement at `(col, row)`. - /// - /// - If the improvement is `severable`: sets `pillaged = true` and returns - /// `true` (the improvement remains but its route-gating effect is - /// suspended). - /// - If the improvement is not severable (e.g. a Beacon Tower): removes it - /// outright and returns `false`. - /// - If there is no improvement: returns `false`. - pub fn pillage_improvement(&mut self, col: u16, row: u16) -> bool { - match self.tile_improvements.get_mut(&(col, row)) { - Some(imp) if imp.severable => { - imp.pillaged = true; - true - } - Some(_) => { - self.tile_improvements.remove(&(col, row)); - false - } - None => false, - } - } - - /// Populate `improvement_registry` from a slice of already-parsed specs. - /// Call once at game start after loading JSON files. - pub fn load_improvement_specs(&mut self, specs: impl IntoIterator) { - for spec in specs { - self.improvement_registry.insert(spec.id.clone(), spec); - } - } -} - -/// Per-player state. Wide struct by design — every field is read directly by -/// the bench or by mc-ai evaluators, and hiding them behind getters would just -/// add boilerplate without safety benefit. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PlayerState { - /// 0-indexed player slot. `deserialize_with` accepts GDScript's JSON - /// floats (e.g. `1.0` for u8 `1`) — the engine stringifies all numbers - /// as floats, and strict u8 decoding rejects them. See - /// `mc_core::gd_compat::de_u8_flexible` docstring. - #[serde(deserialize_with = "mc_core::gd_compat::de_u8_flexible")] - pub player_index: u8, - /// Treasury. - pub gold: i32, - /// Per-city compact state. Uses `mc_city::CityState` (not the full `City`) - /// because the bench harness doesn't simulate per-building queues. - pub cities: Vec, - /// Per-turn upkeep cost per existing unit, aligned with `units`. - pub unit_upkeep: Vec, - /// Strategic axis weights, e.g. `{"expansion": 5, "production": 3, ...}`. - /// Driven by the strategy profile loaded from JSON or defined inline. - /// `BTreeMap` for deterministic save serialization (byte-equal round-trip). - /// GDScript's `Dictionary.stringify` emits u8 values as JSON floats; the - /// flexible helper accepts both int and float forms. - #[serde(deserialize_with = "mc_core::gd_compat::de_btreemap_string_u8_flexible")] - pub strategic_axes: BTreeMap, - /// AI scoring weights used by the mc-ai evaluator leaf-value function. - #[serde(default)] - pub scoring_weights: ScoringWeights, - /// p2-71: personality / clan id (e.g. `"blackhammer"`) — projected into - /// `TacticalPlayerState::clan_id` so personality-emergent thresholds - /// (`mc_ai::tactical::thresholds`) and the production picker get a - /// non-empty key. Stamped by `GdGameState::set_player_personality_json`. - /// Empty for fixtures predating p2-71; tactical AI treats empty as - /// "neutral / unset" without panic. - #[serde(default)] - pub clan_id: String, - /// p2-71 — Offense-category promotion weight projected into - /// `TacticalPlayerState::promotion_offense_weight`. Defaults to 1.0 - /// (neutral). Stamped by `set_player_personality_json` from the - /// personality JSON entry's optional `promotion_offense_weight`. - #[serde(default = "default_promotion_weight")] - pub promotion_offense_weight: f32, - /// p2-71 — Defense-category promotion weight (companion to - /// `promotion_offense_weight`). - #[serde(default = "default_promotion_weight")] - pub promotion_defense_weight: f32, - /// p2-71 — Mobility / utility promotion weight. - #[serde(default = "default_promotion_weight")] - pub promotion_mobility_weight: f32, - /// p1-42b — Per-clan production-side priors loaded from - /// `ai_personalities.json` (`building_category_weights` + - /// `wonder_priorities`). Projected straight through - /// `mc_player_api::projection::project_tactical` into - /// `TacticalPlayerState::building_priors` so the catalog-driven scorer - /// (`mc_ai::tactical::production::score_building`) sees real - /// per-personality weights instead of the neutral default. - /// - /// Stamped by `GdGameState::set_player_personality_json` from the - /// personality JSON envelope. Empty maps (default) preserve cycle-5 - /// fall-through to axis-driven multipliers — fixtures predating p1-42b - /// keep their current behaviour without any changes. - #[serde(default)] - pub building_priors: mc_core::tactical_types::BuildingPriors, - /// Stage 3 (mod system) — controller registry id used by - /// `mc_player_api::dispatch::drive_ai_slot` to look up which - /// `AiController` impl decides this slot's turn. Defaults to - /// `"scripted:default"` (the in-box MCTS+heuristic). Mod-supplied - /// controllers register their own id (`"learned:duel-v1b"`, - /// `"community:foo"`, ...) and the game-setup UI picks per slot. - /// Empty = fall-through to default at dispatch time. - #[serde(default)] - pub controller_id: String, - /// p1-29h — cross-turn tactical memory: the army-level target-lock + - /// commitment-hysteresis channel that makes the AI's war decisive - /// (capture → press on → elimination) instead of indecisive (capture → - /// disperse → opponent refounds). Borrowed `&mut` by - /// `mc_player_api::dispatch::drive_ai_slot` and threaded into the tactical - /// movement layer. `#[serde(skip)]` — transient; re-acquired from scratch - /// (at most one no-lock turn) after a save/load rather than persisted into - /// the save format, keeping the `mc-save` contract unchanged. - #[serde(skip)] - pub tactical_memory: mc_core::tactical_types::TacticalMemory, - /// Accumulated expansion capacity (earned from the `expansion` axis). - pub expansion_points: u32, - /// Per-city list of constructed building IDs. Aligned with `cities`. - pub city_buildings: Vec>, - /// Per-building runtime state keyed by `(city_idx, building_id)`. Sparse — - /// only present when a building's state deviates from defaults. Lazily - /// inserted on first action; absent entries mean default `BuildingState`. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "building_states_as_pairs")] - pub building_states: BTreeMap<(usize, String), BuildingState>, - /// Per-city list of tile improvement IDs active in the city's worked - /// tiles (e.g. `["farm", "mine"]`). Aligned with `cities`. Populated by - /// GDScript; bench tests set this directly. - #[serde(default)] - pub city_improvements: Vec>, - /// Per-city accumulated fauna harassment pressure. Aligned with `cities`. - pub city_ecology: Vec, - /// Optional research state. `None` = no research simulated. - #[serde(default)] - pub tech_state: Option, - /// Per-turn science accumulation rate. - pub science_yield: u32, - /// Cumulative science points accumulated toward the current research target. - /// Incremented by `science_yield` each turn; decremented by `tech.cost` on - /// completion (overflow carries into the next tech). Zero when no research - /// is in progress. Observable by UI/GDScript without querying PlayerTechState. - #[serde(default)] - pub science_pool: i64, - /// Full mc-tech research state, present when a TechWeb has been injected - /// into the TurnProcessor. Drives cost-gated completion via - /// `PlayerTechState::add_science`. The legacy `tech_state` field continues - /// to serve the victory system's science-tech requirement checks. - #[serde(default)] - pub player_tech: Option, - /// Movable units currently on the map. - pub units: Vec, - /// World-space (col, row) positions of each city, aligned with `cities`. - pub city_positions: Vec<(i32, i32)>, - /// Position of this player's original capital (used for domination victory). - #[serde(default)] - pub capital_position: Option<(i32, i32)>, - /// Cumulative culture generated across all cities. Mirrors - /// `culture_pool.culture_total` — kept as a hot field for victory and - /// scoring code that would otherwise have to walk the pool each turn. - pub culture_total: i64, - /// Per-city culture pool (mc-culture). Drives per-turn accumulation and - /// border-expansion readiness via `CulturePool::tick_all`. Lazily populated - /// by `process_culture` — saves written before this field existed - /// deserialize with an empty pool, and the next turn re-registers every - /// city from `cities` with its computed per-turn yield. - #[serde(default)] - pub culture_pool: CulturePool, - /// Currently-researched cultural tradition id, or empty if none. - #[serde(default)] - pub researching_tradition: String, - /// Culture-research progress accumulated toward the current tradition. - /// Resets to 0 on completion; overflow rolls into the next start. - #[serde(default)] - pub culture_research_progress: u32, - /// Set of cultural tradition ids the player has completed. - #[serde(default)] - pub researched_traditions: std::collections::BTreeSet, - /// Bench / MCTS culture-research state. The live-game path drives the - /// flat fields above via `GdCultureWeb.process_culture_research`; this - /// nested state powers the in-process `TurnProcessor::process_culture_research` - /// path used by the bench and rollout snapshots. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub player_culture: Option, - /// One-time flag: has the arcane-lore population cost already been paid? - pub arcane_lore_pop_deducted: bool, - /// Luxury IDs received via active trade agreements this turn. - /// Merged into `owned_luxuries` before happiness calculation. - /// Cleared and rebuilt each turn by `process_trade_phase`. - #[serde(default)] - pub traded_luxuries: BTreeSet, - /// Diplomatic relation states keyed by canonical pair `(min_idx, max_idx)`. - /// Shared across all players — only player_index 0 carries the authoritative - /// copy; `process_trade_phase` syncs it from the ledger. - #[serde(default, with = "relations_as_pairs")] - pub relations: BTreeMap<(u8, u8), RelationState>, - /// Strategic resource stockpile. Keys are deposit resource IDs (e.g. `"iron_ore"`). - /// Incremented by deposit discovery, decremented on unit build, restored on unit death. - #[serde(default)] - pub strategic_ledger: BTreeMap, - /// Flat tile indices (col * grid_height + row) of deposits already credited to - /// this player's ledger. Prevents double-crediting when fog is refreshed. - #[serde(default)] - pub explored_deposits: BTreeSet, - /// World wonders this player has completed. Maps `WonderId → tier` (1-10). - /// BTreeMap for deterministic iteration order across turn-processor runs. - /// Tier is stored alongside the id so `calculate_score` can weight each - /// wonder without a registry look-up at victory-check time. - #[serde(default)] - pub wonders_built: BTreeMap, - /// Rally points per building slot. Each entry records which hex freshly - /// spawned units from that building should march to and with which standing - /// order. Stored as a flat Vec (not a map) so serde_json serializes cleanly. - #[serde(default)] - pub rally_points: Vec, - /// p2-55: per-relation civilian-capture posture. Keyed by the *defender's* - /// `player_index`. Missing entry falls through to `default_civilian_posture`. - /// `BTreeMap` for deterministic save serialisation (matches `relations` / - /// `building_states` / `wonders_built`). - #[serde(default)] - pub civilian_posture: BTreeMap, - /// p2-55: global default posture used when neither a per-unit override nor - /// a per-relation entry is set. AI seats are seeded with `Capture`; humans - /// are seeded with `Prompt` when the new-player capture-prompt setting is on. - #[serde(default)] - pub default_civilian_posture: crate::capture::CapturePosture, - /// p3-05a: per-player civic axis selections plus the empire-wide anarchy - /// timer. Game 1 default = Chieftainship / LaborPool / Mercantilism with a - /// zero anarchy counter (`mc_core::CivicState::default`). `#[serde(default)]` - /// keeps pre-p3-05a saves loading. - #[serde(default)] - pub civic_state: mc_core::CivicState, - /// p1-29a: cumulative count of cities this player has lost (had captured by - /// another player) over the course of the game. Drives the last-stand - /// defense multiplier: once `cities.len() == 1`, the resolver scales the - /// defender's combat strength and the city's wall HP by - /// `1.0 + LAST_STAND_PER_LOSS × cities_lost_total` (capped at - /// `LAST_STAND_CAP`). Mirrors GDScript's `Player.cities_lost_total` - /// (see `src/game/engine/src/entities/player.gd`); incremented in - /// `process_siege` when a city changes ownership. - #[serde(default)] - pub cities_lost_total: u32, - /// p1-29i: game-turn on which this player most recently LOST a city to - /// capture. Drives the post-capture refound cooldown - /// (`CombatBalance::refound_suppression`): `try_found_city` refuses to found - /// a replacement while `turn - last_city_lost_turn < cooldown_turns`. - /// `None` until the first city loss (and on old saves) → no suppression. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_city_lost_turn: Option, - /// Derived realm-level statistics recomputed at end of every turn by - /// `TurnProcessor::step → recompute_derived_stats`. All future derived - /// scalars land here — single recompute site rule, see `mc_core::derived_stats`. - /// - /// `#[serde(default)]` keeps pre-p3-07 saves loading: fields default to zero, - /// correct for a save that hasn't run the recompute pass yet. - #[serde(default)] - pub derived_stats: mc_core::DerivedStats, -} - -/// Standing order for units that arrive at a rally point. -/// -/// Backward-compat serde: any unrecognised string value (e.g. old saves with -/// `"Defend"` written as a plain string) deserialises as `Defend` via -/// `#[serde(other)]`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RallyCommand { - Hold, - Defend, - Fortify, - JoinFormation, - /// Two-waypoint Patrol: unit PingPongs between spawn hex and `waypoint_2`. - Patrol { waypoint_2: (i32, i32) }, - Advance, - #[serde(other)] - Unknown, -} - -impl Default for RallyCommand { - fn default() -> Self { - RallyCommand::Defend - } -} - -impl RallyCommand { - /// Parse a GDScript string command + optional sentinel waypoint_2. - /// `-1, -1` sentinel means no waypoint_2 (legacy / non-Patrol commands). - pub fn from_str_with_waypoint(cmd: &str, wp2_col: i32, wp2_row: i32) -> Self { - match cmd.to_lowercase().as_str() { - "hold" => RallyCommand::Hold, - "defend" => RallyCommand::Defend, - "fortify" => RallyCommand::Fortify, - "join_formation" | "joinformation" => RallyCommand::JoinFormation, - "patrol" => { - if wp2_col == -1 && wp2_row == -1 { - RallyCommand::Defend - } else { - RallyCommand::Patrol { waypoint_2: (wp2_col, wp2_row) } - } - } - "advance" => RallyCommand::Advance, - _ => RallyCommand::Defend, - } - } -} - -/// A rally point attached to a specific building in a specific city. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BuildingRallyPoint { - pub city_index: usize, - pub building_id: String, - pub hex: (i32, i32), - /// Standing order for freshly spawned units. - #[serde(default)] - pub command: RallyCommand, -} - -/// Ambient fauna-presence pressure accumulated per city. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CityEcology { - /// Sum of (lair_tier × encounter_probability) within the city's radius. - pub adjacent_lair_pressure: f32, - /// Turn index of the most recent harassment event (0 if none). - pub last_harassment_turn: u32, -} - -/// A unit on the world map. Thin data struct; all movement/combat logic lives -/// in `TurnProcessor`. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct MapUnit { - /// Stable monotonic ID assigned at spawn (from GameState::next_unit_id). - /// Used for formation membership lookup so vec-index shifts on unit death - /// do not corrupt formation rosters. - #[serde(default)] - pub id: u32, - pub col: i32, - pub row: i32, - pub hp: i32, - pub max_hp: i32, - pub attack: i32, - pub defense: i32, - pub is_fortified: bool, - /// True if the unit is in sentry posture. Cleared automatically when an - /// enemy unit enters within 2 hex (wake-on-vision, processed in - /// `TurnProcessor::wake_sentrying_units`). No stat bonus — distinct from - /// `is_fortified` which grants cumulative defense. - #[serde(default)] - pub is_sentrying: bool, - /// Unit-type ID (e.g. `"dwarf_warrior"`) — matches JSON data `units/*.json`. - pub unit_id: String, - /// Strategic resources this unit holds (from `requires_resource` at build - /// time). Returned to the empire ledger on death. - #[serde(default)] - pub held_resources: Vec, - /// Active patrol standing order, if any. `None` means idle or fortified. - #[serde(default)] - pub patrol_order: Option, - /// Formation this unit currently belongs to, if any. - #[serde(default)] - pub formation_id: Option, - /// When true the unit is eligible to be automatically grouped into a - /// Formation by `aggregate_formations`. Default true for military units; - /// false for workers, scouts, and founders. - #[serde(default = "default_auto_join")] - pub auto_join: bool, - /// Siege unit is in deployed posture. Cannot move; can Bombard. Set by - /// `handle_deploy_siege`, cleared by `handle_pack_siege`. - #[serde(default)] - pub is_deployed: bool, - /// Amphibious unit is currently on a water tile (embarked). Defence -50%; - /// cannot fortify. Set by `handle_embark`, cleared by `handle_disembark`. - #[serde(default)] - pub is_embarked: bool, - /// Rally command to execute when this unit first reaches its rally hex. - /// Set at spawn; cleared by `apply_rally_arrival_actions` after firing once. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rally_on_arrival: Option, - /// The rally destination hex this unit is marching to. Used by - /// `apply_rally_arrival_actions` to detect arrival. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rally_destination: Option<(i32, i32)>, - /// Active status effects (poison/bleed/burn). Ticked in the health phase; - /// cleared by Medic RemoveStatus. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub status_effects: Vec, - /// Multi-turn action currently in progress (Engineer/Pioneer builds). - /// `None` when idle. Ticked in the production phase; completion fires the effect. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_action: Option, - /// True if the unit is in stealth posture (Scout). Invisible to enemies - /// further than 1 hex. Cleared on attack or entering Fortify posture. - #[serde(default)] - pub is_stealthed: bool, - /// True if the scout has set an ambush on its current hex. Cleared when - /// triggered or when the unit moves. - #[serde(default)] - pub is_ambushing: bool, - /// True if the medic's Field Aura is active — friendly co-hex units - /// regenerate +5 HP/turn. Toggle off with FieldAura action. - /// Bridge reads this to populate `CombatParams::attacker_field_aura_active`. - #[serde(default)] - pub is_field_aura: bool, - /// True if this unit has an active Stabilise cover from a co-hex medic. - /// Set by the Medic Stabilise action; consumed (cleared) on the first - /// prevented-fatal-blow. Bridge reads this to populate - /// `CombatParams::defender_has_stabilise`. - #[serde(default)] - pub stabilise_pending: bool, - /// True when a Stabilise-covered fatal blow was prevented this battle. - /// Set by the bridge caller when `CombatResult::stabilise_prevented_kill` - /// is true. Cleared at battle end. Read by UI to display the "saved" badge. - #[serde(default)] - pub prevented_fatal_this_battle: bool, - /// Turn number when a Mark Trail tag on *this* unit expires (0 = not tagged). - /// Set by a friendly Scout's MarkTrail action targeting this unit. While - /// `game.turn <= mark_trail_expiry && mark_trail_expiry > 0`, this unit's - /// position is visible to the tagging player even through fog of war. - #[serde(default)] - pub mark_trail_expiry: u32, - // p2-53g: ranged posture - /// True when the unit has taken the AimedShot action; cleared on next attack. - #[serde(default)] - pub aimed_shot_pending: bool, - /// True when the unit is in fire-arrow posture (ranged attacks ignite tile). - #[serde(default)] - pub is_fire_arrows: bool, - // p2-53h: cavalry posture - /// True when the unit is in pursue posture (advance-on-rout follow-through active). - #[serde(default)] - pub is_pursuing: bool, - /// Hex of the pending charge target, set during Charge action. Consumed by combat resolver. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pending_charge_target: Option<(i32, i32)>, - // p2-53f: infantry posture - /// True when the unit is in shield-wall posture (+50% defence vs ranged). - #[serde(default)] - pub is_shield_wall: bool, - /// True when the unit is braced against a charge (first-strike on incoming melee). - #[serde(default)] - pub is_braced: bool, - /// Remaining turns of Rage (+40% attack). 0 = not raging. - #[serde(default)] - pub rage_turns_remaining: u8, - /// True when WarCry has been used this battle (once-per-battle gate). - #[serde(default)] - pub war_cry_used_this_battle: bool, - /// Remaining turns of WarCry attack debuff on this unit (-10% attack). - /// Set to 1 when an adjacent enemy uses WarCry; ticked down each turn. - /// Bridge reads `> 0` to set `CombatParams::attacker_war_cry_debuff`. - #[serde(default)] - pub war_cry_debuff_turns_remaining: u8, - /// Pending drill XP from Barracks Drill action; consumed by bridge this turn. - #[serde(default, skip_serializing_if = "crate::game_state::is_zero_u32")] - pub pending_drill_xp: u32, - /// Cached capability flag: true when this unit has the `"amphibious"` keyword. - /// Set at spawn / ingest from GDScript dict key `"is_amphibious"`. - /// Drives movement-cost logic in `step_toward_with_terrain` so ocean/coast - /// biomes are passable for amphibious land units. Does NOT grant naval combat; - /// this is the minimal Game 1 shore-crossing support. - #[serde(default)] - pub is_amphibious: bool, - // p2-53h: Wheel facing - /// Current facing direction of the unit (0–5, hex edge indices, flat-top orientation). - /// Default 0 (east). Updated by `ActionKind::Wheel`. Combat resolver consults this - /// for first-strike avoidance: if attacker changed facing away from defender's braced - /// edge via Wheel, the attacker skips the first-strike penalty. - #[serde(default)] - pub facing_edge: u8, - /// p2-55: per-unit civilian-capture posture override. `None` means fall - /// through to the player's per-relation map / global default. Set by the - /// unit-panel UI. Cleared by selecting "Use civ default". - #[serde(default, skip_serializing_if = "Option::is_none")] - pub posture_override: Option, - /// p2-55: when `Some(captor)`, this unit is in ransom-pending state — it - /// stays in its original owner's `PlayerState::units` vec but is pinned - /// (no movement, no actions) until the offer is accepted, refused, or - /// expires. Movement / action handlers must check `captive_of.is_some()` - /// and refuse to act. On expiry / refusal the processor moves the unit - /// into the captor's vec and clears this field. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub captive_of: Option, - /// p2-67 Phase 9: cached base movement points (from the - /// `UnitsCatalog::get(unit_id).base_moves` lookup at spawn). Lets - /// `refresh_units` recharge `movement_remaining` without needing a - /// catalog reference at refresh time. `0` for tests/fixtures that - /// don't go through `MapUnit::new` — those paths must call - /// `.with_moves(n)` if they intend the unit to be movable. - #[serde(default)] - pub base_moves: i32, - /// p2-67 Phase 8: pending promotion pick. Set by - /// `mc_player_api::dispatch::apply_promote` and consumed by the - /// turn processor (Phase 11 follow-up) which validates the pick - /// against eligibility rules and applies the stat changes. - /// `None` means no promotion is queued. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pending_promotion: Option, - /// p2-67 Phase 9: movement points remaining this turn. Decremented - /// by [`crate::processor::process_move_requests`] as the unit - /// pathfinds; reset to `base_moves` at turn start by - /// [`crate::refresh_units`]. SRP-clean: `0` means **exhausted this - /// turn**, never "uninitialised" — fresh units take `base_moves` - /// directly via the constructor / `.with_moves` builder. - #[serde(default)] - pub movement_remaining: i32, - /// p3-11: action-point pool for Specialist civilians (Pioneer / - /// Engineer progression). `None` for all other unit types — military - /// units, scouts, founders without a configured capacity, etc. - /// - /// Set at spawn from `UnitsCatalog::get(unit_type).action_point_capacity` - /// (only populated for unit JSON that declares `action_point_capacity`). - /// Drained by per-action AP costs resolved through `mc_units::ap::cost_for`. - /// Recharged in full by [`crate::recharge_action_points`] when the unit - /// ends its turn on a friendly city tile. - /// - /// Serde `default = None` keeps old saves loadable. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub action_points: Option, - /// p2-57c: production-quality band stamped at unit-completion time, derived - /// from the producing city's stockpile depth of the gating resource - /// (`mc_city::recipes::tick_and_stamp` → `StampedUnit.quality`). `None` for - /// units that were not produced through a quality-bearing recipe (bench / - /// legacy auto-warrior spawns, captured units, old saves). When set, the - /// unit's `attack` / `defense` / `max_hp` were already adjusted via - /// [`crate::apply_quality`] at spawn — this field records *which* band the - /// adjustment used (for UI badges + replay), it is not re-applied per turn. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub quality: Option, -} - -impl MapUnit { - /// Construct a fresh unit at `(col, row)` for `owner`. Reads - /// `base_moves` from `catalog.get(unit_type).base_moves`; if the - /// unit type is missing from the catalog (test fixtures that - /// didn't populate it), `base_moves` falls back to `0` and the - /// caller is expected to chain `.with_moves(n)`. - /// - /// `owner` is currently retained only for symmetry with the - /// GDScript path (units don't carry an explicit owner field — - /// ownership is implicit in the `PlayerState::units` vec they - /// live in). Kept on the signature so call sites read clearly. - #[must_use] - pub fn new( - unit_type: &str, - col: i32, - row: i32, - _owner: u8, - catalog: &mc_units::UnitsCatalog, - ) -> Self { - let stats = catalog.get(unit_type); - let base_moves = stats.map(|s| s.base_moves).unwrap_or(0); - // p3-11: spawn Specialist civilians with a full AP pool, sized from - // the per-unit JSON capacity. Unit types whose JSON omits - // `action_point_capacity` get `None` (no AP pool). - let action_points = stats - .and_then(|s| s.action_point_capacity) - .map(mc_core::units::ActionPoints::full); - Self { - unit_id: unit_type.to_string(), - col, - row, - base_moves, - movement_remaining: base_moves, - action_points, - ..Self::default() - } - } - - /// Builder override for movement points — used by tests that don't - /// supply a catalog. Sets both `base_moves` and - /// `movement_remaining` so the unit can move immediately and - /// `refresh_units` recharges to the same value next turn. - #[must_use] - pub fn with_moves(mut self, n: i32) -> Self { - self.base_moves = n; - self.movement_remaining = n; - self - } -} - -pub(crate) fn is_zero_u32(v: &u32) -> bool { *v == 0 } - -fn default_auto_join() -> bool { - true -} - -/// p2-71: default for `GameState::ai_difficulty_threshold_mult` — `1.0` is -/// the neutral / normal-difficulty value consumed by -/// `mc_ai::tactical::thresholds` (Easy <1.0, Hard >1.0). -fn default_threshold_mult() -> f32 { - 1.0 -} - -/// p2-71: default for `PlayerState::promotion_*_weight` — `1.0` is the -/// neutral value consumed by `mc_ai::tactical::promotion::pick_promotion` -/// (matches `TacticalPlayerState::default_promotion_weight`). -fn default_promotion_weight() -> f32 { - 1.0 -} - -/// Optional research state for players that simulate tech progression. -/// -/// `progress` uses `BTreeMap` so save files serialize with deterministic key -/// order, enabling byte-equal round-trip verification. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TechState { - pub researched: Vec, - pub progress: BTreeMap, -} - -#[cfg(test)] -mod p2_72a_save_round_trip_tests { - use super::*; - - #[test] - fn default_game_state_round_trips_through_serde() { - let g = GameState::default(); - let json = serde_json::to_string(&g).expect("serialize default GameState"); - let back: GameState = serde_json::from_str(&json).expect("deserialize default GameState"); - // Equality check by re-serialising the round-trip output and comparing - // the JSON — GameState's `#[serde(skip)]` fields make `PartialEq` impractical. - let json2 = serde_json::to_string(&back).expect("re-serialize"); - assert_eq!(json, json2, "default GameState must byte-equal across round-trip"); - } - - #[test] - fn wall3_fields_default_to_zero() { - let g = GameState::default(); - assert_eq!(g.era, 0); - assert_eq!(g.map_seed, 0); - assert_eq!(g.current_player_index, 0); - assert_eq!(g.game_rng_seed, 0); - assert_eq!(g.game_rng_state, 0); - assert_eq!(g.ai_difficulty.production_mult, 1.0); - assert_eq!(g.ai_difficulty.research_mult, 1.0); - assert_eq!(g.ai_difficulty.extra_unit_id, "warrior"); - assert!(g.ai_difficulty.per_player_production_mult.is_empty()); - } - - #[test] - fn wall3_fields_round_trip_with_values() { - let mut g = GameState::default(); - g.era = 3; - g.map_seed = 0xdead_beef_cafe; - g.current_player_index = 2; - g.game_rng_seed = 12345; - g.game_rng_state = 67890; - g.ai_difficulty.production_mult = 1.25; - g.ai_difficulty.research_mult = 0.85; - g.ai_difficulty.starting_gold_bonus = 100; - g.ai_difficulty.extra_starting_units = 1; - g.ai_difficulty.extra_unit_id = "spearman".into(); - g.ai_difficulty.per_player_production_mult.insert(1, 1.5); - g.ai_difficulty.per_player_research_mult.insert(2, 0.9); - - let json = serde_json::to_string(&g).expect("serialize"); - let back: GameState = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(back.era, 3); - assert_eq!(back.map_seed, 0xdead_beef_cafe); - assert_eq!(back.current_player_index, 2); - assert_eq!(back.game_rng_seed, 12345); - assert_eq!(back.game_rng_state, 67890); - assert_eq!(back.ai_difficulty.production_mult, 1.25); - assert_eq!(back.ai_difficulty.research_mult, 0.85); - assert_eq!(back.ai_difficulty.starting_gold_bonus, 100); - assert_eq!(back.ai_difficulty.extra_starting_units, 1); - assert_eq!(back.ai_difficulty.extra_unit_id, "spearman"); - assert_eq!(back.ai_difficulty.per_player_production_mult.get(&1), Some(&1.5)); - assert_eq!(back.ai_difficulty.per_player_research_mult.get(&2), Some(&0.9)); - } - - #[test] - fn pre_wall3_save_deserializes_with_defaults() { - // Simulate loading a save written before Wall-3 fields were added: - // only the legacy fields are present. `#[serde(default)]` must - // back-fill `era` / `map_seed` / etc. without error. - let legacy_json = r#"{ - "turn": 5, - "players": [], - "grid": null - }"#; - let back: GameState = serde_json::from_str(legacy_json) - .expect("legacy save must deserialize via serde(default)"); - assert_eq!(back.turn, 5); - assert_eq!(back.era, 0); - assert_eq!(back.map_seed, 0); - assert_eq!(back.current_player_index, 0); - assert_eq!(back.ai_difficulty.production_mult, 1.0); - } -} +pub use mc_state::game_state::*; diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 0196ac6b..565fa8ad 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -21,6 +21,7 @@ pub mod abstract_projection; pub mod action; pub mod action_handlers; pub mod capture; +pub mod capture_drain; pub mod combat_balance; pub mod ransom; pub mod building_action_handlers; diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index ef793f53..11c8336d 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -26,6 +26,7 @@ use crate::combat_event::{ CivilianDestroyedEvent, FaunaCombatEvent, PvpCombatEvent, SiegeEvent, StrategicGateRejection, TurnResult, UnitCapturedEvent, UnitKilledEvent, UnitRansomOfferedEvent, }; +use crate::capture_drain::DrainCaptureEvents; use crate::game_state::{BuildingRallyPoint, GameState, MapUnit, RallyCommand}; use crate::spatial_index::LairIndex; use mc_core::formation::{Formation, FormationShape}; diff --git a/src/simulator/crates/mc-turn/tests/capture_chronicle_pipeline.rs b/src/simulator/crates/mc-turn/tests/capture_chronicle_pipeline.rs index 07cc6dd2..dc4e2a8b 100644 --- a/src/simulator/crates/mc-turn/tests/capture_chronicle_pipeline.rs +++ b/src/simulator/crates/mc-turn/tests/capture_chronicle_pipeline.rs @@ -41,6 +41,7 @@ use mc_ai::evaluator::ScoringWeights; use mc_city::CityState; use mc_replay::TurnEvent; use mc_turn::{ + capture_drain::DrainCaptureEvents, combat_event::{ CivilianDestroyedEvent, TurnResult, UnitCapturedEvent, UnitRansomAcceptedEvent, UnitRansomExpiredEvent, UnitRansomOfferedEvent, diff --git a/src/simulator/crates/mc-turn/tests/ransom.rs b/src/simulator/crates/mc-turn/tests/ransom.rs index 29c7a455..6f79db01 100644 --- a/src/simulator/crates/mc-turn/tests/ransom.rs +++ b/src/simulator/crates/mc-turn/tests/ransom.rs @@ -106,6 +106,7 @@ fn tick_drains_only_expired_leaves_others() { use mc_turn::combat_event::{ UnitRansomAcceptedEvent, UnitRansomExpiredEvent, }; +use mc_turn::capture_drain::DrainCaptureEvents; use mc_turn::game_state::PendingCaptureEvents; use mc_turn::combat_event::TurnResult;