From 01e190f40646a3ff8b3a74ead34973208d98b4f9 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 4 Jun 2026 16:24:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(mc-turn):=20=E2=9C=A8=20Implement=20turn-b?= =?UTF-8?q?ased=20combat=20variants=20and=20processor=20logic=20for=20new?= =?UTF-8?q?=20event=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-turn/src/combat_event.rs | 7 +- src/simulator/crates/mc-turn/src/lib.rs | 4 + src/simulator/crates/mc-turn/src/processor.rs | 127 ++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/simulator/crates/mc-turn/src/combat_event.rs b/src/simulator/crates/mc-turn/src/combat_event.rs index 50235e4b..70f9922d 100644 --- a/src/simulator/crates/mc-turn/src/combat_event.rs +++ b/src/simulator/crates/mc-turn/src/combat_event.rs @@ -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, + /// 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, /// Total cities captured this turn. pub cities_captured: u32, /// Build attempts rejected by the strategic resource gate this turn. diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index df969c8b..0196ac6b 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -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}; diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 8b7cad73..ef793f53 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -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 = 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 { @@ -1321,6 +1345,20 @@ impl TurnProcessor { pi: usize, events: &mut Vec, ) { + // p1-29i: post-capture refound suppression. If this empire lost a city + // to capture within the last `cooldown_turns` turns, it may not found a + // *replacement* yet — giving an attacker a window to press the now + // near-undefended capital before the loser rebuilds (the p1-29h Phase-2 + // capture-stickiness gap). `cooldown_turns == 0` disables the lever. + let cooldown = state.combat_balance.refound_suppression.cooldown_turns; + if cooldown > 0 { + if let Some(lost_turn) = state.players[pi].last_city_lost_turn { + if state.turn.saturating_sub(lost_turn) < cooldown { + return; + } + } + } + let player = &mut state.players[pi]; if player.expansion_points < self.lair_combat_config.city_founding_cost { return; @@ -3515,6 +3553,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 = 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. @@ -3581,6 +3704,7 @@ impl TurnProcessor { // Process in reverse to avoid index invalidation within the same player. captures.sort_by(|a, b| b.2.cmp(&a.2)); captures.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2); + let capture_turn = state.turn; for (attacker_pi, defender_pi, city_idx) in captures { let defender = &mut state.players[defender_pi]; if city_idx < defender.cities.len() { @@ -3590,6 +3714,9 @@ impl TurnProcessor { // Mirrors GDScript `combat_utils.gd:118` so bench and live // engine agree on `cities_lost_total`. defender.cities_lost_total = defender.cities_lost_total.saturating_add(1); + // p1-29i: stamp the loss turn so `try_found_city` can enforce + // the post-capture refound cooldown. + defender.last_city_lost_turn = Some(capture_turn); let city = defender.cities.swap_remove(city_idx); let pos = if city_idx < defender.city_positions.len() { defender.city_positions.swap_remove(city_idx)