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.
-
-
-
-## 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/)
+
+
+
+## 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 players, Commander preset. Named seats, poison counters
+ and the match timer running along the top.
+
+
+
+ Arena theme. Floating player cards on a dark stage, here with
+ poison and energy counters.
+
+
+
+
+
+ Layout chooser. Opens right after the player count; each
+ option is drawn for your table and the table behind updates as you pick.
+
+
+
+ Sides layout. The same table turned a quarter turn for
+ players sitting along the left and right edge.
+
+
+
+
+
+
+
+
+ Settings. Players, preset, starting life, theme, names and
+ colours.
+
+
+
+ Dice. Coin and d4 to d100, with the recent rolls kept.
+
+
+
+ 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 @@
-
-
-
-
-
- {
- showSettings = !showSettings
- showElements = false
- }
- "
- />
- {
- showColorSel = !showColorSel
- showElements = false
- }
- "
- />
- {
- resetPlayers()
- showElements = false
- }
- "
- />
-
- (showSettings = !value)"
- />
- (showColorSel = false)"
- v-model:p1-hue="p1Hue"
- v-model:p2-hue="p2Hue"
- />
-
-
+
+
+
+
+
+
+
+
+
+
-
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 @@
+
+
+
+
+
+
{{ resultLabel }}
+
{{ resultHint }}
+
+
+
+
+ {{ dieLabel(sides) }}
+
+
+
+
+ Recent
+
+ {{ dieLabel(roll.sides) }}: {{ roll.value }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ onLife(player.id, delta)"
+ @counter="(kind, delta) => onCounter(player.id, kind, delta)"
+ />
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
{{ timer.label.value }}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
{{ title }}
+
{{ subtitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {
+ capture(event)
+ minus.start(event.pointerId)
+ }
+ "
+ @pointerup="(event: PointerEvent) => minus.stop(event.pointerId)"
+ @pointercancel="(event: PointerEvent) => minus.cancel(event.pointerId)"
+ @keydown.enter.prevent="changeLife(-step)"
+ @keydown.space.prevent="changeLife(-step)"
+ >
+
+
+
+
+
+
+
+
+ {{ player.name }}
+
+
+
+ {{ pending.label.value }}
+
+
+
{{ lifeLabel }}
+
+
+
+
+
+
+
+ {{ COUNTER_ICONS[kind] }}
+ {{ player.counters[kind] }}
+
+
+
+
+
+
+
+
+ {
+ capture(event)
+ plus.start(event.pointerId)
+ }
+ "
+ @pointerup="(event: PointerEvent) => plus.stop(event.pointerId)"
+ @pointercancel="(event: PointerEvent) => plus.cancel(event.pointerId)"
+ @keydown.enter.prevent="changeLife(step)"
+ @keydown.space.prevent="changeLife(step)"
+ >
+
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
- -
-
-
-
- {{ partValueCom }}
-
-
{{ CurrentLifeCom }}
-
-
- +
-
-
-
-
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
}
-
-
-
App ready to work offline
-
- New content available, click on reload button to update.
-
+
+
+ {{ offlineReady ? 'Ready to play offline.' : 'A new version is available.' }}
+
+
+
+ Reload
+
+ Dismiss
-
Reload
-
Close
-
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 @@
-
-
-
-
-
Fullscreen
-
-
-
-
-
Screen-Off
-
-
-
-
-
-
-
{{ errorSceenOff }}
-
-
-
-
-
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 @@
+
+
+
+
+
+
+
+
+ {{ tableLayout.name }}
+ {{ tableLayout.description }}
+
+
+
+
+
+
+ Done
+
+
+
+
+
+ Players
+
+
+ {{ count }}
+
+
+
+
+
+ {{ game.tableLayout.value.name }}
+ {{ game.tableLayout.value.description }}
+
+ ›
+
+
+
+
+ Game preset
+
+
+
+ {{ preset.name }}
+ {{ preset.description }}
+
+ {{ preset.startingLife }}
+
+
+
+
+
+
+ Starting life
+
+
+ {{ step > 0 ? `+${step}` : step }}
+
+ {{ startingLifeLabel }}
+
+
+
+ Start new game
+
+
+
+
+ Theme
+
+
+
+
+ {{ theme.name }}
+ {{ theme.tagline }}
+
+
+
+
+
+
+
+
+
+ Display
+
+
+
+ Fullscreen
+ Hide the browser interface
+
+
+
+
+
+
+ Keep screen awake
+ Stops the display from sleeping mid-game
+
+
+
+ {{ wakeLock.error.value }}
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
{{ player.name }}
+
starts the game — tap to dismiss
+
+
+
+
+
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'],
+ },
},
})