feat: enhance SWDBG animations and Dungeon Boss mid-walk casting

- SWDBG: add animated buy sequence with funding arcs, center-screen reveal,
  and face-down stow to discard/deck
- SWDBG: introduce stable row slot tracking so bought/lost cards don't shift
  neighbors; add ghost cards for pending animations
- SWDBG: add end-of-turn discard animations preserving old-hand positions;
  emit endTurnDiscard events from logic
- SWDBG: add deep-dive Buy/Attack buttons for actionable cards; refactor
  dealCardAnimated with startAs/flipTo/keepAlive support
- SWDBG: add drawArcArrow/drawArcArrows for converging attack visuals on
  bounty kills and base attacks
- Dungeon Boss: add mid-walk advRoom casting window after room events,
  letting players react to damage/kills during hero crawl
- Dungeon Boss: track walking hero in publicView and entranceHeroesOf so
  spells and AI heuristics can target mid-crawl heroes
- Dungeon Boss: replace pre-bait token snapshot with overlay system —
  pending bait heroes stay in town with fly-in tokens; pending draw cards
  show card backs until animations land
- Dungeon Boss: keep heroes in battle zone after roomHit/pass instead of
  returning them immediately
- Update images, registry (defaultPlayfield), and verifier (advRoomCasts)
This commit is contained in:
Brian Fertig 2026-07-05 18:20:06 -06:00
parent ee60d7fa1c
commit a1503d19fe
9 changed files with 773 additions and 115 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 967 KiB

After

Width:  |  Height:  |  Size: 981 KiB

Binary file not shown.

View File

@ -104,5 +104,5 @@ registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGa
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75 });
registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 76 });
registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77 });
registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77, defaultPlayfield: 'fantasy' });
registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78, defaultPlayfield: 'stars' });

View File

