From cc2461c6b5da67292994694ebaa81e31b0f6ca96 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 6 Jul 2026 21:55:04 -0600 Subject: [PATCH] feat(dungeonboss): mid-walk spell casting in adventure windows Add a "Spells" button during adventure room windows allowing the human to cast hand spells targeting the hero currently mid-crawl (not just entrance-queued heroes). The adventure modal stays open across the cast, narrates spell effects as banners, and correctly resumes the walk afterward without skipping rooms. Key changes: - New `adventurePause` walk type and `ADV_RESUME_MARKERS` to mark the boundary between a cast's own effects and resumed walk events - `findHero` now locates heroes in `adv.walking.hero` - `showPickModal`/`showReactModal` accept custom callbacks to stay inside the adventure modal session - `driveAdventureWindowDecision` routes window/react decisions to the modal UI instead of closing it - AI window/react turns are computed and revealed via a "has cast:" overlay before applying - `applyAdventureWindowChoice` applies choices directly to the engine, splits events into narration vs resumed walk, and plays banners - New event types (`roomBoosted`, `heroHurt`, `heroHealed`, `heroFeared`, `heroTeleportMarked`, `heroesBlocked`, `soulBurned`, `handReset`) render as banners in the narration pipeline - Two new verification tests for mid-walk targeting and fearHero safety --- src/games/dungeonboss/DungeonBossGame.js | 321 +++++++++++++++++++++-- tools/verifyDungeonBoss.js | 96 +++++++ 2 files changed, 401 insertions(+), 16 deletions(-) diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js index c13b3e8..8ebe8cf 100644 --- a/src/games/dungeonboss/DungeonBossGame.js +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -64,7 +64,16 @@ const DECK_PILE_POS = { room: { x: 60, y: 520, w: 46, h: 64 }, spell: { x: 60, y // splitAdventureWalk's midHero guard. const ADV_WALK_TYPES = new Set([ 'heroEnters', 'heroTeleported', 'heroHurt', 'roomHits', 'heroDies', 'bossWounded', 'roomDestroyed', 'eliminated', + 'adventurePause', ]); +// Marks the boundary, inside a single mid-walk actWindow()/actReact() result, +// between a spell's own direct effects (played as narration — see +// applyAdventureWindowChoice/playAdventureNarration) and the walk actually +// resuming afterward. These types can ONLY come from stepAdventure resuming +// (never from a cast's own applyOp), since castSpell/activateRoom always run +// to completion, synchronously, before advance() gets a chance to resume the +// walk — so the first occurrence of one of these always marks that boundary. +const ADV_RESUME_MARKERS = ['roomHits', 'adventurePause', 'heroEnters', 'bossWounded', 'eliminated']; const ADV_HERO_ROW = { x0: 40, x1: 860, y: 200, maxW: 120 }; const ADV_ROOM_ROW = { x0: 900, x1: 1880, y: 200, maxW: 150 }; const ADV_STAGE_HERO = { x: 380, y: 680 }; @@ -75,7 +84,8 @@ const ADV_STAGE_ROOM = { x: 760, y: 680 }; const ADV_STAGE_HERO_W = 314; const ADV_STAGE_ROOM_W = 344; const ADV_LOG_PANEL = { x0: 1180, y0: 420, w: 700, h: 480, lineH: 50 }; -const ADV_CONTINUE_POS = { x: 1530, y: 990 }; +const ADV_CONTINUE_POS = { x: 1670, y: 990 }; +const ADV_SPELLS_POS = { x: 1430, y: 990 }; const ADV_FRAME_MARGIN = 18; // Labeled section boxes drawn around the hero row, dungeon+boss row, and // battle stage — comfortably outside the content each area actually lays @@ -2011,6 +2021,7 @@ export default class DungeonBossGame extends Phaser.Scene { findHero(uid) { for (const h of this.gs.town) if (h.uid === uid) return h; for (const p of this.gs.players) for (const h of p.entrance) if (h.uid === uid) return h; + if (this.gs.adv?.walking?.hero.uid === uid) return this.gs.adv.walking.hero; return null; } @@ -2066,8 +2077,11 @@ export default class DungeonBossGame extends Phaser.Scene { } closeModal() { if (this._modal) { this._modal.destroy(); this._modal = null; } } - // Generic candidate picker for off-board targets. - showPickModal(cands, onPick) { + // Generic candidate picker for off-board targets. By default Cancel/Skip + // routes back into this.mode's normal decision flow; pass opts.onCancel to + // override that (used by the adventure modal's spell-casting overlay, which + // doesn't go through this.mode at all). + showPickModal(cands, onPick, opts = {}) { const root = this.modalRoot(); const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(cands.length)))); // ch sets the row pitch; it must clear the tallest candidate card, which is @@ -2092,13 +2106,14 @@ export default class DungeonBossGame extends Phaser.Scene { root.add(b); } }); - const cancel = this.mode.decision?.optional; + const cancel = opts.onCancel ? opts.optional : this.mode.decision?.optional; + const onCancelClick = opts.onCancel || (() => { + if (cancel) this.applyDecision(this.mode.decision, null); + else this.enterHumanMode(this.mode.decision); + }); const cancelBtn = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + (rows / 2) * (ch + 16) + 60, - cancel ? 'Skip' : 'Cancel', () => { - this.closeModal(); - if (cancel) this.applyDecision(this.mode.decision, null); - else this.enterHumanMode(this.mode.decision); - }, { width: 150, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.overlay + 1); + cancel ? 'Skip' : 'Cancel', () => { this.closeModal(); onCancelClick(); }, + { width: 150, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.overlay + 1); root.add(cancelBtn); } @@ -2140,7 +2155,11 @@ export default class DungeonBossGame extends Phaser.Scene { return 'Choose'; } - showReactModal(d) { + // onResp defaults to the normal top-level applyDecision flow; the adventure + // modal passes its own applyAdventureWindowChoice instead, so a reaction + // triggered mid-walk stays inside that same session (see + // driveAdventureWindowDecision). + showReactModal(d, onResp = (resp) => this.applyDecision(d, resp)) { const root = this.modalRoot(); const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const g = this.add.graphics(); @@ -2173,7 +2192,7 @@ export default class DungeonBossGame extends Phaser.Scene { // established fix) — reparent it so closeModal()'s root.destroy() // actually removes it instead of leaving a stray, still-clickable // button behind once a choice is made. - const btn = new Button(this, cx, cy + 4 + i * 54, b.label, () => { this.closeModal(); this.applyDecision(d, b.resp); }, + const btn = new Button(this, cx, cy + 4 + i * 54, b.label, () => { this.closeModal(); onResp(b.resp); }, { width: 380, fontSize: 19, variant: b.resp ? undefined : 'ghost' }).setDepth(DEPTH.overlay + 1); root.add(btn); }); @@ -2414,7 +2433,13 @@ export default class DungeonBossGame extends Phaser.Scene { walk.push(e); midHero = true; continue; } if (e.seat !== seat || !ADV_WALK_TYPES.has(e.type)) break; - if (!midHero && e.type !== 'bossWounded' && e.type !== 'eliminated') break; + // adventurePause always immediately follows a kill or a boss-wound (see + // openAdvRoomWindow) — it must be allowed through here alongside them, + // same as bossWounded/eliminated, or the walk gets truncated one event + // too early: the modal closes and reopens fresh for the next hero, + // losing whatever this hero's run had already dimmed/destroyed in the + // room row. + if (!midHero && !['bossWounded', 'eliminated', 'adventurePause'].includes(e.type)) break; walk.push(e); if (e.type === 'heroDies' || e.type === 'bossWounded') midHero = false; } @@ -2468,9 +2493,16 @@ export default class DungeonBossGame extends Phaser.Scene { root, dim, seat, heroCards: new Map(), heroOrder: [], heroMeta: new Map(), heroHp: new Map(), roomCards: new Map(), bossCard: null, liveOrder: [], attackedPortrait: [], - stage: null, logLines: [], continueBtn: null, + stage: null, logLines: [], continueBtn: null, spellsBtn: null, events: [], cursor: 0, currentLiveIdx: null, activeHeroUid: null, lastStep: null, onSessionDrained: null, + // Set only while a live 'window' decision is showing for the human + // (driveAdventureWindowDecision) — disambiguates onAdventureContinue's + // two meanings (settle+advance vs. pass this window). Deliberately NOT + // derived from a live pendingDecision() re-query at click time: that + // can already read true (the engine itself is that far along) before + // the human has visually caught up to it — see advanceAdventureStep. + pendingWindowDecision: null, }; this.buildAdventureRoomRow(seat); this.syncHeroRow(firstBatch); @@ -2499,6 +2531,9 @@ export default class DungeonBossGame extends Phaser.Scene { ab.continueBtn = new Button(this, ADV_CONTINUE_POS.x, ADV_CONTINUE_POS.y, 'Continue', () => this.onAdventureContinue(), { width: 220, fontSize: 22 }).setDepth(DEPTH.overlay + 1); ab.continueBtn.setVisible(false); + ab.spellsBtn = new Button(this, ADV_SPELLS_POS.x, ADV_SPELLS_POS.y, 'Spells', + () => this.onAdventureSpellsClick(), { width: 220, fontSize: 22, variant: 'ghost' }).setDepth(DEPTH.overlay + 1); + ab.spellsBtn.setVisible(false); root.setAlpha(0); this.tweens.add({ targets: root, alpha: 1, duration: 220 }); } @@ -2654,6 +2689,13 @@ export default class DungeonBossGame extends Phaser.Scene { if (ab.cursor >= ab.events.length) return null; const e = ab.events[ab.cursor]; + // Observability-only marker (see openAdvRoomWindow) — either the window + // auto-closed with nothing to react to (silently skip and keep reading + // straight through to whatever the walk does next) or it's genuinely + // the last thing in this batch (queue drains, same as any other silent + // pass-through end-of-batch). + if (e.type === 'adventurePause') { ab.cursor++; continue; } + if (e.type === 'heroEnters') { ab.cursor++; ab.activeHeroUid = e.uid; @@ -2723,7 +2765,9 @@ export default class DungeonBossGame extends Phaser.Scene { advanceAdventureStep() { const ab = this._advBattle; + ab.pendingWindowDecision = null; ab.continueBtn.setVisible(false); + ab.spellsBtn.setVisible(false); const step = this.nextAdventureStep(); if (!step) { this.onAdventureQueueDrained(); return; } ab.lastStep = step; @@ -2733,8 +2777,22 @@ export default class DungeonBossGame extends Phaser.Scene { }); } + onAdventureSpellsClick() { + const ab = this._advBattle; + if (!ab.pendingWindowDecision) return; + this.showAdventureSpellsOverlay(ab.pendingWindowDecision); + } + onAdventureContinue() { const ab = this._advBattle; + // Same physical button, two meanings: settle+advance the last narrated + // step (ordinary case), or — once the queue has fully drained into a + // live advRoom window for the human (pendingWindowDecision set by + // driveAdventureWindowDecision) — pass on casting anything this window. + if (ab.pendingWindowDecision) { + this.applyAdventureWindowChoice(ab.pendingWindowDecision, { pass: true }); + return; + } ab.continueBtn.setVisible(false); this.settleAdventureStepIntoTopRows(ab.lastStep, () => this.advanceAdventureStep()); } @@ -2771,27 +2829,215 @@ export default class DungeonBossGame extends Phaser.Scene { const ab = this._advBattle; const d = pendingDecision(this.gs); if (d && d.seat === ab.seat && ['target', 'discard', 'roomDraw'].includes(d.kind)) { + ab.pendingWindowDecision = null; ab.root.setVisible(false); ab.continueBtn.setVisible(false); + ab.spellsBtn.setVisible(false); ab.attackedPortrait.forEach((o) => o.setVisible?.(false)); // scene-root objects, not children of ab.root this.setAdventureVideosVisible(true); // the decision UI (showPickModal etc.) is a normal board overlay, fine to show these under this.busy = false; this.pump(); return; } + // A live spell-casting window (any alive seat, not just ab.seat's own + // walk — see plan §1.1) or a reaction (Counterspell/All-Seeing-Eye) + // triggered by a mid-walk cast. Both stay inside this same modal session + // — but ONLY while it's still genuinely ab.seat's own advRoom window. + // castSpell sets state.reaction without clearing state.window, so + // state.window.id is reliable context for a 'react' decision too. Once + // ab.seat's whole hero queue is done, nextAdventureSeat opens a fresh + // 'advStart' window for whichever seat is next (or none, if the round + // ends) — that's a DIFFERENT session and must close this modal first + // (onSessionDrained, below) rather than get dragged into it; a brand-new + // modal opens for that seat's own walk once its advStart resolves. + if (d && (d.kind === 'window' || d.kind === 'react') && this.gs.window?.id === 'advRoom') { + this.driveAdventureWindowDecision(d); + return; + } ab.onSessionDrained(); } + // Routes a live window/react decision surfaced mid-walk to the right UI: + // the human gets the Continue/Spells buttons (window) or the reaction + // modal (react); an AI seat's turn is revealed via driveAdventureAIWindowTurn + // before it's actually applied. Any alive seat can be asked here, not just + // ab.seat — see the plan's any-seat-can-act scope decision. + driveAdventureWindowDecision(d) { + const ab = this._advBattle; + ab.pendingWindowDecision = null; + if (d.kind === 'react') { + if (d.seat === this.humanSeat) { + ab.continueBtn.setVisible(false); + ab.spellsBtn.setVisible(false); + this.showReactModal(d, (resp) => this.applyAdventureWindowChoice(d, resp)); + } else { + this.driveAdventureAIWindowTurn(d); + } + return; + } + if (d.seat === this.humanSeat) { + ab.pendingWindowDecision = d; + ab.continueBtn.setVisible(true); + ab.spellsBtn.setVisible(true).setEnabled(true); + } else { + this.driveAdventureAIWindowTurn(d); + } + } + + // Computes an AI seat's window/react choice without mutating anything yet. + // A real cast/response gets a Continue-gated "SeatName has cast:" reveal + // (matching the user's explicit "once continue is clicked, apply the + // spell"); a pass/no-op applies immediately with no overlay. + driveAdventureAIWindowTurn(d) { + const skill = this.opponents[d.seat - 1]?.skill ?? 3; + const choice = decide(publicView(this.gs, d.seat), d, skill); + const isReact = d.kind === 'react'; + const isNoOp = isReact ? !choice : (!choice || choice.pass); + if (isNoOp) { this.applyAdventureWindowChoice(d, choice); return; } + let renderCard; + let label; + if (isReact) { + if (choice.type === 'counterspell') { + const inst = this.gs.players[d.seat].hand.spells.find((c) => c.uid === choice.spellUid); + label = `${this.seatName(d.seat)} casts:`; + renderCard = (x, y, parent) => this.makeSpellCard(x, y, inst, 260, 360, { parent, hover: false, noHoverPreview: true }); + } else { + const slot = this.gs.players[d.seat].dungeon[choice.slotIdx]; + label = `${this.seatName(d.seat)} uses:`; + renderCard = (x, y, parent) => this.makeRoomCard(x, y, slot.room, 280, 280 / CARD_ASPECT.room, { parent, showText: true, hover: false, noHoverPreview: true }); + } + } else if (choice.spellUid != null) { + const inst = this.gs.players[d.seat].hand.spells.find((c) => c.uid === choice.spellUid); + label = `${this.seatName(d.seat)} has cast:`; + renderCard = (x, y, parent) => this.makeSpellCard(x, y, inst, 260, 360, { parent, hover: false, noHoverPreview: true }); + } else { + // A `window: 'any'` room ability (e.g. recover-discard) — legal in any + // window including the new advRoom one, not just advStart. + const slot = this.gs.players[d.seat].dungeon[choice.slotIdx]; + label = `${this.seatName(d.seat)} uses:`; + renderCard = (x, y, parent) => this.makeRoomCard(x, y, slot.room, 280, 280 / CARD_ASPECT.room, { parent, showText: true, hover: false, noHoverPreview: true }); + } + const ab = this._advBattle; + ab.continueBtn.setVisible(false); + ab.spellsBtn.setVisible(false); + this.showAdventureOpponentCastOverlay(label, renderCard, () => this.applyAdventureWindowChoice(d, choice)); + } + + showAdventureOpponentCastOverlay(label, renderCard, onContinue) { + const root = this.add.container(0, 0).setDepth(DEPTH.overlay + 5); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive(); + root.add(dim); + const g = this.add.graphics(); + g.fillStyle(0x1a1422, 0.97); g.fillRoundedRect(cx - 260, cy - 280, 520, 560, 14); + g.lineStyle(3, C.gold, 0.9); g.strokeRoundedRect(cx - 260, cy - 280, 520, 560, 14); + root.add(g); + root.add(this.add.text(cx, cy - 240, label, { + fontFamily: 'Righteous', fontSize: '26px', color: '#f2ead8', align: 'center', + }).setOrigin(0.5)); + renderCard(cx, cy - 10, root); + const btn = new Button(this, cx, cy + 250, 'Continue', () => { root.destroy(); onContinue(); }, + { width: 220, fontSize: 22 }).setDepth(DEPTH.overlay + 6); + root.add(btn); + } + + // Applies a window pass/cast or a react response directly against the + // engine (bypassing the generic applyDecision()/playEvents() pipeline, + // which doesn't know this modal session is open), then splits the result: + // the cast's own direct effects narrate via playAdventureNarration, and + // anything from the walk actually resuming afterward gets appended to + // ab.events for the normal step machinery to pick up. + applyAdventureWindowChoice(d, choice) { + const ab = this._advBattle; + ab.pendingWindowDecision = null; + ab.continueBtn.setVisible(false); + ab.spellsBtn.setVisible(false); + try { + if (d.kind === 'react') actReact(this.gs, d.seat, choice); + else actWindow(this.gs, d.seat, choice); + } catch (err) { + console.error('dungeonboss adventure window action rejected:', err); + this.renderAll(); + this.pump(); + return; + } + const events = takeEvents(this.gs); + const boundary = events.findIndex((e) => ADV_RESUME_MARKERS.includes(e.type)); + const narration = boundary === -1 ? events : events.slice(0, boundary); + const resumed = boundary === -1 ? [] : events.slice(boundary); + this.playAdventureNarration(narration, () => { + ab.events.push(...resumed); + this.advanceAdventureStep(); + }); + } + + // Plays a short list of non-walk events (a cast's own direct effects) + // through the same small popup pipeline playEvents() uses for its own + // `before` queue — see eventFx/eventDelay's roomBoosted/heroHurt/etc cases. + playAdventureNarration(events, onDone) { + const queue = events.filter((e) => this.eventDelay(e) > 0 || this.eventFx(e, true)); + const step = () => { + const e = queue.shift(); + if (!e) { onDone(); return; } + this.eventFx(e, false); + this.time.delayedCall(this.eventDelay(e), step); + }; + step(); + } + + // Human casting UI: a grid of the human's currently-castable hand spells + // (rooms are never castable during advRoom — see the plan's scope + // decision), reusing showPickModal for target selection exactly like the + // existing build-phase window UI does. + showAdventureSpellsOverlay(d) { + const ab = this._advBattle; + ab.continueBtn.setVisible(false); + ab.spellsBtn.setVisible(false); + const acts = windowActions(this.gs, this.humanSeat); + const root = this.modalRoot(); + const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(acts.spells.length)))); + const cw = 150, ch = 220; + const rows = Math.ceil(acts.spells.length / cols); + const x0 = GAME_WIDTH / 2 - ((cols - 1) * (cw + 14)) / 2; + const y0 = GAME_HEIGHT / 2 - ((rows - 1) * (ch + 16)) / 2; + const reopen = () => { this.closeModal(); this.showAdventureSpellsOverlay(d); }; + const cancel = () => { + this.closeModal(); + ab.continueBtn.setVisible(true); + ab.spellsBtn.setVisible(true).setEnabled(true); + }; + acts.spells.forEach((sp, i) => { + const x = x0 + (i % cols) * (cw + 14); + const y = y0 + Math.floor(i / cols) * (ch + 16); + const inst = this.gs.players[this.humanSeat].hand.spells.find((c) => c.uid === sp.uid); + this.makeSpellCard(x, y, inst, cw * 0.72, ch, { + parent: root, hover: false, + onClick: () => { + this.closeModal(); + if (sp.targets === null) { this.applyAdventureWindowChoice(d, { spellUid: sp.uid }); return; } + this.showPickModal(sp.targets, (t) => this.applyAdventureWindowChoice(d, { spellUid: sp.uid, target: t }), + { onCancel: reopen }); + }, + }); + }); + const cancelBtn = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + (rows / 2) * (ch + 16) + 60, + 'Cancel', cancel, { width: 150, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.overlay + 1); + root.add(cancelBtn); + } + closeAdventureModal(onDone) { const ab = this._advBattle; ab.continueBtn.setVisible(false); + ab.spellsBtn.setVisible(false); this.tweens.add({ - // ab.continueBtn and ab.attackedPortrait are scene-root objects (see - // their own comments), not children of ab.root, so they need to fade - // and be destroyed alongside it explicitly rather than for free. + // ab.continueBtn, ab.spellsBtn and ab.attackedPortrait are scene-root + // objects (see their own comments), not children of ab.root, so they + // need to fade and be destroyed alongside it explicitly rather than + // for free. targets: [ab.root, ...ab.attackedPortrait], alpha: 0, duration: 240, ease: 'Cubic.easeIn', onComplete: () => { ab.continueBtn.destroy(); + ab.spellsBtn.destroy(); ab.attackedPortrait.forEach((o) => o.destroy()); ab.root.destroy(); this._advBattle = null; @@ -3210,6 +3456,9 @@ export default class DungeonBossGame extends Phaser.Scene { case 'roomFrozen': return 300; case 'trapArmed': return 260; case 'draw': return 260; + case 'roomBoosted': return 500; + case 'heroHurt': case 'heroHealed': case 'heroFeared': case 'heroTeleportMarked': return 850; + case 'heroesBlocked': case 'soulBurned': case 'handReset': return 850; default: return 0; } } @@ -3363,6 +3612,46 @@ export default class DungeonBossGame extends Phaser.Scene { case 'trapArmed': this.popText(GAME_WIDTH / 2, 700, '⚠ trap armed', '#c9962a'); break; + // The following only ever reach eventFx via playAdventureNarration — a + // mid-walk (advRoom) spell's own direct effect, narrated as a simple + // banner/popup rather than folded into the room-walk step machinery + // (see applyAdventureWindowChoice/ADV_RESUME_MARKERS for why: a spell's + // heroHurt is indistinguishable in shape from a room's, and misattributing + // it to whatever room the hero happens to be paused next to would lie). + case 'roomBoosted': { + const r = this.slotRect(e.seat, e.slotIdx); + if (r) this.popText(r.x, r.y - 20, `+${e.amount} dmg!`, '#f5d76e', 22); + break; + } + case 'heroHurt': { + const hero = this.findHero(e.uid); + this.showBanner(`${hero ? heroDef(hero).name : 'The hero'} takes ${e.amount} damage!`); + break; + } + case 'heroHealed': { + const hero = this.findHero(e.uid); + this.showBanner(`${hero ? heroDef(hero).name : 'The hero'} is healed ${e.amount}!`); + break; + } + case 'heroFeared': { + const hero = this.findHero(e.uid); + this.showBanner(`${hero ? heroDef(hero).name : 'The hero'} flees back to town!`); + break; + } + case 'heroTeleportMarked': { + const hero = this.findHero(e.uid); + this.showBanner(`${hero ? heroDef(hero).name : 'The hero'} will be yanked back to the entrance!`); + break; + } + case 'heroesBlocked': + this.showBanner(`No hero enters ${this.seatName(e.seat)}'s dungeon this round!`); + break; + case 'soulBurned': + this.showBanner(`${this.seatName(e.seat)} burns a soul!`); + break; + case 'handReset': + this.showBanner('Hands reset — everyone discards and redraws!'); + break; default: break; } return true; diff --git a/tools/verifyDungeonBoss.js b/tools/verifyDungeonBoss.js index 378e907..44f3cf8 100644 --- a/tools/verifyDungeonBoss.js +++ b/tools/verifyDungeonBoss.js @@ -335,5 +335,101 @@ function driveTo(st, predicate, maxSteps = 5000) { ok(p.wounds - before === 2 || !p.alive, `epic hero deals 2 wounds (got ${p.wounds - before})`); } +{ + // A mid-walk advRoom window can target the hero currently mid-crawl (not + // just heroes still queued in entrance) — the whole point of this feature + // — and the walk correctly resumes at the next room afterward rather than + // skipping or re-resolving one. + const st = newGame(2, 13579); + driveTo(st, (d) => d.kind === 'build' && !d.setup); + const seat = st.turnOrder[0]; + const other = st.turnOrder[1]; + const p = st.players[seat]; + let uid = 9000; + const slot = () => ({ room: { uid: ++uid, id: 'hauntedlibrary' }, under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null }); + p.dungeon = [slot(), slot()]; // two dmg-1 rooms, no side-effect triggers + st.town = []; + st.players[other].hand.spells = []; // starve the other seat's turn in this window + st.players[other].dungeon = []; + const heroId = Object.keys(HEROES).find((id) => !HEROES[id].epic); + p.entrance = [{ uid: 555555, id: heroId, hp: 6, hpMax: 6 }]; + p.hand.rooms = []; // this round's own build/flip must not add a real room over our scripted dungeon + p.hand.spells = [{ uid: 424242, id: 'exhaustion' }]; + + const rnd = makeRng(13); + let steps = 0; + let d = pendingDecision(st); + while (!(d.kind === 'window' && d.window === 'advRoom' && d.seat === seat) && !isOver(st) && steps < 5000) { + steps++; + driveDecision(st, d, 3, rnd, null); + takeEvents(st); + d = pendingDecision(st); + } + ok(d.kind === 'window' && d.window === 'advRoom', 'a mid-walk advRoom window opens'); + ok(st.adv.walking && st.adv.walking.hero.uid === 555555, 'the walk is paused mid-crawl on our hero, not before/after it'); + ok(st.adv.walking.hero.hp === 5, `hero took 1 dmg from the first room before the pause (hp ${st.adv.walking.hero.hp})`); + + const acts = windowActions(st, seat); + const sp = acts.spells.find((s) => s.id === 'exhaustion'); + ok(!!sp, 'exhaustion is castable during a mid-walk advRoom window'); + const target = (sp.targets || []).find((t) => t.uid === 555555); + ok(!!target, 'the currently-walking hero (not just an entrance-queued one) is a legal exhaustion target'); + + actWindow(st, seat, { spellUid: sp.uid, target }); + const events = takeEvents(st); + const hurt = events.find((e) => e.type === 'heroHurt' && e.uid === 555555); + ok(hurt && hurt.amount === 2, `exhaustion dealt dungeonSize (2) damage to the walking hero (got ${hurt?.amount})`); + const laterHit = events.find((e) => e.type === 'roomHits' && e.uid === 555555); + ok(!!laterHit, 'the walk resumed and hit the next room after the mid-walk cast (no room skipped)'); + const wounded = events.find((e) => e.type === 'bossWounded' && e.uid === 555555); + ok(!!wounded, 'the hero survived the rest of the walk and wounded the boss (mid-walk cast did not corrupt the walk)'); +} + +{ + // fearHero cast on the CURRENTLY-WALKING hero must not incorrectly splice + // an unrelated queued hero out of the entrance array (see findEntranceHero/ + // applyOp's fearHero case — walking heroes aren't in p.entrance at all). + const st = newGame(2, 24680); + driveTo(st, (d) => d.kind === 'build' && !d.setup); + const seat = st.turnOrder[0]; + const other = st.turnOrder[1]; + const p = st.players[seat]; + p.dungeon = [{ room: { uid: 9100, id: 'hauntedlibrary' }, under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null }]; + st.town = []; + st.players[other].hand.spells = []; + st.players[other].dungeon = []; + const heroId = Object.keys(HEROES).find((id) => !HEROES[id].epic); + p.entrance = [ + { uid: 111111, id: heroId, hp: 6, hpMax: 6 }, + { uid: 222222, id: heroId, hp: 6, hpMax: 6 }, + ]; + p.hand.rooms = []; // this round's own build/flip must not add a real room over our scripted dungeon + p.hand.spells = [{ uid: 434343, id: 'fear' }]; + + const rnd = makeRng(24); + let steps = 0; + let d = pendingDecision(st); + while (!(d.kind === 'window' && d.window === 'advRoom' && d.seat === seat) && !isOver(st) && steps < 5000) { + steps++; + driveDecision(st, d, 3, rnd, null); + takeEvents(st); + d = pendingDecision(st); + } + ok(st.adv.walking && st.adv.walking.hero.uid === 111111, 'first hero is mid-crawl when the window opens'); + ok(p.entrance.some((h) => h.uid === 222222), 'second hero still sits queued in entrance'); + + const acts = windowActions(st, seat); + const sp = acts.spells.find((s) => s.id === 'fear'); + const target = (sp.targets || []).find((t) => t.uid === 111111); + ok(!!target, 'the walking hero is a legal fear target'); + actWindow(st, seat, { spellUid: sp.uid, target }); + const events = takeEvents(st); + + ok(events.some((e) => e.type === 'heroFeared' && e.uid === 111111), 'the walking hero was feared back to town'); + ok(st.town.some((h) => h.uid === 111111), 'the feared hero landed in town'); + ok(!st.town.some((h) => h.uid === 222222), 'the OTHER (still-queued) hero was not incorrectly removed too'); + ok(events.some((e) => e.type === 'heroEnters' && e.uid === 222222), 'the second hero still gets its own turn to walk'); +} + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);