@puzzmo/cli 1.0.38 → 1.0.39
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/lib/commands/game/create.js +187 -71
- package/lib/commands/game/create.js.map +1 -1
- package/lib/commands/upload.js +49 -2
- package/lib/commands/upload.js.map +1 -1
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/skills/runner.d.ts +2 -0
- package/lib/skills/runner.js +33 -0
- package/lib/skills/runner.js.map +1 -1
- package/lib/util/api.d.ts +5 -0
- package/lib/util/api.js +18 -1
- package/lib/util/api.js.map +1 -1
- package/lib/util/createGame.d.ts +13 -0
- package/lib/util/createGame.js +36 -0
- package/lib/util/createGame.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/game/create.ts +186 -74
- package/src/commands/upload.ts +52 -2
- package/src/index.ts +4 -2
- package/src/skills/runner.ts +35 -0
- package/src/util/api.ts +24 -7
- package/src/util/createGame.ts +60 -0
- package/templates/minesweeper/README.md +13 -0
- package/templates/minesweeper/fixtures/puzzles/easy/9x9.json +16 -0
- package/templates/minesweeper/fixtures/puzzles/hard/16x16.json +46 -0
- package/templates/minesweeper/index.html +19 -0
- package/templates/minesweeper/package.json +17 -0
- package/templates/minesweeper/puzzmo.json +11 -0
- package/templates/minesweeper/src/appBundle.ts +122 -0
- package/templates/minesweeper/src/main.ts +236 -0
- package/templates/minesweeper/src/style.css +160 -0
- package/templates/minesweeper/tsconfig.json +14 -0
- package/templates/minesweeper/vite.config.ts +10 -0
|
@@ -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,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,17 @@
|
|
|
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/sdk": "*"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"typescript": "^5.4.0",
|
|
15
|
+
"vite": "^5.4.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -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()
|