T1ckbase commited on
Commit
a0040c1
·
1 Parent(s): aa1e918
Files changed (6) hide show
  1. .gitignore +1 -0
  2. deno.json +2 -1
  3. generate-table.ts +23 -0
  4. main.ts +80 -5
  5. minesweeper.ts +377 -0
  6. utils.ts +22 -0
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ .env
deno.json CHANGED
@@ -1,9 +1,10 @@
1
  {
2
  "tasks": {
3
- "start": "deno --allow-net --watch main.ts"
4
  },
5
  "imports": {
6
  "@std/async": "jsr:@std/async@^1.0.12",
 
7
  "hono": "jsr:@hono/hono@^4.7.8"
8
  },
9
  "fmt": {
 
1
  {
2
  "tasks": {
3
+ "start": "deno --allow-net --allow-read --allow-env --env-file=.env --watch main.ts"
4
  },
5
  "imports": {
6
  "@std/async": "jsr:@std/async@^1.0.12",
7
+ "@std/path": "jsr:@std/path@^1.0.9",
8
  "hono": "jsr:@hono/hono@^4.7.8"
9
  },
10
  "fmt": {
generate-table.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const rows = 8;
2
+ const cols = 8;
3
+ const baseUrl = 'https://t1ckbase-minesweeper.hf.space';
4
+
5
+ const html = '<table id="toc">\n' +
6
+ ' <tr>\n' +
7
+ ' <td align="center">\n' +
8
+ ` <a href="${baseUrl}/game/reset"><img src="${baseUrl}/game/status" width="48px" height="48px" /></a>\n` +
9
+ ' </td>\n' +
10
+ ' </tr>\n' +
11
+ Array.from({ length: rows }, (_, r) =>
12
+ ' <tr>\n' +
13
+ ' <td align="center">\n' +
14
+ Array.from({ length: cols }, (_, c) => {
15
+ const imageUrl = `${baseUrl}/cell/${r}/${c}/image`;
16
+ const clickUrl = `${baseUrl}/cell/${r}/${c}/click`;
17
+ return ` <a href="${clickUrl}"><img src="${imageUrl}" width="32px" height="32px" /></a>`;
18
+ }).join('\n') + '\n' +
19
+ ' </td>\n' +
20
+ ' </tr>').join('\n') +
21
+ '\n</table>';
22
+
23
+ console.log(html);
main.ts CHANGED
@@ -1,19 +1,94 @@
1
  import { Hono } from 'hono';
2
  import { logger } from 'hono/logger';
3
- import { serveStatic } from 'hono/deno';
 
 
 
4
 
5
  // https://t1ckbase-minesweeper.hf.space
6
 
 
 
 
 
7
  const app = new Hono();
8
 
9
  app.use(logger());
10
 
11
- app.get('/', (c) => c.text('Play minesweeper:\nhttps://github.com/T1ckbase\n\n' + Array.from(c.req.raw.headers).join('\n')));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- // app.get('*', serveStatic({ path: './gray.svg' }));
14
- app.get('*', (c) => {
15
  c.header('Content-Type', 'image/svg+xml');
16
- return c.body(`<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg"><rect width="32" height="32" fill="#c3c3c3" /></svg>`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  });
18
 
19
  Deno.serve(app.fetch);
 
1
  import { Hono } from 'hono';
2
  import { logger } from 'hono/logger';
3
+ import { Minesweeper } from './minesweeper.ts';
4
+ import { isGithubUserPath } from './utils.ts';
5
+
6
+ // TODO: check header referer
7
 
8
  // https://t1ckbase-minesweeper.hf.space
9
 
10
+ const USER = 'T1ckbase';
11
+
12
+ const minesweeper = new Minesweeper(8, 8, 10, './images');
13
+
14
  const app = new Hono();
15
 
16
  app.use(logger());
17
 
18
+ app.get('/', (c) => c.text(`Play minesweeper:\nhttps://github.com/${USER}`));
19
+
20
+ app.get('/headers', (c) => c.text(Array.from(c.req.raw.headers).join('\n')));
21
+
22
+ if (Deno.env.get('DENO_ENV') === 'development') {
23
+ // app.get('/board', (c) => c.text(JSON.stringify(minesweeper.getBoard(), null, 2)));
24
+ app.get('/board', (c) => c.text(minesweeper.getBoard().map((row) => row.map((cell) => cell.isMine ? 'b' : cell.adjacentMines).join('')).join('\n')));
25
+ }
26
+
27
+ app.get('/cell/:row/:col/image', (c) => {
28
+ const row = Number(c.req.param('row'));
29
+ const col = Number(c.req.param('col'));
30
+ if (Number.isNaN(row) || Number.isNaN(col)) return c.text('Invalid coordinates', 400);
31
+
32
+ const cellImage = minesweeper.getCellImage(row, col);
33
+ if (!cellImage) return c.text(`Not Found: Image for cell (${row}, ${col}) could not be found. Coordinates may be invalid or no image is defined for this cell state.`, 404);
34
 
 
 
35
  c.header('Content-Type', 'image/svg+xml');
36
+ return c.body(cellImage);
37
+ });
38
+
39
+ app.get('/cell/:row/:col/click', (c) => {
40
+ const row = Number(c.req.param('row'));
41
+ const col = Number(c.req.param('col'));
42
+ if (Number.isNaN(row) || Number.isNaN(col)) return c.text('Invalid coordinates', 400);
43
+
44
+ const referer = c.req.header('Referer');
45
+ let redirectUrl = `https://github.com/${USER}`;
46
+ if (referer) {
47
+ if (isGithubUserPath(referer, USER)) {
48
+ redirectUrl = referer;
49
+ } else {
50
+ console.warn(`Invalid or non-GitHub referer: ${referer}`);
51
+ // return c.text('?', 403);
52
+ }
53
+ } else {
54
+ console.warn('Referer header is missing.');
55
+ // return c.text('?', 403);
56
+ }
57
+
58
+ minesweeper.revealCell(row, col);
59
+
60
+ return c.redirect(redirectUrl);
61
+ });
62
+
63
+ app.get('/game/status', (c) => {
64
+ c.header('Content-Type', 'image/svg+xml');
65
+ const image = minesweeper.getGameStatusImage();
66
+ if (!image) return c.text('Status image is not available.', 404);
67
+ return c.body(image);
68
+ });
69
+
70
+ app.get('/game/reset', (c) => {
71
+ const referer = c.req.header('Referer');
72
+ let redirectUrl = `https://github.com/${USER}`;
73
+ if (referer) {
74
+ if (isGithubUserPath(referer, USER)) {
75
+ redirectUrl = referer;
76
+ } else {
77
+ console.warn(`Invalid or non-GitHub referer: ${referer}`);
78
+ // return c.text('?', 403);
79
+ }
80
+ } else {
81
+ console.warn('Referer header is missing.');
82
+ // return c.text('?', 403);
83
+ }
84
+
85
+ try {
86
+ minesweeper.resetGame();
87
+ } catch (e) {
88
+ console.warn(e instanceof Error ? e.message : e);
89
+ }
90
+
91
+ return c.redirect(redirectUrl);
92
  });
93
 
94
  Deno.serve(app.fetch);
minesweeper.ts ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as path from '@std/path';
2
+
3
+ export interface Cell {
4
+ isMine: boolean;
5
+ isRevealed: boolean;
6
+ adjacentMines: number;
7
+ }
8
+
9
+ export type GameState = 'playing' | 'won' | 'lost';
10
+
11
+ export type ImageKey =
12
+ | 'cell_hidden' // gray-button.svg
13
+ | 'cell_revealed_0' // gray.svg
14
+ | 'cell_revealed_1' // 1.svg
15
+ | 'cell_revealed_2' // 2.svg
16
+ | 'cell_revealed_3' // 3.svg
17
+ | 'cell_revealed_4' // 4.svg
18
+ | 'cell_revealed_5' // 5.svg
19
+ | 'cell_revealed_6' // 6.svg
20
+ | 'cell_revealed_7' // 7.svg
21
+ | 'cell_revealed_8' // 8.svg
22
+ | 'mine_normal' // mine.svg (for unrevealed mines at game end, or revealed mines in won state)
23
+ | 'mine_hit' // mine-red.svg (for the mine that was clicked and caused loss)
24
+ | 'status_playing' // emoji-smile.svg
25
+ | 'status_won' // emoji-sunglasses.svg
26
+ | 'status_lost'; // emoji-dead.svg
27
+
28
+ export class Minesweeper {
29
+ // deno-fmt-ignore
30
+ // Static readonly array for neighbor directions
31
+ private static readonly DIRECTIONS: ReadonlyArray<[number, number]> = [
32
+ [-1, -1], [-1, 0], [-1, 1],
33
+ [0, -1], [0, 1],
34
+ [1, -1], [1, 0], [1, 1],
35
+ ];
36
+
37
+ private board: Cell[][];
38
+ private gameState: GameState;
39
+ private mineCount: number;
40
+ private rows: number;
41
+ private cols: number;
42
+ private remainingNonMineCells: number; // Number of non-mine cells that need to be revealed to win
43
+ private hitMineCoordinates: { row: number; col: number } | null = null; // To identify the exploded mine
44
+
45
+ private startTime: Date; // Time when the game was started
46
+ private endTime: Date | null = null; // Time when the game ended (won or lost)
47
+
48
+ // Store loaded images
49
+ private imageCache: Map<ImageKey, Uint8Array>;
50
+ private imageDirectory: string;
51
+
52
+ constructor(rows: number, cols: number, mineCount: number, imageDirectory: string = './image') {
53
+ // Validate input parameters
54
+ if (rows <= 0 || cols <= 0) throw new Error('Board dimensions (rows, cols) must be positive integers.');
55
+ if (mineCount < 0) throw new Error('Mine count cannot be negative.');
56
+ // If mineCount > rows * cols, the placeMines method would loop indefinitely
57
+ // as it tries to place more mines than available cells.
58
+ if (mineCount > rows * cols) throw new Error('Mine count cannot exceed the total number of cells (rows * cols).');
59
+
60
+ this.rows = rows;
61
+ this.cols = cols;
62
+ this.mineCount = mineCount;
63
+
64
+ this.gameState = 'playing';
65
+ // This tracks the number of non-mine cells that still need to be revealed for the player to win.
66
+ this.remainingNonMineCells = rows * cols - mineCount;
67
+ this.board = this.initializeBoard();
68
+ this.placeMines();
69
+ this.calculateAdjacentMines();
70
+ this.startTime = new Date(); // Record the start time of the game
71
+
72
+ this.imageDirectory = imageDirectory;
73
+ this.imageCache = new Map();
74
+ this.loadImages(); // Load images upon initialization
75
+ }
76
+
77
+ /**
78
+ * Defines the mapping from logical image keys to their filenames.
79
+ */
80
+ private getImageFileMap(): Record<ImageKey, string> {
81
+ return {
82
+ cell_hidden: 'gray-button.svg',
83
+ cell_revealed_0: 'gray.svg',
84
+ cell_revealed_1: '1.svg',
85
+ cell_revealed_2: '2.svg',
86
+ cell_revealed_3: '3.svg',
87
+ cell_revealed_4: '4.svg',
88
+ cell_revealed_5: '5.svg',
89
+ cell_revealed_6: '6.svg',
90
+ cell_revealed_7: '7.svg',
91
+ cell_revealed_8: '8.svg',
92
+ mine_normal: 'mine.svg',
93
+ mine_hit: 'mine-red.svg',
94
+ status_playing: 'emoji-smile.svg',
95
+ status_won: 'emoji-sunglasses.svg',
96
+ status_lost: 'emoji-dead.svg',
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Loads all necessary images from the specified directory into the imageCache.
102
+ * This method assumes a Node.js environment for file system access.
103
+ */
104
+ private loadImages(): void {
105
+ const imageFileMap = this.getImageFileMap();
106
+ for (const key in imageFileMap) {
107
+ if (Object.prototype.hasOwnProperty.call(imageFileMap, key)) {
108
+ const typedKey = key as ImageKey;
109
+ const fileName = imageFileMap[typedKey];
110
+ const filePath = path.join(this.imageDirectory, fileName); // Use path.join for cross-platform compatibility
111
+ try {
112
+ const fileBuffer = Deno.readFileSync(filePath);
113
+ this.imageCache.set(typedKey, new Uint8Array(fileBuffer)); // Deno.readFileSync returns a Buffer, which is a Uint8Array
114
+ // console.log(`Loaded image: ${filePath} for key: ${typedKey}`);
115
+ } catch (error) {
116
+ console.error(`Failed to load image ${filePath} for key ${typedKey}:`, error);
117
+ // You might want to throw an error here or have a default placeholder image
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Initializes or resets the game to a new state with the current settings.
125
+ * This method is called by the constructor and resetGame.
126
+ */
127
+ private initializeNewGame(): void {
128
+ this.gameState = 'playing';
129
+ this.remainingNonMineCells = this.rows * this.cols - this.mineCount;
130
+ this.board = this.initializeBoard();
131
+ this.placeMines();
132
+ this.calculateAdjacentMines();
133
+ this.startTime = new Date(); // Record/Reset the start time
134
+ this.endTime = null; // Reset the end time
135
+ this.hitMineCoordinates = null; // Reset hit mine coordinates
136
+ }
137
+
138
+ /**
139
+ * Initializes the game board with all cells set to default state (not a mine, not revealed, 0 adjacent mines).
140
+ * @returns A 2D array of Cell objects representing the initialized board.
141
+ */
142
+ private initializeBoard(): Cell[][] {
143
+ return Array.from({ length: this.rows }, () =>
144
+ Array.from({ length: this.cols }, () => ({
145
+ isMine: false,
146
+ isRevealed: false,
147
+ adjacentMines: 0,
148
+ })));
149
+ }
150
+
151
+ /**
152
+ * Randomly places the specified number of mines on the board.
153
+ * Ensures that mines are placed only on cells that do not already contain a mine.
154
+ */
155
+ private placeMines(): void {
156
+ let minesPlaced = 0;
157
+ // Ensure board is clean of mines if this is called during a reset
158
+ for (let r = 0; r < this.rows; r++) {
159
+ for (let c = 0; c < this.cols; c++) {
160
+ this.board[r][c].isMine = false;
161
+ }
162
+ }
163
+
164
+ while (minesPlaced < this.mineCount) {
165
+ const row = Math.floor(Math.random() * this.rows);
166
+ const col = Math.floor(Math.random() * this.cols);
167
+ if (!this.board[row][col].isMine) {
168
+ this.board[row][col].isMine = true;
169
+ minesPlaced++;
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Calculates and stores the number of adjacent mines for each cell on the board that is not a mine itself.
176
+ */
177
+ private calculateAdjacentMines(): void {
178
+ for (let row = 0; row < this.rows; row++) {
179
+ for (let col = 0; col < this.cols; col++) {
180
+ this.board[row][col].adjacentMines = 0; // Reset for recalculation (e.g., on game reset)
181
+ if (this.board[row][col].isMine) {
182
+ continue; // Mines don't have an adjacent mine count in this context
183
+ }
184
+
185
+ let count = 0;
186
+ for (const [dr, dc] of Minesweeper.DIRECTIONS) {
187
+ const newRow = row + dr;
188
+ const newCol = col + dc;
189
+ if (this.isValidPosition(newRow, newCol) && this.board[newRow][newCol].isMine) {
190
+ count++;
191
+ }
192
+ }
193
+ this.board[row][col].adjacentMines = count;
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Checks if a given row and column are within the valid boundaries of the game board.
200
+ * @param row The row index to check.
201
+ * @param col The column index to check.
202
+ * @returns True if the position is valid, false otherwise.
203
+ */
204
+ private isValidPosition(row: number, col: number): boolean {
205
+ return row >= 0 && row < this.rows && col >= 0 && col < this.cols;
206
+ }
207
+
208
+ /**
209
+ * Reveals all cells on the board. This is typically called when the game ends.
210
+ */
211
+ private revealAllCells(): void {
212
+ for (let r = 0; r < this.rows; r++) {
213
+ for (let c = 0; c < this.cols; c++) {
214
+ this.board[r][c].isRevealed = true;
215
+ }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Handles the logic for revealing a cell at the given row and column.
221
+ * If the cell is a mine, the game is lost.
222
+ * If the cell has 0 adjacent mines, it triggers a recursive reveal of neighboring cells (flood fill).
223
+ * Checks for win condition after a successful reveal.
224
+ * @param row The row index of the cell to reveal.
225
+ * @param col The column index of the cell to reveal.
226
+ */
227
+ public revealCell(row: number, col: number): void {
228
+ // Ignore if game is over, position is invalid, or cell is already revealed
229
+ if (this.gameState !== 'playing' || !this.isValidPosition(row, col) || this.board[row][col].isRevealed) {
230
+ return;
231
+ }
232
+
233
+ const cell = this.board[row][col];
234
+ cell.isRevealed = true;
235
+ // Note: The time of the last move that *concludes* the game is captured by this.endTime.
236
+
237
+ if (cell.isMine) {
238
+ this.gameState = 'lost';
239
+ this.endTime = new Date(); // Record game end time
240
+ this.hitMineCoordinates = { row, col }; // Record which mine was hit
241
+ this.revealAllCells(); // Reveal all cells as the game is lost
242
+ return;
243
+ }
244
+
245
+ // If it's a non-mine cell, decrement the count of remaining non-mine cells to be revealed.
246
+ this.remainingNonMineCells--;
247
+
248
+ // If the revealed cell has no adjacent mines, recursively reveal its neighbors (flood fill).
249
+ if (cell.adjacentMines === 0) {
250
+ for (const [dr, dc] of Minesweeper.DIRECTIONS) {
251
+ const newRow = row + dr;
252
+ const newCol = col + dc;
253
+ // The recursive call to revealCell itself handles isValidPosition and isRevealed checks.
254
+ this.revealCell(newRow, newCol);
255
+ }
256
+ }
257
+
258
+ // Check for win condition if all non-mine cells have been revealed.
259
+ if (this.checkWinCondition()) {
260
+ this.gameState = 'won';
261
+ this.endTime = new Date(); // Record game end time
262
+ this.revealAllCells(); // Reveal all cells as the game is won
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Checks if the win condition has been met (all non-mine cells are revealed).
268
+ * @returns True if the player has won, false otherwise.
269
+ */
270
+ private checkWinCondition(): boolean {
271
+ return this.remainingNonMineCells === 0;
272
+ }
273
+
274
+ /**
275
+ * Resets the game to its initial state with the same dimensions and mine count.
276
+ * This method can only be called if the game is currently in a 'won' or 'lost' state.
277
+ * @throws Error if the game is still 'playing'.
278
+ */
279
+ public resetGame(): void {
280
+ if (this.gameState === 'playing') {
281
+ throw new Error("Cannot reset the game while it is still in progress. Game must be 'won' or 'lost'.");
282
+ }
283
+ // Re-initialize the game using the original settings
284
+ this.initializeNewGame();
285
+ }
286
+
287
+ /**
288
+ * @returns The current state of the game board (2D array of Cells).
289
+ */
290
+ public getBoard(): Cell[][] {
291
+ return this.board;
292
+ }
293
+
294
+ /**
295
+ * @returns The current game state ('playing', 'won', or 'lost').
296
+ */
297
+ public getGameState(): GameState {
298
+ return this.gameState;
299
+ }
300
+
301
+ /**
302
+ * @returns The Date object representing when the game started.
303
+ */
304
+ public getStartTime(): Date {
305
+ return this.startTime;
306
+ }
307
+
308
+ /**
309
+ * @returns The Date object representing when the game ended, or null if the game is still in progress.
310
+ * This also serves as the time of the "last move" that concluded the game.
311
+ */
312
+ public getEndTime(): Date | null {
313
+ return this.endTime;
314
+ }
315
+
316
+ /**
317
+ * Gets the Uint8Array for the image corresponding to the cell's current state.
318
+ * @param row The row of the cell.
319
+ * @param col The column of the cell.
320
+ * @returns The Uint8Array of the image, or undefined if no image is found for the state.
321
+ */
322
+ public getCellImage(row: number, col: number): Uint8Array | undefined {
323
+ if (!this.isValidPosition(row, col)) {
324
+ console.warn(`getCellImage: Invalid position (${row}, ${col})`);
325
+ return undefined;
326
+ }
327
+ const cell = this.board[row][col];
328
+
329
+ // If cell is not revealed (only possible if game is 'playing')
330
+ if (!cell.isRevealed) {
331
+ // During 'playing' state, all unrevealed cells are hidden buttons.
332
+ // If the game has ended (won/lost), `revealAllCells` makes all cells `isRevealed = true`,
333
+ // so this branch effectively only runs when gameState === 'playing'.
334
+ return this.imageCache.get('cell_hidden');
335
+ }
336
+
337
+ // Cell is revealed (either by user action or by revealAllCells at game end)
338
+ if (cell.isMine) {
339
+ if (this.gameState === 'lost') {
340
+ // If this specific mine was the one clicked that ended the game
341
+ if (this.hitMineCoordinates && this.hitMineCoordinates.row === row && this.hitMineCoordinates.col === col) {
342
+ return this.imageCache.get('mine_hit'); // e.g., mine-red.svg
343
+ }
344
+ // Other mines revealed after losing
345
+ return this.imageCache.get('mine_normal'); // e.g., mine.svg
346
+ }
347
+ if (this.gameState === 'won') {
348
+ // All mines are revealed peacefully when the game is won
349
+ return this.imageCache.get('mine_normal'); // e.g., mine.svg
350
+ }
351
+ // Fallback: Should not happen if a mine is revealed while 'playing', as game would end.
352
+ // But if it did, treat it as a hit mine.
353
+ return this.imageCache.get('mine_hit');
354
+ } else {
355
+ // Revealed and not a mine
356
+ const key = `cell_revealed_${cell.adjacentMines}` as ImageKey; // e.g., cell_revealed_0 for gray.svg, cell_revealed_1 for 1.svg
357
+ return this.imageCache.get(key);
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Gets the Uint8Array for the image corresponding to the current game status.
363
+ * @returns The Uint8Array of the status image, or undefined if not found.
364
+ */
365
+ public getGameStatusImage(): Uint8Array | undefined {
366
+ switch (this.gameState) {
367
+ case 'playing':
368
+ return this.imageCache.get('status_playing');
369
+ case 'won':
370
+ return this.imageCache.get('status_won');
371
+ case 'lost':
372
+ return this.imageCache.get('status_lost');
373
+ default:
374
+ return this.imageCache.get('status_playing'); // Fallback
375
+ }
376
+ }
377
+ }
utils.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function isGitHubUrl(urlString: string): boolean {
2
+ try {
3
+ const url = new URL(urlString);
4
+ const githubDomains = ['github.com', 'www.github.com'];
5
+ return githubDomains.includes(url.hostname) && url.protocol === 'https:';
6
+ } catch {
7
+ return false;
8
+ }
9
+ }
10
+
11
+ export function isGithubUserPath(url: string, username: string): boolean {
12
+ if (!isGitHubUrl(url)) {
13
+ return false;
14
+ }
15
+ const urlObject = new URL(url);
16
+ const pathSegments = urlObject.pathname.split('/').filter((segment) => segment !== '');
17
+ if (pathSegments.length === 0) {
18
+ return false;
19
+ }
20
+ const pathUsername = pathSegments[0];
21
+ return pathUsername === username;
22
+ }