refactor(mc-state): 🏗️ Phase 1+3a — create mc-state crate, relocate RansomQueue

p2-65 foundation. New data-shape crate `mc-state` (deps: data crates only —
no mc-ai, no mc-turn, no rayon/GPU) to decouple simulation state from the
turn-step mutation logic in mc-turn.

- New crate `crates/mc-state` added to workspace members; lib.rs declares the
  module set (ransom now; game_state to follow).
- `RansomOffer` + `RansomQueue` (struct + self-contained queue mechanics +
  `RANSOM_OFFER_DURATION_TURNS`) moved verbatim from mc-turn to
  `mc_state::ransom`. `mc-turn/src/ransom.rs` is now a `pub use` shim so every
  `mc_turn::ransom::*` import path resolves unchanged.
- mc-turn gains `mc-state` path dep. No cycle: mc-vision→mc-turn→mc-state, and
  mc-state deliberately does NOT dep mc-vision (game_state uses no mc_vision
  field type — only a doc-comment ref).
- Save-format invariant held: serde shapes byte-identical (serde never encoded
  module paths). New round-trip + tick/take tests in mc-state.

Gates (apricot): cargo test --workspace --no-run exit 0; mc-state 2/2;
mc-turn ransom 13/13; api-gdext capture_bridge 8/8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
autocommit 2026-06-04 18:47:12 -07:00
parent 9f1a28b4e8
commit 0bace0e6ce
7 changed files with 270 additions and 155 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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;

View file

@ -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<RansomOffer>,
#[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<Item = &RansomOffer> {
self.offers.iter().filter(move |o| o.owner == owner)
}
/// Iterate every offer (for save snapshots / UI dumps).
pub fn iter(&self) -> impl Iterator<Item = &RansomOffer> {
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<RansomOffer> {
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<RansomOffer> {
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<RansomOffer> {
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<RansomOffer> {
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());
}
}

View file

@ -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" }

View file

@ -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<RansomOffer>,
#[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<Item = &RansomOffer> {
self.offers.iter().filter(move |o| o.owner == owner)
}
/// Iterate every offer (for save snapshots / UI dumps).
pub fn iter(&self) -> impl Iterator<Item = &RansomOffer> {
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<RansomOffer> {
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<RansomOffer> {
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<RansomOffer> {
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<RansomOffer> {
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};