@puzzmo/cli 1.0.38 → 1.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/util/api.ts CHANGED
@@ -19,6 +19,17 @@ export type PuzzmoFile = {
19
19
 
20
20
  const BATCH_SIZE = 10
21
21
 
22
+ /** Thrown when /cliUpload init returns a 404 because the game slug isn't registered yet. */
23
+ export class GameNotFoundError extends Error {
24
+ constructor(
25
+ public readonly slug: string,
26
+ message: string,
27
+ ) {
28
+ super(message)
29
+ this.name = "GameNotFoundError"
30
+ }
31
+ }
32
+
22
33
  type InitResponse = { sessionID: string; basePath: string; error?: string }
23
34
  type FileResponse = { path: string; error?: string }
24
35
  type CompleteResponse = { assetsBase: string; versionID: string; error?: string }
@@ -108,9 +119,17 @@ const uploadFile = async (url: string, token: string, filePath: string, baseDir:
108
119
  step,
109
120
  verbose,
110
121
  )
122
+ if (res.status === 413) throw new Error(`File ${relativePath} is too large to upload (${formatBytes(content.length)})`)
111
123
  return (await readResponse(res, fullURL, step, verbose)) as FileResponse
112
124
  }
113
125
 
126
+ /** Formats a byte count as a human-readable string (e.g. "1.6 MB") */
127
+ const formatBytes = (bytes: number): string => {
128
+ if (bytes < 1024) return `${bytes} B`
129
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
130
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
131
+ }
132
+
114
133
  /** Multi-step upload: init -> batched file uploads -> complete */
