feat(@projects/@magic-civilization): ✨ add live-game culture research picker logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
61c09dc5d0
commit
b75e3c67f3
7 changed files with 755 additions and 10 deletions
|
|
@ -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/<stamp>/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
|
||||
|
||||
|
|
|
|||
50
.project/objectives/p2-43a-rust-port-culture-pick.md
Normal file
50
.project/objectives/p2-43a-rust-port-culture-pick.md
Normal file
|
|
@ -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<TraditionId>`.
|
||||
- Extend `PersonalityPriors` (in `policy.rs`) with `culture_pillar_weights: BTreeMap<PillarId, f32>` 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.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
482
src/simulator/crates/mc-combat/src/lair.rs
Normal file
482
src/simulator/crates/mc-combat/src/lair.rs
Normal file
|
|
@ -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<StackUnit>,
|
||||
/// Lair defenders, in the order they engage incoming attackers.
|
||||
pub defenders: Vec<LairDefender>,
|
||||
/// 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<LootEntry>,
|
||||
/// 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<LootDrop>,
|
||||
stack_survivors: Vec<StackUnit>,
|
||||
},
|
||||
/// 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<LairDefender>,
|
||||
},
|
||||
/// 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<AssaultOutcome, LairCombatError> {
|
||||
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<LootEntry> {
|
||||
vec![
|
||||
LootEntry {
|
||||
resource: "bone".into(),
|
||||
amount: 1,
|
||||
chance: 1.0,
|
||||
},
|
||||
LootEntry {
|
||||
resource: "hide".into(),
|
||||
amount: 2,
|
||||
chance: 1.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn assault_params(
|
||||
attackers: Vec<UnitStats>,
|
||||
defenders: Vec<UnitStats>,
|
||||
loot: Vec<LootEntry>,
|
||||
) -> 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 {}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
123
src/simulator/crates/mc-core/src/lair.rs
Normal file
123
src/simulator/crates/mc-core/src/lair.rs
Normal file
|
|
@ -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<String>) -> 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<String> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue