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:
parent
9f1a28b4e8
commit
0bace0e6ce
7 changed files with 270 additions and 155 deletions
20
src/simulator/Cargo.lock
generated
20
src/simulator/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
28
src/simulator/crates/mc-state/Cargo.toml
Normal file
28
src/simulator/crates/mc-state/Cargo.toml
Normal 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
|
||||
18
src/simulator/crates/mc-state/src/lib.rs
Normal file
18
src/simulator/crates/mc-state/src/lib.rs
Normal 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;
|
||||
194
src/simulator/crates/mc-state/src/ransom.rs
Normal file
194
src/simulator/crates/mc-state/src/ransom.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue