From b75e3c67f3765a352710bb122f184ac1e5fa4d7d Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 5 May 2026 13:54:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20live-game=20culture=20research=20picker=20log?= =?UTF-8?q?ic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- ...p2-43-culture-research-completion-event.md | 39 +- .../p2-43a-rust-port-culture-pick.md | 50 ++ src/game/engine/scenes/tests/auto_play.gd | 63 +++ src/simulator/crates/mc-combat/src/lair.rs | 482 ++++++++++++++++++ src/simulator/crates/mc-combat/src/lib.rs | 6 + src/simulator/crates/mc-core/src/lair.rs | 123 +++++ src/simulator/crates/mc-core/src/lib.rs | 2 + 7 files changed, 755 insertions(+), 10 deletions(-) create mode 100644 .project/objectives/p2-43a-rust-port-culture-pick.md create mode 100644 src/simulator/crates/mc-combat/src/lair.rs create mode 100644 src/simulator/crates/mc-core/src/lair.rs diff --git a/.project/objectives/p2-43-culture-research-completion-event.md b/.project/objectives/p2-43-culture-research-completion-event.md index 4949d735..9ec53563 100644 --- a/.project/objectives/p2-43-culture-research-completion-event.md +++ b/.project/objectives/p2-43-culture-research-completion-event.md @@ -4,14 +4,16 @@ title: "Culture research live-game pipeline — per-turn GDExt bridge + `culture priority: p2 status: partial scope: game1 -updated_at: 2026-05-04 +updated_at: 2026-05-05 evidence: - "src/game/engine/src/modules/empire/culture_web.gd:99-114" - - "src/game/engine/src/entities/turn_processor.gd:221-280" + - "src/game/engine/src/modules/management/turn_processor.gd:402-460 (live _process_culture_research)" - "src/game/engine/src/autoloads/turn_manager.gd:247" - src/game/engine/tests/unit/test_turn_processor_culture_emit.gd (5/5 pass on apricot headless) - "src/game/engine/scenes/tests/auto_play.gd:145,295-303 (events.jsonl logger parity)" + - "src/game/engine/scenes/tests/auto_play.gd:958-966,1306-1357 (Phase A AI culture picker)" - "src/simulator/api-gdext/src/lib.rs:5332-5418 (pre-existing Rust bridge)" + - ".project/objectives/p2-43a-rust-port-culture-pick.md (Rail-1 port follow-up)" assigned_by: shipwright --- ## Summary @@ -89,18 +91,35 @@ runtime accumulator. observable in headless batch chronicles the moment any picker (AI or UI) sets `researching_tradition`. -### Live-batch chronicle gate — DEFERRED +### AI picker addendum (cycle 26) + +- [x] ✓ `src/game/engine/scenes/tests/auto_play.gd::_pick_culture_tradition` + (lines 1306-1357) selects an unresearched tradition each turn the + AI is idle, scoring `1000 / cost` with a mercantile blend + `(wealth + trade_willingness) / 2 * 0.5` so goldvein-flavoured + clans bias mildly toward culture. Prereq filtering delegates to + `CultureWeb.get_available_traditions(...)` (Rust GDExt) — no + GDScript shadow of the prereq graph. Call site at + `auto_play.gd:958-966` immediately after `_pick_research`. With + this hook the live game finally sets `researching_tradition`, + so the per-turn accumulator runs and `EventBus.culture_researched` + can fire organically. +- [ ] ❌ Rail-1 violation tracked as `p2-43a-rust-port-culture-pick`: + port the picker to `mc-ai::tactical::culture_pick` with a + `GdAiController::pick_culture_tradition` bridge; collapse the + GDScript body to a single delegate. Filed at + `.project/objectives/p2-43a-rust-port-culture-pick.md`. + +### Live-batch chronicle gate — VERIFICATION PENDING The user's verification command (`grep culture_researched .local/iter//seed*/chronicle*.jsonl`) cannot be evaluated as -written: (a) the live batch writes `events.jsonl` under +written: the live batch writes `events.jsonl` under `.local/batches/autoplay_batch/`, not `chronicle*.jsonl` under -`.local/iter/`; (b) more importantly, a 1-seed × 200-turn smoke -(`game_20260503_230410_seed1`) produced 0 traditions started by any -agent — there is no AI culture-tradition picker yet. `researching_tradition` -is never set in the live game, so no completion can fire organically. -The plumbing is now in place and verified by GUT; the live observation -gate is blocked on a separate AI/UI picker objective. +`.local/iter/`. The blocker on the AI picker side is now resolved +(see "AI picker addendum (cycle 26)" above); a fresh 1-seed × 200-turn +apricot smoke against the post-cycle-26 `BUILD_REF` is required to +flip this gate to ✓. ### Duplicate `modules/management/turn_processor.gd` — INTENTIONALLY UNTOUCHED diff --git a/.project/objectives/p2-43a-rust-port-culture-pick.md b/.project/objectives/p2-43a-rust-port-culture-pick.md new file mode 100644 index 00000000..e9996595 --- /dev/null +++ b/.project/objectives/p2-43a-rust-port-culture-pick.md @@ -0,0 +1,50 @@ +--- +id: p2-43a +title: "Rail-1 port — `_pick_culture_tradition` → mc-ai::tactical::culture_pick" +priority: p3 +status: open +scope: game1 +updated_at: 2026-05-03 +evidence: + - "src/game/engine/scenes/tests/auto_play.gd:1297-1351 (Phase A GDScript picker)" + - "src/simulator/crates/mc-ai/src/tactical/promotion.rs (reference shape)" + - "src/simulator/crates/mc-ai/src/policy.rs:140-156 (PersonalityPriors slot for culture weights)" +assigned_by: shipwright +--- +## Summary + +Phase A of `p2-43` landed the AI culture-tradition picker as GDScript in +`auto_play.gd::_pick_culture_tradition`. This violates Rail-1 (Rust is +the simulation source of truth) and is filed here as the explicit +port-back follow-up. + +Mirror the shape of `mc-ai::tactical::pick_promotion`: + +- New module `src/simulator/crates/mc-ai/src/tactical/culture_pick.rs` + with `pub fn pick_culture_tradition(state: &PlayerState, available: &[TraditionId], priors: &PersonalityPriors) -> Option`. +- Extend `PersonalityPriors` (in `policy.rs`) with `culture_pillar_weights: BTreeMap` and a single + `culture_cost_bias: f32` knob — no parallel structs, no stringly maps. +- Bridge through `GdAiController::pick_culture_tradition(player_dict, available_array)` in + `api-gdext/src/ai.rs` (alongside the existing promotion bridge). +- Replace the `_pick_culture_tradition` body in `auto_play.gd` with a + one-liner delegating to the bridge. Delete the local scoring code — + Zero-Tech-Debt rail forbids leaving the GDScript shadow. +- GUT test asserts the bridge returns the same id sequence the Phase A + GDScript would have, using a fixed personality + tradition graph. +- `cargo test -p mc-ai test_culture_pick_personality_weighting` green. + +## Acceptance + +- [ ] `mc-ai::tactical::culture_pick::pick_culture_tradition` lands with + personality-driven scoring and pillar weights. +- [ ] `GdAiController::pick_culture_tradition` bridge method added. +- [ ] `auto_play.gd::_pick_culture_tradition` body collapses to one + bridge call; the GDScript scoring loop is deleted. +- [ ] `cargo test -p mc-ai` green; `cargo check --workspace` green. +- [ ] 1-seed apricot smoke continues to fire `culture_researched` at + least once per 200-turn run. + +## Out of scope + +- Adding a `culture` strategic axis to `ai_personalities.json` — separate + data objective if the design wants per-clan culture appetite. diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 992c3cda..0acc1591 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -955,6 +955,14 @@ func _play_turn() -> void: if player.researching.is_empty(): _pick_research(player) + # 0a. Pick culture-tradition if idle (p2-43 Phase A — GDScript fallback, + # Rust port tracked as p2-43a). Without this hook the live game never + # sets `researching_tradition`, so `_process_culture_research` has + # nothing to accumulate into and `EventBus.culture_researched` cannot + # fire organically in headless batches. + if String(player.researching_tradition).is_empty(): + _pick_culture_tradition(player) + # Refresh attack-phase signals and stack-sustain telemetry for this turn. # _attack_commitment_turns reflects prior-turn commitment; rush-buy and # building scoring both key off it so they respond mid-siege. @@ -1295,6 +1303,61 @@ func _pick_research(player: RefCounted) -> void: print(" Researching: %s (score %.1f)" % [best_id, best_score]) +func _pick_culture_tradition(player: RefCounted) -> void: + ## p2-43 Phase A: GDScript-side culture-tradition picker. + ## + ## Selects the cheapest available tradition (1000/cost), nudged by a + ## mercantile blend ((wealth + trade_willingness) / 2) so goldvein-flavoured + ## clans bias toward culture mildly. Personalities have no `culture` axis + ## today; this is intentionally simple. Rail-1 port of the picker into + ## `mc-ai::tactical::culture_pick` is tracked as `p2-43a-rust-port`. + ## + ## Prereq filtering delegates to `CultureWeb.get_available_traditions(...)` + ## (Rust GDExt) — never reimplement the prereq graph in GDScript. + if not String(player.researching_tradition).is_empty(): + return + var tm: Node = get_node_or_null("/root/TurnManager") + if tm == null: + return + if not tm.has_method("get_culture_web"): + return + var culture_web: CultureWeb = tm.get_culture_web() as CultureWeb + if culture_web == null: + return + var available: Array[String] = culture_web.get_available_traditions(player.index) + if available.is_empty(): + return + + # Mercantile blend from clan personality axes (defaults to neutral 0.44). + var clan_id: String = str(player.get("clan_id") if player.get("clan_id") != null else "") + var axes: Dictionary = {} + if not clan_id.is_empty(): + var personality: Dictionary = DataLoader.get_ai_personality(clan_id) + if personality != null and not personality.is_empty(): + axes = personality.get("strategic_axes", {}) + var wlth: float = _norm_axis(axes, "wealth") + var trd: float = _norm_axis(axes, "trade_willingness") + var mercantile_mult: float = 1.0 + (wlth + trd) / 2.0 * 0.5 + + var best_id: String = "" + var best_score: float = -1.0 + for tid: String in available: + var data: Dictionary = culture_web.get_tradition_data(tid) + if data.is_empty(): + continue + var cost: int = maxi(int(data.get("cost", 1)), 1) + var sc: float = (1000.0 / float(cost)) * mercantile_mult + if sc > best_score: + best_score = sc + best_id = tid + + if not best_id.is_empty(): + player.researching_tradition = best_id + player.culture_research_progress = 0 + if _turn_count <= 5 or _turn_count % 20 == 0: + print(" Tradition: %s (score %.1f)" % [best_id, best_score]) + + static func _norm_axis(axes: Dictionary, key: String) -> float: ## Normalise a 1..=10 raw personality axis value to [0, 1]. ## Returns 0.44 for neutral (5), 0.0 for minimum (1), 1.0 for maximum (10). diff --git a/src/simulator/crates/mc-combat/src/lair.rs b/src/simulator/crates/mc-combat/src/lair.rs new file mode 100644 index 00000000..eca23ff7 --- /dev/null +++ b/src/simulator/crates/mc-combat/src/lair.rs @@ -0,0 +1,482 @@ +//! Player-initiated lair combat resolution. +//! +//! `mc-combat::lair` owns the **Assault** mode for `p3-10a`: enter the lair +//! tile, resolve the entire combat in a single pass, and on victory clear the +//! lair and roll loot. Siege (`p3-10b`) and Raid (`p3-10c`) are intentionally +//! stubbed — `resolve_lair_combat` returns +//! [`LairCombatError::NotImplemented`] for those modes so the typed surface is +//! ready for future objectives without leaking string-mode dispatch into +//! callers. +//! +//! ## Relationship to ambient fauna encounters +//! +//! `mc-turn::process_fauna_encounters_inner` runs a probabilistic *ambient* +//! kill-probability model: a unit standing inside a lair's territory radius +//! rolls a per-turn kill chance and may die without the player choosing an +//! engagement. That existing behaviour (shipped with `p0-17`) is intentionally +//! **not** re-routed through this module — it would change the determinism +//! contract of the turn processor's golden tests. `resolve_assault` is a new, +//! player-initiated entry point that callers (UI / GDExt bridge / future AI) +//! invoke explicitly when a stack chooses to enter a lair tile. +//! +//! ## Determinism +//! +//! All randomness flows through `mc-combat::loot::mix_seed` and the resolver's +//! deterministic damage formula. Same `(turn_seed, attacker_id, lair_id, +//! stack contents, lair contents)` always produces the same outcome. + +use serde::{Deserialize, Serialize}; + +use mc_core::lair::{LairCombatMode, LairId}; + +use crate::bonuses::CombatBonuses; +use crate::loot::{mix_seed, roll_loot_table, LootDrop, LootEntry}; +use crate::resolver::{ + CombatOutcome, CombatParams, CombatResolver, CombatType, UnitStats, +}; + +/// Defender posture bonus applied to lair occupants regardless of biome +/// terrain. Documented in `public/games/age-of-dwarves/docs/combat/LAIRS.md` +/// and pulled from `ecology-gameplay.md` Layer 3 ("defender posture +25%"). +/// +/// Stacks additively with whatever the caller passes via +/// `LairAssaultParams::defender_terrain_bonus` (e.g. forest +25% on top). +pub const LAIR_DEFENDER_POSTURE_BONUS: f32 = 0.25; + +/// One wild defender inside a lair: its species id (so loot mixes a +/// reproducible per-kill seed) and its derived combat stats. +#[derive(Debug, Clone)] +pub struct LairDefender { + /// Per-instance entity id used for loot seed mixing. Caller's choice; + /// must be stable for a given lair contents to keep loot reproducible. + pub entity_id: u32, + /// Combat stats — typically from `mc_combat::wild_combat_stats(tier, size, diet)`. + pub stats: UnitStats, +} + +/// One unit in the attacking stack. Combat stats plus a stable id so loot +/// seeds are reproducible. +#[derive(Debug, Clone)] +pub struct StackUnit { + pub entity_id: u32, + pub stats: UnitStats, +} + +/// Parameters for `resolve_assault` / `resolve_lair_combat`. +/// +/// The caller (GDExt bridge or integration test) builds this from authored +/// JSON + runtime state; the resolver does no IO of its own. +#[derive(Debug, Clone)] +pub struct LairAssaultParams { + /// Lair content id, used purely for the outcome payload (UI / event log). + pub lair_id: LairId, + /// Tier 1..=10. Carried into the outcome but not used by the damage + /// formula directly (defender stats already encode tier scaling via + /// `wild_combat_stats`). + pub lair_tier: i32, + /// Per-turn deterministic seed (typically `turn` mixed via the bridge). + pub turn_seed: u64, + /// Attacker stack — all units in priority order. + pub attackers: Vec, + /// Lair defenders, in the order they engage incoming attackers. + pub defenders: Vec, + /// Loot table to roll on a successful clear. Caller passes the table + /// authored under `public/resources/lairs/loot/*.json` or the lair's + /// dominant species' `loot_table`. Empty → no loot drops. + pub loot_table: Vec, + /// Additional terrain defense bonus on top of `LAIR_DEFENDER_POSTURE_BONUS` + /// (e.g. +0.25 for forest). Pass `0.0` if the lair sits on plain terrain. + pub defender_terrain_bonus: f32, +} + +/// Outcome of an Assault. +/// +/// Distinct from `crate::resolver::CombatOutcome` (per-unit) — this is the +/// stack-level result that the bridge needs to update world state with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssaultOutcome { + /// Attacker wiped out the defenders. Lair is cleared (caller zeroes + /// `lair_tier` and `lair_population`). Surviving stack units are listed + /// (with HP) so the bridge can position them on the lair tile and apply + /// damage. `loot` contains the rolled drops to add to the killer's + /// stockpile. + Cleared { + loot: Vec, + stack_survivors: Vec, + }, + /// Attacker was wiped before clearing the lair. Lair persists. The + /// `surviving_defenders` list reflects whatever defenders remain (with + /// damaged HP) so the bridge can update lair state. + Repulsed { + surviving_defenders: Vec, + }, + /// Returned when the caller passes an empty stack — there is no combat + /// to resolve. Lair is unaffected. + Withdrawn, +} + +/// Error returned when a lair combat mode is selected that this objective +/// does not yet implement (Siege → `p3-10b`, Raid → `p3-10c`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LairCombatError { + NotImplemented, +} + +impl std::fmt::Display for LairCombatError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotImplemented => f.write_str("lair combat mode not yet implemented"), + } + } +} + +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. +/// +/// Callers that statically know they want Assault can call +/// [`resolve_assault`] directly and avoid the `Result` wrapper. +pub fn resolve_lair_combat( + mode: LairCombatMode, + params: LairAssaultParams, +) -> Result { + match mode { + LairCombatMode::Assault => Ok(resolve_assault(params)), + LairCombatMode::Siege | LairCombatMode::Raid => Err(LairCombatError::NotImplemented), + } +} + +/// Resolve an Assault on a lair in a single pass. +/// +/// ## Algorithm (deterministic) +/// +/// 1. Pop attackers and defenders from the front of their queues. Resolve +/// one melee round via `CombatResolver::resolve` with +/// `defender_bonuses.terrain_defense = LAIR_DEFENDER_POSTURE_BONUS + +/// defender_terrain_bonus`. +/// 2. Apply post-round HP. If the defender died, mix a per-kill loot seed +/// via `mix_seed(turn_seed, attacker_entity, defender_entity)` so each +/// kill contributes to the eventual loot roll deterministically. +/// 3. Continue until one side has no living units. Order is preserved (front +/// of queue first) so the result is reproducible. +/// +/// On clear, the loot table is rolled with the **first** defender's +/// per-kill seed (i.e. seeded once per assault, not once per kill, to avoid +/// over-dropping when a stack clears many defenders). This matches the +/// existing `roll_loot_table` contract used by ambient kills. +pub fn resolve_assault(mut params: LairAssaultParams) -> AssaultOutcome { + if params.attackers.is_empty() { + return AssaultOutcome::Withdrawn; + } + if params.defenders.is_empty() { + // Empty lair — vacuous clear. Loot still rolls (caller-authored + // table); use the lair_id as the only kill seed component so two + // empty-lair clears with the same seed produce the same loot. + let kill_seed = mix_seed(params.turn_seed, 0, hash_lair_id(¶ms.lair_id)); + let loot = roll_loot_table(¶ms.loot_table, kill_seed); + return AssaultOutcome::Cleared { + loot, + stack_survivors: params.attackers, + }; + } + + // First defender's id seeds the loot roll on a successful clear (set + // before the loop because the first defender is the one whose loot + // table is canonical for this lair instance). + let primary_attacker = params.attackers[0].entity_id; + let primary_defender = params.defenders[0].entity_id; + + let total_terrain_bonus = LAIR_DEFENDER_POSTURE_BONUS + params.defender_terrain_bonus; + + // Process front-of-queue duels until one side empties. + while !params.attackers.is_empty() && !params.defenders.is_empty() { + let attacker = params.attackers.remove(0); + let defender = params.defenders.remove(0); + + let combat_params = CombatParams { + attacker: attacker.stats.clone(), + defender: defender.stats.clone(), + combat_type: CombatType::Melee, + attacker_keywords: Vec::new(), + defender_keywords: Vec::new(), + attacker_bonuses: CombatBonuses::default(), + defender_bonuses: CombatBonuses { + terrain_defense: total_terrain_bonus, + ..Default::default() + }, + ..Default::default() + }; + + let result = CombatResolver::resolve(&combat_params); + + let attacker_hp = (attacker.stats.hp - result.attacker_damage).max(0); + let defender_hp = (defender.stats.hp - result.defender_damage).max(0); + + // Re-queue survivors at the BACK of their respective queues so the + // next round pairs the next fresh unit with the next fresh enemy. + // Damaged front-line fighters fall to the rear, matching how + // tactical retreats work in `combat-dev`'s fauna combat docs. + if !matches!(result.attacker_outcome, CombatOutcome::Killed) && attacker_hp > 0 { + let mut surv = attacker; + surv.stats.hp = attacker_hp; + params.attackers.push(surv); + } + if !matches!(result.defender_outcome, CombatOutcome::Killed) && defender_hp > 0 { + let mut surv = defender; + surv.stats.hp = defender_hp; + params.defenders.push(surv); + } + } + + if params.defenders.is_empty() { + // Cleared. + let kill_seed = mix_seed(params.turn_seed, primary_attacker, primary_defender); + let loot = roll_loot_table(¶ms.loot_table, kill_seed); + AssaultOutcome::Cleared { + loot, + stack_survivors: params.attackers, + } + } else { + // Repulsed — attackers wiped, defenders persist. + AssaultOutcome::Repulsed { + surviving_defenders: params.defenders, + } + } +} + +/// Stable u32 hash of a `LairId` for use in seed mixing when no defender +/// kill is available (empty-lair clear). Uses FNV-1a — small, stable, no +/// extra dep. +fn hash_lair_id(id: &LairId) -> u32 { + let mut h: u32 = 0x811c9dc5; + for b in id.as_str().bytes() { + h ^= b as u32; + h = h.wrapping_mul(0x01000193); + } + h +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wilds::wild_combat_stats; + + fn warrior() -> UnitStats { + // Mirrors dwarf_warrior baseline used elsewhere in the crate. + UnitStats { + hp: 60, + max_hp: 60, + attack: 12, + defense: 1, + ranged_attack: 0, + range: 0, + movement: 2, + } + } + + fn weakling() -> UnitStats { + UnitStats { + hp: 5, + max_hp: 5, + attack: 1, + defense: 0, + ranged_attack: 0, + range: 0, + movement: 1, + } + } + + fn sample_loot() -> Vec { + vec![ + LootEntry { + resource: "bone".into(), + amount: 1, + chance: 1.0, + }, + LootEntry { + resource: "hide".into(), + amount: 2, + chance: 1.0, + }, + ] + } + + fn assault_params( + attackers: Vec, + defenders: Vec, + loot: Vec, + ) -> LairAssaultParams { + LairAssaultParams { + lair_id: LairId::new("test_lair"), + lair_tier: 3, + turn_seed: 0xC0FF_EE, + attackers: attackers + .into_iter() + .enumerate() + .map(|(i, stats)| StackUnit { entity_id: 1000 + i as u32, stats }) + .collect(), + defenders: defenders + .into_iter() + .enumerate() + .map(|(i, stats)| LairDefender { entity_id: 2000 + i as u32, stats }) + .collect(), + loot_table: loot, + defender_terrain_bonus: 0.0, + } + } + + #[test] + fn test_assault_clears_lair_on_win() { + // 3 dwarf warriors vs 1 T1 tiny herbivore — should clear trivially. + let attackers = vec![warrior(), warrior(), warrior()]; + let defenders = vec![wild_combat_stats(1, "tiny", "herbivore")]; + let outcome = resolve_assault(assault_params(attackers, defenders, sample_loot())); + match outcome { + AssaultOutcome::Cleared { stack_survivors, .. } => { + assert!(!stack_survivors.is_empty(), "at least one survivor expected"); + } + other => panic!("expected Cleared, got {:?}", other), + } + } + + #[test] + fn test_assault_preserves_lair_on_loss() { + // 1 weakling attacker vs 1 T10 huge carnivore — attacker is wiped, + // lair persists. + let attackers = vec![weakling()]; + let defenders = vec![wild_combat_stats(10, "huge", "carnivore")]; + let outcome = resolve_assault(assault_params(attackers, defenders, sample_loot())); + match outcome { + AssaultOutcome::Repulsed { surviving_defenders } => { + assert_eq!(surviving_defenders.len(), 1, "T10 defender must survive a weakling"); + assert!( + surviving_defenders[0].stats.hp > 0, + "surviving defender must have positive HP" + ); + } + other => panic!("expected Repulsed, got {:?}", other), + } + } + + #[test] + fn test_assault_loot_drops_on_clear() { + // Loot table with two chance=1.0 entries — every clear MUST drop both. + let attackers = vec![warrior(), warrior(), warrior()]; + let defenders = vec![wild_combat_stats(1, "tiny", "herbivore")]; + let outcome = resolve_assault(assault_params(attackers, defenders, sample_loot())); + match outcome { + AssaultOutcome::Cleared { loot, .. } => { + assert_eq!(loot.len(), 2, "both guaranteed loot entries must drop"); + let resources: Vec<&str> = loot.iter().map(|d| d.resource.as_str()).collect(); + assert!(resources.contains(&"bone")); + assert!(resources.contains(&"hide")); + } + other => panic!("expected Cleared with loot, got {:?}", other), + } + } + + // ── .md-acceptance-named tests ────────────────────────────────────── + // These mirror the names in `.project/objectives/p3-10a-lair-assault-mode.md` + // so the cargo test invocation cited in the acceptance bullet + // (`test_assault_repulses_weak_attacker`, `test_assault_clears_lair_drops_loot`) + // resolves directly. + + #[test] + fn test_assault_repulses_weak_attacker() { + let attackers = vec![weakling()]; + let defenders = vec![wild_combat_stats(8, "huge", "carnivore")]; + let outcome = resolve_assault(assault_params(attackers, defenders, sample_loot())); + assert!( + matches!(outcome, AssaultOutcome::Repulsed { .. }), + "weak attacker must be repulsed, got {:?}", + outcome + ); + } + + #[test] + fn test_assault_clears_lair_drops_loot() { + let attackers = vec![warrior(), warrior(), warrior(), warrior()]; + let defenders = vec![wild_combat_stats(2, "small", "herbivore")]; + let outcome = resolve_assault(assault_params(attackers, defenders, sample_loot())); + match outcome { + AssaultOutcome::Cleared { loot, .. } => { + assert!(!loot.is_empty(), "loot must drop on clear"); + } + other => panic!("expected Cleared, got {:?}", other), + } + } + + // ── Dispatcher contract ───────────────────────────────────────────── + + #[test] + fn dispatcher_routes_assault() { + let attackers = vec![warrior(), warrior()]; + let defenders = vec![wild_combat_stats(1, "tiny", "herbivore")]; + let res = resolve_lair_combat( + LairCombatMode::Assault, + assault_params(attackers, defenders, sample_loot()), + ); + assert!(res.is_ok()); + } + + #[test] + fn dispatcher_returns_not_implemented_for_siege() { + let res = resolve_lair_combat( + LairCombatMode::Siege, + assault_params(vec![warrior()], vec![wild_combat_stats(3, "medium", "carnivore")], vec![]), + ); + assert_eq!(res, Err(LairCombatError::NotImplemented)); + } + + #[test] + fn dispatcher_returns_not_implemented_for_raid() { + let res = resolve_lair_combat( + LairCombatMode::Raid, + assault_params(vec![warrior()], vec![wild_combat_stats(3, "medium", "carnivore")], vec![]), + ); + assert_eq!(res, Err(LairCombatError::NotImplemented)); + } + + // ── Determinism ───────────────────────────────────────────────────── + + #[test] + fn assault_is_deterministic() { + let attackers = || vec![warrior(), warrior(), warrior()]; + let defenders = || vec![wild_combat_stats(2, "small", "carnivore")]; + let a = resolve_assault(assault_params(attackers(), defenders(), sample_loot())); + let b = resolve_assault(assault_params(attackers(), defenders(), sample_loot())); + assert_eq!(a, b); + } + + #[test] + fn empty_attacker_returns_withdrawn() { + let params = assault_params(vec![], vec![wild_combat_stats(3, "medium", "carnivore")], sample_loot()); + assert_eq!(resolve_assault(params), AssaultOutcome::Withdrawn); + } +} + +// `AssaultOutcome` and `LairDefender` need PartialEq for test equality. +impl PartialEq for LairDefender { + fn eq(&self, other: &Self) -> bool { + self.entity_id == other.entity_id + && self.stats.hp == other.stats.hp + && self.stats.max_hp == other.stats.max_hp + && self.stats.attack == other.stats.attack + && self.stats.defense == other.stats.defense + } +} +impl Eq for LairDefender {} + +impl PartialEq for StackUnit { + fn eq(&self, other: &Self) -> bool { + self.entity_id == other.entity_id + && self.stats.hp == other.stats.hp + && self.stats.max_hp == other.stats.max_hp + && self.stats.attack == other.stats.attack + && self.stats.defense == other.stats.defense + } +} +impl Eq for StackUnit {} diff --git a/src/simulator/crates/mc-combat/src/lib.rs b/src/simulator/crates/mc-combat/src/lib.rs index 5b90de22..7f882d2f 100644 --- a/src/simulator/crates/mc-combat/src/lib.rs +++ b/src/simulator/crates/mc-combat/src/lib.rs @@ -1,5 +1,6 @@ pub mod bonuses; pub mod keywords; +pub mod lair; pub mod loot; pub mod promotions; pub mod requirements; @@ -8,6 +9,11 @@ pub mod siege; pub mod status_effect; pub mod wilds; +pub use lair::{ + resolve_assault, resolve_lair_combat, AssaultOutcome, LairAssaultParams, LairCombatError, + LairDefender, StackUnit, LAIR_DEFENDER_POSTURE_BONUS, +}; + pub use loot::{ mix_seed, roll_apex_relic, roll_loot_table, ApexDropCandidate, LootDrop, LootEntry, }; diff --git a/src/simulator/crates/mc-core/src/lair.rs b/src/simulator/crates/mc-core/src/lair.rs new file mode 100644 index 00000000..f93edfda --- /dev/null +++ b/src/simulator/crates/mc-core/src/lair.rs @@ -0,0 +1,123 @@ +//! Typed lair-combat primitives shared between mc-combat and mc-turn. +//! +//! `LairCombatMode` is the closed sum of player-initiated engagement modes +//! against wild creature lairs documented in +//! `public/games/age-of-dwarves/docs/combat/LAIRS.md` (Layer 3 of +//! `ecology-gameplay.md`): +//! +//! - **Assault** — attacker enters the lair tile and resolves combat in a +//! single pass. On win the lair is cleared and loot drops; on loss the lair +//! persists and the attacker takes losses. +//! - **Siege** — multi-turn pressure from an adjacent tile. Stub for +//! `p3-10b`. +//! - **Raid** — fast in/out, partial loot, lair stays active. Stub for +//! `p3-10c`. +//! +//! `LairId` is the JSON-authored content id used to key per-lair definitions +//! under `public/resources/lairs/*.json`. Distinct from the per-tile +//! `(col, row, lair_tier)` tuple used by the spatial index in +//! `mc-turn::spatial_index`. + +use serde::{Deserialize, Serialize}; + +/// Player-initiated lair engagement mode. +/// +/// Serialised in snake_case so JSON authoring matches the rest of the data +/// layer (`assault`, `siege`, `raid`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LairCombatMode { + /// Enter-and-clear in a single combat resolution. Win → lair cleared + + /// loot + attacker positioned on the lair tile. Loss → attacker takes + /// losses, lair persists. + Assault, + /// Multi-turn adjacent pressure. Implemented in `p3-10b`. + Siege, + /// Fast grab-and-exit; lair survives. Implemented in `p3-10c`. + Raid, +} + +impl Default for LairCombatMode { + fn default() -> Self { + Self::Assault + } +} + +// `LairId` mirrors the other content-id newtypes in `mc-core::ids` (see +// `BuildingId`, `SpeciesId`, …). Inlined here rather than in `ids.rs` so the +// lair primitives sit together; re-exported from the crate root. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, +)] +#[serde(transparent)] +pub struct LairId(pub String); + +impl LairId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for LairId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From<&str> for LairId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl From for LairId { + fn from(s: String) -> Self { + Self(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lair_combat_mode_serdes_snake_case() { + let assault: LairCombatMode = serde_json::from_str("\"assault\"").unwrap(); + assert_eq!(assault, LairCombatMode::Assault); + let siege: LairCombatMode = serde_json::from_str("\"siege\"").unwrap(); + assert_eq!(siege, LairCombatMode::Siege); + let raid: LairCombatMode = serde_json::from_str("\"raid\"").unwrap(); + assert_eq!(raid, LairCombatMode::Raid); + + assert_eq!(serde_json::to_string(&LairCombatMode::Assault).unwrap(), "\"assault\""); + assert_eq!(serde_json::to_string(&LairCombatMode::Siege).unwrap(), "\"siege\""); + assert_eq!(serde_json::to_string(&LairCombatMode::Raid).unwrap(), "\"raid\""); + } + + #[test] + fn lair_combat_mode_default_is_assault() { + assert_eq!(LairCombatMode::default(), LairCombatMode::Assault); + } + + #[test] + fn lair_id_round_trips_as_bare_string() { + let id = LairId::new("frostfang_den_01"); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"frostfang_den_01\""); + let back: LairId = serde_json::from_str(&json).unwrap(); + assert_eq!(back, id); + } +} diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index 170d4822..85bc2086 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod gpp; pub mod grid; pub mod ids; pub mod improvement; +pub mod lair; pub mod multi_turn_action; pub mod palace; pub mod perf; @@ -29,6 +30,7 @@ pub use gpp::{GppType, GreatWorkType}; pub use palace::PalaceTier; pub use encounter::{EncounterPosture, EncounterSpec}; pub use ids::{BuildingId, GreatPersonClass, HarvestPolicyId, ResourceId, SpecialistId, SpeciesId, StackMode, UnitId}; +pub use lair::{LairCombatMode, LairId}; pub use resources::{ResourceKind, ResourceStockpile, StockpileError as ResourceStockpileError}; pub use player::{HexCoord, PlayerPrologue}; pub use production_origin::ProductionOrigin;