feat(p3-10b): wire lair siege into the live turn loop

tick_siege/decay_siege + SiegeState (persisted on GameState::siege_pressure)
shipped earlier but were dead code — never called from the turn loop. This
adds the per-turn lair-siege phase + a data-driven config loader.

- mc-turn/src/lair_siege.rs: LairSiegeConfig loader from
  lair_combat_modes.json::siege.siege_pressure (per-tier resistance + tuning,
  Rail 2 — consumes authored data, no fabricated per-lair siege_resistance);
  tick_one_lair pure helper; LairSiegeEvent.
- processor::process_lair_sieges (Phase 5d in step): iterates siege_pressure,
  detects besieger adjacency, ticks/decays, surrenders + rolls loot + drops
  the entry, logs to TurnResult::lair_siege_log. authored_lair_siege_config()
  embeds the JSON build-time + OnceLock (same pattern as encounter_rates).
- resolve_lair_combat Siege arm stays single-shot-NotImplemented BY DESIGN
  (multi-turn state can't flow through LairAssaultParams -> AssaultOutcome;
  same precedent as resolve_raid's separate entry) — doc'd to point at the
  live per-turn phase.

Honest boundary: nothing in the live loop INITIATES a siege yet (a player
picks Siege via the GdLair bridge / UI mode picker — godot-ui follow-up that
owns GdLair::begin_siege). The phase is exercised by tests + the future
bridge; mechanic is live, initiation is the remaining dependency. GDExt
begin_siege/siege_pressure bullet stays open (K=4/5).

Tests (apricot): lair_siege lib 5/5 (incl. named pressure_accumulates_then_
surrenders + pressure_decays_when_unattended) + config loaders 3; integration
lair_siege_phase 4/4 (through processor.step + save/load round-trip); mc-turn
240 lib, mc-combat 143 lib, serde_roundtrip 6/6; cargo check --workspace 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
autocommit 2026-06-04 16:10:58 -07:00
parent beac17f4e2
commit 05ad5fae86
7 changed files with 690 additions and 13 deletions

View file

@ -11,6 +11,25 @@ evidence:
- "src/simulator/crates/mc-combat/src/lair.rs:225 apply_siege_pressure + decay_siege"
blocked_by: [p3-10a]
---
## Wave B update (2026-06-04, finish-game1 Rust lane)
Promoted K=0→4 of 5. The siege mechanic is now **LIVE in the turn loop**
(`TurnProcessor::process_lair_sieges`, Phase 5d in `step`), driven by a
data-driven `LairSiegeConfig` loaded from `lair_combat_modes.json` (per-tier
resistance + tuning, Rail 2). Pressure accumulates under besieger adjacency,
decays unattended, surrenders + drops loot at threshold, and round-trips through
save/load (verified). 4 named acceptance tests + 3 config-loader tests + 5 lib +
4 integration green; full mc-turn 240 lib, mc-combat 143 lib, serde_roundtrip
6/6; `cargo check --workspace` exit 0 (apricot 2026-06-04). Files:
`mc-turn/src/lair_siege.rs` (new) + `lib.rs`, `mc-turn/src/processor.rs`
(`process_lair_sieges` + `authored_lair_siege_config`),
`mc-turn/src/combat_event.rs` (`lair_siege_log`),
`mc-turn/tests/lair_siege_phase.rs` (new), `mc-combat/src/lair.rs` (dispatcher
doc). **Open (☐):** the `GdLair::begin_siege`/`siege_pressure` GDExt + the UI
that *initiates* a siege — godot-ui follow-up; nothing in the live loop creates a
`SiegeState` yet, so the phase is exercised by tests + the future bridge. Status
stays **partial** (K=4 < 5).
## p3-10b close-out (2026-05-05)
`mc_core::lair::SiegeState` + `SiegeOutcome` typed enums shipped. `mc_combat::lair::apply_siege_pressure` and `decay_siege` functions implement multi-turn pressure-from-adjacent semantics. Outcomes include `Continuing | Surrender | AlreadySurrendered`. Status: partial — Rust mechanic implemented; mc-turn integration into the per-turn dispatch (call site for besieging units) and end-to-end batch validation are not yet in place. ACS pushed `2de4ae5ac "add siege pressure state tracking"`.
@ -21,11 +40,53 @@ Per `public/games/age-of-dwarves/docs/combat/LAIRS.md`, Siege mode parks an adja
## Acceptance
- ❌ `mc-combat::lair::tick_siege(lair_id, besieger_stack)` increments per-lair `siege_pressure` by the besieger's siege-power per turn; emits `LairSurrender(loot)` event when threshold passed.
- ❌ Lair JSON `public/resources/lairs/*.json` carries `siege_resistance: u32` and `siege_loot_pct: f32` (loot fraction of Assault-mode loot).
- ❌ Pressure decays at the documented rate when no besieger adjacent; persists across save/load.
- ❌ `cargo test -p mc-combat test_siege_pressure_accumulates_then_surrenders` and `test_siege_pressure_decays_when_unattended` green.
- ❌ GDExt: `GdLair::begin_siege(stack_id)` and `GdLair::siege_pressure() -> u32` for UI.
- ✓ `tick_siege` increments per-lair `siege_pressure` by the besieger's
siege-power per turn and surrenders when the threshold is passed — **now LIVE
in the turn loop (Wave B, 2026-06-04).** `mc_combat::tick_siege`/`decay_siege`
(already shipped) are driven each turn by the new
`mc_turn::TurnProcessor::process_lair_sieges` phase (Phase 5d in `step`), which
iterates `GameState::siege_pressure`, detects besieger adjacency (offset hex
check, owner-agnostic vs the wild lair), ticks or decays, and on `Surrender`
rolls surrender loot + drops the entry. Each tick appends a `LairSiegeEvent`
to `TurnResult::lair_siege_log`. (Signature note: `tick_siege(state, stack,
tuning)` keys on the persisted `SiegeState`, not the spec's literal
`(lair_id, besieger_stack)`; the phase supplies the per-lair state. Surrender
is reported as a typed `SiegeOutcome::Surrender` + a `LairSiegeEvent` carrying
the loot, rather than a free-standing `LairSurrender(loot)` event type — same
information, typed-log shape.)
- ✓ Siege resistance + loot fraction are **data-driven**, consumed (not
hardcoded) from `public/resources/ecology/fauna/lair_combat_modes.json`
`combat_modes[id=siege].siege_pressure`, via the new loader
`mc_turn::lair_siege::LairSiegeConfig::from_lair_combat_modes_json` (embedded
build-time + `OnceLock`-cached by `authored_lair_siege_config()`, the same
Rail-2 pattern as `authored_encounter_rates`). **Shape note vs the literal
bullet:** resistance is authored **per-tier** (`resistance_by_tier: {1:50 …
10:700}`), not as a single per-lair `siege_resistance: u32`; `siege_loot_pct`
(0.5) is authored as the float and converted to integer basis points
(`loot_pct_bp = 5000`) for byte-stable determinism. The data already existed in
`lair_combat_modes.json`; this objective added its Rust consumer rather than
fabricating a duplicate per-lair field (Rail 2 / Commandment 5).
- ✓ Pressure decays at the documented rate (`decay_per_turn = 15`) when no
besieger is adjacent and the entry is dropped at zero; persists across
save/load — the `siege_pressure` serde adapter on `GameState` was already
present; the round-trip is now **verified** by
`lair_siege_phase::siege_state_survives_save_load_round_trip`.
- ✓ Named acceptance tests green (apricot 2026-06-04): unit-level
`mc_turn::lair_siege::tests::{pressure_accumulates_then_surrenders,
pressure_decays_when_unattended}` + the through-the-processor
`tests/lair_siege_phase::{besieger_adjacent_accumulates_pressure_then_lair_surrenders,
unattended_lair_decays_and_entry_is_dropped}` (4/4 + 3 config-loader tests).
(The spec named them `test_siege_pressure_*` and homed them in `mc-combat`;
they live in `mc-turn` because the per-turn integration — the thing being
proven — is mc-turn's, and the resolver primitives they exercise are
mc-combat's. Same coverage, correct crate.)
- ☐ GDExt: `GdLair::begin_siege(stack_id)` and `GdLair::siege_pressure() -> u32`
for UI — **NOT in this Rust lane's Wave B scope.** Depends on the p3-10a
`GdLair` bridge (now landed) + the lair mode-picker UI that *initiates* a siege
(godot-ui follow-up). Until a player can pick Siege, nothing creates a
`SiegeState`, so the live phase is exercised by tests + the future bridge. This
is the same initiation boundary as p2-57c's quality-stamp: the mechanic is live
in the loop; the initiation surface is the remaining dependency.
## Source-of-truth rails

View file

@ -134,8 +134,19 @@ impl std::fmt::Display for LairCombatError {
impl std::error::Error for LairCombatError {}
/// Typed dispatcher across all lair combat modes. Assault delegates to
/// [`resolve_assault`]; Siege and Raid return
/// [`LairCombatError::NotImplemented`] until `p3-10b` / `p3-10c` ship.
/// [`resolve_assault`].
///
/// Siege and Raid are **single-shot-`NotImplemented` by design**, not unwired
/// features: both are multi-turn / distinct-return-shape engagements that the
/// single-shot `LairAssaultParams -> AssaultOutcome` signature cannot express.
/// - **Siege (p3-10b) is LIVE** via the per-turn phase
/// `mc_turn::TurnProcessor::process_lair_sieges`, driving
/// [`tick_siege`] / [`decay_siege`] against the persisted `SiegeState`. It is
/// not reachable through this single-call dispatcher because it mutates
/// per-turn state across many turns.
/// - **Raid (p3-10c)** uses its own entry point [`resolve_raid`] because its
/// return shape (`RaidOutcome` + `RaidResult` per-resource drops) differs
/// from the Assault stack-survivor model.
///
/// Callers that statically know they want Assault can call
/// [`resolve_assault`] directly and avoid the `Result` wrapper.
@ -145,11 +156,6 @@ pub fn resolve_lair_combat(
) -> Result<AssaultOutcome, LairCombatError> {
match mode {
LairCombatMode::Assault => Ok(resolve_assault(params)),
// Siege is multi-turn — see `tick_siege` / `decay_siege`. The
// single-shot dispatcher cannot resolve it in one call.
// Raid uses a distinct entry point (`resolve_raid`) because its
// return shape (`RaidOutcome` + per-resource drops via
// `RaidResult`) differs from the Assault stack-survivor model.
LairCombatMode::Siege | LairCombatMode::Raid => Err(LairCombatError::NotImplemented),
}
}

View file

@ -200,8 +200,13 @@ pub struct TurnResult {
pub pvp_battles: u32,
/// Total units killed in PvP combat this turn (across all players).
pub pvp_kills: u32,
/// Every siege event this turn.
/// Every (city) siege event this turn.
pub siege_log: Vec<SiegeEvent>,
/// p3-10b: every LAIR-siege tick this turn (distinct from `siege_log`,
/// which is city siege). Empty unless a `SiegeState` is active in
/// `GameState::siege_pressure`.
#[serde(default)]
pub lair_siege_log: Vec<crate::lair_siege::LairSiegeEvent>,
/// Total cities captured this turn.
pub cities_captured: u32,
/// Build attempts rejected by the strategic resource gate this turn.

View file

@ -0,0 +1,338 @@
//! p3-10b — lair-siege per-turn integration.
//!
//! `mc_combat::lair::{tick_siege, decay_siege, roll_surrender_loot}` and the
//! `SiegeState` persisted on `GameState::siege_pressure` shipped earlier but
//! were **dead code** — never called from the turn loop, so a besieged lair's
//! pressure never moved in an actual game. This module wires the per-turn
//! siege phase + a config loader for the authored tunables.
//!
//! ## Why this is NOT a `resolve_lair_combat` dispatch branch
//!
//! `mc_combat::resolve_lair_combat(mode, params) -> Result<AssaultOutcome, _>`
//! is single-shot: it takes `LairAssaultParams` and returns an assault-shaped
//! outcome, with no `&mut SiegeState`. A siege is multi-turn and mutates
//! persistent state, so it cannot be expressed through that signature without
//! either faking an `AssaultOutcome` or a return-type refactor rippling through
//! the 7 Assault call sites + the `GdLair` bridge. The codebase already set the
//! precedent — Raid uses its own entry point (`resolve_raid`) "because its
//! return shape differs". Siege is the same case: the dispatcher's `Siege` arm
//! stays single-shot-`NotImplemented` by design, and the real engagement runs
//! here in `process_lair_sieges`. (Objective p3-10b's "dispatch branch" wording
//! describes the dead-code bug this phase fixes, not a literal contortion of
//! the single-shot dispatcher.)
//!
//! ## Honest integration boundary
//!
//! This phase ticks every `SiegeState` already present in
//! `GameState::siege_pressure`. It does NOT *initiate* sieges — nothing in the
//! live loop yet creates a `SiegeState` (a player must choose Siege via the
//! GDExt bridge / UI mode picker, which is the godot-ui follow-up that also
//! owns `GdLair::begin_siege`). Until that initiation surface lands, the phase
//! is exercised by tests + the future bridge, exactly mirroring the
//! quality-stamp boundary in p2-57c. The mechanic is now LIVE in the loop; the
//! initiation half is the remaining dependency.
use std::collections::BTreeMap;
use mc_combat::{decay_siege, roll_surrender_loot, tick_siege, LootEntry, SiegeTuning};
use mc_core::lair::{SiegeOutcome, SiegeState};
use serde::{Deserialize, Serialize};
/// One resolved lair-siege event for the turn log.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LairSiegeEvent {
pub turn: u32,
pub lair_col: i32,
pub lair_row: i32,
/// Pressure after this tick.
pub pressure: u32,
/// Resistance threshold for the lair.
pub resistance: u32,
/// What happened this tick.
pub outcome: SiegeOutcome,
/// On `Surrender`, the rolled surrender loot `(resource, amount)`. Empty
/// on every other outcome.
pub loot: Vec<(String, u32)>,
}
/// Authored lair-siege configuration, loaded from
/// `public/resources/ecology/fauna/lair_combat_modes.json` →
/// `combat_modes[id=siege].siege_pressure`.
///
/// `mc-combat` does no IO; this is the mc-turn-side loader that turns the JSON
/// into the `SiegeTuning` the resolver consumes plus the per-tier resistance
/// table (`resistance_by_tier`). Default mirrors the JSON so test fixtures
/// that never load files still get sane values.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LairSiegeConfig {
pub tuning: SiegeTuningData,
/// Surrender threshold per lair tier (1..=10). Missing tier → no surrender
/// is reachable for that tier (resistance treated as `u32::MAX`).
pub resistance_by_tier: BTreeMap<i32, u32>,
}
/// Serde-friendly mirror of `mc_combat::SiegeTuning` (which is not
/// `Deserialize` — it stores `loot_pct_bp` integer fixed-point while the JSON
/// authors `siege_loot_pct` as a float). This struct reads the JSON shape and
/// converts to `SiegeTuning` via [`SiegeTuningData::to_tuning`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SiegeTuningData {
pub base_per_turn: u32,
pub per_extra_unit: u32,
pub decay_per_turn: u32,
/// Fixed-point ×10000 of `siege_loot_pct` (kept integer for `Eq` and
/// byte-stable determinism). `0.5` → `5000`.
pub loot_pct_bp: u32,
}
impl SiegeTuningData {
#[must_use]
pub fn to_tuning(&self) -> SiegeTuning {
SiegeTuning {
base_per_turn: self.base_per_turn,
per_extra_unit: self.per_extra_unit,
decay_per_turn: self.decay_per_turn,
loot_pct_bp: self.loot_pct_bp,
}
}
}
impl Default for SiegeTuningData {
fn default() -> Self {
// Mirrors lair_combat_modes.json::siege.siege_pressure.
Self { base_per_turn: 25, per_extra_unit: 10, decay_per_turn: 15, loot_pct_bp: 5000 }
}
}
impl Default for LairSiegeConfig {
fn default() -> Self {
let mut resistance_by_tier = BTreeMap::new();
for (tier, r) in
[(1, 50), (2, 75), (3, 100), (4, 150), (5, 200), (6, 275), (7, 350), (8, 450), (9, 575), (10, 700)]
{
resistance_by_tier.insert(tier, r);
}
Self { tuning: SiegeTuningData::default(), resistance_by_tier }
}
}
impl LairSiegeConfig {
/// Parse the `siege.siege_pressure` block out of the full
/// `lair_combat_modes.json` document.
///
/// # Errors
/// Returns a message if the JSON is malformed or the siege block is absent.
pub fn from_lair_combat_modes_json(raw: &str) -> Result<Self, String> {
#[derive(Deserialize)]
struct Doc {
combat_modes: Vec<Mode>,
}
#[derive(Deserialize)]
struct Mode {
id: String,
#[serde(default)]
siege_pressure: Option<SiegePressureBlock>,
}
#[derive(Deserialize)]
struct SiegePressureBlock {
base_per_turn: u32,
per_extra_unit: u32,
decay_per_turn: u32,
siege_loot_pct: f64,
resistance_by_tier: BTreeMap<String, u32>,
}
let doc: Doc = serde_json::from_str(raw).map_err(|e| format!("lair_combat_modes parse: {e}"))?;
let block = doc
.combat_modes
.into_iter()
.find(|m| m.id == "siege")
.and_then(|m| m.siege_pressure)
.ok_or_else(|| "no siege.siege_pressure block in lair_combat_modes.json".to_string())?;
// siege_loot_pct float → integer basis points, rounded to nearest.
let loot_pct_bp = (block.siege_loot_pct * 10_000.0).round().max(0.0) as u32;
let resistance_by_tier = block
.resistance_by_tier
.into_iter()
.filter_map(|(k, v)| k.parse::<i32>().ok().map(|t| (t, v)))
.collect();
Ok(Self {
tuning: SiegeTuningData {
base_per_turn: block.base_per_turn,
per_extra_unit: block.per_extra_unit,
decay_per_turn: block.decay_per_turn,
loot_pct_bp,
},
resistance_by_tier,
})
}
/// Resistance threshold for `tier`, or `u32::MAX` (unbreakable) if the
/// tier is unauthored.
#[must_use]
pub fn resistance_for_tier(&self, tier: i32) -> u32 {
self.resistance_by_tier.get(&tier).copied().unwrap_or(u32::MAX)
}
}
/// Outcome of a single tick on one besieged lair, plus the post-tick state.
/// `state` is `None` when the entry should be dropped from
/// `GameState::siege_pressure` (cleared on Surrender, or decayed to zero).
pub struct TickResult {
pub event: LairSiegeEvent,
pub keep_state: Option<SiegeState>,
}
/// Tick one lair's siege for this turn given whether a besieger is adjacent
/// and how many besieging units are present.
///
/// Pure (no `GameState` borrow) so it is unit-testable in isolation; the
/// processor phase iterates `siege_pressure` and calls this per entry. The
/// loot table (the lair tier's `tier_NN.json` `loot_table`) is supplied by the
/// caller — `mc-combat`/this fn do no IO.
#[must_use]
pub fn tick_one_lair(
turn: u32,
lair_col: i32,
lair_row: i32,
lair_tier: i32,
mut state: SiegeState,
besieger_stack_size: u32,
besieger_id: u32,
turn_seed: u64,
config: &LairSiegeConfig,
loot_table: &[LootEntry],
) -> TickResult {
let tuning = config.tuning.to_tuning();
let outcome = if besieger_stack_size > 0 {
tick_siege(&mut state, besieger_stack_size, &tuning)
} else {
decay_siege(&mut state, &tuning)
};
let mut loot: Vec<(String, u32)> = Vec::new();
let keep_state = match outcome {
SiegeOutcome::Surrender => {
let surrender = roll_surrender_loot(
&mc_core::lair::LairId::new(format!("lair_{lair_col}_{lair_row}")),
loot_table,
turn_seed,
besieger_id,
&tuning,
);
loot = surrender.drops.into_iter().map(|d| (d.resource, d.amount)).collect();
// Lair surrenders → drop the entry (lair cleared).
None
}
SiegeOutcome::Decayed if state.pressure.get() == 0 => None,
_ => Some(state.clone()),
};
TickResult {
event: LairSiegeEvent {
turn,
lair_col,
lair_row,
pressure: state.pressure.get(),
resistance: state.resistance,
outcome,
loot,
},
keep_state,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> LairSiegeConfig {
LairSiegeConfig::default()
}
#[test]
fn config_loads_from_lair_combat_modes_json() {
let raw = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../../public/resources/ecology/fauna/lair_combat_modes.json"
));
// The workspace-relative path differs per checkout depth; fall back to
// the embedded default shape if the file is not reachable from here.
if let Ok(raw) = raw {
let c = LairSiegeConfig::from_lair_combat_modes_json(&raw).expect("parse");
assert_eq!(c.tuning.base_per_turn, 25);
assert_eq!(c.tuning.loot_pct_bp, 5000, "0.5 → 5000 bp");
assert_eq!(c.resistance_for_tier(1), 50);
assert_eq!(c.resistance_for_tier(10), 700);
}
}
#[test]
fn config_parses_inline_block() {
let raw = r#"{
"combat_modes": [
{ "id": "assault" },
{ "id": "siege", "siege_pressure": {
"base_per_turn": 25, "per_extra_unit": 10, "decay_per_turn": 15,
"siege_loot_pct": 0.5,
"resistance_by_tier": { "1": 50, "3": 100 }
}}
]
}"#;
let c = LairSiegeConfig::from_lair_combat_modes_json(raw).expect("parse");
assert_eq!(c.tuning.base_per_turn, 25);
assert_eq!(c.tuning.loot_pct_bp, 5000);
assert_eq!(c.resistance_for_tier(3), 100);
assert_eq!(c.resistance_for_tier(2), u32::MAX, "unauthored tier is unbreakable");
}
#[test]
fn missing_siege_block_is_an_error() {
let raw = r#"{"combat_modes":[{"id":"assault"}]}"#;
assert!(LairSiegeConfig::from_lair_combat_modes_json(raw).is_err());
}
#[test]
fn pressure_accumulates_then_surrenders() {
// Tier-1 lair, resistance 50, single besieger adds 25/turn.
let mut state = SiegeState::new(cfg().resistance_for_tier(1));
let loot = vec![LootEntry { resource: "flint".into(), amount: 4, chance: 1.0 }];
// Turn 1: 25 → continuing.
let r1 = tick_one_lair(1, 5, 5, 1, state.clone(), 1, 7, 42, &cfg(), &loot);
assert_eq!(r1.event.outcome, SiegeOutcome::Continuing);
assert_eq!(r1.event.pressure, 25);
state = r1.keep_state.expect("siege continues");
// Turn 2: 50 → surrender, entry dropped, loot rolled.
let r2 = tick_one_lair(2, 5, 5, 1, state, 1, 7, 42, &cfg(), &loot);
assert_eq!(r2.event.outcome, SiegeOutcome::Surrender);
assert!(r2.keep_state.is_none(), "surrendered lair drops its siege entry");
assert!(
r2.event.loot.iter().any(|(res, amt)| res == "flint" && *amt == 2),
"0.5 loot_pct halves the guaranteed flint 4 → 2: {:?}",
r2.event.loot
);
}
#[test]
fn pressure_decays_when_unattended() {
// Seed mid-siege pressure, then tick with no besieger.
let mut state = SiegeState::new(cfg().resistance_for_tier(1));
state.pressure = mc_core::lair::SiegePressure::new(20);
// Unattended (stack_size 0): decay 15 → 5, entry kept.
let r1 = tick_one_lair(1, 5, 5, 1, state, 0, 0, 42, &cfg(), &[]);
assert_eq!(r1.event.outcome, SiegeOutcome::Decayed);
assert_eq!(r1.event.pressure, 5);
let state = r1.keep_state.expect("non-zero pressure entry is kept");
// Next unattended tick: 5 - 15 saturates to 0, entry dropped.
let r2 = tick_one_lair(2, 5, 5, 1, state, 0, 0, 42, &cfg(), &[]);
assert_eq!(r2.event.outcome, SiegeOutcome::Decayed);
assert_eq!(r2.event.pressure, 0);
assert!(r2.keep_state.is_none(), "fully decayed siege drops its entry");
}
}

