diff --git a/src/simulator/crates/mc-state/src/capture.rs b/src/simulator/crates/mc-state/src/capture.rs new file mode 100644 index 00000000..cdd60bfc --- /dev/null +++ b/src/simulator/crates/mc-state/src/capture.rs @@ -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 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 for PostureResolution { + type Error = PromptUnresolved; + + fn try_from(p: CapturePosture) -> Result { + 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()); + } +} diff --git a/src/simulator/crates/mc-state/src/combat_event.rs b/src/simulator/crates/mc-state/src/combat_event.rs new file mode 100644 index 00000000..64df4ae8 --- /dev/null +++ b/src/simulator/crates/mc-state/src/combat_event.rs @@ -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); + } +} diff --git a/src/simulator/crates/mc-state/src/lib.rs b/src/simulator/crates/mc-state/src/lib.rs index 67dd0c39..b6e750ba 100644 --- a/src/simulator/crates/mc-state/src/lib.rs +++ b/src/simulator/crates/mc-state/src/lib.rs @@ -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; diff --git a/src/simulator/crates/mc-state/src/patrol.rs b/src/simulator/crates/mc-state/src/patrol.rs new file mode 100644 index 00000000..3431d40d --- /dev/null +++ b/src/simulator/crates/mc-state/src/patrol.rs @@ -0,0 +1,116 @@ +//! Patrol standing-order value type (relocated to mc-state in p2-65). +//! +//! `PatrolOrder` is an `Option` 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\""); + } +} diff --git a/src/simulator/crates/mc-turn/src/action_handlers/mod.rs b/src/simulator/crates/mc-turn/src/action_handlers/mod.rs index d31b2542..27c840b2 100644 --- a/src/simulator/crates/mc-turn/src/action_handlers/mod.rs +++ b/src/simulator/crates/mc-turn/src/action_handlers/mod.rs @@ -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)) } diff --git a/src/simulator/crates/mc-turn/src/capture.rs b/src/simulator/crates/mc-turn/src/capture.rs index b480d3ee..ea93f352 100644 --- a/src/simulator/crates/mc-turn/src/capture.rs +++ b/src/simulator/crates/mc-turn/src/capture.rs @@ -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 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 for PostureResolution { - type Error = PromptUnresolved; - - fn try_from(p: CapturePosture) -> Result { - 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: diff --git a/src/simulator/crates/mc-turn/src/combat_event.rs b/src/simulator/crates/mc-turn/src/combat_event.rs index 70f9922d..8fe2bddb 100644 --- a/src/simulator/crates/mc-turn/src/combat_event.rs +++ b/src/simulator/crates/mc-turn/src/combat_event.rs @@ -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)] diff --git a/src/simulator/crates/mc-turn/src/patrol.rs b/src/simulator/crates/mc-turn/src/patrol.rs index 099b071c..96e705e0 100644 --- a/src/simulator/crates/mc-turn/src/patrol.rs +++ b/src/simulator/crates/mc-turn/src/patrol.rs @@ -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)