The finished table lays every hand face-up

Once the game is won, the view stops keeping secrets: revealedHands
carries every player's cards, and the table replaces your hand row
with a face-up gallery — each wizard's name (trophy on the winner)
beside their final hand, tap any card to enlarge, "empty-handed"
written in script for those who died with nothing. In hotseat this is
the thing the winner passes around after dismissing the fanfare; in
async games it greets each player when they open the finished game.
Redaction still guards every phase before "finished."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 11:17:07 -04:00
co-authored by Claude Fable 5
parent 13aeeea0ef
commit e7b17f7643
2 changed files with 87 additions and 1 deletions
+5
View File
@@ -67,6 +67,8 @@ export interface GameView {
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[];
/** Once the game is finished, every hand goes face-up on the table. */
revealedHands: Record<PlayerId, CardInstance[]> | null;
}
export function viewFor(state: GameState, playerId: PlayerId): GameView {
@@ -107,6 +109,9 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)),
})),
yourHand: you ? [...you.hand] : [],
revealedHands: state.phase === "finished"
? Object.fromEntries(state.players.map((p) => [p.id, [...p.hand]]))
: null,
treasures: state.treasures.map((t) => ({ ...t })),
deckCount: state.deck.length,
discardCount: state.discard.length,
+82 -1
View File
@@ -1212,8 +1212,29 @@
</div>
{/if}
{#if view.phase === "finished" && view.revealedHands}
<div class="final-reveal" aria-label="all hands revealed">
<div class="reveal-head">The hands, face-up</div>
{#each view.players as p (p.id)}
<div class="reveal-row">
<span class="reveal-name" class:reveal-winner={p.id === view.winner}>
{p.id === view.winner ? "🏆 " : ""}{p.id}
</span>
<div class="reveal-cards">
{#each view.revealedHands[p.id] ?? [] as card (card.instanceId)}
<Card {card} onclick={() => (peekCard = card)} />
{/each}
{#if (view.revealedHands[p.id] ?? []).length === 0}
<span class="reveal-empty">empty-handed</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<div class="hand" aria-label="your hand">
{#each view.yourHand as card (card.instanceId)}
{#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)}
<Card
{card}
selected={selectedCard?.instanceId === card.instanceId ||
@@ -1771,6 +1792,36 @@
max-width: 11rem;
}
.final-reveal {
background: #efe8d4;
border: 1px solid #b3a687;
border-radius: 6px;
padding: 0.7rem 0.9rem;
margin-bottom: 0.6rem;
}
.reveal-head {
font-family: "Oswald", sans-serif;
font-size: 0.8rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #43331f;
border-bottom: 1px solid #b3a687;
padding-bottom: 0.2rem;
margin-bottom: 0.5rem;
}
.reveal-row { display: flex; align-items: flex-start; gap: 0.6rem; margin-bottom: 0.5rem; }
.reveal-name {
font-family: "Courier Prime", monospace;
font-size: 0.8rem;
color: #43331f;
min-width: 6.5rem;
padding-top: 0.4rem;
}
.reveal-name.reveal-winner { font-weight: 700; }
.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; }
.victory-notice { border-color: #a5842c; box-shadow: 0 0 0 4px rgba(201, 167, 42, 0.35), 0 18px 50px rgba(0, 0, 0, 0.6); }
.victory-trophy { font-size: 3rem; line-height: 1; }
.victory-name {
@@ -1861,6 +1912,36 @@
max-width: 11rem;
}
.final-reveal {
background: #efe8d4;
border: 1px solid #b3a687;
border-radius: 6px;
padding: 0.7rem 0.9rem;
margin-bottom: 0.6rem;
}
.reveal-head {
font-family: "Oswald", sans-serif;
font-size: 0.8rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #43331f;
border-bottom: 1px solid #b3a687;
padding-bottom: 0.2rem;
margin-bottom: 0.5rem;
}
.reveal-row { display: flex; align-items: flex-start; gap: 0.6rem; margin-bottom: 0.5rem; }
.reveal-name {
font-family: "Courier Prime", monospace;
font-size: 0.8rem;
color: #43331f;
min-width: 6.5rem;
padding-top: 0.4rem;
}
.reveal-name.reveal-winner { font-weight: 700; }
.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; }
.victory-notice { border-color: #a5842c; box-shadow: 0 0 0 4px rgba(201, 167, 42, 0.35), 0 18px 50px rgba(0, 0, 0, 0.6); }
.victory-trophy { font-size: 3rem; line-height: 1; }
.victory-name {