reikhelm/deepfall/deepfall-core/src/game.rs
Parley Hatch 277c158378 feat(deepfall): board-game design + balance-by-bot simulator
DEEPFALL: a tabletop game mashing up 9 loved games (SmashUp, Dragon
Rampage, Blood Rage, Catan, EverQuest, Daggerfall, Eye of the Beholder,
Quarriors, SmallWorld). Core fusion = SmallWorld decline + Blood Rage
glorious death -> three exits (Cash Out / Press On / Worthy End) under a
rising Maw that collapses a reikhelm dungeon floor-by-floor.

- deepfall-core: pure deterministic engine (vendored ChaCha8, 11 tests)
- deepfall-sim: balance-by-bot sweeps -> CSV/JSON
- analysis/analyze.py: stdlib ASCII heatmap (no deps)
- DESIGN.md (rules + post-critique revisions), FINDINGS.md (9-iteration
  balance journey), README.md
- reikhelm-core/examples/sample_dungeon.rs: dumps a real generated board

Method: adversarial design critics -> build -> simulate -> tune. 9
iterations compressed combo spread 77.8 -> 32.5 pts (all 36 combos
viable). Fixed deep-content lockout (value rises with the Maw), the
multi-collapse feel-bad (1 collapse/round cap; feel-bad now 0.00/game),
and dead combos. Open finding: Cash-Out and Worthy-End are substitutes
for a glory-maximizing bot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:49:33 -06:00

853 lines
31 KiB
Rust

