1376 lines
56 KiB
JavaScript
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,
|
|
};
|
|
}
|