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>
853 lines
31 KiB
Rust
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]
|
|
}
|