//! The DEEPFALL engine: pure, deterministic, headless. One `(Config, seed, roster)`
//! plays one full game and yields a [`GameResult`]. No I/O, no rendering.
//!
//! The turn resolution implements DESIGN.md Part B as revised by Part C. The within-turn
//! order is fixed and load-bearing: **roll (+press) → resolve Dread → move → claim →
//! loot → recruit → exit**. The Maw advances once, after all players' turns, capped at
//! `max_collapses_per_round` collapses (overflow carries in `maw_accum`).
use crate::config::Config;
use crate::content::*;
use crate::controller::{Exit, Strategy, View};
use crate::rng::Rng;
/// A control marker in a room.
#[derive(Clone, Copy, Debug)]
pub struct Marker {
pub owner: usize,
pub strength: i32,
pub fallen: bool,
}
/// A player's accumulated, not-yet-completed assault on a room (carries across turns).
#[derive(Clone, Copy, Debug)]
struct Assault {
owner: usize,
steel: i32,
cunning: i32,
}
#[derive(Clone, Debug)]
pub struct Room {
pub theme: Theme,
pub depth: u32,
/// Glory base in *room-value units*; collapse glory = `cfg.room_glory(base, depth)`
/// plus [`risen`].
pub base_value: f64,
/// Extra glory (already in glory units) that rose here from a deeper collapse.
pub risen: f64,
pub markers: Vec<Marker>,
assault: Vec<Assault>,
pub collapsed: bool,
}
impl Room {
fn glory(&self, cfg: &Config) -> f64 {
cfg.room_glory(self.base_value, self.depth) + self.risen
}
fn controlled(&self) -> bool {
self.markers.iter().any(|m| m.strength > 0)
}
fn assault_mut(&mut self, owner: usize) -> &mut Assault {
if let Some(i) = self.assault.iter().position(|a| a.owner == owner) {
&mut self.assault[i]
} else {
self.assault.push(Assault { owner, steel: 0, cunning: 0 });
self.assault.last_mut().unwrap()
}
}
}
#[derive(Clone, Debug)]
pub struct Floor {
pub depth: u32,
pub rooms: Vec<Room>,
pub collapsed: bool,
}
/// An active delve-company.
#[derive(Clone, Debug)]
pub struct Party {
pub lineage: Lineage,
pub calling: Calling,
lmods: LineageMods,
cmods: CallingMods,
pub bag: Vec<DieKind>,
pub floor: u32,
pub unbanked: f64,
pub deepest: u32,
pub last_stand: bool,
/// (depth, room index) of every room this party holds an active marker in.
held: Vec<(u32, usize)>,
drafted_round: u32,
}
impl Party {
fn new(lineage: Lineage, calling: Calling, floor: u32, round: u32) -> Self {
let lmods = lineage.mods();
let cmods = calling.mods();
Party {
bag: lmods.starting_bag.clone(),
lineage,
calling,
lmods,
cmods,
floor,
unbanked: 0.0,
deepest: floor,
last_stand: false,
held: vec![],
drafted_round: round,
}
}
}
#[derive(Clone, Debug)]
pub struct Player {
pub id: usize,
pub strategy: Strategy,
/// If set, this player always drafts this exact combo (for combo sweeps).
pub fixed_combo: Option<(Lineage, Calling)>,
pub glory: f64,
party: Option<Party>,
// --- per-game metrics ---
pub setbacks: u32,
pub cashouts: u32,
pub worthy_ends: u32,
pub takes_no_laststand: u32,
pub cashout_depth_sum: u32,
/// Combo actually played most recently (for win attribution).
pub last_combo: Option<(Lineage, Calling)>,
}
impl Player {
pub fn new(id: usize, strategy: Strategy, fixed_combo: Option<(Lineage, Calling)>) -> Self {
Player {
id,
strategy,
fixed_combo,
glory: 0.0,
party: None,
setbacks: 0,
cashouts: 0,
worthy_ends: 0,
takes_no_laststand: 0,
cashout_depth_sum: 0,
last_combo: None,
}
}
}
/// A single claim, recorded for the deep-content-reachability metric.
#[derive(Clone, Copy, Debug)]
pub struct ClaimEvent {
pub depth: u32,
pub owner: usize,
pub round: u32,
pub drafted_round: u32,
}
/// The outcome of one full game.
#[derive(Clone, Debug)]
pub struct GameResult {
pub rounds: u32,
pub players: Vec<PlayerResult>,
pub winner: usize,
pub margin: f64,
pub claims: Vec<ClaimEvent>,
}
#[derive(Clone, Debug)]
pub struct PlayerResult {
pub id: usize,
pub strategy: Strategy,
pub glory: f64,
pub setbacks: u32,
pub cashouts: u32,
pub worthy_ends: u32,
pub takes_no_laststand: u32,
pub mean_cashout_depth: f64,
pub combo: Option<(Lineage, Calling)>,
}
pub struct Game {
cfg: Config,
rng: Rng,
floors: Vec<Floor>,
maw_accum: f64,
/// Deepest uncollapsed floor; 0 once all have collapsed.
frontier: u32,
players: Vec<Player>,
round: u32,
}
impl Game {
/// Build a game. `roster` is one (strategy, optional fixed combo) per player.
pub fn new(cfg: Config, seed: u64, roster: &[(Strategy, Option<(Lineage, Calling)>)]) -> Self {
let rng = Rng::from_seed(seed);
let mut g = Game {
floors: Vec::new(),
maw_accum: 0.0,
frontier: cfg.floors,
players: roster
.iter()
.enumerate()
.map(|(i, (s, c))| Player::new(i, *s, *c))
.collect(),
round: 0,
rng,
cfg,
};
g.build_dungeon();
g
}
fn build_dungeon(&mut self) {
let mut trng = self.rng.fork("dungeon");
let floors = self.cfg.floors;
for depth in 1..=floors {
let mut rooms = Vec::new();
for r in 0..self.cfg.rooms_per_floor {
let theme = roll_theme(depth, floors, r, self.cfg.rooms_per_floor, &mut trng);
rooms.push(Room {
theme,
depth,
base_value: theme.stats().base_value,
risen: 0.0,
markers: vec![],
assault: vec![],
collapsed: false,
});
}
self.floors.push(Floor { depth, rooms, collapsed: false });
}
}
fn floor_idx(depth: u32) -> usize {
(depth - 1) as usize
}
/// Play to completion.
pub fn run(&mut self) -> GameResult {
let mut claims: Vec<ClaimEvent> = Vec::new();
while self.frontier >= 1 && self.round < self.cfg.max_rounds {
self.round += 1;
let mut round_dread = 0.0;
for pi in 0..self.players.len() {
round_dread += self.take_turn(pi, &mut claims);
}
self.advance_maw(round_dread);
}
// Any party still active at the end is force-resolved under the Take rule.
for pi in 0..self.players.len() {
if self.players[pi].party.is_some() {
self.take_party(pi, self.players[pi].party.as_ref().unwrap().floor);
}
}
self.finish(claims)
}
/// One player's turn. Returns the raw Dread this turn contributed to the Maw.
fn take_turn(&mut self, pi: usize, claims: &mut Vec<ClaimEvent>) -> f64 {
// Draft a fresh company if needed (re-entry near the frontier).
if self.players[pi].party.is_none() {
if self.frontier < 1 {
return 0.0;
}
let combo = self.draft_combo(pi);
let entry = self
.frontier
.saturating_sub(self.cfg.reentry_gap)
.max(1)
.min(self.frontier);
let party = Party::new(combo.0, combo.1, entry, self.round);
self.players[pi].last_combo = Some(combo);
self.players[pi].party = Some(party);
}
// --- ROLL (+ adaptive press-on) ---
let mut rng = self.rng.fork(&format!("p{pi}:r{}", self.round));
let (lmods, cmods) = {
let p = self.players[pi].party.as_ref().unwrap();
(p.lmods.clone(), p.cmods.clone())
};
let hand = (self.cfg.hand_size as i32 + lmods.hand_mod + cmods.hand_mod).max(1) as usize;
let mut bag = self.players[pi].party.as_ref().unwrap().bag.clone();
rng.shuffle(&mut bag);
let view = self.view(pi);
let intent = self.players[pi].strategy.intent(&view);
let mut sym = Symbols::default();
let mut cursor = 0usize;
let draw_hand = |sym: &mut Symbols, cursor: &mut usize, rng: &mut Rng| {
for _ in 0..hand {
if *cursor >= bag.len() {
break;
}
let die = bag[*cursor];
*cursor += 1;
sym.add_face(die.faces()[rng.d6()]);
}
};
draw_hand(&mut sym, &mut cursor, &mut rng);
let mut presses = 0;
while presses < intent.press_target
&& cursor < bag.len()
&& sym.dread < self.cfg.dread_threshold
{
draw_hand(&mut sym, &mut cursor, &mut rng);
presses += 1;
}
let raw_dread = sym.dread as f64 + presses as f64 * self.cfg.press_dread_push;
// Lineage symbol mods (clamped to >= 0).
sym.steel = (sym.steel + lmods.steel_mod).max(0);
sym.cunning = (sym.cunning + lmods.cunning_mod + cmods.cunning_bonus).max(0);
sym.faith = (sym.faith + lmods.faith_mod).max(0);
// Trinity is judged on what was produced this turn, before spending.
let trinity = sym.steel > 0 && sym.cunning > 0 && sym.faith > 0;
// --- DREAD ---
self.resolve_dread(pi, &mut sym, &lmods, &cmods);
// --- MOVE --- (may buy extra moves with Cunning then Mettle if diving)
let depth_before = self.players[pi].party.as_ref().unwrap().floor;
let base_moves = (1 + lmods.move_mod + cmods.move_mod).max(1);
let mut moves = base_moves;
let target = intent.target_depth.clamp(1, self.frontier);
if intent.dive_moves {
let dist = (depth_before as i32 - target as i32).abs();
let want = (dist - moves).max(0);
let from_cunning = want.min(sym.cunning);
sym.cunning -= from_cunning;
let from_mettle = (want - from_cunning).min(sym.mettle);
sym.mettle -= from_mettle;
moves += from_cunning + from_mettle;
}
{
let p = self.players[pi].party.as_mut().unwrap();
let mut f = p.floor as i32;
let t = target as i32;
let step = if t > f { 1 } else { -1 };
let mut left = moves;
while f != t && left > 0 {
f += step;
left -= 1;
}
p.floor = (f.clamp(1, self.frontier as i32)) as u32;
p.deepest = p.deepest.max(p.floor);
}
// --- CLAIM --- (assault accumulation + completion)
self.resolve_claim(pi, &mut sym, trinity, &lmods, &cmods, claims);
// --- LOOT --- (Greed + held-room production)
self.resolve_loot(pi, &sym, &cmods);
// --- RECRUIT ---
if intent.want_recruit {
let cost = (self.cfg.recruit_cost - cmods.recruit_discount).max(1);
if sym.mettle >= cost {
let p = self.players[pi].party.as_mut().unwrap();
p.bag.push(cmods.recruit_kind);
// (Mettle is spent here; `sym` is discarded at turn end, so no decrement.)
}
}
// --- EXIT ---
let view2 = self.view(pi);
match self.players[pi].strategy.exit(&view2) {
Exit::Stay => {}
Exit::SetLastStand => {
let elig = view2.holds_deep_room || cmods.free_last_stand;
if elig {
self.players[pi].party.as_mut().unwrap().last_stand = true;
} else {
// Ineligible: treat as a defensive Cash-Out instead of a no-op.
self.cash_out(pi);
}
}
Exit::CashOut => self.cash_out(pi),
}
raw_dread
}
fn resolve_dread(&mut self, pi: usize, sym: &mut Symbols, lmods: &LineageMods, cmods: &CallingMods) {
let mut dread = sym.dread;
if lmods.can_cancel_dread {
let cancel = dread.min(sym.faith + cmods.extra_dread_cancel);
// Faith is consumed first, then the Calling's free cancels.
let from_faith = cancel.min(sym.faith);
sym.faith -= from_faith;
dread -= cancel;
}
if lmods.dread_to_mettle {
sym.mettle += dread; // peril becomes fuel
dread = 0;
}
sym.dread = dread.max(0);
if sym.dread >= self.cfg.dread_threshold && !lmods.setback_immune {
self.players[pi].setbacks += 1;
let mut to_lose = (1 + lmods.setback_extra_loss) - if cmods.heal_setback { 1 } else { 0 };
to_lose = to_lose.max(0);
let p = self.players[pi].party.as_mut().unwrap();
let mut shed = 0;
while shed < to_lose && p.bag.len() > 2 {
// Drop the lowest-quality die.
if let Some((idx, _)) = p
.bag
.iter()
.enumerate()
.min_by_key(|(_, k)| k.quality())
{
p.bag.remove(idx);
shed += 1;
} else {
break;
}
}
if shed < to_lose {
p.unbanked = (p.unbanked - self.cfg.setback_loot).max(0.0);
}
}
}
fn resolve_claim(
&mut self,
pi: usize,
sym: &mut Symbols,
trinity: bool,
lmods: &LineageMods,
cmods: &CallingMods,
claims: &mut Vec<ClaimEvent>,
) {
let depth = self.players[pi].party.as_ref().unwrap().floor;
let fi = Self::floor_idx(depth);
// Pick a target room: prefer the highest-glory room this party can complete
// this turn (counting carried assault); else invest in the richest room.
let (mut best_complete, mut best_complete_g) = (None, -1.0);
let (mut best_any, mut best_any_g) = (None, -1.0);
{
let floor = &self.floors[fi];
for (ri, room) in floor.rooms.iter().enumerate() {
if room.collapsed {
continue;
}
let st = room.theme.stats();
let prior = room.assault.iter().find(|a| a.owner == pi);
let have_steel = prior.map(|a| a.steel).unwrap_or(0) + sym.steel;
let have_cunning = prior.map(|a| a.cunning).unwrap_or(0) + sym.cunning;
let eff_threat = (st.threat - lmods.threat_reduction).max(0);
let steel_ok = have_steel >= eff_threat;
let cunning_ok = have_cunning >= st.lock;
let trinity_ok = !st.needs_trinity || cmods.solo_vault || trinity;
let g = room.glory(&self.cfg);
if g > best_any_g {
best_any_g = g;
best_any = Some(ri);
}
if steel_ok && cunning_ok && trinity_ok && g > best_complete_g {
best_complete_g = g;
best_complete = Some(ri);
}
}
}
let target = best_complete.or(best_any);
let ri = match target {
Some(ri) => ri,
None => return,
};
let complete = best_complete == Some(ri);
let st = self.floors[fi].rooms[ri].theme.stats();
// Fold this turn's steel/cunning into the assault.
{
let room = &mut self.floors[fi].rooms[ri];
let a = room.assault_mut(pi);
a.steel += sym.steel;
a.cunning += sym.cunning;
}
sym.steel = 0;
sym.cunning = 0;
if !complete {
return; // progress carried; no marker yet
}
// Complete the claim: drop/strengthen a marker, clear our assault, take loot.
let (overflow, room_g, combat_room) = {
let room = &mut self.floors[fi].rooms[ri];
let a = room.assault_mut(pi);
let eff_threat = (st.threat - lmods.threat_reduction).max(0);
let steel_over = (a.steel - eff_threat).max(0);
let cunning_over = (a.cunning - st.lock).max(0);
a.steel = 0;
a.cunning = 0;
(steel_over + cunning_over, room.glory(&self.cfg), st.combat_room)
};
// Cap how far a single hard-pressed claim can snowball one marker (iter5:
// overflow cap 4 -> 2), so Warden's x2 stays strong without being a runaway.
let strength = (1 + overflow.min(2) + cmods.claim_strength_bonus) * cmods.marker_mult;
let already_held = {
let room = &mut self.floors[fi].rooms[ri];
if let Some(m) = room.markers.iter_mut().find(|m| m.owner == pi && !m.fallen) {
m.strength += strength;
true
} else {
room.markers.push(Marker { owner: pi, strength, fallen: false });
false
}
};
let drafted = self.players[pi].party.as_ref().unwrap().drafted_round;
if !already_held {
let p = self.players[pi].party.as_mut().unwrap();
p.held.push((depth, ri));
claims.push(ClaimEvent { depth, owner: pi, round: self.round, drafted_round: drafted });
}
// Pillage loot on claim (Reaver), scavenge bonus on combat rooms (Gnoll).
let mut bonus = cmods.claim_loot_bonus;
if combat_room {
bonus += lmods.combat_loot_bonus;
}
if bonus > 0.0 {
self.players[pi].party.as_mut().unwrap().unbanked += room_g * bonus * 0.25;
}
}
fn resolve_loot(&mut self, pi: usize, sym: &Symbols, cmods: &CallingMods) {
let depth = self.players[pi].party.as_ref().unwrap().floor;
let mut loot = sym.greed as f64 * self.cfg.loot_per_greed * self.cfg.loot_depth_mult(depth);
// Held-room production (the Catan engine), richer for Hierophant.
let held: Vec<(u32, usize)> = self.players[pi].party.as_ref().unwrap().held.clone();
for (d, ri) in held {
let fi = Self::floor_idx(d);
if let Some(room) = self.floors.get(fi).and_then(|f| f.rooms.get(ri)) {
if !room.collapsed {
loot += room.base_value * 0.15 * cmods.hold_loot_mult;
}
}
}
self.players[pi].party.as_mut().unwrap().unbanked += loot;
}
/// Cash Out: bank loot + depth dividend, convert held markers to Fallen, retire.
fn cash_out(&mut self, pi: usize) {
let party = self.players[pi].party.take().unwrap();
let raw = party.unbanked + self.cfg.cashout_depth_dividend * party.deepest as f64;
let banked = raw * self.comeback_mult(pi);
self.players[pi].glory += banked;
self.players[pi].cashouts += 1;
self.players[pi].cashout_depth_sum += party.deepest;
// Held active markers become permanent Fallen markers (strength 1). v2 iter5
// reverted the half-strength experiment: it let Warden's x2 markers persist as
// full-strength dead men and spiked it to a 56% blowout. Cashing out is now
// rewarded through the depth dividend (above), not through immortal territory.
for (d, ri) in party.held {
let fi = Self::floor_idx(d);
if let Some(room) = self.floors.get_mut(fi).and_then(|f| f.rooms.get_mut(ri)) {
if let Some(m) = room.markers.iter_mut().find(|m| m.owner == pi && !m.fallen) {
m.fallen = true;
m.strength = 1;
}
}
}
}
/// A party is Taken by the Maw on floor `f` (its floor collapsed).
fn take_party(&mut self, pi: usize, _f: u32) {
let party = match self.players[pi].party.take() {
Some(p) => p,
None => return,
};
if party.last_stand {
let cmods = &party.cmods;
let held_value: f64 = party
.held
.iter()
.filter_map(|&(d, ri)| {
let fi = Self::floor_idx(d);
self.floors.get(fi).and_then(|fl| fl.rooms.get(ri))
})
.filter(|r| !r.collapsed)
.map(|r| r.glory(&self.cfg))
.sum();
let raw = self.cfg.worthy_factor * party.deepest as f64 * cmods.worthy_mult
+ self.cfg.worthy_held_bonus * held_value;
let cm = self.comeback_mult(pi);
self.players[pi].glory += raw * cm;
self.players[pi].worthy_ends += 1;
// Denial: the Maw consumes the held rooms clean — salt them so no rival
// inherits the value at their later collapse.
for (d, ri) in &party.held {
let fi = Self::floor_idx(*d);
if let Some(room) = self.floors.get_mut(fi).and_then(|fl| fl.rooms.get_mut(*ri)) {
if !room.collapsed {
room.markers.clear();
room.base_value = 0.0;
room.risen = 0.0;
}
}
}
} else {
// The one true feel-bad: lose unbanked loot and all active territory.
self.players[pi].takes_no_laststand += 1;
for (d, ri) in &party.held {
let fi = Self::floor_idx(*d);
if let Some(room) = self.floors.get_mut(fi).and_then(|fl| fl.rooms.get_mut(*ri)) {
room.markers.retain(|m| !(m.owner == pi && !m.fallen));
}
}
}
}
fn advance_maw(&mut self, round_dread: f64) {
let speed = self.cfg.maw_base_rate
+ self.cfg.maw_accel * self.round as f64
+ self.cfg.maw_dread_push * round_dread;
self.maw_accum += speed;
let mut collapses = 0;
while self.maw_accum >= 1.0
&& self.frontier >= 1
&& collapses < self.cfg.max_collapses_per_round
{
self.maw_accum -= 1.0;
self.collapse(self.frontier);
self.frontier -= 1;
collapses += 1;
}
}
/// Collapse floor `f`: score room majorities, rise unclaimed value, Take occupants.
fn collapse(&mut self, f: u32) {
let fi = Self::floor_idx(f);
// 1) Score each room; collect risen value from uncontrolled rooms.
let mut risen_total = 0.0;
let awards: Vec<(usize, f64)>;
{
let cfg = self.cfg.clone();
let floor = &mut self.floors[fi];
floor.collapsed = true;
let mut acc: Vec<(usize, f64)> = Vec::new();
for room in floor.rooms.iter_mut() {
let g = room.glory(&cfg);
if !room.controlled() {
risen_total += g * cfg.value_rise_frac;
continue;
}
// Majority by summed marker strength; ties split (floor by count).
let mut tally: Vec<(usize, i32)> = Vec::new();
for m in &room.markers {
if m.strength <= 0 {
continue;
}
if let Some(e) = tally.iter_mut().find(|(o, _)| *o == m.owner) {
e.1 += m.strength;
} else {
tally.push((m.owner, m.strength));
}
}
let top = tally.iter().map(|(_, s)| *s).max().unwrap_or(0);
let winners: Vec<usize> =
tally.iter().filter(|(_, s)| *s == top).map(|(o, _)| *o).collect();
if winners.is_empty() {
continue;
}
let share = g / winners.len() as f64;
for w in winners {
acc.push((w, share));
}
}
awards = acc;
}
for (owner, g) in awards {
self.players[owner].glory += g;
}
// 2) Risen plunder flows to the floor above (f-1), split across its rooms.
if risen_total > 0.0 && f >= 2 {
let up = Self::floor_idx(f - 1);
let live: Vec<usize> = self.floors[up]
.rooms
.iter()
.enumerate()
.filter(|(_, r)| !r.collapsed)
.map(|(i, _)| i)
.collect();
if !live.is_empty() {
let per = risen_total / live.len() as f64;
for i in live {
self.floors[up].rooms[i].risen += per;
}
}
}
// 3) Take any active party standing on this floor.
let occupants: Vec<usize> = self
.players
.iter()
.filter(|p| p.party.as_ref().map(|pt| pt.floor == f).unwrap_or(false))
.map(|p| p.id)
.collect();
for pi in occupants {
self.take_party(pi, f);
}
}
fn comeback_mult(&self, pi: usize) -> f64 {
if self.cfg.comeback_bp == 0 {
return 1.0;
}
let leader = self
.players
.iter()
.map(|p| p.glory)
.fold(0.0_f64, f64::max)
.max(1.0);
let deficit = (leader - self.players[pi].glory).max(0.0);
1.0 + (self.cfg.comeback_bp as f64 / 10_000.0) * (deficit / leader)
}
fn draft_combo(&self, pi: usize) -> (Lineage, Calling) {
if let Some(c) = self.players[pi].fixed_combo {
return c;
}
let mut rng = self.rng.fork(&format!("draft:p{pi}:r{}", self.round));
let l = *rng.choose(&ALL_LINEAGES).unwrap();
let c = *rng.choose(&ALL_CALLINGS).unwrap();
(l, c)
}
fn view(&self, pi: usize) -> View {
let leader = self.players.iter().map(|p| p.glory).fold(0.0_f64, f64::max);
let my = self.players[pi].glory;
let rank = 1 + self.players.iter().filter(|p| p.glory > my).count() as u32;
let cmods = self.players[pi]
.party
.as_ref()
.map(|p| p.cmods.clone());
let (has_party, floor, deepest, unbanked, bag_size, last_stand, held_count, held_value, holds_deep) =
if let Some(p) = self.players[pi].party.as_ref() {
let hv: f64 = p
.held
.iter()
.filter_map(|&(d, ri)| {
let fi = Self::floor_idx(d);
self.floors.get(fi).and_then(|fl| fl.rooms.get(ri))
})
.filter(|r| !r.collapsed)
.map(|r| r.glory(&self.cfg))
.sum();
let deep = p.held.iter().any(|&(d, ri)| {
let fi = Self::floor_idx(d);
self.floors
.get(fi)
.and_then(|fl| fl.rooms.get(ri))
.map(|r| matches!(r.theme, Theme::Vault | Theme::Throne))
.unwrap_or(false)
});
(true, p.floor, p.deepest, p.unbanked, p.bag.len() as u32, p.last_stand, p.held.len() as u32, hv, deep)
} else {
(false, 0, 0, 0.0, 0, false, 0, 0.0, false)
};
let collapses_until = if has_party && floor <= self.frontier {
self.frontier - floor + 1
} else {
self.cfg.floors + 1
};
let worthy_mult = cmods.as_ref().map(|c| c.worthy_mult).unwrap_or(1.0);
let worthy_estimate = self.cfg.worthy_factor * deepest as f64 * worthy_mult;
View {
round: self.round,
frontier: self.frontier,
floors_total: self.cfg.floors,
my_glory: my,
leader_glory: leader,
rank,
num_players: self.players.len() as u32,
has_party,
floor,
deepest,
unbanked,
bag_size,
last_stand,
held_count,
held_value,
holds_deep_room: holds_deep,
free_last_stand: cmods.map(|c| c.free_last_stand).unwrap_or(false),
worthy_estimate,
collapses_until_my_floor: collapses_until,
cashout_min_bank: self.cfg.cashout_min_bank,
cashout_depth_dividend: self.cfg.cashout_depth_dividend,
}
}
fn finish(&self, claims: Vec<ClaimEvent>) -> GameResult {
let players: Vec<PlayerResult> = self
.players
.iter()
.map(|p| PlayerResult {
id: p.id,
strategy: p.strategy,
glory: p.glory,
setbacks: p.setbacks,
cashouts: p.cashouts,
worthy_ends: p.worthy_ends,
takes_no_laststand: p.takes_no_laststand,
mean_cashout_depth: if p.cashouts > 0 {
p.cashout_depth_sum as f64 / p.cashouts as f64
} else {
0.0
},
combo: p.last_combo,
})
.collect();
// Winner = highest glory; lowest id breaks ties deterministically.
let mut best = 0usize;
for i in 1..players.len() {
if players[i].glory > players[best].glory {
best = i;
}
}
let mut sorted: Vec<f64> = players.iter().map(|p| p.glory).collect();
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
let margin = if sorted.len() >= 2 { sorted[0] - sorted[1] } else { 0.0 };
GameResult { rounds: self.round, players, winner: best, margin, claims }
}
}
/// Pick a room theme for floor `depth` of `floors`, biased richer with depth.
fn roll_theme(depth: u32, floors: u32, room_idx: u32, rooms_per_floor: u32, rng: &mut Rng) -> Theme {
// The deepest floor guarantees a Throne in its first room.
if depth == floors && room_idx == 0 {
return Theme::Throne;
}
// The top floor's first room is the safe Threshold mouth.
if depth == 1 && room_idx == 0 {
return Theme::Threshold;
}
let n = RICHNESS_ORDER.len() as i32;
// Target richness index scales with depth; jitter ±2 for variety.
let frac = if floors > 1 {
(depth as f64 - 1.0) / (floors as f64 - 1.0)
} else {
0.5
};
let center = (frac * (n as f64 - 1.0)).round() as i32;
let jitter = rng.range(-2, 3); // [-2, 2]
let mut idx = (center + jitter).clamp(1, n - 1); // never re-roll Threshold here
// Avoid stacking too many Thrones on the deepest floor.
if RICHNESS_ORDER[idx as usize] as i32 == Theme::Throne as i32 && room_idx != 0 {
idx -= 1;
}
let _ = rooms_per_floor;
RICHNESS_ORDER[idx as usize]
}