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:
parent
7d4bfc38dc
commit
01e190f406
3 changed files with 137 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue