From bf1c7e1eb0700e58186e68f18741b360d568f3b4 Mon Sep 17 00:00:00 2001 From: Radoslav Stoyanov Date: Fri, 12 Jun 2026 18:10:35 +0200 Subject: [PATCH 01/11] Switched to ESM --- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 5 ++++- README.md | 14 ++++++++++---- index.html | 2 +- require.js | 5 ----- src/board.ts | 10 +++++----- src/cell.ts | 4 ++-- src/config.ts | 4 ++-- src/encoder/binaryToBase64UrlEncoder.ts | 2 +- src/encoder/binaryToBase64UrlEncoderV2.ts | 2 +- src/game.ts | 18 +++++++++--------- src/main.ts | 8 ++++---- src/pairer/cantorPairer.ts | 2 +- src/pairer/mortonPairer.ts | 2 +- src/pairer/peterPairer.ts | 2 +- src/pairer/szudzikPairer.ts | 2 +- src/settings.ts | 4 ++-- src/urlTool.ts | 8 ++++---- src/util/pub-sub.ts | 2 +- tsconfig.json | 3 +-- 20 files changed, 52 insertions(+), 49 deletions(-) delete mode 100644 require.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93e5d19..6ae08ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: run: tsc --outDir "${ARTIFACT_PATH}/dist" - name: Prepare artifact - run: mv index.html require.js main.js styles.css favicon.svg robots.txt sitemap.xml manifest.webmanifest sw.js LICENSE assets $ARTIFACT_PATH + run: mv index.html main.js styles.css favicon.svg robots.txt sitemap.xml manifest.webmanifest sw.js LICENSE assets $ARTIFACT_PATH - name: Prepare release asset run: zip -r $ASSET_NAME $ARTIFACT_PATH diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d57cc06..9722b8c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,5 +32,8 @@ $ git commit -am "Something descriptive but concise about the changes I made" $ git push ``` 6. Repeat 3-5 as needed -7. Test your changes locally +7. Test your changes locally - serve the project root over HTTP and open the printed URL in your browser (the game loads as ES modules, so opening `index.html` from the filesystem won't work): +```sh +$ npx serve +``` 8. Create a new pull request - assign it to yourself, add some appropriate tags and link it to the corresponding issue diff --git a/README.md b/README.md index 33bff0f..440eaf3 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,26 @@ # Minesweeper ## Requirements -- typescript v3 or later (if you choose the compile option below) - a non-ancient browser +- _to play locally:_ a static file server — the game loads as ES modules, which browsers refuse to load over `file://`, so opening `index.html` straight from the filesystem won't work. Any server will do, e.g. `npx serve` (requires [Node.js](https://nodejs.org)). +- _to compile from source:_ typescript v4 or later ## Options to play ### Online Enjoy the published version of the game at [theminesweeper.com](https://theminesweeper.com). You can also install it as a progressive web app! ### Locally - download precompiled -Simply download the asset from the [latest release](https://github.com/rdlf0/minesweeper/releases/latest), unzip, and then open `index.html`. +Download the asset from the [latest release](https://github.com/rdlf0/minesweeper/releases/latest), unzip, then serve the folder over HTTP and open the printed URL in your browser: +``` +$ npx serve +``` +(Opening `index.html` directly from the filesystem won't work — the game loads as ES modules, which browsers block over `file://`.) ### Locally - compile from source -Clone, go to the root project directory and run: +Clone, go to the root project directory, compile, then serve: ``` $ tsc +$ npx serve ``` -After that open the `index.html`. +After that open the printed URL (e.g. http://localhost:3000) in your browser. ## Settings | Setting | Options | Default | diff --git a/index.html b/index.html index a22460a..ab7920c 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ Minesweeper - + diff --git a/require.js b/require.js deleted file mode 100644 index a4203f0..0000000 --- a/require.js +++ /dev/null @@ -1,5 +0,0 @@ -/** vim: et:ts=4:sw=4:sts=4 - * @license RequireJS 2.3.6 Copyright jQuery Foundation and other contributors. - * Released under MIT license, https://github.com/requirejs/requirejs/blob/master/LICENSE - */ -var requirejs,require,define;!function(global,setTimeout){var req,s,head,baseElement,dataMain,src,interactiveScript,currentlyAddingScript,mainScript,subPath,version="2.3.6",commentRegExp=/\/\*[\s\S]*?\*\/|([^:"'=]|^)\/\/.*$/gm,cjsRequireRegExp=/[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g,jsSuffixRegExp=/\.js$/,currDirRegExp=/^\.\//,op=Object.prototype,ostring=op.toString,hasOwn=op.hasOwnProperty,isBrowser=!("undefined"==typeof window||"undefined"==typeof navigator||!window.document),isWebWorker=!isBrowser&&"undefined"!=typeof importScripts,readyRegExp=isBrowser&&"PLAYSTATION 3"===navigator.platform?/^complete$/:/^(complete|loaded)$/,defContextName="_",isOpera="undefined"!=typeof opera&&"[object Opera]"===opera.toString(),contexts={},cfg={},globalDefQueue=[],useInteractive=!1;function commentReplace(e,t){return t||""}function isFunction(e){return"[object Function]"===ostring.call(e)}function isArray(e){return"[object Array]"===ostring.call(e)}function each(e,t){var i;if(e)for(i=0;i Date: Fri, 12 Jun 2026 14:43:51 +0200 Subject: [PATCH 02/11] Enabled strictNullChecks --- src/board.ts | 9 +++++++-- src/encoder/binaryToBase64UrlEncoder.ts | 2 +- src/encoder/binaryToBase64UrlEncoderV2.ts | 2 +- src/game.ts | 18 +++++++++--------- src/settings.ts | 22 ++++++++++++++-------- src/timer.ts | 2 +- src/urlTool.ts | 8 ++++---- src/util/session.ts | 2 +- tsconfig.json | 5 ++++- 9 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/board.ts b/src/board.ts index aeb2461..bb61d04 100644 --- a/src/board.ts +++ b/src/board.ts @@ -31,7 +31,7 @@ export class Board { constructor( private mode: Mode, - private state: State, + private state: State | null, private el: HTMLElement, ) { this.initGrid(); @@ -92,8 +92,13 @@ export class Board { } private plantMinesFromState(): void { + const state = this.state; + if (state == null) { + return; + } + for (let i = 0; i < this.mode.rows * this.mode.cols; i++) { - if (this.state.isHighBit(i)) { + if (state.isHighBit(i)) { const row = Math.floor(i / this.mode.cols); const col = i % this.mode.cols; this.grid[row][col].setMine(); diff --git a/src/encoder/binaryToBase64UrlEncoder.ts b/src/encoder/binaryToBase64UrlEncoder.ts index 544e7e3..6eea38d 100644 --- a/src/encoder/binaryToBase64UrlEncoder.ts +++ b/src/encoder/binaryToBase64UrlEncoder.ts @@ -12,7 +12,7 @@ export class BinaryToBase64UrlEncoder implements Encoder { public encode(binary: string): string { const padded = BinaryToBase64UrlEncoder.padString(binary, Side.END, 8, "0"); - const bytes = padded.match(/.{8}/g); + const bytes = padded.match(/.{8}/g) ?? []; const chars = bytes .map(b => String.fromCharCode(parseInt(b, 2))) .join(""); diff --git a/src/encoder/binaryToBase64UrlEncoderV2.ts b/src/encoder/binaryToBase64UrlEncoderV2.ts index 20874a8..319ce63 100644 --- a/src/encoder/binaryToBase64UrlEncoderV2.ts +++ b/src/encoder/binaryToBase64UrlEncoderV2.ts @@ -16,7 +16,7 @@ export class BinaryToBase64UrlEncoderV2 implements Encoder { public encode(binary: string): string { const padded = BinaryToBase64UrlEncoderV2.padString(binary, Side.END, 6, "0"); - const b64u = padded.match(/.{6}/g) + const b64u = (padded.match(/.{6}/g) ?? []) .map(sextet => ALPHABET[parseInt(sextet, 2)]) .join(""); diff --git a/src/game.ts b/src/game.ts index a542ec8..274202f 100644 --- a/src/game.ts +++ b/src/game.ts @@ -39,20 +39,20 @@ export class Game { constructor(private config: Config) { document.body.classList.toggle("dark", this.config.darkModeOn); - this.counter = new Counter(document.getElementById("mines-counter")); - this.timer = new Timer(document.getElementById("timer")); + this.counter = new Counter(document.getElementById("mines-counter")!); + this.timer = new Timer(document.getElementById("timer")!); - this.resetBtn = document.getElementById("reset"); + this.resetBtn = document.getElementById("reset")!; this.resetBtn.addEventListener("click", this.reset.bind(this)); - this.replayBtn = document.getElementById("replay"); + this.replayBtn = document.getElementById("replay")!; this.replayBtn.addEventListener("click", this.replay.bind(this)); - this.toggleSettingsBtn = document.getElementById("toggle-settings"); + this.toggleSettingsBtn = document.getElementById("toggle-settings")!; this.toggleSettingsBtn.addEventListener("click", this.toggleSettings.bind(this)); - this.boardEl = document.getElementById("board"); - this.settingsEl = document.getElementById("settings"); + this.boardEl = document.getElementById("board")!; + this.settingsEl = document.getElementById("settings")!; window.addEventListener("hashchange", this.handleHashChange.bind(this)); this.urlTool = new UrlTool( @@ -131,7 +131,7 @@ export class Game { private generateBoard(): void { let mode: Mode; - let state: State; + let state: State | null; if (this.isReset) { mode = this.board.getMode(); @@ -150,7 +150,7 @@ export class Game { console.warn("Could not extract mode or state from hash. Falling back to defaults."); } } else { - mode = BOARD_CONFIG[this.config.mode]; + mode = BOARD_CONFIG[this.config.mode]!; state = null; Session.set("applyFirstClickRule", true); } diff --git a/src/settings.ts b/src/settings.ts index fc75f6e..b61c76a 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -54,7 +54,7 @@ export class Settings { const modeDetailsWrapper = document.createElement("div"); modeDetailsWrapper.id = "mode_details_wrapper"; fieldset.append(modeDetailsWrapper); - Object.keys(BOARD_CONFIG[MODE_NAME.Beginner]).forEach(modeProperty => { + Object.keys(BOARD_CONFIG[MODE_NAME.Beginner]!).forEach(modeProperty => { this.drawModeDetails(modeDetailsWrapper, modeProperty); }); } @@ -123,9 +123,10 @@ export class Settings { } private drawModeSwitch(parent: HTMLElement, modeKey: string, modeValue: MODE_NAME, fieldset: HTMLElement) { - const rows = BOARD_CONFIG[modeValue].rows; - const cols = BOARD_CONFIG[modeValue].cols; - const mines = BOARD_CONFIG[modeValue].mines; + const modeConfig = BOARD_CONFIG[modeValue]!; + const rows = modeConfig.rows; + const cols = modeConfig.cols; + const mines = modeConfig.mines; const current = rows == BOARD_CONFIG[this.config.mode]?.rows && cols == BOARD_CONFIG[this.config.mode]?.cols && mines == BOARD_CONFIG[this.config.mode]?.mines; @@ -153,7 +154,8 @@ export class Settings { const input = document.createElement("input"); input.setAttribute("type", "number"); input.setAttribute("id", `${modeProperty}Input`); - input.setAttribute("value", BOARD_CONFIG[this.config.mode] == null ? "" : BOARD_CONFIG[this.config.mode][modeProperty].toString()); + const currentModeConfig = BOARD_CONFIG[this.config.mode]; + input.setAttribute("value", currentModeConfig == null ? "" : currentModeConfig[modeProperty].toString()); input.disabled = true; wrapper.appendChild(input); parent.appendChild(wrapper); @@ -207,14 +209,18 @@ ${navigator.userAgent}`; private updateConfig(fieldset: HTMLElement, e: MouseEvent) { const target = e.currentTarget as HTMLElement; - this.config[target.getAttribute("data-configKey")] = target.getAttribute("data-configValue"); - this.drawFieldset(target.getAttribute("data-configKey") as keyof typeof AVAILABLE_SETTINGS, fieldset); + const configKey = target.getAttribute("data-configKey"); + if (configKey == null) { + return; + } + this.config[configKey] = target.getAttribute("data-configValue"); + this.drawFieldset(configKey as keyof typeof AVAILABLE_SETTINGS, fieldset); PubSub.publish(EVENT_SETTINGS_CHANGED) } private toggleDarkMode() { const state = document.body.classList.toggle("dark"); - document.getElementById("darkModeCheckbox").setAttribute("checked", String(state)); + document.getElementById("darkModeCheckbox")!.setAttribute("checked", String(state)); } } diff --git a/src/timer.ts b/src/timer.ts index 5f77634..c8791a2 100644 --- a/src/timer.ts +++ b/src/timer.ts @@ -1,5 +1,5 @@ export class Timer { - private intervaID: number; + private intervaID: number | undefined; private value: number = 0; constructor(private el: HTMLElement) { } diff --git a/src/urlTool.ts b/src/urlTool.ts index cafb067..cd22bf2 100644 --- a/src/urlTool.ts +++ b/src/urlTool.ts @@ -21,7 +21,7 @@ export class UrlTool { return window.location.hash.length > 1; } - public extractMode(): Mode { + public extractMode(): Mode | null { try { this.decodedHash = this.encoder.decode(window.location.hash.slice(1)); } catch (e) { @@ -59,7 +59,7 @@ export class UrlTool { } } - public extractState(mode: Mode): State { + public extractState(mode: Mode): State | null { const stateString = this.decodedHash.slice(MODE_SIZE, mode.rows * mode.cols + MODE_SIZE); if (mode.rows * mode.cols != stateString.length) { console.error("Invalid hash! Can't extract state!"); @@ -75,7 +75,7 @@ export class UrlTool { return state; } - public updateHash(mode: Mode, state: State): void { + public updateHash(mode: Mode | null, state: State | null): void { let encodedHash = ""; if (mode != null && state != null) { @@ -84,7 +84,7 @@ export class UrlTool { encodedHash = this.encoder.encode(decodedHash); } - history.replaceState(undefined, undefined, `#${encodedHash}`); + history.replaceState(undefined, "", `#${encodedHash}`); } private encodeMode(mode: Mode): string { diff --git a/src/util/session.ts b/src/util/session.ts index bfc32f3..8855eb8 100644 --- a/src/util/session.ts +++ b/src/util/session.ts @@ -14,7 +14,7 @@ export class Session { } } - public static get(key: string, defaultValue?: ValueType): ValueType { + public static get(key: string, defaultValue?: ValueType): ValueType | undefined { if (Session.data.has(key)) { return Session.data.get(key); } diff --git a/tsconfig.json b/tsconfig.json index 5a8b592..f5b2977 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,11 @@ { "compilerOptions": { + "rootDir": "./src", "outDir": "./dist", "module": "es2020", - "target": "ES2020" + "target": "ES2020", + "strict": false, + "strictNullChecks": true }, "include": [ "src/**/*" From 5208f74191973e18e64adc298e5edf0bddf03a3c Mon Sep 17 00:00:00 2001 From: Radoslav Stoyanov Date: Fri, 12 Jun 2026 18:36:35 +0200 Subject: [PATCH 03/11] Fixed noImplicitAny; TS is now strict --- .github/workflows/compilabilityCheck.yml | 10 +++++++++- .github/workflows/release.yml | 8 ++++++++ src/game.ts | 3 +-- src/settings.ts | 15 ++++++++------- tsconfig.json | 4 ++-- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/compilabilityCheck.yml b/.github/workflows/compilabilityCheck.yml index 6831ff6..0021b4e 100644 --- a/.github/workflows/compilabilityCheck.yml +++ b/.github/workflows/compilabilityCheck.yml @@ -12,6 +12,14 @@ jobs: steps: - name: Check out the code uses: actions/checkout@v3 - + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install TypeScript + run: npm install -g typescript@6.0.3 # keep in sync with the release workflow + - name: Compile run: tsc --noEmit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ae08ea..59ceb70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,14 @@ jobs: with: ref: ${{ github.sha }} + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install TypeScript + run: npm install -g typescript@6.0.3 # keep in sync with the compilability check workflow + - name: Compile the source run: tsc --outDir "${ARTIFACT_PATH}/dist" diff --git a/src/game.ts b/src/game.ts index 274202f..046a50a 100644 --- a/src/game.ts +++ b/src/game.ts @@ -161,8 +161,7 @@ export class Game { } private getModeNameFromMode(mode: Mode): MODE_NAME { - for (const modeKey in MODE_NAME) { - const modeValue = MODE_NAME[modeKey]; + for (const modeValue of Object.values(MODE_NAME)) { const m = BOARD_CONFIG[modeValue]; if (m == null) { continue; diff --git a/src/settings.ts b/src/settings.ts index b61c76a..011a19a 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,4 @@ -import { BOARD_CONFIG, Config, FIRST_CLICK, MODE_NAME } from "./config.js"; +import { BOARD_CONFIG, Config, FIRST_CLICK, Mode, MODE_NAME } from "./config.js"; import { EVENT_SETTINGS_CHANGED, PubSub } from "./util/pub-sub.js"; enum AVAILABLE_SETTINGS { @@ -42,8 +42,7 @@ export class Settings { modeSwitchWrapper.id = "mode_switch_wrapper"; fieldset.append(modeSwitchWrapper); - Object.keys(MODE_NAME).forEach(modeKey => { - const modeValue = MODE_NAME[modeKey]; + Object.entries(MODE_NAME).forEach(([modeKey, modeValue]) => { if (BOARD_CONFIG[modeValue] == null) { return; } @@ -65,12 +64,13 @@ export class Settings { return; } + const value = FIRST_CLICK[firstClickKey as keyof typeof FIRST_CLICK]; this.drawRadioButton( fieldset, firstClickKey, "firstClick", - FIRST_CLICK[firstClickKey], - this.config.firstClick == FIRST_CLICK[firstClickKey]); + value.toString(), + this.config.firstClick == value); }) } @@ -155,7 +155,7 @@ export class Settings { input.setAttribute("type", "number"); input.setAttribute("id", `${modeProperty}Input`); const currentModeConfig = BOARD_CONFIG[this.config.mode]; - input.setAttribute("value", currentModeConfig == null ? "" : currentModeConfig[modeProperty].toString()); + input.setAttribute("value", currentModeConfig == null ? "" : currentModeConfig[modeProperty as keyof Mode].toString()); input.disabled = true; wrapper.appendChild(input); parent.appendChild(wrapper); @@ -213,7 +213,8 @@ ${navigator.userAgent}`; if (configKey == null) { return; } - this.config[configKey] = target.getAttribute("data-configValue"); + // Dynamic write of a config field (mode / firstClick) from a DOM attribute string + (this.config as any)[configKey] = target.getAttribute("data-configValue"); this.drawFieldset(configKey as keyof typeof AVAILABLE_SETTINGS, fieldset); PubSub.publish(EVENT_SETTINGS_CHANGED) } diff --git a/tsconfig.json b/tsconfig.json index f5b2977..2019420 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,8 +4,8 @@ "outDir": "./dist", "module": "es2020", "target": "ES2020", - "strict": false, - "strictNullChecks": true + "strict": true, + "strictPropertyInitialization": false }, "include": [ "src/**/*" From 5d297c4a7cba1de7a238ea516c962b97d90922a9 Mon Sep 17 00:00:00 2001 From: Radoslav Stoyanov Date: Fri, 12 Jun 2026 19:08:23 +0200 Subject: [PATCH 04/11] Added basic tests --- ...mpilabilityCheck.yml => pr-validation.yml} | 15 +++---- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 4 ++ test/cantorPairer.test.mjs | 43 +++++++++++++++++++ test/encoder.test.mjs | 25 +++++++++++ test/state.test.mjs | 28 ++++++++++++ 6 files changed, 108 insertions(+), 9 deletions(-) rename .github/workflows/{compilabilityCheck.yml => pr-validation.yml} (55%) create mode 100644 test/cantorPairer.test.mjs create mode 100644 test/encoder.test.mjs create mode 100644 test/state.test.mjs diff --git a/.github/workflows/compilabilityCheck.yml b/.github/workflows/pr-validation.yml similarity index 55% rename from .github/workflows/compilabilityCheck.yml rename to .github/workflows/pr-validation.yml index 0021b4e..9aeabc9 100644 --- a/.github/workflows/compilabilityCheck.yml +++ b/.github/workflows/pr-validation.yml @@ -1,13 +1,12 @@ -name: Compilability check +name: PR Validation on: - push: - branches-ignore: - - 'master' + pull_request: + types: [opened, reopened, synchronize] jobs: - compile: - name: Compile + pr-validation: + name: PR Validation runs-on: ubuntu-22.04 steps: - name: Check out the code @@ -21,5 +20,5 @@ jobs: - name: Install TypeScript run: npm install -g typescript@6.0.3 # keep in sync with the release workflow - - name: Compile - run: tsc --noEmit + - name: Compile and test + run: tsc && node --test # tsc emits to dist/ and type-checks (fails on errors); tests import the build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59ceb70..8ec6a3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: node-version: 22 - name: Install TypeScript - run: npm install -g typescript@6.0.3 # keep in sync with the compilability check workflow + run: npm install -g typescript@6.0.3 # keep in sync with the PR validation workflow - name: Compile the source run: tsc --outDir "${ARTIFACT_PATH}/dist" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9722b8c..1162f9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,5 +35,9 @@ $ git push 7. Test your changes locally - serve the project root over HTTP and open the printed URL in your browser (the game loads as ES modules, so opening `index.html` from the filesystem won't work): ```sh $ npx serve +``` + And run the unit tests (Node's built-in runner - no dependencies; build first so the compiled `dist/` exists): +```sh +$ tsc && node --test ``` 8. Create a new pull request - assign it to yourself, add some appropriate tags and link it to the corresponding issue diff --git a/test/cantorPairer.test.mjs b/test/cantorPairer.test.mjs new file mode 100644 index 0000000..461284f --- /dev/null +++ b/test/cantorPairer.test.mjs @@ -0,0 +1,43 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { CantorPairer } from "../dist/pairer/cantorPairer.js"; + +const pairer = new CantorPairer(); + +test("pair() produces the known Cantor values for small tuples", () => { + assert.equal(pairer.pair({ a: 0, b: 0 }), 0); + assert.equal(pairer.pair({ a: 1, b: 0 }), 1); + assert.equal(pairer.pair({ a: 0, b: 1 }), 2); + assert.equal(pairer.pair({ a: 1, b: 1 }), 4); +}); + +test("unpair() inverts pair() for a range of tuples", () => { + for (let a = 0; a < 40; a++) { + for (let b = 0; b < 40; b++) { + const x = pairer.pair({ a, b }); + assert.deepEqual(pairer.unpair(x), { a, b }, `round-trip failed for (${a}, ${b})`); + } + } +}); + +// This mirrors how UrlTool folds a mode into a single integer: +// pair(pair(rows, cols), mines) — then unpairs twice to recover it. +test("nested pairing round-trips the built-in game modes", () => { + const modes = [ + { rows: 9, cols: 14, mines: 10 }, // beginner + { rows: 16, cols: 16, mines: 40 }, // intermediate + { rows: 16, cols: 30, mines: 99 }, // expert + ]; + + for (const { rows, cols, mines } of modes) { + const paired = pairer.pair({ a: pairer.pair({ a: rows, b: cols }), b: mines }); + + const outer = pairer.unpair(paired); + const inner = pairer.unpair(outer.a); + + assert.equal(inner.a, rows); + assert.equal(inner.b, cols); + assert.equal(outer.b, mines); + } +}); diff --git a/test/encoder.test.mjs b/test/encoder.test.mjs new file mode 100644 index 0000000..1e2bdd0 --- /dev/null +++ b/test/encoder.test.mjs @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { BinaryToBase64UrlEncoder } from "../dist/encoder/binaryToBase64UrlEncoder.js"; +import { BinaryToBase64UrlEncoderV2 } from "../dist/encoder/binaryToBase64UrlEncoderV2.js"; + +// 24 bits — a multiple of both encoders' block sizes (8 and 6), so encode -> decode is exact. +const binary = "100100011010001010111000"; + +for (const Encoder of [BinaryToBase64UrlEncoder, BinaryToBase64UrlEncoderV2]) { + const encoder = new Encoder(); + + test(`${Encoder.name}: decode() inverts encode()`, () => { + assert.equal(encoder.decode(encoder.encode(binary)), binary); + }); + + test(`${Encoder.name}: produces a URL-safe string (no +, /, =)`, () => { + const encoded = encoder.encode(binary); + assert.doesNotMatch(encoded, /[+/=]/); + }); + + test(`${Encoder.name}: decode() rejects invalid input`, () => { + assert.throws(() => encoder.decode("!!!not base64url!!!")); + }); +} diff --git a/test/state.test.mjs b/test/state.test.mjs new file mode 100644 index 0000000..cee1629 --- /dev/null +++ b/test/state.test.mjs @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { State } from "../dist/state.js"; + +test("a fresh State is all zeros", () => { + assert.equal(new State(8).toString(), "00000000"); +}); + +test("setBit / isHighBit / unsetBit flip a single bit", () => { + const state = new State(8); + + assert.equal(state.isHighBit(3), false); + + state.setBit(3); + assert.equal(state.isHighBit(3), true); + assert.equal(state.toString(), "00010000"); + + state.unsetBit(3); + assert.equal(state.isHighBit(3), false); + assert.equal(state.toString(), "00000000"); +}); + +test("out-of-bounds access throws", () => { + const state = new State(4); + assert.throws(() => state.setBit(4)); + assert.throws(() => state.isHighBit(4)); +}); From 0d5fe67632f9c910196fe4f0eea920141e1e44ca Mon Sep 17 00:00:00 2001 From: Radoslav Stoyanov Date: Fri, 12 Jun 2026 19:27:46 +0200 Subject: [PATCH 05/11] Oops, a typo --- src/cell.ts | 8 ++++---- styles.css | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cell.ts b/src/cell.ts index 868b43d..5b6a3c3 100644 --- a/src/cell.ts +++ b/src/cell.ts @@ -14,7 +14,7 @@ enum CellState { Questioned = "questioned", Revealed = "revealed", RevealedMine = "revealedMine", - Exploаded = "exploaded", + Exploded = "exploded", WronglyFlagged = "wronglyFlagged", } @@ -106,7 +106,7 @@ export class Cell { } private explode(): void { - this.setState(CellState.Exploаded); + this.setState(CellState.Exploded); PubSub.publish(EVENT_GAME_OVER); } @@ -114,8 +114,8 @@ export class Cell { // Leave flags if (this.state === CellState.Flagged) return; - // Reveal not exploaded mines - if (this.state !== CellState.Exploаded) { + // Reveal not exploded mines + if (this.state !== CellState.Exploded) { this.setState(CellState.RevealedMine) } } diff --git a/styles.css b/styles.css index 35dd644..abc8bdb 100644 --- a/styles.css +++ b/styles.css @@ -164,7 +164,7 @@ main { .cell.state-revealed, .cell.state-revealedMine, -.cell.state-exploaded, +.cell.state-exploded, .cell.state-wronglyFlagged { background-color: #bdbdbd; border-style: solid none none solid; @@ -185,11 +185,11 @@ main { } .cell.state-revealedMine, -.cell.state-exploaded { +.cell.state-exploded { background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMzAwIiB3aWR0aD0iMzAwIiB2aWV3Qm94PSIwIDAgMzAwIDMwMCI+Cgk8dGl0bGU+bWluZTwvdGl0bGU+Cgk8ZyBmaWxsPSIjZmZmZmZmIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMSI+CgkJPGNpcmNsZSBjeD0iMTUwIiBjeT0iMTUwIiByPSIxMTAiIC8+CgkJPGc+CgkJCTxyZWN0IHg9IjAiIHk9IjEzMCIgd2lkdGg9IjMwMCIgaGVpZ2h0PSI0MCIgcng9IjIwIiByeT0iMjAiIC8+CgkJCTxyZWN0IHg9IjEzMCIgeT0iMCIgd2lkdGg9IjQwIiBoZWlnaHQ9IjMwMCIgcng9IjIwIiByeT0iMjAiIC8+CgkJPC9nPgoJCTxnIHN0eWxlPSJ0cmFuc2Zvcm0tb3JpZ2luOiBjZW50ZXI7IHRyYW5zZm9ybTogcm90YXRlKDQ1ZGVnKTsiPgoJCQk8cmVjdCB4PSIxMCIgeT0iMTMwIiB3aWR0aD0iMjgwIiBoZWlnaHQ9IjQwIiByeD0iMjAiIHJ5PSIyMCIgLz4KCQkJPHJlY3QgeD0iMTMwIiB5PSIxMCIgd2lkdGg9IjQwIiBoZWlnaHQ9IjI4MCIgcng9IjIwIiByeT0iMjAiIC8+CgkJPC9nPgoJPC9nPgoJPGcgZmlsbD0iIzAwMDAwMCIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjEiPgoJCTxjaXJjbGUgY3g9IjE1MCIgY3k9IjE1MCIgcj0iMTAwIiAvPgoJCTxnPgoJCQk8cmVjdCB4PSIxMCIgeT0iMTQwIiB3aWR0aD0iMjgwIiBoZWlnaHQ9IjIwIiByeD0iMTAiIHJ5PSIxMCIgLz4KCQkJPHJlY3QgeD0iMTQwIiB5PSIxMCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjI4MCIgcng9IjEwIiByeT0iMTAiIC8+CgkJPC9nPgoJCTxnIHN0eWxlPSJ0cmFuc2Zvcm0tb3JpZ2luOiBjZW50ZXI7IHRyYW5zZm9ybTogcm90YXRlKDQ1ZGVnKTsiPgoJCQk8cmVjdCB4PSIyMCIgeT0iMTQwIiB3aWR0aD0iMjYwIiBoZWlnaHQ9IjIwIiByeD0iMTAiIHJ5PSIxMCIgLz4KCQkJPHJlY3QgeD0iMTQwIiB5PSIyMCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjI2MCIgcng9IjEwIiByeT0iMTAiIC8+CgkJPC9nPgoJCTxjaXJjbGUgY3g9IjEyMCIgY3k9IjEyMCIgcj0iMjAiIGZpbGw9IiNmZmZmZmYiIHN0cm9rZT0iI2ZmZmZmZiIgLz4KCTwvZz4KPC9zdmc+); } -.cell.state-exploaded { +.cell.state-exploded { background-color: #ff0000; } From bccbbf2217d33ccd945be74b9001f05807dae140 Mon Sep 17 00:00:00 2001 From: Radoslav Stoyanov Date: Fri, 12 Jun 2026 21:07:10 +0200 Subject: [PATCH 06/11] Improved release workflow; bumped actions everywhere --- .github/workflows/commentReleasedPRs.yml | 5 +- .github/workflows/pr-validation.yml | 8 +- .github/workflows/release.yml | 132 +++++++++++++---------- 3 files changed, 83 insertions(+), 62 deletions(-) diff --git a/.github/workflows/commentReleasedPRs.yml b/.github/workflows/commentReleasedPRs.yml index e67df31..b71607e 100644 --- a/.github/workflows/commentReleasedPRs.yml +++ b/.github/workflows/commentReleasedPRs.yml @@ -8,7 +8,10 @@ on: jobs: comment: name: Comment released PRs - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 + permissions: + contents: read # read the release and its commits + pull-requests: write # comment on and label the released PRs steps: - name: Comment released PRs uses: rdlf0/comment-released-prs-action@v3 diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 9aeabc9..4ebe861 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -7,13 +7,15 @@ on: jobs: pr-validation: name: PR Validation - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 + permissions: + contents: read steps: - name: Check out the code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ec6a3c..5a62d3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,21 +8,27 @@ on: env: ARTIFACT_NAME: minesweeper-compiled +concurrency: + group: release + cancel-in-progress: false + jobs: build: name: Build - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 + permissions: + contents: write # needed to upload the release asset via the gh CLI env: ARTIFACT_PATH: artifact ASSET_NAME: minesweeper.zip steps: - name: Check out the code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.sha }} - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 @@ -33,31 +39,45 @@ jobs: run: tsc --outDir "${ARTIFACT_PATH}/dist" - name: Prepare artifact - run: mv index.html main.js styles.css favicon.svg robots.txt sitemap.xml manifest.webmanifest sw.js LICENSE assets $ARTIFACT_PATH + # Ship the whole site, excluding dev tooling, so new static files are picked + # up automatically instead of having to be added to a hand-maintained list. + run: | + rsync -a \ + --exclude="/.git" \ + --exclude="/.github" \ + --exclude="/.vscode" \ + --exclude="/src" \ + --exclude="/test" \ + --exclude="/node_modules" \ + --exclude="/tsconfig.json" \ + --exclude="/.gitignore" \ + --exclude="*.md" \ + --exclude="/${ARTIFACT_PATH}" \ + ./ "${ARTIFACT_PATH}/" - name: Prepare release asset - run: zip -r $ASSET_NAME $ARTIFACT_PATH + run: zip -r "$ASSET_NAME" "$ARTIFACT_PATH" - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ env.ARTIFACT_NAME }} path: ./${{ env.ARTIFACT_PATH }} - name: Upload release asset - uses: AButler/upload-release-assets@v3.0 - with: - files: "./${{ env.ASSET_NAME }}" - repo-token: ${{ secrets.GITHUB_TOKEN }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "${{ github.event.release.tag_name }}" "$ASSET_NAME" --repo "$GITHUB_REPOSITORY" deploy: name: Deploy needs: build - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 env: AWS_S3_BUCKET: theminesweeper.com AWS_REGION: us-east-2 AWS_ROLE: arn:aws:iam::067429979131:role/github_actions_minesweeper + CLOUDFRONT_DISTRIBUTION_ID: E32QGZLPPEMLDF environment: name: production url: "https://${{ env.AWS_S3_BUCKET }}" @@ -66,63 +86,59 @@ jobs: contents: read steps: - name: Download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ env.ARTIFACT_NAME }} - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ env.AWS_ROLE }} aws-region: ${{ env.AWS_REGION }} - name: Upload to S3 run: | - declare -a files=( - [0]="index.html" - [1]="*.js" - [2]="*.css" - [3]="*.svg" - [4]="*.png" - [5]="*.webmanifest" - [6]="*.txt" - [7]="*.xml" - [8]="LICENSE" - ) - - declare -a caches=( - [0]="no-caches" - [1]="max-age=31536000 immutable" - [2]="max-age=31536000 immutable" - [3]="max-age=31536000 immutable" - [4]="max-age=31536000 immutable" - [5]="max-age=31536000 immutable" - [6]="max-age=31536000 immutable" - [7]="max-age=31536000 immutable" - [8]="" - ) - - declare -a contentTypes=( - [0]="text/html; charset=utf-8" - [1]="application/javascript; charset=utf-8" - [2]="text/css; charset=utf-8" - [3]="image/svg+xml; charset=utf-8" - [4]="image/png; charset=utf-8" - [5]="application/manifest+json; charset=utf-8" - [6]="text/plain; charset=utf-8" - [7]="application/xml; charset=utf-8" - [8]="" + set -euo pipefail + + # Filenames are NOT content-hashed, so assets must NOT be 'immutable': + # they get a short TTL and CloudFront is invalidated below, so a deploy + # reaches users right away. index.html and sw.js always revalidate. + # Each rule: || + rules=( + "index.html|text/html; charset=utf-8|no-cache" + "*.js|application/javascript; charset=utf-8|public, max-age=3600" + "*.css|text/css; charset=utf-8|public, max-age=3600" + "*.svg|image/svg+xml; charset=utf-8|public, max-age=3600" + "*.png|image/png; charset=utf-8|public, max-age=3600" + "*.webmanifest|application/manifest+json; charset=utf-8|public, max-age=3600" + "*.txt|text/plain; charset=utf-8|public, max-age=3600" + "*.xml|application/xml; charset=utf-8|public, max-age=3600" + "LICENSE|text/plain; charset=utf-8|public, max-age=3600" ) - for i in "${!files[@]}" - do - echo "Uploading ${files[$i]}" - - aws s3 sync . s3://$AWS_S3_BUCKET \ - --delete \ - --acl public-read \ - --exclude "*" \ - --include "${files[$i]}" \ - --cache-control "${caches[$i]}" \ - --content-type "${contentTypes[$i]}" + for rule in "${rules[@]}"; do + IFS="|" read -r glob ctype cache <<< "$rule" + echo "Uploading $glob" + aws s3 sync . "s3://$AWS_S3_BUCKET" \ + --delete \ + --exclude "*" \ + --include "$glob" \ + --content-type "$ctype" \ + --cache-control "$cache" done + + # sw.js is uploaded by the *.js rule above; the service worker must always + # revalidate, so force its Cache-Control afterwards. + aws s3 cp "s3://$AWS_S3_BUCKET/sw.js" "s3://$AWS_S3_BUCKET/sw.js" \ + --metadata-directive REPLACE \ + --content-type "application/javascript; charset=utf-8" \ + --cache-control "no-cache" + + - name: Invalidate CloudFront + # Asset filenames are stable (not content-hashed), so without this CloudFront + # keeps serving the previous deploy from its edge cache. The deploy IAM role + # needs cloudfront:CreateInvalidation. + run: | + aws cloudfront create-invalidation \ + --distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" \ + --paths "/*" From 34cb742b2957e177907779355e2798af2c58ebc8 Mon Sep 17 00:00:00 2001 From: Radoslav Stoyanov Date: Fri, 12 Jun 2026 21:29:42 +0200 Subject: [PATCH 07/11] Fixed release badge in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 440eaf3..180adec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![](https://github.com/rdlf0/minesweeper/workflows/CI/CD/badge.svg) +[![Release](https://github.com/rdlf0/minesweeper/actions/workflows/release.yml/badge.svg)](https://github.com/rdlf0/minesweeper/actions/workflows/release.yml) # Minesweeper From 831b89a5d0d5a89d06e1fb5dc2ebcc091648b1a8 Mon Sep 17 00:00:00 2001 From: Radoslav Stoyanov Date: Fri, 12 Jun 2026 21:35:19 +0200 Subject: [PATCH 08/11] Added dependabot config --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly From aee59b7b0c2cef8c0bda55911a1c13a84f02d5cd Mon Sep 17 00:00:00 2001 From: Radoslav Stoyanov Date: Fri, 12 Jun 2026 21:38:29 +0200 Subject: [PATCH 09/11] Renamed main.js to pwa-resize.js --- index.html | 2 +- main.js => pwa-resize.js | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename main.js => pwa-resize.js (100%) diff --git a/index.html b/index.html index ab7920c..b639a5c 100644 --- a/index.html +++ b/index.html @@ -43,7 +43,7 @@
- + \ No newline at end of file diff --git a/main.js b/pwa-resize.js similarity index 100% rename from main.js rename to pwa-resize.js From 6ee8164d20412191eff85c7f177ea308141e18a6 Mon Sep 17 00:00:00 2001 From: Radoslav Stoyanov Date: Fri, 12 Jun 2026 21:38:55 +0200 Subject: [PATCH 10/11] Throw errors instead of plan strings --- src/encoder/binaryToBase64UrlEncoder.ts | 2 +- src/encoder/binaryToBase64UrlEncoderV2.ts | 2 +- src/state.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/encoder/binaryToBase64UrlEncoder.ts b/src/encoder/binaryToBase64UrlEncoder.ts index 6eea38d..16887c0 100644 --- a/src/encoder/binaryToBase64UrlEncoder.ts +++ b/src/encoder/binaryToBase64UrlEncoder.ts @@ -36,7 +36,7 @@ export class BinaryToBase64UrlEncoder implements Encoder { try { chars = atob(b64); } catch (e) { - throw "Invalid Base64Url string!"; + throw new Error("Invalid Base64Url string!"); } const bytes = chars.split("") diff --git a/src/encoder/binaryToBase64UrlEncoderV2.ts b/src/encoder/binaryToBase64UrlEncoderV2.ts index 319ce63..d60f228 100644 --- a/src/encoder/binaryToBase64UrlEncoderV2.ts +++ b/src/encoder/binaryToBase64UrlEncoderV2.ts @@ -28,7 +28,7 @@ export class BinaryToBase64UrlEncoderV2 implements Encoder { .map(ch => { const index = ALPHABET.indexOf(ch); if (index == -1) { - throw "Invalid Base64Url string!"; + throw new Error("Invalid Base64Url string!"); } return index; diff --git a/src/state.ts b/src/state.ts index bc3e5bd..d5817ab 100644 --- a/src/state.ts +++ b/src/state.ts @@ -16,7 +16,7 @@ export class State { public isHighBit(index: number): boolean { if (index >= this.size) { - throw "Index out of bounds!"; + throw new Error("Index out of bounds!"); } return this.data[index] == 1; @@ -24,7 +24,7 @@ export class State { public setBit(index: number): void { if (index >= this.size) { - throw "Index out of bounds!"; + throw new Error("Index out of bounds!"); } this.data[index] = 1; @@ -32,7 +32,7 @@ export class State { public unsetBit(index: number): void { if (index >= this.size) { - throw "Index out of bounds!"; + throw new Error("Index out of bounds!"); } this.data[index] = 0; From 318a2882ecf1ba7961675e531e974d57a27e0b1a Mon Sep 17 00:00:00 2001 From: Radoslav Stoyanov Date: Mon, 15 Jun 2026 13:51:10 +0200 Subject: [PATCH 11/11] Tweaked comments --- .github/workflows/commentReleasedPRs.yml | 2 +- .github/workflows/release.yml | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/commentReleasedPRs.yml b/.github/workflows/commentReleasedPRs.yml index b71607e..63687b0 100644 --- a/.github/workflows/commentReleasedPRs.yml +++ b/.github/workflows/commentReleasedPRs.yml @@ -10,7 +10,7 @@ jobs: name: Comment released PRs runs-on: ubuntu-24.04 permissions: - contents: read # read the release and its commits + contents: read # read the release and its commits pull-requests: write # comment on and label the released PRs steps: - name: Comment released PRs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a62d3a..c0ab3de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,8 +39,6 @@ jobs: run: tsc --outDir "${ARTIFACT_PATH}/dist" - name: Prepare artifact - # Ship the whole site, excluding dev tooling, so new static files are picked - # up automatically instead of having to be added to a hand-maintained list. run: | rsync -a \ --exclude="/.git" \ @@ -100,9 +98,6 @@ jobs: run: | set -euo pipefail - # Filenames are NOT content-hashed, so assets must NOT be 'immutable': - # they get a short TTL and CloudFront is invalidated below, so a deploy - # reaches users right away. index.html and sw.js always revalidate. # Each rule: || rules=( "index.html|text/html; charset=utf-8|no-cache" @@ -135,9 +130,6 @@ jobs: --cache-control "no-cache" - name: Invalidate CloudFront - # Asset filenames are stable (not content-hashed), so without this CloudFront - # keeps serving the previous deploy from its edge cache. The deploy IAM role - # needs cloudfront:CreateInvalidation. run: | aws cloudfront create-invalidation \ --distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" \