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
This commit is contained in:
Brian Fertig 2026-07-06 21:55:04 -06:00
parent 137acc3500
commit cc2461c6b5
2 changed files with 401 additions and 16 deletions

View File

@ -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;

View File

@ -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);