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 diff --git a/.github/workflows/commentReleasedPRs.yml b/.github/workflows/commentReleasedPRs.yml index e67df31..63687b0 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/compilabilityCheck.yml b/.github/workflows/compilabilityCheck.yml deleted file mode 100644 index 6831ff6..0000000 --- a/.github/workflows/compilabilityCheck.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Compilability check - -on: - push: - branches-ignore: - - 'master' - -jobs: - compile: - name: Compile - runs-on: ubuntu-22.04 - steps: - - name: Check out the code - uses: actions/checkout@v3 - - - name: Compile - run: tsc --noEmit diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..4ebe861 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,26 @@ +name: PR Validation + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + pr-validation: + name: PR Validation + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Check out the code + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Install TypeScript + run: npm install -g typescript@6.0.3 # keep in sync with the release workflow + + - 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 93e5d19..c0ab3de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,48 +8,74 @@ 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@v6 + with: + node-version: 22 + + - name: Install TypeScript + 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" - 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: | + 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 }}" @@ -58,63 +84,53 @@ 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]="" - ) + set -euo pipefail - 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]="" + # 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 + run: | + aws cloudfront create-invalidation \ + --distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" \ + --paths "/*" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d57cc06..1162f9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,5 +32,12 @@ $ 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 +``` + 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/README.md b/README.md index 33bff0f..180adec 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,28 @@ -![](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 ## 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..b639a5c 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ Minesweeper - + @@ -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 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 String.fromCharCode(parseInt(b, 2))) .join(""); @@ -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 4f12bfc..d60f228 100644 --- a/src/encoder/binaryToBase64UrlEncoderV2.ts +++ b/src/encoder/binaryToBase64UrlEncoderV2.ts @@ -1,4 +1,4 @@ -import { Encoder } from "./encoder"; +import { Encoder } from "./encoder.js"; enum Side { BEGINING, @@ -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(""); @@ -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/game.ts b/src/game.ts index da07012..046a50a 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,9 +1,9 @@ -import { Board } from "./board"; -import { Timer } from "./timer"; -import { Counter } from "./counter"; -import { Config, Mode, BOARD_CONFIG, MODE_NAME } from "./config"; -import { State } from "./state"; -import { UrlTool } from "./urlTool"; +import { Board } from "./board.js"; +import { Timer } from "./timer.js"; +import { Counter } from "./counter.js"; +import { Config, Mode, BOARD_CONFIG, MODE_NAME } from "./config.js"; +import { State } from "./state.js"; +import { UrlTool } from "./urlTool.js"; import { EVENT_CELL_REVEALED, EVENT_CELL_FLAGGED, @@ -12,9 +12,9 @@ import { EVENT_SAFE_AREA_CREATED, EVENT_SETTINGS_CHANGED, PubSub, -} from "./util/pub-sub"; -import { Session } from "./util/session"; -import { Settings } from "./settings"; +} from "./util/pub-sub.js"; +import { Session } from "./util/session.js"; +import { Settings } from "./settings.js"; export class Game { @@ -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); } @@ -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/main.ts b/src/main.ts index e92edd2..06340ff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ -import { Game } from "./game"; -import { FIRST_CLICK, MODE_NAME, Config } from "./config"; -import { CantorPairer } from "./pairer/cantorPairer"; -import { BinaryToBase64UrlEncoderV2 } from "./encoder/binaryToBase64UrlEncoderV2"; +import { Game } from "./game.js"; +import { FIRST_CLICK, MODE_NAME, Config } from "./config.js"; +import { CantorPairer } from "./pairer/cantorPairer.js"; +import { BinaryToBase64UrlEncoderV2 } from "./encoder/binaryToBase64UrlEncoderV2.js"; const config: Config = { mode: MODE_NAME.Expert, diff --git a/src/pairer/cantorPairer.ts b/src/pairer/cantorPairer.ts index f5a6b3b..f1e6397 100644 --- a/src/pairer/cantorPairer.ts +++ b/src/pairer/cantorPairer.ts @@ -1,4 +1,4 @@ -import { Pairer, Tuple } from "./pairer"; +import { Pairer, Tuple } from "./pairer.js"; /** * Implemetns Cantor pairing and inverted pairing functions diff --git a/src/pairer/mortonPairer.ts b/src/pairer/mortonPairer.ts index 045a514..9c5f151 100644 --- a/src/pairer/mortonPairer.ts +++ b/src/pairer/mortonPairer.ts @@ -1,4 +1,4 @@ -import { Pairer, Tuple } from "./pairer"; +import { Pairer, Tuple } from "./pairer.js"; /** * Implements Morton order function (Z-Order curve) diff --git a/src/pairer/peterPairer.ts b/src/pairer/peterPairer.ts index 7874215..cd2b036 100644 --- a/src/pairer/peterPairer.ts +++ b/src/pairer/peterPairer.ts @@ -1,4 +1,4 @@ -import { Pairer, Tuple } from "./pairer"; +import { Pairer, Tuple } from "./pairer.js"; export class PeterPairer implements Pairer { diff --git a/src/pairer/szudzikPairer.ts b/src/pairer/szudzikPairer.ts index ecad411..626d8df 100644 --- a/src/pairer/szudzikPairer.ts +++ b/src/pairer/szudzikPairer.ts @@ -1,4 +1,4 @@ -import { Pairer, Tuple } from "./pairer"; +import { Pairer, Tuple } from "./pairer.js"; export class SzudzikPairer implements Pairer { diff --git a/src/settings.ts b/src/settings.ts index 46445ac..011a19a 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,5 +1,5 @@ -import { BOARD_CONFIG, Config, FIRST_CLICK, MODE_NAME } from "./config"; -import { EVENT_SETTINGS_CHANGED, PubSub } from "./util/pub-sub"; +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 { mode = "Mode", @@ -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; } @@ -54,7 +53,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); }); } @@ -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); }) } @@ -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 as keyof Mode].toString()); input.disabled = true; wrapper.appendChild(input); parent.appendChild(wrapper); @@ -207,14 +209,19 @@ ${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; + } + // 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) } 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/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; 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 58d0702..cd22bf2 100644 --- a/src/urlTool.ts +++ b/src/urlTool.ts @@ -1,7 +1,7 @@ -import { Encoder } from "./encoder/encoder"; -import { Pairer, Tuple } from "./pairer/pairer"; -import { Mode } from "./config"; -import { State } from "./state"; +import { Encoder } from "./encoder/encoder.js"; +import { Pairer, Tuple } from "./pairer/pairer.js"; +import { Mode } from "./config.js"; +import { State } from "./state.js"; const MODE_SIZE = 24; const MIN_ROWS = 5; @@ -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/pub-sub.ts b/src/util/pub-sub.ts index fbfeda3..d215f2f 100644 --- a/src/util/pub-sub.ts +++ b/src/util/pub-sub.ts @@ -1,4 +1,4 @@ -import { Session } from "./session"; +import { Session } from "./session.js"; interface CallbackFunc { (data?: any): any; 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/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; } 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)); +}); diff --git a/tsconfig.json b/tsconfig.json index 2cdf007..2019420 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,11 @@ { "compilerOptions": { - "baseUrl": "./", + "rootDir": "./src", "outDir": "./dist", - "module": "amd", - "target": "ES2020" + "module": "es2020", + "target": "ES2020", + "strict": true, + "strictPropertyInitialization": false }, "include": [ "src/**/*"