@ -149,13 +149,25 @@ function chooseWindow(view, seat, skill, rnd, decision) {
const acts = windowActions(view, seat);
if ((!acts.spells.length && !acts.rooms.length) || rnd() > prof.actChance) return { pass: true };
const p = me(view, seat);
const isAdv = decision.window === 'advStart';
const isAdv = decision.window === 'advStart' || decision.window === 'advRoom';
const myTurn = isAdv && decision.advSeat === seat;
const leader = soulLeader(view, seat);
const cands = [{ choice: { pass: true }, s: 1 }];
const survivor = (who) => who.entrance.find((h) => h.hp > dungeonDamage(view, who.seat));
const dying = (who) => who.entrance.find((h) => h.hp <= dungeonDamage(view, who.seat));
// The hero currently mid-crawl (see publicView's `adv` field) isn't in any
// entrance array — surface it as a fallback so these heuristics can still
// reason about "the hero at risk" during a mid-walk advRoom window.
const walkingHeroOf = (s) => (view.adv && view.turnOrder[view.adv.orderIdx] === s ? view.adv.walking?.hero : null);
const survivor = (who) => {
const w = walkingHeroOf(who.seat);
if (w && w.hp > dungeonDamage(view, who.seat)) return w;
return who.entrance.find((h) => h.hp > dungeonDamage(view, who.seat));
};
const dying = (who) => {
const w = walkingHeroOf(who.seat);
if (w && w.hp <= dungeonDamage(view, who.seat)) return w;
return who.entrance.find((h) => h.hp <= dungeonDamage(view, who.seat));
};
for (const sp of acts.spells) {
const def = spellDef({ id: sp.id });
@ -169,9 +181,13 @@ function chooseWindow(view, seat, skill, rnd, decision) {
else if (best) push(best, 3);
} else if (op === 'healHero' && isAdv && !myTurn && leader) {
const t = (sp.targets || []).find((t2) => {
const owner = view.players.find((q) => q.entrance.some((h) => h.uid === t2.uid));
let owner = view.players.find((q) => q.entrance.some((h) => h.uid === t2.uid));
let hero = owner && owner.entrance.find((h) => h.uid === t2.uid);
if (!hero && view.adv?.walking?.hero.uid === t2.uid) {
owner = view.players[view.turnOrder[view.adv.orderIdx]];
hero = view.adv.walking.hero;
}
if (!owner || owner.seat === seat) return false;
const hero = owner.entrance.find((h) => h.uid === t2.uid);
return hero.hp <= dungeonDamage(view, owner.seat) && hero.hp + def.op.amount > dungeonDamage(view, owner.seat);
});
if (t) push(t, 11);

View File

@ -271,6 +271,17 @@ export default class DungeonBossGame extends Phaser.Scene {
this._hoverSuppressCont = null; // card container currently mid-drag — its own hover popup stays disabled until dropped
this._advBattle = null; // adventure battle modal session state (see openAdventureModal) while open
this._advDungeonSnapshot = null; // per-seat pre-mutation dungeon snapshot, refreshed every applyDecision (see there)
// Bait-walk overlay (town -> entrance), refreshed every playEvents() batch
// that contains heroWalks events — see renderTown/renderHumanBoard/
// renderOpponents and eventFx's 'heroWalks' case.
this._pendingBaitUids = new Set(); // uids to hide from the real entrance render until the whole batch finishes
this._baitStillInTownUids = new Set(); // uids still drawn in town (not yet walked this batch)
this._baitHeroData = new Map(); // uid -> {id} for uids no longer in gs.town but still shown there
this._baitFlightTokens = []; // every flying/landed token created this batch, cleaned up by finishBaitOverlay()
// Drawn-card overlay: uids in the human hand whose fly-in-from-deck
// animation hasn't landed yet — see renderHand's placeholder and
// eventFx's 'draw' case / renderHandOnly.
this._pendingDrawUids = new Set();
}
create() {
@ -1284,8 +1295,14 @@ export default class DungeonBossGame extends Phaser.Scene {
}
// pending secret build
if (p.pendingBuild) this.makeCardBack(x + 128 - p.dungeon.length * pitch, y + 62, mw * 0.8, mh * 0.9, this.boardLayer);
// entrance heroes — mini cards, fanned with a slight overlap
// entrance heroes — mini cards, fanned with a slight overlap. Any hero
// still mid-bait-walk (see playEvents/eventFx's 'heroWalks' case) is
// skipped here — its resting flying token stands in for it, at this
// SAME slot position, until the whole bait batch finishes — but hi
// still counts every entry (skipped or not) so later heroes' slots
// don't shift.
p.entrance.forEach((hero, hi) => {
if (this._pendingBaitUids.has(hero.uid)) return;
const hx = x - pw / 2 + 30 + hi * OPP_ENTRANCE_PITCH;
const hy = y + 62;
this.miniHeroCard(hx, hy, hero, OPP_ENTRANCE_W, {
@ -1298,14 +1315,20 @@ export default class DungeonBossGame extends Phaser.Scene {
}
}
// Heroes queued to walk to an entrance this bait batch stay drawn here
// (via _baitStillInTownUids) until their own walk step actually begins —
// see eventFx's 'heroWalks' case, which deletes a hero from that set and
// calls renderTownOnly() right as its flight starts.
renderTown() {
const gs = this.gs;
const n = gs.town.length;
const extraUids = [...this._baitStillInTownUids].filter((uid) => !gs.town.some((h) => h.uid === uid));
const roster = [...gs.town, ...extraUids.map((uid) => ({ uid, ...(this._baitHeroData.get(uid) || {}) }))];
const n = roster.length;
const label = this.add.text(GAME_WIDTH / 2, 368, `— TOWN ${n ? '' : '(empty)'}`, {
fontFamily: 'Righteous', fontSize: '18px', color: '#9e9080',
}).setOrigin(0.5);
this.townLayer.add(label);
gs.town.forEach((hero, i) => {
roster.forEach((hero, i) => {
const { x, y } = this.townPos(i, n);
this._heroTokens.set(hero.uid, { x, y });
this.makeHeroCard(x, y, hero, TOWN_HERO_W, TOWN_HERO_W / CARD_ASPECT.hero, {
@ -1316,6 +1339,29 @@ export default class DungeonBossGame extends Phaser.Scene {
});
}
// Light-weight re-render of just the town row (see renderTown's overlay) —
// used mid-bait-playback so pulling one hero out of town doesn't disturb
// the rest of the board, which must keep showing this batch's other
// not-yet-walked heroes exactly where they are.
renderTownOnly() {
this.townLayer.removeAll(true);
this.renderTown();
}
// Ends a bait-walk overlay session: kills + drops every flight token
// created this batch, whether it already landed or is still mid-tween
// (its own queue-step delay and its tween duration are both 1500ms by
// design, so completion order isn't guaranteed) — the authoritative
// renderAll() about to run replaces it with the real entrance mini card
// either way — then clears the hide/keep-in-town sets.
finishBaitOverlay() {
this._baitFlightTokens.forEach((t) => { this.tweens.killTweensOf(t); t.destroy(); });
this._baitFlightTokens = [];
this._pendingBaitUids.clear();
this._baitStillInTownUids.clear();
this._baitHeroData.clear();
}
renderHumanBoard() {
const gs = this.gs;
const p = gs.players[this.humanSeat];
@ -1389,9 +1435,12 @@ export default class DungeonBossGame extends Phaser.Scene {
this.boardLayer.add(g);
}
}
// queued heroes at entrance
// queued heroes at entrance — any hero still mid-bait-walk is skipped
// (see renderOpponents' matching comment); hi still counts every entry
// so later heroes' slots don't shift.
const entR = this.humanSlotRect(Math.max(p.dungeon.length, 1) - 0.35);
p.entrance.forEach((hero, hi) => {
if (this._pendingBaitUids.has(hero.uid)) return;
const hx = entR.x - 130 - hi * HUMAN_ENTRANCE_PITCH;
this.miniHeroCard(hx, 640, hero, HUMAN_ENTRANCE_W, {
parent: this.boardLayer, hover: false,
@ -1520,6 +1569,15 @@ export default class DungeonBossGame extends Phaser.Scene {
let x = GAME_WIDTH / 2 - width / 2;
const y = 985;
for (const inst of rooms) {
// A just-drawn card reserves its slot (so the rest of the hand doesn't
// reflow again once revealed) but shows only a card back until its
// fly-in animation lands — see eventFx's 'draw' case / renderHandOnly.
if (this._pendingDrawUids.has(inst.uid)) {
const sp = this.makeCardBack(x, y, roomW * 0.85, roomH * 0.9, this.handLayer).setAlpha(0.9);
this._handSprites.set(inst.uid, sp);
x += pitch;
continue;
}
const selectable = this.isHandSelectable(inst);
const draggable = (zoneActive || buildActive) && selectable;
const sp = this.makeRoomCard(x, y, inst, roomW, roomH, {
@ -1538,6 +1596,12 @@ export default class DungeonBossGame extends Phaser.Scene {
x += pitch;
}
for (const inst of spells) {
if (this._pendingDrawUids.has(inst.uid)) {
const sp = this.makeCardBack(x, y - 16, spellW * 0.85, spellH * 0.9, this.handLayer).setAlpha(0.9);
this._handSprites.set(inst.uid, sp);
x += pitch;
continue;
}
const castable = this.castableUids?.has(inst.uid);
const selectable = this.isHandSelectable(inst);
const draggable = zoneActive && selectable;
@ -1554,6 +1618,14 @@ export default class DungeonBossGame extends Phaser.Scene {
}
}
// Light-weight re-render of just the hand row (see renderHand's
// _pendingDrawUids placeholder) — used when a drawn card's fly-in
// animation lands, revealing its real face without touching anything else.
renderHandOnly() {
this.handLayer.removeAll(true);
this.renderHand();
}
// ── discard drop zone ────────────────────────────────────────────────────────
// Draws a slot for each card still owed during a discard/setupDiscard
// decision, centered on screen. Slots already claimed (mode.selected) show
@ -1705,13 +1777,6 @@ export default class DungeonBossGame extends Phaser.Scene {
this._advDungeonSnapshot = this.gs.players.map((p) => p.dungeon.map((s) => ({
room: { id: s.room.id, uid: s.room.uid }, deactivated: s.deactivated, armed: s.armed,
})));
// Same problem, different spot: runBait() removes every baited hero from
// gs.town before emitting heroWalks, all within this SAME mutator call —
// so by the time playEvents()'s own renderAll() runs and heroWalks' fx
// reads this._heroTokens for a "from" (town) position, that hero is
// already gone from gs.town and renderTown() never gave it an entry.
// Snapshot the current (pre-mutation, still-accurate) town positions now.
this._preBaitHeroTokens = new Map(this._heroTokens);
try {
switch (d.kind) {
case 'setupDiscard': actSetupDiscard(this.gs, d.seat, choice); break;
@ -2675,21 +2740,18 @@ export default class DungeonBossGame extends Phaser.Scene {
}
// Return-to-top / dim / advance, per the spec: a survived room hit sends
// both cards back up then dims the room just passed; a trap kill sends
// just the room back up as a destroyed placeholder (the hero already faded
// away in playTrapKillStep); bossWound and pass need no further settling
// (already fully resolved inline in their own play functions).
// just the room back up and dims it — the hero stays put in the battle
// zone for its whole run, only ever leaving once it's defeated (removeHeroCard,
// already run inside playRoomHitStep) or wounds the boss (playBossWoundStep);
// a trap kill sends just the room back up as a destroyed placeholder (the
// hero already faded away in playTrapKillStep); bossWound and pass need no
// further settling (already fully resolved inline in their own play functions).
settleAdventureStepIntoTopRows(step, onDone) {
if (!step) { onDone(); return; } // the opening Continue click — nothing has played yet to settle
switch (step.kind) {
case 'roomHit': {
// The hero (if it died here) is already gone from the stage by now
// (removeHeroCard ran inside playRoomHitStep) — only the room needs
// to travel back, either way.
const uid = step.heroDies ? null : step.uid;
this.returnHome(uid, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
case 'roomHit':
this.returnHome(null, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
break;
}
case 'trapKill':
this.returnHome(null, step.displayKey, () => { this.markRoomDestroyed(step.displayKey); onDone(); });
break;
@ -2943,7 +3005,9 @@ export default class DungeonBossGame extends Phaser.Scene {
playPassStep(step, onDone) {
this.moveToStage(step.uid, step.displayKey, { duration: 200 }, () => {
this.time.delayedCall(140, () => {
this.returnHome(step.uid, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
// Only the room travels back — the hero stays in the battle zone
// until it's defeated or wounds the boss (see settleAdventureStepIntoTopRows).
this.returnHome(null, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
});
});
}
@ -3074,6 +3138,29 @@ export default class DungeonBossGame extends Phaser.Scene {
this._humanDrawBatch = { room: 0, spell: 0 };
this._humanDrawSeen = { room: 0, spell: 0 };
for (const e of events) if (e.type === 'draw' && e.seat === this.humanSeat) this._humanDrawBatch[e.card]++;
// These freshly-drawn cards (already appended to the tail of the hand
// arrays by the mutator) reserve their hand slot right away but stay a
// card back until their own fly-in-from-deck animation lands — see
// renderHand's placeholder and eventFx's 'draw' case / renderHandOnly.
this._pendingDrawUids = new Set();
const humanHand = this.gs.players[this.humanSeat];
if (this._humanDrawBatch.room) {
const list = humanHand.hand.rooms;
for (let i = list.length - this._humanDrawBatch.room; i < list.length; i++) this._pendingDrawUids.add(list[i].uid);
}
if (this._humanDrawBatch.spell) {
const list = humanHand.hand.spells;
for (let i = list.length - this._humanDrawBatch.spell; i < list.length; i++) this._pendingDrawUids.add(list[i].uid);
}
// Heroes baited to walk to an entrance this batch: keep each one's town
// card in place and hide it from the (already-mutated) real entrance
// render until its own walk step actually fires — see renderTown's
// overlay and eventFx's 'heroWalks' case, which peels one uid at a time
// off _baitStillInTownUids as each hero's own flight begins.
const walkers = events.filter((e) => e.type === 'heroWalks');
this._pendingBaitUids = new Set(walkers.map((e) => e.uid));
this._baitStillInTownUids = new Set(this._pendingBaitUids);
this._baitHeroData = new Map(walkers.map((e) => [e.uid, { id: e.id, hp: heroDef({ id: e.id }).hp }]));
// `before` (e.g. the windowPass event(s) that close the pre-adventure
// advStart window — confirmed via engine trace to share the SAME batch
// as the heroEnters it unblocks) plays through the normal fixed-delay
@ -3084,6 +3171,7 @@ export default class DungeonBossGame extends Phaser.Scene {
const step = () => {
const e = queue.shift();
if (!e) {
this.finishBaitOverlay();
if (walk.length) { this.renderAll(); this.runAdventureWalk(walk, rest); return; }
this.busy = false;
this.renderAll();
@ -3152,16 +3240,26 @@ export default class DungeonBossGame extends Phaser.Scene {
const inst = list[list.length - total + seen];
const sprite = inst && this._handSprites.get(inst.uid);
if (inst && sprite) {
this.flyCardFromDeck(e.card, inst, sprite.x, sprite.y, e.card === 'room' ? 125 : 104, (cont) => cont.destroy());
this.flyCardFromDeck(e.card, inst, sprite.x, sprite.y, e.card === 'room' ? 125 : 104, (cont) => {
// Reveal the real card in the hand (see renderHand's
// _pendingDrawUids placeholder) in the same tick as destroying
// the flying stand-in, so there's no flicker.
this._pendingDrawUids.delete(inst.uid);
this.renderHandOnly();
cont.destroy();
});
} else this.sfx(SFX.CARD_DEAL);
break;
}
case 'heroWalks': {
// Use the pre-mutation snapshot (see applyDecision) — by now
// renderAll() has already redrawn the town without this hero (bait
// already moved it into an entrance), so the live _heroTokens has no
// entry for it any more.
const from = this._preBaitHeroTokens?.get(e.uid) || this._heroTokens.get(e.uid);
// This hero's town card has sat in place (see renderTown's
// _baitStillInTownUids overlay, set up in playEvents) until right
// now — pull it out of town as its own walk begins, so the batch's
// heroes leave town one at a time instead of all vanishing together
// the instant the batch starts.
const from = this._heroTokens.get(e.uid);
this._baitStillInTownUids.delete(e.uid);
this.renderTownOnly();
const hero = this.findHero(e.uid) || { uid: e.uid, id: e.id, hp: 0 };
if (from) {
// Built at full town-card width, then tweened down (via scale) to
@ -3171,11 +3269,16 @@ export default class DungeonBossGame extends Phaser.Scene {
const tok = this.miniHeroCard(from.x, from.y, hero, TOWN_HERO_W, {
parent: this.fxLayer, noHoverPreview: true,
});
const to = this.entranceApprox(e.seat);
// Tracked from creation (not on tween completion — see
// finishBaitOverlay) so it's left resting at the entrance once
// landed (the real entrance render stays suppressed for this uid
// via _pendingBaitUids) until the whole bait batch finishes.
this._baitFlightTokens.push(tok);
const to = this.entranceSlotFor(e.seat, e.uid);
const scale = entW / TOWN_HERO_W;
this.tweens.add({
targets: tok, x: to.x, y: to.y, scaleX: scale, scaleY: scale,
duration: 1500, ease: 'Cubic.easeInOut', onComplete: () => tok.destroy(),
duration: 1500, ease: 'Cubic.easeInOut',
});
}
break;
@ -3279,6 +3382,25 @@ export default class DungeonBossGame extends Phaser.Scene {
const c = centers[seat - 1];
return { x: c.x - 250, y: c.y + 62 };
}
// A baited hero's EXACT resting slot — matching renderHumanBoard/
// renderOpponents' own fan-out formulas exactly — so its heroWalks flying
// token (see eventFx) lands precisely where the real entrance mini card
// will eventually render, with zero visual jump once the bait batch's
// closing renderAll() swaps it in. Falls back to entranceApprox if the
// engine hasn't actually placed this hero in the entrance yet (shouldn't
// happen — heroWalks fires after runBait's own state mutation).
entranceSlotFor(seat, uid) {
const p = this.gs.players[seat];
const hi = p.entrance.findIndex((h) => h.uid === uid);
if (hi < 0) return this.entranceApprox(seat);
if (seat === this.humanSeat) {
const entR = this.humanSlotRect(Math.max(p.dungeon.length, 1) - 0.35);
return { x: entR.x - 130 - hi * HUMAN_ENTRANCE_PITCH, y: 640 };
}
const centers = this.oppPanelCenters();
const c = centers[seat - 1];
return { x: c.x - 560 / 2 + 30 + hi * OPP_ENTRANCE_PITCH, y: c.y + 62 };
}
oppBossPos(seat) {
const c = this.oppPanelCenters()[seat - 1];
return { x: c.x + 228, y: c.y - 20 };

View File

@ -6,9 +6,12 @@
// 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.
// - Spells resolve at discrete cast windows (build start, post flip, pre-walk,
// and — matching the tabletop's true any-time-during-Adventure-Phase
// casting more closely — a fresh window after every room a hero resolves
// during the walk itself) rather than true continuous 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.
@ -224,7 +227,7 @@ function windowKindOk(effWindow, windowId) {
function spellPhaseOk(def, windowId) {
if (def.phase === 'both') return true;
if (def.phase === 'build') return windowId === 'buildStart' || windowId === 'postFlip';
return windowId === 'advStart';
return windowId === 'advStart' || windowId === 'advRoom';
}
// All actions a seat could take in the current window.
@ -269,6 +272,18 @@ function costPayable(state, seat, cost, slotIdx) {
return true;
}
// A seat's entrance queue, PLUS its currently-walking hero (if any) — the
// walking hero lives only in state.adv.walking (shifted out of p.entrance by
// stepAdventure the moment it starts resolving), so hero-targeting ops during
// a mid-walk advRoom window would otherwise be unable to target the very
// hero the window paused for. See also findEntranceHero.
function entranceHeroesOf(state, seat) {
const list = P(state, seat).entrance.slice();
const a = state.adv;
if (a && a.walking && state.turnOrder[a.orderIdx] === seat) list.push(a.walking.hero);
return list;
}
// ── Op target candidates ────────────────────────────────────────────────────
// Returns null when the op needs no target, else an array of targetRefs
// (possibly empty = not castable). targetRef kinds:
@ -349,15 +364,15 @@ export function opCandidates(state, seat, eff, ctx = {}) {
return out;
}
case 'damageHero': case 'teleportHero':
return p.entrance.map((h) => ({ kind: 'hero', uid: h.uid }));
return entranceHeroesOf(state, seat).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 });
for (const s of opps) for (const h of entranceHeroesOf(state, s)) 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 });
for (const s of aliveSeats(state)) for (const h of entranceHeroesOf(state, s)) out.push({ kind: 'hero', uid: h.uid });
return out;
}
case 'killHeroTown':
@ -818,7 +833,13 @@ function applyOp(state, seat, eff, targetRef, ctx = {}) {
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 });
if (found.hero.hp <= 0) {
// A kill on the currently-walking hero can't be spliced out of
// p.entrance (it isn't there — see findEntranceHero) and stepAdventure
// must stop treating it as still walking.
if (found.walking) state.adv.walking = null;
scoreHeroKill(state, seat, found.hero, { removeFromSeat: found.walking ? null : found.seat, noRoom: true });
}
break;
}
case 'healHero': {
@ -829,8 +850,14 @@ function applyOp(state, seat, eff, targetRef, ctx = {}) {
case 'fearHero': {
const found = findEntranceHero(state, targetRef.uid);
if (found) {
if (found.walking) {
// Splicing p.entrance here would remove the WRONG hero (the
// walking hero isn't in that array) — just end its walk instead.
state.adv.walking = null;
} else {
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 });
}
@ -920,7 +947,12 @@ function applyOp(state, seat, eff, targetRef, ctx = {}) {
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 };
if (hero) return { seat, hero, walking: false };
}
const a = state.adv;
if (a && a.walking && a.walking.hero.uid === uid) {
const seat = state.turnOrder[a.orderIdx];
if (P(state, seat).alive) return { seat, hero: a.walking.hero, walking: true };
}
return null;
}
@ -1073,7 +1105,26 @@ function nextAdventureSeat(state) {
}
}
// Walk heroes one room-step at a time; pauses whenever effectQueue gains a decision.
// Opens a fresh casting window after a real per-room event during the walk
// (any seat may act, exactly like advStart — see nextWindowSeat), letting
// adventure-phase spells react to what just happened mid-crawl rather than
// only before the walk started. If the event that triggered this ALSO queued
// a real decision (e.g. an onHeroDieHere room effect), that decision takes
// priority — advance()'s effectQueue check runs before window handling — so
// this is a harmless no-op in that case; the resume-after-decision path in
// advance() already re-enters stepAdventure() once the queue drains.
function openAdvRoomWindow(state, seat) {
if (state.gameOver || state.effectQueue.length) return;
state.window = { id: 'advRoom', advSeat: seat, passes: [], casts: 0 };
state.adv.windowOpened = true;
emit(state, { type: 'adventurePause', seat }); // observability only — UI must not gate control flow on this
}
// Walk heroes one room-step at a time; pauses whenever effectQueue gains a
// decision, OR after every real per-room event (a kill, or any survived room
// with actual damage/effects) to open a fresh advRoom casting window — matching
// the granularity the battle modal already shows via its Continue button
// (roomHit/trapKill/bossWound steps), not every silent pass-through.
function stepAdventure(state) {
const a = state.adv;
const seat = state.turnOrder[a.orderIdx];
@ -1103,12 +1154,14 @@ function stepAdventure(state) {
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;
openAdvRoomWindow(state, seat);
return;
}
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);
let hadEvent = false;
// Armed traps resolve on entry.
if (slot.armed) {
const armed = slot.armed;
@ -1118,12 +1171,19 @@ function stepAdventure(state) {
destroySlot(state, seat, idx);
scoreHeroKill(state, seat, hero, { slot: null });
a.walking = null;
continue;
openAdvRoomWindow(state, seat);
return;
}
if (armed.type === 'ramp') {
hero.hp -= armed.bonus;
hadEvent = true;
emit(state, { type: 'heroHurt', uid: hero.uid, amount: armed.bonus, seat });
if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot }); a.walking = null; continue; }
if (hero.hp <= 0) {
scoreHeroKill(state, seat, hero, { slot });
a.walking = null;
openAdvRoomWindow(state, seat);
return;
}
}
}
// Minotaur's Maze: the first hero through each round re-enters the room it
@ -1135,17 +1195,30 @@ function stepAdventure(state) {
const dmg = roomDamage(state, seat, backIdx);
if (dmg > 0) {
hero.hp -= dmg;
hadEvent = true;
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; }
if (hero.hp <= 0) {
scoreHeroKill(state, seat, hero, { slot: p.dungeon[backIdx] });
a.walking = null;
openAdvRoomWindow(state, seat);
return;
}
}
}
const dmg = roomDamage(state, seat, w.roomIdx);
if (dmg > 0) {
hero.hp -= dmg;
hadEvent = true;
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; }
if (hero.hp <= 0) {
scoreHeroKill(state, seat, hero, { slot });
a.walking = null;
openAdvRoomWindow(state, seat);
return;
}
}
w.roomIdx--;
if (hadEvent) { openAdvRoomWindow(state, seat); return; }
}
}
@ -1289,7 +1362,7 @@ function advance(state) {
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 (closed.id === 'advStart' || closed.id === 'advRoom') { stepAdventure(state); continue; }
}
if (state.adv && state.adv.windowOpened && !state.window) {
// resumed mid-walk after an effect decision
@ -1350,6 +1423,10 @@ export function publicView(state, seat) {
return {
seed: state.seed, nPlayers: state.nPlayers, round: state.round,
phase: state.phase, window: state.window, turnOrder: state.turnOrder,
// The currently-walking hero (mid-crawl, not yet back in an entrance
// array — see stepAdventure/entranceHeroesOf) so AI heuristics can
// reason about it during a mid-walk advRoom casting window.
adv: state.adv && { orderIdx: state.adv.orderIdx, walking: state.adv.walking && { hero: state.adv.walking.hero, roomIdx: state.adv.walking.roomIdx } },
epicsActive: state.epicsActive,
// Deck contents are hidden (stubs keep lengths honest); discards are public.
decks: {

View File

@ -136,6 +136,14 @@ export default class SWDBGGame extends Phaser.Scene {
this._rowPos = new Map(); // uid → {x,y}
this._playPos = new Map(); // uid → {x,y}
this._pendingCardUids = new Set(); // uids whose deal/refill animation hasn't landed yet
this._pendingDiscardUids = new Set(); // uids counted in a discard pile but not yet visually landed there
this._pendingDeckUids = new Set(); // same, for the rare "buy, topdeck it" ability
this._pendingAttackFrom = []; // {x,y}[] snapshot of the attacking squad's board positions, captured just before dispatch
this._rowSlots = new Map(); // uid → stable screen-slot index — a card keeps its slot for as long as it's in the row, so buying/losing one never shifts its neighbors
this._rowGhosts = new Map(); // uid → id — a row card (bought or bountied) still shown in its vacated slot until its own animation actually reaches/removes it
this._endTurnFromPos = new Map(); // uid → {x,y}, snapshot of an end-of-turn discard's in-play position, taken before it's cleared
this._endTurnHandLayout = new Map(); // uid → {idx,total}, virtual old-hand-slot layout for a human's end-of-turn hand discard
this._endTurnGhosts = new Map(); // uid → {seat,id,zone} — an end-of-turn discard stays visible in its old spot until its own fly-away animation begins
this._dealingInitial = false; // true only during the game-start deal sequence
this._actionButtons = [];
this.hoverTimer = null;
@ -456,6 +464,12 @@ export default class SWDBGGame extends Phaser.Scene {
cont.add(hl);
this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 });
}
if (opts.highlightDiscard) {
const ov = this.add.graphics();
ov.fillStyle(C.bad, 0.4); ov.fillRoundedRect(-w / 2, -h / 2, w, h, 7);
cont.add(ov);
this.tweens.add({ targets: ov, alpha: 0.12, duration: 460, yoyo: true, repeat: -1 });
}
const wantHoverPreview = opts._hoverBuild && !opts.noHoverPreview;
if (opts.onClick || wantHoverPreview) {
cont.setSize(w, h);
@ -552,10 +566,28 @@ export default class SWDBGGame extends Phaser.Scene {
const zone = this.add.zone(0, 0, w, h).setInteractive();
cardHolder.add(zone);
const closeBtn = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + h / 2 + 70, 'Close',
// offer Buy/Attack right alongside Close when this card is legally
// actionable right now — mutually exclusive (buy requires no committed
// squad, attack requires one), so at most one ever joins Close
const buyInfo = deepDiveInfo.kind === 'card' ? this.deepDiveBuyInfo(deepDiveInfo.inst) : null;
const canAttack = !buyInfo && deepDiveInfo.kind === 'card' && this.deepDiveAttackable(deepDiveInfo.inst);
const hasExtra = !!buyInfo || canAttack;
const btnY = GAME_HEIGHT / 2 + h / 2 + 70;
const closeBtn = new Button(this, hasExtra ? GAME_WIDTH / 2 + 90 : GAME_WIDTH / 2, btnY, 'Close',
() => this.closeDeepDive(), { width: 160, fontSize: 20 });
closeBtn.setDepth(DEPTH.overlay + 1);
root.add(closeBtn);
if (buyInfo) {
const buyBtn = new Button(this, GAME_WIDTH / 2 - 90, btnY, 'Buy',
() => this.deepDiveBuy(buyInfo), { width: 160, fontSize: 20 });
buyBtn.setDepth(DEPTH.overlay + 1);
root.add(buyBtn);
} else if (canAttack) {
const atkBtn = new Button(this, GAME_WIDTH / 2 - 90, btnY, 'Attack',
() => this.deepDiveAttack(deepDiveInfo.inst), { width: 160, fontSize: 20 });
atkBtn.setDepth(DEPTH.overlay + 1);
root.add(atkBtn);
}
this._deepDive = {
modal: root, cardHolder, zoneTimers: [], zoneNodes: [],
@ -645,7 +677,10 @@ export default class SWDBGGame extends Phaser.Scene {
this._deepDive.zoneNodes.push(box, line, pill);
}
closeDeepDive() {
// afterClose (optional) fires once the card has fully flown back to its
// original spot and the modal is gone — used by Buy/Attack so the actual
// action + its animations only start once the card is visually "returned"
closeDeepDive(afterClose) {
if (!this._deepDive) return;
const { modal, cardHolder, zoneTimers, zoneNodes, returnX, returnY } = this._deepDive;
zoneTimers.forEach((t) => t.remove());
@ -654,7 +689,47 @@ export default class SWDBGGame extends Phaser.Scene {
this.tweens.add({
targets: cardHolder, x: returnX, y: returnY,
duration: 280, ease: 'Cubic.easeIn',
onComplete: () => { modal.destroy(); this._deepDive = null; },
onComplete: () => { modal.destroy(); this._deepDive = null; afterClose?.(); },
});
}
// is this deep-dived card buyable by the human right now? Handles the
// Outer Rim Pilot specially — legalActions() keys its buy entry on the
// literal string 'outerrim', not the actual pilot card's own uid.
deepDiveBuyInfo(inst) {
if (this.squad.size) return null;
const legal = this.humanTurnLegal();
if (!legal) return null;
if (legal.buys.some((b) => b.uid === inst.uid)) return { uid: inst.uid };
const orp = this.gs.outerRim[this.gs.outerRim.length - 1];
if (orp && orp.uid === inst.uid && legal.buys.some((b) => b.uid === 'outerrim')) return { uid: 'outerrim' };
return null;
}
// is this deep-dived card a legal bounty target for the currently
// committed squad? rowTargetReachable() alone doesn't confirm the card is
// actually IN the row (it would also pass for a same-faction unit shown
// from elsewhere), so check row membership first.
deepDiveAttackable(inst) {
if (!this.squad.size || !this.gs.galaxy.row.some((c) => c.uid === inst.uid)) return false;
return this.rowTargetReachable(inst);
}
deepDiveBuy(buyInfo) {
const d = this.mode.decision;
this.closeDeepDive(() => {
this.sfx(SFX.PURCHASE);
this.applyDecision(d, { type: 'buy', uid: buyInfo.uid });
});
}
deepDiveAttack(inst) {
const d = this.mode.decision;
this.closeDeepDive(() => {
const uids = [...this.squad];
this.squad.clear();
this.sfx(SFX.SCIFI_LAUNCH);
this.applyDecision(d, { type: 'attackRow', targetUid: inst.uid, uids });
});
}
@ -751,6 +826,13 @@ export default class SWDBGGame extends Phaser.Scene {
onClick: () => this.showInspect(e.card),
});
});
// end-of-turn discard ghosts: a discarded in-play card stays put right up
// until its own fly-to-discard animation begins
for (const [uid, ghost] of this._endTurnGhosts) {
if (ghost.zone !== 'inPlay' || ghost.seat !== seat) continue;
const pos = this._endTurnFromPos.get(uid);
if (pos) this.makeCard(pos.x, pos.y, { uid, id: ghost.id }, 84, 118, { parent: this.boardLayer, hover: false });
}
// counters
const cx = GAME_WIDTH - 220;
const lines = [
@ -774,8 +856,13 @@ export default class SWDBGGame extends Phaser.Scene {
// portrait so pile sizes are visible at a glance without opening any panel
renderOpponentPiles(x, p) {
const pw = 30, ph = 42;
// while the old hand is still discarding (each ghost clears the instant
// its own fly-away animation begins), show that shrinking count instead
// of the new hand's — which only starts counting up once every old ghost
// is gone
const oldHandGhosts = [...this._endTurnGhosts.values()].filter((g) => g.zone === 'hand' && g.seat === p.seat).length;
const pendingInHand = p.hand.filter((c) => this._pendingCardUids.has(c.uid)).length;
const visibleCount = p.hand.length - pendingInHand;
const visibleCount = oldHandGhosts > 0 ? oldHandGhosts : p.hand.length - pendingInHand;
const shown = Math.min(visibleCount, 4);
const fanY = 72;
for (let i = 0; i < shown; i++) {
@ -798,8 +885,23 @@ export default class SWDBGGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.muted,
}).setOrigin(0.5));
};
pile(132, p.deck.length, 'DRAW');
pile(192, p.discard.length, 'DISCARD');
pile(132, this.visibleDeckCount(p), 'DRAW');
pile(192, this.visibleDiscardCount(p), 'DISCARD');
}
// a bought card is already in p.discard state-wise the instant it's
// purchased, but its reveal-then-stow animation hasn't landed yet — lag the
// displayed discard count by however many of its cards are still pending
visibleDiscardCount(p) {
if (!this._pendingDiscardUids.size) return p.discard.length;
return p.discard.length - p.discard.filter((c) => this._pendingDiscardUids.has(c.uid)).length;
}
// same idea for the draw pile — only relevant for the rare "buy it and
// topdeck it" ability, which sends a bought card to the deck instead
visibleDeckCount(p) {
if (!this._pendingDeckUids.size) return p.deck.length;
return p.deck.length - p.deck.filter((c) => this._pendingDeckUids.has(c.uid)).length;
}
renderBasePips(x, y, p, info) {
@ -846,7 +948,29 @@ export default class SWDBGGame extends Phaser.Scene {
const rowX0 = 510;
const legal = this.humanTurnLegal();
const humanFaction = gs.players[this.humanSeat].faction;
gs.galaxy.row.forEach((card, i) => {
// stable per-card slot assignment: a card keeps its screen slot for as
// long as it's in the row; when it leaves, that slot frees up for
// whatever card claims it next — so buying (or losing) a row card never
// shifts its neighbors, and a refill naturally lands in the same slot
// its predecessor vacated. A bought/bountied card's slot is held open
// (via _rowGhosts) until its own animation actually reaches/removes it,
// so nothing else can claim it while it's still visible.
const liveUids = new Set(gs.galaxy.row.map((c) => c.uid));
for (const uid of [...this._rowSlots.keys()]) {
if (!liveUids.has(uid) && !this._rowGhosts.has(uid)) this._rowSlots.delete(uid);
}
const usedSlots = new Set(this._rowSlots.values());
for (const card of gs.galaxy.row) {
if (this._rowSlots.has(card.uid)) continue;
let slot = 0;
while (usedSlots.has(slot)) slot++;
if (slot >= this.gs.meta.galaxyRowSize) continue; // no free slot yet
this._rowSlots.set(card.uid, slot);
usedSlots.add(slot);
}
gs.galaxy.row.forEach((card) => {
const i = this._rowSlots.get(card.uid);
if (i == null) return;
const def = cardDef(card);
const slot = this.galaxyRowSlot(i, def.faction, humanFaction);
this._rowPos.set(card.uid, { x: slot.x, y: slot.y });
@ -861,6 +985,16 @@ export default class SWDBGGame extends Phaser.Scene {
onClick: () => this.onRowClicked(card),
});
});
// ghosts: a just-bought or just-bountied card still occupies its vacated
// slot until its own animation actually reaches/removes it
for (const [uid, id] of this._rowGhosts) {
const gi = this._rowSlots.get(uid);
if (gi == null) continue;
const def = cardDef(id);
const slot = this.galaxyRowSlot(gi, def.faction, humanFaction);
this._rowPos.set(uid, { x: slot.x, y: slot.y });
this.makeCard(slot.x, slot.y, { uid, id }, slot.w, slot.h, { parent: this.rowLayer, hover: false, showText: true });
}
// Outer Rim Pilot stack
if (gs.outerRim.length) {
const orp = getData().json.outerRimPilot;
@ -987,6 +1121,13 @@ export default class SWDBGGame extends Phaser.Scene {
});
if (canUse) this.addUsePill(slot.x, this.usePillY(slot.y, slot.h), () => this.beginAbility({ zone: 'play', uid: e.card.uid }));
});
// end-of-turn discard ghosts: a discarded in-play card stays put right up
// until its own fly-to-discard animation begins
for (const [uid, ghost] of this._endTurnGhosts) {
if (ghost.zone !== 'inPlay' || ghost.seat !== seat) continue;
const pos = this._endTurnFromPos.get(uid);
if (pos) this.makeCard(pos.x, pos.y, { uid, id: ghost.id }, 122, 170, { parent: this.boardLayer, hover: false });
}
if (!units.length && !p.capitals.length) {
this.boardLayer.add(this.add.text(1180, playRowY, 'cards you play land here', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a3c66',
@ -1030,8 +1171,16 @@ export default class SWDBGGame extends Phaser.Scene {
const p = gs.players[this.humanSeat];
const y = 985;
// draw pile sits just under the human's base card (base is 392,730 size 300x190)
this.renderHandPile(392, 911, p.deck.length, 'DRAW');
this.renderHandPile(GAME_WIDTH - 205, y, p.discard.length, 'DISCARD');
this.renderHandPile(392, 911, this.visibleDeckCount(p), 'DRAW');
this.renderHandPile(GAME_WIDTH - 205, y, this.visibleDiscardCount(p), 'DISCARD');
// end-of-turn discard ghosts: a discarded hand card stays put in its
// virtual old-hand slot right up until its own fly-to-discard animation
// begins (the real hand has already been replaced by then)
for (const [uid, ghost] of this._endTurnGhosts) {
if (ghost.zone !== 'hand' || ghost.seat !== this.humanSeat) continue;
const layout = this._endTurnHandLayout.get(uid);
if (layout) this.makeCard(this.humanHandSlotX(layout.idx, layout.total), y, { uid, id: ghost.id }, 148, 206, { parent: this.handLayer, hover: false, showText: true });
}
if (!p.hand.length) return;
const cw = 148, chh = 206;
const pitch = Math.min(cw + 8, 1250 / Math.max(1, p.hand.length));
@ -1041,11 +1190,17 @@ export default class SWDBGGame extends Phaser.Scene {
x += pitch;
if (this._pendingCardUids.has(inst.uid)) continue;
const playable = this.myTurnMode();
const discardable = this.mode.type === 'oppDiscard' || (this.mode.type === 'target' && this.mode.decision?.op === 'exileCards'
&& (this.mode.candidates || []).some((c) => c.zone === 'hand' && c.uid === inst.uid));
// forced discard (an opponent's effect) reads as something being done
// TO the player, so it gets its own pulsing red overlay rather than the
// gold "you get to pick" cue used for a voluntary exile-as-cost choice
const forcedDiscard = this.mode.type === 'oppDiscard';
const exileCost = this.mode.type === 'target' && this.mode.decision?.op === 'exileCards'
&& (this.mode.candidates || []).some((c) => c.zone === 'hand' && c.uid === inst.uid);
const discardable = forcedDiscard || exileCost;
this.makeCard(cardX, y, inst, cw, chh, {
parent: this.handLayer, showText: true,
highlightGold: discardable,
highlightGold: exileCost,
highlightDiscard: forcedDiscard,
onClick: () => this.onHandClicked(inst),
});
if (!playable && !discardable) this.handLayer.list[this.handLayer.list.length - 1].setAlpha(0.9);
@ -1220,6 +1375,15 @@ export default class SWDBGGame extends Phaser.Scene {
}
applyDecision(d, choice) {
// snapshot the attacking squad's board positions here (not just in the
// human click handlers) since AI-driven attackRow/attackBase choices are
// built directly in SWDBGAI.js and dispatched straight into this method,
// bypassing onRowClicked()/launchBaseAttack() entirely — _playPos is still
// valid here, as nothing re-renders between a decision being made and
// applyDecision running
if (choice?.type === 'attackRow' || choice?.type === 'attackBase') {
this._pendingAttackFrom = (choice.uids || []).map((uid) => this._playPos.get(uid)).filter(Boolean);
}
try {
switch (d.kind) {
case 'turn': actTurn(this.gs, d.seat, choice); break;
@ -1546,8 +1710,46 @@ export default class SWDBGGame extends Phaser.Scene {
playEvents(events, opts = {}) {
this.busy = true;
this.setPrompt('');
// a purchase or a bounty kill always emits its rowRefill just before its
// own buy/bounty event (SWDBGLogic's removeRowCard refills before
// buyCard/attackRow emit) — swap them so the bought/defeated card's full
// animation plays out (arc + reveal/explosion) before the replacement
// card animates into the vacated row slot
events = events.slice();
for (let i = 1; i < events.length; i++) {
if ((events[i].type === 'buy' || events[i].type === 'bounty') && events[i - 1].type === 'rowRefill') {
[events[i - 1], events[i]] = [events[i], events[i - 1]];
}
}
// an end-of-turn discard batch has no stored position for the old hand's
// cards (by the time these events are read, endTurn() has already drawn
// the NEW hand) — reconstruct the human's virtual old-hand layout purely
// from the emitted events' own order (one 'endTurnDiscard' zone:'hand'
// event per old card, in original hand order)
const handDiscardsBySeat = new Map();
for (const e of events) {
if (e.type === 'endTurnDiscard' && e.zone === 'hand') {
if (!handDiscardsBySeat.has(e.seat)) handDiscardsBySeat.set(e.seat, []);
handDiscardsBySeat.get(e.seat).push(e.uid);
}
}
for (const uids of handDiscardsBySeat.values()) {
uids.forEach((uid, idx) => this._endTurnHandLayout.set(uid, { idx, total: uids.length }));
}
for (const e of events) {
if ((e.type === 'draw' || e.type === 'rowRefill' || e.type === 'play') && e.uid != null) this._pendingCardUids.add(e.uid);
if (e.type === 'buy' && e.uid != null) {
if (this._rowPos.has(e.uid)) this._rowGhosts.set(e.uid, e.id);
if (e.topdeck) this._pendingDeckUids.add(e.uid); else this._pendingDiscardUids.add(e.uid);
}
if (e.type === 'bounty' && e.uid != null && this._rowPos.has(e.uid)) {
this._rowGhosts.set(e.uid, e.id);
}
if (e.type === 'endTurnDiscard' && e.uid != null) {
this._pendingDiscardUids.add(e.uid);
this._endTurnGhosts.set(e.uid, { seat: e.seat, id: e.id, zone: e.zone });
if (e.zone === 'inPlay' && this._playPos.has(e.uid)) this._endTurnFromPos.set(e.uid, this._playPos.get(e.uid));
}
}
const queue = events.filter((e) => this.eventDelay(e) > 0);
const step = () => {
@ -1571,9 +1773,9 @@ export default class SWDBGGame extends Phaser.Scene {
switch (e.type) {
case 'turnStart': return 460;
case 'play': return aiEvent ? 300 : 220;
case 'buy': return 420;
case 'bounty': return 640;
case 'attackBase': return 380;
case 'buy': return 2300; // 500 funding arc + 420 reveal + 1000 hold + 380 flip-to-discard
case 'bounty': return 1140; // 500 attack arc + 640 existing popText/banner hold
case 'attackBase': return 600; // 500 attack arc + 100 buffer before capitalDamage/baseDamage resolve
case 'capitalDamage': return 300;
case 'capitalDestroyed': return 520;
case 'baseDamage': return 460;
@ -1590,23 +1792,29 @@ export default class SWDBGGame extends Phaser.Scene {
case 'galaxyReshuffle': return 300;
case 'draw': return this._dealingInitial ? 300 : 260;
case 'rowRefill': return this._dealingInitial ? 320 : 280;
case 'endTurnDiscard': return 200;
default: return 0;
}
}
// fly + resize + optional mid-flight flip, used for dealing a card from a
// pile (draw pile / galaxy deck) to its final hand/row slot. The flip runs
// inside a nested container so its scaleX collapse doesn't fight the outer
// tween's own position/resize scale, and is timed to finish with margin
// before the outer tween's onComplete destroys everything.
// pile (draw pile / galaxy deck / center-screen reveal) to its final slot.
// startAs picks the initial visual ('back' or 'face'); flipTo, if set,
// swaps it mid-flight to 'face' or 'back' — covering reveals (back->face)
// and cards going into a hidden pile (face->back). The flip runs inside a
// nested container so its scaleX collapse doesn't fight the outer tween's
// own position/resize scale, and is timed to finish with margin before the
// outer tween's onComplete destroys everything.
dealCardAnimated(opts) {
const {
fromX, fromY, fromW, fromH,
toX, toY, toW, toH,
toAngle = 0,
flipToId = null, // card def id to reveal mid-flight; null = stays a card-back
faceId = null, // card def id to show face-up for the whole flight (no back stage at all)
startAs = 'back', // 'back' | 'face' — visual at the start of the flight
flipTo = null, // 'face' | 'back' | null — what it flips into mid-flight, if anything
id = null, // card def id, needed whenever a face is shown (start and/or flip target)
duration = 280,
keepAlive = false, // if true, don't destroy the token on arrival — caller owns its lifecycle via onLand(token)
onLand,
} = opts;
const outer = this.add.container(fromX, fromY);
@ -1614,25 +1822,24 @@ export default class SWDBGGame extends Phaser.Scene {
this.fxLayer.add(outer);
const flipHost = this.add.container(0, 0);
outer.add(flipHost);
const back = faceId != null
? this.makeCard(0, 0, { uid: -1, id: faceId }, toW, toH, { parent: flipHost, hover: false, showText: true, noHoverPreview: true })
const buildSide = (side) => side === 'face'
? this.makeCard(0, 0, { uid: -1, id }, toW, toH, { parent: flipHost, hover: false, showText: true, noHoverPreview: true })
: this.makeCardBack(0, 0, toW, toH, flipHost);
let side = buildSide(startAs);
this.tweens.add({
targets: outer, x: toX, y: toY, scaleX: 1, scaleY: 1, angle: toAngle,
duration, ease: 'Cubic.easeInOut',
onComplete: () => { outer.destroy(); onLand?.(); },
onComplete: () => { if (!keepAlive) outer.destroy(); onLand?.(outer); },
});
if (flipToId != null) {
if (flipTo != null) {
this.tweens.add({
targets: flipHost, scaleX: 0, duration: duration * 0.35, delay: duration * 0.15,
ease: 'Cubic.easeIn',
onComplete: () => {
back.destroy();
this.makeCard(0, 0, { uid: -1, id: flipToId }, toW, toH, {
parent: flipHost, hover: false, showText: true, noHoverPreview: true,
});
side.destroy();
side = buildSide(flipTo);
this.tweens.add({ targets: flipHost, scaleX: 1, duration: duration * 0.35, ease: 'Cubic.easeOut' });
},
});
@ -1648,7 +1855,7 @@ export default class SWDBGGame extends Phaser.Scene {
this.dealCardAnimated({
fromX: 392, fromY: 911, fromW: 90, fromH: 124,
toX: this.humanHandSlotX(idx, p.hand.length), toY: 985, toW: 148, toH: 206,
flipToId: e.id, duration: dur,
startAs: 'back', flipTo: 'face', id: e.id, duration: dur,
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
});
} else {
@ -1659,12 +1866,56 @@ export default class SWDBGGame extends Phaser.Scene {
this.dealCardAnimated({
fromX: 570, fromY: 132, fromW: 30, fromH: 42,
toX: 570 + off * 7, toY: 72, toW: 30, toH: 42, toAngle: off * 7,
flipToId: null, duration: dur,
startAs: 'back', flipTo: null, duration: dur,
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
});
}
}
// end-of-turn cleanup: every discarded in-play unit and every discarded
// hand card stays visible in its original spot (via _endTurnGhosts) right
// up until this exact moment, then flies individually to the owner's
// discard pile. In-play cards fly from their pre-mutation board position
// (_endTurnFromPos, snapshotted in playEvents); hand cards fly from a
// virtual old-hand layout reconstructed purely from event order
// (_endTurnHandLayout), since the real hand has already been replaced with
// the new one by the time this event is processed. Own cards flip
// face-down (the discard pile is shown as a back); the opponent's hand
// cards were already hidden, so no flip.
animateEndTurnDiscard(e) {
const isHuman = e.seat === this.humanSeat;
const dest = isHuman
? { x: GAME_WIDTH - 205, y: 985, w: 90, h: 124 }
: { x: 570, y: 192, w: 30, h: 42 };
let fromX, fromY, fromW, fromH, startAs, flipTo;
if (e.zone === 'inPlay') {
const pos = this._endTurnFromPos.get(e.uid) || dest;
this._endTurnFromPos.delete(e.uid);
fromX = pos.x; fromY = pos.y;
fromW = isHuman ? 122 : 84; fromH = isHuman ? 170 : 118;
startAs = 'face'; flipTo = 'back';
} else if (isHuman) {
const layout = this._endTurnHandLayout.get(e.uid) || { idx: 0, total: 1 };
this._endTurnHandLayout.delete(e.uid);
fromX = this.humanHandSlotX(layout.idx, layout.total); fromY = 985;
fromW = 148; fromH = 206;
startAs = 'face'; flipTo = 'back';
} else {
this._endTurnHandLayout.delete(e.uid);
fromX = 570; fromY = 72;
fromW = 30; fromH = 42;
startAs = 'back'; flipTo = null;
}
this._endTurnGhosts.delete(e.uid);
this.renderAll();
this.dealCardAnimated({
fromX, fromY, fromW, fromH,
toX: dest.x, toY: dest.y, toW: dest.w, toH: dest.h,
startAs, flipTo, id: e.id, duration: 200,
onLand: () => { this._pendingDiscardUids.delete(e.uid); this.renderAll(); },
});
}
// opponent's hand is only ever shown as a generic face-down fan, so a played
// card animates out of that fan (reveal-flip included, since playing it is
// exactly the moment it stops being secret) into its capital/in-play slot
@ -1677,7 +1928,7 @@ export default class SWDBGGame extends Phaser.Scene {
this.dealCardAnimated({
fromX: 570, fromY: 72, fromW: 30, fromH: 42,
toX: slot.x, toY: slot.y, toW: slot.w, toH: slot.h,
flipToId: e.id, duration: 300,
startAs: 'back', flipTo: 'face', id: e.id, duration: 300,
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
});
}
@ -1694,25 +1945,89 @@ export default class SWDBGGame extends Phaser.Scene {
this.dealCardAnimated({
fromX: GAME_WIDTH / 2, fromY: 985, fromW: 60, fromH: 84,
toX: slot.x, toY: slot.y, toW: slot.w, toH: slot.h,
faceId: e.id, duration: 220,
startAs: 'face', flipTo: null, id: e.id, duration: 220,
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
});
}
animateRowRefill(e) {
const row = this.gs.galaxy.row;
const idx = row.findIndex((c) => c.uid === e.uid);
if (idx < 0) { this._pendingCardUids.delete(e.uid); return; }
// the stable slot map (assigned in renderGalaxy()) already places this
// card in whatever slot its predecessor vacated, so it always lands
// exactly where the purchased/lost card used to sit
const idx = this._rowSlots.get(e.uid);
if (idx == null) { this._pendingCardUids.delete(e.uid); return; }
const humanFaction = this.gs.players[this.humanSeat].faction;
const slot = this.galaxyRowSlot(idx, cardDef(e.id).faction, humanFaction);
this.dealCardAnimated({
fromX: 330, fromY: 430, fromW: 158 * 0.9, fromH: 222 * 0.9,
toX: slot.x, toY: slot.y, toW: slot.w, toH: slot.h,
flipToId: e.id, duration: this._dealingInitial ? 320 : 280,
startAs: 'back', flipTo: 'face', id: e.id, duration: this._dealingInitial ? 320 : 280,
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
});
}
// buying a card is a four-beat moment: funding arcs converge on it while it
// still sits in its row slot (rendered via _rowGhosts), then it flies up to
// a big center-screen reveal (already face-up, no flip) — leaving its row
// slot empty from that instant on — holds there so it can be read, then
// flips face-down and shrinks into the buyer's discard pile (or, for the
// rare "topdeck it" ability, the draw pile instead). The row's own
// replacement card (a separate 'rowRefill' event, reordered to play right
// after this one in playEvents) lands in that same now-empty slot only
// once this whole sequence completes.
animateBuy(e) {
const src = this._rowPos.get(e.uid) || this._orpPos || { x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 };
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const bigW = 316, bigH = 444; // 2x the on-board row/outer-rim card size
const dest = e.topdeck
? (e.seat === this.humanSeat ? { x: 392, y: 911, w: 90, h: 124 } : { x: 570, y: 132, w: 30, h: 42 })
: (e.seat === this.humanSeat ? { x: GAME_WIDTH - 205, y: 985, w: 90, h: 124 } : { x: 570, y: 192, w: 30, h: 42 });
const land = () => {
this._pendingCardUids.delete(e.uid);
this._pendingDiscardUids.delete(e.uid);
this._pendingDeckUids.delete(e.uid);
this.renderAll();
};
const startReveal = () => {
// the card leaves its row slot the instant it starts flying to the
// center-screen reveal — clearing the ghost here (rather than at the
// very end) is what frees the slot up for the eventual rowRefill card
if (this._rowGhosts.get(e.uid) === e.id) { this._rowGhosts.delete(e.uid); this.renderAll(); }
this.dealCardAnimated({
fromX: src.x, fromY: src.y, fromW: 158, fromH: 222,
toX: cx, toY: cy, toW: bigW, toH: bigH,
startAs: 'face', flipTo: null, id: e.id, duration: 420,
keepAlive: true,
onLand: (token) => {
this.time.delayedCall(1000, () => {
token.destroy();
this.dealCardAnimated({
fromX: cx, fromY: cy, fromW: bigW, fromH: bigH,
toX: dest.x, toY: dest.y, toW: dest.w, toH: dest.h,
startAs: 'face', flipTo: 'back', id: e.id, duration: 380,
onLand: land,
});
});
},
});
};
// Funding arcs: gold arcs converge from every one of the buyer's currently
// in-play cards (units + capitals — unaffected by the purchase itself) onto
// the row/outer-rim slot being bought, before the reveal-hold-stow sequence
// starts. _playPos is read live since the buyer's board is untouched by a
// purchase; _rowPos for the bought card itself is kept fresh by the
// _rowGhosts render in renderGalaxy() until startReveal() clears it.
const buyer = this.gs.players[e.seat];
const sources = [...buyer.inPlay, ...buyer.capitals]
.map((entry) => this._playPos.get(entry.card.uid))
.filter(Boolean);
if (!sources.length) {
const basePos = e.seat === this.humanSeat ? this._myBasePos : this._oppBasePos;
if (basePos) sources.push(basePos);
}
this.drawArcArrows(sources, src.x, src.y, C.gold, {}, startReveal);
}
eventFx(e) {
switch (e.type) {
case 'turnStart':
@ -1725,31 +2040,40 @@ export default class SWDBGGame extends Phaser.Scene {
if (e.seat !== this.humanSeat) this.animatePlay(e);
else this.animateHumanPlay(e);
break;
case 'buy': {
case 'buy':
this.sfx(SFX.PURCHASE);
const from = this._rowPos.get(e.uid) || this._orpPos;
if (from) {
const tok = this.makeCardBack(from.x, from.y, 110, 154, this.fxLayer);
const to = e.seat === this.humanSeat ? { x: 140, y: 940 } : { x: GAME_WIDTH - 220, y: 90 };
this.tweens.add({ targets: tok, x: to.x, y: to.y, scale: 0.3, alpha: 0.4, duration: 380, ease: 'Cubic.easeIn', onComplete: () => tok.destroy() });
}
this.animateBuy(e);
break;
}
case 'bounty': {
this.sfx(SFX.SCIFI_EXPLODE);
// the target stays visible in its row slot (via _rowGhosts, held
// open the same way a bought card is) until the arrow actually
// reaches it
const at = this._rowPos.get(e.uid);
const from = this._pendingAttackFrom;
this._pendingAttackFrom = [];
const impact = () => {
if (this._rowGhosts.get(e.uid) === e.id) { this._rowGhosts.delete(e.uid); this.renderAll(); }
this.sfx(SFX.SCIFI_EXPLODE);
if (at) {
this.popText(at.x, at.y, '💥', '#ffffff', 44);
this.popText(at.x, at.y - 56, `${cardDef(e.id).name} defeated!`, C.goldHex, 20);
}
};
if (at && from.length) this.drawArcArrows(from, at.x, at.y, C.bad, {}, impact);
else impact();
break;
}
case 'attackBase':
case 'attackBase': {
this.sfx(SFX.SCIFI_LAUNCH);
const dest = e.seat === this.humanSeat ? this._oppBasePos : this._myBasePos;
const from = this._pendingAttackFrom;
this._pendingAttackFrom = [];
if (dest && from.length) this.drawArcArrows(from, dest.x, dest.y, C.bad, {});
break;
}
case 'capitalDamage': {
const at = this._playPos.get(e.uid);
if (at) { this.popText(at.x, at.y - 20, `-${e.n}`, '#ff6b5e', 26); this.shakeAt(at.x, at.y); }
if (at) { this.popText(at.x, at.y - 20, `-${e.n}`, '#ff6b5e', 36); this.shakeAt(at.x, at.y); }
break;
}
case 'capitalDestroyed': {
@ -1762,7 +2086,7 @@ export default class SWDBGGame extends Phaser.Scene {
case 'baseDamage': {
const pos = e.seat === this.humanSeat ? this._myBasePos : this._oppBasePos;
if (pos) {
this.popText(pos.x, pos.y - 40, `-${e.n}`, '#ff5348', 34);
this.popText(pos.x, pos.y - 40, `-${e.n}`, '#ff5348', 44);
const flash = this.add.rectangle(pos.x, pos.y, 310, 200, 0xd6604d, 0.35).setDepth(DEPTH.fx);
this.tweens.add({ targets: flash, alpha: 0, duration: 380, onComplete: () => flash.destroy() });
this.sfx(SFX.BATTLESHIP_HIT);
@ -1823,6 +2147,10 @@ export default class SWDBGGame extends Phaser.Scene {
this.sfx(SFX.CARD_DEAL);
this.animateRowRefill(e);
break;
case 'endTurnDiscard':
this.sfx(SFX.CARD_PLACE);
this.animateEndTurnDiscard(e);
break;
default: break;
}
}
@ -1841,6 +2169,98 @@ export default class SWDBGGame extends Phaser.Scene {
this.tweens.add({ targets: g, scale: 1.6, alpha: 0, duration: 300, onComplete: () => g.destroy() });
}
// Risk-style converging attack arc: manual quadratic-bezier re-rasterized every
// tween tick (not Phaser's Curves.QuadraticBezier), with a white-outline pass
// under the colored pass and an arrowhead aligned to the curve's tangent at the
// tip. Callback-style (onDone) to match this file's other animation helpers.
drawArcArrow(fromX, fromY, toX, toY, color, opts = {}) {
const { duration = 500, archCap = 90, linger = 120, onDone } = opts;
const dx = toX - fromX, dy = toY - fromY;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
let perpX = -dy / len, perpY = dx / len;
if (perpY > 0) { perpX = -perpX; perpY = -perpY; } // always arch toward top of screen
const archH = Math.min(len * 0.35, archCap);
const cpx = (fromX + toX) / 2 + perpX * archH;
const cpy = (fromY + toY) / 2 + perpY * archH;
const g = this.add.graphics().setDepth(DEPTH.fx);
const LINE_W = 5, STROKE_W = 2, ARROW_LEN = 20, ARROW_WID = 9;
const drawArc = (t) => {
g.clear();
const steps = Math.max(2, Math.ceil(t * 48));
const pts = [];
for (let i = 0; i <= steps; i++) {
const tt = (i / steps) * t;
pts.push(
(1 - tt) * (1 - tt) * fromX + 2 * (1 - tt) * tt * cpx + tt * tt * toX,
(1 - tt) * (1 - tt) * fromY + 2 * (1 - tt) * tt * cpy + tt * tt * toY,
);
}
const drawPath = (w, col, alpha) => {
g.lineStyle(w, col, alpha);
g.beginPath();
for (let i = 0; i <= steps; i++) {
if (i === 0) g.moveTo(pts[i * 2], pts[i * 2 + 1]);
else g.lineTo(pts[i * 2], pts[i * 2 + 1]);
}
g.strokePath();
};
drawPath(LINE_W + STROKE_W * 2, 0xffffff, 0.85);
drawPath(LINE_W, color, 0.92);
if (t > 0.05) {
const prevT = Math.max(0, t - 0.04);
const tx = (1 - t) * (1 - t) * fromX + 2 * (1 - t) * t * cpx + t * t * toX;
const ty = (1 - t) * (1 - t) * fromY + 2 * (1 - t) * t * cpy + t * t * toY;
const px = (1 - prevT) * (1 - prevT) * fromX + 2 * (1 - prevT) * prevT * cpx + prevT * prevT * toX;
const py = (1 - prevT) * (1 - prevT) * fromY + 2 * (1 - prevT) * prevT * cpy + prevT * prevT * toY;
const adx = tx - px, ady = ty - py;
const aLen = Math.sqrt(adx * adx + ady * ady) || 1;
const ax = adx / aLen, ay = ady / aLen;
const s = STROKE_W;
g.fillStyle(0xffffff, 0.85);
g.fillTriangle(
tx + ax * s, ty + ay * s,
tx - ax * (ARROW_LEN + s) + ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) - ax * (ARROW_WID + s),
tx - ax * (ARROW_LEN + s) - ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) + ax * (ARROW_WID + s),
);
g.fillStyle(color, 0.95);
g.fillTriangle(
tx, ty,
tx - ax * ARROW_LEN + ay * ARROW_WID, ty - ay * ARROW_LEN - ax * ARROW_WID,
tx - ax * ARROW_LEN - ay * ARROW_WID, ty - ay * ARROW_LEN + ax * ARROW_WID,
);
}
};
const progress = { t: 0 };
this.tweens.add({
targets: progress, t: 1, duration, ease: 'Sine.easeInOut',
onUpdate: () => drawArc(progress.t),
onComplete: () => {
drawArc(1);
onDone?.();
this.time.delayedCall(linger, () => g.destroy());
},
});
}
// Fan-in wrapper: fires one drawArcArrow per source point simultaneously,
// calling onAllDone once every arc has individually finished. Used both for
// the buy funding arc (many sources converge on one purchase) and the attack
// arc (many attacking squad members converge on one target).
drawArcArrows(points, toX, toY, color, opts = {}, onAllDone) {
if (!points.length) { onAllDone?.(); return; }
let remaining = points.length;
for (const p of points) {
this.drawArcArrow(p.x, p.y, toX, toY, color, {
...opts,
onDone: () => { if (--remaining === 0) onAllDone?.(); },
});
}
}
showBanner(text) {
const banner = this.add.text(GAME_WIDTH / 2, 300, text, {
fontFamily: 'Righteous', fontSize: '30px', color: C.text,

View File

@ -700,9 +700,15 @@ function attackBase(state, seat, action) {
function endTurn(state, seat) {
const p = P(state, seat);
for (const e of p.inPlay) p.discard.push(e.card);
for (const e of p.inPlay) {
p.discard.push(e.card);
emit(state, { type: 'endTurnDiscard', seat, uid: e.card.uid, id: e.card.id, zone: 'inPlay' });
}
p.inPlay = [];
for (const c of p.hand) p.discard.push(c);
for (const c of p.hand) {
p.discard.push(c);
emit(state, { type: 'endTurnDiscard', seat, uid: c.uid, id: c.id, zone: 'hand' });
}
p.hand = [];
p.resources = 0;
drawCards(state, seat, state.meta.handSize);

View File

@ -108,7 +108,15 @@ function driveDecision(st, d, skill, rnd, tally) {
actBuild(st, d.seat, choice);
break;
}
case 'window': actWindow(st, d.seat, choice); if (tally && choice && !choice.pass) tally.casts++; break;
case 'window': {
const windowId = d.window;
actWindow(st, d.seat, choice);
if (tally && choice && !choice.pass) {
tally.casts++;
if (windowId === 'advRoom') tally.advRoomCasts++;
}
break;
}
case 'react': actReact(st, d.seat, choice); break;
case 'target': actChooseTarget(st, d.seat, choice); break;
case 'discard': actDiscard(st, d.seat, choice); break;
@ -120,7 +128,7 @@ function driveDecision(st, d, skill, rnd, tally) {
function playGame(nPlayers, seed, skills, collect = null) {
const st = newGame(nPlayers, seed);
const rnd = makeRng(seed ^ 0x5eed);
const tally = { casts: 0 };
const tally = { casts: 0, advRoomCasts: 0 };
let steps = 0;
const eventLog = [];
while (!isOver(st)) {
@ -163,6 +171,7 @@ console.log('Self-play soak (320 games)…');
{
let totalRounds = 0;
let totalCasts = 0;
let totalAdvRoomCasts = 0;
let eliminations = 0;
const winReasons = {};
const winsBySeat = {};
@ -174,6 +183,7 @@ console.log('Self-play soak (320 games)…');
games++;
totalRounds += st.round;
totalCasts += tally.casts;
totalAdvRoomCasts += tally.advRoomCasts;
eliminations += st.players.filter((p) => !p.alive).length;
const over = st.events; // drained already; use final state
ok(st.winner != null, `game ${g}: has a winner`);
@ -192,11 +202,12 @@ console.log('Self-play soak (320 games)…');
ok(ranking[0] === st.winner || !w.alive === false, `game ${g}: winner ranks first`);
}
console.log(` ${games} games · avg rounds ${(totalRounds / games).toFixed(1)}`
+ ` · spell/ability activations ${totalCasts}`
+ ` · spell/ability activations ${totalCasts} (${totalAdvRoomCasts} mid-walk)`
+ ` · eliminations ${eliminations}`
+ ` · endings ${JSON.stringify(winReasons)}`
+ ` · wins by seat ${JSON.stringify(winsBySeat)}`);
ok(totalCasts > games, 'AI actually casts spells / activates rooms');
ok(totalAdvRoomCasts > 0, 'AI actually uses the mid-walk advRoom casting window');
ok((winReasons.souls || 0) > games * 0.25, 'a healthy share of mixed-skill games end on 10 souls');
}
@ -306,10 +317,16 @@ function driveTo(st, predicate, maxSteps = 5000) {
const tough = Object.keys(HEROES).find((id) => HEROES[id].epic);
p.entrance = [{ uid: 777777, id: tough, hp: HEROES[tough].hp, hpMax: HEROES[tough].hp }];
const before = p.wounds;
// Run just the adventure by driving until the entrance empties.
// Run just the adventure by driving until this hero's walk (queued, or mid-
// crawl and paused at a mid-walk advRoom window) finishes — entrance alone
// isn't enough any more: it empties the instant the hero starts walking,
// but the walk itself can now pause (for an advRoom casting window) partway
// through, before the boss-wound step that's actually being asserted here.
const rnd = makeRng(5);
let steps = 0;
while (p.entrance.length && !isOver(st) && steps < 5000) {
const stillWalking = () => p.entrance.some((h) => h.uid === 777777)
|| (st.adv && st.adv.walking && st.adv.walking.hero.uid === 777777);
while (stillWalking() && !isOver(st) && steps < 5000) {
steps++;
const d = pendingDecision(st);
driveDecision(st, d, 1, rnd, null);