diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index b44c150b..ef103caa 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -1977,6 +1977,25 @@ dependencies = [ "serde_json", ] +[[package]] +name = "mc-state" +version = "0.1.0" +dependencies = [ + "mc-city", + "mc-civics", + "mc-combat", + "mc-comms", + "mc-core", + "mc-culture", + "mc-observation", + "mc-replay", + "mc-tech", + "mc-trade", + "mc-units", + "serde", + "serde_json", +] + [[package]] name = "mc-tech" version = "0.1.0" @@ -2014,6 +2033,7 @@ dependencies = [ "mc-observation", "mc-pathfinding", "mc-replay", + "mc-state", "mc-tech", "mc-trade", "mc-units", diff --git a/src/simulator/Cargo.toml b/src/simulator/Cargo.toml index ee8eaaf8..49b368d3 100644 --- a/src/simulator/Cargo.toml +++ b/src/simulator/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/mc-ai", "crates/mc-trade", "crates/mc-civics", + "crates/mc-state", "crates/mc-turn", "crates/mc-compute", "crates/mc-items", diff --git a/src/simulator/crates/mc-state/Cargo.toml b/src/simulator/crates/mc-state/Cargo.toml new file mode 100644 index 00000000..a1ef9a3b --- /dev/null +++ b/src/simulator/crates/mc-state/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "mc-state" +version = "0.1.0" +edition = "2021" + +# Data-shape crate: owns the canonical full-simulation state struct +# (`GameState`) and its pending-queue value types. Depends only on data-shape +# crates — NO `mc-ai` (decision logic), NO `mc-turn` (turn-step mutation), +# NO rayon / GPU deps. `mc-turn` depends on `mc-state` for the shapes it +# mutates; `mc-ai` reads `mc-state` shapes. Never the reverse (no cycles). + +[dependencies] +mc-core = { path = "../mc-core" } +mc-units = { path = "../mc-units" } +mc-city = { path = "../mc-city" } +mc-culture = { path = "../mc-culture" } +mc-civics = { path = "../mc-civics" } +mc-combat = { path = "../mc-combat" } +mc-trade = { path = "../mc-trade" } +mc-tech = { path = "../mc-tech" } +mc-comms = { path = "../mc-comms" } +mc-replay = { path = "../mc-replay" } +mc-observation = { path = "../mc-observation" } +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/src/simulator/crates/mc-state/src/lib.rs b/src/simulator/crates/mc-state/src/lib.rs new file mode 100644 index 00000000..67dd0c39 --- /dev/null +++ b/src/simulator/crates/mc-state/src/lib.rs @@ -0,0 +1,18 @@ +//! Canonical full-simulation state shapes (`mc-state`). +//! +//! This crate owns the *data* of the simulation — `GameState` and the +//! pending-queue value types it carries — decoupled from the *mutation* logic +//! that lives in `mc-turn` (`processor.rs`, `action_handlers/`, victory, +//! capture/ransom resolvers, …). +//! +//! Dependency direction is strict: `mc-turn` depends on `mc-state` for the +//! shapes it mutates, and `mc-ai` reads `mc-state` shapes. Never the reverse — +//! `mc-state` must not depend on `mc-ai` (decision logic) or `mc-turn` +//! (turn-step machinery). See `mc-state/Cargo.toml` for the enforced dep set. +//! +//! Save-format invariant: the serde shapes here are byte-identical to their +//! prior `mc-turn` homes. On-disk JSON never encoded module paths, so +//! relocating these types across crate boundaries does not change the save +//! format. Round-trip tests live alongside each module. + +pub mod ransom; diff --git a/src/simulator/crates/mc-state/src/ransom.rs b/src/simulator/crates/mc-state/src/ransom.rs new file mode 100644 index 00000000..9e699b6e --- /dev/null +++ b/src/simulator/crates/mc-state/src/ransom.rs @@ -0,0 +1,194 @@ +//! Ransom-offer queue (p2-55, Wave 1). +//! +//! When `CombatOutcome::RansomOffered` fires, the captor's turn processor +//! enqueues a `RansomOffer` here. The defender's owner sees the offer next +//! turn (UI / AI surface in Wave 2). Three lifecycle paths: +//! +//! * `accept(id)` — caller verifies gold, deducts, restores +//! `MapUnit::owner_id`, clears `captive_of`, emits `UnitRansomAccepted`. +//! * `refuse(id)` — caller flips `MapUnit` ownership to the captor, clears +//! `captive_of`, emits `UnitRansomRefused` (or rolls into `UnitCaptured`). +//! * `tick(current_turn)` — start-of-turn sweep; expired offers convert to +//! captures by the caller (same effect as refuse). +//! +//! The queue itself stores no game state beyond the offer record; callers +//! own the `MapUnit` mutation. This keeps the queue easy to reason about and +//! decoupled from `GameState` field-poking. + +use serde::{Deserialize, Serialize}; + +/// Fallback default for ransom-offer duration. +/// +/// **Not the canonical value.** The data-driven source is +/// `GameState.combat_balance.ransom_offer_duration_turns`, loaded from +/// `public/games/age-of-dwarves/data/combat_balance.json` via +/// `GdGameState::set_combat_balance_json` at game start and read by +/// `processor::enqueue_ransom_offer` (p2-55f). This const remains only as a +/// fallback for `RansomQueue::push` callers that do not have a +/// `CombatBalance` handle (tests, future unmigrated paths) and to keep the +/// drift guard in `mc-turn/tests/ransom.rs` honest. The value here MUST +/// match `CombatBalance::default().ransom_offer_duration_turns`. +pub const RANSOM_OFFER_DURATION_TURNS: u32 = 3; + +/// One pending ransom offer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RansomOffer { + /// Stable monotonic offer id (assigned by `RansomQueue`). + pub id: u32, + /// Captured unit's stable id (`MapUnit::id`). + pub unit_id: u32, + /// Player who captured the unit and is offering ransom. + pub captor: u8, + /// Original owner — the player who pays the ransom to recover the unit. + pub owner: u8, + /// Gold price the owner must pay to recover the unit. + pub price: i32, + /// Turn the offer was created on. + pub created_turn: u32, + /// Turn the offer expires on (inclusive — `tick(current_turn)` returns + /// the offer when `current_turn >= expires_turn`). + pub expires_turn: u32, +} + +/// Queue of pending ransom offers. Stored on `GameState` alongside other +/// `pending_*` queues. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RansomQueue { + #[serde(default)] + offers: Vec, + #[serde(default)] + next_id: u32, +} + +impl RansomQueue { + /// Push a new offer; returns the assigned offer id. `expires_turn` is + /// `created_turn + RANSOM_OFFER_DURATION_TURNS` unless overridden via + /// the explicit-form constructor. + pub fn push( + &mut self, + unit_id: u32, + captor: u8, + owner: u8, + price: i32, + created_turn: u32, + ) -> u32 { + self.push_with_duration(unit_id, captor, owner, price, created_turn, RANSOM_OFFER_DURATION_TURNS) + } + + /// Variant that lets callers override the duration (used by tests and, + /// in Wave 2, by JSON config). + pub fn push_with_duration( + &mut self, + unit_id: u32, + captor: u8, + owner: u8, + price: i32, + created_turn: u32, + duration: u32, + ) -> u32 { + let id = self.next_id; + self.next_id = self.next_id.wrapping_add(1); + self.offers.push(RansomOffer { + id, + unit_id, + captor, + owner, + price, + created_turn, + expires_turn: created_turn.saturating_add(duration), + }); + id + } + + /// Iterate all offers awaiting `owner`'s decision. + pub fn pending_for(&self, owner: u8) -> impl Iterator { + self.offers.iter().filter(move |o| o.owner == owner) + } + + /// Iterate every offer (for save snapshots / UI dumps). + pub fn iter(&self) -> impl Iterator { + self.offers.iter() + } + + /// Number of pending offers across all players. + pub fn len(&self) -> usize { + self.offers.len() + } + + /// True when no offers are pending. + pub fn is_empty(&self) -> bool { + self.offers.is_empty() + } + + /// Remove and return the offer with `offer_id`. Caller is responsible for + /// deducting gold, restoring `MapUnit::owner_id`, clearing `captive_of`, + /// and emitting `UnitRansomAccepted`. + pub fn accept(&mut self, offer_id: u32) -> Option { + self.take(offer_id) + } + + /// Remove and return the offer with `offer_id`. Caller is responsible for + /// flipping `MapUnit::owner_id` to the captor, clearing `captive_of`, and + /// emitting `UnitRansomRefused` / `UnitCaptured`. + pub fn refuse(&mut self, offer_id: u32) -> Option { + self.take(offer_id) + } + + /// Remove and return all offers whose `expires_turn <= current_turn`. + /// Caller must convert each to a capture (flip ownership, clear + /// `captive_of`, emit `UnitRansomExpired`). + pub fn tick(&mut self, current_turn: u32) -> Vec { + let mut expired = Vec::new(); + let mut i = 0; + while i < self.offers.len() { + if self.offers[i].expires_turn <= current_turn { + expired.push(self.offers.swap_remove(i)); + } else { + i += 1; + } + } + expired + } + + fn take(&mut self, offer_id: u32) -> Option { + let pos = self.offers.iter().position(|o| o.id == offer_id)?; + Some(self.offers.swap_remove(pos)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Save-format guard: a populated `RansomQueue` round-trips byte-identically + /// through serde_json. The relocation from `mc-turn` to `mc-state` must not + /// change the on-disk shape (serde never encoded module paths). + #[test] + fn ransom_queue_serde_round_trip() { + let mut q = RansomQueue::default(); + let id0 = q.push(7, 1, 2, 50, 10); + let id1 = q.push_with_duration(9, 2, 3, 120, 12, 5); + assert_eq!((id0, id1), (0, 1)); + + let json = serde_json::to_string(&q).expect("serialize"); + let back: RansomQueue = serde_json::from_str(&json).expect("deserialize"); + let re = serde_json::to_string(&back).expect("re-serialize"); + assert_eq!(json, re, "round-trip must be byte-identical"); + assert_eq!(back.len(), 2); + assert_eq!(back.iter().next().unwrap().expires_turn, 13); + } + + #[test] + fn tick_expires_and_take_removes() { + let mut q = RansomQueue::default(); + let id = q.push(1, 0, 1, 10, 0); // expires at 0 + 3 = 3 + q.push(2, 0, 1, 10, 100); // expires at 103 + let expired = q.tick(3); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].id, id); + assert_eq!(q.len(), 1); + assert!(q.accept(99).is_none()); + assert!(q.refuse(1).is_some()); + assert!(q.is_empty()); + } +} diff --git a/src/simulator/crates/mc-turn/Cargo.toml b/src/simulator/crates/mc-turn/Cargo.toml index d9a63150..0f84165f 100644 --- a/src/simulator/crates/mc-turn/Cargo.toml +++ b/src/simulator/crates/mc-turn/Cargo.toml @@ -8,6 +8,7 @@ gpu = ["dep:wgpu", "dep:pollster", "dep:bytemuck"] [dependencies] mc-core = { path = "../mc-core" } +mc-state = { path = "../mc-state" } mc-units = { path = "../mc-units" } mc-pathfinding = { path = "../mc-pathfinding" } mc-city = { path = "../mc-city" } diff --git a/src/simulator/crates/mc-turn/src/ransom.rs b/src/simulator/crates/mc-turn/src/ransom.rs index 87cb415a..3e525c1d 100644 --- a/src/simulator/crates/mc-turn/src/ransom.rs +++ b/src/simulator/crates/mc-turn/src/ransom.rs @@ -1,157 +1,10 @@ -//! Ransom-offer queue (p2-55, Wave 1). +//! Ransom-offer queue — re-export shim (p2-65 Phase 3a). //! -//! When `CombatOutcome::RansomOffered` fires, the captor's turn processor -//! enqueues a `RansomOffer` here. The defender's owner sees the offer next -//! turn (UI / AI surface in Wave 2). Three lifecycle paths: -//! -//! * `accept(id)` — caller verifies gold, deducts, restores -//! `MapUnit::owner_id`, clears `captive_of`, emits `UnitRansomAccepted`. -//! * `refuse(id)` — caller flips `MapUnit` ownership to the captor, clears -//! `captive_of`, emits `UnitRansomRefused` (or rolls into `UnitCaptured`). -//! * `tick(current_turn)` — start-of-turn sweep; expired offers convert to -//! captures by the caller (same effect as refuse). -//! -//! The queue itself stores no game state beyond the offer record; callers -//! own the `MapUnit` mutation. This keeps the queue easy to reason about and -//! decoupled from `GameState` field-poking. +//! The data + queue-mechanics now live in [`mc_state::ransom`]; this module +//! re-exports them so existing `mc_turn::ransom::{RansomQueue, RansomOffer, +//! RANSOM_OFFER_DURATION_TURNS}` import paths keep resolving unchanged. The +//! `mc-turn` turn-step resolver (`processor::enqueue_ransom_offer`, the +//! accept/refuse/expiry handlers) continues to *use* these shapes; only the +//! shape definitions moved. -use serde::{Deserialize, Serialize}; - -/// Fallback default for ransom-offer duration. -/// -/// **Not the canonical value.** The data-driven source is -/// `GameState.combat_balance.ransom_offer_duration_turns`, loaded from -/// `public/games/age-of-dwarves/data/combat_balance.json` via -/// `GdGameState::set_combat_balance_json` at game start and read by -/// `processor::enqueue_ransom_offer` (p2-55f). This const remains only as a -/// fallback for `RansomQueue::push` callers that do not have a -/// `CombatBalance` handle (tests, future unmigrated paths) and to keep the -/// drift guard in `mc-turn/tests/ransom.rs` honest. The value here MUST -/// match `CombatBalance::default().ransom_offer_duration_turns`. -pub const RANSOM_OFFER_DURATION_TURNS: u32 = 3; - -/// One pending ransom offer. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RansomOffer { - /// Stable monotonic offer id (assigned by `RansomQueue`). - pub id: u32, - /// Captured unit's stable id (`MapUnit::id`). - pub unit_id: u32, - /// Player who captured the unit and is offering ransom. - pub captor: u8, - /// Original owner — the player who pays the ransom to recover the unit. - pub owner: u8, - /// Gold price the owner must pay to recover the unit. - pub price: i32, - /// Turn the offer was created on. - pub created_turn: u32, - /// Turn the offer expires on (inclusive — `tick(current_turn)` returns - /// the offer when `current_turn >= expires_turn`). - pub expires_turn: u32, -} - -/// Queue of pending ransom offers. Stored on `GameState` alongside other -/// `pending_*` queues. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RansomQueue { - #[serde(default)] - offers: Vec, - #[serde(default)] - next_id: u32, -} - -impl RansomQueue { - /// Push a new offer; returns the assigned offer id. `expires_turn` is - /// `created_turn + RANSOM_OFFER_DURATION_TURNS` unless overridden via - /// the explicit-form constructor. - pub fn push( - &mut self, - unit_id: u32, - captor: u8, - owner: u8, - price: i32, - created_turn: u32, - ) -> u32 { - self.push_with_duration(unit_id, captor, owner, price, created_turn, RANSOM_OFFER_DURATION_TURNS) - } - - /// Variant that lets callers override the duration (used by tests and, - /// in Wave 2, by JSON config). - pub fn push_with_duration( - &mut self, - unit_id: u32, - captor: u8, - owner: u8, - price: i32, - created_turn: u32, - duration: u32, - ) -> u32 { - let id = self.next_id; - self.next_id = self.next_id.wrapping_add(1); - self.offers.push(RansomOffer { - id, - unit_id, - captor, - owner, - price, - created_turn, - expires_turn: created_turn.saturating_add(duration), - }); - id - } - - /// Iterate all offers awaiting `owner`'s decision. - pub fn pending_for(&self, owner: u8) -> impl Iterator { - self.offers.iter().filter(move |o| o.owner == owner) - } - - /// Iterate every offer (for save snapshots / UI dumps). - pub fn iter(&self) -> impl Iterator { - self.offers.iter() - } - - /// Number of pending offers across all players. - pub fn len(&self) -> usize { - self.offers.len() - } - - /// True when no offers are pending. - pub fn is_empty(&self) -> bool { - self.offers.is_empty() - } - - /// Remove and return the offer with `offer_id`. Caller is responsible for - /// deducting gold, restoring `MapUnit::owner_id`, clearing `captive_of`, - /// and emitting `UnitRansomAccepted`. - pub fn accept(&mut self, offer_id: u32) -> Option { - self.take(offer_id) - } - - /// Remove and return the offer with `offer_id`. Caller is responsible for - /// flipping `MapUnit::owner_id` to the captor, clearing `captive_of`, and - /// emitting `UnitRansomRefused` / `UnitCaptured`. - pub fn refuse(&mut self, offer_id: u32) -> Option { - self.take(offer_id) - } - - /// Remove and return all offers whose `expires_turn <= current_turn`. - /// Caller must convert each to a capture (flip ownership, clear - /// `captive_of`, emit `UnitRansomExpired`). - pub fn tick(&mut self, current_turn: u32) -> Vec { - let mut expired = Vec::new(); - let mut i = 0; - while i < self.offers.len() { - if self.offers[i].expires_turn <= current_turn { - expired.push(self.offers.swap_remove(i)); - } else { - i += 1; - } - } - expired - } - - fn take(&mut self, offer_id: u32) -> Option { - let pos = self.offers.iter().position(|o| o.id == offer_id)?; - Some(self.offers.swap_remove(pos)) - } -} +pub use mc_state::ransom::{RansomOffer, RansomQueue, RANSOM_OFFER_DURATION_TURNS};