//! 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
            + self.cfg.cashout_extraction_bonus;
        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 {
            // Taken with no Last Stand. Still the worst exit (territory lost, no depth
            // dividend), but a "Desperate Strike" salvages a fraction of unbanked loot so
            // it isn't a humiliating zero — the playtest's most-requested change.
            let salvage = party.unbanked * self.cfg.taken_salvage_frac;
            let cm = self.comeback_mult(pi);
            self.players[pi].glory += salvage * cm;
            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]
}
