From 13aeeea0ef0c6b6a033a8ea38fc6ae192dd406f9 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 11:11:24 -0400 Subject: [PATCH] Victory gets its moment: a gold-ringed proclamation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/engine/src/game.ts | 4 ++++ packages/engine/src/view.ts | 2 ++ packages/web/src/App.svelte | 43 +++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 3b33bde..ab35d15 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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" }); } } diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index a73e3bb..2dbe8fd 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -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, diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 36cf080..4d2abd3 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -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(null); /** A face-up card being peeked at from the scoresheet (view-only). */ @@ -697,6 +699,25 @@ (showHelp = false)} /> {/if} + {#if view?.phase === "finished" && view.winner && !victorySeen} +
+
+
🏆
+
{view.winner}
+
+ {#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} +
+ +
+
+ {/if} + {#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey}
@@ -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); } }