115
134
  export const uploadFiles = async (
116
135
  token: string,
@@ -127,13 +146,19 @@ export const uploadFiles = async (
127
146
  if (verbose) console.log(` API URL: ${apiURL}`)
128
147
 
129
148
  // Step 1: Init session (includes puzzmo.json metadata)
130
- const init = (await jsonPost(
131
- `${apiURL}/cliUpload`,
132
- token,
133
- { gameSlug, sha, puzzmoFile, description, repoURL },
134
- "upload init",
135
- verbose,
136
- )) as InitResponse
149
+ let init: InitResponse
150
+ try {
151
+ init = (await jsonPost(
152
+ `${apiURL}/cliUpload`,
153
+ token,
154
+ { gameSlug, sha, puzzmoFile, description, repoURL },
155
+ "upload init",
156
+ verbose,
157
+ )) as InitResponse
158
+ } catch (e) {
159
+ if (e instanceof Error && e.message.includes("Game not found:")) throw new GameNotFoundError(gameSlug, e.message)
160
+ throw e
161
+ }
137
162
  if (verbose) console.log(` session: ${init.sessionID}`)
138
163
 
139
164
  // Step 2: Upload files in concurrent batches
@@ -0,0 +1,60 @@
1
+ import { getAPIURL } from "./config.js"
2
+
3
+ const createUserGameMutation = `
4
+ mutation cliCreateUserGameMutation($teamAccessToken: String!, $input: CreateUserGameInput!) {
5
+ createUserGame(teamAccessToken: $teamAccessToken, input: $input) {
6
+ game { id slug displayName }
7
+ newAccessToken
8
+ }
9
+ }
10
+ `
11
+
12
+ type GraphQLError = { message: string }
13
+
14
+ type CreateUserGameResponse = {
15
+ data?: {
16
+ createUserGame: {
17
+ game: { id: string; slug: string; displayName: string }
18
+ newAccessToken: string | null
19
+ } | null
20
+ }
21
+ errors?: GraphQLError[]
22
+ }
23
+
24
+ export type CreateUserGameOptions = {
25
+ /** A teamAccessToken (the same token saved by `puzzmo login`). The mutation uses it to identify the team. */
26
+ teamAccessToken: string
27
+ displayName: string
28
+ }
29
+
30
+ export type CreatedGame = { id: string; slug: string; displayName: string; newAccessToken: string | null }
31
+
32
+ /** Calls the createUserGame mutation using a teamAccessToken for auth. */
33
+ export const createUserGame = async (options: CreateUserGameOptions): Promise<CreatedGame> => {
34
+ const url = `${getAPIURL()}/graphql`
35
+
36
+ const response = await fetch(url, {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify({
40
+ query: createUserGameMutation,
41
+ variables: {
42
+ teamAccessToken: options.teamAccessToken,
43
+ input: { displayName: options.displayName },
44
+ },
45
+ }),
46
+ })
47
+
48
+ if (!response.ok) throw new Error(`createUserGame request failed: ${response.status} ${response.statusText}`)
49
+
50
+ const result = (await response.json()) as CreateUserGameResponse
51
+
52
+ if (result.errors?.length) {
53
+ const message = result.errors.map((e) => e.message).join("; ")
54
+ throw new Error(`createUserGame failed: ${message}`)
55
+ }
56
+ if (!result.data?.createUserGame) throw new Error("createUserGame returned no data")
57
+
58
+ const { game, newAccessToken } = result.data.createUserGame
59
+ return { id: game.id, slug: game.slug, displayName: game.displayName, newAccessToken }
60
+ }
@@ -0,0 +1,13 @@
1
+ # __DISPLAY_NAME__
2
+
3
+ A Minesweeper-style Puzzmo game scaffold.
4
+
5
+ ## Develop
6
+
7
+ ```
8
+ yarn install # or npm/pnpm
9
+ yarn dev # opens the Puzzmo simulator
10
+ yarn build
11
+ ```
12
+
13
+ Edit puzzles in `fixtures/puzzles/`. Game logic is in `src/main.ts`.
@@ -0,0 +1,16 @@
1
+ {
2
+ "rows": 9,
3
+ "cols": 9,
4
+ "mines": [
5
+ [0, 2],
6
+ [1, 5],
7
+ [2, 0],
8
+ [3, 7],
9
+ [4, 3],
10
+ [5, 8],
11
+ [6, 1],
12
+ [7, 4],
13
+ [7, 6],
14
+ [8, 2]
15
+ ]
16
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "rows": 16,
3
+ "cols": 16,
4
+ "mines": [
5
+ [0, 3],
6
+ [0, 11],
7
+ [1, 5],
8
+ [1, 14],
9
+ [2, 1],
10
+ [2, 8],
11
+ [2, 12],
12
+ [3, 4],
13
+ [3, 9],
14
+ [4, 0],
15
+ [4, 6],
16
+ [4, 13],
17
+ [5, 2],
18
+ [5, 10],
19
+ [5, 15],
20
+ [6, 5],
21
+ [6, 8],
22
+ [7, 1],
23
+ [7, 11],
24
+ [7, 14],
25
+ [8, 3],
26
+ [8, 7],
27
+ [9, 0],
28
+ [9, 9],
29
+ [9, 12],
30
+ [10, 5],
31
+ [10, 14],
32
+ [11, 2],
33
+ [11, 8],
34
+ [11, 11],
35
+ [12, 0],
36
+ [12, 6],
37
+ [12, 13],
38
+ [13, 4],
39
+ [13, 9],
40
+ [13, 15],
41
+ [14, 1],
42
+ [14, 7],
43
+ [14, 12],
44
+ [15, 10]
45
+ ]
46
+ }
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
6
+ <title>__DISPLAY_NAME__</title>
7
+ <link rel="stylesheet" href="./src/style.css" />
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <div class="hud">
12
+ <span id="hudLeft">0 mines</span>
13
+ <span id="hudRight">0 flags</span>
14
+ </div>
15
+ <div id="board" class="board" aria-label="minesweeper board"></div>
16
+ </main>
17
+ <script type="module" src="./src/main.ts"></script>
18
+ </body>
19
+ </html>
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "__SLUG__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "preview": "vite preview"
9
+ },
10
+ "dependencies": {
11
+ "@puzzmo/cli": "*",
12
+ "@puzzmo/sdk": "*"
13
+ },
14
+ "devDependencies": {
15
+ "typescript": "^5.4.0",
16
+ "vite": "^5.4.0"
17
+ }
18
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://dev.puzzmo.com/schema/puzzmo-file-schema.json",
3
+ "game": {
4
+ "displayName": "__DISPLAY_NAME__",
5
+ "slug": "__SLUG__",
6
+ "teamID": "REPLACE_ME"
7
+ },
8
+ "output": {
9
+ "dir": "dist"
10
+ }
11
+ }
@@ -0,0 +1,122 @@
1
+ import type { AppBundle, ThumbnailConfig } from "@puzzmo/sdk"
2
+ import { svgFontFaceCSSRaw } from "@puzzmo/sdk/fonts"
3
+ import { createEncoder, defineSchema } from "@puzzmo/sdk/inputs"
4
+
5
+ type Puzzle = { rows: number; cols: number; mines: [number, number][] }
6
+ type State = { revealed: boolean[]; flagged: boolean[] }
7
+
8
+ /** Mirrors the schema in src/main.ts so a saved input string round-trips here too. */
9
+ const stateSchema = defineSchema<State>({
10
+ version: 1,
11
+ fields: {
12
+ revealed: { type: "bitArray" },
13
+ flagged: { type: "bitArray" },
14
+ },
15
+ })
16
+
17
+ const escapeXml = (s: string) => s.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`)
18
+
19
+ /**
20
+ * Renders a small SVG preview of the puzzle. When an `inputStr` is supplied the
21
+ * preview reflects the player's progress; otherwise it shows a blank board.
22
+ *
23
+ * The host renders this in lists/share cards/completion screens — there's no DOM,
24
+ * no animation, no theme variables. We bake colors from `config.theme` directly.
25
+ */
26
+ export const renderThumbnail: AppBundle["renderThumbnail"] = (puzzleStr: string, inputStr?: string, config?: ThumbnailConfig): string => {
27
+ const puzzle = JSON.parse(puzzleStr) as Puzzle
28
+ const { rows, cols } = puzzle
29
+ const cells = rows * cols
30
+ const mineSet = new Set(puzzle.mines.map(([r, c]) => r * cols + c))
31
+
32
+ let revealed: boolean[] = new Array(cells).fill(false)
33
+ let flagged: boolean[] = new Array(cells).fill(false)
34
+
35
+ if (inputStr) {
36
+ try {
37
+ const codec = createEncoder(stateSchema, { revealed: { length: cells }, flagged: { length: cells } })
38
+ const decoded = codec.decode(codec.migrate(inputStr))
39
+ revealed = decoded.revealed
40
+ flagged = decoded.flagged
41
+ } catch {
42
+ // Bad/old state: fall back to a blank thumbnail.
43
+ }
44
+ }
45
+
46
+ const theme = config?.theme
47
+ const bg = theme?.a_bg ?? "#1a1a1a"
48
+ const cellHidden = theme?.g_unsolved ?? "#3a3a3a"
49
+ const cellShown = theme?.g_bg ?? "#1f1f1f"
50
+ const border = theme?.g_outline ?? "#4a4a4a"
51
+ const mineColor = theme?.error ?? "#ff5555"
52
+ const flagColor = theme?.subBrand ?? "#ffaa55"
53
+ const numberColor = theme?.g_textDark ?? "#1b1b28"
54
+
55
+ const size = 200
56
+ const pad = 4
57
+ const gap = 1
58
+ const cellW = (size - pad * 2 - gap * (cols - 1)) / cols
59
+ const cellH = (size - pad * 2 - gap * (rows - 1)) / rows
60
+
61
+ const parts: string[] = []
62
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">`)
63
+ // Embed the @font-face rule so the SVG renders with Puzzmo's bundled font
64
+ // even outside the game (host previews, share cards, opengraph).
65
+ parts.push(`<style>${svgFontFaceCSSRaw(["RedHatMono-Bold"])}</style>`)
66
+ parts.push(`<rect width="${size}" height="${size}" fill="${escapeXml(bg)}"/>`)
67
+
68
+ for (let i = 0; i < cells; i++) {
69
+ const r = Math.floor(i / cols)
70
+ const c = i % cols
71
+ const x = pad + c * (cellW + gap)
72
+ const y = pad + r * (cellH + gap)
73
+ const isRevealed = revealed[i]
74
+ const fill = isRevealed ? cellShown : cellHidden
75
+ parts.push(
76
+ `<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${cellW.toFixed(2)}" height="${cellH.toFixed(2)}" fill="${escapeXml(fill)}" stroke="${escapeXml(border)}" stroke-width="0.5"/>`,
77
+ )
78
+
79
+ if (isRevealed && mineSet.has(i)) {
80
+ const cx = x + cellW / 2
81
+ const cy = y + cellH / 2
82
+ const rad = Math.min(cellW, cellH) * 0.25
83
+ parts.push(`<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${rad.toFixed(2)}" fill="${escapeXml(mineColor)}"/>`)
84
+ } else if (isRevealed && !mineSet.has(i)) {
85
+ const n = countAdjacent(mineSet, i, rows, cols)
86
+ if (n > 0) {
87
+ const cx = x + cellW / 2
88
+ const cy = y + cellH / 2 + cellH * 0.32
89
+ const fontSize = Math.min(cellW, cellH) * 0.7
90
+ parts.push(
91
+ `<text x="${cx.toFixed(2)}" y="${cy.toFixed(2)}" text-anchor="middle" font-family="RedHatMono-Bold, ui-monospace, monospace" font-size="${fontSize.toFixed(1)}" font-weight="700" fill="${escapeXml(numberColor)}">${n}</text>`,
92
+ )
93
+ }
94
+ } else if (flagged[i]) {
95
+ const cx = x + cellW / 2
96
+ const cy = y + cellH / 2 + cellH * 0.32
97
+ const fontSize = Math.min(cellW, cellH) * 0.7
98
+ parts.push(
99
+ `<text x="${cx.toFixed(2)}" y="${cy.toFixed(2)}" text-anchor="middle" font-family="RedHatMono-Bold, ui-monospace, monospace" font-size="${fontSize.toFixed(1)}" fill="${escapeXml(flagColor)}">⚑</text>`,
100
+ )
101
+ }
102
+ }
103
+
104
+ parts.push(`</svg>`)
105
+ return parts.join("")
106
+ }
107
+
108
+ const countAdjacent = (mineSet: Set<number>, i: number, rows: number, cols: number): number => {
109
+ const r = Math.floor(i / cols)
110
+ const c = i % cols
111
+ let n = 0
112
+ for (let dr = -1; dr <= 1; dr++) {
113
+ for (let dc = -1; dc <= 1; dc++) {
114
+ if (dr === 0 && dc === 0) continue
115
+ const nr = r + dr
116
+ const nc = c + dc
117
+ if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue
118
+ if (mineSet.has(nr * cols + nc)) n++
119
+ }
120
+ }
121
+ return n
122
+ }
@@ -0,0 +1,236 @@
1
+ import { createPuzzmoSDK, type Theme } from "@puzzmo/sdk"
2
+ import { createEncoder, defineSchema, type EncoderResult } from "@puzzmo/sdk/inputs"
3
+
4
+ /** Decoded puzzle definition shipped from the host. The host serializes this as JSON. */
5
+ type Puzzle = { rows: number; cols: number; mines: [number, number][] }
6
+
7
+ /** Persisted player progress. `won` is derived, not stored. */
8
+ type State = { revealed: boolean[]; flagged: boolean[] }
9
+
10
+ const MINE_PENALTY_MS = 60_000
11
+
12
+ /**
13
+ * Schema for serializing per-cell boolean state. `bitArray` packs 4 cells per hex char,
14
+ * so a 16x16 board ≈ 64 chars instead of a multi-kilobyte JSON blob.
15
+ */
16
+ const stateSchema = defineSchema<State>({
17
+ version: 1,
18
+ fields: {
19
+ revealed: { type: "bitArray" },
20
+ flagged: { type: "bitArray" },
21
+ },
22
+ })
23
+
24
+ /**
25
+ * Construct the host SDK. Resolves messaging with the embed, manages the timer, and
26
+ * surfaces lifecycle events ("start", "settingsUpdate", etc.).
27
+ */
28
+ const sdk = createPuzzmoSDK()
29
+
30
+ const boardEl = document.getElementById("board")!
31
+ const hudLeftEl = document.getElementById("hudLeft")!
32
+ const hudRightEl = document.getElementById("hudRight")!
33
+
34
+ let puzzle: Puzzle = { rows: 9, cols: 9, mines: [] }
35
+ let state: State = { revealed: [], flagged: [] }
36
+ let mineSet = new Set<number>()
37
+ let stateCodec: EncoderResult<State> | null = null
38
+ let won = false
39
+
40
+ /** Maps the Puzzmo Theme onto the CSS custom properties consumed by style.css. */
41
+ const applyTheme = (theme: Theme) => {
42
+ const r = document.documentElement.style
43
+ r.setProperty("--bg", theme.a_bg)
44
+ r.setProperty("--panel", theme.a_infoBG)
45
+ r.setProperty("--cell", theme.g_unsolved)
46
+ r.setProperty("--cell-revealed", theme.g_bg)
47
+ r.setProperty("--border", theme.g_outline)
48
+ r.setProperty("--text", theme.fg)
49
+ r.setProperty("--text-on-cell", theme.g_textDark)
50
+ r.setProperty("--accent", theme.key)
51
+ r.setProperty("--mine", theme.error)
52
+ r.setProperty("--flag", theme.subBrand)
53
+ r.setProperty("--n1", theme.player)
54
+ r.setProperty("--n2", theme.alt1)
55
+ r.setProperty("--n3", theme.alt2)
56
+ r.setProperty("--n4", theme.alt3)
57
+ r.setProperty("--n5", theme.error)
58
+ r.setProperty("--n6", theme.keyStrong)
59
+ r.setProperty("--n7", theme.playerLight)
60
+ r.setProperty("--n8", theme.alwaysDark)
61
+ }
62
+
63
+ const idx = (r: number, c: number) => r * puzzle.cols + c
64
+
65
+ const neighbours = (i: number): number[] => {
66
+ const r = Math.floor(i / puzzle.cols)
67
+ const c = i % puzzle.cols
68
+ const out: number[] = []
69
+ for (let dr = -1; dr <= 1; dr++) {
70
+ for (let dc = -1; dc <= 1; dc++) {
71
+ if (dr === 0 && dc === 0) continue
72
+ const nr = r + dr
73
+ const nc = c + dc
74
+ if (nr < 0 || nr >= puzzle.rows || nc < 0 || nc >= puzzle.cols) continue
75
+ out.push(idx(nr, nc))
76
+ }
77
+ }
78
+ return out
79
+ }
80
+
81
+ const countAdjacentMines = (i: number) => neighbours(i).filter((n) => mineSet.has(n)).length
82
+
83
+ const reveal = (i: number, isUserClick: boolean) => {
84
+ if (state.revealed[i] || state.flagged[i]) return
85
+ state.revealed[i] = true
86
+ if (mineSet.has(i)) {
87
+ /**
88
+ * `addPenalty(ms)` adds to the timer's tracked "added time", surfaced separately
89
+ * in `display()` and reported via `gameCompleted({ additionalTimeAddedSecs })`.
90
+ */
91
+ if (isUserClick) sdk.timer.addPenalty(MINE_PENALTY_MS)
92
+ return
93
+ }
94
+ if (countAdjacentMines(i) === 0) for (const n of neighbours(i)) reveal(n, false)
95
+ }
96
+
97
+ const checkWin = () => {
98
+ const safe = puzzle.rows * puzzle.cols - mineSet.size
99
+ let revealedSafe = 0
100
+ for (let i = 0; i < state.revealed.length; i++) if (state.revealed[i] && !mineSet.has(i)) revealedSafe++
101
+ if (revealedSafe === safe) won = true
102
+ }
103
+
104
+ const flaggedCount = () => state.flagged.reduce((acc, v) => acc + (v ? 1 : 0), 0)
105
+
106
+ const render = () => {
107
+ boardEl.innerHTML = ""
108
+ for (let i = 0; i < puzzle.rows * puzzle.cols; i++) {
109
+ const cell = document.createElement("button")
110
+ cell.className = "cell"
111
+ cell.dataset.i = String(i)
112
+ if (state.revealed[i]) {
113
+ cell.classList.add("revealed")
114
+ if (mineSet.has(i)) {
115
+ cell.classList.add("mine")
116
+ cell.textContent = "✸"
117
+ } else {
118
+ const n = countAdjacentMines(i)
119
+ if (n > 0) {
120
+ cell.dataset.n = String(n)
121
+ cell.textContent = String(n)
122
+ }
123
+ }
124
+ } else if (state.flagged[i]) {
125
+ cell.classList.add("flagged")
126
+ cell.textContent = "⚑"
127
+ }
128
+ boardEl.appendChild(cell)
129
+ }
130
+ if (won) {
131
+ hudLeftEl.textContent = "completed"
132
+ hudRightEl.textContent = ""
133
+ } else {
134
+ hudLeftEl.textContent = `${mineSet.size} mines`
135
+ hudRightEl.textContent = `${flaggedCount()} flags`
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Called once on win. Reports gameplay metrics back to the host via:
141
+ * - `sdk.gameCompleted(gameplay, augmentations)` — durable record + deeds
142
+ * - `sdk.showCompletionScreen(content, gameplay, completed)` — the host's win UI
143
+ */
144
+ const onComplete = () => {
145
+ const elapsed = Math.round(sdk.timer.timeSecs())
146
+ const penalty = Math.round(sdk.timer.addedTimeSecs())
147
+ const data = { elapsedTimeSecs: elapsed, additionalTimeAddedSecs: penalty, pointsAwarded: 0, completed: true }
148
+ sdk.gameCompleted(data, { deeds: [] })
149
+ sdk.showCompletionScreen([{ type: "md", text: penalty > 0 ? `**Cleared!** With ${penalty}s in penalties.` : "**Cleared!**" }], data, true)
150
+ }
151
+
152
+ const persistState = () => {
153
+ if (!stateCodec) return
154
+ /**
155
+ * `sdk.updateGameState(string)` ships an opaque string back to the host so progress
156
+ * survives a reload. We use the SDK's input encoder for compactness over JSON.
157
+ */
158
+ sdk.updateGameState(stateCodec.encode(state))
159
+ }
160
+
161
+ const handleClick = (e: MouseEvent) => {
162
+ const target = e.target as HTMLElement
163
+ if (!target.classList.contains("cell")) return
164
+ if (won) return
165
+ const i = Number(target.dataset.i)
166
+ if (e.shiftKey || e.button === 2) {
167
+ if (!state.revealed[i]) state.flagged[i] = !state.flagged[i]
168
+ } else {
169
+ reveal(i, true)
170
+ checkWin()
171
+ }
172
+ persistState()
173
+ render()
174
+ if (won) onComplete()
175
+ }
176
+
177
+ const blankState = (cells: number): State => ({ revealed: new Array(cells).fill(false), flagged: new Array(cells).fill(false) })
178
+
179
+ const setupBoard = (data: Puzzle, savedState?: State) => {
180
+ puzzle = data
181
+ mineSet = new Set(data.mines.map(([r, c]) => idx(r, c)))
182
+ const cells = data.rows * data.cols
183
+ state = savedState ?? blankState(cells)
184
+ /**
185
+ * `bitArray` requires each field's length in the encoder context to round-trip
186
+ * correctly. We rebuild the codec whenever the board dimensions change.
187
+ */
188
+ stateCodec = createEncoder(stateSchema, {
189
+ revealed: { length: cells },
190
+ flagged: { length: cells },
191
+ })
192
+ won = false
193
+ // Publish board dimensions to CSS so grid tracks + cell font-size scale purely from the stylesheet.
194
+ boardEl.style.setProperty("--cols", String(data.cols))
195
+ boardEl.style.setProperty("--rows", String(data.rows))
196
+ checkWin()
197
+ render()
198
+ }
199
+
200
+ /**
201
+ * Lifecycle entrypoint:
202
+ * - `sdk.gameReady()` blocks until the host sends puzzle/state/theme.
203
+ * - `sdk.gameLoaded()` tells the host we're done bootstrapping; the host will
204
+ * then dispatch `start` (or restore a paused session).
205
+ */
206
+ const init = async () => {
207
+ const { puzzleString, inputString, theme } = await sdk.gameReady()
208
+ if (theme) applyTheme(theme)
209
+ const parsed = puzzleString ? (JSON.parse(puzzleString) as Puzzle) : puzzle
210
+ const cells = parsed.rows * parsed.cols
211
+ /**
212
+ * Decode prior progress with the encoder so older save formats can be migrated
213
+ * via the schema's `migrations` map (none yet, but wired up for free).
214
+ */
215
+ const tempCodec = createEncoder(stateSchema, { revealed: { length: cells }, flagged: { length: cells } })
216
+ const saved = inputString ? (tempCodec.decode(tempCodec.migrate(inputString)) as State) : undefined
217
+ setupBoard(parsed, saved)
218
+ sdk.gameLoaded()
219
+ }
220
+
221
+ sdk.on("start", () => {
222
+ render()
223
+ })
224
+
225
+ /** The host re-emits its theme (and other settings) when the player toggles light/dark. */
226
+ sdk.on("settingsUpdate", (data: { theme?: Theme }) => {
227
+ if (data?.theme) applyTheme(data.theme)
228
+ })
229
+
230
+ boardEl.addEventListener("click", handleClick)
231
+ boardEl.addEventListener("contextmenu", (e) => {
232
+ e.preventDefault()
233
+ handleClick(e)
234
+ })
235
+
236
+ init()