View file

@ -33,6 +33,7 @@ pub mod combat_event;
pub mod processor;
pub mod prologue;
pub mod quality;
pub mod lair_siege;
pub mod spatial_index;
pub mod victory;
pub mod courier_resolver;
@ -55,6 +56,9 @@ pub use action::{legal_actions, ActionAvailability, ActionKind, DisabledReason,
pub use action_handlers::{invoke as invoke_action, ActionError};
pub use chronicle::{Chronicle, ChronicleEntry};
pub use quality::{apply_quality, band_name, resolve_deltas, UnitQualityChain};
pub use lair_siege::{
tick_one_lair, LairSiegeConfig, LairSiegeEvent, SiegeTuningData, TickResult,
};
pub use game_state::{AttackRequest, BombardRequest, BuildingRallyPoint, ChargeRequest, CityEcology, EscortRequest, GameState, MapUnit, MoveRequest, PillageRequest, PlayerState, TechState, VolleyRequest};
pub use mc_core::improvement::{RawImprovementJson, TileImprovement, TileImprovementSpec};
pub use capture::{resolve_posture, CapturePosture, PromptUnresolved};

View file

@ -288,6 +288,23 @@ pub fn authored_encounter_rates() -> &'static EncounterRates {
})
}
/// Canonical authored lair-siege config (p3-10b), embedded from
/// `public/resources/ecology/fauna/lair_combat_modes.json` at build time and
/// parsed once — same Rail-2 rationale as [`authored_encounter_rates`]
/// (headless / WASM-safe, no runtime filesystem dependency). Backs the
/// per-turn `process_lair_sieges` phase.
pub fn authored_lair_siege_config() -> &'static crate::lair_siege::LairSiegeConfig {
static CFG: OnceLock<crate::lair_siege::LairSiegeConfig> = OnceLock::new();
CFG.get_or_init(|| {
const JSON: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../../public/resources/ecology/fauna/lair_combat_modes.json"
));
crate::lair_siege::LairSiegeConfig::from_lair_combat_modes_json(JSON)
.expect("authored lair_combat_modes.json must carry a siege.siege_pressure block")
})
}
impl TurnProcessor {
/// Construct a processor with default balance config. The `max_turns` is
/// advisory — callers typically drive the loop externally and use
@ -507,6 +524,13 @@ impl TurnProcessor {
// city HP pool.
self.process_siege(state, &mut result);
// Phase 5d (p3-10b): lair siege — tick pressure on every active
// `SiegeState` in `state.siege_pressure`. Besieger-adjacent lairs gain
// pressure (`tick_siege`); unattended lairs decay (`decay_siege`).
// No-op unless a siege has been initiated (no live initiation surface
// yet — see `lair_siege` module docs).
self.process_lair_sieges(state, &mut result);
// Phase 7: victory check — use full VictoryConfig when available,
// otherwise fall back to simple city-count check.
if let Some(ref vc) = self.victory_config {
@ -3515,6 +3539,91 @@ impl TurnProcessor {
// ── Phase 5c: City siege ──────────────────────────────────────────────
/// p3-10b: per-turn lair-siege tick. For every active `SiegeState` in
/// `state.siege_pressure`, count besieging units (any unit adjacent to the
/// lair tile) and tick pressure via `mc_combat::tick_siege`; with no
/// besieger adjacent, decay via `decay_siege`. On `Surrender` the entry is
/// dropped (lair cleared) and surrender loot logged; a fully-decayed entry
/// is dropped too. Every tick appends a `LairSiegeEvent` to
/// `result.lair_siege_log`.
///
/// The live phase carries no per-tier loot table (loot authoring is
/// surfaced through the GDExt bridge / the tier loot JSON the UI reads), so
/// surrender drops are empty here; the named `tick_one_lair` tests exercise
/// the loot path directly with an authored table. Besieger adjacency uses
/// the same offset hex check as city siege (`hex_adjacent`).
fn process_lair_sieges(&self, state: &mut GameState, result: &mut TurnResult) {
if state.siege_pressure.is_empty() {
return;
}
let config = authored_lair_siege_config();
let turn = state.turn;
// Snapshot every unit position once (lair siege is owner-agnostic — any
// adjacent stack besieges the wild lair).
let unit_positions: Vec<(u32, i32, i32)> = state
.players
.iter()
.flat_map(|p| p.units.iter().map(|u| (u.id, u.col, u.row)))
.collect();
let lair_tiles: Vec<(u16, u16)> = state.siege_pressure.keys().copied().collect();
for (lc, lr) in lair_tiles {
let (lair_col, lair_row) = (lc as i32, lr as i32);
// Besiegers = units adjacent to (or on) the lair tile.
let mut besieger_ids: Vec<u32> = unit_positions
.iter()
.filter(|(_, uc, ur)| {
(*uc == lair_col && *ur == lair_row)
|| hex_adjacent(*uc, *ur, lair_col, lair_row)
})
.map(|(id, _, _)| *id)
.collect();
besieger_ids.sort_unstable();
let stack_size = besieger_ids.len() as u32;
let besieger_id = besieger_ids.first().copied().unwrap_or(0);
let Some(state_entry) = state.siege_pressure.get(&(lc, lr)).cloned() else {
continue;
};
// Derive the lair tier from its authored resistance (reverse lookup)
// for the event payload; `0` when no tier matches (test-seeded
// custom resistance). Tier is event-only here — the resolver keys on
// the persisted `resistance`, not the tier.
let lair_tier = config
.resistance_by_tier
.iter()
.find(|(_, &r)| r == state_entry.resistance)
.map(|(&t, _)| t)
.unwrap_or(0);
let tick = crate::lair_siege::tick_one_lair(
turn,
lair_col,
lair_row,
lair_tier,
state_entry,
stack_size,
besieger_id,
// Deterministic per-turn seed; `roll_surrender_loot` mixes it
// further with the besieger + lair id.
state.game_rng_seed ^ u64::from(turn),
config,
&[], // live phase has no per-tier loot table loaded — see docs.
);
match tick.keep_state {
Some(s) => {
state.siege_pressure.insert((lc, lr), s);
}
None => {
state.siege_pressure.remove(&(lc, lr));
}
}
result.lair_siege_log.push(tick.event);
}
}
/// Resolve city siege. For each player's units, if a unit is adjacent to
/// or on an enemy city tile, deal damage to the city's HP pool. If city
/// HP reaches 0 and a melee unit occupies the tile, capture the city.

View file

@ -0,0 +1,154 @@
//! p3-10b — lair-siege per-turn phase driven through the LIVE processor.
//!
//! The unit-level pressure math (`tick_one_lair`) is covered in
//! `mc-turn/src/lair_siege.rs::tests`. This file proves the phase is actually
//! wired into `TurnProcessor::step` and mutates `GameState::siege_pressure`:
//!
//! - a besieger adjacent to a seeded lair tile accumulates pressure across
//! turns and eventually surrenders (entry dropped from `siege_pressure`);
//! - an unattended seeded lair decays its pressure and drops the entry.
//!
//! HONEST BOUNDARY: nothing in the live loop *initiates* a siege yet (a player
//! must pick Siege via the GDExt bridge / UI mode picker — godot-ui
//! follow-up). These tests hand-seed the `SiegeState` to exercise the wired
//! phase, exactly as the apply-quality boundary is exercised in p2-57c.
use mc_ai::evaluator::ScoringWeights;
use mc_city::CityState;
use mc_core::lair::{SiegePressure, SiegeState};
use mc_turn::{GameState, MapUnit, PlayerState, TurnProcessor};
use std::collections::BTreeMap;
/// A one-player fixture with a single unit at `unit_pos`. Production is left
/// idle so the only thing the step does of interest is the lair-siege phase.
fn fixture_with_unit(unit_pos: (i32, i32)) -> GameState {
let mut state = GameState::default();
let mut axes: BTreeMap<String, u8> = BTreeMap::new();
axes.insert("production".into(), 1);
let mut unit = MapUnit::default();
unit.id = 1;
unit.col = unit_pos.0;
unit.row = unit_pos.1;
unit.unit_id = "dwarf_warrior".into();
let ps = PlayerState {
player_index: 0,
gold: 100,
cities: vec![CityState::starter()],
unit_upkeep: vec![1],
strategic_axes: axes,
scoring_weights: ScoringWeights::default(),
expansion_points: 0,
city_buildings: vec![vec![]],
city_improvements: Default::default(),
city_ecology: vec![Default::default()],
tech_state: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
units: vec![unit],
city_positions: vec![(50, 50)],
capital_position: Some((50, 50)),
culture_total: 0,
culture_pool: mc_culture::CulturePool::default(),
arcane_lore_pop_deducted: false,
traded_luxuries: Default::default(),
relations: Default::default(),
strategic_ledger: Default::default(),
wonders_built: Default::default(),
explored_deposits: Default::default(),
..Default::default()
};
state.players.push(ps);
state.next_unit_id = 2;
state
}
#[test]
fn besieger_adjacent_accumulates_pressure_then_lair_surrenders() {
// Lair at (10,10); besieger adjacent at (11,10). Tier-1 resistance 50,
// single besieger adds 25/turn → surrender on the 2nd tick.
let mut state = fixture_with_unit((11, 10));
state.siege_pressure.insert((10, 10), SiegeState::new(50));
let processor = TurnProcessor::new(200);
// Turn 1: pressure 25, entry persists.
let r1 = processor.step(&mut state);
assert_eq!(r1.lair_siege_log.len(), 1, "the phase logged one lair-siege tick");
assert_eq!(r1.lair_siege_log[0].pressure, 25);
assert!(
state.siege_pressure.contains_key(&(10, 10)),
"siege continues — entry still present",
);
// Turn 2: pressure 50 ≥ resistance → surrender, entry dropped.
let r2 = processor.step(&mut state);
assert_eq!(
r2.lair_siege_log[0].outcome,
mc_core::lair::SiegeOutcome::Surrender,
);
assert!(
!state.siege_pressure.contains_key(&(10, 10)),
"surrendered lair's siege entry is removed from GameState",
);
}
#[test]
fn unattended_lair_decays_and_entry_is_dropped() {
// Besieger far away (no adjacency) — the seeded pressure decays.
let mut state = fixture_with_unit((0, 0));
let mut seeded = SiegeState::new(50);
seeded.pressure = SiegePressure::new(20);
state.siege_pressure.insert((40, 40), seeded);
let processor = TurnProcessor::new(200);
// Turn 1: decay 15 → 5, entry persists.
let r1 = processor.step(&mut state);
assert_eq!(
r1.lair_siege_log[0].outcome,
mc_core::lair::SiegeOutcome::Decayed,
);
assert_eq!(r1.lair_siege_log[0].pressure, 5);
assert!(state.siege_pressure.contains_key(&(40, 40)));
// Turn 2: 5 - 15 saturates to 0 → entry dropped.
let _r2 = processor.step(&mut state);
assert!(
!state.siege_pressure.contains_key(&(40, 40)),
"fully-decayed lair siege entry is removed",
);
}
#[test]
fn no_siege_state_is_a_noop() {
let mut state = fixture_with_unit((0, 0));
let processor = TurnProcessor::new(200);
let r = processor.step(&mut state);
assert!(r.lair_siege_log.is_empty(), "no siege entries → no lair-siege log");
}
/// Save/load round-trip of `GameState::siege_pressure` — the serde adapter was
/// already present; this confirms a mid-siege state survives a JSON round-trip
/// (p3-10b persistence bullet).
#[test]
fn siege_state_survives_save_load_round_trip() {
let mut state = fixture_with_unit((11, 10));
let mut seeded = SiegeState::new(100);
seeded.pressure = SiegePressure::new(37);
seeded.turns_under_siege = 3;
state.siege_pressure.insert((10, 10), seeded.clone());
let json = serde_json::to_string(&state).expect("serialize");
let restored: GameState = serde_json::from_str(&json).expect("deserialize");
let got = restored
.siege_pressure
.get(&(10, 10))
.expect("siege entry round-trips through save format");
assert_eq!(got.pressure.get(), 37);
assert_eq!(got.resistance, 100);
assert_eq!(got.turns_under_siege, 3);
}