From da4ab031358c465b606733d29879175492ac56f8 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 11:20:10 -0400 Subject: [PATCH] The reveal remembers the hand a wizard fell holding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elimination destroys the evidence: a killed wizard's cards pass to the killer, a treasure-lost wizard's go to the discard — so the post-game reveal showed "empty-handed" for exactly the player whose hand everyone wants to see. Both elimination paths now snapshot the hand at the moment of death (finalHand), the finished view serves it, and the gallery marks those rows "as they fell" in script. Because rooms replay from the command log, already-finished games show their fallen hands too. Test kills a wizard holding a Fireball and a 6 and finds them in the reveal. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 4 ++++ packages/engine/src/view.ts | 2 +- packages/engine/test/casting.test.ts | 23 +++++++++++++++++++++++ packages/web/src/App.svelte | 3 +++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index ab35d15..eb8a90c 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -61,6 +61,8 @@ export interface PlayerState { hand: CardInstance[]; /** Instance ids of cards displayed face-up (Master Key, Wizardblade, stones). */ displayed: string[]; + /** The hand held at the moment of elimination — for the post-game reveal. */ + finalHand?: CardInstance[]; carriedTreasureId: string | null; lostTurns: number; extraTurns: number; @@ -4473,6 +4475,7 @@ function applyDamage( if (target.life > 0) return; target.alive = false; + target.finalHand = [...target.hand]; events.push({ type: "died", player: target.id, killedBy: attackerId }); events.push({ type: "playerEliminated", player: target.id, reason: "killed" }); state.sustained = state.sustained.filter((s) => s.targetId !== target.id && s.casterId !== target.id); @@ -4656,6 +4659,7 @@ function checkVictory(state: GameState, events: GameEvent[]): void { }); if (lost) { p.alive = false; + p.finalHand = [...p.hand]; state.discard.push(...p.hand.splice(0)); p.displayed = []; events.push({ type: "playerEliminated", player: p.id, reason: "treasuresLost" }); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 597210f..3c10e71 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -110,7 +110,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { })), yourHand: you ? [...you.hand] : [], revealedHands: state.phase === "finished" - ? Object.fromEntries(state.players.map((p) => [p.id, [...p.hand]])) + ? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]])) : null, treasures: state.treasures.map((t) => ({ ...t })), deckCount: state.deck.length, diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 1311c64..32caf97 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -315,3 +315,26 @@ describe("speedstone", () => { expect(state.turn.movementAllowance).toBe(4); }); }); + +describe("the post-game reveal", () => { + it("shows an eliminated player's hand as they fell, not their emptied one", async () => { + const { viewFor } = await import("../src/view"); + let { state } = newGame(); + state = toRound2(state); + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + defender.position = { ...attacker.position }; + defender.life = 1; + defender.hand = [ + { instanceId: "fireball#J", cardId: "fireball" }, + { instanceId: "number-6#J", cardId: "number-6" }, + ]; + state = must(state, attacker.id, { type: "punch", targetId: defender.id }); + state = must(state, defender.id, { type: "pass" }); + expect(state.phase).toBe("finished"); + // The killer took the cards — but the reveal remembers the fallen hand. + const view = viewFor(state, attacker.id); + const fallen = view.revealedHands?.[defender.id] ?? []; + expect(fallen.map((c) => c.cardId).sort()).toEqual(["fireball", "number-6"]); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index ca84da6..9430438 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1219,6 +1219,7 @@
{p.id === view.winner ? "🏆 " : ""}{p.id} + {#if !p.alive}as they fell{/if}
{#each view.revealedHands[p.id] ?? [] as card (card.instanceId)} @@ -1818,6 +1819,7 @@ padding-top: 0.4rem; } .reveal-name.reveal-winner { font-weight: 700; } + .reveal-note { display: block; font-family: "Caveat", cursive; font-size: 0.85rem; color: #8a7a5e; } .reveal-cards { display: flex; flex-wrap: wrap; gap: 0.3rem; } .reveal-cards :global(.card) { transform: scale(0.82); transform-origin: top left; margin: -0.35rem -0.8rem -1.1rem 0; } .reveal-empty { font-family: "Caveat", cursive; color: #8a7a5e; padding-top: 0.5rem; } @@ -1938,6 +1940,7 @@ padding-top: 0.4rem; } .reveal-name.reveal-winner { font-weight: 700; } + .reveal-note { display: block; font-family: "Caveat", cursive; font-size: 0.85rem; color: #8a7a5e; } .reveal-cards { display: flex; flex-wrap: wrap; gap: 0.3rem; } .reveal-cards :global(.card) { transform: scale(0.82); transform-origin: top left; margin: -0.35rem -0.8rem -1.1rem 0; } .reveal-empty { font-family: "Caveat", cursive; color: #8a7a5e; padding-top: 0.5rem; }