diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 61330ba..8832c8f 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -1,17 +1,16 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# Installs dependencies, type-checks, runs the unit tests and builds the app # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs name: Node.js CI on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: build: - runs-on: ubuntu-latest strategy: @@ -20,11 +19,16 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - - uses: actions/checkout@v6 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run test + - uses: actions/checkout@v6 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - name: Type check + run: npm run type-check + - name: Unit tests + run: npm run test + - name: Build + run: npm run build-only diff --git a/.prettierrc b/.prettierrc index 1818847..17c3d4c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,7 @@ { "tabWidth": 2, "useTabs": false, - "semi" :false, - "singleQuote": true + "semi": false, + "singleQuote": true, + "printWidth": 100 } diff --git a/README.md b/README.md index 10aa871..9a496a9 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,129 @@ # GameLifeCounter -Is a basic life counter for games, -it can handle two persons at the same time. - -[App](https://albertlast.github.io/GameLifeCounter/) - -Keeping holding the - or + area to jump 10 steps - -You can use the setting menu to switch to fullscreen. -When android with chromebase browser is used, -you can also disable the screensaver (enable wakelock). - -Support pwa -> can be installed locally on supported devices. - -![App](doc/App.png) - -## Requirments - -Only a webserver to delifer the js files is needed, -the website runs full on client side. - -## Project Setup +A life counter for board and card games that runs entirely in the browser. + +[Open the app](https://albertlast.github.io/GameLifeCounter/) + +Two player table in the Pulse theme + +## Features + +- **2 to 6 players.** Picking the player count asks how the table is seated, + and the table behind the chooser updates as you pick: + - _Facing_ (default) puts players along the top and bottom edge facing each + other, the way the two player table works. Life totals and the -/+ buttons + read horizontally for everybody. + - _Sides_ turns the same table a quarter turn for players sitting along the + left and right edge. + - _Same side_ stacks every seat upright, for when nobody sits opposite. + + Each seat is rotated towards the edge its player sits at, so nobody reads + their life total upside down. + +- **Tap and hold.** A tap changes the life total by one step, holding a button + keeps applying the big step until you let go. Recent changes are summed up + and shown above the total ("-7") before fading away. +- **Game presets.** Standard (20), Commander (40 with poison), Energy (poison + and energy counters), Duel 8000 (steps of 50) and a neutral board-game + counter. The starting life total can be adjusted freely. +- **Two themes.** _Pulse_ keeps the original edge-to-edge colour blocks with + oversized numerals; _Arena_ puts floating player cards on a dark stage. +- **Player identity.** Each seat has its own name and colour. +- **Table tools.** Dice (coin, d4–d100), a match timer and a random + first-player picker, all reachable from the hub in the middle of the table. +- **Installable and offline.** The app is a PWA and keeps its state in local + storage, including the life totals and colours of the previous version. +- **Screen options.** Fullscreen and, where the browser supports it, a wake + lock that stops the screen from sleeping mid-game. + +## Screenshots + + + + + + + + + + +
+ Six player table with names, poison counters and the match timer + Six players, Commander preset. Named seats, poison counters + and the match timer running along the top. + + Four player table in the Arena theme + Arena theme. Floating player cards on a dark stage, here with + poison and energy counters. +
+ Table layout chooser with the table visible behind it + Layout chooser. Opens right after the player count; each + option is drawn for your table and the table behind updates as you pick. + + Four player table with the sides layout + Sides layout. The same table turned a quarter turn for + players sitting along the left and right edge. +
+ + + + + + + +
+ Settings sheet + Settings. Players, preset, starting life, theme, names and + colours. + + Dice sheet showing a d20 roll + Dice. Coin and d4 to d100, with the recent rolls kept. + + Random first player announcement + Random first player. Announced in that player's colour; the + crown stays on their seat. +
+ +## Requirements + +Only a webserver to deliver the static files; the app runs fully client side. + +## Project setup ```sh npm install ``` -### Compile and Hot-Reload for Development +### Compile and hot-reload for development ```sh npm run dev ``` -### Type-Check, Compile and Minify for Production +### Type-check, compile and minify for production ```sh npm run build ``` -### Run Unit Tests with [Vitest](https://vitest.dev/) +### Run the unit tests with [Vitest](https://vitest.dev/) ```sh -npm run test:unit +npm run test # single run +npm run test:unit # watch mode +npm run test:coverage # with coverage report ``` + +## Project structure + +| Path | Contents | +| ------------------------------ | --------------------------------------------------------------- | +| `src/game/` | Game state, presets, seat layouts and persistence | +| `src/theme/` | Theme catalogue and the binding to `` | +| `src/composables/` | Hold-to-repeat, pending delta, dice, timer, fullscreen/wakelock | +| `src/components/` | Vue components | +| `src/assets/styles/tokens.css` | Design tokens; a theme only overrides this block | + +Adding a theme means adding an entry to `src/theme/themes.ts` and a +`[data-theme='…']` token block — components read colours and sizes from +variables only. diff --git a/doc/App.png b/doc/App.png deleted file mode 100644 index cf4e84c..0000000 Binary files a/doc/App.png and /dev/null differ diff --git a/doc/App.webp b/doc/App.webp new file mode 100644 index 0000000..e181e00 Binary files /dev/null and b/doc/App.webp differ diff --git a/doc/dice.webp b/doc/dice.webp new file mode 100644 index 0000000..96b15dd Binary files /dev/null and b/doc/dice.webp differ diff --git a/doc/layout-chooser.webp b/doc/layout-chooser.webp new file mode 100644 index 0000000..492ce53 Binary files /dev/null and b/doc/layout-chooser.webp differ diff --git a/doc/layout-sides.webp b/doc/layout-sides.webp new file mode 100644 index 0000000..e320c28 Binary files /dev/null and b/doc/layout-sides.webp differ diff --git a/doc/players-six.webp b/doc/players-six.webp new file mode 100644 index 0000000..c7383e3 Binary files /dev/null and b/doc/players-six.webp differ diff --git a/doc/settings.webp b/doc/settings.webp new file mode 100644 index 0000000..d206405 Binary files /dev/null and b/doc/settings.webp differ diff --git a/doc/starter.webp b/doc/starter.webp new file mode 100644 index 0000000..583ad71 Binary files /dev/null and b/doc/starter.webp differ diff --git a/doc/theme-arena.webp b/doc/theme-arena.webp new file mode 100644 index 0000000..b85f8ff Binary files /dev/null and b/doc/theme-arena.webp differ diff --git a/env.d.ts b/env.d.ts index 11f02fe..04636e8 100644 --- a/env.d.ts +++ b/env.d.ts @@ -1 +1,2 @@ /// +/// diff --git a/index.html b/index.html index 6d3a8d9..2a63fad 100644 --- a/index.html +++ b/index.html @@ -1,16 +1,31 @@ - + - + + + + + + + - - + + - - + + Game Life Counter diff --git a/package-lock.json b/package-lock.json index 01eacc5..112d557 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,13 @@ { "name": "gamelifecounter", - "version": "0.0.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gamelifecounter", - "version": "0.0.0", + "version": "1.0.0", "dependencies": { - "@radial-color-picker/vue-color-picker": "^6.0.0", "vue": "^3.5.27" }, "devDependencies": { @@ -22,6 +21,7 @@ "jsdom": "^27.4.0", "npm-run-all": "^4.1.5", "postcss": "^8.5.3", + "prettier": "^3.4.2", "tailwindcss": "^4.1.10", "typescript": "^5.8.2", "vite": "^7.3.1", @@ -2434,24 +2434,6 @@ "node": ">=14" } }, - "node_modules/@radial-color-picker/rotator": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@radial-color-picker/rotator/-/rotator-3.0.2.tgz", - "integrity": "sha512-JiSVkN/hSMKqIcQz8KaeLvbzA0UrZkKQL2Q8s4MUxehjLcciTSctG75V8trHk0tPkTfglZjLlmg+S5+xO3GBLQ==", - "license": "MIT" - }, - "node_modules/@radial-color-picker/vue-color-picker": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@radial-color-picker/vue-color-picker/-/vue-color-picker-6.0.0.tgz", - "integrity": "sha512-mYITvZq7P37xrTfSjl0pDMSGlxo6nDawc8NdxGgHJhrvnPQsbLar0i/wJRK6A94NsDucJxHJdE5nbsajkVewwg==", - "license": "MIT", - "dependencies": { - "@radial-color-picker/rotator": "3.0.2" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -6831,6 +6813,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", diff --git a/package.json b/package.json index 5d9328c..c0640ef 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,20 @@ { "name": "gamelifecounter", - "version": "0.0.0", + "version": "1.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "run-p type-check build-only", - "preview": "vite preview", - "test:unit": "vitest --environment jsdom --root src/", "build-only": "vite build", - "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", - "test": "npm run build-only" + "preview": "vite preview", + "type-check": "vue-tsc --build", + "test": "vitest run", + "test:unit": "vitest", + "test:coverage": "vitest run --coverage", + "format": "prettier --write \"{src,.}/**/*.{ts,vue,css,json,html}\"" }, "dependencies": { - "@radial-color-picker/vue-color-picker": "^6.0.0", "vue": "^3.5.27" }, "devDependencies": { @@ -27,6 +28,7 @@ "jsdom": "^27.4.0", "npm-run-all": "^4.1.5", "postcss": "^8.5.3", + "prettier": "^3.4.2", "tailwindcss": "^4.1.10", "typescript": "^5.8.2", "vite": "^7.3.1", @@ -34,4 +36,4 @@ "vitest": "^4.0.16", "vue-tsc": "^2.2.10" } -} \ No newline at end of file +} diff --git a/src/App.vue b/src/App.vue index 442f0ca..0f8d695 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,115 +1,85 @@ - diff --git a/src/assets/base.css b/src/assets/base.css deleted file mode 100644 index 1f27637..0000000 --- a/src/assets/base.css +++ /dev/null @@ -1,62 +0,0 @@ -/* color palette from */ -:root { - --vt-c-white: #ffffff; - --vt-c-white-soft: #f8f8f8; - --vt-c-white-mute: #f2f2f2; - - --vt-c-black: #181818; - --vt-c-black-soft: #222222; - --vt-c-black-mute: #282828; - - --vt-c-indigo: #2c3e50; - - --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); - --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); - --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); - --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); - - --vt-c-text-light-1: var(--vt-c-indigo); - --vt-c-text-light-2: rgba(60, 60, 60, 0.66); - --vt-c-text-dark-1: var(--vt-c-white); - --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); -} - -/* semantic color variables for this project */ -:root { - --color-background: var(--vt-c-black); - --color-background-soft: var(--vt-c-black-soft); - --color-background-mute: var(--vt-c-black-mute); - - --color-border: var(--vt-c-divider-dark-2); - --color-border-hover: var(--vt-c-divider-dark-1); - - --color-heading: var(--vt-c-text-dark-1); - --color-text: var(--vt-c-text-dark-2); - - --section-gap: 160px; -} - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - position: relative; - font-weight: normal; -} - -body { - min-height: 100vh; - color: var(--color-text); - background: var(--color-background); - transition: color 0.5s, background-color 0.5s; - line-height: 1.6; - font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, - Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - font-size: 15px; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - user-select: none; - -webkit-user-select: none; -} diff --git a/src/assets/logo.svg b/src/assets/logo.svg deleted file mode 100644 index bc826fe..0000000 --- a/src/assets/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/main.css b/src/assets/main.css deleted file mode 100644 index 13480a1..0000000 --- a/src/assets/main.css +++ /dev/null @@ -1,13 +0,0 @@ -@import './base.css'; - -#app { - width: 100%; -} - -a, -.green { - text-decoration: none; - color: hsla(160, 100%, 37%, 1); - transition: 0.4s; -} - diff --git a/src/assets/styles/index.css b/src/assets/styles/index.css index 977c1f9..8a51938 100644 --- a/src/assets/styles/index.css +++ b/src/assets/styles/index.css @@ -1,20 +1,2 @@ @import 'tailwindcss'; - -/* - The default border color has changed to `currentcolor` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. -*/ -@layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } -} - +@import './tokens.css'; diff --git a/src/assets/styles/tokens.css b/src/assets/styles/tokens.css new file mode 100644 index 0000000..402c1ec --- /dev/null +++ b/src/assets/styles/tokens.css @@ -0,0 +1,200 @@ +/* + Design tokens for GameLifeCounter. + + Everything the UI paints comes from these variables. A theme only has to + redefine the token block; components never hard-code colours. The per-player + hue is injected as `--player-hue` on the panel element. + + IMPORTANT: a token whose value reads `--player-hue` must be declared on the + panel itself (see the `[data-theme] .panel` blocks below). `var()` inside a + custom property is substituted in the context of the element that *declares* + it, so the same token declared on `:root` would always resolve the hue to its + fallback and paint every player the same colour. +*/ + +:root { + color-scheme: dark; + + --font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-display: var(--font-ui); + + --radius-lg: 26px; + --radius-md: 16px; + --radius-sm: 10px; + + --ease: cubic-bezier(0.22, 0.9, 0.24, 1); + --dur-fast: 130ms; + --dur: 220ms; + + --safe-top: env(safe-area-inset-top, 0px); + --safe-right: env(safe-area-inset-right, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-left: env(safe-area-inset-left, 0px); + + /* Chrome = menus, sheets and everything floating above the stage. */ + --chrome-bg: rgba(17, 18, 25, 0.94); + --chrome-raised: rgba(255, 255, 255, 0.07); + --chrome-fg: #f4f6fb; + --chrome-muted: rgba(244, 246, 251, 0.6); + --chrome-border: rgba(255, 255, 255, 0.14); + --chrome-shadow: 0 24px 70px rgba(0, 0, 0, 0.6); + --accent: #7cf5d5; + --accent-contrast: #06231c; + --danger: #ff7a7a; + + /* Stage + panel defaults, overridden per theme. */ + --stage-bg: #0b0d13; + --stage-pad: 0px; + --stage-gap: 0px; + --panel-radius: 0px; + --panel-fg: #ffffff; + --panel-border: none; + --panel-shadow: none; + --panel-pad: 0px; + --life-weight: 800; + --life-letter-spacing: -0.04em; + --life-shadow: none; + --zone-size: 100%; + --zone-radius: 0; + --zone-bg: transparent; + --zone-border: none; + --zone-opacity: 0.55; + --name-opacity: 0.72; +} + +/* ── Pulse ─────────────────────────────────────────────────────────────── + Successor to the original look: saturated colour from edge to edge and + numerals as big as the seat allows. */ +[data-theme='pulse'] { + --stage-bg: #08080c; + --stage-pad: 0px; + --stage-gap: 2px; + --panel-radius: 0px; + --panel-pad: 0px; + --panel-fg: #ffffff; + --life-weight: 800; + --zone-opacity: 0.5; + --name-opacity: 0.68; +} + +[data-theme='pulse'] .panel { + --panel-bg: linear-gradient( + 155deg, + hsl(var(--player-hue, 0) 96% 54%) 0%, + hsl(var(--player-hue, 0) 90% 40%) 55%, + hsl(var(--player-hue, 0) 92% 31%) 100% + ); + --panel-shadow: inset 0 0 120px hsl(var(--player-hue, 0) 100% 22% / 0.55); + --life-shadow: 0 6px 34px hsl(var(--player-hue, 0) 100% 18% / 0.65); + --chip-bg: rgba(0, 0, 0, 0.22); + --chip-border: rgba(255, 255, 255, 0.28); +} + +/* ── Arena ─────────────────────────────────────────────────────────────── + New direction: dark stage, each seat is a floating card with explicit + controls and a light, wide life readout. */ +[data-theme='arena'] { + --stage-bg: radial-gradient(130% 120% at 50% 0%, #161c2b 0%, #0a0c12 55%, #06070b 100%); + --stage-pad: 10px; + --stage-gap: 10px; + --panel-radius: var(--radius-lg); + --panel-pad: clamp(8px, 2.5%, 22px); + --panel-fg: #eef2ff; + --life-weight: 300; + --life-letter-spacing: -0.05em; + --zone-size: min(26cqw, 30cqh); + --zone-radius: 999px; + --zone-opacity: 0.9; + --name-opacity: 0.85; +} + +[data-theme='arena'] .panel { + --panel-bg: + linear-gradient( + 160deg, + hsl(var(--player-hue, 0) 62% 24% / 0.92) 0%, + hsl(var(--player-hue, 0) 45% 11% / 0.94) 70% + ), + #0d0f16; + --panel-border: 1px solid hsl(var(--player-hue, 0) 75% 62% / 0.34); + --panel-shadow: + 0 18px 44px rgba(0, 0, 0, 0.5), inset 0 1px 0 hsl(var(--player-hue, 0) 90% 80% / 0.18); + --life-shadow: 0 0 46px hsl(var(--player-hue, 0) 95% 55% / 0.28); + --zone-bg: hsl(var(--player-hue, 0) 70% 70% / 0.12); + --zone-border: 1px solid hsl(var(--player-hue, 0) 80% 70% / 0.3); + --chip-bg: hsl(var(--player-hue, 0) 60% 70% / 0.12); + --chip-border: hsl(var(--player-hue, 0) 75% 70% / 0.32); +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body, +#app { + width: 100%; + height: 100%; + /* Mobile browsers: follow the viewport as the address bar collapses. */ + height: 100dvh; +} + +body { + background: var(--stage-bg); + color: var(--chrome-fg); + font-family: var(--font-ui); + /* 16px keeps iOS from zooming when an input in the settings sheet is focused. */ + font-size: 16px; + line-height: 1.4; + overflow: hidden; + overscroll-behavior: none; + touch-action: manipulation; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +/* + Grows a small control's touch target to roughly 44px without changing how it + looks — for the icon buttons that cannot afford to get visually bigger. +*/ +.touch-target { + position: relative; +} + +.touch-target::after { + content: ''; + position: absolute; + inset: -10px; +} + +button { + font: inherit; + color: inherit; + background: none; + border: 0; + cursor: pointer; + touch-action: manipulation; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/src/components/AppIcon.vue b/src/components/AppIcon.vue new file mode 100644 index 0000000..01cfa1e --- /dev/null +++ b/src/components/AppIcon.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/src/components/ColorView.vue b/src/components/ColorView.vue deleted file mode 100644 index 7375b9c..0000000 --- a/src/components/ColorView.vue +++ /dev/null @@ -1,79 +0,0 @@ -import RePaletteLineVue from './icons/RePaletteLine.vue'; - - - - - diff --git a/src/components/ControlHub.vue b/src/components/ControlHub.vue new file mode 100644 index 0000000..0412e1f --- /dev/null +++ b/src/components/ControlHub.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/components/DiceSheet.vue b/src/components/DiceSheet.vue new file mode 100644 index 0000000..89835f5 --- /dev/null +++ b/src/components/DiceSheet.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/src/components/GameStage.vue b/src/components/GameStage.vue new file mode 100644 index 0000000..c4d584f --- /dev/null +++ b/src/components/GameStage.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/components/LayoutPreview.vue b/src/components/LayoutPreview.vue new file mode 100644 index 0000000..68c4456 --- /dev/null +++ b/src/components/LayoutPreview.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/src/components/MatchTimerBar.vue b/src/components/MatchTimerBar.vue new file mode 100644 index 0000000..e3ecad5 --- /dev/null +++ b/src/components/MatchTimerBar.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/components/OverlaySheet.vue b/src/components/OverlaySheet.vue new file mode 100644 index 0000000..1eaa792 --- /dev/null +++ b/src/components/OverlaySheet.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/src/components/PlayerPanel.vue b/src/components/PlayerPanel.vue new file mode 100644 index 0000000..2097287 --- /dev/null +++ b/src/components/PlayerPanel.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/src/components/PlayerSeat.vue b/src/components/PlayerSeat.vue new file mode 100644 index 0000000..73e876a --- /dev/null +++ b/src/components/PlayerSeat.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/src/components/PlayerView.vue b/src/components/PlayerView.vue deleted file mode 100644 index 1fff946..0000000 --- a/src/components/PlayerView.vue +++ /dev/null @@ -1,143 +0,0 @@ - - - - diff --git a/src/components/ReloadPrompt.vue b/src/components/ReloadPrompt.vue index d6bc86e..ff31592 100644 --- a/src/components/ReloadPrompt.vue +++ b/src/components/ReloadPrompt.vue @@ -3,47 +3,65 @@ import { useRegisterSW } from 'virtual:pwa-register/vue' const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW() -const close = async () => { +const close = () => { offlineReady.value = false needRefresh.value = false } - diff --git a/src/components/SettingView.vue b/src/components/SettingView.vue deleted file mode 100644 index 91811e7..0000000 --- a/src/components/SettingView.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - - - diff --git a/src/components/SettingsSheet.vue b/src/components/SettingsSheet.vue new file mode 100644 index 0000000..99c9722 --- /dev/null +++ b/src/components/SettingsSheet.vue @@ -0,0 +1,516 @@ + + + + + diff --git a/src/components/StarterOverlay.vue b/src/components/StarterOverlay.vue new file mode 100644 index 0000000..bc8899d --- /dev/null +++ b/src/components/StarterOverlay.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/src/components/__tests__/App.spec.ts b/src/components/__tests__/App.spec.ts new file mode 100644 index 0000000..0576a55 --- /dev/null +++ b/src/components/__tests__/App.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import App from '../../App.vue' +import PlayerPanel from '../PlayerPanel.vue' +import SettingsSheet from '../SettingsSheet.vue' +import DiceSheet from '../DiceSheet.vue' +import StarterOverlay from '../StarterOverlay.vue' +import { useGame } from '../../game/store' + +const openHub = async (wrapper: ReturnType) => { + await wrapper.find('.hub__toggle').trigger('click') +} + +describe('App', () => { + it('shows the table and applies the stored theme', () => { + const wrapper = mount(App) + + expect(wrapper.findAllComponents(PlayerPanel)).toHaveLength(2) + expect(document.documentElement.dataset.theme).toBe('pulse') + }) + + it('opens the settings sheet from the hub', async () => { + const wrapper = mount(App) + + await openHub(wrapper) + await wrapper.find('[aria-label="Settings"]').trigger('click') + + expect(wrapper.findComponent(SettingsSheet).exists()).toBe(true) + + await wrapper.findComponent(SettingsSheet).vm.$emit('close') + expect(wrapper.findComponent(SettingsSheet).exists()).toBe(false) + }) + + it('opens the dice sheet from the hub', async () => { + const wrapper = mount(App) + + await openHub(wrapper) + await wrapper.find('[aria-label="Roll dice"]').trigger('click') + + expect(wrapper.findComponent(DiceSheet).exists()).toBe(true) + }) + + it('toggles the match timer without losing its state', async () => { + // Attached to the document so visibility of the v-show'd bar is real. + const wrapper = mount(App, { attachTo: document.body }) + const timer = () => wrapper.find('[role="timer"]') + + expect(timer().isVisible()).toBe(false) + + await openHub(wrapper) + await wrapper.find('[aria-label="Match timer"]').trigger('click') + expect(timer().isVisible()).toBe(true) + + await openHub(wrapper) + await wrapper.find('[aria-label="Match timer"]').trigger('click') + expect(timer().isVisible()).toBe(false) + + wrapper.unmount() + }) + + it('starts a new game from the hub', async () => { + const wrapper = mount(App) + const game = useGame() + game.adjustLife(0, -13) + + await openHub(wrapper) + await wrapper.find('[aria-label="New game"]').trigger('click') + + expect(game.playerAt(0)?.life).toBe(20) + expect(wrapper.findComponent(PlayerPanel).props('isFirstPlayer')).toBe(false) + }) + + it('picks and announces a random first player', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.99) + const wrapper = mount(App) + + await openHub(wrapper) + await wrapper.find('[aria-label="Random first player"]').trigger('click') + + const overlay = wrapper.findComponent(StarterOverlay) + expect(overlay.exists()).toBe(true) + expect(overlay.props('player').id).toBe(1) + expect(wrapper.findAllComponents(PlayerPanel)[1].props('isFirstPlayer')).toBe(true) + + await overlay.find('.starter').trigger('click') + expect(wrapper.findComponent(StarterOverlay).exists()).toBe(false) + // The crown stays on the table after the announcement is dismissed. + expect(wrapper.findAllComponents(PlayerPanel)[1].props('isFirstPlayer')).toBe(true) + }) + + it('keeps life totals across a reload', async () => { + const first = mount(App) + await first.findAllComponents(PlayerPanel)[0].vm.$emit('life', -5) + first.unmount() + + // A reload re-reads local storage; the shared store is rebuilt from it. + const { resetGameStore } = await import('../../game/store') + resetGameStore() + const second = mount(App) + + expect(second.findAllComponents(PlayerPanel)[0].props('player').life).toBe(15) + }) +}) diff --git a/src/components/__tests__/ControlHub.spec.ts b/src/components/__tests__/ControlHub.spec.ts new file mode 100644 index 0000000..d06ff42 --- /dev/null +++ b/src/components/__tests__/ControlHub.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import ControlHub from '../ControlHub.vue' + +describe('ControlHub', () => { + it('starts collapsed with the actions out of the tab order', () => { + const wrapper = mount(ControlHub) + + expect(wrapper.find('.hub').attributes('data-open')).toBe('false') + expect(wrapper.find('.hub__toggle').attributes('aria-expanded')).toBe('false') + expect( + wrapper.findAll('.hub__action').every((action) => action.attributes('tabindex') === '-1'), + ).toBe(true) + }) + + it('makes the closed ring inert rather than aria-hidden', async () => { + // aria-hidden on the ring is invalid: it keeps focus after an action is + // picked, and hiding a focused element from assistive tech is an error. + // The binding resolves to `undefined` when open so the attribute is really + // removed: a literal `inert="false"` would still make the ring inert. + const wrapper = mount(ControlHub) + const inert = () => wrapper.find('.hub__ring').attributes('inert') + + expect(inert()).toBeDefined() + expect(wrapper.find('.hub__ring').attributes('aria-hidden')).toBeUndefined() + + await wrapper.find('.hub__toggle').trigger('click') + + expect(inert()).toBeUndefined() + }) + + it('opens and closes the ring', async () => { + const wrapper = mount(ControlHub) + + await wrapper.find('.hub__toggle').trigger('click') + expect(wrapper.find('.hub').attributes('data-open')).toBe('true') + + await wrapper.find('.hub__backdrop').trigger('click') + expect(wrapper.find('.hub').attributes('data-open')).toBe('false') + }) + + it('offers every table action', () => { + const wrapper = mount(ControlHub) + const labels = wrapper.findAll('.hub__action').map((action) => action.attributes('aria-label')) + + expect(labels).toEqual([ + 'Settings', + 'Roll dice', + 'Match timer', + 'Random first player', + 'New game', + ]) + }) + + it('spreads the actions evenly around the centre', () => { + const wrapper = mount(ControlHub) + const angles = wrapper.findAll('.hub__action').map((action) => action.attributes('style') ?? '') + + expect(angles[0]).toContain('--angle: -90deg') + expect(angles[1]).toContain('--angle: -18deg') + }) + + it('emits the chosen action and collapses again', async () => { + const wrapper = mount(ControlHub) + await wrapper.find('.hub__toggle').trigger('click') + + await wrapper.find('[aria-label="Roll dice"]').trigger('click') + + expect(wrapper.emitted('action')).toEqual([['dice']]) + expect(wrapper.find('.hub').attributes('data-open')).toBe('false') + }) +}) diff --git a/src/components/__tests__/DiceSheet.spec.ts b/src/components/__tests__/DiceSheet.spec.ts new file mode 100644 index 0000000..c0baa0b --- /dev/null +++ b/src/components/__tests__/DiceSheet.spec.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import DiceSheet from '../DiceSheet.vue' + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('DiceSheet', () => { + it('offers a coin plus the usual dice', () => { + const wrapper = mount(DiceSheet) + + expect(wrapper.findAll('.die').map((die) => die.text())).toEqual([ + 'Coin', + 'd4', + 'd6', + 'd8', + 'd10', + 'd12', + 'd20', + 'd100', + ]) + }) + + it('waits for the first roll before showing a result', () => { + const wrapper = mount(DiceSheet) + + expect(wrapper.find('.result__value').text()).toBe('—') + expect(wrapper.find('.result__hint').text()).toBe('Pick a die') + }) + + it('shows the rolled value and which die produced it', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5) + const wrapper = mount(DiceSheet) + + await wrapper.findAll('.die')[6].trigger('click') + + expect(wrapper.find('.result__value').text()).toBe('11') + expect(wrapper.find('.result__hint').text()).toBe('d20 · 1–20') + }) + + it('reads a two sided die as a coin flip', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.1) + const wrapper = mount(DiceSheet) + + await wrapper.findAll('.die')[0].trigger('click') + + expect(wrapper.find('.result__value').text()).toBe('Heads') + }) + + it('keeps earlier rolls out of the way until there are some', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5) + const wrapper = mount(DiceSheet) + + await wrapper.findAll('.die')[2].trigger('click') + expect(wrapper.find('.history').exists()).toBe(false) + + await wrapper.findAll('.die')[2].trigger('click') + expect(wrapper.findAll('.history__item')).toHaveLength(1) + expect(wrapper.find('.history__item').text()).toBe('d6: 4') + }) + + it('closes from the sheet header', async () => { + const wrapper = mount(DiceSheet) + + await wrapper.find('.sheet__close').trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/src/components/__tests__/GameStage.spec.ts b/src/components/__tests__/GameStage.spec.ts new file mode 100644 index 0000000..18cc9a7 --- /dev/null +++ b/src/components/__tests__/GameStage.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import GameStage from '../GameStage.vue' +import PlayerPanel from '../PlayerPanel.vue' +import { createGameStore } from '../../game/store' +import { GAME_STORE_KEY } from '../../game/inject' + +const mountStage = (store = createGameStore(), props: Record = {}) => ({ + store, + wrapper: mount(GameStage, { + props, + global: { provide: { [GAME_STORE_KEY as symbol]: store } }, + }), +}) + +describe('GameStage', () => { + it('seats every active player and rotates them towards their edge', async () => { + const { store, wrapper } = mountStage() + + expect(wrapper.findAllComponents(PlayerPanel)).toHaveLength(2) + expect(wrapper.findAll('.seat').map((seat) => seat.attributes('data-rotation'))).toEqual([ + '180', + '0', + ]) + + store.setPlayerCount(6) + await nextTick() + + expect(wrapper.findAllComponents(PlayerPanel)).toHaveLength(6) + expect(wrapper.findAll('.seat').map((seat) => seat.attributes('data-rotation'))).toEqual([ + '180', + '180', + '180', + '0', + '0', + '0', + ]) + }) + + it('lays the grid out from the seat layout', () => { + const store = createGameStore({ playerCount: 3 }) + const { wrapper } = mountStage(store) + const style = wrapper.find('.stage').attributes('style') ?? '' + + expect(style).toContain('grid-template-columns: repeat(2, 1fr)') + expect(style).toContain('grid-template-rows: repeat(2, 1fr)') + }) + + it('routes life changes to the right player', async () => { + const { store, wrapper } = mountStage() + + await wrapper.findAllComponents(PlayerPanel)[1].vm.$emit('life', -3) + + expect(store.playerAt(0)?.life).toBe(20) + expect(store.playerAt(1)?.life).toBe(17) + }) + + it('routes counter changes to the right player', async () => { + const { store, wrapper } = mountStage() + + await wrapper.findAllComponents(PlayerPanel)[0].vm.$emit('counter', 'poison', 2) + + expect(store.playerAt(0)?.counters.poison).toBe(2) + }) + + it('hands the preset steps and counters to each panel', () => { + const store = createGameStore() + store.applyPreset('duel8000') + const { wrapper } = mountStage(store) + + expect(wrapper.findComponent(PlayerPanel).props()).toMatchObject({ + step: 50, + bigStep: 500, + counters: [], + }) + }) + + it('flags the randomly picked starting player', () => { + const { wrapper } = mountStage(createGameStore(), { firstPlayerId: 1 }) + const panels = wrapper.findAllComponents(PlayerPanel) + + expect(panels[0].props('isFirstPlayer')).toBe(false) + expect(panels[1].props('isFirstPlayer')).toBe(true) + }) +}) diff --git a/src/components/__tests__/MatchTimerBar.spec.ts b/src/components/__tests__/MatchTimerBar.spec.ts new file mode 100644 index 0000000..b52a445 --- /dev/null +++ b/src/components/__tests__/MatchTimerBar.spec.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import MatchTimerBar from '../MatchTimerBar.vue' + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('MatchTimerBar', () => { + it('starts at zero and paused', () => { + const wrapper = mount(MatchTimerBar) + + expect(wrapper.find('.timer__value').text()).toBe('00:00') + expect(wrapper.find('.timer__glyph').attributes('data-running')).toBe('false') + expect(wrapper.find('[aria-label="Start timer"]').exists()).toBe(true) + }) + + it('runs, pauses and resets', async () => { + const wrapper = mount(MatchTimerBar) + + await wrapper.find('[aria-label="Start timer"]').trigger('click') + vi.advanceTimersByTime(62_000) + await wrapper.vm.$nextTick() + + expect(wrapper.find('.timer__value').text()).toBe('01:02') + expect(wrapper.find('.timer__glyph').attributes('data-running')).toBe('true') + + await wrapper.find('[aria-label="Pause timer"]').trigger('click') + vi.advanceTimersByTime(10_000) + await wrapper.vm.$nextTick() + expect(wrapper.find('.timer__value').text()).toBe('01:02') + + await wrapper.find('[aria-label="Reset timer"]').trigger('click') + expect(wrapper.find('.timer__value').text()).toBe('00:00') + }) + + it('asks to be hidden', async () => { + const wrapper = mount(MatchTimerBar) + + await wrapper.find('[aria-label="Hide timer"]').trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/src/components/__tests__/OverlaySheet.spec.ts b/src/components/__tests__/OverlaySheet.spec.ts new file mode 100644 index 0000000..2ecb5b0 --- /dev/null +++ b/src/components/__tests__/OverlaySheet.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import OverlaySheet from '../OverlaySheet.vue' + +const mountSheet = () => + mount(OverlaySheet, { + props: { title: 'Dice', subtitle: 'Settle it with a roll' }, + slots: { default: '

