refactor(mc-state): 🏗️ Phase 3a — relocate capture/event/patrol data shapes
p2-65 cont. Move the remaining GameState/PlayerState/MapUnit field-type data
shapes out of mc-turn into mc-state, leaving turn-step logic behind. Each is a
re-export shim so `crate::{capture,combat_event,patrol}::*` paths resolve
unchanged.
- mc_state::capture — `CapturePosture` enum + `Default` + `TryFrom<_> for
PostureResolution` + `PromptUnresolved`. (The `TryFrom` MUST move with the
enum: once `CapturePosture` is foreign to mc-turn, that impl would be
orphan-rule-illegal there — Self=PostureResolution is mc-combat, trait is
std.) `resolve_posture` (reads MapUnit/PlayerState) stays in mc-turn.
- mc_state::combat_event — the 6 capture/PvP event structs staged on
`PendingCaptureEvents` (`UnitCaptured/RansomOffered/RansomAccepted/
RansomExpired/Killed/CivilianDestroyed`). `TurnResult` + Fauna/Pvp/Siege/
StrategicGate events stay in mc-turn (they embed VictoryType + mc_replay,
and TurnResult is not a persisted GameState field — only a drain target).
- mc_state::patrol — `PatrolOrder` + `PatrolMode` + pure self-methods
(`advance_cursor`/`target`). The unit-mutating command surface
(`issue`/`cancel`/`edit`, `PatrolError`, `validate_waypoints`,
`advance_on_turn`) stays in mc-turn, converted from `PatrolOrder::`
associated fns to `patrol::` free fns; 3 call sites in
action_handlers/mod.rs updated.
Save-format invariant held (verbatim fields + serde attrs; snake_case rename
preserved on CapturePosture/PatrolMode). New serde round-trip tests in each
mc-state module.
Gates (apricot): cargo test --workspace --no-run exit 0; mc-state 8/8;
mc-turn lib 238/238 (1 ignored, pre-existing); mc-turn patrol 14/14,
capture_posture 7/7; capture_pvp_end_to_end/caravan/chronicle 4/3/3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39bf244f74
commit
45e9adea92
8 changed files with 437 additions and 309 deletions
90
src/simulator/crates/mc-state/src/capture.rs
Normal file
90
src/simulator/crates/mc-state/src/capture.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
//! Civilian-capture posture value type (p2-55; relocated to mc-state in p2-65).
|
||||
//!
|
||||
//! `CapturePosture` is a data field of `GameState`/`PlayerState`/`MapUnit`
|
||||
//! (`civilian_posture`, `default_civilian_posture`, `posture_override`), so it
|
||||
//! lives in the data crate. The combat resolver's post-prompt decision is
|
||||
//! `mc_combat::resolver::PostureResolution` (`Capture | Destroy | Ransom`);
|
||||
//! this enum adds the `Prompt` UI sentinel and converts to the resolver type
|
||||
//! via `TryFrom` (which rejects `Prompt`).
|
||||
//!
|
||||
//! The `resolve_posture` precedence helper — which *reads* `MapUnit`/
|
||||
//! `PlayerState` to pick the effective posture — is turn-step logic and stays
|
||||
//! in `mc-turn::capture`.
|
||||
|
||||
use mc_combat::resolver::PostureResolution;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Civilian-capture posture chosen by a player against a target civilian.
|
||||
///
|
||||
/// `Prompt` is a UI sentinel: the player has not yet decided. Combat must not
|
||||
/// fire until the human resolves this to one of `Capture | Destroy | Ransom`.
|
||||
/// `TryFrom<CapturePosture> for PostureResolution` rejects `Prompt` for that
|
||||
/// reason.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CapturePosture {
|
||||
/// Take ownership of the captured civilian unit.
|
||||
Capture,
|
||||
/// Eliminate the civilian; identical mechanics to a normal kill but
|
||||
/// distinct in chronicle / AI memory.
|
||||
Destroy,
|
||||
/// Offer the civilian back to its owner for gold.
|
||||
Ransom,
|
||||
/// Surface a UI modal before combat resolves. Never passed to the resolver.
|
||||
Prompt,
|
||||
}
|
||||
|
||||
impl Default for CapturePosture {
|
||||
fn default() -> Self {
|
||||
// Sensible default for AI seats; humans get reseeded to `Prompt` at
|
||||
// game-setup time when the new-player capture-prompt flag is on.
|
||||
Self::Capture
|
||||
}
|
||||
}
|
||||
|
||||
/// `Prompt` cannot be resolved without UI input. Callers must convert to
|
||||
/// `PostureResolution` only after the human has chosen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PromptUnresolved;
|
||||
|
||||
impl TryFrom<CapturePosture> for PostureResolution {
|
||||
type Error = PromptUnresolved;
|
||||
|
||||
fn try_from(p: CapturePosture) -> Result<Self, Self::Error> {
|
||||
match p {
|
||||
CapturePosture::Capture => Ok(Self::Capture),
|
||||
CapturePosture::Destroy => Ok(Self::Destroy),
|
||||
CapturePosture::Ransom => Ok(Self::Ransom),
|
||||
CapturePosture::Prompt => Err(PromptUnresolved),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn posture_serde_snake_case_round_trip() {
|
||||
for p in [
|
||||
CapturePosture::Capture,
|
||||
CapturePosture::Destroy,
|
||||
CapturePosture::Ransom,
|
||||
CapturePosture::Prompt,
|
||||
] {
|
||||
let json = serde_json::to_string(&p).expect("serialize");
|
||||
let back: CapturePosture = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(p, back);
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_string(&CapturePosture::Capture).unwrap(),
|
||||
"\"capture\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_into_resolution_rejects_prompt() {
|
||||
assert!(PostureResolution::try_from(CapturePosture::Capture).is_ok());
|
||||
assert!(PostureResolution::try_from(CapturePosture::Prompt).is_err());
|
||||
}
|
||||
}
|
||||
138
src/simulator/crates/mc-state/src/combat_event.rs
Normal file
138
src/simulator/crates/mc-state/src/combat_event.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
//! Per-turn combat *capture* event data (p2-55/p2-67; relocated to mc-state in
|
||||
//! p2-65).
|
||||
//!
|
||||
//! These six structs are staged on `GameState.pending_capture_events`
|
||||
//! (`PendingCaptureEvents`) by the combat resolver and later drained into the
|
||||
//! turn-step `TurnResult`. They are pure value types (no `VictoryType` /
|
||||
//! `mc_replay::TurnEvent` deps), so they live in the data crate alongside the
|
||||
//! `PendingCaptureEvents` field that holds them.
|
||||
//!
|
||||
//! The richer aggregate `TurnResult` (and `FaunaCombatEvent` / `PvpCombatEvent`
|
||||
//! / `SiegeEvent` / `StrategicGateRejection`) stays in `mc-turn::combat_event`
|
||||
//! because it embeds `VictoryType` and `mc_replay::TurnEvent` — turn-step
|
||||
//! result shapes, not persisted `GameState` fields.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// p2-55: a civilian unit was captured (posture = Capture, or a ransom offer
|
||||
/// expired / was refused and rolled into a capture).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitCapturedEvent {
|
||||
pub turn: u32,
|
||||
/// Stable id of the captured unit (`MapUnit::id`).
|
||||
pub unit_id: u32,
|
||||
/// Player who took ownership.
|
||||
pub captor: u8,
|
||||
/// Original owner.
|
||||
pub prior_owner: u8,
|
||||
/// Hex where the capture occurred.
|
||||
pub col: i32,
|
||||
pub row: i32,
|
||||
/// Catalog kind of the captured unit (e.g. `"worker"`). Used when
|
||||
/// translating to a `mc_replay::TurnEvent::UnitCaptured` entry.
|
||||
pub unit_kind: String,
|
||||
}
|
||||
|
||||
/// p2-55: a ransom offer was created (posture = Ransom). The unit is pinned in
|
||||
/// `prior_owner`'s vec via `MapUnit::captive_of` until accepted, refused, or
|
||||
/// expired.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitRansomOfferedEvent {
|
||||
pub turn: u32,
|
||||
/// Stable offer id (returned by `RansomQueue::push`).
|
||||
pub offer_id: u32,
|
||||
pub unit_id: u32,
|
||||
pub captor: u8,
|
||||
pub owner: u8,
|
||||
pub price: i32,
|
||||
pub expires_turn: u32,
|
||||
}
|
||||
|
||||
/// p2-55e: an existing ransom offer was paid out — gold deducted from the
|
||||
/// owner, unit ownership restored, captive_of cleared. Distinct from a
|
||||
/// generic UnitCapturedEvent so chronicle / AI memory can read the resolution
|
||||
/// directly without cross-referencing the prior turn's offers list.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitRansomAcceptedEvent {
|
||||
pub turn: u32,
|
||||
/// Stable offer id (from `RansomQueue::push`).
|
||||
pub offer_id: u32,
|
||||
pub unit_id: u32,
|
||||
pub captor: u8,
|
||||
pub owner: u8,
|
||||
/// Gold actually deducted from `owner.gold` at accept time. Equals the
|
||||
/// offer's price; included on the event so chronicle text can read it
|
||||
/// without re-looking-up the offer (which has been removed from the queue).
|
||||
pub price_paid: i32,
|
||||
}
|
||||
|
||||
/// p2-55e: a ransom offer aged past its `expires_turn` without being
|
||||
/// accepted/refused. The unit converts to the captor's ownership (mirrors
|
||||
/// `process_ransom_expiry` semantics) but the chronicle reads this event
|
||||
/// directly instead of inferring from a prior-turn offers cross-reference.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitRansomExpiredEvent {
|
||||
pub turn: u32,
|
||||
pub offer_id: u32,
|
||||
pub unit_id: u32,
|
||||
pub captor: u8,
|
||||
pub prior_owner: u8,
|
||||
}
|
||||
|
||||
/// p2-67 Bug 3: a unit was killed in PvP combat (the `CombatOutcome::Killed`
|
||||
/// or `attacker !survived` branches of `resolve_single_pvp_attack`). Mirrors
|
||||
/// the shape of `CivilianDestroyedEvent` so chronicle pipeline + wire-event
|
||||
/// translation are symmetric. Staged on `state.pending_capture_events` from
|
||||
/// `resolve_single_pvp_attack` (which does NOT take `&mut TurnResult`) and
|
||||
/// drained into `result.events_emitted` via `PendingCaptureEvents::drain_into`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitKilledEvent {
|
||||
pub turn: u32,
|
||||
/// Stable id of the killed unit (`MapUnit::id`).
|
||||
pub unit_id: u32,
|
||||
/// Player who landed the killing blow.
|
||||
pub attacker: u8,
|
||||
/// Owner of the killed unit.
|
||||
pub defender: u8,
|
||||
/// Hex where the unit died.
|
||||
pub col: i32,
|
||||
pub row: i32,
|
||||
/// Catalog kind of the killed unit (e.g. `"dwarf_warrior"`).
|
||||
pub unit_kind: String,
|
||||
}
|
||||
|
||||
/// p2-55: a civilian was deliberately destroyed (posture = Destroy). Distinct
|
||||
/// from a generic kill so chronicle / AI memory can differentiate.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CivilianDestroyedEvent {
|
||||
pub turn: u32,
|
||||
pub unit_id: u32,
|
||||
pub destroyer: u8,
|
||||
pub owner: u8,
|
||||
pub col: i32,
|
||||
pub row: i32,
|
||||
/// Catalog kind of the destroyed unit (e.g. `"worker"`). Used when
|
||||
/// translating to a `mc_replay::TurnEvent::CivilianDestroyed` entry.
|
||||
pub unit_kind: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capture_events_serde_round_trip() {
|
||||
let ev = UnitCapturedEvent {
|
||||
turn: 5,
|
||||
unit_id: 12,
|
||||
captor: 1,
|
||||
prior_owner: 2,
|
||||
col: 3,
|
||||
row: 4,
|
||||
unit_kind: "worker".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&ev).expect("serialize");
|
||||
let back: UnitCapturedEvent = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(ev, back);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,4 +15,7 @@
|
|||
//! relocating these types across crate boundaries does not change the save
|
||||
//! format. Round-trip tests live alongside each module.
|
||||
|
||||
pub mod capture;
|
||||
pub mod combat_event;
|
||||
pub mod patrol;
|
||||
pub mod ransom;
|
||||
|
|
|
|||
116
src/simulator/crates/mc-state/src/patrol.rs
Normal file
116
src/simulator/crates/mc-state/src/patrol.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
//! Patrol standing-order value type (relocated to mc-state in p2-65).
|
||||
//!
|
||||
//! `PatrolOrder` is an `Option<PatrolOrder>` field on `MapUnit`, so the data
|
||||
//! shape — the struct, the `PatrolMode` enum, and the two pure self-methods
|
||||
//! (`advance_cursor`, `target`) that operate on the order alone — lives in the
|
||||
//! data crate.
|
||||
//!
|
||||
//! The command-validation + unit-mutation logic (`issue` / `cancel` / `edit`,
|
||||
//! `PatrolError`, `validate_waypoints`, `advance_on_turn`) stays in
|
||||
//! `mc-turn::patrol` as free functions: those touch `&mut MapUnit` and are
|
||||
//! turn-step orders, not state shape.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Maximum waypoints (including origin) per route.
|
||||
pub const MAX_WAYPOINTS: usize = 8;
|
||||
/// Minimum waypoints (including origin) per route.
|
||||
pub const MIN_WAYPOINTS: usize = 2;
|
||||
|
||||
/// Whether a patrol loops around its waypoint ring or ping-pongs back and forth.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PatrolMode {
|
||||
Loop,
|
||||
PingPong,
|
||||
}
|
||||
|
||||
/// Durable patrol standing order attached to a unit.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PatrolOrder {
|
||||
/// Waypoint sequence as `(col, row)` pairs; 2..=8 entries including origin.
|
||||
pub waypoints: Vec<(i32, i32)>,
|
||||
/// Index of the *current target* waypoint (the one the unit is walking toward).
|
||||
pub cursor: u8,
|
||||
/// +1 for forward (loop or ping-pong outbound), -1 for ping-pong return.
|
||||
pub direction: i8,
|
||||
pub mode: PatrolMode,
|
||||
/// Turn number when this order was established.
|
||||
pub established_turn: u32,
|
||||
}
|
||||
|
||||
impl PatrolOrder {
|
||||
/// Advance the patrol cursor after the unit has reached its current target
|
||||
/// waypoint. Called by the turn processor after the unit steps onto
|
||||
/// `waypoints[cursor]`.
|
||||
pub fn advance_cursor(&mut self) {
|
||||
let len = self.waypoints.len() as i8;
|
||||
match self.mode {
|
||||
PatrolMode::Loop => {
|
||||
self.cursor = ((self.cursor as i8 + self.direction).rem_euclid(len)) as u8;
|
||||
}
|
||||
PatrolMode::PingPong => {
|
||||
let next = self.cursor as i8 + self.direction;
|
||||
if next < 0 || next >= len {
|
||||
self.direction = -self.direction;
|
||||
self.cursor =
|
||||
(self.cursor as i8 + self.direction).clamp(0, len - 1) as u8;
|
||||
} else {
|
||||
self.cursor = next as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Target position for this turn: `waypoints[cursor]`.
|
||||
pub fn target(&self) -> (i32, i32) {
|
||||
self.waypoints[self.cursor as usize]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loop_cursor_wraps() {
|
||||
let mut order = PatrolOrder {
|
||||
waypoints: vec![(0, 0), (1, 0), (2, 0)],
|
||||
cursor: 2,
|
||||
direction: 1,
|
||||
mode: PatrolMode::Loop,
|
||||
established_turn: 1,
|
||||
};
|
||||
order.advance_cursor();
|
||||
assert_eq!(order.cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pingpong_direction_flips_at_end() {
|
||||
let mut order = PatrolOrder {
|
||||
waypoints: vec![(0, 0), (1, 0), (2, 0)],
|
||||
cursor: 2,
|
||||
direction: 1,
|
||||
mode: PatrolMode::PingPong,
|
||||
established_turn: 1,
|
||||
};
|
||||
order.advance_cursor();
|
||||
assert_eq!(order.direction, -1);
|
||||
assert_eq!(order.cursor, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patrol_order_serde_round_trip() {
|
||||
let order = PatrolOrder {
|
||||
waypoints: vec![(0, 0), (3, 0)],
|
||||
cursor: 1,
|
||||
direction: 1,
|
||||
mode: PatrolMode::Loop,
|
||||
established_turn: 7,
|
||||
};
|
||||
let json = serde_json::to_string(&order).expect("serialize");
|
||||
let back: PatrolOrder = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(order, back);
|
||||
assert_eq!(serde_json::to_string(&PatrolMode::PingPong).unwrap(), "\"ping_pong\"");
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ mod cavalry;
|
|||
|
||||
use crate::action::{ActionKind, DisabledReason};
|
||||
use crate::game_state::GameState;
|
||||
use crate::patrol::{PatrolError, PatrolMode, PatrolOrder};
|
||||
use crate::patrol::{self, PatrolError, PatrolMode};
|
||||
|
||||
/// An action invocation failed its pre-condition check.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
|
@ -294,7 +294,7 @@ fn handle_issue_patrol(
|
|||
// `invoke_patrol_with_args`; this path handles the registry validation gate.
|
||||
let origin = (unit.col, unit.row);
|
||||
let dest = (unit.col + 1, unit.row);
|
||||
PatrolOrder::issue(unit, vec![origin, dest], PatrolMode::Loop, turn)
|
||||
patrol::issue(unit, vec![origin, dest], PatrolMode::Loop, turn)
|
||||
.map_err(|e| patrol_error_to_action_error(ActionKind::IssuePatrol, e))
|
||||
}
|
||||
|
||||
|
|
@ -304,7 +304,7 @@ fn handle_cancel_patrol(
|
|||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::CancelPatrol)?;
|
||||
PatrolOrder::cancel(unit)
|
||||
patrol::cancel(unit)
|
||||
.map_err(|e| patrol_error_to_action_error(ActionKind::CancelPatrol, e))
|
||||
}
|
||||
|
||||
|
|
@ -326,7 +326,7 @@ fn handle_edit_patrol(
|
|||
})
|
||||
}
|
||||
};
|
||||
PatrolOrder::edit(unit, waypoints, mode, turn)
|
||||
patrol::edit(unit, waypoints, mode, turn)
|
||||
.map_err(|e| patrol_error_to_action_error(ActionKind::EditPatrol, e))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
//! Civilian capture posture state (p2-55, Wave 1).
|
||||
//! Civilian capture posture resolution (p2-55, Wave 1).
|
||||
//!
|
||||
//! `mc-state` does not exist as a separate crate; `MapUnit` and `PlayerState`
|
||||
//! live in `mc-turn::game_state`, so the data-layer `CapturePosture` enum and
|
||||
//! the `resolve_posture` precedence helper live here too. The combat resolver
|
||||
//! over in `mc-combat::resolver::PostureResolution` is the *post-prompt*
|
||||
//! decision (`Capture | Destroy | Ransom`); this module's `CapturePosture`
|
||||
//! adds the `Prompt` variant the UI must resolve before combat can fire.
|
||||
//! The `CapturePosture` enum + its `PostureResolution` conversion now live in
|
||||
//! [`mc_state::capture`] (data layer); this module re-exports them so existing
|
||||
//! `crate::capture::CapturePosture` paths resolve unchanged, and owns the
|
||||
//! `resolve_posture` precedence helper (turn-step logic that *reads*
|
||||
//! `MapUnit`/`PlayerState`).
|
||||
//!
|
||||
//! Resolution order (`resolve_posture`):
|
||||
//! 1. unit-level override (`MapUnit::posture_override`)
|
||||
|
|
@ -14,57 +13,11 @@
|
|||
//!
|
||||
//! Defaults: AI players are seeded with `Capture`; humans are seeded with
|
||||
//! `Prompt` when the new-player capture-prompt setting is on. The seeding
|
||||
//! itself happens in `TurnProcessor` / game setup — this module just stores.
|
||||
//! itself happens in `TurnProcessor` / game setup — this module just reads.
|
||||
|
||||
use crate::game_state::{MapUnit, PlayerState};
|
||||
use mc_combat::resolver::PostureResolution;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Civilian-capture posture chosen by a player against a target civilian.
|
||||
///
|
||||
/// `Prompt` is a UI sentinel: the player has not yet decided. Combat must not
|
||||
/// fire until the human resolves this to one of `Capture | Destroy | Ransom`.
|
||||
/// `TryFrom<CapturePosture> for PostureResolution` rejects `Prompt` for that
|
||||
/// reason.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CapturePosture {
|
||||
/// Take ownership of the captured civilian unit.
|
||||
Capture,
|
||||
/// Eliminate the civilian; identical mechanics to a normal kill but
|
||||
/// distinct in chronicle / AI memory.
|
||||
Destroy,
|
||||
/// Offer the civilian back to its owner for gold.
|
||||
Ransom,
|
||||
/// Surface a UI modal before combat resolves. Never passed to the resolver.
|
||||
Prompt,
|
||||
}
|
||||
|
||||
impl Default for CapturePosture {
|
||||
fn default() -> Self {
|
||||
// Sensible default for AI seats; humans get reseeded to `Prompt` at
|
||||
// game-setup time when the new-player capture-prompt flag is on.
|
||||
Self::Capture
|
||||
}
|
||||
}
|
||||
|
||||
/// `Prompt` cannot be resolved without UI input. Callers must convert to
|
||||
/// `PostureResolution` only after the human has chosen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PromptUnresolved;
|
||||
|
||||
impl TryFrom<CapturePosture> for PostureResolution {
|
||||
type Error = PromptUnresolved;
|
||||
|
||||
fn try_from(p: CapturePosture) -> Result<Self, Self::Error> {
|
||||
match p {
|
||||
CapturePosture::Capture => Ok(Self::Capture),
|
||||
CapturePosture::Destroy => Ok(Self::Destroy),
|
||||
CapturePosture::Ransom => Ok(Self::Ransom),
|
||||
CapturePosture::Prompt => Err(PromptUnresolved),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use mc_state::capture::{CapturePosture, PromptUnresolved};
|
||||
|
||||
/// Resolve the effective posture for `unit` (owned by `player`) attacking a
|
||||
/// capturable defender owned by `defender_owner`. Walks the precedence chain:
|
||||
|
|
|
|||
|
|
@ -47,107 +47,15 @@ pub struct PvpCombatEvent {
|
|||
pub attacker_damage: i32,
|
||||
}
|
||||
|
||||
/// p2-55: a civilian unit was captured (posture = Capture, or a ransom offer
|
||||
/// expired / was refused and rolled into a capture).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitCapturedEvent {
|
||||
pub turn: u32,
|
||||
/// Stable id of the captured unit (`MapUnit::id`).
|
||||
pub unit_id: u32,
|
||||
/// Player who took ownership.
|
||||
pub captor: u8,
|
||||
/// Original owner.
|
||||
pub prior_owner: u8,
|
||||
/// Hex where the capture occurred.
|
||||
pub col: i32,
|
||||
pub row: i32,
|
||||
/// Catalog kind of the captured unit (e.g. `"worker"`). Used when
|
||||
/// translating to a `mc_replay::TurnEvent::UnitCaptured` entry.
|
||||
pub unit_kind: String,
|
||||
}
|
||||
|
||||
/// p2-55: a ransom offer was created (posture = Ransom). The unit is pinned in
|
||||
/// `prior_owner`'s vec via `MapUnit::captive_of` until accepted, refused, or
|
||||
/// expired.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitRansomOfferedEvent {
|
||||
pub turn: u32,
|
||||
/// Stable offer id (returned by `RansomQueue::push`).
|
||||
pub offer_id: u32,
|
||||
pub unit_id: u32,
|
||||
pub captor: u8,
|
||||
pub owner: u8,
|
||||
pub price: i32,
|
||||
pub expires_turn: u32,
|
||||
}
|
||||
|
||||
/// p2-55e: an existing ransom offer was paid out — gold deducted from the
|
||||
/// owner, unit ownership restored, captive_of cleared. Distinct from a
|
||||
/// generic UnitCapturedEvent so chronicle / AI memory can read the resolution
|
||||
/// directly without cross-referencing the prior turn's offers list.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitRansomAcceptedEvent {
|
||||
pub turn: u32,
|
||||
/// Stable offer id (from `RansomQueue::push`).
|
||||
pub offer_id: u32,
|
||||
pub unit_id: u32,
|
||||
pub captor: u8,
|
||||
pub owner: u8,
|
||||
/// Gold actually deducted from `owner.gold` at accept time. Equals the
|
||||
/// offer's price; included on the event so chronicle text can read it
|
||||
/// without re-looking-up the offer (which has been removed from the queue).
|
||||
pub price_paid: i32,
|
||||
}
|
||||
|
||||
/// p2-55e: a ransom offer aged past its `expires_turn` without being
|
||||
/// accepted/refused. The unit converts to the captor's ownership (mirrors
|
||||
/// `process_ransom_expiry` semantics) but the chronicle reads this event
|
||||
/// directly instead of inferring from a prior-turn offers cross-reference.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitRansomExpiredEvent {
|
||||
pub turn: u32,
|
||||
pub offer_id: u32,
|
||||
pub unit_id: u32,
|
||||
pub captor: u8,
|
||||
pub prior_owner: u8,
|
||||
}
|
||||
|
||||
/// p2-67 Bug 3: a unit was killed in PvP combat (the `CombatOutcome::Killed`
|
||||
/// or `attacker !survived` branches of `resolve_single_pvp_attack`). Mirrors
|
||||
/// the shape of `CivilianDestroyedEvent` so chronicle pipeline + wire-event
|
||||
/// translation are symmetric. Staged on `state.pending_capture_events` from
|
||||
/// `resolve_single_pvp_attack` (which does NOT take `&mut TurnResult`) and
|
||||
/// drained into `result.events_emitted` via `PendingCaptureEvents::drain_into`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitKilledEvent {
|
||||
pub turn: u32,
|
||||
/// Stable id of the killed unit (`MapUnit::id`).
|
||||
pub unit_id: u32,
|
||||
/// Player who landed the killing blow.
|
||||
pub attacker: u8,
|
||||
/// Owner of the killed unit.
|
||||
pub defender: u8,
|
||||
/// Hex where the unit died.
|
||||
pub col: i32,
|
||||
pub row: i32,
|
||||
/// Catalog kind of the killed unit (e.g. `"dwarf_warrior"`).
|
||||
pub unit_kind: String,
|
||||
}
|
||||
|
||||
/// p2-55: a civilian was deliberately destroyed (posture = Destroy). Distinct
|
||||
/// from a generic kill so chronicle / AI memory can differentiate.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CivilianDestroyedEvent {
|
||||
pub turn: u32,
|
||||
pub unit_id: u32,
|
||||
pub destroyer: u8,
|
||||
pub owner: u8,
|
||||
pub col: i32,
|
||||
pub row: i32,
|
||||
/// Catalog kind of the destroyed unit (e.g. `"worker"`). Used when
|
||||
/// translating to a `mc_replay::TurnEvent::CivilianDestroyed` entry.
|
||||
pub unit_kind: String,
|
||||
}
|
||||
/// The six civilian/PvP *capture* event structs staged on
|
||||
/// `PendingCaptureEvents` now live in [`mc_state::combat_event`] (they are
|
||||
/// pure value types persisted via `GameState`). Re-exported here so
|
||||
/// `TurnResult`'s field types and every `crate::combat_event::*` import path
|
||||
/// resolve unchanged.
|
||||
pub use mc_state::combat_event::{
|
||||
CivilianDestroyedEvent, UnitCapturedEvent, UnitKilledEvent,
|
||||
UnitRansomAcceptedEvent, UnitRansomExpiredEvent, UnitRansomOfferedEvent,
|
||||
};
|
||||
|
||||
/// A city siege event: a unit attacked an enemy city tile.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1,25 +1,17 @@
|
|||
//! Patrol standing order — per-unit state and mutation functions.
|
||||
//! Patrol standing-order command logic (p2-65: data shape moved to mc-state).
|
||||
//!
|
||||
//! A `PatrolOrder` makes a unit auto-advance along a fixed waypoint loop each
|
||||
//! turn (in the turn-processor prologue phase) without per-turn player input.
|
||||
//! The `PatrolOrder` / `PatrolMode` value types + the pure `advance_cursor` /
|
||||
//! `target` self-methods now live in [`mc_state::patrol`] and are re-exported
|
||||
//! here. This module owns the turn-step *command* surface that mutates units:
|
||||
//! `issue` / `cancel` / `edit` (free functions taking `&mut MapUnit`),
|
||||
//! `PatrolError`, `validate_waypoints`, and `advance_on_turn`.
|
||||
//!
|
||||
//! Waypoints are stored as `(col, row)` pairs matching `MapUnit`'s coordinate
|
||||
//! system. 2..=8 waypoints (including the origin) are valid.
|
||||
|
||||
use crate::game_state::MapUnit;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Maximum waypoints (including origin) per route.
|
||||
pub const MAX_WAYPOINTS: usize = 8;
|
||||
/// Minimum waypoints (including origin) per route.
|
||||
pub const MIN_WAYPOINTS: usize = 2;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PatrolMode {
|
||||
Loop,
|
||||
PingPong,
|
||||
}
|
||||
pub use mc_state::patrol::{PatrolMode, PatrolOrder, MAX_WAYPOINTS, MIN_WAYPOINTS};
|
||||
|
||||
/// Why `issue` or `edit` rejected the patrol request.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -41,103 +33,60 @@ impl std::fmt::Display for PatrolError {
|
|||
}
|
||||
}
|
||||
|
||||
/// Durable patrol standing order attached to a unit.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PatrolOrder {
|
||||
/// Waypoint sequence as `(col, row)` pairs; 2..=8 entries including origin.
|
||||
pub waypoints: Vec<(i32, i32)>,
|
||||
/// Index of the *current target* waypoint (the one the unit is walking toward).
|
||||
pub cursor: u8,
|
||||
/// +1 for forward (loop or ping-pong outbound), -1 for ping-pong return.
|
||||
pub direction: i8,
|
||||
pub mode: PatrolMode,
|
||||
/// Turn number when this order was established.
|
||||
pub established_turn: u32,
|
||||
/// Issue a new patrol order on a unit that is not yet patrolling.
|
||||
///
|
||||
/// `waypoints` must have 2..=8 entries (caller supplies origin as `waypoints[0]`).
|
||||
/// Returns `Err` if pre-conditions fail; does not mutate on error.
|
||||
pub fn issue(
|
||||
unit: &mut MapUnit,
|
||||
waypoints: Vec<(i32, i32)>,
|
||||
mode: PatrolMode,
|
||||
current_turn: u32,
|
||||
) -> Result<(), PatrolError> {
|
||||
if unit.patrol_order.is_some() {
|
||||
return Err(PatrolError::AlreadyPatrolling);
|
||||
}
|
||||
validate_waypoints(&waypoints)?;
|
||||
// Clear fortify — patrol and fortify are mutually exclusive.
|
||||
unit.is_fortified = false;
|
||||
unit.patrol_order = Some(PatrolOrder {
|
||||
waypoints,
|
||||
cursor: 1, // target is the second waypoint; origin is index 0
|
||||
direction: 1,
|
||||
mode,
|
||||
established_turn: current_turn,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PatrolOrder {
|
||||
/// Issue a new patrol order on a unit that is not yet patrolling.
|
||||
///
|
||||
/// `waypoints` must have 2..=8 entries (caller supplies origin as `waypoints[0]`).
|
||||
/// Returns `Err` if pre-conditions fail; does not mutate on error.
|
||||
pub fn issue(
|
||||
unit: &mut MapUnit,
|
||||
waypoints: Vec<(i32, i32)>,
|
||||
mode: PatrolMode,
|
||||
current_turn: u32,
|
||||
) -> Result<(), PatrolError> {
|
||||
if unit.patrol_order.is_some() {
|
||||
return Err(PatrolError::AlreadyPatrolling);
|
||||
}
|
||||
validate_waypoints(&waypoints)?;
|
||||
// Clear fortify — patrol and fortify are mutually exclusive.
|
||||
unit.is_fortified = false;
|
||||
unit.patrol_order = Some(PatrolOrder {
|
||||
waypoints,
|
||||
cursor: 1, // target is the second waypoint; origin is index 0
|
||||
direction: 1,
|
||||
mode,
|
||||
established_turn: current_turn,
|
||||
});
|
||||
Ok(())
|
||||
/// Cancel an active patrol order, leaving the unit idle at its current tile.
|
||||
pub fn cancel(unit: &mut MapUnit) -> Result<(), PatrolError> {
|
||||
if unit.patrol_order.is_none() {
|
||||
return Err(PatrolError::NotPatrolling);
|
||||
}
|
||||
unit.patrol_order = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel an active patrol order, leaving the unit idle at its current tile.
|
||||
pub fn cancel(unit: &mut MapUnit) -> Result<(), PatrolError> {
|
||||
if unit.patrol_order.is_none() {
|
||||
return Err(PatrolError::NotPatrolling);
|
||||
}
|
||||
unit.patrol_order = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the route of an active patrol order. Cursor resets to 1.
|
||||
pub fn edit(
|
||||
unit: &mut MapUnit,
|
||||
new_waypoints: Vec<(i32, i32)>,
|
||||
new_mode: PatrolMode,
|
||||
current_turn: u32,
|
||||
) -> Result<(), PatrolError> {
|
||||
if unit.patrol_order.is_none() {
|
||||
return Err(PatrolError::NotPatrolling);
|
||||
}
|
||||
validate_waypoints(&new_waypoints)?;
|
||||
unit.patrol_order = Some(PatrolOrder {
|
||||
waypoints: new_waypoints,
|
||||
cursor: 1,
|
||||
direction: 1,
|
||||
mode: new_mode,
|
||||
established_turn: current_turn,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advance the patrol cursor after the unit has reached its current target
|
||||
/// waypoint. Called by the turn processor after the unit steps onto
|
||||
/// `waypoints[cursor]`.
|
||||
pub fn advance_cursor(&mut self) {
|
||||
let len = self.waypoints.len() as i8;
|
||||
match self.mode {
|
||||
PatrolMode::Loop => {
|
||||
self.cursor = ((self.cursor as i8 + self.direction).rem_euclid(len)) as u8;
|
||||
}
|
||||
PatrolMode::PingPong => {
|
||||
let next = self.cursor as i8 + self.direction;
|
||||
if next < 0 || next >= len {
|
||||
self.direction = -self.direction;
|
||||
self.cursor =
|
||||
(self.cursor as i8 + self.direction).clamp(0, len - 1) as u8;
|
||||
} else {
|
||||
self.cursor = next as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Target position for this turn: `waypoints[cursor]`.
|
||||
pub fn target(&self) -> (i32, i32) {
|
||||
self.waypoints[self.cursor as usize]
|
||||
/// Replace the route of an active patrol order. Cursor resets to 1.
|
||||
pub fn edit(
|
||||
unit: &mut MapUnit,
|
||||
new_waypoints: Vec<(i32, i32)>,
|
||||
new_mode: PatrolMode,
|
||||
current_turn: u32,
|
||||
) -> Result<(), PatrolError> {
|
||||
if unit.patrol_order.is_none() {
|
||||
return Err(PatrolError::NotPatrolling);
|
||||
}
|
||||
validate_waypoints(&new_waypoints)?;
|
||||
unit.patrol_order = Some(PatrolOrder {
|
||||
waypoints: new_waypoints,
|
||||
cursor: 1,
|
||||
direction: 1,
|
||||
mode: new_mode,
|
||||
established_turn: current_turn,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advance all patrolling units one step along their route.
|
||||
|
|
@ -213,7 +162,7 @@ mod tests {
|
|||
fn issue_patrol_sets_order() {
|
||||
let mut unit = unit_at(0, 0);
|
||||
let waypoints = vec![(0, 0), (3, 0)];
|
||||
PatrolOrder::issue(&mut unit, waypoints.clone(), PatrolMode::Loop, 1).unwrap();
|
||||
issue(&mut unit, waypoints.clone(), PatrolMode::Loop, 1).unwrap();
|
||||
let order = unit.patrol_order.unwrap();
|
||||
assert_eq!(order.waypoints, waypoints);
|
||||
assert_eq!(order.cursor, 1);
|
||||
|
|
@ -224,38 +173,37 @@ mod tests {
|
|||
fn issue_clears_fortify() {
|
||||
let mut unit = unit_at(0, 0);
|
||||
unit.is_fortified = true;
|
||||
PatrolOrder::issue(&mut unit, vec![(0, 0), (1, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
issue(&mut unit, vec![(0, 0), (1, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
assert!(!unit.is_fortified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_issue_errors() {
|
||||
let mut unit = unit_at(0, 0);
|
||||
PatrolOrder::issue(&mut unit, vec![(0, 0), (1, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
let err =
|
||||
PatrolOrder::issue(&mut unit, vec![(0, 0), (2, 0)], PatrolMode::Loop, 2).unwrap_err();
|
||||
issue(&mut unit, vec![(0, 0), (1, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
let err = issue(&mut unit, vec![(0, 0), (2, 0)], PatrolMode::Loop, 2).unwrap_err();
|
||||
assert_eq!(err, PatrolError::AlreadyPatrolling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_clears_order() {
|
||||
let mut unit = unit_at(0, 0);
|
||||
PatrolOrder::issue(&mut unit, vec![(0, 0), (1, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
PatrolOrder::cancel(&mut unit).unwrap();
|
||||
issue(&mut unit, vec![(0, 0), (1, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
cancel(&mut unit).unwrap();
|
||||
assert!(unit.patrol_order.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_when_not_patrolling_errors() {
|
||||
let mut unit = unit_at(0, 0);
|
||||
assert_eq!(PatrolOrder::cancel(&mut unit).unwrap_err(), PatrolError::NotPatrolling);
|
||||
assert_eq!(cancel(&mut unit).unwrap_err(), PatrolError::NotPatrolling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_replaces_route() {
|
||||
let mut unit = unit_at(0, 0);
|
||||
PatrolOrder::issue(&mut unit, vec![(0, 0), (1, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
PatrolOrder::edit(&mut unit, vec![(0, 0), (5, 5)], PatrolMode::PingPong, 2).unwrap();
|
||||
issue(&mut unit, vec![(0, 0), (1, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
edit(&mut unit, vec![(0, 0), (5, 5)], PatrolMode::PingPong, 2).unwrap();
|
||||
let order = unit.patrol_order.unwrap();
|
||||
assert_eq!(order.waypoints, vec![(0, 0), (5, 5)]);
|
||||
assert_eq!(order.mode, PatrolMode::PingPong);
|
||||
|
|
@ -265,8 +213,7 @@ mod tests {
|
|||
#[test]
|
||||
fn too_short_errors() {
|
||||
let mut unit = unit_at(0, 0);
|
||||
let err =
|
||||
PatrolOrder::issue(&mut unit, vec![(0, 0)], PatrolMode::Loop, 1).unwrap_err();
|
||||
let err = issue(&mut unit, vec![(0, 0)], PatrolMode::Loop, 1).unwrap_err();
|
||||
assert_eq!(err, PatrolError::TooShort);
|
||||
}
|
||||
|
||||
|
|
@ -274,41 +221,14 @@ mod tests {
|
|||
fn too_long_errors() {
|
||||
let mut unit = unit_at(0, 0);
|
||||
let waypoints: Vec<(i32, i32)> = (0..=8).map(|i| (i, 0)).collect(); // 9 entries
|
||||
let err = PatrolOrder::issue(&mut unit, waypoints, PatrolMode::Loop, 1).unwrap_err();
|
||||
let err = issue(&mut unit, waypoints, PatrolMode::Loop, 1).unwrap_err();
|
||||
assert_eq!(err, PatrolError::TooLong);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_cursor_wraps() {
|
||||
let mut order = PatrolOrder {
|
||||
waypoints: vec![(0, 0), (1, 0), (2, 0)],
|
||||
cursor: 2,
|
||||
direction: 1,
|
||||
mode: PatrolMode::Loop,
|
||||
established_turn: 1,
|
||||
};
|
||||
order.advance_cursor();
|
||||
assert_eq!(order.cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pingpong_direction_flips_at_end() {
|
||||
let mut order = PatrolOrder {
|
||||
waypoints: vec![(0, 0), (1, 0), (2, 0)],
|
||||
cursor: 2,
|
||||
direction: 1,
|
||||
mode: PatrolMode::PingPong,
|
||||
established_turn: 1,
|
||||
};
|
||||
order.advance_cursor();
|
||||
assert_eq!(order.direction, -1);
|
||||
assert_eq!(order.cursor, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_on_turn_moves_unit_one_step() {
|
||||
let mut unit = unit_at(0, 0);
|
||||
PatrolOrder::issue(&mut unit, vec![(0, 0), (3, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
issue(&mut unit, vec![(0, 0), (3, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
let mut units = vec![unit];
|
||||
advance_on_turn(&mut units);
|
||||
assert_eq!(units[0].col, 1);
|
||||
|
|
@ -319,7 +239,7 @@ mod tests {
|
|||
fn advance_on_turn_advances_cursor_when_waypoint_reached() {
|
||||
let mut unit = unit_at(0, 0);
|
||||
// Waypoint is one tile away — will be reached in one step
|
||||
PatrolOrder::issue(&mut unit, vec![(0, 0), (1, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
issue(&mut unit, vec![(0, 0), (1, 0)], PatrolMode::Loop, 1).unwrap();
|
||||
let mut units = vec![unit];
|
||||
advance_on_turn(&mut units);
|
||||
// Reached (1,0), cursor should have advanced to 0 (loop wraps 2-waypoint list)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue