try to fix this spaghetti of a code

This commit is contained in:
Frederik Spirgi
2026-09-09 10:55:40 +02:00
parent 2194ea4f0d
commit 101116472a
26 changed files with 1938 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
+29
View File
@@ -0,0 +1,29 @@
name: validation
on:
push:
pull_request:
schedule:
- cron: '0 8 * * 1'
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
- run: npm audit --audit-level=high
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: browser-test-results
path: test-results/
+38
View File
@@ -0,0 +1,38 @@
# Architecture and operations
`createApplication(options)` constructs an isolated application; importing `gambling.js` starts no listener or timers. Configuration, repository, clock, deck generation, bcrypt cost, and timing limits are injectable for deterministic tests.
| Module | Responsibility |
| --- | --- |
| `lib/repository.js` | SQLite schema migration, transactions, ledger, idempotency, interrupted-hand refunds |
| `lib/database-lock.js` | Enforce one process owner per database, recover locks after a crashed PID |
| `lib/sessions.js` | Password/session verification, hashed session storage, rotation/revocation events |
| `lib/lobbies.js` | Membership, command queues, staged state, timer scheduling, post-commit broadcasts |
| `lib/engine.js` | Synchronous poker transitions with injected clock and deck |
| `lib/poker.js` | Hand evaluation, shuffling, contribution-layer payouts |
| `lib/http.js` / `lib/transport.js` | HTTP / WebSocket validation, limits, subscription, personalized output |
| `public/protocol.js` / `public/client.js` | Shared request contracts and resilient browser connection |
Commands lock their lobby and account; membership mutations also share a membership queue. Session replacement and authorized actions share a session/account queue. SQLite operations share a database queue, so reads cannot see partial multi-statement transactions. Each command clones state, validates and calculates its complete result, and checks that account deltas plus the escrow delta sum to zero. A transaction persists all balance deltas, the ledger, lobby snapshot, and idempotency record. Only then is memory replaced and state broadcast.
Timers have hand IDs, deadlines, and lobby revisions. Stale timers are no-ops. Failed timer transactions retry with the same identity and the original unchanged state. There are no detached settlement promises. The engine can settle and move into intermission within the same atomic action transaction.
SQLite uses WAL and synchronous=FULL. The startup recovery transaction refunds the contributions of all persisted active hands and clears lobbies. Deleting those snapshots in the same transaction makes recovery repeatable without duplicate refunds. The server deliberately supports **one process per database**, not clustered workers; a transactionally claimed SQLite ownership row enforces this. Ownership held by a dead PID is reclaimed atomically. If an OS PID has been reused by another live process, startup fails closed; verify no server owns the file before clearing the runtime_owner row manually. Keep the database on a local filesystem.
## Upgrades and migration
Stop the application before upgrading or importing. Back up the database and any legacy JSON outside the checkout. On schema version 0 upgrades, SQLite creates an additional `*.backup` snapshot before rebuilding the user table. The transactional migration preserves usernames/password hashes/balances, rejects invalid balances, discards raw stored sessions, and advances `PRAGMA user_version` to 1. Users sign in again. Databases from newer versions are rejected.
Legacy JSON is never imported automatically. Run `npm run migrate:legacy -- path/to/data.json` while the server is stopped. The importer validates all records first, preserves the source, creates a content-verified backup, then imports every row and records the source hash in one transaction. Username conflicts fail the entire import. Repeating a successful identical file is a no-op. Legacy tokens/lobby references are not imported. Protect backups because legacy JSON may contain plaintext passwords.
Run only the migration command against the target database; development and tests use separate databases. Test suites use in-memory or temporary files and do not touch the application database or `data.json`.
## Deployment and maintenance
Set `PUBLIC_ORIGIN` to the exact HTTPS origin without a trailing slash when behind a reverse proxy. Set `TRUST_PROXY` only to the IPs/subnets of proxies you control; it defaults to false. Do not trust arbitrary forwarded headers. Bind `HOST` as appropriate (the local default is 127.0.0.1). Set a randomly generated `TOKEN_SECRET` of at least 32 characters. Changing it invalidates sessions.
CI runs syntax/contract checks, unit/integration tests, Chromium end-to-end tests, and npm audit. Scheduled CI and Dependabot check dependencies weekly; review lockfile diffs and run the full suite before merging updates. High/critical audit findings block CI. Review moderate findings rather than applying blind `--force` updates. `npm ci` uses the checked-in lockfile.
The ledger and idempotency records intentionally have no automatic deletion. Monitor file growth; any future retention policy must preserve replay safety and accounting evidence. Keep backups, and test restoring them. This is virtual-chip software with an explicit simplified ruleset, not a real-money payment or regulatory platform.
Testing references: [Playwright server setup](https://playwright.dev/docs/test-webserver), [isolated browser contexts](https://playwright.dev/docs/browser-contexts), and [npm audit](https://docs.npmjs.com/cli/v11/commands/npm-audit/).
+42
View File
@@ -0,0 +1,42 @@
# HTTP and WebSocket protocol
`swagger.json` describes HTTP request and response bodies. Run `npm run docs` to regenerate it from the same command schemas used by both transports. `npm run check` fails when generated contracts are stale. The running documentation is available at `/swagger-ui`.
`docs/websocket.schema.json` is JSON Schema draft 2020-12: its root validates incoming messages; `$defs` describes every outgoing message. Runtime request validation lives in the dependency-free `public/protocol.js`, shared by server and browser.
## Authentication
Register/login with JSON `{ "username": "alice", "password": "..." }`. The response contains `{username, token}` and sets a 30-minute HttpOnly/SameSite=Strict cookie. Cookies are Secure when Express recognizes the request as HTTPS under the configured trusted proxy rules. Bearer clients use `Authorization: Bearer <token>`.
WebSockets connect at `/` on the same host. Browser upgrades must send the expected Origin. Non-browser upgrades may omit Origin only with a bearer header. Wait for `auth:success` before sending commands. Expired, replaced, and logged-out sessions close immediately with code 1008. Heartbeats remove dead peers. There are 200 total connections, 10 per direct peer IP, 30 messages/second/connection, eight queued commands/connection, and 16 KiB message/body limits. These limits are injectable application configuration; proxy-backed installations must account for the direct peer IP limit.
Authentication routes allow 20 attempts per direct/trusted-proxy client IP per 15 minutes, returning HTTP 429 plus Retry-After when exhausted. WebSocket over-limit messages close with 1008; excess upgrades return HTTP 429. Slow clients exceeding 256 KiB buffered output close with 1013.
## Mutating commands
All mutations use a unique `requestId` (8-128 letters, digits, hyphens, or underscores). Use a UUID. For HTTP, POST the fields below without `type` to the corresponding route. The old GET mutations return 405.
| Type | HTTP route | Other fields |
| --- | --- | --- |
| `lobby:create` | `/lobby/create` | optional boolean `private` |
| `lobby:join` | `/lobby/join` | `lobbyId` |
| `lobby:leave` | `/lobby/leave` | `lobbyId` |
| `poker:start` | `/poker/start` | `lobbyId` |
| `poker:action` | `/poker/action` | `lobbyId`, `action` (`hit`, `fold`, `bet`), `payload: {amount}` for bets |
Lobby IDs contain 16 lowercase hexadecimal characters. Unknown fields, malformed types, fractional bets, and missing fields are rejected. Failed business-rule validation does not consume a request ID. Committed commands retain their durable idempotency records. Retry an identical command using the same request ID after an uncertain response; reusing the ID with a different command returns `IDEMPOTENCY_CONFLICT`. A committed command replay returns its original acknowledgement and never repeats its effects, including after restart. Do not reuse an ID for a new intention.
The browser retries pending commands on reconnect with the same ID, using 0.5/1/2/4/5-second connection backoff. After five failed reconnects it asks for reload. It waits up to 30 seconds for an acknowledgement. On timeout, check restored state before creating a new command.
`poker:join` is a read/subscription message `{type, lobbyId}` without a request ID. It requires existing membership and never starts a game. Reconnecting automatically subscribes to the account's current lobby and sends a fresh snapshot.
## Server events
- `auth:success`: authenticated `username`.
- `command:ack`: `requestId`, `commandType`, `lobbyId`, `username`. HTTP returns the same fields with the command name in `type` instead.
- `lobby:state`: players, host, visibility, start eligibility, gameStarted, and gameUrl.
- `lobby:left`: lobbyId. All tabs belonging to the departing account are detached.
- `poker:state`: personalized hand, community cards, queue/turn, phase/street/round, stack/contribution, pot/table bet/call amount, minimumRaise/canRaise, deadlines, and completed-hand payouts/showdown. Opponents' private cards appear only at showdown. Full field definitions are in the schema.
- `error`: stable `code`, human-readable `message`, and `requestId` when available. HTTP uses `{code, error}`.
Error codes include `INVALID_MESSAGE`, `INVALID_JSON`, `INVALID_CREDENTIALS`, `UNAUTHORIZED`, `RATE_LIMITED`, `ORIGIN_DENIED`, `USERNAME_EXISTS`, `ALREADY_SEATED`, `HAND_PENDING`, `LOBBY_NOT_FOUND`, `LOBBY_FULL`, `NOT_SEATED`, `HOST_REQUIRED`, `GAME_STARTED`, `NOT_ENOUGH_PLAYERS`, `WRONG_PHASE`, `NOT_YOUR_TURN`, `INVALID_BET`, `MINIMUM_RAISE`, `RAISE_NOT_REOPENED`, `IDEMPOTENCY_CONFLICT`, and `INTERNAL_ERROR`. HTTP status codes are declared in OpenAPI. Storage failures never publish uncommitted state.
+26
View File
@@ -0,0 +1,26 @@
# Poker rules
This application plays an **ante-based no-limit community-card variant**, not casino Texas Hold'em with blinds. All amounts are integer virtual chips. The rules are implemented in `lib/engine.js`.
- Accounts start with 200 chips. Each hand requires a 10-chip ante; players below 10 watch. Tables have at most 10 seats.
- After a 30-second intermission, eligible players receive two private cards. The dealer marker rotates one seat; the first solvent player to its left acts first on every street. There are no small/big blinds or burn cards.
- Preflop, flop (three cards), turn, and river each have a betting round. At showdown, the best five of seven cards wins. Aces may be low in A-2-3-4-5 straights. Suits do not break ties.
- **Check/call** (`hit` in the protocol) checks for free or pays the smaller of the outstanding call and the remaining stack. **Fold** gives up eligibility. **Bet** specifies chips to add now, not a target total.
- An opening bet is at least 10. A full raise adds at least the previous full raise above the current table bet. A smaller increase is allowed only if it consumes the player's entire stack.
- A short all-in requires others to match the increased bet but does not reopen raises for someone who has already acted. Cumulative short raises reopen action once the increase faced since that player's last action reaches the previous full-raise size.
- A lone solvent player may call outstanding chips but may not raise into opponents who are all-in. If nobody can act, remaining board cards run out immediately.
- After 30 seconds, a turn checks when free and folds when facing a bet. Timeout never commits additional chips.
## Pots and leaving
Each distinct contribution level creates a pot. Folded players' chips contribute but their hands cannot win. Eligible tied winners split each layer. Odd chips go to eligible winners in seat order starting left of the dealer. Unmatched chips return to their contributor.
If no live hand is eligible for a contribution layer, that layer is refunded to its contributors. This explicit house rule also handles abandoned/dead hands and forfeited high contribution layers; it avoids destroying chips.
Leaving immediately folds, removes lobby membership, and keeps committed contributions in the hand. The account cannot join another table or rejoin the same hand until that hand settles. This prevents spending the same balance while an old table still holds a claim. Uncommitted chips remain in the account. A player joining an already-running hand spectates until the next deal.
A disconnect has a 10-second grace period. Reconnecting restores the same hand; if the last connection stays absent, the server leaves/folds the account through the same command queue.
## Restart policy
Committed bets are held in durable escrow; `/balance` and leaderboard values exclude that escrow. On restart, interrupted hands are cancelled, contributions are refunded exactly once, and lobbies are cleared. Completed payouts remain paid. The server does not attempt to resume partially played hands after a restart.
+654
View File
@@ -0,0 +1,654 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "WebSocket protocol",
"oneOf": [
{
"type": "object",
"required": [
"type",
"requestId"
],
"properties": {
"type": {
"const": "lobby:create"
},
"requestId": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{8,128}$"
},
"private": {
"type": "boolean"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"type",
"lobbyId",
"requestId"
],
"properties": {
"type": {
"const": "lobby:join"
},
"lobbyId": {
"type": "string",
"pattern": "^[a-f0-9]{16}$"
},
"requestId": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{8,128}$"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"type",
"lobbyId",
"requestId"
],
"properties": {
"type": {
"const": "lobby:leave"
},
"lobbyId": {
"type": "string",
"pattern": "^[a-f0-9]{16}$"
},
"requestId": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{8,128}$"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"type",
"lobbyId",
"requestId"
],
"properties": {
"type": {
"const": "poker:start"
},
"lobbyId": {
"type": "string",
"pattern": "^[a-f0-9]{16}$"
},
"requestId": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{8,128}$"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"type",
"lobbyId"
],
"properties": {
"type": {
"const": "poker:join"
},
"lobbyId": {
"type": "string",
"pattern": "^[a-f0-9]{16}$"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"type",
"lobbyId",
"requestId",
"action"
],
"properties": {
"type": {
"const": "poker:action"
},
"lobbyId": {
"type": "string",
"pattern": "^[a-f0-9]{16}$"
},
"requestId": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{8,128}$"
},
"action": {
"enum": [
"hit",
"fold",
"bet"
]
},
"payload": {
"type": "object",
"properties": {
"amount": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
],
"$defs": {
"auth:success": {
"type": "object",
"properties": {
"type": {
"const": "auth:success"
},
"username": {
"type": "string"
}
},
"required": [
"type",
"username"
],
"additionalProperties": false
},
"command:ack": {
"type": "object",
"properties": {
"type": {
"const": "command:ack"
},
"commandType": {
"type": "string"
},
"lobbyId": {
"type": "string"
},
"username": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"type",
"commandType",
"lobbyId",
"username",
"requestId"
],
"additionalProperties": false
},
"error": {
"type": "object",
"properties": {
"type": {
"const": "error"
},
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"type",
"code",
"message"
],
"additionalProperties": false
},
"lobby:left": {
"type": "object",
"properties": {
"type": {
"const": "lobby:left"
},
"lobbyId": {
"type": "string"
}
},
"required": [
"type",
"lobbyId"
],
"additionalProperties": false
},
"lobby:state": {
"type": "object",
"properties": {
"type": {
"const": "lobby:state"
},
"lobbyId": {
"type": "string"
},
"host": {
"type": [
"string",
"null"
]
},
"players": {
"type": "array",
"items": {
"type": "string"
}
},
"isPublic": {
"type": "boolean"
},
"canStart": {
"type": "boolean"
},
"gameStarted": {
"type": "boolean"
},
"gameUrl": {
"type": "string"
}
},
"required": [
"type",
"lobbyId",
"host",
"players",
"isPublic",
"canStart",
"gameStarted",
"gameUrl"
],
"additionalProperties": false
},
"poker:state": {
"type": "object",
"properties": {
"type": {
"const": "poker:state"
},
"lobbyId": {
"type": "string"
},
"username": {
"type": "string"
},
"players": {
"type": "array",
"items": {
"type": "string"
}
},
"round": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"pot": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"currentBet": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"bet": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"communityCards": {
"type": "array",
"items": {
"type": "object",
"properties": {
"rank": {
"enum": [
"A",
"K",
"Q",
"J",
"10",
"9",
"8",
"7",
"6",
"5",
"4",
"3",
"2"
]
},
"suit": {
"enum": [
"S",
"H",
"D",
"C"
]
}
},
"required": [
"rank",
"suit"
],
"additionalProperties": false
}
},
"hand": {
"type": "array",
"items": {
"type": "object",
"properties": {
"rank": {
"enum": [
"A",
"K",
"Q",
"J",
"10",
"9",
"8",
"7",
"6",
"5",
"4",
"3",
"2"
]
},
"suit": {
"enum": [
"S",
"H",
"D",
"C"
]
}
},
"required": [
"rank",
"suit"
],
"additionalProperties": false
}
},
"currentPlayer": {
"type": [
"string",
"null"
]
},
"isYourTurn": {
"type": "boolean"
},
"queue": {
"type": "array",
"items": {
"type": "string"
}
},
"folded": {
"type": "object",
"additionalProperties": {
"type": "boolean"
}
},
"street": {
"enum": [
"intermission",
"preflop",
"flop",
"turn",
"river"
]
},
"phase": {
"enum": [
"intermission",
"waiting",
"betting"
]
},
"lastAction": {
"type": "string"
},
"yourContribution": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"yourStack": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"isSpectator": {
"type": "boolean"
},
"callAmount": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"minimumStake": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"minimumRaise": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"canRaise": {
"type": "boolean"
},
"nextRoundStartsAt": {
"type": [
"integer",
"null"
]
},
"turnEndsAt": {
"type": [
"integer",
"null"
]
},
"winner": {
"type": [
"string",
"null"
]
},
"showdown": {
"anyOf": [
{
"type": "null"
},
{
"type": "array",
"items": {
"type": "object",
"properties": {
"player": {
"type": "string"
},
"hand": {
"type": "array",
"items": {
"type": "object",
"properties": {
"rank": {
"enum": [
"A",
"K",
"Q",
"J",
"10",
"9",
"8",
"7",
"6",
"5",
"4",
"3",
"2"
]
},
"suit": {
"enum": [
"S",
"H",
"D",
"C"
]
}
},
"required": [
"rank",
"suit"
],
"additionalProperties": false
}
},
"result": {
"type": "object",
"properties": {
"rank": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"name": {
"type": "string"
},
"score": {
"type": "array",
"items": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"comboCards": {
"type": "array",
"items": {
"type": "object",
"properties": {
"rank": {
"enum": [
"A",
"K",
"Q",
"J",
"10",
"9",
"8",
"7",
"6",
"5",
"4",
"3",
"2"
]
},
"suit": {
"enum": [
"S",
"H",
"D",
"C"
]
}
},
"required": [
"rank",
"suit"
],
"additionalProperties": false
}
}
},
"required": [
"rank",
"name",
"score",
"comboCards"
],
"additionalProperties": false
}
},
"required": [
"player",
"hand",
"result"
],
"additionalProperties": false
}
}
]
},
"payouts": {
"type": "object",
"additionalProperties": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
}
},
"required": [
"type",
"lobbyId",
"username",
"players",
"round",
"pot",
"currentBet",
"bet",
"communityCards",
"hand",
"currentPlayer",
"isYourTurn",
"queue",
"folded",
"street",
"phase",
"lastAction",
"yourContribution",
"yourStack",
"isSpectator",
"callAmount",
"minimumStake",
"minimumRaise",
"canRaise",
"nextRoundStartsAt",
"turnEndsAt",
"winner",
"showdown",
"payouts"
],
"additionalProperties": false
}
}
}
+56
View File
@@ -0,0 +1,56 @@
const {test,expect} = require('@playwright/test');
async function register(page,name) {
await page.goto('/login');
await page.getByLabel('Username',{exact:true}).fill(name);
await page.getByLabel('Password',{exact:true}).fill('test-password');
await page.getByRole('button',{name:'Register',exact:true}).click();
await expect(page).toHaveURL(/\/$/);
await expect(page.locator('#balance')).toHaveText('200');
}
test('two-player table, reload, reconnect, modal keyboard access, and leave',async({browser})=>{
const first=await browser.newContext({viewport:{width:390,height:844},reducedMotion:'reduce'});
const second=await browser.newContext({viewport:{width:390,height:844},reducedMotion:'reduce'});
const a=await first.newPage(), b=await second.newPage();
const errors=[];a.on('pageerror',error=>errors.push(error.message));b.on('pageerror',error=>errors.push(error.message));
try {
await register(a,`alice-${Date.now()}`);await register(b,`bob-${Date.now()}`);
await a.getByRole('button',{name:'Host Lobby',exact:true}).click();
await a.getByRole('button',{name:'Create Lobby',exact:true}).click();
await expect(a.locator('#lobby-id')).not.toHaveText('N/A');
const lobbyId=await a.locator('#lobby-id').textContent();
await b.getByRole('button',{name:'Join Lobby',exact:true}).click();
await b.getByLabel('Lobby code').fill(lobbyId);
await b.locator('#submit-join').click();
await expect(a.locator('#start-game')).toBeEnabled();
await a.locator('#start-game').click();
await expect(a).toHaveURL(/poker.html/);await expect(b).toHaveURL(/poker.html/);
await expect(b.locator('[data-action="bet"]')).toBeEnabled();
await b.locator('[data-action="bet"]').click();
await expect(b.locator('#bet-range')).toBeFocused();
await expect(b.locator('#range-max')).toHaveText('$190');
await b.keyboard.press('Escape');await expect(b.locator('#bet-modal')).toBeHidden();
await expect(b.locator('[data-action="bet"]')).toBeFocused();
await b.locator('[data-action="hit"]').click();
await expect(a.locator('[data-action="hit"]')).toBeEnabled();
const before=await a.locator('#player-cards').textContent();
await a.reload();await expect(a.locator('[data-action="hit"]')).toBeEnabled();
await expect(a.locator('#player-cards')).toHaveText(before);
await first.setOffline(true);await a.evaluate(()=>window.dispatchEvent(new Event('offline')));
await first.setOffline(false);
await expect(a.locator('[data-action="hit"]')).toBeEnabled({timeout:10000});
await a.locator('[data-action="hit"]').click();
await expect(a.locator('#street')).toHaveText('flop');
for(const page of [a,b]) {
expect(await page.evaluate(()=>document.documentElement.scrollWidth <= innerWidth)).toBe(true);
}
await a.screenshot({path:'test-results/mobile-table.png',fullPage:true});
await b.locator('#leave-table').click();await expect(b).toHaveURL(/\/$/);
expect(errors).toEqual([]);
} finally {await first.close();await second.close();}
});
test('login fits a narrow viewport and protected pages redirect',async({page})=>{
await page.setViewportSize({width:320,height:568});await page.goto('/poker.html');
await expect(page).toHaveURL(/\/login$/);
expect(await page.evaluate(()=>document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await expect(page.getByLabel('Username',{exact:true})).toBeVisible();
});
+6
View File
@@ -0,0 +1,6 @@
const {createApplication} = require('../gambling');
(async () => {
const app = await createApplication({secret:'browser-test-secret-with-thirty-two-characters',databasePath:':memory:',bcryptRounds:4,port:42170,intermissionMs:1200,turnMs:30000,disconnectMs:8000});
await app.listen();
for (const signal of ['SIGINT','SIGTERM']) process.once(signal,()=>app.close());
})().catch(error=>{console.error(error);process.exitCode=1;});
+21
View File
@@ -0,0 +1,21 @@
const path = require('node:path');
function configuration(overrides = {}) {
const config = {
port: Number(process.env.PORT || 42069),
host: process.env.HOST || '127.0.0.1',
databasePath: process.env.DATABASE_PATH || path.join(__dirname, '..', 'users.sqlite'),
secret: process.env.TOKEN_SECRET,
origin: process.env.PUBLIC_ORIGIN || null,
trustProxy: process.env.TRUST_PROXY ? process.env.TRUST_PROXY.split(',').map(value => value.trim()) : false,
sessionSeconds: 1800, bcryptRounds: 12, ante: 10,
intermissionMs: 30000, turnMs: 30000, disconnectMs: 10000,
heartbeatMs: 30000, maxConnections: 200, maxConnectionsPerIp: 10,
authLimit: 20, authWindowMs: 15 * 60 * 1000, messageLimit: 30,
now: Date.now, ...overrides
};
if (typeof config.secret !== 'string' || config.secret.length < 32) throw new Error('TOKEN_SECRET must contain at least 32 characters');
if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) throw new Error('Invalid PORT');
if (config.origin && new URL(config.origin).origin !== config.origin) throw new Error('PUBLIC_ORIGIN must be an origin without a trailing slash');
return config;
}
module.exports = {configuration};
+19
View File
@@ -0,0 +1,19 @@
const crypto = require('node:crypto');
class AppError extends Error {
constructor(code, message, status = 400) { super(message); this.code = code; this.status = status; }
}
class KeyedQueue {
constructor() { this.tails = new Map(); }
run(keys, task) {
keys = [...new Set(keys)].sort();
const previous = keys.map(key => this.tails.get(key) || Promise.resolve());
const result = Promise.all(previous).then(task);
const tail = result.catch(() => {});
for (const key of keys) this.tails.set(key, tail);
tail.then(() => { for (const key of keys) if (this.tails.get(key) === tail) this.tails.delete(key); });
return result;
}
async drain() { await Promise.all([...this.tails.values()]); }
}
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
module.exports = {AppError, KeyedQueue, hash};
+20
View File
@@ -0,0 +1,20 @@
const crypto = require('node:crypto');
// Claim ownership inside a SQLite transaction, including replacement of a dead PID.
// This avoids races between two processes attempting crash recovery at once.
async function acquireDatabaseLock(db) {
const token = crypto.randomUUID();
await db.exec('BEGIN IMMEDIATE');
try {
await db.exec('CREATE TABLE IF NOT EXISTS runtime_owner (id INTEGER PRIMARY KEY CHECK(id = 1), pid INTEGER NOT NULL, token TEXT NOT NULL)');
const owner = await db.get('SELECT pid FROM runtime_owner WHERE id = 1');
if (owner) {
if (!Number.isInteger(owner.pid) || owner.pid <= 0) throw new Error('Invalid database owner');
try {process.kill(owner.pid, 0); throw new Error(`Database is in use by process ${owner.pid}`);}
catch (error) {if (error.code !== 'ESRCH') throw error;}
}
await db.run('INSERT INTO runtime_owner(id,pid,token) VALUES(1,?,?) ON CONFLICT(id) DO UPDATE SET pid=excluded.pid,token=excluded.token', process.pid, token);
await db.exec('COMMIT');
} catch (error) {await db.exec('ROLLBACK'); throw error;}
return () => db.run('DELETE FROM runtime_owner WHERE id = 1 AND token = ?', token);
}
module.exports = {acquireDatabaseLock};
+143
View File
@@ -0,0 +1,143 @@
const crypto = require('node:crypto');
const {createDeck, evaluateBestHand, calculatePayouts} = require('./poker');
const {AppError} = require('./core');
const dict = () => Object.create(null);
const live = game => game.players.filter(player => game.hands[player] && !game.folded[player]);
const canAct = (game, player) => live(game).includes(player) && game.stacks[player] > 0;
const callAmount = (game, player) => Math.max(0, game.currentBet - (game.playerBets[player] || 0));
const canRaise = (game, player) => game.actedAt[player] == null || game.currentBet - game.actedAt[player] >= game.minRaise;
const turn = game => game.phase === 'betting' ? game.players[game.currentPlayerIndex] : null;
function intermission(players, balances, config, previous = null) {
return {
id: crypto.randomUUID(), players: [...players], stacks: {...balances}, hands: dict(), folded: dict(),
contributions: dict(), playerBets: dict(), actedAt: dict(), communityCards: [], deck: [],
phase: 'intermission', street: 'intermission', round: previous?.round || 0,
dealerIndex: previous?.dealerIndex ?? -1, currentPlayerIndex: -1,
pot: 0, currentBet: 0, minRaise: config.ante, turnEndsAt: null,
nextRoundStartsAt: config.now() + config.intermissionMs, lastAction: 'Waiting for the next hand.',
winner: null, showdown: null, payouts: dict(), departed: []
};
}
function deal(players, balances, config, previous) {
const game = intermission(players, balances, config, previous);
game.round++;
game.dealerIndex = (game.dealerIndex + 1) % players.length;
game.nextRoundStartsAt = null;
const funded = players.filter(player => balances[player] >= config.ante);
if (funded.length < 2) {
game.phase = 'waiting'; game.lastAction = `Waiting for two players with at least ${config.ante} chips.`;
return game;
}
game.deck = (config.createDeck || createDeck)();
game.phase = 'betting'; game.street = 'preflop';
for (const player of players) {
game.contributions[player] = 0; game.playerBets[player] = 0; game.actedAt[player] = null;
game.folded[player] = !funded.includes(player);
if (!game.folded[player]) {
game.hands[player] = [game.deck.pop(), game.deck.pop()];
pay(game, player, config.ante);
// The ante is not a street bet.
game.playerBets[player] = 0;
}
}
game.lastAction = `Round ${game.round}: each player paid ${config.ante} chips.`;
advance(game, config, game.dealerIndex);
return game;
}
function pay(game, player, amount) {
game.stacks[player] -= amount;
game.playerBets[player] += amount;
game.contributions[player] += amount;
game.pot += amount;
}
function settle(game, config) {
const active = live(game);
const evaluations = active.map(player => ({player, hand: game.hands[player], result: active.length === 1
? {score: [0], name: 'Last remaining hand', comboCards: []}
: evaluateBestHand([...game.hands[player], ...game.communityCards])}));
const seats = game.players.slice(game.dealerIndex + 1).concat(game.players.slice(0, game.dealerIndex + 1));
game.payouts = calculatePayouts(game.contributions, evaluations, seats);
for (const [player, amount] of Object.entries(game.payouts)) game.stacks[player] += amount;
const distributed = Object.values(game.payouts).reduce((sum, amount) => sum + amount, 0);
if (distributed !== game.pot) throw new Error('Pot conservation failed');
game.lastAction = `Distributed ${game.pot} chips: ${Object.entries(game.payouts).map(([player, amount]) => `${player} +${amount}`).join(', ')}.`;
game.pot = 0; game.phase = 'intermission'; game.currentPlayerIndex = -1;
game.turnEndsAt = null; game.nextRoundStartsAt = config.now() + config.intermissionMs;
game.winner = Object.keys(game.payouts).join(', ');
game.showdown = active.length > 1 ? evaluations : [];
}
function advance(game, config, from = game.currentPlayerIndex) {
if (live(game).length <= 1) { settle(game, config); return; }
const solvent = live(game).filter(player => game.stacks[player] > 0);
const pending = solvent.filter(player => game.actedAt[player] == null || callAmount(game, player) > 0);
// A sole solvent hand cannot bet into players who are already all-in.
if (pending.length && !(solvent.length === 1 && callAmount(game, solvent[0]) === 0)) {
for (let offset = 1; offset <= game.players.length; offset++) {
const index = (from + offset + game.players.length) % game.players.length;
if (!pending.includes(game.players[index])) continue;
game.currentPlayerIndex = index;
game.turnEndsAt = config.now() + config.turnMs;
return;
}
}
if (game.street === 'river') { settle(game, config); return; }
const next = {preflop: 'flop', flop: 'turn', turn: 'river'}[game.street];
const count = next === 'flop' ? 3 : 1;
for (let i = 0; i < count; i++) game.communityCards.push(game.deck.pop());
game.street = next; game.currentBet = 0; game.minRaise = config.ante;
for (const player of game.players) { game.playerBets[player] = 0; game.actedAt[player] = null; }
advance(game, config, game.dealerIndex);
}
function action(game, player, kind, amount, config) {
if (game.phase !== 'betting') throw new AppError('WRONG_PHASE', 'No hand is accepting actions', 409);
if (turn(game) !== player || !canAct(game, player)) throw new AppError('NOT_YOUR_TURN', 'Wait for your turn', 409);
if (kind === 'timeout') kind = callAmount(game, player) ? 'fold' : 'hit';
if (kind === 'fold') {
game.folded[player] = true; game.lastAction = `${player} folded.`;
} else if (kind === 'hit' || kind === 'bet') {
const call = callAmount(game, player);
if (kind === 'hit') amount = Math.min(call, game.stacks[player]);
if (!Number.isSafeInteger(amount) || amount < 0 || amount > game.stacks[player]) throw new AppError('INVALID_BET', 'Bet must be whole chips within your stack');
const allIn = amount === game.stacks[player];
const target = game.playerBets[player] + amount;
if (amount < call && !allIn) throw new AppError('INVALID_BET', `Call at least ${call} chips or go all-in`);
if (target > game.currentBet) {
if (!canRaise(game, player)) throw new AppError('RAISE_NOT_REOPENED', 'A short all-in has not reopened your raise');
if (live(game).filter(other => other !== player && game.stacks[other] > 0).length === 0) throw new AppError('INVALID_BET', 'No opponent can match a raise');
const raise = target - game.currentBet;
if (raise < game.minRaise && !allIn) throw new AppError('MINIMUM_RAISE', `A full raise must add at least ${game.minRaise} chips above the table bet`);
if (raise >= game.minRaise) game.minRaise = raise;
game.currentBet = target;
}
pay(game, player, amount);
game.actedAt[player] = game.currentBet;
game.lastAction = `${player} ${amount ? `paid ${amount} chips` : 'checked'}.`;
} else throw new AppError('INVALID_ACTION', 'Unknown poker action');
advance(game, config);
}
function leave(game, player, config) {
if (!game || game.phase !== 'betting' || !game.players.includes(player)) return;
game.departed.push(player); game.folded[player] = true;
if (turn(game) === player || live(game).length <= 1) advance(game, config);
else {
const solvent = live(game).filter(other => game.stacks[other] > 0);
if (!solvent.length || (solvent.length === 1 && callAmount(game, solvent[0]) === 0)) advance(game, config);
}
}
function state(game, username, lobbyId, config) {
return {
type: 'poker:state', lobbyId, username, players: game.players, round: game.round,
pot: game.pot, currentBet: game.currentBet, bet: game.currentBet,
communityCards: game.communityCards, hand: game.hands[username] || [],
currentPlayer: turn(game), isYourTurn: turn(game) === username,
queue: game.phase === 'betting' ? game.players.slice(game.currentPlayerIndex).concat(game.players.slice(0, game.currentPlayerIndex)).filter(player => canAct(game, player)) : [],
folded: game.folded, street: game.street, phase: game.phase, lastAction: game.lastAction,
yourContribution: game.contributions[username] || 0, yourStack: game.stacks[username] || 0,
isSpectator: !game.hands[username] || Boolean(game.folded[username]), callAmount: callAmount(game, username),
minimumStake: config.ante, minimumRaise: game.minRaise, canRaise: canRaise(game, username),
nextRoundStartsAt: game.nextRoundStartsAt, turnEndsAt: game.turnEndsAt,
winner: game.winner, showdown: game.showdown, payouts: game.payouts
};
}
module.exports = {intermission, deal, action, leave, state, turn, callAmount};
+89
View File
@@ -0,0 +1,89 @@
const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('node:path');
const swaggerUi = require('swagger-ui-express');
const {AppError} = require('./core');
const {tokenFrom} = require('./transport');
const {validate} = require('../public/protocol');
function createHttpApp(repository, sessions, lobbies, config) {
const app = express();
app.disable('x-powered-by'); app.set('trust proxy', config.trustProxy);
app.use(express.json({limit: '16kb'})); app.use(cookieParser());
app.use((req, res, next) => {
res.set('X-Content-Type-Options', 'nosniff');
res.set('Referrer-Policy', 'same-origin');
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method) && req.headers.origin) {
const origin = config.origin || `${req.protocol}://${req.get('host')}`;
if (req.headers.origin !== origin) return res.status(403).json({code: 'ORIGIN_DENIED', error: 'Origin denied'});
}
next();
});
const attempts = new Map();
function rateLimit(req, res, next) {
const now = config.now();
for (const [key, value] of attempts) if (value.until <= now) attempts.delete(key);
const key = req.ip;
if (!attempts.has(key)) {
if (attempts.size >= 10000) return res.status(503).json({code: 'BUSY', error: 'Try again later'});
attempts.set(key, {count: 0, until: now + config.authWindowMs});
}
const record = attempts.get(key);
if (++record.count > config.authLimit) {
res.set('Retry-After', String(Math.ceil((record.until - now) / 1000)));
return res.status(429).json({code: 'RATE_LIMITED', error: 'Too many authentication attempts'});
}
next();
}
const cookie = req => ({httpOnly: true, sameSite: 'strict', secure: req.secure, path: '/', maxAge: config.sessionSeconds * 1000});
const authorized = handler => async (req, res) => sessions.authorized(tokenFrom(req), user => handler(req, res, user));
for (const method of ['register', 'login']) app.post(`/${method}`, rateLimit, async (req, res) => {
const result = await sessions[method](req.body);
res.cookie('authorization', result.token, cookie(req)); res.set('Cache-Control', 'no-store'); res.json(result);
});
app.post('/verify', authorized((req, res, user) => res.json({valid: true, user})));
app.delete('/logout', authorized(async (req, res, user) => {
const lobby = lobbies.membership(user.username);
if (lobby) await lobbies.command(user.username, {type: 'lobby:leave', lobbyId: lobby.id, requestId: require('node:crypto').randomUUID()});
await sessions.revoke(user.username);
const options = cookie(req); delete options.maxAge;
res.clearCookie('authorization', options); res.json({message: 'Logged out'});
}));
app.get('/balance', authorized(async (req, res, user) => res.json({balance: (await repository.user(user.username)).money})));
app.get('/leaderboard', authorized(async (req, res, user) => {
const users = await repository.users(); const index = users.findIndex(entry => entry.username === user.username);
res.json({leaderboard: users.slice(0, 10), self: {...users[index], position: index + 1}});
}));
for (const [route, type] of [['/lobby/create', 'lobby:create'], ['/lobby/join', 'lobby:join'], ['/lobby/leave', 'lobby:leave'], ['/poker/start', 'poker:start'], ['/poker/action', 'poker:action']]) {
app.post(route, authorized(async (req, res, user) => {
const message = {...req.body, type};
const invalid = validate(message);
if (invalid) throw new AppError('INVALID_MESSAGE', invalid);
res.json(await lobbies.command(user.username, message));
}));
app.get(route, (req, res) => res.set('Allow', 'POST').status(405).json({code: 'METHOD_NOT_ALLOWED', error: 'Use POST with a requestId'}));
}
app.get('/lobby/public', authorized((req, res) => res.json({lobbies: lobbies.publicList()})));
app.get('/lobby/players', authorized((req, res, user) => {
const lobby = lobbies.get(req.query.lobbyId);
if (!lobby?.players.includes(user.username)) throw new AppError('NOT_SEATED', 'Join this lobby first', 403);
res.json({players: lobby.players});
}));
const publicPath = path.join(__dirname, '..', 'public');
app.get(['/', '/home.html', '/poker.html'], async (req, res) => {
if (!await sessions.verify(tokenFrom(req))) return res.redirect('/login');
res.set('Cache-Control', 'no-store');
res.sendFile(path.join(publicPath, req.path === '/poker.html' ? 'poker.html' : 'home.html'));
});
app.get('/login', (req, res) => res.sendFile(path.join(publicPath, 'login.html')));
app.use('/swagger-ui', swaggerUi.serve, swaggerUi.setup(require('../swagger.json')));
app.use(express.static(publicPath, {index: false}));
app.use((req, res) => res.status(404).json({code: 'NOT_FOUND', error: 'Not found'}));
app.use((error, req, res, next) => {
if (res.headersSent) return next(error);
const status = error.status >= 400 && error.status < 600 ? error.status : 500;
if (status === 500) console.error('Request failed:', error);
res.status(status).json({code: error.code || (status === 500 ? 'INTERNAL_ERROR' : 'INVALID_BODY'), error: status === 500 ? 'Internal server error' : error.message});
});
return app;
}
module.exports = {createHttpApp};
+121
View File
@@ -0,0 +1,121 @@
const crypto = require('node:crypto');
const {EventEmitter} = require('node:events');
const {KeyedQueue, AppError, hash} = require('./core');
const engine = require('./engine');
function createLobbyService(repository, config) {
const lobbies = new Map();
const queue = new KeyedQueue();
const events = new EventEmitter();
const timers = new Map();
let closing = false;
function membership(username) { return [...lobbies.values()].find(lobby => lobby.players.includes(username)); }
function reserved(username) { return [...lobbies.values()].find(lobby => lobby.game?.phase === 'betting' && lobby.game.players.includes(username)); }
async function balances(players) { return Object.fromEntries(await Promise.all(players.map(async player => [player, (await repository.user(player)).money]))); }
function schedule(lobby) {
clearTimeout(timers.get(lobby.id)); timers.delete(lobby.id);
if (closing || !lobby.players.length || !lobby.game) return;
const deadline = lobby.game.phase === 'betting' ? lobby.game.turnEndsAt : lobby.game.nextRoundStartsAt;
if (!deadline) return;
const id = lobby.game.id;
const expectedPhase = lobby.game.phase;
const revision = lobby.revision;
const timer = setTimeout(() => {
command(null, {type: 'timer', lobbyId: lobby.id, handId: id, deadline, phase: expectedPhase, revision, requestId: `timer-${id}-${revision}`})
.catch(error => {
// Failed transactions leave state untouched. Retry the same deadline/idempotency key.
events.emit('failure', error);
if (!closing && lobbies.get(lobby.id) === lobby) {
const retry = setTimeout(() => schedule(lobby), 1000); retry.unref(); timers.set(lobby.id, retry);
}
});
}, Math.max(1, deadline - config.now()));
timer.unref(); timers.set(lobby.id, timer);
}
async function command(username, message) {
if (closing) throw new AppError('SHUTTING_DOWN', 'Server is shutting down', 503);
const membershipMutation = ['lobby:create', 'lobby:join', 'lobby:leave'].includes(message.type);
return queue.run([...(membershipMutation ? ['membership'] : []), ...(username ? [`account:${username}`] : []),
...(message.lobbyId ? [`lobby:${message.lobbyId}`] : [])], async () => {
const id = `${username || 'system'}:${message.requestId}`;
const fingerprint = hash(JSON.stringify(message));
const prior = await repository.command(id);
if (prior) {
if (prior.fingerprint !== fingerprint) throw new AppError('IDEMPOTENCY_CONFLICT', 'Request ID was reused', 409);
return JSON.parse(prior.result);
}
let old = lobbies.get(message.lobbyId);
if (message.type === 'lobby:create') {
if (membership(username) || reserved(username)) throw new AppError('ALREADY_SEATED', 'Leave your table and wait for its hand to settle first', 409);
old = {id: crypto.randomBytes(8).toString('hex'), host: username, players: [], private: message.private || false, game: null};
}
if (!old) throw new AppError('LOBBY_NOT_FOUND', 'Lobby not found', 404);
const lobby = structuredClone(old);
const baseline = Object.assign(Object.create(null), old.game?.stacks || {});
if (lobby.game) {
for (const key of ['stacks', 'hands', 'folded', 'contributions', 'playerBets', 'actedAt', 'payouts']) lobby.game[key] = Object.assign(Object.create(null), lobby.game[key]);
}
if (message.type === 'lobby:create' || message.type === 'lobby:join') {
const current = membership(username) || reserved(username);
if (current && current.id !== lobby.id) throw new AppError('ALREADY_SEATED', 'Leave your table and wait for its hand to settle first', 409);
if (!lobby.players.includes(username)) {
if (lobby.game?.phase === 'betting' && lobby.game.players.includes(username)) throw new AppError('HAND_PENDING', 'Rejoin after your previous hand settles', 409);
if (lobby.players.length >= 10) throw new AppError('LOBBY_FULL', 'Lobby is full', 409);
lobby.players.push(username);
if (lobby.game) {
const balance = (await repository.user(username)).money;
lobby.game.stacks[username] = balance; baseline[username] = balance;
}
}
if (lobby.game?.phase === 'waiting' && (Object.values(await balances(lobby.players)).filter(value => value >= config.ante).length >= 2)) {
lobby.game = engine.intermission(lobby.players, await balances(lobby.players), config, lobby.game);
}
} else if (message.type === 'lobby:leave') {
if (!lobby.players.includes(username)) throw new AppError('NOT_SEATED', 'You are not in this lobby', 409);
lobby.players = lobby.players.filter(player => player !== username);
engine.leave(lobby.game, username, config);
if (lobby.host === username) lobby.host = lobby.players[0] || null;
} else if (message.type === 'poker:start') {
if (lobby.host !== username) throw new AppError('HOST_REQUIRED', 'Only the host can start', 403);
if (lobby.game) throw new AppError('GAME_STARTED', 'Game already started', 409);
if (lobby.players.length < 2) throw new AppError('NOT_ENOUGH_PLAYERS', 'At least two players are required', 409);
lobby.game = engine.intermission(lobby.players, await balances(lobby.players), config);
Object.assign(baseline, lobby.game.stacks);
} else if (message.type === 'poker:action') {
if (!lobby.players.includes(username)) throw new AppError('NOT_SEATED', 'Join this lobby first', 403);
if (!lobby.game) throw new AppError('WRONG_PHASE', 'Host must start the game', 409);
engine.action(lobby.game, username, message.action, message.payload?.amount, config);
} else if (message.type === 'timer') {
if (message.revision !== undefined && message.revision !== lobby.revision) return {ignored: true};
if (!lobby.game || lobby.game.id !== message.handId || lobby.game.phase !== message.phase) return {ignored: true};
const deadline = lobby.game.phase === 'betting' ? lobby.game.turnEndsAt : lobby.game.nextRoundStartsAt;
if (deadline !== message.deadline || deadline > config.now()) return {ignored: true};
if (lobby.game.phase === 'betting') engine.action(lobby.game, engine.turn(lobby.game), 'timeout', undefined, config);
else {
const fresh = await balances(lobby.players);
Object.assign(baseline, fresh);
lobby.game = engine.deal(lobby.players, fresh, config, lobby.game);
}
} else throw new AppError('INVALID_MESSAGE', 'Unsupported command');
const deltas = Object.fromEntries(Object.entries(lobby.game?.stacks || {}).map(([player, stack]) => [player, stack - (baseline[player] ?? stack)]));
const difference = Object.values(deltas).reduce((sum, amount) => sum + amount, 0) + (lobby.game?.pot || 0) - (old.game?.pot || 0);
if (difference !== 0) throw new Error('Command violates chip conservation');
lobby.revision = (old.revision || 0) + 1;
const result = {lobbyId: lobby.id, username, type: message.type, requestId: message.requestId};
await repository.commit({id, fingerprint, lobby, deltas, result, reason: message.type});
if (lobby.players.length) lobbies.set(lobby.id, lobby); else lobbies.delete(lobby.id);
schedule(lobby);
events.emit('changed', lobby, username, message.type);
return result;
});
}
return {command, membership, events,
get: id => lobbies.get(id),
publicList: () => [...lobbies.values()].filter(lobby => !lobby.private).map(lobby => ({lobbyId: lobby.id, host: lobby.host, players: lobby.players.length, gameStarted: Boolean(lobby.game)})),
state: (lobby, username) => lobby.game ? engine.state(lobby.game, username, lobby.id, config) : null,
lobbyState: lobby => ({type: 'lobby:state', lobbyId: lobby.id, host: lobby.host, players: lobby.players, isPublic: !lobby.private,
canStart: lobby.players.length >= 2 && !lobby.game, gameStarted: Boolean(lobby.game), gameUrl: `/poker.html?lobbyId=${lobby.id}`}),
async close() { closing = true; for (const timer of timers.values()) clearTimeout(timer); await queue.drain(); }
};
}
module.exports = {createLobbyService};
+106
View File
@@ -0,0 +1,106 @@
const sqlite3 = require('sqlite3');
const {open} = require('sqlite');
const {KeyedQueue, AppError} = require('./core');
async function createRepository(filename, {beforeCommit = async () => {}} = {}) {
const db = await open({filename, driver: sqlite3.Database});
const queue = new KeyedQueue();
let release;
try {
await db.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;');
release = await require('./database-lock').acquireDatabaseLock(db);
} catch (error) {await db.close(); throw error;}
async function transaction(work) {
return queue.run(['database'], async () => {
await db.exec('BEGIN IMMEDIATE');
try {
const result = await work(db);
await beforeCommit();
await db.exec('COMMIT');
return result;
} catch (error) { await db.exec('ROLLBACK'); throw error; }
});
}
try {
const version = await db.get('PRAGMA user_version');
const oldUsers = await db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='users'");
if (version.user_version === 0 && oldUsers && filename !== ':memory:') {
await db.run('VACUUM INTO ?', `${filename}.${Date.now()}.backup`);
}
await transaction(async tx => {
const {user_version: version} = await tx.get('PRAGMA user_version');
if (version > 1) throw new Error('Database schema is newer than this application');
if (version === 1) return;
const old = await tx.get("SELECT name FROM sqlite_master WHERE type='table' AND name='users'");
if (old) await tx.exec('ALTER TABLE users RENAME TO legacy_users');
await tx.exec(`
CREATE TABLE users (
username TEXT PRIMARY KEY COLLATE NOCASE,
password_hash TEXT NOT NULL,
session_hash TEXT,
money INTEGER NOT NULL DEFAULT 200 CHECK(typeof(money) = 'integer' AND money >= 0 AND money <= 9007199254740991)
);
CREATE TABLE lobbies (id TEXT PRIMARY KEY, state TEXT NOT NULL);
CREATE TABLE commands (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at INTEGER NOT NULL);
CREATE TABLE ledger (id INTEGER PRIMARY KEY, command_id TEXT NOT NULL, username TEXT NOT NULL REFERENCES users(username), delta INTEGER NOT NULL, reason TEXT NOT NULL);
CREATE TABLE migrations (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL);
`);
if (old) {
// Fails and rolls back rather than silently rounding or losing invalid balances.
await tx.exec('INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; DROP TABLE legacy_users;');
}
await tx.exec('PRAGMA user_version = 1');
});
} catch (error) { await release(); await db.close(); throw error; }
const read = work => queue.run(['database'], () => work(db));
return {
transaction,
user: username => read(tx => tx.get('SELECT username, password_hash AS passwordHash, session_hash AS sessionHash, money FROM users WHERE username = ?', username)),
users: () => read(tx => tx.all('SELECT username, money FROM users ORDER BY money DESC, username ASC')),
async register(username, passwordHash, sessionHash) {
try { await transaction(tx => tx.run('INSERT INTO users(username,password_hash,session_hash) VALUES(?,?,?)', username, passwordHash, sessionHash)); }
catch (error) { if (error.code === 'SQLITE_CONSTRAINT') throw new AppError('USERNAME_EXISTS', 'Username already exists', 409); throw error; }
},
setSession: (username, sessionHash) => transaction(tx => tx.run('UPDATE users SET session_hash = ? WHERE username = ?', sessionHash, username)),
async recover() {
return transaction(async tx => {
const rows = await tx.all('SELECT id,state FROM lobbies');
for (const row of rows) {
const lobby = JSON.parse(row.state);
if (lobby.game?.phase !== 'betting') continue;
for (const [username, amount] of Object.entries(lobby.game.contributions)) {
if (!amount) continue;
await tx.run('UPDATE users SET money = money + ? WHERE username = ?', amount, username);
await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,?,?)', `recovery:${lobby.game.id}`, username, amount, 'Interrupted hand refund');
}
}
await tx.run('DELETE FROM lobbies');
});
},
command: id => read(tx => tx.get('SELECT fingerprint,result FROM commands WHERE id = ?', id)),
async commit({id, fingerprint, lobby, deltas, result, reason}) {
return transaction(async tx => {
const prior = await tx.get('SELECT fingerprint,result FROM commands WHERE id = ?', id);
if (prior) {
if (prior.fingerprint !== fingerprint) throw new AppError('IDEMPOTENCY_CONFLICT', 'Request ID was reused for a different command', 409);
return {duplicate: true, result: JSON.parse(prior.result)};
}
for (const [username, amount] of Object.entries(deltas)) {
if (!Number.isSafeInteger(amount)) throw new Error('Invalid ledger delta');
if (!amount) continue;
const updated = await tx.run('UPDATE users SET money = money + ? WHERE username = ? AND money + ? >= 0', amount, username, amount);
if (updated.changes !== 1) throw new AppError('INSUFFICIENT_CHIPS', 'Insufficient chips', 409);
await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,?,?)', id, username, amount, reason);
}
if (lobby.players.length) await tx.run('INSERT INTO lobbies(id,state) VALUES(?,?) ON CONFLICT(id) DO UPDATE SET state=excluded.state', lobby.id, JSON.stringify(lobby));
else await tx.run('DELETE FROM lobbies WHERE id = ?', lobby.id);
await tx.run('INSERT INTO commands(id,fingerprint,result,created_at) VALUES(?,?,?,?)', id, fingerprint, JSON.stringify(result), Date.now());
return {duplicate: false, result};
});
},
totals: () => read(async tx => ({users: (await tx.get('SELECT COALESCE(SUM(money),0) AS total FROM users')).total,
escrow: (await tx.all('SELECT state FROM lobbies')).reduce((sum, row) => sum + (JSON.parse(row.state).game?.pot || 0), 0)})),
close: async () => { await queue.drain(); await release(); await db.close(); }
};
}
module.exports = {createRepository};
+67
View File
@@ -0,0 +1,67 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const crypto = require('node:crypto');
const {EventEmitter} = require('node:events');
const {AppError, KeyedQueue, hash} = require('./core');
const {schemas, matches} = require('../public/protocol');
function createSessionService(repository, config) {
const events = new EventEmitter();
const queue = new KeyedQueue();
let dummyHash;
function credentials(body) {
if (!matches(body, schemas.credentials) || Buffer.byteLength(body.password, 'utf8') > 72) throw new AppError('INVALID_CREDENTIALS', 'Username must be 1-40 characters; password must be 1-72 UTF-8 bytes');
const username = body.username.trim().toLowerCase();
if (!username) throw new AppError('INVALID_CREDENTIALS', 'Username is required');
return {username, password: body.password};
}
function issue(username) { return jwt.sign({username}, config.secret, {algorithm: 'HS256', expiresIn: config.sessionSeconds, jwtid: crypto.randomUUID()}); }
async function verify(token) {
try {
if (typeof token !== 'string') return null;
const decoded = jwt.verify(token, config.secret, {algorithms: ['HS256']});
if (typeof decoded.username !== 'string') return null;
const user = await repository.user(decoded.username);
if (!user?.sessionHash || user.sessionHash !== hash(token)) return null;
return {username: user.username, exp: decoded.exp};
} catch (error) {
if (error instanceof jwt.JsonWebTokenError || error instanceof jwt.TokenExpiredError || error instanceof jwt.NotBeforeError) return null;
throw error;
}
}
return {events, verify,
async register(body) {
const {username, password} = credentials(body);
return queue.run([username], async () => {
const passwordHash = await bcrypt.hash(password, config.bcryptRounds);
const token = issue(username);
await repository.register(username, passwordHash, hash(token));
return {username, token};
});
},
async login(body) {
const {username, password} = credentials(body);
return queue.run([username], async () => {
const user = await repository.user(username);
dummyHash ||= bcrypt.hash('invalid-account-placeholder', config.bcryptRounds);
const valid = await bcrypt.compare(password, user?.passwordHash || await dummyHash);
if (!user || !valid) throw new AppError('INVALID_CREDENTIALS', 'Invalid credentials', 401);
const token = issue(user.username);
await repository.setSession(user.username, hash(token));
events.emit('revoked', user.username);
return {username: user.username, token};
});
},
async authorized(token, work) {
const decoded = typeof token === 'string' ? jwt.decode(token) : null;
if (typeof decoded?.username !== 'string') throw new AppError('UNAUTHORIZED', 'Please sign in', 401);
return queue.run([decoded.username], async () => {
const user = await verify(token);
if (!user) throw new AppError('UNAUTHORIZED', 'Session expired or revoked', 401);
return work(user);
});
},
async revoke(username) { await repository.setSession(username, null); events.emit('revoked', username); },
close: () => queue.drain()
};
}
module.exports = {createSessionService};
+122
View File
@@ -0,0 +1,122 @@
const {WebSocketServer, WebSocket} = require('ws');
const crypto = require('node:crypto');
const {AppError} = require('./core');
const {validate} = require('../public/protocol');
function parseCookies(header = '') {
const result = Object.create(null);
for (const entry of header.split(';')) {
const index = entry.indexOf('='); if (index < 0) continue;
try { result[entry.slice(0, index).trim()] = decodeURIComponent(entry.slice(index + 1).trim()); } catch { /* Ignore malformed cookies. */ }
}
return result;
}
function tokenFrom(request) {
const bearer = /^Bearer ([^ ]+)$/i.exec(request.headers.authorization || '');
return bearer?.[1] || request.cookies?.authorization || parseCookies(request.headers.cookie).authorization;
}
function connectTransport(server, sessions, lobbies, config) {
const wss = new WebSocketServer({noServer: true, maxPayload: 16 * 1024});
const connections = new Map();
const disconnects = new Map();
let stopping = false;
function send(socket, data) {
if (socket.readyState !== WebSocket.OPEN) return;
if (socket.bufferedAmount > 256 * 1024) { socket.close(1013, 'Client is too slow'); return; }
socket.send(JSON.stringify(data));
}
function snapshot(socket) {
const lobby = lobbies.get(socket.lobbyId);
if (!lobby?.players.includes(socket.user.username)) return;
send(socket, lobbies.lobbyState(lobby));
if (lobby.game) send(socket, lobbies.state(lobby, socket.user.username));
}
function disconnected(username) {
if (stopping || [...wss.clients].some(socket => socket.user?.username === username && socket.readyState === WebSocket.OPEN)) return;
clearTimeout(disconnects.get(username));
const timer = setTimeout(() => {
disconnects.delete(username);
if ([...wss.clients].some(socket => socket.user?.username === username && socket.readyState === WebSocket.OPEN)) return;
const lobby = lobbies.membership(username);
if (lobby) lobbies.command(username, {type: 'lobby:leave', lobbyId: lobby.id, requestId: crypto.randomUUID()}).catch(error => {
console.error('Disconnect leave failed:', error.message);
disconnected(username);
});
}, config.disconnectMs);
timer.unref(); disconnects.set(username, timer);
}
server.on('upgrade', (request, socket, head) => {
const ip = request.socket.remoteAddress;
const reject = status => { socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); };
if (stopping) return reject('503 Service Unavailable');
if (request.url !== '/') return reject('404 Not Found');
const expectedOrigin = config.origin || `${request.socket.encrypted ? 'https' : 'http'}://${request.headers.host}`;
if (request.headers.origin ? request.headers.origin !== expectedOrigin : !request.headers.authorization) return reject('403 Forbidden');
if ([...connections.values()].reduce((sum, count) => sum + count, 0) >= config.maxConnections || (connections.get(ip) || 0) >= config.maxConnectionsPerIp) return reject('429 Too Many Requests');
connections.set(ip, (connections.get(ip) || 0) + 1);
let released = false;
const release = () => { if (released) return; released = true; const count = connections.get(ip) - 1; if (count) connections.set(ip, count); else connections.delete(ip); };
socket.once('close', release);
socket.on('error', () => {});
const token = tokenFrom(request);
sessions.authorized(token, user => {
if (socket.destroyed || stopping) return socket.destroy();
wss.handleUpgrade(request, socket, head, ws => {
ws.user = user; ws.token = token; ws.alive = true; ws.pending = 0;
ws.windowAt = config.now(); ws.messages = 0;
clearTimeout(disconnects.get(user.username)); disconnects.delete(user.username);
ws.lobbyId = lobbies.membership(user.username)?.id || null;
ws.on('error', error => console.error('WebSocket error:', error.message));
ws.on('pong', () => { ws.alive = true; });
const expiry = setTimeout(() => ws.close(1008, 'Session expired'), Math.max(1, user.exp * 1000 - config.now()));
expiry.unref();
ws.on('close', () => { clearTimeout(expiry); release(); disconnected(user.username); });
ws.on('message', raw => {
if (config.now() - ws.windowAt >= 1000) { ws.windowAt = config.now(); ws.messages = 0; }
if (++ws.messages > config.messageLimit || ws.pending >= 8) { ws.close(1008, 'Message limit exceeded'); return; }
let message;
try { message = JSON.parse(raw.toString()); } catch { send(ws, {type: 'error', code: 'INVALID_JSON', message: 'Invalid JSON'}); return; }
const invalid = validate(message);
if (invalid) { send(ws, {type: 'error', code: 'INVALID_MESSAGE', message: invalid, requestId: message?.requestId}); return; }
ws.pending++;
sessions.authorized(token, async current => {
if (message.type === 'poker:join') {
const lobby = lobbies.get(message.lobbyId);
if (!lobby?.players.includes(current.username)) throw new AppError('NOT_SEATED', 'You are not in this lobby', 403);
ws.lobbyId = lobby.id; snapshot(ws); return;
}
const result = await lobbies.command(current.username, message);
send(ws, {...result, type: 'command:ack', commandType: result.type});
}).catch(error => {
send(ws, {type: 'error', code: error.code || 'INTERNAL_ERROR', message: error.status ? error.message : 'Command failed; retry with the same request ID', requestId: message.requestId});
if (error.status === 401) ws.close(1008, 'Session expired or revoked');
if (!error.status) console.error('Socket command failed:', error);
}).finally(() => ws.pending--);
});
// Handlers exist before readiness is announced.
send(ws, {type: 'auth:success', username: user.username}); snapshot(ws);
});
}).catch(error => { if (error.status === 401) reject('401 Unauthorized'); else {console.error('Upgrade failed:', error.message); reject('500 Internal Server Error');} });
});
const onChanged = (lobby, username, type) => {
for (const socket of wss.clients) {
if (socket.user?.username === username && ['lobby:create', 'lobby:join'].includes(type)) socket.lobbyId = lobby.id;
if (socket.lobbyId !== lobby.id) continue;
if (!lobby.players.includes(socket.user.username)) {
socket.lobbyId = null; send(socket, {type: 'lobby:left', lobbyId: lobby.id});
} else snapshot(socket);
}
};
const onRevoked = username => { for (const socket of wss.clients) if (socket.user?.username === username) socket.close(1008, 'Session expired or revoked'); };
lobbies.events.on('changed', onChanged); sessions.events.on('revoked', onRevoked);
const heartbeat = setInterval(() => {
for (const socket of wss.clients) { if (!socket.alive) socket.terminate(); else { socket.alive = false; socket.ping(); } }
}, config.heartbeatMs);
heartbeat.unref();
return {wss, async close() {
stopping = true; clearInterval(heartbeat); for (const timer of disconnects.values()) clearTimeout(timer);
lobbies.events.off('changed', onChanged); sessions.events.off('revoked', onRevoked);
for (const socket of wss.clients) socket.terminate();
await new Promise(resolve => wss.close(resolve));
}};
}
module.exports = {connectTransport, tokenFrom, parseCookies};
+8
View File
@@ -0,0 +1,8 @@
const path = require('node:path');
const {defineConfig} = require('@playwright/test');
process.env.PLAYWRIGHT_BROWSERS_PATH ||= path.join(__dirname,'.playwright-browsers');
module.exports = defineConfig({
testDir:'./e2e', timeout:45000, workers:1, retries:0,
use:{baseURL:'http://127.0.0.1:42170',headless:true,trace:'retain-on-failure',screenshot:'only-on-failure'},
webServer:{command:'node e2e/server.js',url:'http://127.0.0.1:42170/login',reuseExistingServer:false,timeout:15000}
});
+81
View File
@@ -0,0 +1,81 @@
'use strict';
window.apiRequest = async function (url, options = {}) {
const response = await fetch(url, {credentials: 'same-origin', ...options});
const data = await response.json().catch(() => ({}));
if (response.status === 401) {
localStorage.removeItem('activeLobbyId');
window.location.assign('/login');
}
if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
return data;
};
window.RealtimeClient = class extends EventTarget {
constructor() {
super(); this.socket = null; this.ready = false; this.attempts = 0;
this.pending = new Map(); this.stopped = false;
window.addEventListener('offline', () => this.socket?.close());
window.addEventListener('pagehide', () => this.close());
}
status(message) { this.dispatchEvent(new CustomEvent('status', {detail: message})); }
connect() {
if (this.ready) return Promise.resolve();
if (this.connecting) return this.connecting;
this.stopped = false;
this.connecting = new Promise((resolve, reject) => {this.resolveReady = resolve; this.rejectReady = reject;});
if (!this.reconnectTimer && (!this.socket || this.socket.readyState >= WebSocket.CLOSING)) this.open();
return this.connecting;
}
open() {
this.reconnectTimer = null;
if (this.stopped) return;
const socket = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/`);
this.socket = socket;
const timeout = setTimeout(() => socket.close(), 5000);
socket.addEventListener('message', event => {
let message; try {message = JSON.parse(event.data);} catch {this.status('Server sent an invalid response.'); return;}
if (message.type === 'auth:success') {
clearTimeout(timeout); this.ready = true; this.attempts = 0;
this.resolveReady?.(); this.connecting = null; this.status('');
for (const item of this.pending.values()) socket.send(JSON.stringify(item.message));
}
const pending = this.pending.get(message.requestId);
if (pending && (message.type === 'command:ack' || message.type === 'error')) {
clearTimeout(pending.timer); this.pending.delete(message.requestId);
if (message.type === 'error') pending.reject(new Error(message.message)); else pending.resolve(message);
}
this.dispatchEvent(new CustomEvent('message', {detail: message}));
});
socket.addEventListener('error', () => this.status('Connection interrupted. Reconnecting…'));
socket.addEventListener('close', async event => {
clearTimeout(timeout); this.ready = false;
if (this.stopped) return;
this.status('Connection interrupted. Reconnecting…');
if (event.code === 1008 && /Session/.test(event.reason)) {
this.close(); localStorage.removeItem('activeLobbyId'); location.assign('/login'); return;
}
try { await apiRequest('/verify', {method: 'POST'}); } catch { /* apiRequest redirects expired sessions; network outages are retried. */ }
if (++this.attempts > 5) { this.close(); this.status('Could not reconnect. Reload to try again.'); return; }
this.reconnectTimer = setTimeout(() => this.open(), Math.min(500 * 2 ** (this.attempts - 1), 5000));
});
}
async send(message) {
if (message.type === 'poker:join') { await this.connect(); this.socket.send(JSON.stringify(message)); return; }
message = {...message, requestId: message.requestId || crypto.randomUUID()};
const invalid = Protocol.validate(message); if (invalid) throw new Error(invalid);
await this.connect();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(message.requestId);
reject(new Error('No acknowledgement received. Reload to check the latest table state before retrying.'));
}, 30000);
this.pending.set(message.requestId, {message, resolve, reject, timer});
this.socket.send(JSON.stringify(message));
});
}
close() {
this.stopped = true; this.ready = false; clearTimeout(this.reconnectTimer);
this.rejectReady?.(new Error('Connection closed')); this.connecting = null;
for (const item of this.pending.values()) {clearTimeout(item.timer); item.reject(new Error('Connection closed'));}
this.pending.clear(); this.socket?.close();
}
};
+42
View File
@@ -0,0 +1,42 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.Protocol = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const string = {type: 'string', minLength: 1, maxLength: 128};
const lobbyId = {type: 'string', pattern: '^[a-f0-9]{16}$'};
const requestId = {type: 'string', pattern: '^[a-zA-Z0-9_-]{8,128}$'};
const schemas = {
'lobby:create': {type: 'object', required: ['type', 'requestId'], properties: {type: {const: 'lobby:create'}, requestId, private: {type: 'boolean'}}, additionalProperties: false},
'lobby:join': command('lobby:join'), 'lobby:leave': command('lobby:leave'), 'poker:start': command('poker:start'),
'poker:join': {type: 'object', required: ['type', 'lobbyId'], properties: {type: {const: 'poker:join'}, lobbyId}, additionalProperties: false},
'poker:action': {type: 'object', required: ['type', 'lobbyId', 'requestId', 'action'], properties: {
type: {const: 'poker:action'}, lobbyId, requestId, action: {enum: ['hit', 'fold', 'bet']},
payload: {type: 'object', properties: {amount: {type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER}}, additionalProperties: false}
}, additionalProperties: false},
credentials: {type: 'object', required: ['username', 'password'], properties: {
username: {type: 'string', minLength: 1, maxLength: 40, pattern: '^[^\\u0000-\\u001f\\u007f]+$'},
password: {type: 'string', minLength: 1, maxLength: 72}
}, additionalProperties: false}
};
function command(type) { return {type: 'object', required: ['type', 'lobbyId', 'requestId'], properties: {type: {const: type}, lobbyId, requestId}, additionalProperties: false}; }
function matches(value, schema) {
if (schema.const !== undefined && value !== schema.const) return false;
if (schema.enum && !schema.enum.includes(value)) return false;
if (schema.type === 'object') {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
if ((schema.required || []).some(key => !Object.hasOwn(value, key))) return false;
return Object.keys(value).every(key => Object.hasOwn(schema.properties, key) ? matches(value[key], schema.properties[key]) : schema.additionalProperties !== false);
}
if (schema.type === 'string') return typeof value === 'string' && (!schema.minLength || value.length >= schema.minLength) && (!schema.maxLength || value.length <= schema.maxLength) && (!schema.pattern || new RegExp(schema.pattern).test(value));
if (schema.type === 'integer') return Number.isSafeInteger(value) && value >= schema.minimum && value <= schema.maximum;
if (schema.type === 'boolean') return typeof value === 'boolean';
return true;
}
function validate(message) {
if (!message || !Object.hasOwn(schemas, message.type) || message.type === 'credentials' || !matches(message, schemas[message.type])) return 'Invalid message shape or fields';
if (message.type === 'poker:action' && message.action === 'bet' && !Object.hasOwn(message.payload || {}, 'amount')) return 'Bet amount is required';
return null;
}
return {schemas, matches, validate, string};
});
+7
View File
@@ -0,0 +1,7 @@
:root { --accent: #0f0; --surface: #111; --foreground: #fff; }
* { box-sizing: border-box; }
.hidden { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }
button:disabled { cursor: not-allowed; opacity: .65; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } }
+7
View File
@@ -0,0 +1,7 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const directories = ['lib','public','scripts','test','e2e'];
const files = ['gambling.js','swagger.js','playwright.config.js', ...directories.flatMap(directory => fs.readdirSync(directory).filter(file=>file.endsWith('.js')).map(file=>path.join(directory,file)))];
for (const filename of files) new vm.Script(fs.readFileSync(filename,'utf8'),{filename});
console.log(`Syntax checked ${files.length} JavaScript files`);
+47
View File
@@ -0,0 +1,47 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const bcrypt = require('bcrypt');
const {hash, AppError} = require('../lib/core');
const {schemas, matches} = require('../public/protocol');
async function migrateLegacy(repository, filename, rounds = 12) {
const source = await fs.readFile(filename, 'utf8');
const users = JSON.parse(source);
if (!Array.isArray(users)) throw new Error('Legacy file must contain an array');
const seen = new Set();
for (const user of users) {
if (!user || typeof user.username !== 'string' || typeof user.password !== 'string') throw new Error('Each legacy row needs username and password strings');
const username = user.username.trim().toLowerCase();
if (!matches(username, schemas.credentials.properties.username) || seen.has(username)) throw new Error('Invalid or duplicate legacy username');
seen.add(username);
if (!/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/.test(user.password) && (Buffer.byteLength(user.password, 'utf8') > 72 || !user.password)) throw new Error('Invalid legacy password');
if (user.money !== undefined && (!Number.isSafeInteger(user.money) || user.money < 0)) throw new Error('Invalid legacy balance');
}
const id = `json:${hash(source)}`;
const backup = `${path.resolve(filename)}.${hash(source).slice(0, 12)}.backup`;
try { await fs.writeFile(backup, source, {flag: 'wx', mode: 0o600}); }
catch (error) { if (error.code !== 'EEXIST') throw error; if (await fs.readFile(backup, 'utf8') !== source) throw new Error('Backup content does not match'); }
const records = await Promise.all(users.map(async user => ({username: user.username.trim().toLowerCase(), money: user.money ?? 200,
passwordHash: user.password.startsWith('$2') ? user.password : await bcrypt.hash(user.password, rounds)})));
return repository.transaction(async tx => {
if (await tx.get('SELECT id FROM migrations WHERE id = ?', id)) return {imported: 0, alreadyApplied: true, backup};
for (const user of records) {
if (await tx.get('SELECT username FROM users WHERE username = ?', user.username)) throw new AppError('MIGRATION_CONFLICT', `Account already exists: ${user.username}`);
await tx.run('INSERT INTO users(username,password_hash,money) VALUES(?,?,?)', user.username, user.passwordHash, user.money);
await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,?,?)', id, user.username, user.money, 'Legacy import');
}
await tx.run('INSERT INTO migrations(id,applied_at) VALUES(?,?)', id, Date.now());
return {imported: records.length, backup};
});
}
if (require.main === module) {
require('dotenv').config();
const {createRepository} = require('../lib/repository');
(async () => {
const filename = process.argv[2];
if (!filename) throw new Error('Usage: npm run migrate:legacy -- path/to/data.json (stop the server first)');
const repository = await createRepository(process.env.DATABASE_PATH || path.join(__dirname, '..', 'users.sqlite'));
try { console.log(await migrateLegacy(repository, filename)); } finally { await repository.close(); }
})().catch(error => {console.error(error.message); process.exitCode = 1;});
}
module.exports = {migrateLegacy};
+42
View File
@@ -0,0 +1,42 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const engine = require('../lib/engine');
const config = {ante: 10, now: () => 100, turnMs: 30000, intermissionMs: 30000};
function game(stacks = {a: 200, b: 200, c: 200}) {return engine.deal(Object.keys(stacks), stacks, config);}
function act(g, kind, amount) {engine.action(g, engine.turn(g), kind, amount, config);}
test('complete checked hand conserves all chips and reveals exactly five board cards', () => {
const g = game();
for (let i = 0; i < 20 && g.phase === 'betting'; i++) act(g, 'hit');
assert.equal(g.phase, 'intermission'); assert.equal(g.communityCards.length, 5);
assert.equal(Object.values(g.stacks).reduce((a,b) => a+b,0), 600); assert.equal(g.pot, 0);
});
test('short all-in does not reopen raising for an earlier actor', () => {
const g = game({a: 200, b: 200, c: 25});
assert.equal(engine.turn(g), 'b'); act(g, 'bet', 10);
assert.equal(engine.turn(g), 'c'); act(g, 'bet', 15);
assert.equal(engine.turn(g), 'a'); act(g, 'hit');
assert.equal(engine.turn(g), 'b');
assert.throws(() => act(g, 'bet', 20), error => error.code === 'RAISE_NOT_REOPENED');
act(g, 'hit'); assert.equal(g.street, 'flop');
});
test('minimum raise and whole-chip validation do not change state on failure', () => {
const g = game(); act(g, 'bet', 20);
const before = JSON.stringify(g);
for (const amount of [21, 0.5, '20', -1, Infinity]) assert.throws(() => act(g, 'bet', amount));
assert.equal(JSON.stringify(g), before);
});
test('timeout folds facing a bet and checks for free', () => {
const g = game(); act(g, 'bet', 20);
const player = engine.turn(g), stack = g.stacks[player];
act(g, 'timeout'); assert.equal(g.stacks[player], stack); assert.equal(g.folded[player], true);
const free = game(); act(free, 'timeout'); assert.equal(free.folded.b, false); assert.equal(free.stacks.b, 190);
});
test('ante-only all-ins run out immediately and settlement pays exactly the pot', () => {
const g = game({a: 10, b: 10}); assert.equal(g.phase, 'intermission');
assert.equal(g.communityCards.length, 5); assert.equal(g.stacks.a + g.stacks.b, 20);
});
test('leaving preserves seat order and refunds unclaimable contribution layers', () => {
const g = game(); const current = engine.turn(g); engine.leave(g, 'a', config);
assert.equal(engine.turn(g), current); engine.leave(g, 'b', config);
assert.equal(g.phase, 'intermission'); assert.equal(Object.values(g.stacks).reduce((a,b) => a+b,0), 600);
});
+35
View File
@@ -0,0 +1,35 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const {createRepository} = require('../lib/repository');
const {migrateLegacy} = require('../scripts/migrate-legacy');
test('legacy import validates all rows, backs up source, and is idempotent', async t => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(),'gambling-migration-'));
t.after(()=>fs.rm(directory,{recursive:true,force:true}));
const file = path.join(directory,'data.json');
const repo = await createRepository(':memory:'); t.after(()=>repo.close());
const source = JSON.stringify([{username:' Alice ',password:'password',money:123},{username:'bob',password:'password',money:200}]);
await fs.writeFile(file,source);
const result = await migrateLegacy(repo,file,4);
assert.equal(result.imported,2); assert.equal(await fs.readFile(result.backup,'utf8'),source);
assert.equal(await fs.readFile(file,'utf8'),source);
assert.equal((await migrateLegacy(repo,file,4)).alreadyApplied,true);
assert.equal((await repo.user('alice')).money,123);
await fs.writeFile(file,JSON.stringify([{username:'valid',password:'password'},{username:'invalid',password:'password',money:-1}]));
await assert.rejects(migrateLegacy(repo,file,4),/Invalid legacy balance/);
assert.equal(await repo.user('valid'),undefined);
});
test('migration database failure rolls back every row and records no applied marker', async t => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(),'gambling-migration-'));
t.after(()=>fs.rm(directory,{recursive:true,force:true}));
const file=path.join(directory,'data.json');
const repo=await createRepository(':memory:'); t.after(()=>repo.close());
await repo.register('existing','unused',null);
await fs.writeFile(file,JSON.stringify([{username:'new-user',password:'password'},{username:'existing',password:'password'}]));
await assert.rejects(migrateLegacy(repo,file,4),/already exists/);
assert.equal(await repo.user('new-user'),undefined);
await assert.rejects(repo.transaction(tx=>tx.run('UPDATE users SET money = -1 WHERE username = ?', 'existing')));
assert.equal((await repo.user('existing')).money,200);
});
+99
View File
@@ -0,0 +1,99 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const {createApplication} = require('../gambling');
const {createRepository} = require('../lib/repository');
const {createLobbyService} = require('../lib/lobbies');
const secret = 'test-secret-with-at-least-thirty-two-characters';
const command = (service, username, type, fields = {}) => service.command(username, {type, requestId: crypto.randomUUID(), ...fields});
async function setup(t, overrides = {}) {
let now = 100000;
const api = await createApplication({secret, databasePath: ':memory:', bcryptRounds: 4, now: () => now, intermissionMs: 60000, ...overrides});
t.after(() => api.close());
for (const username of ['a','b','c']) await api.sessions.register({username, password: 'password'});
if (overrides.startingBalances) await api.repository.transaction(async tx => {
for (const [username, money] of Object.entries(overrides.startingBalances)) await tx.run('UPDATE users SET money = ? WHERE username = ?', money, username);
});
const {lobbyId} = await command(api.lobbies, 'a', 'lobby:create');
for (const username of ['b','c']) await command(api.lobbies, username, 'lobby:join', {lobbyId});
await command(api.lobbies, 'a', 'poker:start', {lobbyId});
const intermission = api.lobbies.get(lobbyId).game; now = intermission.nextRoundStartsAt;
await command(api.lobbies, null, 'timer', {lobbyId, handId: intermission.id, phase: 'intermission', deadline: now});
return {api, lobbyId};
}
test('accepted actions persist once; failed commits leave both memory and database unchanged', async t => {
let fail = false;
const {api, lobbyId} = await setup(t, {repositoryHooks: {beforeCommit: async () => {if (fail) {fail = false; throw new Error('Injected disk failure');}}}});
const message = {type: 'poker:action', lobbyId, requestId: crypto.randomUUID(), action: 'bet', payload: {amount: 20}};
const before = JSON.stringify(api.lobbies.get(lobbyId)); fail = true;
await assert.rejects(api.lobbies.command('b', message), /Injected/);
assert.equal(JSON.stringify(api.lobbies.get(lobbyId)), before);
assert.equal((await api.repository.user('b')).money, 190);
await Promise.all([api.lobbies.command('b', message), api.lobbies.command('b', message)]);
assert.equal((await api.repository.user('b')).money, 170);
assert.equal(api.lobbies.get(lobbyId).game.pot, 50);
await assert.rejects(api.lobbies.command('b', {...message, action: 'fold'}), error => error.code === 'IDEMPOTENCY_CONFLICT');
assert.deepEqual(await api.repository.totals(), {users: 550, escrow: 50});
});
test('concurrent account joins and table starts serialize; simultaneous leaves conserve chips', async t => {
const {api, lobbyId} = await setup(t);
const results = await Promise.allSettled([command(api.lobbies, 'a', 'lobby:create'), command(api.lobbies, 'a', 'poker:start', {lobbyId})]);
assert.ok(results.every(result => result.status === 'rejected'));
await Promise.all(['a', 'b', 'c'].map(username => command(api.lobbies, username, 'lobby:leave', {lobbyId})));
assert.deepEqual(await api.repository.totals(), {users: 600, escrow: 0}); assert.equal(api.lobbies.get(lobbyId), undefined);
});
test('stale timer cannot act on a new turn', async t => {
const {api, lobbyId} = await setup(t);
const original = api.lobbies.get(lobbyId);
const game = original.game;
await command(api.lobbies, 'b', 'poker:action', {lobbyId, action: 'hit'});
const before = JSON.stringify(api.lobbies.get(lobbyId));
await command(api.lobbies, null, 'timer', {lobbyId, handId: game.id, phase: 'betting', deadline: game.turnEndsAt, revision: original.revision});
assert.equal(JSON.stringify(api.lobbies.get(lobbyId)), before);
});
test('unequal all-ins settle correct side pots atomically even after a settlement write fails', async t => {
let fail = false;
const draws = 'AS AH KS KH QS QH 2D 3C 7S 8H 9D'.split(' ').map(value => ({rank:value.slice(0,-1),suit:value.slice(-1)}));
const {api, lobbyId} = await setup(t, {startingBalances:{a:60,b:200,c:100}, createDeck:()=>[
...require('../lib/poker').createDeck().filter(card=>!draws.some(d=>d.rank===card.rank && d.suit===card.suit)), ...draws.toReversed()
], repositoryHooks:{beforeCommit:async()=>{if(fail){fail=false;throw new Error('Settlement disk failure');}}}});
await command(api.lobbies,'b','poker:action',{lobbyId,action:'bet',payload:{amount:190}});
await command(api.lobbies,'c','poker:action',{lobbyId,action:'hit'});
const message={type:'poker:action',lobbyId,requestId:crypto.randomUUID(),action:'hit'};
fail=true;
await assert.rejects(api.lobbies.command('a',message),/Settlement disk failure/);
assert.equal(api.lobbies.get(lobbyId).game.phase,'betting');
assert.deepEqual(await api.repository.totals(),{users:50,escrow:310});
await api.lobbies.command('a',message);
await api.lobbies.command('a',message);
const game=api.lobbies.get(lobbyId).game;
assert.equal(game.phase,'intermission');
assert.deepEqual({...game.payouts},{a:180,b:180});
for(const username of ['a','b','c']) assert.equal((await api.repository.user(username)).money,game.stacks[username]);
assert.deepEqual(await api.repository.totals(),{users:360,escrow:0});
});
test('reopening a database refunds interrupted escrow exactly once', async t => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'gambling-recovery-'));
t.after(() => fs.rm(directory, {recursive: true, force: true}));
const filename = path.join(directory, 'test.sqlite');
const repo = await createRepository(filename);
for (const username of ['a','b']) await repo.register(username, 'unused-test-hash', null);
let now = 1;
const service = createLobbyService(repo, {ante: 10, intermissionMs: 60000, turnMs: 30000, now: () => now});
const {lobbyId} = await command(service, 'a', 'lobby:create');
await command(service, 'b', 'lobby:join', {lobbyId}); await command(service, 'a', 'poker:start', {lobbyId});
const game = service.get(lobbyId).game; now = game.nextRoundStartsAt;
await command(service, null, 'timer', {lobbyId, handId: game.id, phase: 'intermission', deadline: now});
await command(service, 'b', 'poker:action', {lobbyId, action: 'bet', payload: {amount: 30}});
assert.deepEqual(await repo.totals(), {users: 350, escrow: 50});
await service.close(); await repo.close();
const restarted = await createRepository(filename);
try {
await restarted.recover(); await restarted.recover();
assert.deepEqual(await restarted.totals(), {users: 400, escrow: 0});
assert.equal((await restarted.user('a')).money, 200); assert.equal((await restarted.user('b')).money, 200);
} finally {await restarted.close();}
});