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:
parent
beac17f4e2
commit
05ad5fae86
7 changed files with 690 additions and 13 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
338
src/simulator/crates/mc-turn/src/lair_siege.rs
Normal file
338
src/simulator/crates/mc-turn/src/lair_siege.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
154
src/simulator/crates/mc-turn/tests/lair_siege_phase.rs
Normal file
154
src/simulator/crates/mc-turn/tests/lair_siege_phase.rs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue