From c3497ea813b1f05d37104e72758d5fd2b2205cf6 Mon Sep 17 00:00:00 2001 From: necessarylion Date: Tue, 1 Sep 2026 07:50:56 +0700 Subject: [PATCH 1/4] Run the suites on pull requests, four shards at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser tests were a folder off to one side with its own manifest and its own lockfile, which meant they only ever ran when somebody remembered to run them. Playwright's config, its dependency and the tsconfig that types the specs now sit at the root beside the app's, so `bun run test:e2e` works from a plain `bun install` and CI can reach them; `e2e/` keeps the specs and nothing else. CI splits both suites four ways with `--shard`, a runner each. That also settles the one thing that could not be parallelised before: the integration test binds port 8899, and separate runners never contend for it. A push to main no longer re-runs what the pull request already proved — it builds the image and deploys. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/docker-publish.yml | 58 ++++++++++++++++--- .gitignore | 3 + bun.lock | 9 +++ e2e/.gitignore | 5 -- e2e/README.md | 36 ++++++------ e2e/bun.lock | 21 ------- e2e/package.json | 17 ------ e2e/tsconfig.json | 13 ----- package.json | 5 +- ...aywright.config.ts => playwright.config.ts | 9 +-- tsconfig.json | 6 +- 11 files changed, 89 insertions(+), 93 deletions(-) delete mode 100644 e2e/.gitignore delete mode 100644 e2e/bun.lock delete mode 100644 e2e/package.json delete mode 100644 e2e/tsconfig.json rename e2e/playwright.config.ts => playwright.config.ts (94%) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 47a8b8b..ec59ce1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,7 +4,7 @@ on: push: branches: [main] tags: ['v*'] - # Pull requests are verified but never published. + # Pull requests are verified; pushes to main are built and deployed. pull_request: branches: [main] workflow_dispatch: @@ -14,26 +14,66 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: - verify: - name: Typecheck and test + typecheck: + if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - run: bun run typecheck + test: + name: Test (shard ${{ matrix.shard }}/4) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest + - run: bun install --frozen-lockfile + # One shard holds the end-to-end suite, which starts the real server on + # port 8899 and plays a full game between two WebSocket clients. Shards + # get a runner each, so that fixed port is never contended. + - run: bunx vitest run --shard=${{ matrix.shard }}/4 + e2e: + name: Browser tests (shard ${{ matrix.shard }}/4) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest - run: bun install --frozen-lockfile - - run: bun run typecheck - # Includes the end-to-end suite, which starts the real server and plays a - # full game between two WebSocket clients. - - run: bun run test + # Chromium covers every viewport project; WebKit is only for mobile-safari. + - run: bunx playwright install --with-deps chromium webkit + # Playwright starts the app itself (`bun run dev`: Vite plus the game server). + - run: bunx playwright test --shard=${{ matrix.shard }}/4 + + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ matrix.shard }} + path: playwright-report/ + retention-days: 7 publish: name: Build and push - # Never publish an image that did not pass the suite. - needs: verify + # The suite gates the pull request; by the time a commit reaches main it has + # already run, so a push only builds and deploys. if: github.event_name != 'pull_request' runs-on: ubuntu-latest permissions: diff --git a/.gitignore b/.gitignore index 19a7b2b..d090460 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist-server/ .DS_Store *.log .vite/ +test-results/ +playwright-report/ +blob-report/ diff --git a/bun.lock b/bun.lock index 4f5fd6a..3303753 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "ws": "^8.18.0", }, "devDependencies": { + "@playwright/test": "^1.49.0", "@types/node": "^22.10.2", "@types/pg": "^8.20.0", "@types/three": "^0.185.4", @@ -123,6 +124,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.3", "", { "os": "android", "cpu": "arm" }, "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.3", "", { "os": "android", "cpu": "arm64" }, "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA=="], @@ -401,6 +404,10 @@ "pinia": ["pinia@2.3.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" } }, "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], @@ -555,6 +562,8 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": "bin/esbuild" }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], diff --git a/e2e/.gitignore b/e2e/.gitignore deleted file mode 100644 index a4fcaaf..0000000 --- a/e2e/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -test-results/ -playwright-report/ -blob-report/ -.playwright/ diff --git a/e2e/README.md b/e2e/README.md index 699e8f0..4cffce0 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -4,32 +4,29 @@ Playwright tests for the board games app: one spec per game, driving two real browser windows through a whole table — landing screen, room, join by invite link, deal, and a turn or two. -This folder is **standalone**. It has its own `package.json`, its own -`node_modules` and its own `tsconfig.json`, and nothing in the game project -refers to it. The app's `package.json`, `vite.config.ts` and `tsconfig.json` are -untouched; delete this folder and the app is exactly as it was. +Only the specs live here. `playwright.config.ts`, the `@playwright/test` +dependency and the `tsconfig.json` that types these files are all at the +repository root, alongside the app's. ## Running +Everything runs from the repository root: + ```sh -cd e2e bun install -bunx playwright install chromium # once; ~150MB, cached in your user profile -bun run test +bunx playwright install chromium webkit # once; cached in your user profile +bun run test:e2e ``` -npm works just as well if you prefer it: `npm install`, `npx playwright install -chromium`, `npm test`. - -`playwright test` starts the app itself (`bun run dev` in the parent folder, -which is Vite on 5173 plus the game server on 8787) and shuts it down -afterwards. If a dev server is already up it is reused. +`playwright test` starts the app itself (`bun run dev`, which is Vite on 5173 +plus the game server on 8787) and shuts it down afterwards. If a dev server is +already up it is reused. To test something that is already running — a built server, a deployment — point at it and no server is started: ```sh -E2E_BASE_URL=http://localhost:8787 bun run test +E2E_BASE_URL=http://localhost:8787 bun run test:e2e ``` ## What runs where @@ -44,10 +41,10 @@ Four viewports, all Chromium, so one browser download covers the suite: | `desktop` | 1440 × 900 | The full three-column tables | ```sh -bun run test -- --project=mobile # one shape -bun run test -- tests/monopoly.e2e.ts # one game -bun run test -- --headed --project=desktop -bun run report # the HTML report from the last run +bun run test:e2e --project=mobile # one shape +bun run test:e2e e2e/tests/monopoly.e2e.ts # one game +bun run test:e2e --headed --project=desktop +bun run test:e2e:report # the HTML report from the last run ``` Tests marked mobile-only skip themselves on the desktop project, so a full run @@ -66,6 +63,9 @@ is honest about what it covered rather than quietly passing. | `tests/snake.e2e.ts` | Snake | One arena, live on both screens | | `tests/ladders.e2e.ts` | Snakes & Ladders | Rolls the die and passes the turn | +CI runs the same suite split four ways (`--shard`), one runner each — see +`.github/workflows/docker-publish.yml`. + `support/table.ts` holds what every game does the same way: host, join, start, and the two checks every screen owes a phone (no sideways scroll, no button too small to hit). diff --git a/e2e/bun.lock b/e2e/bun.lock deleted file mode 100644 index 74a2b0a..0000000 --- a/e2e/bun.lock +++ /dev/null @@ -1,21 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "board-games-e2e", - "devDependencies": { - "@playwright/test": "^1.49.0", - }, - }, - }, - "packages": { - "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], - - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - - "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], - - "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], - } -} diff --git a/e2e/package.json b/e2e/package.json deleted file mode 100644 index e7666bf..0000000 --- a/e2e/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "board-games-e2e", - "version": "1.0.0", - "private": true, - "description": "Browser tests for the board games app. Standalone: install and run from this folder.", - "type": "module", - "scripts": { - "test": "playwright test", - "test:desktop": "playwright test --project=desktop", - "test:mobile": "playwright test --project=mobile", - "report": "playwright show-report", - "codegen": "playwright codegen http://localhost:5173" - }, - "devDependencies": { - "@playwright/test": "^1.49.0" - } -} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json deleted file mode 100644 index e5f8b76..0000000 --- a/e2e/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM"], - "strict": true, - "noEmit": true, - "types": ["node"], - "skipLibCheck": true - }, - "include": ["**/*.ts"] -} diff --git a/package.json b/package.json index 7ffd1fd..e60ce86 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "preview": "bun run build && bun run start", "typecheck": "vue-tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:report": "playwright show-report" }, "dependencies": { "cannon-es": "^0.20.0", @@ -25,6 +27,7 @@ "ws": "^8.18.0" }, "devDependencies": { + "@playwright/test": "^1.49.0", "@types/node": "^22.10.2", "@types/pg": "^8.20.0", "@types/three": "^0.185.4", diff --git a/e2e/playwright.config.ts b/playwright.config.ts similarity index 94% rename from e2e/playwright.config.ts rename to playwright.config.ts index 7517ce0..5eef922 100644 --- a/e2e/playwright.config.ts +++ b/playwright.config.ts @@ -1,11 +1,7 @@ import { defineConfig, devices } from '@playwright/test' /** - * Browser tests for the board games app. - * - * Standalone on purpose: this folder has its own manifest and its own - * `node_modules`, and nothing in the game project references it. Delete the - * folder and the app is exactly as it was. + * Browser tests for the board games app, in `e2e/`. * * The specs are named `*.e2e.ts` rather than `*.spec.ts` for one specific * reason: the app runs Vitest from the repository root, and Vitest's default @@ -19,7 +15,7 @@ import { defineConfig, devices } from '@playwright/test' const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173' export default defineConfig({ - testDir: './tests', + testDir: './e2e/tests', testMatch: '**/*.e2e.ts', /* A table is two browser contexts talking over a websocket, so a test is @@ -133,7 +129,6 @@ export default defineConfig({ ? undefined : { command: 'bun run dev', - cwd: '..', url: BASE_URL, reuseExistingServer: true, timeout: 120_000, diff --git a/tsconfig.json b/tsconfig.json index 5a0af4b..f329246 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "esModuleInterop": true, "useDefineForClassFields": true, "noEmit": true, - "types": ["vite/client", "vitest/globals"], + "types": ["node", "vite/client", "vitest/globals"], "baseUrl": ".", "paths": { "@/*": ["./src/*"], @@ -32,6 +32,8 @@ "shared/**/*.ts", "server/**/*.ts", "tests/**/*.ts", - "vite.config.ts" + "e2e/**/*.ts", + "vite.config.ts", + "playwright.config.ts" ] } From 4d24934e4dee919a4422de5ee524a2700e6fe9b5 Mon Sep 17 00:00:00 2001 From: necessarylion Date: Tue, 1 Sep 2026 07:53:48 +0700 Subject: [PATCH 2/4] Run the browser tests in the image that already has the browsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `playwright install --with-deps` downloaded Chromium, WebKit and a pile of system libraries on every one of the four shards. Microsoft publishes an image with all of it baked in, so the job just runs the tests. The image holds the browser builds for exactly one Playwright version, so `@playwright/test` is pinned rather than ranged — a lockfile bump that moved it would otherwise leave the container carrying browsers the runner refuses. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/docker-publish.yml | 6 +- README.md | 218 ++++++++++++++++----------- bun.lock | 2 +- e2e/README.md | 7 +- package.json | 2 +- src/i18n/en/samurai.ts | 2 +- src/i18n/my/samurai.ts | 2 +- 7 files changed, 145 insertions(+), 94 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ec59ce1..db09def 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -48,6 +48,10 @@ jobs: name: Browser tests (shard ${{ matrix.shard }}/4) if: github.event_name == 'pull_request' runs-on: ubuntu-latest + # Ships the browsers and their system libraries, so there is nothing to + # download. The tag has to match the pinned `@playwright/test` exactly — + # the image holds the browser builds for that one version. + container: mcr.microsoft.com/playwright:v1.62.1-noble strategy: fail-fast: false matrix: @@ -58,8 +62,6 @@ jobs: with: bun-version: latest - run: bun install --frozen-lockfile - # Chromium covers every viewport project; WebKit is only for mobile-safari. - - run: bunx playwright install --with-deps chromium webkit # Playwright starts the app itself (`bun run dev`: Vite plus the game server). - run: bunx playwright test --shard=${{ matrix.shard }}/4 diff --git a/README.md b/README.md index de2e283..2a6a2c8 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,44 @@ -# Samurai - -An unofficial web implementation of the board game **Samurai**, designed by -Reiner Knizia. Two to four players, each in their own browser, kept in sync over -WebSockets. - -> **Not affiliated with, endorsed by, or licensed by Reiner Knizia or the game's -> publishers.** This is a fan project. It implements the game's rules, which are -> not copyrightable, using entirely original code, artwork and board layout. It -> ships no rulebook, no scanned components and no publisher artwork. If you enjoy -> the game, buy a physical copy — it is worth owning. - -Vue 3 + TypeScript + Vite on the client, a small Bun WebSocket server holding -the authoritative game state. - -![The table mid-game: the hex board, the player panel and the play log](assets/preview2.png) - -*The table. The board pans and zooms, the sidebar tracks the caste counts and -every player's hand size, and your own tiles sit along the bottom.* +# Board Games + +Eight board and card games in the browser, played with friends over a +four-character room code. One Vue 3 + TypeScript client, one small Bun +WebSocket server holding the authoritative state, and a `shared/` rules layer +imported unchanged by both sides. + +| Game | Players | What it is | +| --- | --- | --- | +| **Samurai** | 2–6 | Place influence across feudal Japan and lead the most castes | +| **Halli Galli** | 2–8 | Flip cards and race to slap the bell the instant five fruit show | +| **Coup** | 2–8 | Claim whatever character suits you and hope nobody calls the bluff | +| **Carnivals** | 2–8 | Bet on a hand you can only half see — your own red, everyone else's blue | +| **COP** | 2–8 | Hide behind a door with your loot; the Cop opens two | +| **Snake** | 2–8 | The arcade classic, head to head, last one slithering wins | +| **Snakes & Ladders** | 2–8 | Roll, climb the ladders, dodge the snakes, reach 100 | +| **Monopoly** | 2–8 | Buy up the tech giants, build them out, and charge rent until everyone is broke | + +The games share the room, seat, reconnection, redaction and persistence +machinery and nothing else — no common engine, no rules in common. Each has its +own engine in `shared/`, its own store slice and its own components. + +> **Not affiliated with, endorsed by, or licensed by any game's designer or +> publisher.** This is a fan project. It implements rules, which are not +> copyrightable, using original code and artwork. It ships no rulebook, no +> scanned components and no publisher artwork. If you enjoy any of these games, +> buy a physical copy. + +![The Samurai table mid-game: the hex board, the player panel and the play log](assets/preview2.png) + +*Samurai's table. The board pans and zooms, the sidebar tracks the caste counts +and every player's hand size, and your own tiles sit along the bottom.* ![The draft screen: what each tile does, beside the twenty tiles to choose from](assets/preview1.jpeg) -*The opening draft. Everyone picks five of their twenty tiles at the same time, -with a reference for what each tile does alongside.* +*Samurai's opening draft. Everyone picks five of their twenty tiles at the same +time, with a reference for what each tile does alongside.* + +Every table is laid out for a phone as well as a desktop: the side panels +become sheets, the prompts move under the board, and touch targets are sized +for a thumb. ## Running it @@ -31,8 +48,8 @@ bun run dev ``` That starts both processes: the game server on `:8787` and Vite on `:5173`. Open -, create a room, and share the four-character code (or the -invite link) with the other players. +, pick a game, create a room, and share the four-character +code (or the invite link) with the other players. Vite binds to every interface, so players on the same network can join at `http://:5173`. @@ -89,69 +106,58 @@ tables. | `bun run dev` | Server + client with hot reload | | `bun run build` | Typecheck and bundle the client | | `bun start` | Serve the built client and the game server together | -| `bun run test` | Full suite — rules, engine, board, rendering, and a live end-to-end game over WebSockets | +| `bun run test` | Full suite — every game's engine and room, rendering, and a live end-to-end game over WebSockets | | `bun run typecheck` | `vue-tsc` over client, server and shared code | +| `cd e2e && bun run test` | Playwright, one spec per game, two real browsers per table | + +`e2e/` is a standalone package with its own `package.json` and +`node_modules` — see [e2e/README.md](e2e/README.md). There is also a visual harness at , which renders the -table against a locally simulated game so the board can be inspected without -opening four browsers. `&shape=circle` picks a map, and `&zoom=4&at=0.45,0.55` -additionally scrolls the board in, for checking the zoomed view. It is dev-only and is not part of the production -bundle. +Samurai table against a locally simulated game so the board can be inspected +without opening four browsers. `&shape=circle` picks a map, and +`&zoom=4&at=0.45,0.55` additionally scrolls the board in, for checking the +zoomed view. It is dev-only and is not part of the production bundle. ## How it is put together ``` -shared/ game rules, board, tiles, scoring, wire protocol — used by both sides +shared/ one engine per game, plus board, tiles, scoring, wire protocol — used by both sides server/ WebSocket server: rooms, seats, reconnection, redaction, persistence -src/ Vue client: board rendering, hand, lobby, draft +src/ Vue client: one component folder and store slice per game, plus the shared shell tests/ unit, render and end-to-end tests +e2e/ Playwright browser suite, standalone ``` -The rules live in `shared/` and are imported unchanged by both the server and the -browser. The server owns the only real game state (`shared/engine.ts`) and -validates every action, so a tampered client cannot cheat. The client uses the -same rule functions purely to decide what to highlight. - -### Hidden information - -Each client receives its own redacted view of the game. A player's draw stack -never leaves the server, opponents' hands are sent only as counts, and captured -pieces stay hidden until the game ends — unless the table is created with *open -information*. - -Tile definitions are recoverable from their ids (`p2-t7` is player 2's eighth -tile), so the protocol only ever sends ids. That keeps messages small and means -sending a hand can never accidentally leak one. - -### Moving around the board +Rules live in `shared/` and are imported unchanged by both the server and the +browser. The server owns the only real game state and validates every action, so +a tampered client cannot cheat. The client uses the same rule functions purely to +decide what to highlight and enable. -The board pans and zooms, which matters most on a phone where the four-player -map would otherwise be a grid of tiny hexes. Scroll or pinch to zoom, drag to -pan, and use the buttons in the corner (`+`, `−`, `Fit`) to reach the same thing. +Adding a game is a fixed list of files rather than an interface to implement — +`CLAUDE.md` has the checklist. -It works by driving the SVG's `viewBox`, and the view box is always given the -container's aspect ratio so it never letterboxes — which is what makes zooming -anchor exactly on the cursor or the pinch midpoint rather than drifting. A drag -that ends over a hex is swallowed in the capture phase, so panning across the -board never places a tile by accident. +### Hidden information -### Ending and restarting +Each client receives its own redacted view, built in one place +(`Room.stateFor`). What that means per game: a Samurai player's draw stack never +leaves the server, opponents' hands travel as counts and captured pieces stay +hidden until the game ends; Coup keeps the court deck off the wire entirely and +sends held influence as a count; Carnivals shows you your own red card and +everyone else's blue, never the other halves; Monopoly hides almost nothing — +cash and ownership are public — beyond a trade's terms and each seat's own list +of legal moves. -The **Table** menu in the top bar is available at any point during a game. The -host can *End game*, which throws away the board and puts everyone back in the -lobby with their seats intact, ready to change the settings and deal again. Any -player can *Leave table* and go back to the start screen. Both ask for a -confirming second click, since neither can be undone. +Where the client cannot derive its options from a redacted view — a Coup +challenge window, a Monopoly build or trade — the server computes that seat's +affordances and sends them ready-made. -At the end of a game the host also gets *Play again*, which deals a fresh game to -the same players straight away. +### Opening seat -Players who are away when a new game is dealt lose their seat, because seat -numbers index into the game and can only be renumbered between games. If they -come back while the room is still in the lobby, their browser quietly claims a -free seat again. The host role also moves to someone still present whenever the -host drops, so a room can never be left with nobody able to restart it. +No game gives the first turn to seat 0. `shared/opening.ts` draws the opening +seat for every game that has one, either silently or through a roll-off the +whole table watches, depending on the room's settings. ### Reconnection @@ -177,10 +183,42 @@ expired room, or a memory-only server that restarted — it is told so and returned to the start screen, rather than left looking at a board that no longer exists. -## Notes on the rules +### Ending and restarting + +The **Table** menu in the top bar is available at any point during a game. The +host can *End game*, which throws away the board and puts everyone back in the +lobby with their seats intact, ready to change the settings and deal again. Any +player can *Leave table* and go back to the start screen. Both ask for a +confirming second click, since neither can be undone. + +At the end of a game the host also gets *Play again*, which deals a fresh game to +the same players straight away. + +Players who are away when a new game is dealt lose their seat, because seat +numbers index into the game and can only be renumbered between games. If they +come back while the room is still in the lobby, their browser quietly claims a +free seat again. The host role also moves to someone still present whenever the +host drops, so a room can never be left with nobody able to restart it. + +## Samurai + +The oldest game here and the most involved, so a few notes. + +**Moving around the board.** The board pans and zooms, which matters most on a +phone where the six-player map would otherwise be a grid of tiny hexes. Scroll or +pinch to zoom, drag to pan, and use the buttons in the corner (`+`, `−`, `Fit`) +to reach the same thing. It works by driving the SVG's `viewBox`, always given +the container's aspect ratio so it never letterboxes — which is what makes +zooming anchor exactly on the cursor or the pinch midpoint rather than drifting. +A drag that ends over a hex is swallowed in the capture phase, so panning never +places a tile by accident. -Two details are worth recording, because the rulebook constrains them without -spelling them out: +**Tile ids encode their definitions** (`p2-t7` is player 2's eighth tile), so the +protocol only ever sends ids. That keeps messages small and means sending a hand +can never accidentally leak one. + +Two rules details are worth recording, because the rulebook constrains them +without spelling them out: - **Tile distribution.** The rulebook fixes the totals — 20 tiles per player, exactly five bearing the fast icon, one switch tile and one move tile — but @@ -189,29 +227,37 @@ spelling them out: ships, switch, move). Change that one array if your printing differs. - **The board.** The map in `shared/board.ts` is an original layout, not a copy - of the printed board. It is built to the same structural rules: an - island chain of sea, land and settlement hexes whose capacity matches the - supply exactly at every player count (21 / 30 / 39 pieces), with the smaller - boards nested inside the larger ones the way the physical board's map pieces - nest. The board is authored as text, so editing those rows is all it takes to - swap in a different map — `tests/board.test.ts` will verify it still adds up. + of the printed board. It is built to the same structural rules: an island chain + of sea, land and settlement hexes whose capacity matches the supply exactly at + every player count (21 / 30 / 39 / 48 / 57 pieces), with the smaller boards + nested inside the larger ones the way the physical board's map pieces nest. The + board is authored as text, so editing those rows is all it takes to swap in a + different map — `tests/board.test.ts` will verify it still adds up. Capture order is the other place the rulebook leaves a choice: it lets the active player pick the order in which surrounded settlements resolve. Because captures never remove tiles, every order produces identical influence totals, so the engine resolves in board order and the choice is not surfaced. +Five and six players are an extension, not a port — the published game stops at +four, and the outlying islands, the supply formula and the raised set-aside +ending are all choices made here. None of them changes how two, three or four +players play. + ## Licence The code and artwork in this repository are MIT licensed — see [LICENSE](LICENSE). -That covers this implementation only. *Samurai* was designed by Reiner Knizia, -and the game's name, rulebook, published board and component art belong to their -respective rights holders. None of those are included here: the artwork in -`assets/` is our own, `src/game/icons.ts` also carries an SVG silhouette for -every icon, and the board in `shared/board.ts` is an original map. Game rules and -mechanics are not subject to copyright, which is what makes an independent -implementation possible. +That covers this implementation only. The games' names, rulebooks, published +boards and component art belong to their respective rights holders, and none of +those are included here: the artwork in `assets/` is our own, +`src/game/icons.ts` carries an SVG silhouette for every icon, and the maps and +boards in `shared/` are original layouts. Game rules and mechanics are not +subject to copyright, which is what makes an independent implementation possible. + +The one exception is `src/game/companies.ts`: Monopoly's properties are +technology companies, and the logos drawn there are hand-made approximations of +real marks, which remain trademarks of their owners. -If you hold rights to *Samurai* and want something here changed, open an issue -and I will act on it. +If you hold rights to any of these games and want something here changed, open an +issue and I will act on it. diff --git a/bun.lock b/bun.lock index 3303753..07ae040 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ "ws": "^8.18.0", }, "devDependencies": { - "@playwright/test": "^1.49.0", + "@playwright/test": "1.62.1", "@types/node": "^22.10.2", "@types/pg": "^8.20.0", "@types/three": "^0.185.4", diff --git a/e2e/README.md b/e2e/README.md index 4cffce0..25243db 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -63,8 +63,11 @@ is honest about what it covered rather than quietly passing. | `tests/snake.e2e.ts` | Snake | One arena, live on both screens | | `tests/ladders.e2e.ts` | Snakes & Ladders | Rolls the die and passes the turn | -CI runs the same suite split four ways (`--shard`), one runner each — see -`.github/workflows/docker-publish.yml`. +CI runs the same suite split four ways (`--shard`), one runner each, in +Microsoft's `mcr.microsoft.com/playwright` image so no browser is downloaded — +see `.github/workflows/docker-publish.yml`. That image carries the browsers for +one Playwright version, which is why `@playwright/test` is pinned rather than +ranged: bump the two together. `support/table.ts` holds what every game does the same way: host, join, start, and the two checks every screen owes a phone (no sideways scroll, no button too diff --git a/package.json b/package.json index e60ce86..5d922e2 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "ws": "^8.18.0" }, "devDependencies": { - "@playwright/test": "^1.49.0", + "@playwright/test": "1.62.1", "@types/node": "^22.10.2", "@types/pg": "^8.20.0", "@types/three": "^0.185.4", diff --git a/src/i18n/en/samurai.ts b/src/i18n/en/samurai.ts index dc0341d..b54ba43 100644 --- a/src/i18n/en/samurai.ts +++ b/src/i18n/en/samurai.ts @@ -307,7 +307,7 @@ export const enSamurai = { // --- landing (choose a game) -------------------------------------------- 'landing.title': 'Game Table', - 'landing.tagline': 'Two board games, one table. Pick one to host or join a room with friends.', + 'landing.tagline': 'Eight board games, one table. Pick one to host or join a room with friends.', 'landing.play': 'Play', 'landing.samurai.name': 'Samurai', 'landing.samurai.blurb': 'Place your influence across feudal Japan and lead the most castes.', diff --git a/src/i18n/my/samurai.ts b/src/i18n/my/samurai.ts index d11f813..10fe832 100644 --- a/src/i18n/my/samurai.ts +++ b/src/i18n/my/samurai.ts @@ -306,7 +306,7 @@ export const mySamurai = { // --- landing (choose a game) -------------------------------------------- 'landing.title': 'ဂိမ်းဝိုင်း', - 'landing.tagline': 'ဘုတ်ဂိမ်းနှစ်မျိုး၊ ဝိုင်းတစ်ခု။ တစ်ခုကိုရွေးပြီး သူငယ်ချင်းများနှင့် အခန်းဖွင့်ပါ သို့မဟုတ် ဝင်ပါ။', + 'landing.tagline': 'ဘုတ်ဂိမ်းရှစ်မျိုး၊ ဝိုင်းတစ်ခု။ တစ်ခုကိုရွေးပြီး သူငယ်ချင်းများနှင့် အခန်းဖွင့်ပါ သို့မဟုတ် ဝင်ပါ။', 'landing.play': 'ကစားရန်', 'landing.samurai.name': 'ဆာမူရိုင်း', 'landing.samurai.blurb': 'ရှေးဂျပန်တစ်ဝှမ်း သြဇာချထားပြီး အများဆုံး လူတန်းစားများကို ဦးဆောင်ပါ။', From 6cbd1f130380ec223772c92e71b454808310fa3e Mon Sep 17 00:00:00 2001 From: necessarylion Date: Tue, 1 Sep 2026 07:54:46 +0700 Subject: [PATCH 3/4] Split the workflow in two: one tests, one ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file called `docker-publish.yml` holding the whole test suite behind an `if: github.event_name == 'pull_request'` on every job was two workflows sharing a name. They are now `test.yml`, which runs on pull requests, and `docker-publish.yml`, which builds the image and calls Coolify on a push to main or a tag. Neither needs an `if:` any more — the triggers say it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/docker-publish.yml | 67 ++-------------------------- .github/workflows/test.yml | 65 +++++++++++++++++++++++++++ CLAUDE.md | 2 +- e2e/README.md | 2 +- 4 files changed, 70 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index db09def..aebc1e3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,12 +1,12 @@ name: Publish Docker image +# Builds the image and tells Coolify to pull it. The suite lives in +# `test.yml` and gates the pull request, so a commit that reaches main has +# already passed it. on: push: branches: [main] tags: ['v*'] - # Pull requests are verified; pushes to main are built and deployed. - pull_request: - branches: [main] workflow_dispatch: env: @@ -14,69 +14,8 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: - typecheck: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - run: bun run typecheck - - test: - name: Test (shard ${{ matrix.shard }}/4) - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - # One shard holds the end-to-end suite, which starts the real server on - # port 8899 and plays a full game between two WebSocket clients. Shards - # get a runner each, so that fixed port is never contended. - - run: bunx vitest run --shard=${{ matrix.shard }}/4 - - e2e: - name: Browser tests (shard ${{ matrix.shard }}/4) - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - # Ships the browsers and their system libraries, so there is nothing to - # download. The tag has to match the pinned `@playwright/test` exactly — - # the image holds the browser builds for that one version. - container: mcr.microsoft.com/playwright:v1.62.1-noble - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - # Playwright starts the app itself (`bun run dev`: Vite plus the game server). - - run: bunx playwright test --shard=${{ matrix.shard }}/4 - - - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-report-${{ matrix.shard }} - path: playwright-report/ - retention-days: 7 - publish: name: Build and push - # The suite gates the pull request; by the time a commit reaches main it has - # already run, so a push only builds and deploys. - if: github.event_name != 'pull_request' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..df778fd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,65 @@ +name: Tests + +# Everything that says whether a change is sound. A pull request is where it +# runs; by the time a commit reaches main it has already passed, and the push +# only builds and deploys (`docker-publish.yml`). +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - run: bun run typecheck + + test: + name: Test (shard ${{ matrix.shard }}/4) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + # One shard holds the end-to-end suite, which starts the real server on + # port 8899 and plays a full game between two WebSocket clients. Shards + # get a runner each, so that fixed port is never contended. + - run: bunx vitest run --shard=${{ matrix.shard }}/4 + + e2e: + name: Browser tests (shard ${{ matrix.shard }}/4) + runs-on: ubuntu-latest + # Ships the browsers and their system libraries, so there is nothing to + # download. The tag has to match the pinned `@playwright/test` exactly — + # the image holds the browser builds for that one version. + container: mcr.microsoft.com/playwright:v1.62.1-noble + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + # Playwright starts the app itself (`bun run dev`: Vite plus the game server). + - run: bunx playwright test --shard=${{ matrix.shard }}/4 + + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ matrix.shard }} + path: playwright-report/ + retention-days: 7 diff --git a/CLAUDE.md b/CLAUDE.md index 09bc4c0..8ca1f9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,4 +170,4 @@ primary buttons were 38px on a touch screen rather than 44. ## CI and deployment -`.github/workflows/docker-publish.yml` runs `bun install --frozen-lockfile`, `bun run typecheck` and `bun run test` on every push and PR to `main`, and publishes a multi-arch image to GHCR only after the suite passes (never on PRs). The runtime image copies just `dist/` and the self-contained `dist-server/index.js`, with no `node_modules`. `PORT`, `HOST` and `STATIC_DIR` are the server's env vars; `/healthz` returns `{ok, rooms}`. +Two workflows, split by what they are for. `.github/workflows/test.yml` runs on pull requests to `main`: `bun run typecheck`, then vitest and Playwright, each split four ways with `--shard` across a matrix of runners — which is also what keeps `tests/integration.test.ts` off a contended port 8899. The browser shards run in `mcr.microsoft.com/playwright`, whose tag must match the pinned `@playwright/test`. `.github/workflows/docker-publish.yml` runs on a push to `main` or a `v*` tag and does no testing: it publishes a multi-arch image to GHCR and pokes Coolify's webhook. The pull request is the gate, so a commit pushed straight to `main` ships untested. The runtime image copies just `dist/` and the self-contained `dist-server/index.js`, with no `node_modules`. `PORT`, `HOST` and `STATIC_DIR` are the server's env vars; `/healthz` returns `{ok, rooms}`. diff --git a/e2e/README.md b/e2e/README.md index 25243db..49778cf 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -65,7 +65,7 @@ is honest about what it covered rather than quietly passing. CI runs the same suite split four ways (`--shard`), one runner each, in Microsoft's `mcr.microsoft.com/playwright` image so no browser is downloaded — -see `.github/workflows/docker-publish.yml`. That image carries the browsers for +see `.github/workflows/test.yml`. That image carries the browsers for one Playwright version, which is why `@playwright/test` is pinned rather than ranged: bump the two together. From 5f56fe8fe67d8b06bb6ff06e5e0f336993a4724a Mon Sep 17 00:00:00 2001 From: necessarylion Date: Tue, 1 Sep 2026 07:56:09 +0700 Subject: [PATCH 4/4] Give the Playwright container the unzip that setup-bun needs The image ships Node, not Bun, and no unzip for setup-bun to unpack Bun with. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df778fd..c513b5b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,6 +50,8 @@ jobs: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 + # The image is built for Node, and `setup-bun` unpacks a zip. + - run: apt-get update && apt-get install -y --no-install-recommends unzip - uses: oven-sh/setup-bun@v2 with: bun-version: latest