//! 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, assault: Vec, 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, 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, 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, // --- 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, pub winner: usize, pub margin: f64, pub claims: Vec, } #[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, maw_accum: f64, /// Deepest uncollapsed floor; 0 once all have collapsed. frontier: u32, players: Vec, 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 = 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) -> 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, ) { 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 = 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 = 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 = 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) -> GameResult { let players: Vec = 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 = 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] }