body

' }, + attachTo: document.body, + }) + +describe('OverlaySheet', () => { + it('renders as a labelled modal with its content', () => { + const wrapper = mountSheet() + + expect(wrapper.find('[role="dialog"]').attributes('aria-label')).toBe('Dice') + expect(wrapper.find('.sheet__title').text()).toBe('Dice') + expect(wrapper.find('.sheet__subtitle').text()).toBe('Settle it with a roll') + expect(wrapper.find('.content').text()).toBe('body') + }) + + it('closes on the backdrop, the close button and Escape', async () => { + const backdrop = mountSheet() + await backdrop.find('.overlay__backdrop').trigger('click') + expect(backdrop.emitted('close')).toHaveLength(1) + + const button = mountSheet() + await button.find('.sheet__close').trigger('click') + expect(button.emitted('close')).toHaveLength(1) + + const escape = mountSheet() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(escape.emitted('close')).toHaveLength(1) + }) + + it('stops listening for Escape once unmounted', async () => { + const wrapper = mountSheet() + wrapper.unmount() + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + + expect(wrapper.emitted('close')).toBeUndefined() + }) + + it('leaves out the subtitle when there is none', () => { + const wrapper = mount(OverlaySheet, { props: { title: 'Settings' } }) + + expect(wrapper.find('.sheet__subtitle').exists()).toBe(false) + }) +}) diff --git a/src/components/__tests__/PlayerPanel.spec.ts b/src/components/__tests__/PlayerPanel.spec.ts new file mode 100644 index 0000000..367857b --- /dev/null +++ b/src/components/__tests__/PlayerPanel.spec.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick, reactive } from 'vue' +import PlayerPanel from '../PlayerPanel.vue' +import type { Player } from '../../game/types' + +const makePlayer = (overrides: Partial = {}): Player => + reactive({ + id: 0, + name: 'Ada', + hue: 200, + life: 20, + counters: { poison: 0, energy: 0 }, + ...overrides, + }) + +const mountPanel = (props: Record = {}) => + mount(PlayerPanel, { props: { player: makePlayer(), ...props } }) + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('PlayerPanel', () => { + it('shows the player name and life total', () => { + const wrapper = mountPanel() + + expect(wrapper.find('.name').text()).toContain('Ada') + expect(wrapper.find('.life').text()).toBe('20') + expect(wrapper.find('section').attributes('aria-label')).toBe('Ada: 20 life') + }) + + it('exposes the player hue and digit count to the theme', () => { + const wrapper = mountPanel({ player: makePlayer({ life: 8000, hue: 120 }) }) + const style = wrapper.find('section').attributes('style') ?? '' + + expect(style).toContain('--player-hue: 120') + expect(style).toContain('--digits: 4') + }) + + it('applies a single step on a tap', async () => { + const wrapper = mountPanel() + + await wrapper.find('.zone--minus').trigger('pointerdown') + await wrapper.find('.zone--minus').trigger('pointerup') + + expect(wrapper.emitted('life')).toEqual([[-1]]) + }) + + it('applies the big step repeatedly while held', async () => { + const wrapper = mountPanel({ step: 1, bigStep: 10 }) + + await wrapper.find('.zone--plus').trigger('pointerdown') + vi.advanceTimersByTime(500) + vi.advanceTimersByTime(360) + await wrapper.find('.zone--plus').trigger('pointerup') + + expect(wrapper.emitted('life')).toEqual([[10], [10], [10]]) + }) + + it('does not let a second finger on the same button add a step', async () => { + const wrapper = mountPanel() + const minus = wrapper.find('.zone--minus') + + await minus.trigger('pointerdown', { pointerId: 1 }) + await minus.trigger('pointerdown', { pointerId: 2 }) + await minus.trigger('pointerup', { pointerId: 2 }) + await minus.trigger('pointerup', { pointerId: 1 }) + + expect(wrapper.emitted('life')).toEqual([[-1]]) + }) + + it('keeps the two life buttons independent for simultaneous touches', async () => { + const wrapper = mountPanel() + + await wrapper.find('.zone--minus').trigger('pointerdown', { pointerId: 1 }) + await wrapper.find('.zone--plus').trigger('pointerdown', { pointerId: 2 }) + await wrapper.find('.zone--plus').trigger('pointerup', { pointerId: 2 }) + await wrapper.find('.zone--minus').trigger('pointerup', { pointerId: 1 }) + + expect(wrapper.emitted('life')).toEqual([[1], [-1]]) + }) + + it('drops a press that is cancelled by the browser', async () => { + const wrapper = mountPanel() + + await wrapper.find('.zone--minus').trigger('pointerdown') + await wrapper.find('.zone--minus').trigger('pointercancel') + vi.advanceTimersByTime(2000) + + expect(wrapper.emitted('life')).toBeUndefined() + }) + + it('supports the keyboard', async () => { + const wrapper = mountPanel({ step: 5 }) + + await wrapper.find('.zone--plus').trigger('keydown', { key: 'Enter' }) + + expect(wrapper.emitted('life')).toEqual([[5]]) + }) + + it('shows the running total of recent changes and fades it out', async () => { + const wrapper = mountPanel() + const minus = wrapper.find('.zone--minus') + + await minus.trigger('pointerdown') + await minus.trigger('pointerup') + await minus.trigger('pointerdown') + await minus.trigger('pointerup') + await nextTick() + + const delta = wrapper.find('.delta') + expect(delta.text()).toBe('-2') + expect(delta.classes()).toContain('delta--visible') + + vi.advanceTimersByTime(2500) + await nextTick() + + expect(wrapper.find('.delta').classes()).not.toContain('delta--visible') + }) + + it('hides the counters unless the preset uses them', () => { + expect(mountPanel().find('.chips').exists()).toBe(false) + expect( + mountPanel({ counters: ['poison'] }) + .find('.chips') + .exists(), + ).toBe(true) + }) + + it('emits counter changes in both directions', async () => { + const wrapper = mountPanel({ counters: ['poison', 'energy'] }) + const chips = wrapper.findAll('.chip') + + expect(chips).toHaveLength(2) + await chips[0].findAll('button')[1].trigger('click') + await chips[1].findAll('button')[0].trigger('click') + + expect(wrapper.emitted('counter')).toEqual([ + ['poison', 1], + ['energy', -1], + ]) + }) + + it('marks the randomly picked starting player', () => { + expect(mountPanel().find('.name__crown').exists()).toBe(false) + expect(mountPanel({ isFirstPlayer: true }).find('.name__crown').exists()).toBe(true) + expect(mountPanel({ isFirstPlayer: true }).find('section').attributes('data-first')).toBe( + 'true', + ) + }) + + it('clears a stale delta when a new game resets the life total', async () => { + const player = makePlayer({ life: 20 }) + const wrapper = mountPanel({ player }) + + await wrapper.find('.zone--minus').trigger('pointerdown') + await wrapper.find('.zone--minus').trigger('pointerup') + player.life = 8000 + await nextTick() + + expect(wrapper.find('.delta').classes()).not.toContain('delta--visible') + }) +}) diff --git a/src/components/__tests__/PlayerView.spec.ts b/src/components/__tests__/PlayerView.spec.ts deleted file mode 100644 index 38a6fd0..0000000 --- a/src/components/__tests__/PlayerView.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { mount } from '@vue/test-utils' -import { nextTick } from 'vue' - -import PlayerView from '../PlayerView.vue' - -test('moount Calc', () => { - expect(PlayerView).toBeTruthy() - - const wrapper = mount(PlayerView) - - expect(wrapper.text()).toContain('020') -}) diff --git a/src/components/__tests__/SettingView.spec.ts b/src/components/__tests__/SettingView.spec.ts deleted file mode 100644 index dbe5bd2..0000000 --- a/src/components/__tests__/SettingView.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { mount } from '@vue/test-utils' -import { nextTick } from 'vue' - -import SettingView from '../SettingView.vue' - -test('mount SettingView', () => { - expect(SettingView).toBeTruthy() - - const wrapper = mount(SettingView) - - expect(wrapper.text()).toContain('Fullscreen') -}) diff --git a/src/components/__tests__/SettingsSheet.spec.ts b/src/components/__tests__/SettingsSheet.spec.ts new file mode 100644 index 0000000..a86c06d --- /dev/null +++ b/src/components/__tests__/SettingsSheet.spec.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import SettingsSheet from '../SettingsSheet.vue' +import LayoutPreview from '../LayoutPreview.vue' +import { createGameStore } from '../../game/store' +import { GAME_STORE_KEY } from '../../game/inject' + +const mountSheet = (store = createGameStore()) => ({ + store, + wrapper: mount(SettingsSheet, { + global: { provide: { [GAME_STORE_KEY as symbol]: store } }, + }), +}) + +describe('SettingsSheet', () => { + it('offers every supported player count and marks the current one', () => { + const { wrapper } = mountSheet() + const buttons = wrapper.findAll('.segmented__item') + + expect(buttons.map((button) => button.text())).toEqual(['2', '3', '4', '5', '6']) + expect(buttons[0].attributes('aria-pressed')).toBe('true') + }) + + it('asks for the layout right after the player count is chosen', async () => { + const { store, wrapper } = mountSheet() + + await wrapper.findAll('.segmented__item')[2].trigger('click') + + expect(store.state.playerCount).toBe(4) + expect(wrapper.find('[role="dialog"]').attributes('aria-label')).toBe('Table layout') + // The table stays readable behind the layout step so it previews live. + expect(wrapper.find('.overlay').attributes('data-preview')).toBe('true') + }) + + it('applies a layout from the layout step and shows which one is active', async () => { + const { store, wrapper } = mountSheet() + await wrapper.findAll('.segmented__item')[4].trigger('click') + + const options = wrapper.findAll('.option') + expect(options.map((option) => option.find('.option__name').text())).toEqual([ + 'Facing', + 'Sides', + 'Same side', + ]) + expect(options[0].attributes('aria-pressed')).toBe('true') + + await options[1].trigger('click') + + expect(store.state.layoutId).toBe('sides') + expect(wrapper.findAll('.option')[1].attributes('aria-pressed')).toBe('true') + }) + + it('draws a seat diagram per layout option', async () => { + const { wrapper } = mountSheet() + await wrapper.findAll('.segmented__item')[0].trigger('click') + + const diagrams = wrapper.findAllComponents(LayoutPreview) + expect(diagrams).toHaveLength(3) + expect( + diagrams[0].findAll('.preview__seat').map((seat) => seat.attributes('data-edge')), + ).toEqual(['top', 'bottom']) + expect( + diagrams[1].findAll('.preview__seat').map((seat) => seat.attributes('data-edge')), + ).toEqual(['left', 'right']) + }) + + it('goes back to the settings page from the layout step', async () => { + const { wrapper } = mountSheet() + await wrapper.findAll('.segmented__item')[1].trigger('click') + + await wrapper.find('.sheet__close').trigger('click') + + expect(wrapper.find('[role="dialog"]').attributes('aria-label')).toBe('Settings') + expect(wrapper.emitted('close')).toBeUndefined() + }) + + it('closes everything from Done on the layout step', async () => { + const { wrapper } = mountSheet() + await wrapper.findAll('.segmented__item')[1].trigger('click') + + await wrapper.find('.action').trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('opens the layout step from the settings row without changing the count', async () => { + const { store, wrapper } = mountSheet() + + await wrapper.find('.layout-row').trigger('click') + + expect(store.state.playerCount).toBe(2) + expect(wrapper.find('[role="dialog"]').attributes('aria-label')).toBe('Table layout') + }) + + it('applies a preset and restarts the game', async () => { + const { store, wrapper } = mountSheet() + store.adjustLife(0, -8) + + const commander = wrapper.findAll('.option')[1] + await commander.trigger('click') + + expect(store.state.presetId).toBe('commander') + expect(store.state.startingLife).toBe(40) + expect(store.playerAt(0)?.life).toBe(40) + }) + + it('steps the starting life up and down', async () => { + const { store, wrapper } = mountSheet() + const steps = wrapper.findAll('.stepper__button') + + await steps[0].trigger('click') // -10 + await steps[3].trigger('click') // +10 + await steps[2].trigger('click') // +1 + + expect(store.state.startingLife).toBe(21) + expect(wrapper.find('.stepper__value').text()).toBe('21') + }) + + it('starts a new game and closes', async () => { + const { store, wrapper } = mountSheet() + store.adjustLife(1, -6) + + await wrapper.find('.action').trigger('click') + + expect(store.playerAt(1)?.life).toBe(20) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('switches the theme', async () => { + const { store, wrapper } = mountSheet() + + await wrapper.findAll('.theme')[1].trigger('click') + + expect(store.state.themeId).toBe('arena') + expect(wrapper.findAll('.theme')[1].attributes('aria-pressed')).toBe('true') + }) + + it('lists only the seated players for renaming and recolouring', async () => { + const store = createGameStore({ playerCount: 3 }) + const { wrapper } = mountSheet(store) + + expect(wrapper.findAll('.player')).toHaveLength(3) + + const nameInput = wrapper.findAll('.player__name')[1] + await nameInput.setValue('Grace') + await nameInput.trigger('change') + expect(store.playerAt(1)?.name).toBe('Grace') + + const hueInput = wrapper.findAll('.player__hue')[0] + await hueInput.setValue('300') + expect(store.playerAt(0)?.hue).toBe(300) + }) + + it('restores the default colours', async () => { + const { store, wrapper } = mountSheet() + store.setHue(0, 5) + + await wrapper.find('.link').trigger('click') + + expect(store.playerAt(0)?.hue).toBe(32) + }) + + it('hides display toggles the browser cannot do', () => { + const { wrapper } = mountSheet() + + expect(wrapper.findAll('.toggle')).toHaveLength(0) + }) + + it('closes when asked', async () => { + const { wrapper } = mountSheet() + + await wrapper.find('.sheet__close').trigger('click') + + await nextTick() + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/src/components/__tests__/StarterOverlay.spec.ts b/src/components/__tests__/StarterOverlay.spec.ts new file mode 100644 index 0000000..5cf3500 --- /dev/null +++ b/src/components/__tests__/StarterOverlay.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import StarterOverlay from '../StarterOverlay.vue' +import type { Player } from '../../game/types' + +const player: Player = { + id: 2, + name: 'Grace', + hue: 140, + life: 40, + counters: { poison: 0, energy: 0 }, +} + +describe('StarterOverlay', () => { + it('announces the chosen player in their colour', () => { + const wrapper = mount(StarterOverlay, { props: { player } }) + + expect(wrapper.find('.starter__name').text()).toBe('Grace') + expect(wrapper.find('[role="dialog"]').attributes('aria-label')).toBe('Grace starts') + expect(wrapper.find('.starter__card').attributes('style')).toContain('--player-hue: 140') + }) + + it('dismisses on tap', async () => { + const wrapper = mount(StarterOverlay, { props: { player } }) + + await wrapper.find('.starter').trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/src/components/icons/ReCheckboxBlankLine.vue b/src/components/icons/ReCheckboxBlankLine.vue deleted file mode 100644 index 4c4eaa6..0000000 --- a/src/components/icons/ReCheckboxBlankLine.vue +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/src/components/icons/ReCheckboxLine.vue b/src/components/icons/ReCheckboxLine.vue deleted file mode 100644 index 35bc168..0000000 --- a/src/components/icons/ReCheckboxLine.vue +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/src/components/icons/RePaletteLine.vue b/src/components/icons/RePaletteLine.vue deleted file mode 100644 index 4efb983..0000000 --- a/src/components/icons/RePaletteLine.vue +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/src/components/icons/ReSettings5Line.vue b/src/components/icons/ReSettings5Line.vue deleted file mode 100644 index 2ff8bee..0000000 --- a/src/components/icons/ReSettings5Line.vue +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/src/components/icons/ThMoreAlt.vue b/src/components/icons/ThMoreAlt.vue deleted file mode 100644 index 134182d..0000000 --- a/src/components/icons/ThMoreAlt.vue +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/src/components/icons/TiRefresh.vue b/src/components/icons/TiRefresh.vue deleted file mode 100644 index c31990a..0000000 --- a/src/components/icons/TiRefresh.vue +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/src/composables/__tests__/useDice.spec.ts b/src/composables/__tests__/useDice.spec.ts new file mode 100644 index 0000000..bd8138f --- /dev/null +++ b/src/composables/__tests__/useDice.spec.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope } from 'vue' +import { DICE_SIDES, rollDie, useDice } from '../useDice' + +describe('rollDie', () => { + it('covers the whole range of the die', () => { + expect(rollDie(6, () => 0)).toBe(1) + expect(rollDie(6, () => 0.999)).toBe(6) + expect(rollDie(20, () => 0.5)).toBe(11) + }) + + it('never exceeds the die even for a random() of 1', () => { + for (const sides of DICE_SIDES) { + expect(rollDie(sides, () => 1)).toBe(sides) + } + }) + + it('stays sane for nonsense input', () => { + expect(rollDie(1, () => 0.99)).toBe(2) + expect(rollDie(6.7, () => 0)).toBe(1) + }) + + it('produces every face over many rolls', () => { + const seen = new Set() + for (let i = 0; i < 400; i += 1) seen.add(rollDie(6)) + expect([...seen].sort()).toEqual([1, 2, 3, 4, 5, 6]) + }) +}) + +describe('useDice', () => { + let scope: ReturnType + + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + scope?.stop() + vi.useRealTimers() + }) + + const setup = (random: () => number) => { + scope = effectScope() + return scope.run(() => useDice(random))! + } + + it('records the roll and keeps a short history', () => { + const dice = setup(() => 0.5) + + const result = dice.roll(20) + + expect(result).toEqual({ sides: 20, value: 11 }) + expect(dice.last.value).toEqual(result) + expect(dice.history.value[0]).toEqual(result) + }) + + it('caps the history at eight entries, newest first', () => { + const dice = setup(() => 0) + + for (let i = 0; i < 12; i += 1) dice.roll(6) + dice.roll(20) + + expect(dice.history.value).toHaveLength(8) + expect(dice.history.value[0].sides).toBe(20) + }) + + it('flags a roll as animating and settles again', () => { + const dice = setup(() => 0.5) + + dice.roll(6) + expect(dice.rolling.value).toBe(true) + + vi.advanceTimersByTime(320) + expect(dice.rolling.value).toBe(false) + }) + + it('clears the results', () => { + const dice = setup(() => 0.5) + dice.roll(6) + + dice.clear() + + expect(dice.last.value).toBeNull() + expect(dice.history.value).toEqual([]) + }) +}) diff --git a/src/composables/__tests__/useHoldRepeat.spec.ts b/src/composables/__tests__/useHoldRepeat.spec.ts new file mode 100644 index 0000000..4a62fc2 --- /dev/null +++ b/src/composables/__tests__/useHoldRepeat.spec.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope } from 'vue' +import { useHoldRepeat } from '../useHoldRepeat' + +const setup = () => { + const onTap = vi.fn() + const onHold = vi.fn() + const scope = effectScope() + const hold = scope.run(() => + useHoldRepeat({ onTap, onHold, holdDelay: 500, repeatInterval: 100 }), + )! + return { onTap, onHold, hold, scope } +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('useHoldRepeat', () => { + it('applies a single tap on a short press', () => { + const { hold, onTap, onHold } = setup() + + hold.start() + vi.advanceTimersByTime(200) + hold.stop() + + expect(onTap).toHaveBeenCalledTimes(1) + expect(onHold).not.toHaveBeenCalled() + }) + + it('repeats while held and skips the tap on release', () => { + const { hold, onTap, onHold } = setup() + + hold.start() + vi.advanceTimersByTime(500) + expect(onHold).toHaveBeenCalledTimes(1) + expect(hold.isHolding.value).toBe(true) + + vi.advanceTimersByTime(300) + expect(onHold).toHaveBeenCalledTimes(4) + + hold.stop() + vi.advanceTimersByTime(1000) + + expect(onHold).toHaveBeenCalledTimes(4) + expect(onTap).not.toHaveBeenCalled() + expect(hold.isHolding.value).toBe(false) + }) + + it('cancelling a press applies nothing', () => { + const { hold, onTap, onHold } = setup() + + hold.start() + vi.advanceTimersByTime(550) + hold.cancel() + vi.advanceTimersByTime(1000) + + expect(onHold).toHaveBeenCalledTimes(1) + expect(onTap).not.toHaveBeenCalled() + }) + + it('ignores a second start while a press is active', () => { + const { hold, onTap } = setup() + + hold.start() + hold.start() + hold.stop() + + expect(onTap).toHaveBeenCalledTimes(1) + }) + + it('lets only the finger that started the press end it', () => { + const { hold, onTap, onHold } = setup() + + hold.start(1) + vi.advanceTimersByTime(500) + expect(onHold).toHaveBeenCalledTimes(1) + + // A second finger lands on the same button and lifts again. + hold.start(2) + hold.stop(2) + vi.advanceTimersByTime(200) + expect(onHold).toHaveBeenCalledTimes(3) + expect(onTap).not.toHaveBeenCalled() + + hold.stop(1) + vi.advanceTimersByTime(500) + expect(onHold).toHaveBeenCalledTimes(3) + }) + + it('ignores a cancel that belongs to another finger', () => { + const { hold, onHold } = setup() + + hold.start(1) + hold.cancel(2) + vi.advanceTimersByTime(500) + + expect(onHold).toHaveBeenCalledTimes(1) + }) + + it('a release without a press does nothing', () => { + const { hold, onTap } = setup() + + hold.stop() + + expect(onTap).not.toHaveBeenCalled() + }) + + it('stops repeating when the scope is disposed', () => { + const { hold, onHold, scope } = setup() + + hold.start() + vi.advanceTimersByTime(500) + scope.stop() + vi.advanceTimersByTime(1000) + + expect(onHold).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/composables/__tests__/useMatchTimer.spec.ts b/src/composables/__tests__/useMatchTimer.spec.ts new file mode 100644 index 0000000..61f8aa5 --- /dev/null +++ b/src/composables/__tests__/useMatchTimer.spec.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope } from 'vue' +import { formatDuration, useMatchTimer } from '../useMatchTimer' + +describe('formatDuration', () => { + it('formats minutes and seconds, adding hours only when needed', () => { + expect(formatDuration(0)).toBe('00:00') + expect(formatDuration(9_000)).toBe('00:09') + expect(formatDuration(61_000)).toBe('01:01') + expect(formatDuration(3_600_000)).toBe('1:00:00') + expect(formatDuration(-500)).toBe('00:00') + }) +}) + +describe('useMatchTimer', () => { + let clock = 1000 + let scope: ReturnType + let timer: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + clock = 1000 + scope = effectScope() + timer = scope.run(() => useMatchTimer(() => clock))! + }) + + afterEach(() => { + scope.stop() + vi.useRealTimers() + }) + + it('counts wall-clock time while running', () => { + timer.start() + clock += 5000 + vi.advanceTimersByTime(1000) + + expect(timer.running.value).toBe(true) + expect(timer.elapsed.value).toBe(5000) + expect(timer.label.value).toBe('00:05') + }) + + it('freezes on pause and continues from there', () => { + timer.start() + clock += 5000 + vi.advanceTimersByTime(1000) + timer.pause() + + clock += 10_000 + vi.advanceTimersByTime(1000) + expect(timer.elapsed.value).toBe(5000) + + timer.start() + clock += 2000 + vi.advanceTimersByTime(500) + expect(timer.elapsed.value).toBe(7000) + }) + + it('toggles and resets', () => { + timer.toggle() + expect(timer.running.value).toBe(true) + + timer.toggle() + expect(timer.running.value).toBe(false) + + clock += 3000 + timer.start() + clock += 4000 + vi.advanceTimersByTime(500) + timer.reset() + + expect(timer.elapsed.value).toBe(0) + expect(timer.running.value).toBe(false) + expect(timer.label.value).toBe('00:00') + }) + + it('stops ticking once the scope is gone', () => { + timer.start() + scope.stop() + + clock += 9000 + vi.advanceTimersByTime(2000) + + expect(timer.elapsed.value).toBe(0) + }) +}) diff --git a/src/composables/__tests__/usePendingDelta.spec.ts b/src/composables/__tests__/usePendingDelta.spec.ts new file mode 100644 index 0000000..da6f4f3 --- /dev/null +++ b/src/composables/__tests__/usePendingDelta.spec.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope } from 'vue' +import { usePendingDelta } from '../usePendingDelta' + +const setup = (timeout = 2000) => { + const scope = effectScope() + return { pending: scope.run(() => usePendingDelta(timeout))!, scope } +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('usePendingDelta', () => { + it('accumulates changes and formats them with a sign', () => { + const { pending } = setup() + + pending.add(-1) + pending.add(-6) + + expect(pending.visible.value).toBe(true) + expect(pending.value.value).toBe(-7) + expect(pending.label.value).toBe('-7') + + pending.add(10) + expect(pending.label.value).toBe('+3') + }) + + it('hides and restarts after the timeout', () => { + const { pending } = setup() + + pending.add(-4) + vi.advanceTimersByTime(1999) + expect(pending.visible.value).toBe(true) + + vi.advanceTimersByTime(1) + expect(pending.visible.value).toBe(false) + expect(pending.value.value).toBe(0) + + pending.add(-2) + expect(pending.value.value).toBe(-2) + }) + + it('extends the timeout while taps keep coming', () => { + const { pending } = setup() + + pending.add(1) + vi.advanceTimersByTime(1800) + pending.add(1) + vi.advanceTimersByTime(1800) + + expect(pending.visible.value).toBe(true) + expect(pending.value.value).toBe(2) + }) + + it('ignores zero and invalid deltas', () => { + const { pending } = setup() + + pending.add(0) + pending.add(Number.NaN) + + expect(pending.visible.value).toBe(false) + }) + + it('reset clears the readout immediately', () => { + const { pending } = setup() + + pending.add(5) + pending.reset() + + expect(pending.visible.value).toBe(false) + expect(pending.value.value).toBe(0) + }) +}) diff --git a/src/composables/__tests__/useScreen.spec.ts b/src/composables/__tests__/useScreen.spec.ts new file mode 100644 index 0000000..7dc5d61 --- /dev/null +++ b/src/composables/__tests__/useScreen.spec.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { useFullscreen, useWakeLock } from '../useScreen' + +const harness = (composable: () => T) => + mount( + defineComponent({ + setup: () => ({ api: composable() }), + template: '
', + }), + ) + +const stubFullscreenApi = () => { + const requestFullscreen = vi.fn(async () => { + Object.defineProperty(document, 'fullscreenElement', { + value: document.documentElement, + configurable: true, + }) + }) + const exitFullscreen = vi.fn(async () => { + Object.defineProperty(document, 'fullscreenElement', { value: null, configurable: true }) + }) + + Object.defineProperty(document.documentElement, 'requestFullscreen', { + value: requestFullscreen, + configurable: true, + }) + Object.defineProperty(document, 'exitFullscreen', { value: exitFullscreen, configurable: true }) + Object.defineProperty(document, 'fullscreenElement', { value: null, configurable: true }) + + return { requestFullscreen, exitFullscreen } +} + +afterEach(() => { + Reflect.deleteProperty(document.documentElement, 'requestFullscreen') + Reflect.deleteProperty(document, 'exitFullscreen') + Reflect.deleteProperty(navigator, 'wakeLock') +}) + +describe('useFullscreen', () => { + it('reports no support when the browser lacks the API', () => { + const wrapper = harness(() => useFullscreen()) + + expect(wrapper.vm.api.supported.value).toBe(false) + }) + + it('enters and leaves fullscreen', async () => { + const { requestFullscreen, exitFullscreen } = stubFullscreenApi() + const wrapper = harness(() => useFullscreen()) + + expect(wrapper.vm.api.supported.value).toBe(true) + + await wrapper.vm.api.toggle() + expect(requestFullscreen).toHaveBeenCalled() + expect(wrapper.vm.api.isFullscreen.value).toBe(true) + + await wrapper.vm.api.toggle() + expect(exitFullscreen).toHaveBeenCalled() + expect(wrapper.vm.api.isFullscreen.value).toBe(false) + }) + + it('follows fullscreen changes made outside the app', async () => { + stubFullscreenApi() + const wrapper = harness(() => useFullscreen()) + + Object.defineProperty(document, 'fullscreenElement', { + value: document.documentElement, + configurable: true, + }) + document.dispatchEvent(new Event('fullscreenchange')) + + expect(wrapper.vm.api.isFullscreen.value).toBe(true) + }) + + it('surfaces a rejected request instead of throwing', async () => { + stubFullscreenApi() + Object.defineProperty(document.documentElement, 'requestFullscreen', { + value: vi.fn(async () => { + throw new Error('denied') + }), + configurable: true, + }) + + const wrapper = harness(() => useFullscreen()) + await wrapper.vm.api.toggle() + + expect(wrapper.vm.api.error.value).toBe('denied') + expect(wrapper.vm.api.isFullscreen.value).toBe(false) + }) +}) + +describe('useWakeLock', () => { + const stubWakeLock = (request: () => Promise) => { + Object.defineProperty(navigator, 'wakeLock', { value: { request }, configurable: true }) + } + + const fakeSentinel = () => { + const listeners: (() => void)[] = [] + return { + release: vi.fn(async () => listeners.forEach((listener) => listener())), + addEventListener: (_type: string, listener: () => void) => listeners.push(listener), + } + } + + it('reports no support when the browser lacks the API', () => { + const wrapper = harness(() => useWakeLock()) + + expect(wrapper.vm.api.supported.value).toBe(false) + }) + + it('acquires and releases the lock', async () => { + const sentinel = fakeSentinel() + stubWakeLock(async () => sentinel) + const wrapper = harness(() => useWakeLock()) + + await wrapper.vm.api.toggle() + expect(wrapper.vm.api.active.value).toBe(true) + + await wrapper.vm.api.toggle() + expect(sentinel.release).toHaveBeenCalled() + expect(wrapper.vm.api.active.value).toBe(false) + }) + + it('goes inactive when the browser releases the lock itself', async () => { + const sentinel = fakeSentinel() + stubWakeLock(async () => sentinel) + const wrapper = harness(() => useWakeLock()) + + await wrapper.vm.api.toggle() + await sentinel.release() + + expect(wrapper.vm.api.active.value).toBe(false) + }) + + it('keeps the error message when the request is denied', async () => { + stubWakeLock(async () => { + const error = new Error('not allowed') + error.name = 'NotAllowedError' + throw error + }) + const wrapper = harness(() => useWakeLock()) + + await wrapper.vm.api.toggle() + + expect(wrapper.vm.api.active.value).toBe(false) + expect(wrapper.vm.api.error.value).toContain('NotAllowedError') + }) +}) diff --git a/src/composables/useDice.ts b/src/composables/useDice.ts new file mode 100644 index 0000000..d309441 --- /dev/null +++ b/src/composables/useDice.ts @@ -0,0 +1,42 @@ +import { ref } from 'vue' + +export const DICE_SIDES = [2, 4, 6, 8, 10, 12, 20, 100] as const +export type DiceSides = (typeof DICE_SIDES)[number] + +export interface DiceRoll { + sides: number + value: number +} + +export function rollDie(sides: number, random: () => number = Math.random): number { + const faces = Math.max(2, Math.floor(sides)) + return Math.min(faces, Math.floor(random() * faces) + 1) +} + +export function useDice(random: () => number = Math.random) { + const last = ref(null) + const history = ref([]) + const rolling = ref(false) + let settleId: ReturnType | null = null + + const roll = (sides: number): DiceRoll => { + const result: DiceRoll = { sides, value: rollDie(sides, random) } + last.value = result + history.value = [result, ...history.value].slice(0, 8) + + rolling.value = true + if (settleId !== null) clearTimeout(settleId) + settleId = setTimeout(() => { + rolling.value = false + }, 320) + + return result + } + + const clear = () => { + last.value = null + history.value = [] + } + + return { last, history, rolling, roll, clear } +} diff --git a/src/composables/useHoldRepeat.ts b/src/composables/useHoldRepeat.ts new file mode 100644 index 0000000..752369c --- /dev/null +++ b/src/composables/useHoldRepeat.ts @@ -0,0 +1,85 @@ +import { onScopeDispose, ref } from 'vue' + +export interface HoldRepeatOptions { + /** Fired on a short press. */ + onTap: () => void + /** Fired once the press turns into a hold, then repeatedly. */ + onHold: () => void + /** Delay before a press becomes a hold. */ + holdDelay?: number + /** Interval between repeats while held. */ + repeatInterval?: number +} + +/** + * Press-and-hold behaviour for the life buttons: a tap applies the small + * step, holding applies the big step over and over until release. + * + * A press belongs to the finger that started it: on a shared tablet a second + * finger landing on the same button must not end somebody else's hold, so + * `stop`/`cancel` only listen to the pointer that called `start`. + */ +export function useHoldRepeat(options: HoldRepeatOptions) { + const holdDelay = options.holdDelay ?? 500 + const repeatInterval = options.repeatInterval ?? 180 + + const isHolding = ref(false) + let timerId: ReturnType | null = null + let didHold = false + let active = false + let ownerId: number | null = null + + const clearTimer = () => { + if (timerId !== null) { + clearTimeout(timerId) + timerId = null + } + } + + const repeat = () => { + options.onHold() + timerId = setTimeout(repeat, repeatInterval) + } + + /** True when the event comes from the finger that owns the current press. */ + const owns = (pointerId?: number) => + ownerId === null || pointerId === undefined || pointerId === ownerId + + const start = (pointerId?: number) => { + if (active) return + active = true + ownerId = pointerId ?? null + didHold = false + clearTimer() + timerId = setTimeout(() => { + didHold = true + isHolding.value = true + repeat() + }, holdDelay) + } + + /** Release: applies a tap when the press never became a hold. */ + const stop = (pointerId?: number) => { + if (!active || !owns(pointerId)) return + active = false + ownerId = null + clearTimer() + if (!didHold) options.onTap() + didHold = false + isHolding.value = false + } + + /** Aborts the press without applying anything (pointer left / cancelled). */ + const cancel = (pointerId?: number) => { + if (!owns(pointerId)) return + active = false + ownerId = null + didHold = false + isHolding.value = false + clearTimer() + } + + onScopeDispose(cancel) + + return { start, stop, cancel, isHolding } +} diff --git a/src/composables/useMatchTimer.ts b/src/composables/useMatchTimer.ts new file mode 100644 index 0000000..8e71271 --- /dev/null +++ b/src/composables/useMatchTimer.ts @@ -0,0 +1,60 @@ +import { computed, onScopeDispose, ref } from 'vue' + +export function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + const pad = (value: number) => value.toString().padStart(2, '0') + return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${pad(minutes)}:${pad(seconds)}` +} + +/** Wall-clock based match timer, so it stays correct if the tab is throttled. */ +export function useMatchTimer(now: () => number = () => Date.now()) { + const elapsed = ref(0) + const running = ref(false) + let startedAt = 0 + let intervalId: ReturnType | null = null + + const stopTicking = () => { + if (intervalId !== null) { + clearInterval(intervalId) + intervalId = null + } + } + + const tick = () => { + if (running.value) elapsed.value = now() - startedAt + } + + const start = () => { + if (running.value) return + startedAt = now() - elapsed.value + running.value = true + stopTicking() + intervalId = setInterval(tick, 250) + tick() + } + + const pause = () => { + if (!running.value) return + tick() + running.value = false + stopTicking() + } + + const toggle = () => (running.value ? pause() : start()) + + const reset = () => { + stopTicking() + running.value = false + elapsed.value = 0 + startedAt = now() + } + + const label = computed(() => formatDuration(elapsed.value)) + + onScopeDispose(stopTicking) + + return { elapsed, running, label, start, pause, toggle, reset } +} diff --git a/src/composables/usePendingDelta.ts b/src/composables/usePendingDelta.ts new file mode 100644 index 0000000..4771a23 --- /dev/null +++ b/src/composables/usePendingDelta.ts @@ -0,0 +1,46 @@ +import { computed, onScopeDispose, ref } from 'vue' + +const formatter = new Intl.NumberFormat('en-US', { signDisplay: 'exceptZero' }) + +/** + * Accumulates the life changes of the last few seconds so a player can see + * "-7" while they tap, then it fades out. + */ +export function usePendingDelta(timeout = 2500) { + const value = ref(0) + const visible = ref(false) + let timerId: ReturnType | null = null + + const clear = () => { + if (timerId !== null) { + clearTimeout(timerId) + timerId = null + } + } + + const add = (delta: number) => { + if (!Number.isFinite(delta) || delta === 0) return + if (!visible.value) { + value.value = 0 + visible.value = true + } + value.value += delta + clear() + timerId = setTimeout(() => { + visible.value = false + value.value = 0 + }, timeout) + } + + const reset = () => { + clear() + visible.value = false + value.value = 0 + } + + const label = computed(() => formatter.format(value.value)) + + onScopeDispose(clear) + + return { value, visible, label, add, reset } +} diff --git a/src/composables/useScreen.ts b/src/composables/useScreen.ts new file mode 100644 index 0000000..5823d06 --- /dev/null +++ b/src/composables/useScreen.ts @@ -0,0 +1,101 @@ +import { onMounted, onScopeDispose, ref } from 'vue' + +/** Fullscreen toggle that stays in sync when the user leaves via Esc. */ +export function useFullscreen() { + const supported = ref( + typeof document !== 'undefined' && + typeof document.documentElement?.requestFullscreen === 'function', + ) + const isFullscreen = ref(false) + const error = ref(null) + + const sync = () => { + if (typeof document === 'undefined') return + isFullscreen.value = document.fullscreenElement !== null + } + + const toggle = async () => { + if (!supported.value) return + error.value = null + try { + if (document.fullscreenElement) await document.exitFullscreen() + else await document.documentElement.requestFullscreen() + } catch (e) { + error.value = e instanceof Error ? e.message : String(e) + } + sync() + } + + onMounted(() => { + sync() + document.addEventListener('fullscreenchange', sync) + }) + onScopeDispose(() => { + if (typeof document !== 'undefined') { + document.removeEventListener('fullscreenchange', sync) + } + }) + + return { supported, isFullscreen, error, toggle } +} + +type WakeLockNavigator = Navigator & { + wakeLock?: { request(type: 'screen'): Promise } +} + +/** Keeps the screen awake during a game, re-acquiring after tab switches. */ +export function useWakeLock() { + const nav = typeof navigator === 'undefined' ? undefined : (navigator as WakeLockNavigator) + const supported = ref(typeof nav?.wakeLock?.request === 'function') + const active = ref(false) + const error = ref(null) + let sentinel: WakeLockSentinel | null = null + + const request = async () => { + if (!supported.value || !nav?.wakeLock) return + try { + sentinel = await nav.wakeLock.request('screen') + active.value = true + sentinel.addEventListener('release', () => { + active.value = false + sentinel = null + }) + } catch (e) { + error.value = e instanceof Error ? `${e.name}: ${e.message}` : String(e) + active.value = false + } + } + + const release = async () => { + try { + await sentinel?.release() + } catch { + // Sentinel may already be gone; the state below is what matters. + } + sentinel = null + active.value = false + } + + const toggle = async () => { + error.value = null + if (active.value) await release() + else await request() + } + + // Browsers drop the lock when the tab is hidden; take it back on return. + const onVisibility = () => { + if (document.visibilityState === 'visible' && active.value && sentinel === null) { + void request() + } + } + + onMounted(() => document.addEventListener('visibilitychange', onVisibility)) + onScopeDispose(() => { + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onVisibility) + } + void release() + }) + + return { supported, active, error, toggle } +} diff --git a/src/game/__tests__/layouts.spec.ts b/src/game/__tests__/layouts.spec.ts new file mode 100644 index 0000000..51db869 --- /dev/null +++ b/src/game/__tests__/layouts.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { TABLE_LAYOUTS, clampPlayerCount, findLayout, isLayoutId, seatLayout } from '../layouts' +import { MAX_PLAYERS, MIN_PLAYERS } from '../types' + +describe('clampPlayerCount', () => { + it('keeps counts inside the supported range', () => { + expect(clampPlayerCount(1)).toBe(MIN_PLAYERS) + expect(clampPlayerCount(7)).toBe(MAX_PLAYERS) + expect(clampPlayerCount(3.4)).toBe(3) + expect(clampPlayerCount(Number.NaN)).toBe(MIN_PLAYERS) + }) +}) + +describe('seatLayout', () => { + it('provides one seat per player for every supported count', () => { + for (let count = MIN_PLAYERS; count <= MAX_PLAYERS; count += 1) { + expect(seatLayout(count).seats).toHaveLength(count) + } + }) + + it('faces two players at each other across the device', () => { + const layout = seatLayout(2) + + expect(layout).toMatchObject({ columns: 1, rows: 2 }) + expect(layout.seats.map((seat) => seat.rotation)).toEqual([180, 0]) + expect(layout.seats[0].gridArea).toBe('1 / 1 / 2 / 2') + }) + + it('gives the third player the full bottom row', () => { + const layout = seatLayout(3) + + expect(layout.seats[2].gridArea).toBe('2 / 1 / 3 / 3') + expect(layout.seats[2].rotation).toBe(0) + }) + + it('seats everyone at the top or bottom edge, never sideways', () => { + for (let count = MIN_PLAYERS; count <= MAX_PLAYERS; count += 1) { + const rotations = seatLayout(count).seats.map((seat) => seat.rotation) + expect(rotations.every((rotation) => rotation === 0 || rotation === 180)).toBe(true) + } + }) + + it('splits five and six players into a facing top and bottom row', () => { + expect(seatLayout(6)).toMatchObject({ columns: 3, rows: 2 }) + expect(seatLayout(6).seats.map((seat) => seat.rotation)).toEqual([180, 180, 180, 0, 0, 0]) + + // Three across the top, two wider seats across the bottom. + expect(seatLayout(5).seats.map((seat) => seat.rotation)).toEqual([180, 180, 180, 0, 0]) + expect(seatLayout(5).seats[3].gridArea).toBe('2 / 1 / 3 / 4') + expect(seatLayout(5).seats[4].gridArea).toBe('2 / 4 / 3 / 7') + }) + + it('puts the top row first so seat order runs around the table', () => { + for (let count = MIN_PLAYERS; count <= MAX_PLAYERS; count += 1) { + const seats = seatLayout(count).seats + expect(seats[0].rotation, `players=${count}`).toBe(180) + expect(seats[seats.length - 1].rotation, `players=${count}`).toBe(0) + } + }) + + it('falls back to the facing layout for an unknown id', () => { + expect(seatLayout(4, 'nonsense')).toEqual(seatLayout(4, 'facing')) + }) + + it('fills the grid without gaps or overlaps, in every layout', () => { + for (const layoutId of TABLE_LAYOUTS.map((entry) => entry.id)) { + for (let count = MIN_PLAYERS; count <= MAX_PLAYERS; count += 1) { + const layout = seatLayout(count, layoutId) + const where = `${layoutId}/${count}` + const cells = new Set() + + expect(layout.seats, where).toHaveLength(count) + + for (const seat of layout.seats) { + const [rowStart, colStart, rowEnd, colEnd] = seat.gridArea + .split('/') + .map((part) => Number.parseInt(part.trim(), 10)) + + for (let row = rowStart; row < rowEnd; row += 1) { + for (let col = colStart; col < colEnd; col += 1) { + const key = `${row}:${col}` + expect(cells.has(key), `${where} cell ${key} used twice`).toBe(false) + cells.add(key) + } + } + } + + expect(cells.size, where).toBe(layout.rows * layout.columns) + } + } + }) +}) + +describe('table layouts', () => { + it('offers facing, sides and same-side seating', () => { + expect(TABLE_LAYOUTS.map((layout) => layout.id)).toEqual(['facing', 'sides', 'stacked']) + expect(isLayoutId('sides')).toBe(true) + expect(isLayoutId('diagonal')).toBe(false) + expect(findLayout('diagonal').id).toBe('facing') + }) + + it('turns the facing table a quarter turn for side seating', () => { + const facing = seatLayout(6, 'facing') + const sides = seatLayout(6, 'sides') + + expect(sides).toMatchObject({ columns: facing.rows, rows: facing.columns }) + expect(sides.seats.map((seat) => seat.rotation)).toEqual([90, 90, 90, 270, 270, 270]) + expect(seatLayout(2, 'sides').seats.map((seat) => seat.rotation)).toEqual([90, 270]) + }) + + /** + * The panel's top points away from its player: 90° for somebody at the left + * edge, 270° at the right. Getting these the wrong way round leaves both + * columns readable only upside down. + */ + it('turns left-column seats to 90° and right-column seats to 270°', () => { + for (let count = MIN_PLAYERS; count <= MAX_PLAYERS; count += 1) { + for (const seat of seatLayout(count, 'sides').seats) { + const column = Number.parseInt(seat.gridArea.split('/')[1]!.trim(), 10) + expect(seat.rotation, `players=${count} column=${column}`).toBe(column === 1 ? 90 : 270) + } + } + }) + + it('keeps the uneven split when turned', () => { + // Three seats down the left edge, two down the right. + expect(seatLayout(5, 'sides').seats.map((seat) => seat.rotation)).toEqual([ + 90, 90, 90, 270, 270, + ]) + expect(seatLayout(5, 'sides')).toMatchObject({ columns: 2, rows: 6 }) + }) + + it('stacks every seat upright for one-sided seating', () => { + for (let count = MIN_PLAYERS; count <= MAX_PLAYERS; count += 1) { + const layout = seatLayout(count, 'stacked') + + expect(layout).toMatchObject({ columns: 1, rows: count }) + expect(layout.seats.every((seat) => seat.rotation === 0)).toBe(true) + } + }) +}) diff --git a/src/game/__tests__/storage.spec.ts b/src/game/__tests__/storage.spec.ts new file mode 100644 index 0000000..8820935 --- /dev/null +++ b/src/game/__tests__/storage.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { clearLegacyState, readJson, readLegacyState, writeJson } from '../storage' + +describe('storage helpers', () => { + it('round-trips JSON', () => { + writeJson('glc.test', { a: 1 }) + expect(readJson<{ a: number }>('glc.test')).toEqual({ a: 1 }) + }) + + it('returns null for missing or broken entries', () => { + expect(readJson('glc.missing')).toBeNull() + localStorage.setItem('glc.broken', '{') + expect(readJson('glc.broken')).toBeNull() + }) + + it('does not throw when storage rejects writes', () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + + expect(() => writeJson('glc.test', { a: 1 })).not.toThrow() + setItem.mockRestore() + }) +}) + +describe('legacy state', () => { + it('reads nothing when the old keys are absent', () => { + expect(readLegacyState()).toBeNull() + }) + + it('fills in defaults for partially stored legacy data', () => { + localStorage.setItem('glcP1Hue.Color', '120') + + expect(readLegacyState()).toEqual({ lives: [20, 20], hues: [120, 222] }) + }) + + it('clears the old keys', () => { + localStorage.setItem('glcfirst.life', '5') + localStorage.setItem('glcP2Hue.Color', '10') + + clearLegacyState() + + expect(readLegacyState()).toBeNull() + }) +}) diff --git a/src/game/__tests__/store.spec.ts b/src/game/__tests__/store.spec.ts new file mode 100644 index 0000000..150b8b0 --- /dev/null +++ b/src/game/__tests__/store.spec.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest' +import { createGameStore, sanitizeState, useGame, DEFAULT_HUES } from '../store' +import { STORAGE_KEY } from '../storage' +import { findPreset } from '../presets' +import type { GameState } from '../types' + +const readPersisted = (): GameState => JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + +describe('game store defaults', () => { + it('starts a two player standard game', () => { + const game = createGameStore() + + expect(game.state.playerCount).toBe(2) + expect(game.state.presetId).toBe('standard') + expect(game.state.themeId).toBe('pulse') + expect(game.state.startingLife).toBe(20) + expect(game.activePlayers.value).toHaveLength(2) + expect(game.activePlayers.value.map((player) => player.life)).toEqual([20, 20]) + expect(game.activePlayers.value.map((player) => player.hue)).toEqual(DEFAULT_HUES.slice(0, 2)) + }) + + it('keeps six players in memory so seats remember their name and colour', () => { + const game = createGameStore() + game.setName(4, 'Ada') + game.setPlayerCount(6) + + expect(game.activePlayers.value).toHaveLength(6) + expect(game.activePlayers.value[4].name).toBe('Ada') + }) +}) + +describe('life and counters', () => { + it('adds and removes life, including below zero', () => { + const game = createGameStore() + + game.adjustLife(0, -5) + game.adjustLife(0, -20) + + expect(game.playerAt(0)?.life).toBe(-5) + }) + + it('ignores life changes for unknown players and invalid deltas', () => { + const game = createGameStore() + + game.adjustLife(99, 5) + game.adjustLife(0, Number.NaN) + + expect(game.playerAt(0)?.life).toBe(20) + }) + + it('never lets a counter drop below zero', () => { + const game = createGameStore() + + game.adjustCounter(1, 'poison', 3) + game.adjustCounter(1, 'poison', -10) + + expect(game.playerAt(1)?.counters.poison).toBe(0) + }) +}) + +describe('presets and new games', () => { + it('applying a preset sets the life total and restarts', () => { + const game = createGameStore() + game.adjustLife(0, -12) + + game.applyPreset('commander') + + expect(game.state.presetId).toBe('commander') + expect(game.state.startingLife).toBe(40) + expect(game.counters.value).toEqual(['poison']) + expect(game.activePlayers.value.every((player) => player.life === 40)).toBe(true) + }) + + it('falls back to the standard preset for unknown ids', () => { + expect(findPreset('nope').id).toBe('standard') + }) + + it('resetting clears life and counters for every seat', () => { + const game = createGameStore() + game.setStartingLife(30) + game.adjustLife(0, -9) + game.adjustCounter(0, 'poison', 4) + game.adjustCounter(1, 'energy', 2) + + game.resetGame() + + expect(game.state.players.every((player) => player.life === 30)).toBe(true) + expect(game.state.players.every((player) => player.counters.poison === 0)).toBe(true) + expect(game.state.players.every((player) => player.counters.energy === 0)).toBe(true) + }) +}) + +describe('table configuration', () => { + it('clamps the player count to the supported range', () => { + const game = createGameStore() + + game.setPlayerCount(99) + expect(game.state.playerCount).toBe(6) + + game.setPlayerCount(0) + expect(game.state.playerCount).toBe(2) + }) + + it('wraps hues into 0-359 and trims names', () => { + const game = createGameStore() + + game.setHue(0, 400) + game.setHue(1, -30) + game.setName(0, ' Bo ') + game.setName(1, ' ') + + expect(game.playerAt(0)?.hue).toBe(40) + expect(game.playerAt(1)?.hue).toBe(330) + expect(game.playerAt(0)?.name).toBe('Bo') + expect(game.playerAt(1)?.name).toBe('Player 2') + }) + + it('restores the default colours', () => { + const game = createGameStore() + game.setHue(0, 111) + + game.resetColors() + + expect(game.playerAt(0)?.hue).toBe(DEFAULT_HUES[0]) + }) + + it('remembers the table layout and rejects unknown ones', () => { + const game = createGameStore() + + expect(game.state.layoutId).toBe('facing') + + game.setLayout('sides') + expect(game.state.layoutId).toBe('sides') + expect(game.tableLayout.value.name).toBe('Sides') + expect(game.layout.value.seats[0].rotation).toBe(90) + + game.setLayout('diagonal') + expect(game.state.layoutId).toBe('sides') + }) + + it('keeps the layout when the player count changes', () => { + const game = createGameStore() + game.setLayout('stacked') + + game.setPlayerCount(5) + + expect(game.state.layoutId).toBe('stacked') + expect(game.layout.value.seats).toHaveLength(5) + }) + + it('only accepts known themes', () => { + const game = createGameStore() + + game.setTheme('arena') + expect(game.state.themeId).toBe('arena') + + game.setTheme('hot-pink') + expect(game.state.themeId).toBe('arena') + }) + + it('picks a first player inside the active seats', () => { + const game = createGameStore() + game.setPlayerCount(4) + + expect(game.randomFirstPlayer(() => 0)).toBe(0) + expect(game.randomFirstPlayer(() => 0.99)).toBe(3) + // Guards against a random() that returns exactly 1. + expect(game.randomFirstPlayer(() => 1)).toBe(3) + }) +}) + +describe('persistence', () => { + it('writes every change to local storage', () => { + const game = createGameStore() + + game.adjustLife(0, -3) + game.setPlayerCount(5) + + const stored = readPersisted() + expect(stored.playerCount).toBe(5) + expect(stored.players[0].life).toBe(17) + }) + + it('restores a stored game on the next visit', () => { + const first = createGameStore() + first.applyPreset('commander') + first.setPlayerCount(3) + first.adjustLife(2, -7) + + const second = createGameStore(JSON.parse(localStorage.getItem(STORAGE_KEY)!)) + + expect(second.state.playerCount).toBe(3) + expect(second.state.presetId).toBe('commander') + expect(second.playerAt(2)?.life).toBe(33) + }) + + it('migrates the two player data written by the old version', () => { + localStorage.setItem('glcfirst.life', '13') + localStorage.setItem('glcsecond.life', '7') + localStorage.setItem('glcP1Hue.Color', '99') + localStorage.setItem('glcP2Hue.Color', '250') + + const game = useGame() + + expect(game.playerAt(0)?.life).toBe(13) + expect(game.playerAt(1)?.life).toBe(7) + expect(game.playerAt(0)?.hue).toBe(99) + expect(game.playerAt(1)?.hue).toBe(250) + expect(localStorage.getItem('glcfirst.life')).toBeNull() + }) + + it('survives corrupted storage', () => { + localStorage.setItem(STORAGE_KEY, '{not json') + + const game = useGame() + + expect(game.state.playerCount).toBe(2) + expect(game.playerAt(0)?.life).toBe(20) + }) +}) + +describe('sanitizeState', () => { + it('repairs partial and out-of-range values', () => { + const state = sanitizeState({ + presetId: 'unknown', + themeId: 'nope', + layoutId: 'sideways', + playerCount: 42, + startingLife: Number.NaN, + players: [{ name: '', hue: 720, life: 8.6, counters: { poison: -4 } }], + } as never) + + expect(state.presetId).toBe('standard') + expect(state.themeId).toBe('pulse') + expect(state.layoutId).toBe('facing') + expect(state.playerCount).toBe(6) + expect(state.startingLife).toBe(20) + expect(state.players).toHaveLength(6) + expect(state.players[0]).toMatchObject({ + name: 'Player 1', + hue: 0, + life: 9, + counters: { poison: 0, energy: 0 }, + }) + }) + + it('returns defaults for anything that is not an object', () => { + expect(sanitizeState(null).playerCount).toBe(2) + }) +}) diff --git a/src/game/inject.ts b/src/game/inject.ts new file mode 100644 index 0000000..00b5054 --- /dev/null +++ b/src/game/inject.ts @@ -0,0 +1,12 @@ +import { inject, type InjectionKey } from 'vue' +import { useGame, type GameStore } from './store' + +export const GAME_STORE_KEY: InjectionKey = Symbol('glc.game') + +/** + * Components resolve the store through injection so tests can mount them + * with an isolated store; falls back to the shared app store. + */ +export function useGameStore(): GameStore { + return inject(GAME_STORE_KEY, null) ?? useGame() +} diff --git a/src/game/layouts.ts b/src/game/layouts.ts new file mode 100644 index 0000000..6077358 --- /dev/null +++ b/src/game/layouts.ts @@ -0,0 +1,188 @@ +import type { PlayerCount, SeatLayout, SeatPlacement, Rotation } from './types' +import { MAX_PLAYERS, MIN_PLAYERS } from './types' + +interface SeatSpec { + row: number + column: number + rowSpan?: number + columnSpan?: number + rotation: Rotation +} + +interface LayoutSpec { + columns: number + rows: number + seats: SeatSpec[] +} + +export type LayoutId = 'facing' | 'sides' | 'stacked' + +export interface LayoutDefinition { + id: LayoutId + name: string + description: string +} + +export const TABLE_LAYOUTS: LayoutDefinition[] = [ + { + id: 'facing', + name: 'Facing', + description: 'Players sit along the top and bottom edge, facing each other.', + }, + { + id: 'sides', + name: 'Sides', + description: 'Players sit along the left and right edge; seats turn sideways.', + }, + { + id: 'stacked', + name: 'Same side', + description: 'Everyone sits on the same side; all seats stay upright.', + }, +] + +export const DEFAULT_LAYOUT_ID: LayoutId = 'facing' + +export function isLayoutId(value: unknown): value is LayoutId { + return typeof value === 'string' && TABLE_LAYOUTS.some((layout) => layout.id === value) +} + +export function findLayout(id: string): LayoutDefinition { + const match = TABLE_LAYOUTS.find((layout) => layout.id === id) + return match ?? TABLE_LAYOUTS[0]! +} + +function toPlacement(spec: SeatSpec): SeatPlacement { + const rowEnd = spec.row + (spec.rowSpan ?? 1) + const columnEnd = spec.column + (spec.columnSpan ?? 1) + return { + rotation: spec.rotation, + gridArea: `${spec.row} / ${spec.column} / ${rowEnd} / ${columnEnd}`, + } +} + +/** + * The `facing` seat maps: players sit along the top and bottom edge the way the + * two player table works, so life totals read horizontally. Seats in the top + * row are flipped 180°, the bottom row stays upright. + */ +const FACING_SPECS: Record = { + 2: { + columns: 1, + rows: 2, + seats: [ + { row: 1, column: 1, rotation: 180 }, + { row: 2, column: 1, rotation: 0 }, + ], + }, + 3: { + columns: 2, + rows: 2, + seats: [ + { row: 1, column: 1, rotation: 180 }, + { row: 1, column: 2, rotation: 180 }, + { row: 2, column: 1, columnSpan: 2, rotation: 0 }, + ], + }, + 4: { + columns: 2, + rows: 2, + seats: [ + { row: 1, column: 1, rotation: 180 }, + { row: 1, column: 2, rotation: 180 }, + { row: 2, column: 1, rotation: 0 }, + { row: 2, column: 2, rotation: 0 }, + ], + }, + // Three across the top, two across the bottom; the sixth-column grid lets + // the two rows use different seat widths without a gap. + 5: { + columns: 6, + rows: 2, + seats: [ + { row: 1, column: 1, columnSpan: 2, rotation: 180 }, + { row: 1, column: 3, columnSpan: 2, rotation: 180 }, + { row: 1, column: 5, columnSpan: 2, rotation: 180 }, + { row: 2, column: 1, columnSpan: 3, rotation: 0 }, + { row: 2, column: 4, columnSpan: 3, rotation: 0 }, + ], + }, + 6: { + columns: 3, + rows: 2, + seats: [ + { row: 1, column: 1, rotation: 180 }, + { row: 1, column: 2, rotation: 180 }, + { row: 1, column: 3, rotation: 180 }, + { row: 2, column: 1, rotation: 0 }, + { row: 2, column: 2, rotation: 0 }, + { row: 2, column: 3, rotation: 0 }, + ], + }, +} + +/** + * Rotation of a facing seat once the table is turned a quarter turn. + * + * A player reads the panel with its top pointing away from them, so somebody + * at the left edge needs it turned 90° (top towards the right of the screen) + * and somebody at the right edge 270°. The top row therefore becomes the left + * column at 90°, and the bottom row the right column at 270°. + */ +const TURNED: Record = { 0: 270, 90: 0, 180: 90, 270: 180 } + +/** + * `sides` is the facing table turned a quarter turn: rows become columns, the + * top group moves to the left edge and the bottom group to the right edge. + */ +function turn(spec: LayoutSpec): LayoutSpec { + return { + columns: spec.rows, + rows: spec.columns, + seats: spec.seats.map((seat) => ({ + row: seat.column, + column: seat.row, + rowSpan: seat.columnSpan, + columnSpan: seat.rowSpan, + rotation: TURNED[seat.rotation], + })), + } +} + +/** `stacked` puts every seat in its own upright row, for one-sided seating. */ +function stacked(playerCount: PlayerCount): LayoutSpec { + return { + columns: 1, + rows: playerCount, + seats: Array.from({ length: playerCount }, (_, index) => ({ + row: index + 1, + column: 1, + rotation: 0 as Rotation, + })), + } +} + +function specFor(playerCount: PlayerCount, layoutId: LayoutId): LayoutSpec { + switch (layoutId) { + case 'sides': + return turn(FACING_SPECS[playerCount]) + case 'stacked': + return stacked(playerCount) + default: + return FACING_SPECS[playerCount] + } +} + +export function clampPlayerCount(count: number): PlayerCount { + if (!Number.isFinite(count)) return MIN_PLAYERS + return Math.min(MAX_PLAYERS, Math.max(MIN_PLAYERS, Math.round(count))) as PlayerCount +} + +export function seatLayout(playerCount: number, layoutId: string = DEFAULT_LAYOUT_ID): SeatLayout { + const spec = specFor(clampPlayerCount(playerCount), findLayout(layoutId).id) + return { + columns: spec.columns, + rows: spec.rows, + seats: spec.seats.map(toPlacement), + } +} diff --git a/src/game/presets.ts b/src/game/presets.ts new file mode 100644 index 0000000..3c73810 --- /dev/null +++ b/src/game/presets.ts @@ -0,0 +1,58 @@ +import type { GamePreset } from './types' + +export const GAME_PRESETS: GamePreset[] = [ + { + id: 'standard', + name: 'Standard', + description: 'Duel life total, single steps.', + startingLife: 20, + step: 1, + bigStep: 10, + counters: [], + }, + { + id: 'commander', + name: 'Commander', + description: 'Multiplayer life total with poison.', + startingLife: 40, + step: 1, + bigStep: 10, + counters: ['poison'], + }, + { + id: 'energy', + name: 'Energy', + description: 'Life plus poison and energy counters.', + startingLife: 20, + step: 1, + bigStep: 10, + counters: ['poison', 'energy'], + }, + { + id: 'duel8000', + name: 'Duel 8000', + description: 'Large life pool, steps of 50.', + startingLife: 8000, + step: 50, + bigStep: 500, + counters: [], + }, + { + id: 'board', + name: 'Board game', + description: 'Neutral counter starting at zero.', + startingLife: 0, + step: 1, + bigStep: 10, + counters: [], + }, +] + +export const DEFAULT_PRESET_ID = 'standard' + +export function findPreset(id: string): GamePreset { + return ( + GAME_PRESETS.find((preset) => preset.id === id) ?? + GAME_PRESETS.find((preset) => preset.id === DEFAULT_PRESET_ID)! + ) +} diff --git a/src/game/storage.ts b/src/game/storage.ts new file mode 100644 index 0000000..a12225a --- /dev/null +++ b/src/game/storage.ts @@ -0,0 +1,91 @@ +export const STORAGE_KEY = 'glc.state.v2' + +/** Keys written by the pre-revamp two-player version. */ +const LEGACY_KEYS = { + p1Life: 'glcfirst.life', + p2Life: 'glcsecond.life', + p1Hue: 'glcP1Hue.Color', + p2Hue: 'glcP2Hue.Color', +} as const + +function storage(): Storage | null { + try { + return typeof localStorage === 'undefined' ? null : localStorage + } catch { + // Storage can throw when cookies/site data are blocked. + return null + } +} + +export function readJson(key: string): T | null { + const store = storage() + if (!store) return null + try { + const raw = store.getItem(key) + return raw === null ? null : (JSON.parse(raw) as T) + } catch { + return null + } +} + +export function writeJson(key: string, value: unknown): void { + const store = storage() + if (!store) return + try { + store.setItem(key, JSON.stringify(value)) + } catch { + // Quota or privacy mode: the app stays usable without persistence. + } +} + +export function removeKey(key: string): void { + const store = storage() + if (!store) return + try { + store.removeItem(key) + } catch { + // ignore + } +} + +export interface LegacySnapshot { + lives: [number, number] + hues: [number, number] +} + +function readLegacyNumber(key: string): number | null { + const store = storage() + if (!store) return null + try { + const raw = store.getItem(key) + if (raw === null) return null + const value = Number.parseFloat(raw) + return Number.isFinite(value) ? value : null + } catch { + return null + } +} + +/** + * Reads the two-player data written by earlier releases so returning users + * keep their life totals and colours. Returns null when nothing is stored. + */ +export function readLegacyState(): LegacySnapshot | null { + const p1Life = readLegacyNumber(LEGACY_KEYS.p1Life) + const p2Life = readLegacyNumber(LEGACY_KEYS.p2Life) + const p1Hue = readLegacyNumber(LEGACY_KEYS.p1Hue) + const p2Hue = readLegacyNumber(LEGACY_KEYS.p2Hue) + + if (p1Life === null && p2Life === null && p1Hue === null && p2Hue === null) { + return null + } + + return { + lives: [p1Life ?? 20, p2Life ?? 20], + hues: [p1Hue ?? 32, p2Hue ?? 222], + } +} + +export function clearLegacyState(): void { + Object.values(LEGACY_KEYS).forEach(removeKey) +} diff --git a/src/game/store.ts b/src/game/store.ts new file mode 100644 index 0000000..80b3b10 --- /dev/null +++ b/src/game/store.ts @@ -0,0 +1,244 @@ +import { computed, reactive, readonly } from 'vue' +import type { CounterKind, GameState, Player } from './types' +import { MAX_PLAYERS } from './types' +import { DEFAULT_PRESET_ID, findPreset } from './presets' +import { DEFAULT_LAYOUT_ID, clampPlayerCount, findLayout, isLayoutId, seatLayout } from './layouts' +import { DEFAULT_THEME_ID, isThemeId } from '../theme/themes' +import { STORAGE_KEY, clearLegacyState, readJson, readLegacyState, writeJson } from './storage' + +export const DEFAULT_HUES = [32, 222, 145, 305, 355, 265] + +function defaultPlayer(index: number, life: number): Player { + return { + id: index, + name: `Player ${index + 1}`, + hue: DEFAULT_HUES[index] ?? 0, + life, + counters: { poison: 0, energy: 0 }, + } +} + +function toFiniteNumber(value: unknown, fallback: number): number { + const parsed = typeof value === 'number' ? value : Number.parseFloat(String(value)) + return Number.isFinite(parsed) ? parsed : fallback +} + +function normaliseHue(value: unknown, fallback: number): number { + const hue = toFiniteNumber(value, fallback) + return ((Math.round(hue) % 360) + 360) % 360 +} + +function defaultState(): GameState { + const preset = findPreset(DEFAULT_PRESET_ID) + return { + presetId: preset.id, + themeId: DEFAULT_THEME_ID, + layoutId: DEFAULT_LAYOUT_ID, + playerCount: 2, + startingLife: preset.startingLife, + players: Array.from({ length: MAX_PLAYERS }, (_, index) => + defaultPlayer(index, preset.startingLife), + ), + } +} + +/** Rebuilds a trustworthy state object from whatever was persisted. */ +export function sanitizeState(raw: Partial | null): GameState { + const base = defaultState() + if (!raw || typeof raw !== 'object') return base + + const preset = findPreset(typeof raw.presetId === 'string' ? raw.presetId : base.presetId) + const startingLife = Math.round(toFiniteNumber(raw.startingLife, preset.startingLife)) + const storedPlayers = Array.isArray(raw.players) ? raw.players : [] + + return { + presetId: preset.id, + themeId: isThemeId(raw.themeId) ? raw.themeId : base.themeId, + layoutId: isLayoutId(raw.layoutId) ? raw.layoutId : base.layoutId, + playerCount: clampPlayerCount(toFiniteNumber(raw.playerCount, base.playerCount)), + startingLife, + players: Array.from({ length: MAX_PLAYERS }, (_, index) => { + const fallback = defaultPlayer(index, startingLife) + const stored = storedPlayers[index] as Partial | undefined + if (!stored || typeof stored !== 'object') return fallback + return { + id: index, + name: + typeof stored.name === 'string' && stored.name.trim().length > 0 + ? stored.name.slice(0, 24) + : fallback.name, + hue: normaliseHue(stored.hue, fallback.hue), + life: Math.round(toFiniteNumber(stored.life, fallback.life)), + counters: { + poison: Math.max(0, Math.round(toFiniteNumber(stored.counters?.poison, 0))), + energy: Math.max(0, Math.round(toFiniteNumber(stored.counters?.energy, 0))), + }, + } + }), + } +} + +function loadState(): GameState { + const stored = readJson>(STORAGE_KEY) + if (stored) return sanitizeState(stored) + + const legacy = readLegacyState() + if (!legacy) return defaultState() + + // One-off upgrade from the two-player release. + const migrated = defaultState() + legacy.lives.forEach((life, index) => { + const player = migrated.players[index] + if (player) player.life = Math.round(life) + }) + legacy.hues.forEach((hue, index) => { + const player = migrated.players[index] + if (player) player.hue = normaliseHue(hue, player.hue) + }) + clearLegacyState() + return migrated +} + +export function createGameStore(initial?: Partial) { + const state = reactive(initial ? sanitizeState(initial) : loadState()) + + const persist = () => writeJson(STORAGE_KEY, state) + + const preset = computed(() => findPreset(state.presetId)) + const activePlayers = computed(() => state.players.slice(0, state.playerCount)) + const tableLayout = computed(() => findLayout(state.layoutId)) + const layout = computed(() => seatLayout(state.playerCount, state.layoutId)) + const counters = computed(() => preset.value.counters) + + const playerAt = (id: number): Player | undefined => + state.players.find((player) => player.id === id) + + const adjustLife = (id: number, delta: number) => { + const player = playerAt(id) + if (!player || !Number.isFinite(delta)) return + player.life = Math.round(player.life + delta) + persist() + } + + const setLife = (id: number, life: number) => { + const player = playerAt(id) + if (!player || !Number.isFinite(life)) return + player.life = Math.round(life) + persist() + } + + const adjustCounter = (id: number, kind: CounterKind, delta: number) => { + const player = playerAt(id) + if (!player || !Number.isFinite(delta)) return + player.counters[kind] = Math.max(0, Math.round(player.counters[kind] + delta)) + persist() + } + + const setHue = (id: number, hue: number) => { + const player = playerAt(id) + if (!player) return + player.hue = normaliseHue(hue, player.hue) + persist() + } + + const setName = (id: number, name: string) => { + const player = playerAt(id) + if (!player) return + const trimmed = name.trim() + player.name = trimmed.length > 0 ? trimmed.slice(0, 24) : `Player ${id + 1}` + persist() + } + + const setPlayerCount = (count: number) => { + state.playerCount = clampPlayerCount(count) + persist() + } + + const setStartingLife = (life: number) => { + if (!Number.isFinite(life)) return + state.startingLife = Math.round(life) + persist() + } + + /** Puts every player back to the starting life and clears their counters. */ + const resetGame = () => { + state.players.forEach((player) => { + player.life = state.startingLife + player.counters.poison = 0 + player.counters.energy = 0 + }) + persist() + } + + const applyPreset = (presetId: string) => { + const next = findPreset(presetId) + state.presetId = next.id + state.startingLife = next.startingLife + resetGame() + } + + const setTheme = (themeId: string) => { + if (!isThemeId(themeId)) return + state.themeId = themeId + persist() + } + + const setLayout = (layoutId: string) => { + if (!isLayoutId(layoutId)) return + state.layoutId = layoutId + persist() + } + + const resetColors = () => { + state.players.forEach((player, index) => { + player.hue = DEFAULT_HUES[index] ?? 0 + }) + persist() + } + + /** Returns the id of a randomly chosen starting player. */ + const randomFirstPlayer = (random: () => number = Math.random): number => { + const seats = activePlayers.value + // Math.random() is [0, 1) but a stubbed one may return exactly 1. + const index = Math.min(Math.max(0, Math.floor(random() * seats.length)), seats.length - 1) + return seats[index]?.id ?? 0 + } + + return { + state: readonly(state), + preset, + activePlayers, + layout, + tableLayout, + counters, + playerAt, + adjustLife, + setLife, + adjustCounter, + setHue, + setName, + setPlayerCount, + setStartingLife, + resetGame, + applyPreset, + setTheme, + setLayout, + resetColors, + randomFirstPlayer, + persist, + } +} + +export type GameStore = ReturnType + +let singleton: GameStore | null = null + +export function useGame(): GameStore { + if (!singleton) singleton = createGameStore() + return singleton +} + +/** Test seam: drops the shared store so the next `useGame()` rebuilds it. */ +export function resetGameStore(): void { + singleton = null +} diff --git a/src/game/types.ts b/src/game/types.ts new file mode 100644 index 0000000..290856b --- /dev/null +++ b/src/game/types.ts @@ -0,0 +1,68 @@ +export type CounterKind = 'poison' | 'energy' + +export interface Player { + id: number + name: string + /** Hue (0-359) used by every theme as the player's identity colour. */ + hue: number + life: number + counters: Record +} + +export interface GamePreset { + id: string + name: string + description: string + startingLife: number + /** Life change of a single tap. */ + step: number + /** Life change applied repeatedly while a button is held down. */ + bigStep: number + /** Extra counters shown on the player panel. */ + counters: CounterKind[] +} + +/** + * How far the panel turns so it reads upright from its player's seat: 0° for + * the bottom edge, 180° for the top and — in the `sides` layout — 90° for the + * left edge and 270° for the right. + */ +export type Rotation = 0 | 90 | 180 | 270 + +export interface SeatPlacement { + /** Rotation that makes the panel readable from the player's seat. */ + rotation: Rotation + /** CSS `grid-area` shorthand: row-start / col-start / row-end / col-end. */ + gridArea: string +} + +export interface SeatLayout { + columns: number + rows: number + seats: SeatPlacement[] +} + +export interface GameState { + presetId: string + themeId: string + layoutId: string + playerCount: number + startingLife: number + players: Player[] +} + +export const MIN_PLAYERS = 2 +export const MAX_PLAYERS = 6 + +/** Player counts with a defined seat layout. */ +export type PlayerCount = 2 | 3 | 4 | 5 | 6 + +export const COUNTER_LABELS: Record = { + poison: 'Poison', + energy: 'Energy', +} + +export const COUNTER_ICONS: Record = { + poison: '☠', + energy: '⚡', +} diff --git a/src/main.ts b/src/main.ts index 804997c..391b2ac 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,6 @@ import { createApp } from 'vue' import App from './App.vue' -import './assets/main.css' - -//tailwind css import './assets/styles/index.css' createApp(App).mount('#app') diff --git a/src/test/pwaRegisterStub.ts b/src/test/pwaRegisterStub.ts new file mode 100644 index 0000000..1ec51b7 --- /dev/null +++ b/src/test/pwaRegisterStub.ts @@ -0,0 +1,10 @@ +import { ref } from 'vue' + +/** Stand-in for `virtual:pwa-register/vue`, which only exists in a Vite build. */ +export function useRegisterSW() { + return { + offlineReady: ref(false), + needRefresh: ref(false), + updateServiceWorker: async () => {}, + } +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..eb66f29 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,8 @@ +import { beforeEach } from 'vitest' +import { resetGameStore } from '../game/store' + +beforeEach(() => { + localStorage.clear() + resetGameStore() + document.documentElement.removeAttribute('data-theme') +}) diff --git a/src/theme/__tests__/tokens.spec.ts b/src/theme/__tests__/tokens.spec.ts new file mode 100644 index 0000000..72bbbb5 --- /dev/null +++ b/src/theme/__tests__/tokens.spec.ts @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Resolved from the project root: under jsdom `import.meta.url` is not a file URL. +const css = readFileSync(resolve(process.cwd(), 'src/assets/styles/tokens.css'), 'utf8').replace( + /\/\*[\s\S]*?\*\//g, + '', +) + +interface Rule { + selector: string + body: string +} + +const rules: Rule[] = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((match) => ({ + selector: match[1].trim(), + body: match[2], +})) + +describe('theme tokens', () => { + /** + * `var()` inside a custom property is substituted where the property is + * *declared*. A hue-dependent token declared on `:root` therefore resolves + * `--player-hue` to its fallback and paints every player the same colour, so + * those declarations have to live on the panel that carries the hue. + */ + it('declares every hue-dependent token on the panel itself', () => { + const offenders = rules + .filter((rule) => rule.body.includes('var(--player-hue')) + .filter((rule) => !rule.selector.includes('.panel')) + .map((rule) => rule.selector) + + expect(offenders).toEqual([]) + }) + + it('still defines the hue-dependent panel tokens for both themes', () => { + for (const theme of ['pulse', 'arena']) { + const rule = rules.find((r) => r.selector === `[data-theme='${theme}'] .panel`) + expect(rule, `${theme} panel tokens missing`).toBeDefined() + expect(rule!.body).toContain('--panel-bg') + expect(rule!.body).toContain('var(--player-hue') + } + }) + + it('keeps a fallback hue on every var(--player-hue) reference', () => { + const references = css.match(/var\(--player-hue[^)]*\)/g) ?? [] + + expect(references.length).toBeGreaterThan(0) + expect(references.every((reference) => reference.includes(','))).toBe(true) + }) +}) diff --git a/src/theme/__tests__/useTheme.spec.ts b/src/theme/__tests__/useTheme.spec.ts new file mode 100644 index 0000000..13f389c --- /dev/null +++ b/src/theme/__tests__/useTheme.spec.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { effectScope, nextTick } from 'vue' +import { createGameStore } from '../../game/store' +import { useTheme } from '../useTheme' +import { findTheme, isThemeId, THEMES } from '../themes' + +describe('theme catalogue', () => { + it('offers a bleed and a card variant', () => { + expect(THEMES.map((theme) => theme.variant).sort()).toEqual(['bleed', 'card']) + }) + + it('recognises only known ids and falls back safely', () => { + expect(isThemeId('pulse')).toBe(true) + expect(isThemeId('nope')).toBe(false) + expect(isThemeId(7)).toBe(false) + expect(findTheme('nope').id).toBe('pulse') + }) +}) + +describe('useTheme', () => { + let scope: ReturnType | null = null + + afterEach(() => { + scope?.stop() + scope = null + document.head.querySelector('meta[name="theme-color"]')?.remove() + }) + + it('mirrors the stored theme onto the document', async () => { + const meta = document.createElement('meta') + meta.name = 'theme-color' + meta.content = '#ffffff' + document.head.appendChild(meta) + + const game = createGameStore() + scope = effectScope() + const { theme } = scope.run(() => useTheme(game))! + + expect(document.documentElement.dataset.theme).toBe('pulse') + expect(meta.content).toBe('#111014') + + game.setTheme('arena') + await nextTick() + + expect(theme.value.id).toBe('arena') + expect(document.documentElement.dataset.theme).toBe('arena') + expect(meta.content).toBe('#0b0d13') + }) + + it('works when the page has no theme-color meta tag', () => { + const game = createGameStore({ themeId: 'arena' }) + scope = effectScope() + scope.run(() => useTheme(game)) + + expect(document.documentElement.dataset.theme).toBe('arena') + }) +}) diff --git a/src/theme/themes.ts b/src/theme/themes.ts new file mode 100644 index 0000000..0c77286 --- /dev/null +++ b/src/theme/themes.ts @@ -0,0 +1,42 @@ +export type ThemeId = 'pulse' | 'arena' + +export interface ThemeDefinition { + id: ThemeId + name: string + tagline: string + /** + * How a player panel is drawn: + * - `bleed`: edge-to-edge colour field, the whole half is the tap target. + * - `card`: dark stage with a floating card and explicit controls. + */ + variant: 'bleed' | 'card' + /** Two hues used for the settings preview swatch. */ + preview: [number, number] +} + +export const THEMES: [ThemeDefinition, ThemeDefinition] = [ + { + id: 'pulse', + name: 'Pulse', + tagline: 'Full-bleed colour, oversized numerals.', + variant: 'bleed', + preview: [32, 222], + }, + { + id: 'arena', + name: 'Arena', + tagline: 'Dark stage with floating player cards.', + variant: 'card', + preview: [265, 190], + }, +] + +export const DEFAULT_THEME_ID: ThemeId = 'pulse' + +export function isThemeId(value: unknown): value is ThemeId { + return typeof value === 'string' && THEMES.some((theme) => theme.id === value) +} + +export function findTheme(id: string): ThemeDefinition { + return THEMES.find((theme) => theme.id === id) ?? THEMES[0] +} diff --git a/src/theme/useTheme.ts b/src/theme/useTheme.ts new file mode 100644 index 0000000..d0c00aa --- /dev/null +++ b/src/theme/useTheme.ts @@ -0,0 +1,27 @@ +import { computed, watchEffect } from 'vue' +import { useGame } from '../game/store' +import { findTheme } from './themes' +import type { GameStore } from '../game/store' + +const THEME_COLORS: Record = { + pulse: '#111014', + arena: '#0b0d13', +} + +/** + * Keeps `` and the browser theme-colour in sync with the + * theme stored in the game state. + */ +export function useTheme(store: GameStore = useGame()) { + const theme = computed(() => findTheme(store.state.themeId)) + + watchEffect(() => { + if (typeof document === 'undefined') return + document.documentElement.dataset.theme = theme.value.id + + const meta = document.querySelector('meta[name="theme-color"]') + if (meta) meta.content = THEME_COLORS[theme.value.id] ?? '#111014' + }) + + return { theme } +} diff --git a/tsconfig.app.json b/tsconfig.app.json index b5b2ca9..895ac92 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,13 +1,13 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], - "exclude": ["src/**/__tests__/*"], + "exclude": ["src/**/__tests__/*", "src/test/**/*"], "compilerOptions": { "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "baseUrl": ".", "paths": { "@/*": ["./src/*"] - }, - "allowJs": true + } } } diff --git a/tsconfig.config.json b/tsconfig.config.json index a2b025d..c8c8181 100644 --- a/tsconfig.config.json +++ b/tsconfig.config.json @@ -1,9 +1,9 @@ { "extends": "@vue/tsconfig/tsconfig.json", - "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"], + "include": ["vite.config.*", "vitest.config.*"], "compilerOptions": { "composite": true, - "types": ["vitest/globals"] - + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.config.tsbuildinfo", + "types": ["node", "vitest/globals"] } } diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json index 8fa2848..01f3775 100644 --- a/tsconfig.vitest.json +++ b/tsconfig.vitest.json @@ -1,9 +1,13 @@ { "extends": "./tsconfig.app.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], "exclude": [], "compilerOptions": { "composite": true, - "lib": [], - "types": ["node", "jsdom","vitest/globals"] + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo", + "types": ["node", "vitest/globals"], + // Specs index into query results (`findAll(...)[0]`) constantly; the strict + // index checks stay on for application code. + "noUncheckedIndexedAccess": false } } diff --git a/vite-env.d.ts b/vite-env.d.ts deleted file mode 100644 index 3de2bbf..0000000 --- a/vite-env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// - -declare module "*.vue" { - import type { DefineComponent } from "vue" - const component: DefineComponent<{}, {}, any> - export default component -} diff --git a/vite.config.ts b/vite.config.ts index 0f2c2e6..f54ae44 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,15 +2,26 @@ import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' - import { VitePWA } from 'vite-plugin-pwa' -// https://vitejs.dev/config/ +// https://vite.dev/config/ export default defineConfig({ + base: './', plugins: [ vue(), VitePWA({ + registerType: 'prompt', manifest: { + name: 'Game Life Counter', + short_name: 'Life Counter', + description: + 'Life counter for board and card games — 2 to 6 players, presets, dice and a match timer.', + theme_color: '#111014', + background_color: '#08080c', + display: 'standalone', + orientation: 'any', + start_url: './', + scope: './', icons: [ { src: './icons/heart.svg', @@ -18,19 +29,19 @@ export default defineConfig({ type: 'image/svg+xml', purpose: 'any', }, + { + src: './icons/heart.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable', + }, ], }, }), ], - base: './', resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, - test: { - coverage: { - reporter: ['text', 'json', 'html'], - }, - }, }) diff --git a/vitest.config.ts b/vitest.config.ts index d8f45dc..2ad9d98 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,12 +1,30 @@ -/// +import { fileURLToPath, URL } from 'node:url' -import { defineConfig } from "vite" -import Vue from "@vitejs/plugin-vue" +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' export default defineConfig({ - plugins: [Vue()], + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + // The PWA virtual module only exists inside a Vite build. + 'virtual:pwa-register/vue': fileURLToPath( + new URL('./src/test/pwaRegisterStub.ts', import.meta.url), + ), + }, + }, test: { globals: true, - environment: "jsdom", + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + setupFiles: ['./src/test/setup.ts'], + restoreMocks: true, + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'json'], + include: ['src/**/*.{ts,vue}'], + exclude: ['src/main.ts', 'src/test/**', 'src/**/*.spec.ts'], + }, }, })