// 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, }; }