fertig-classic-games/src/games/dungeonboss/DungeonBossLogic.js

1376 lines
56 KiB
JavaScript

// DungeonBossLogic.js
// Pure (no Phaser) engine for Dungeon Boss, a Boss Monster adaptation.
// Pull model: after every act*() mutator the engine advances automatically
// (reveals, draws, bait, adventure walking) until it needs a decision —
// pendingDecision(state) says whose and what kind. Typed events accumulate in
// state.events; the scene/verify drain them with takeEvents().
//
// Deliberate simplifications from the tabletop game (noted where they apply):
// - Spells resolve at three cast windows (build start, post flip, pre-walk)
// instead of true any-time interrupts; Pit/Ramp/Cave-In "arm" a room and
// resolve when a hero enters it, which is where they'd be used anyway.
// - Counterspell chains don't recurse (a reaction can't itself be countered).
// - Vampire Bordello heals a wound without flipping it to a soul.
// - Trepidation keeps blocked heroes in town rather than parked at an entrance.
import {
CLASSES, BOSSES, ROOMS, SPELLS, HEROES, OPS,
heroSouls, heroWounds, buildDecks, makeInstance, resetUids,
roomDef, spellDef, heroDef,
} from './DungeonBossData.js';
export const SOULS_TO_WIN = 10;
export const WOUNDS_TO_DIE = 5;
export const MAX_ROOMS = 5;
const WINDOW_CAST_CAP = 20;
// ── RNG (mulberry32, seedable) ──────────────────────────────────────────────
export function makeRng(seed) {
let s = (seed >>> 0) || 1;
const fn = () => {
s |= 0; s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
fn.int = (n) => Math.floor(fn() * n);
fn.pick = (arr) => arr[fn.int(arr.length)];
fn.range = (lo, hi) => lo + fn.int(hi - lo + 1);
fn.shuffle = (arr) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = fn.int(i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; };
return fn;
}
// ── Game creation ───────────────────────────────────────────────────────────
export function newGame(nPlayers, seed = (Math.random() * 1e9) | 0) {
resetUids();
const rng = makeRng(seed);
const decks = buildDecks(rng, nPlayers);
const bossIds = rng.shuffle(Object.keys(BOSSES)).slice(0, nPlayers);
const players = [];
for (let seat = 0; seat < nPlayers; seat++) {
players.push({
seat,
alive: true,
boss: { id: bossIds[seat], leveledUp: false },
dungeon: [], // index 0 = boss-adjacent; entrance = last index
hand: { rooms: [], spells: [] },
entrance: [], // heroes queued to walk this round
souls: 0,
soulCards: [],
wounds: 0,
pendingBuild: undefined, // undefined = not asked/answered, null = pass
extraBuilds: 0,
roundMods: freshMods(),
});
}
const state = {
seed, rng, nPlayers, round: 0,
phase: 'setupDiscard',
decks: { ...decks, roomDiscard: [], spellDiscard: [] },
epicsActive: false,
town: [],
players,
// Highest XP acts first. Souls tie at game end: LOWEST XP wins.
turnOrder: players.map((p) => p.seat).sort((a, b) => BOSSES[players[b].boss.id].xp - BOSSES[players[a].boss.id].xp),
window: null,
reaction: null,
effectQueue: [],
adv: null,
roundNoBuild: false,
events: [],
gameOver: false,
winner: null,
setupBuilt: false,
};
for (const p of players) {
for (let i = 0; i < 5; i++) p.hand.rooms.push(state.decks.rooms.pop());
for (let i = 0; i < 2; i++) p.hand.spells.push(state.decks.spells.pop());
}
emit(state, { type: 'gameStart', bosses: players.map((p) => p.boss.id) });
return state;
}
function freshMods() {
return { allBoost: 0, doubleTreasure: false, blocked: false, extraBuildUsed: false };
}
function emit(state, ev) { state.events.push(ev); }
export function takeEvents(state) { const e = state.events; state.events = []; return e; }
// ── Small helpers ───────────────────────────────────────────────────────────
const P = (state, seat) => state.players[seat];
const aliveSeats = (state) => state.turnOrder.filter((s) => P(state, s).alive);
function handAll(p) { return [...p.hand.rooms, ...p.hand.spells]; }
function removeFromHand(p, uid) {
let i = p.hand.rooms.findIndex((c) => c.uid === uid);
if (i >= 0) return p.hand.rooms.splice(i, 1)[0];
i = p.hand.spells.findIndex((c) => c.uid === uid);
if (i >= 0) return p.hand.spells.splice(i, 1)[0];
return null;
}
function discardCard(state, inst) {
if (ROOMS[inst.id]) state.decks.roomDiscard.push(inst);
else state.decks.spellDiscard.push(inst);
}
function drawRooms(state, seat, n) {
const p = P(state, seat);
for (let i = 0; i < n; i++) {
if (!state.decks.rooms.length) reshuffleRooms(state);
if (!state.decks.rooms.length) return;
p.hand.rooms.push(state.decks.rooms.pop());
emit(state, { type: 'draw', seat, card: 'room' });
}
}
function drawSpells(state, seat, n) {
const p = P(state, seat);
for (let i = 0; i < n; i++) {
if (!state.decks.spells.length) reshuffleSpells(state);
if (!state.decks.spells.length) return;
p.hand.spells.push(state.decks.spells.pop());
emit(state, { type: 'draw', seat, card: 'spell' });
}
}
function reshuffleRooms(state) {
if (!state.decks.roomDiscard.length) return;
state.decks.rooms = state.rng.shuffle(state.decks.roomDiscard.filter((c) => ROOMS[c.id]));
state.decks.roomDiscard = [];
emit(state, { type: 'reshuffle', deck: 'rooms' });
}
function reshuffleSpells(state) {
if (!state.decks.spellDiscard.length) return;
state.decks.spells = state.rng.shuffle(state.decks.spellDiscard);
state.decks.spellDiscard = [];
emit(state, { type: 'reshuffle', deck: 'spells' });
}
function newSlot(roomInst) {
return { room: roomInst, under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null };
}
// ── Treasure / damage math ──────────────────────────────────────────────────
export function treasureCount(state, seat, cls) {
const p = P(state, seat);
let rooms = 0;
for (const slot of p.dungeon) {
if (slot.deactivated) continue;
rooms += (roomDef(slot.room).treasure || {})[cls] || 0;
}
if (p.roundMods.doubleTreasure) rooms *= 2;
return rooms + (BOSSES[p.boss.id].treasure === cls ? 1 : 0);
}
// Damage a hero takes in dungeon slot `idx` (entrance = dungeon.length-1).
export function roomDamage(state, seat, idx) {
const p = P(state, seat);
const slot = p.dungeon[idx];
if (!slot || slot.deactivated) return 0;
const def = roomDef(slot.room);
let dmg = def.dmg;
if (def.passive === 'ballroomDamage') {
dmg = p.dungeon.filter((s) => !s.deactivated && roomDef(s.room).type === 'monster').length;
}
if (def.type === 'monster') {
for (const j of [idx - 1, idx + 1]) {
const adj = p.dungeon[j];
if (adj && !adj.deactivated && roomDef(adj.room).passive === 'adjacentMonsterBoost') dmg += 1;
}
}
// Dizzygas: if the room the hero just left (idx+1, nearer the entrance) is an
// active Dizzygas Hallway and this room is a trap, +2.
const prev = p.dungeon[idx + 1];
if (def.type === 'trap' && prev && !prev.deactivated && roomDef(prev.room).passive === 'dizzygas') dmg += 2;
dmg += slot.tempDmg + p.roundMods.allBoost;
return Math.max(0, dmg);
}
// Total damage of a full walk — used by the AI to judge "can I kill this hero".
export function dungeonDamage(state, seat) {
let total = 0;
for (let i = P(state, seat).dungeon.length - 1; i >= 0; i--) total += roomDamage(state, seat, i);
return total;
}
// ── Legality helpers (shared by scene, AI, verify) ──────────────────────────
export function legalBuilds(state, seat) {
const p = P(state, seat);
const out = [];
for (const inst of p.hand.rooms) {
const def = roomDef(inst);
if (def.advanced) {
for (let idx = 0; idx < p.dungeon.length; idx++) {
if (canBuildOver(state, seat, def, idx)) out.push({ roomUid: inst.uid, slotIdx: idx });
}
} else {
if (p.dungeon.length < MAX_ROOMS) out.push({ roomUid: inst.uid, slotIdx: p.dungeon.length });
for (let idx = 0; idx < p.dungeon.length; idx++) out.push({ roomUid: inst.uid, slotIdx: idx });
}
}
return out;
}
function canBuildOver(state, seat, def, idx) {
const slot = P(state, seat).dungeon[idx];
if (!slot) return false;
const under = roomDef(slot.room);
if (under.passive === 'noAdvancedOnTop' && def.advanced) return false;
if (!def.advanced) return true;
return CLASSES.some((c) => (def.treasure[c] || 0) > 0 && (under.treasure[c] || 0) > 0);
}
function windowKindOk(effWindow, windowId) {
if (effWindow === 'any') return true;
if (effWindow === 'build') return windowId === 'buildStart' || windowId === 'postFlip';
return windowId === 'advStart';
}
function spellPhaseOk(def, windowId) {
if (def.phase === 'both') return true;
if (def.phase === 'build') return windowId === 'buildStart' || windowId === 'postFlip';
return windowId === 'advStart';
}
// All actions a seat could take in the current window.
export function windowActions(state, seat) {
if (!state.window || state.gameOver) return { spells: [], rooms: [] };
const p = P(state, seat);
if (!p.alive) return { spells: [], rooms: [] };
const windowId = state.window.id;
const spells = [];
for (const inst of p.hand.spells) {
const def = spellDef(inst);
if (def.op.op === 'counterSpell') continue; // reaction-only
if (!spellPhaseOk(def, windowId)) continue;
const targets = opCandidates(state, seat, def.op, { source: 'spell' });
if (targets === null || targets.length) spells.push({ uid: inst.uid, id: inst.id, targets });
}
const rooms = [];
for (let idx = 0; idx < p.dungeon.length; idx++) {
const slot = p.dungeon[idx];
if (slot.deactivated || slot.armed) continue;
const def = roomDef(slot.room);
for (let e = 0; e < (def.effects || []).length; e++) {
const eff = def.effects[e];
if (eff.trigger !== 'activated') continue;
if (!windowKindOk(eff.window, windowId)) continue;
if (eff.oncePerTurn && slot.usedOnce[e]) continue;
if (!costPayable(state, seat, eff.cost, idx)) continue;
const targets = opCandidates(state, seat, eff, { source: 'room', slotIdx: idx });
if (targets === null || targets.length) rooms.push({ slotIdx: idx, effIdx: e, targets });
}
}
return { spells, rooms };
}
function costPayable(state, seat, cost, slotIdx) {
if (!cost) return true;
const p = P(state, seat);
if (cost.discardRooms && p.hand.rooms.length < cost.discardRooms) return false;
if (cost.discardMonsterRoom && !p.hand.rooms.some((c) => roomDef(c).type === 'monster')) return false;
if (cost.discardSpell && !p.hand.spells.length) return false;
if (cost.destroyOther && p.dungeon.length < 2) return false;
return true;
}
// ── Op target candidates ────────────────────────────────────────────────────
// Returns null when the op needs no target, else an array of targetRefs
// (possibly empty = not castable). targetRef kinds:
// {kind:'hero',uid} {kind:'room',seat,slotIdx} {kind:'player',seat}
// {kind:'card',seat,uid} {kind:'soul',seat,uid} {kind:'deckHero',uid}
// {kind:'build',roomUid,slotIdx} {kind:'swap',seat,a,b}
export function opCandidates(state, seat, eff, ctx = {}) {
const p = P(state, seat);
const opps = aliveSeats(state).filter((s) => s !== seat);
switch (eff.op) {
case 'drawRoom': case 'drawSpell': case 'drawThenDiscard': case 'handReset':
case 'noBuildRound': case 'doubleTreasure':
case 'healWound': case 'pitTrap': case 'counterSpell':
return null;
case 'extraBuild':
if (eff.cond === 'fewerRooms' && !opps.some((s) => P(state, s).dungeon.length > p.dungeon.length)) return [];
return null;
case 'recoverDiscard': {
const pool = [];
if (eff.pool === 'any' || eff.pool === 'room') pool.push(...state.decks.roomDiscard);
if (eff.pool === 'monsterRoom') pool.push(...state.decks.roomDiscard.filter((c) => roomDef(c).type === 'monster'));
if (eff.pool === 'any' || eff.pool === 'spell') pool.push(...state.decks.spellDiscard);
return pool.map((c) => ({ kind: 'card', seat: -1, uid: c.uid }));
}
case 'revealTake': {
const out = [];
for (const s of opps) for (const c of handAll(P(state, s))) out.push({ kind: 'card', seat: s, uid: c.uid });
return out;
}
case 'stealRandom': case 'opponentDiscardRandom': {
const filter = eff.filter || 'any';
return opps.filter((s) => {
const o = P(state, s);
if (filter === 'spell') return o.hand.spells.length > 0;
if (filter === 'room') return o.hand.rooms.length > 0;
return handAll(o).length > 0;
}).map((s) => ({ kind: 'player', seat: s }));
}
case 'wreckEachOpponent':
return null; // queues per-opponent destroyOwnRoom decisions
case 'destroyOwnRoom':
return P(state, ctx.forSeat ?? seat).dungeon.map((_, i) => ({ kind: 'room', seat: ctx.forSeat ?? seat, slotIdx: i }));
case 'swapRooms': {
const out = [];
for (const s of aliveSeats(state)) {
const d = P(state, s).dungeon;
for (let a = 0; a < d.length; a++) for (let b = a + 1; b < d.length; b++) out.push({ kind: 'swap', seat: s, a, b });
}
return out;
}
case 'deactivateRoom': {
const out = [];
for (const s of aliveSeats(state)) {
P(state, s).dungeon.forEach((slot, i) => { if (!slot.deactivated) out.push({ kind: 'room', seat: s, slotIdx: i }); });
}
return out;
}
case 'tutorAdvanced': {
const seen = new Set();
const out = [];
for (const c of [...state.decks.rooms, ...state.decks.roomDiscard]) {
const def = roomDef(c);
if (!def.advanced || def.type !== eff.roomType || seen.has(c.id)) continue;
seen.add(c.id);
for (let idx = 0; idx < p.dungeon.length; idx++) {
if (canBuildOver(state, seat, def, idx)) out.push({ kind: 'build', roomUid: c.uid, slotIdx: idx });
}
}
return out;
}
case 'dmgBoostRoom': {
const out = [];
for (const s of aliveSeats(state)) {
P(state, s).dungeon.forEach((slot, i) => {
if (!slot.deactivated && roomDef(slot.room).type === eff.filter) out.push({ kind: 'room', seat: s, slotIdx: i });
});
}
return out;
}
case 'damageHero': case 'teleportHero':
return p.entrance.map((h) => ({ kind: 'hero', uid: h.uid }));
case 'healHero': {
const out = [];
for (const s of opps) for (const h of P(state, s).entrance) out.push({ kind: 'hero', uid: h.uid });
return out;
}
case 'fearHero': {
const out = [];
for (const s of aliveSeats(state)) for (const h of P(state, s).entrance) out.push({ kind: 'hero', uid: h.uid });
return out;
}
case 'killHeroTown':
return state.town.map((h) => ({ kind: 'hero', uid: h.uid }));
case 'lureHero': {
const out = state.town
.filter((h) => eff.filter !== 'ordinary' || !heroDef(h).epic)
.map((h) => ({ kind: 'hero', uid: h.uid }));
if (eff.from === 'townOrDeck') {
for (const h of [...state.decks.heroes, ...state.decks.epics]) out.push({ kind: 'deckHero', uid: h.uid });
}
return out;
}
case 'resurrectAttack': {
const out = [];
for (const s of opps) for (const h of P(state, s).soulCards) out.push({ kind: 'soul', seat: s, uid: h.uid });
return out;
}
case 'blockHeroes':
return opps.filter((s) => P(state, s).souls >= p.souls + 2).map((s) => ({ kind: 'player', seat: s }));
case 'sacrificeSoulDraw':
return p.soulCards.map((h) => ({ kind: 'soul', seat, uid: h.uid }));
case 'caveIn': case 'rampTrap': case 'dmgBoostAllRooms':
// caveIn: own room to collapse. rampTrap/Crushinator: the *other* own room
// to demolish (the sacrifice), never the activating room itself.
return p.dungeon.map((_, i) => ({ kind: 'room', seat, slotIdx: i }))
.filter((r) => !((eff.op === 'rampTrap' || eff.op === 'dmgBoostAllRooms') && r.slotIdx === ctx.slotIdx));
default:
return null;
}
}
// ── Decisions ───────────────────────────────────────────────────────────────
export function pendingDecision(state) {
if (state.gameOver) return null;
const q = state.effectQueue[0];
if (q) {
if (q.kind === 'discard') return { kind: 'discard', seat: q.seat, cardType: q.cardType, n: q.n };
if (q.kind === 'roomDraw') return { kind: 'roomDraw', seat: q.seat };
return { kind: 'target', seat: q.seat, op: q.eff.op, candidates: q.candidates, optional: !!q.eff.optional, source: q.source };
}
if (state.reaction) {
const r = state.reaction;
return { kind: 'react', seat: r.responders[r.idx], spellId: r.spellId, casterSeat: r.casterSeat, target: r.targetRef || null };
}
if (state.window) {
const seat = nextWindowSeat(state);
if (seat != null) return { kind: 'window', seat, window: state.window.id, advSeat: state.window.advSeat };
return null; // advance() will close it
}
if (state.phase === 'setupDiscard') {
const seat = aliveSeats(state).find((s) => !P(state, s).setupDiscarded);
if (seat != null) return { kind: 'setupDiscard', seat, n: 2 };
}
if (state.phase === 'setupBuild' || state.phase === 'build') {
const seat = aliveSeats(state).find((s) => P(state, s).pendingBuild === undefined);
if (seat != null) return { kind: 'build', seat, setup: state.phase === 'setupBuild' };
}
if (state.phase === 'extraBuild') {
const seat = aliveSeats(state).find((s) => P(state, s).extraBuilds > 0);
if (seat != null) return { kind: 'build', seat, extra: true };
}
return null;
}
function nextWindowSeat(state) {
const w = state.window;
if (w.casts >= WINDOW_CAST_CAP) return null;
for (const seat of aliveSeats(state)) {
if (w.passes.includes(seat)) continue;
const acts = windowActions(state, seat);
if (!acts.spells.length && !acts.rooms.length) { w.passes.push(seat); continue; }
return seat;
}
return null;
}
// ── Mutators ────────────────────────────────────────────────────────────────
function assertSeat(state, seat, kind) {
const d = pendingDecision(state);
if (!d || d.seat !== seat || d.kind !== kind) {
throw new Error(`illegal action: expected ${d ? `${d.kind} by seat ${d.seat}` : 'none'}, got ${kind} by ${seat}`);
}
return d;
}
export function actSetupDiscard(state, seat, uids) {
assertSeat(state, seat, 'setupDiscard');
if (!Array.isArray(uids) || uids.length !== 2) throw new Error('setupDiscard needs exactly 2 uids');
const p = P(state, seat);
for (const uid of uids) {
const c = removeFromHand(p, uid);
if (!c) throw new Error('setupDiscard: card not in hand');
discardCard(state, c);
}
p.setupDiscarded = true;
emit(state, { type: 'setupDiscard', seat });
advance(state);
}
export function actBuild(state, seat, choice) {
const d = assertSeat(state, seat, 'build');
const p = P(state, seat);
if (choice) {
const legal = legalBuilds(state, seat).some((b) => b.roomUid === choice.roomUid && b.slotIdx === choice.slotIdx);
if (!legal) throw new Error('illegal build');
} else if (d.setup && legalBuilds(state, seat).length) {
throw new Error('setup build is mandatory');
}
if (d.extra) {
p.extraBuilds--;
if (choice && !state.roundNoBuild) placeRoom(state, seat, choice.roomUid, choice.slotIdx);
} else {
p.pendingBuild = choice || null;
emit(state, { type: 'buildSubmitted', seat, pass: !choice });
}
advance(state);
}
export function actWindow(state, seat, action) {
assertSeat(state, seat, 'window');
const w = state.window;
if (!action || action.pass) {
w.passes.push(seat);
emit(state, { type: 'windowPass', seat });
advance(state);
return;
}
w.casts++;
w.passes = []; // an action reopens responses for everyone
if (action.spellUid != null) {
castSpell(state, seat, action.spellUid, action.target);
} else {
activateRoom(state, seat, action.slotIdx, action.effIdx, action);
}
advance(state);
}
export function actReact(state, seat, response) {
assertSeat(state, seat, 'react');
const r = state.reaction;
if (!response) {
r.idx++;
if (r.idx >= r.responders.length) resolveReaction(state, false);
} else {
const p = P(state, seat);
if (response.type === 'counterspell') {
const inst = p.hand.spells.find((c) => c.uid === response.spellUid && spellDef(c).op.op === 'counterSpell');
if (!inst) throw new Error('no counterspell in hand');
removeFromHand(p, inst.uid);
discardCard(state, inst);
} else if (response.type === 'allseeingeye') {
const slot = p.dungeon[response.slotIdx];
const def = slot && roomDef(slot.room);
const effIdx = def && (def.effects || []).findIndex((e) => e.trigger === 'reaction');
if (!slot || slot.deactivated || effIdx < 0 || slot.usedOnce[effIdx]) throw new Error('cannot react with that room');
const cost = p.hand.spells.find((c) => c.uid === response.discardSpellUid);
if (!cost) throw new Error('reaction needs a spell to discard');
removeFromHand(p, cost.uid);
discardCard(state, cost);
slot.usedOnce[effIdx] = true;
} else {
throw new Error('unknown reaction');
}
emit(state, { type: 'spellCountered', seat, casterSeat: r.casterSeat, spellId: r.spellId });
resolveReaction(state, true);
}
advance(state);
}
export function actChooseTarget(state, seat, targetRef) {
const d = assertSeat(state, seat, 'target');
const q = state.effectQueue[0];
if (!targetRef) {
if (!d.optional) throw new Error('target is required');
state.effectQueue.shift();
emit(state, { type: 'fizzle', seat, op: q.eff.op });
advance(state);
return;
}
const match = q.candidates.some((c) => sameRef(c, targetRef));
if (!match) throw new Error('target not in candidates');
state.effectQueue.shift();
applyOp(state, seat, q.eff, targetRef, q.ctx || {});
advance(state);
}
export function actDiscard(state, seat, uids) {
const d = assertSeat(state, seat, 'discard');
if (!Array.isArray(uids) || uids.length !== d.n) throw new Error(`discard needs exactly ${d.n} uids`);
const p = P(state, seat);
for (const uid of uids) {
const pool = d.cardType === 'spell' ? p.hand.spells : p.hand.rooms;
if (!pool.some((c) => c.uid === uid)) throw new Error('discard: card not in hand');
const c = removeFromHand(p, uid);
discardCard(state, c);
emit(state, { type: 'discard', seat, id: c.id });
}
state.effectQueue.shift();
advance(state);
}
export function actRoomDraw(state, seat, pick) {
assertSeat(state, seat, 'roomDraw');
state.effectQueue.shift();
if (pick === 'spell') drawSpells(state, seat, 1);
else drawRooms(state, seat, 1);
advance(state);
}
function sameRef(a, b) {
return a.kind === b.kind && a.uid === b.uid && a.seat === b.seat
&& a.slotIdx === b.slotIdx && a.roomUid === b.roomUid && a.a === b.a && a.b === b.b;
}
// ── Spells & activated rooms ────────────────────────────────────────────────
function castSpell(state, seat, spellUid, targetRef) {
const p = P(state, seat);
const inst = p.hand.spells.find((c) => c.uid === spellUid);
if (!inst) throw new Error('spell not in hand');
const def = spellDef(inst);
if (!spellPhaseOk(def, state.window.id)) throw new Error('wrong phase for spell');
const cands = opCandidates(state, seat, def.op, { source: 'spell' });
if (cands !== null) {
if (!targetRef || !cands.some((c) => sameRef(c, targetRef))) throw new Error('bad spell target');
}
removeFromHand(p, inst.uid);
emit(state, { type: 'spellCast', seat, id: inst.id, target: targetRef || null });
// Liger's Den: once a round, when you cast a spell, draw a spell.
p.dungeon.forEach((slot) => {
if (slot.deactivated) return;
(roomDef(slot.room).effects || []).forEach((eff, e) => {
if (eff.trigger === 'onCastSpell' && !slot.usedOnce[e]) { slot.usedOnce[e] = true; drawSpells(state, seat, eff.n); }
});
});
const responders = reactionResponders(state, seat);
if (responders.length) {
state.reaction = { casterSeat: seat, spellUid: inst.uid, spellId: inst.id, inst, eff: def.op, targetRef, responders, idx: 0 };
} else {
discardCard(state, inst);
applyOp(state, seat, def.op, targetRef, { source: 'spell' });
}
}
function reactionResponders(state, casterSeat) {
const out = [];
for (const seat of aliveSeats(state)) {
if (seat === casterSeat) continue;
const p = P(state, seat);
const hasCounter = p.hand.spells.some((c) => spellDef(c).op.op === 'counterSpell');
const hasEye = p.dungeon.some((slot) => {
if (slot.deactivated) return false;
return (roomDef(slot.room).effects || []).some((e, i) => e.trigger === 'reaction' && !slot.usedOnce[i]);
}) && p.hand.spells.length > 0;
if (hasCounter || hasEye) out.push(seat);
}
return out;
}
function resolveReaction(state, countered) {
const r = state.reaction;
state.reaction = null;
discardCard(state, r.inst);
if (!countered) applyOp(state, r.casterSeat, r.eff, r.targetRef, { source: 'spell' });
}
function activateRoom(state, seat, slotIdx, effIdx, action) {
const p = P(state, seat);
const slot = p.dungeon[slotIdx];
if (!slot || slot.deactivated || slot.armed) throw new Error('cannot activate that room');
const def = roomDef(slot.room);
const eff = (def.effects || [])[effIdx];
if (!eff || eff.trigger !== 'activated') throw new Error('no such activated ability');
if (!windowKindOk(eff.window, state.window.id)) throw new Error('wrong window');
if (eff.oncePerTurn && slot.usedOnce[effIdx]) throw new Error('already used this round');
if (!costPayable(state, seat, eff.cost, slotIdx)) throw new Error('cannot pay cost');
const cands = opCandidates(state, seat, eff, { source: 'room', slotIdx });
if (cands !== null && (!action.target || !cands.some((c) => sameRef(c, action.target)))) throw new Error('bad ability target');
emit(state, { type: 'roomActivated', seat, slotIdx, id: slot.room.id });
if (eff.oncePerTurn) slot.usedOnce[effIdx] = true;
payCost(state, seat, eff.cost, slotIdx, action);
applyOp(state, seat, eff, action.target, { source: 'room', slotIdx, slot });
// destroySelf sacrifices resolve after the effect so index-based targets stay
// valid. pitTrap keeps its room armed instead; it is destroyed on trigger (or
// at end of round if no hero ever enters).
if (eff.cost && eff.cost.destroySelf && eff.op !== 'pitTrap') destroySlotByRef(state, seat, slot);
}
function payCost(state, seat, cost, slotIdx, action = {}) {
if (!cost) return;
const p = P(state, seat);
if (cost.discardRooms) {
const uids = action.costUids || p.hand.rooms.slice(0, cost.discardRooms).map((c) => c.uid);
if (uids.length !== cost.discardRooms) throw new Error('cost: wrong discard count');
for (const uid of uids) {
const c = p.hand.rooms.find((x) => x.uid === uid);
if (!c) throw new Error('cost: room not in hand');
removeFromHand(p, uid); discardCard(state, c);
}
}
if (cost.discardMonsterRoom) {
const uid = action.costUids ? action.costUids[0] : (p.hand.rooms.find((c) => roomDef(c).type === 'monster') || {}).uid;
const c = p.hand.rooms.find((x) => x.uid === uid && roomDef(x).type === 'monster');
if (!c) throw new Error('cost: no monster room to discard');
removeFromHand(p, uid); discardCard(state, c);
}
if (cost.discardSpell) {
const uid = action.costUids ? action.costUids[0] : (p.hand.spells[0] || {}).uid;
const c = p.hand.spells.find((x) => x.uid === uid);
if (!c) throw new Error('cost: no spell to discard');
removeFromHand(p, uid); discardCard(state, c);
}
// destroySelf / destroyOther are handled inside the op (pitTrap, rampTrap,
// darkaltar etc.) so ordering with the effect is explicit.
}
// ── Effect application ──────────────────────────────────────────────────────
function queueEffect(state, seat, eff, ctx = {}) {
const candidates = opCandidates(state, seat, eff, ctx);
if (candidates === null) { applyOp(state, seat, eff, null, ctx); return; }
if (!candidates.length) { emit(state, { type: 'fizzle', seat, op: eff.op }); return; }
if (eff.auto) { applyOp(state, seat, eff, autoPick(state, eff, candidates), ctx); return; }
state.effectQueue.push({ kind: 'target', seat, eff, candidates, ctx, source: ctx.source || null });
}
// Deterministic auto-pick for effects flagged `auto` (Open Grave): best room by damage.
function autoPick(state, eff, candidates) {
if (eff.op === 'recoverDiscard') {
let best = candidates[0]; let bestDmg = -1;
for (const c of candidates) {
const inst = state.decks.roomDiscard.find((x) => x.uid === c.uid);
const d = inst ? roomDef(inst).dmg : -1;
if (d > bestDmg) { bestDmg = d; best = c; }
}
return best;
}
return candidates[0];
}
function applyOp(state, seat, eff, targetRef, ctx = {}) {
const p = P(state, seat);
switch (eff.op) {
case 'drawRoom': drawRooms(state, seat, eff.n || 1); break;
case 'drawSpell': drawSpells(state, seat, eff.n || 1); break;
case 'drawThenDiscard':
if (eff.draw === 'spell') drawSpells(state, seat, eff.n); else drawRooms(state, seat, eff.n);
if ((eff.discard === 'spell' ? p.hand.spells : p.hand.rooms).length >= eff.d) {
state.effectQueue.push({ kind: 'discard', seat, cardType: eff.discard, n: eff.d });
}
break;
case 'recoverDiscard': {
let inst = null;
let i = state.decks.roomDiscard.findIndex((c) => c.uid === targetRef.uid);
if (i >= 0) { inst = state.decks.roomDiscard.splice(i, 1)[0]; p.hand.rooms.push(inst); }
else {
i = state.decks.spellDiscard.findIndex((c) => c.uid === targetRef.uid);
if (i >= 0) { inst = state.decks.spellDiscard.splice(i, 1)[0]; p.hand.spells.push(inst); }
}
if (inst) emit(state, { type: 'recover', seat, id: inst.id });
break;
}
case 'revealTake': {
const o = P(state, targetRef.seat);
const c = removeFromHand(o, targetRef.uid);
if (c) {
(ROOMS[c.id] ? p.hand.rooms : p.hand.spells).push(c);
emit(state, { type: 'cardTaken', seat, from: targetRef.seat, id: c.id });
}
break;
}
case 'stealRandom': case 'opponentDiscardRandom': {
const o = P(state, targetRef.seat);
const filter = eff.filter || 'any';
const pool = filter === 'spell' ? o.hand.spells : filter === 'room' ? o.hand.rooms : handAll(o);
if (!pool.length) break;
const c = state.rng.pick(pool);
removeFromHand(o, c.uid);
if (eff.op === 'stealRandom') {
(ROOMS[c.id] ? p.hand.rooms : p.hand.spells).push(c);
emit(state, { type: 'cardTaken', seat, from: targetRef.seat, id: c.id });
} else {
discardCard(state, c);
emit(state, { type: 'discard', seat: targetRef.seat, id: c.id, forced: true });
}
break;
}
case 'handReset':
for (const s of aliveSeats(state)) {
const o = P(state, s);
for (const c of handAll(o)) discardCard(state, c);
o.hand.rooms = []; o.hand.spells = [];
drawSpells(state, s, 1); drawRooms(state, s, 2);
}
emit(state, { type: 'handReset' });
break;
case 'wreckEachOpponent':
for (const s of state.turnOrder) {
if (s === seat || !P(state, s).alive || !P(state, s).dungeon.length) continue;
state.effectQueue.push({
kind: 'target', seat: s, eff: { op: 'destroyOwnRoom' },
candidates: opCandidates(state, s, { op: 'destroyOwnRoom' }), ctx: {},
});
}
break;
case 'destroyOwnRoom':
destroySlot(state, targetRef.seat, targetRef.slotIdx);
break;
case 'swapRooms': {
const d = P(state, targetRef.seat).dungeon;
[d[targetRef.a], d[targetRef.b]] = [d[targetRef.b], d[targetRef.a]];
emit(state, { type: 'roomsSwapped', seat: targetRef.seat, a: targetRef.a, b: targetRef.b });
break;
}
case 'extraBuild':
p.extraBuilds++;
break;
case 'noBuildRound':
state.roundNoBuild = true;
emit(state, { type: 'noBuild' });
break;
case 'deactivateRoom': {
const slot = P(state, targetRef.seat).dungeon[targetRef.slotIdx];
slot.deactivated = true;
emit(state, { type: 'roomFrozen', seat: targetRef.seat, slotIdx: targetRef.slotIdx });
break;
}
case 'tutorAdvanced': {
let i = state.decks.rooms.findIndex((c) => c.uid === targetRef.roomUid);
let inst;
if (i >= 0) inst = state.decks.rooms.splice(i, 1)[0];
else {
i = state.decks.roomDiscard.findIndex((c) => c.uid === targetRef.roomUid);
inst = state.decks.roomDiscard.splice(i, 1)[0];
}
p.hand.rooms.push(inst);
placeRoom(state, seat, inst.uid, targetRef.slotIdx);
break;
}
case 'dmgBoostRoom': {
const slot = P(state, targetRef.seat).dungeon[targetRef.slotIdx];
slot.tempDmg += eff.amount;
emit(state, { type: 'roomBoosted', seat: targetRef.seat, slotIdx: targetRef.slotIdx, amount: eff.amount });
break;
}
case 'dmgBoostAllRooms':
destroySlot(state, seat, targetRef.slotIdx); // the sacrificed room
p.roundMods.allBoost += eff.amount;
emit(state, { type: 'allRoomsBoosted', seat, amount: eff.amount });
break;
case 'doubleTreasure':
p.roundMods.doubleTreasure = true;
emit(state, { type: 'treasureDoubled', seat });
break;
case 'damageHero': {
const found = findEntranceHero(state, targetRef.uid);
if (!found) break;
const amount = eff.amount === 'dungeonSize' ? p.dungeon.length : eff.amount;
found.hero.hp -= amount;
emit(state, { type: 'heroHurt', uid: found.hero.uid, amount, seat: found.seat });
if (found.hero.hp <= 0) scoreHeroKill(state, seat, found.hero, { removeFromSeat: found.seat, noRoom: true });
break;
}
case 'healHero': {
const found = findEntranceHero(state, targetRef.uid);
if (found) { found.hero.hp += eff.amount; emit(state, { type: 'heroHealed', uid: found.hero.uid, amount: eff.amount }); }
break;
}
case 'fearHero': {
const found = findEntranceHero(state, targetRef.uid);
if (found) {
const q = P(state, found.seat).entrance;
q.splice(q.findIndex((h) => h.uid === found.hero.uid), 1);
state.town.push(found.hero);
emit(state, { type: 'heroFeared', uid: found.hero.uid, from: found.seat });
}
break;
}
case 'teleportHero': {
const found = findEntranceHero(state, targetRef.uid);
if (found) { found.hero.teleport = true; emit(state, { type: 'heroTeleportMarked', uid: found.hero.uid }); }
break;
}
case 'killHeroTown': {
const i = state.town.findIndex((h) => h.uid === targetRef.uid);
if (i >= 0) {
const hero = state.town.splice(i, 1)[0];
scoreHeroKill(state, seat, hero, { noRoom: true });
}
break;
}
case 'lureHero': {
let hero = null;
let i = state.town.findIndex((h) => h.uid === targetRef.uid);
if (i >= 0) hero = state.town.splice(i, 1)[0];
else {
for (const deck of [state.decks.heroes, state.decks.epics]) {
i = deck.findIndex((h) => h.uid === targetRef.uid);
if (i >= 0) { hero = deck.splice(i, 1)[0]; break; }
}
}
if (hero) { p.entrance.push(hero); emit(state, { type: 'heroLured', seat, uid: hero.uid, id: hero.id }); }
break;
}
case 'resurrectAttack': {
const o = P(state, targetRef.seat);
const i = o.soulCards.findIndex((h) => h.uid === targetRef.uid);
if (i < 0) break;
const hero = o.soulCards.splice(i, 1)[0];
o.souls -= heroSouls(heroDef(hero));
hero.hp = hero.hpMax + (eff.hpBonus || 0);
o.entrance.push(hero);
emit(state, { type: 'heroResurrected', seat: targetRef.seat, uid: hero.uid, id: hero.id });
break;
}
case 'blockHeroes':
P(state, targetRef.seat).roundMods.blocked = true;
emit(state, { type: 'heroesBlocked', seat: targetRef.seat });
break;
case 'sacrificeSoulDraw': {
const i = p.soulCards.findIndex((h) => h.uid === targetRef.uid);
if (i < 0) break;
const hero = p.soulCards.splice(i, 1)[0];
p.souls -= heroSouls(heroDef(hero));
drawSpells(state, seat, eff.n);
emit(state, { type: 'soulBurned', seat, id: hero.id });
break;
}
case 'healWound':
// Tabletop flips the wound into a soul; we simply heal it.
if (p.wounds > 0) { p.wounds--; emit(state, { type: 'woundHealed', seat }); }
break;
case 'pitTrap': {
const slot = ctx.slot;
slot.armed = { type: 'pit' };
emit(state, { type: 'trapArmed', seat, kind: 'pit' });
break;
}
case 'rampTrap': {
destroySlot(state, seat, targetRef.slotIdx);
const slot = ctx.slot; // ramp itself (survives; the target was the sacrifice)
if (P(state, seat).dungeon.includes(slot)) {
slot.armed = { type: 'ramp', bonus: 5 };
emit(state, { type: 'trapArmed', seat, kind: 'ramp' });
}
break;
}
case 'caveIn': {
const slot = P(state, seat).dungeon[targetRef.slotIdx];
if (slot) { slot.armed = { type: 'cavein' }; emit(state, { type: 'trapArmed', seat, kind: 'cavein' }); }
break;
}
case 'counterSpell':
break; // handled in the reaction flow
default:
throw new Error(`unknown op ${eff.op}`);
}
}
function findEntranceHero(state, uid) {
for (const seat of aliveSeats(state)) {
const hero = P(state, seat).entrance.find((h) => h.uid === uid);
if (hero) return { seat, hero };
}
return null;
}
function destroySlotByRef(state, seat, slot) {
if (!slot) return;
const idx = P(state, seat).dungeon.indexOf(slot);
if (idx >= 0) destroySlot(state, seat, idx);
}
function destroySlot(state, seat, slotIdx) {
const p = P(state, seat);
const slot = p.dungeon[slotIdx];
if (!slot) return;
p.dungeon.splice(slotIdx, 1);
discardCard(state, slot.room);
for (const c of slot.under) discardCard(state, c);
emit(state, { type: 'roomDestroyed', seat, slotIdx, id: slot.room.id });
// Recycling Center: whenever ANOTHER of your rooms is destroyed, draw 2 rooms.
for (const s of p.dungeon) {
if (s.deactivated) continue;
(roomDef(s.room).effects || []).forEach((eff) => {
if (eff.trigger === 'onOwnRoomDestroyed') drawRooms(state, seat, eff.n);
});
}
}
// ── Build resolution ────────────────────────────────────────────────────────
function placeRoom(state, seat, roomUid, slotIdx, { setup = false } = {}) {
const p = P(state, seat);
const inst = p.hand.rooms.find((c) => c.uid === roomUid);
removeFromHand(p, roomUid);
const def = roomDef(inst);
if (slotIdx >= p.dungeon.length) {
p.dungeon.push(newSlot(inst));
} else {
const slot = p.dungeon[slotIdx];
slot.under.push(slot.room);
slot.room = inst;
slot.deactivated = false;
slot.usedOnce = {};
slot.tempDmg = 0;
slot.armed = null;
}
emit(state, { type: 'roomBuilt', seat, slotIdx: Math.min(slotIdx, p.dungeon.length - 1), id: inst.id, advanced: !!def.advanced });
// Own Beast Menagerie: once a round, when you build another monster room, draw.
if (def.type === 'monster') {
p.dungeon.forEach((slot) => {
if (slot.deactivated || slot.room.uid === inst.uid) return;
(roomDef(slot.room).effects || []).forEach((eff, e) => {
if (eff.trigger === 'onBuildOtherMonster' && !slot.usedOnce[e]) { slot.usedOnce[e] = true; drawRooms(state, seat, eff.n); }
});
});
}
// onBuild effects of the room itself (suppressed for the simultaneous setup
// build so round 1 starts from a clean slate).
if (!setup) {
for (const eff of (def.effects || [])) {
if (eff.trigger === 'onBuild') queueEffect(state, seat, eff, { source: 'room', slotIdx });
}
}
// Level up on reaching the 5-room maximum, once per game.
if (p.dungeon.length === MAX_ROOMS && !p.boss.leveledUp) {
p.boss.leveledUp = true;
emit(state, { type: 'levelUp', seat, boss: p.boss.id });
queueEffect(state, seat, { ...BOSSES[p.boss.id].levelUp, optional: true }, { source: 'levelUp' });
}
}
function flipBuilds(state, { setup = false } = {}) {
for (const seat of state.turnOrder) {
const p = P(state, seat);
if (!p.alive) continue;
const b = p.pendingBuild;
p.pendingBuild = undefined;
if (!b) { emit(state, { type: 'flip', seat, pass: true }); continue; }
if (state.roundNoBuild) { emit(state, { type: 'flip', seat, cancelled: true }); continue; } // card stays in hand
// Re-validate: an earlier flip/effect may have invalidated the placement.
const stillLegal = legalBuilds(state, seat).some((x) => x.roomUid === b.roomUid && x.slotIdx === b.slotIdx);
if (!stillLegal) { emit(state, { type: 'flip', seat, cancelled: true }); continue; }
emit(state, { type: 'flip', seat });
placeRoom(state, seat, b.roomUid, b.slotIdx, { setup });
}
}
// ── Bait ────────────────────────────────────────────────────────────────────
export function baitTargets(state) {
const out = {};
for (const hero of state.town) {
const def = heroDef(hero);
const eligible = aliveSeats(state).filter((s) => !P(state, s).roundMods.blocked);
let best = null;
if (def.fool) {
// The Fool storms the dungeon of the player with the FEWEST souls.
let min = Infinity; let ties = 0;
for (const s of eligible) {
const v = P(state, s).souls;
if (v < min) { min = v; best = s; ties = 1; } else if (v === min) ties++;
}
if (ties > 1) best = null;
} else {
let max = 0; let ties = 0;
for (const s of eligible) {
const v = treasureCount(state, s, def.cls);
if (v > max) { max = v; best = s; ties = 1; } else if (v === max && v > 0) ties++;
}
if (ties > 1) best = null;
}
out[hero.uid] = best;
}
return out;
}
function runBait(state) {
const targets = baitTargets(state);
const staying = [];
for (const hero of state.town) {
const seat = targets[hero.uid];
if (seat == null) { staying.push(hero); continue; }
P(state, seat).entrance.push(hero);
emit(state, { type: 'heroWalks', uid: hero.uid, id: hero.id, seat });
}
state.town = staying;
}
// ── Adventure ───────────────────────────────────────────────────────────────
function startAdventure(state) {
state.phase = 'adventure';
state.adv = { orderIdx: -1, walking: null, windowOpened: false };
nextAdventureSeat(state);
}
function nextAdventureSeat(state) {
if (state.gameOver || !state.adv) return;
const a = state.adv;
a.walking = null;
a.windowOpened = false;
while (true) {
a.orderIdx++;
if (a.orderIdx >= state.turnOrder.length) { state.adv = null; endRound(state); return; }
const seat = state.turnOrder[a.orderIdx];
if (P(state, seat).alive && P(state, seat).entrance.length) {
state.window = { id: 'advStart', advSeat: seat, passes: [], casts: 0 };
a.windowOpened = true;
emit(state, { type: 'adventureStart', seat });
return;
}
}
}
// Walk heroes one room-step at a time; pauses whenever effectQueue gains a decision.
function stepAdventure(state) {
const a = state.adv;
const seat = state.turnOrder[a.orderIdx];
const p = P(state, seat);
while (true) {
if (state.effectQueue.length || state.gameOver) return;
if (!a.walking) {
if (!p.entrance.length || !p.alive) { nextAdventureSeat(state); return; }
const hero = p.entrance.shift();
a.walking = { hero, roomIdx: p.dungeon.length - 1, bounced: false, secondPass: false };
emit(state, { type: 'heroEnters', seat, uid: hero.uid, id: hero.id });
}
const w = a.walking;
const hero = w.hero;
if (w.roomIdx < 0) {
if (hero.teleport && !w.secondPass) {
hero.teleport = false;
w.roomIdx = p.dungeon.length - 1;
w.secondPass = true;
w.bounced = false;
emit(state, { type: 'heroTeleported', seat, uid: hero.uid });
continue;
}
// Survived: the boss takes wounds.
const wounds = heroWounds(heroDef(hero));
p.wounds += wounds;
emit(state, { type: 'bossWounded', seat, uid: hero.uid, id: hero.id, wounds, total: p.wounds });
a.walking = null;
if (p.wounds >= WOUNDS_TO_DIE) { eliminate(state, seat); nextAdventureSeat(state); return; }
continue;
}
const slot = p.dungeon[w.roomIdx];
if (!slot) { w.roomIdx = Math.min(w.roomIdx - 1, p.dungeon.length - 1); continue; }
if (slot.deactivated) { w.roomIdx--; continue; }
const def = roomDef(slot.room);
// Armed traps resolve on entry.
if (slot.armed) {
const armed = slot.armed;
slot.armed = null;
if (armed.type === 'pit' || armed.type === 'cavein') {
const idx = p.dungeon.indexOf(slot);
destroySlot(state, seat, idx);
scoreHeroKill(state, seat, hero, { slot: null });
a.walking = null;
continue;
}
if (armed.type === 'ramp') {
hero.hp -= armed.bonus;
emit(state, { type: 'heroHurt', uid: hero.uid, amount: armed.bonus, seat });
if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot }); a.walking = null; continue; }
}
}
// Minotaur's Maze: the first hero through each round re-enters the room it
// just left (takes its damage again), then continues.
if (def.passive === 'minotaurBounce' && !slot.usedOnce.bounce && !w.bounced && w.roomIdx < p.dungeon.length - 1) {
slot.usedOnce.bounce = true;
w.bounced = true;
const backIdx = w.roomIdx + 1;
const dmg = roomDamage(state, seat, backIdx);
if (dmg > 0) {
hero.hp -= dmg;
emit(state, { type: 'roomHits', seat, slotIdx: backIdx, uid: hero.uid, amount: dmg, bounce: true });
if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot: p.dungeon[backIdx] }); a.walking = null; continue; }
}
}
const dmg = roomDamage(state, seat, w.roomIdx);
if (dmg > 0) {
hero.hp -= dmg;
emit(state, { type: 'roomHits', seat, slotIdx: w.roomIdx, uid: hero.uid, amount: dmg });
if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot }); a.walking = null; continue; }
}
w.roomIdx--;
}
}
function scoreHeroKill(state, seat, hero, { slot = null, removeFromSeat = null, noRoom = false } = {}) {
if (removeFromSeat != null) {
const q = P(state, removeFromSeat).entrance;
const i = q.findIndex((h) => h.uid === hero.uid);
if (i >= 0) q.splice(i, 1);
}
const p = P(state, seat);
const def = heroDef(hero);
p.souls += heroSouls(def);
p.soulCards.push(hero);
emit(state, { type: 'heroDies', seat, uid: hero.uid, id: hero.id, souls: heroSouls(def), total: p.souls });
if (!noRoom && slot && !slot.deactivated) {
const rdef = roomDef(slot.room);
(rdef.effects || []).forEach((eff, e) => {
if (eff.trigger !== 'onHeroDieHere' || slot.usedOnce[e]) return;
slot.usedOnce[e] = true;
queueEffect(state, seat, eff, { source: 'room' });
});
}
}
function eliminate(state, seat) {
const p = P(state, seat);
p.alive = false;
for (const c of handAll(p)) discardCard(state, c);
p.hand.rooms = []; p.hand.spells = [];
for (const slot of p.dungeon) { discardCard(state, slot.room); for (const c of slot.under) discardCard(state, c); }
p.dungeon = [];
p.entrance = [];
p.pendingBuild = undefined;
p.extraBuilds = 0;
emit(state, { type: 'eliminated', seat });
state.effectQueue = state.effectQueue.filter((q) => q.seat !== seat);
const alive = aliveSeats(state);
if (alive.length === 1) endGame(state, alive[0], 'lastStanding');
}
// ── Round flow ──────────────────────────────────────────────────────────────
function startRound(state) {
state.round++;
state.roundNoBuild = false;
for (const seat of aliveSeats(state)) {
const p = P(state, seat);
p.roundMods = freshMods();
p.extraBuilds = 0;
for (const slot of p.dungeon) {
slot.deactivated = false;
slot.usedOnce = {};
slot.tempDmg = 0;
if (slot.armed) slot.armed = null;
}
}
emit(state, { type: 'roundStart', round: state.round });
// Reveal one hero per (living) player.
let exhausted = false;
for (let i = 0; i < aliveSeats(state).length; i++) {
let deck = state.decks.heroes;
if (!deck.length) { state.epicsActive = true; deck = state.decks.epics; }
if (!deck.length) { exhausted = true; break; }
const hero = deck.pop();
state.town.push(hero);
emit(state, { type: 'heroRevealed', uid: hero.uid, id: hero.id, epic: heroDef(hero).epic });
}
if (exhausted && !state.town.length) {
// Decks empty and nobody left to fight: highest souls wins now.
endGame(state, bestBySouls(state), 'decksExhausted');
return;
}
// Draws — Haunted Library owners choose room-or-spell.
for (const seat of aliveSeats(state)) {
const p = P(state, seat);
const hasLibrary = p.dungeon.some((s) => !s.deactivated && roomDef(s.room).passive === 'librarySpellDraw');
if (hasLibrary) state.effectQueue.push({ kind: 'roomDraw', seat });
else drawRooms(state, seat, 1);
}
state.phase = 'roundStart';
}
function endRound(state) {
if (state.gameOver) return;
// Untriggered armed pits/cave-ins still collapse at end of round.
for (const seat of aliveSeats(state)) {
const p = P(state, seat);
for (let i = p.dungeon.length - 1; i >= 0; i--) {
const armed = p.dungeon[i].armed;
if (armed && (armed.type === 'pit' || armed.type === 'cavein')) destroySlot(state, seat, i);
}
}
const winners = aliveSeats(state).filter((s) => P(state, s).souls >= SOULS_TO_WIN);
if (winners.length) {
// Most souls; ties go to the LOWEST-XP boss.
winners.sort((a, b) => (P(state, b).souls - P(state, a).souls)
|| (BOSSES[P(state, a).boss.id].xp - BOSSES[P(state, b).boss.id].xp));
endGame(state, winners[0], 'souls');
return;
}
emit(state, { type: 'roundEnd', round: state.round });
startRound(state);
}
function bestBySouls(state) {
const alive = aliveSeats(state);
alive.sort((a, b) => (P(state, b).souls - P(state, a).souls)
|| (BOSSES[P(state, a).boss.id].xp - BOSSES[P(state, b).boss.id].xp));
return alive[0];
}
function endGame(state, winner, reason) {
state.gameOver = true;
state.winner = winner;
state.phase = 'gameover';
state.window = null;
state.reaction = null;
state.effectQueue = [];
state.adv = null;
emit(state, { type: 'gameOver', winner, reason });
}
export function isOver(state) { return state.gameOver; }
export function finalRanking(state) {
return state.players.slice()
.sort((a, b) => (b.alive - a.alive) || (b.souls - a.souls)
|| (BOSSES[a.boss.id].xp - BOSSES[b.boss.id].xp))
.map((p) => p.seat);
}
// ── The advance loop ────────────────────────────────────────────────────────
function advance(state) {
let guard = 0;
while (!state.gameOver) {
if (++guard > 10000) throw new Error('advance() ran away');
if (state.effectQueue.length || state.reaction) return; // decision needed
if (state.window) {
const seat = nextWindowSeat(state);
if (seat != null) return;
const closed = state.window;
state.window = null;
if (closed.id === 'buildStart') { state.phase = 'build'; continue; }
if (closed.id === 'postFlip') { runBait(state); startAdventure(state); continue; }
if (closed.id === 'advStart') { stepAdventure(state); continue; }
}
if (state.adv && state.adv.windowOpened && !state.window) {
// resumed mid-walk after an effect decision
stepAdventure(state);
if (state.effectQueue.length || state.window || state.gameOver) continue;
if (!state.adv) continue;
return; // shouldn't happen; stepAdventure always progresses
}
switch (state.phase) {
case 'setupDiscard':
if (aliveSeats(state).every((s) => P(state, s).setupDiscarded)) { state.phase = 'setupBuild'; continue; }
return;
case 'setupBuild':
if (aliveSeats(state).every((s) => P(state, s).pendingBuild !== undefined)) {
flipBuilds(state, { setup: true });
state.setupBuilt = true;
startRound(state);
continue;
}
return;
case 'roundStart':
state.window = { id: 'buildStart', passes: [], casts: 0 };
state.phase = 'windowBuildStart';
continue;
case 'windowBuildStart':
// window handled above; when closed phase becomes 'build'
if (!state.window) { state.phase = 'build'; continue; }
return;
case 'build':
if (aliveSeats(state).every((s) => P(state, s).pendingBuild !== undefined)) {
flipBuilds(state);
state.phase = 'extraBuild';
continue;
}
return;
case 'extraBuild':
if (state.roundNoBuild) { for (const s of aliveSeats(state)) P(state, s).extraBuilds = 0; }
if (aliveSeats(state).some((s) => P(state, s).extraBuilds > 0)) return;
state.window = { id: 'postFlip', passes: [], casts: 0 };
state.phase = 'windowPostFlip';
continue;
case 'windowPostFlip':
if (!state.window) { runBait(state); startAdventure(state); continue; }
return;
case 'adventure':
if (!state.window && state.adv) { stepAdventure(state); continue; }
return;
default:
return;
}
}
}
// ── Public (redacted) view for the AI ───────────────────────────────────────
// Own hand stays visible; opponents' hands and pending builds are hidden.
export function publicView(state, seat) {
return {
seed: state.seed, nPlayers: state.nPlayers, round: state.round,
phase: state.phase, window: state.window, turnOrder: state.turnOrder,
epicsActive: state.epicsActive,
// Deck contents are hidden (stubs keep lengths honest); discards are public.
decks: {
rooms: state.decks.rooms.map(() => ({ uid: -1, id: null, hidden: true })),
spells: state.decks.spells.map(() => ({ uid: -1, id: null, hidden: true })),
heroes: state.decks.heroes.map(() => ({ uid: -1, id: null, hidden: true })),
epics: state.decks.epics.map(() => ({ uid: -1, id: null, hidden: true })),
roomDiscard: state.decks.roomDiscard,
spellDiscard: state.decks.spellDiscard,
},
roomDiscard: state.decks.roomDiscard, spellDiscard: state.decks.spellDiscard,
town: state.town,
players: state.players.map((p) => (p.seat === seat ? p : {
seat: p.seat, alive: p.alive, boss: p.boss, dungeon: p.dungeon,
// Hidden cards become stubs so shared helpers can still count them.
hand: {
rooms: p.hand.rooms.map(() => ({ uid: -1, id: null, hidden: true })),
spells: p.hand.spells.map(() => ({ uid: -1, id: null, hidden: true })),
},
entrance: p.entrance, souls: p.souls, soulCards: p.soulCards,
wounds: p.wounds, pendingBuild: p.pendingBuild === undefined ? undefined : p.pendingBuild !== null,
extraBuilds: p.extraBuilds, roundMods: p.roundMods,
})),
gameOver: state.gameOver, winner: state.winner,
};
}