diff --git a/assets/images/swdbg-bases-art.png b/assets/images/swdbg-bases-art.png index 499c83d..add8a97 100644 Binary files a/assets/images/swdbg-bases-art.png and b/assets/images/swdbg-bases-art.png differ diff --git a/assets/images/swdbg-bases-art.psd b/assets/images/swdbg-bases-art.psd index 07ee6ae..f271c13 100644 Binary files a/assets/images/swdbg-bases-art.psd and b/assets/images/swdbg-bases-art.psd differ diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 65421ba..3c7a1f6 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -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' }); diff --git a/src/games/dungeonboss/DungeonBossAI.js b/src/games/dungeonboss/DungeonBossAI.js index 62639f6..d7247ca 100644 --- a/src/games/dungeonboss/DungeonBossAI.js +++ b/src/games/dungeonboss/DungeonBossAI.js @@ -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); diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js index c42f53a..c13b3e8 100644 --- a/src/games/dungeonboss/DungeonBossGame.js +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -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 }; diff --git a/src/games/dungeonboss/DungeonBossLogic.js b/src/games/dungeonboss/DungeonBossLogic.js index 2d8d6af..84ca886 100644 --- a/src/games/dungeonboss/DungeonBossLogic.js +++ b/src/games/dungeonboss/DungeonBossLogic.js @@ -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) { - const q = P(state, found.seat).entrance; - q.splice(q.findIndex((h) => h.uid === found.hero.uid), 1); + 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: { diff --git a/src/games/swdbg/SWDBGGame.js b/src/games/swdbg/SWDBGGame.js index 2c09372..b82caa8 100644 --- a/src/games/swdbg/SWDBGGame.js +++ b/src/games/swdbg/SWDBGGame.js @@ -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); - 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); - } + 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, diff --git a/src/games/swdbg/SWDBGLogic.js b/src/games/swdbg/SWDBGLogic.js index 4a66790..24e96b4 100644 --- a/src/games/swdbg/SWDBGLogic.js +++ b/src/games/swdbg/SWDBGLogic.js @@ -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); diff --git a/tools/verifyDungeonBoss.js b/tools/verifyDungeonBoss.js index 6845347..378e907 100644 --- a/tools/verifyDungeonBoss.js +++ b/tools/verifyDungeonBoss.js @@ -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);