feat(mc-turn): Implement turn-based combat variants and processor logic for new event handling

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-04 16:24:26 -07:00
parent 7d4bfc38dc
commit 01e190f406
3 changed files with 137 additions and 1 deletions

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

@ -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 {
@ -1321,6 +1345,20 @@ impl TurnProcessor {
pi: usize,
events: &mut Vec<mc_replay::TurnEvent>,
) {
// 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<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.
@ -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)