Victory gets its moment: a gold-ringed proclamation

Winning showed only as a small slip in the rail — anticlimactic, as
playtesting put it. The engine now records HOW the game was won
(winReason: treasures carried home, or last wizard standing) alongside
the winner, the view carries it, and the finish opens a gold-ringed
modal: trophy, the winner's name in tall caps, and the manner of
victory in a handwritten line. "Behold the final board" dismisses it
to the finished table. In hotseat the one screen proclaims to the
whole table at once; in async games each player is greeted by it when
they next open the game.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 11:11:24 -04:00
co-authored by Claude Fable 5
parent 8f7beb5ef3
commit 13aeeea0ef
3 changed files with 49 additions and 0 deletions
+4
View File
@@ -247,6 +247,7 @@ export interface GameState {
stack: CastStack | null;
rng: RngState;
winner: PlayerId | null;
winReason: "treasures" | "lastStanding" | null;
pendingDiscard: PlayerId | null;
/** Monotonic counter for sustained-effect ids. */
nextEffectId: number;
@@ -2999,6 +3000,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
stack: null,
rng,
winner: null,
winReason: null,
pendingDiscard: null,
nextEffectId: 1,
};
@@ -4668,6 +4670,7 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
if (stolenAtHome.length >= 2) {
state.phase = "finished";
state.winner = p.id;
state.winReason = "treasures";
events.push({ type: "gameWon", player: p.id, reason: "treasures" });
return;
}
@@ -4677,6 +4680,7 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
if (alive.length === 1) {
state.phase = "finished";
state.winner = alive[0]!.id;
state.winReason = "lastStanding";
events.push({ type: "gameWon", player: alive[0]!.id, reason: "lastStanding" });
}
}
+2
View File
@@ -37,6 +37,7 @@ export interface GameView {
you: PlayerId;
phase: GameState["phase"];
winner: PlayerId | null;
winReason: "treasures" | "lastStanding" | null;
turn: TurnState;
activePlayerId: PlayerId;
/** Board with dynamic wall changes already merged in. */
@@ -88,6 +89,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
you: playerId,
phase: state.phase,
winner: state.winner,
winReason: state.winReason,
turn: state.turn,
activePlayerId: state.players[state.turn.activeIndex]!.id,
board,
+43
View File
@@ -15,6 +15,8 @@
let joinCode = $state("");
let claimPhrase = $state("");
let showHelp = $state(false);
/** The finished game whose victory fanfare has been dismissed. */
let victorySeen = $state(false);
/** Which pending attack the player has already acknowledged (modal dismissed). */
let attackNoticeSeen = $state<string | null>(null);
/** A face-up card being peeked at from the scoresheet (view-only). */
@@ -697,6 +699,25 @@
<Help initialTab={helpTab} onclose={() => (showHelp = false)} />
{/if}
{#if view?.phase === "finished" && view.winner && !victorySeen}
<div class="scrim attack-scrim" role="alertdialog" aria-label="the game is won">
<div class="attack-notice victory-notice">
<div class="victory-trophy">🏆</div>
<div class="victory-name">{view.winner}</div>
<div class="victory-how">
{#if view.winReason === "treasures"}
carried two stolen treasures home — the maze is theirs
{:else if view.winReason === "lastStanding"}
the last wizard standing
{:else}
wins the game
{/if}
</div>
<button class="stamp big" onclick={() => (victorySeen = true)}>Behold the final board</button>
</div>
</div>
{/if}
{#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey}
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack">
<div class="attack-notice">
@@ -1750,6 +1771,17 @@
max-width: 11rem;
}
.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 {
font-family: "Oswald", sans-serif;
font-size: 1.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #43331f;
}
.victory-how { font-family: "Caveat", cursive; font-size: 1.25rem; color: #6b5a41; }
.inspector {
position: fixed;
right: 0.9rem;
@@ -1829,6 +1861,17 @@
max-width: 11rem;
}
.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 {
font-family: "Oswald", sans-serif;
font-size: 1.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #43331f;
}
.victory-how { font-family: "Caveat", cursive; font-size: 1.25rem; color: #6b5a41; }
.inspector { right: 0.5rem; bottom: 0.5rem; }
.inspector-card { transform: scale(1.3); }
}