try to fix this spaghetti of a code
This commit is contained in:
+6
-3
@@ -1,5 +1,8 @@
|
|||||||
# Generate a secret with: node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
|
# Generate a value with: node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
|
||||||
TOKEN_SECRET=replace-with-a-random-secret
|
TOKEN_SECRET=replace-with-a-generated-secret-at-least-32-characters
|
||||||
|
HOST=127.0.0.1
|
||||||
PORT=42069
|
PORT=42069
|
||||||
# DATABASE_PATH=users.sqlite
|
# DATABASE_PATH=users.sqlite
|
||||||
# AUTH_DEBUG=true
|
# PUBLIC_ORIGIN=https://poker.example.com
|
||||||
|
# Comma-separated IPs/subnets of proxies you control; unset means no trust.
|
||||||
|
# TRUST_PROXY=loopback
|
||||||
|
|||||||
@@ -1,43 +1,52 @@
|
|||||||
# Gambling API
|
# Gambling API
|
||||||
|
|
||||||
A small multiplayer poker prototype using Express, WebSockets, SQLite, and a vanilla JavaScript frontend. Accounts start with 200 virtual chips.
|
Multiplayer virtual-chip poker with Express, WebSockets, SQLite, and a vanilla JavaScript frontend. Accounts start with 200 chips. See [the rules](docs/RULES.md) for the ante-based variant.
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
Use Node.js 24 (the version used for validation) and npm.
|
Use Node.js 24 or newer.
|
||||||
|
|
||||||
1. Run `npm install`.
|
1. Run `npm ci`.
|
||||||
2. Copy `.env.example` to `.env` and replace `TOKEN_SECRET` with a generated secret. The generation command is in the example file.
|
2. Copy `.env.example` to `.env` and generate a random `TOKEN_SECRET` using the command in that file.
|
||||||
3. Run `npm start` and open <http://localhost:42069/login>.
|
3. Run `npm start` and visit <http://127.0.0.1:42069/login>.
|
||||||
4. Register two accounts in separate browser profiles, create a lobby, join with the second account, and start the game as host.
|
4. Register two accounts in separate browser profiles. Create a lobby, join with the other account, and start as host.
|
||||||
|
|
||||||
`PORT` overrides the listening port. `DATABASE_PATH` overrides the default `users.sqlite` path. `AUTH_DEBUG=true` enables verbose authentication diagnostics. SQLite files and `.env` are ignored by Git.
|
The default listener is `127.0.0.1:42069`. Configure `HOST`, `PORT`, and `DATABASE_PATH` as needed. For HTTPS proxies, set `PUBLIC_ORIGIN` and explicitly configure `TRUST_PROXY`; see [operations](docs/ARCHITECTURE.md).
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `npm start`: start the application.
|
| Command | Purpose |
|
||||||
- `npm run dev`: restart on source changes.
|
| --- | --- |
|
||||||
- `npm test`: syntax checks and Node regression tests.
|
| `npm start` | Start the server |
|
||||||
- `npm run check`: syntax checks only.
|
| `npm run dev` | Restart on source changes |
|
||||||
- `npm run docs`: regenerate the HTTP OpenAPI document; browse `/swagger-ui` while the server is running.
|
| `npm test` | Syntax, contract freshness, unit/integration tests |
|
||||||
|
| `npm run test:e2e` | Chromium two-player, mobile, reconnect, and process-crash tests |
|
||||||
|
| `npm run docs` | Generate HTTP and WebSocket contracts |
|
||||||
|
| `npm run audit:deps` | Check known dependency vulnerabilities |
|
||||||
|
| `npm run migrate:legacy -- path/to/data.json` | Explicit transactional import; stop the server first |
|
||||||
|
|
||||||
If the environment blocks test child processes, run `npm run check` followed by `node --test --test-isolation=none`. Tests use an in-memory database, a temporary listening port, and skip legacy data migration.
|
Install Chromium before browser tests:
|
||||||
|
|
||||||
## Current behavior
|
```powershell
|
||||||
|
$env:PLAYWRIGHT_BROWSERS_PATH = "$PWD/.playwright-browsers"
|
||||||
|
npx playwright install chromium
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
Sessions last 30 minutes. Logging in replaces the previous session. HTTP clients can use the authorization cookie or `Authorization: Bearer <token>`; browser WebSockets use the cookie. Clients must wait for `auth:success` before sending messages.
|
On Linux/macOS: `PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright-browsers" npx playwright install chromium`. CI installs system dependencies as well. The test runner uses no child-process isolation for unit tests, while browser/crash tests require process-launch permission. Tests use isolated in-memory or temporary databases and leave real accounts untouched.
|
||||||
|
|
||||||
Lobbies hold at most 10 players, and each account may join one lobby at a time. Private lobbies are hidden from the public list but remain joinable by code. The host starts play. A round begins after a 30-second intermission. Each participating player pays a 10-chip ante; players below that amount spectate. There are four betting streets, best-five-of-seven hand evaluation, and contribution-based side pots. `hit` is the legacy protocol name for check/call. Turns time out after 30 seconds and automatically check/call. Disconnects have a 10-second grace period before leaving.
|
## Persistence and upgrades
|
||||||
|
|
||||||
This is a simplified poker variant: it does not yet enforce standard blinds or minimum-raise/reopening rules. Leaving folds the hand. Unclaimable contribution layers are returned to their contributors. See the audit for settlement edge cases still needing work.
|
Accepted commands atomically store account deltas, escrow/table state, a ledger, and an idempotency record. `/balance` and the leaderboard show current spendable chips. Interrupted hands are refunded exactly once at startup; lobbies are then cleared. One process may own a database at a time.
|
||||||
|
|
||||||
Account records persist in SQLite. Lobby and hand state live in memory and are lost on restart. Startup clears stale lobby references. Legacy `data.json` accounts are imported if present; the source is emptied after import, so back it up before migration.
|
**Upgrading from the original version:** stop the server and back up the database/legacy JSON. Startup migrates the SQLite schema and makes an additional backup, preserves accounts and balances, and invalidates old sessions. Users must log in again. Legacy JSON imports are now explicit and never erase the source.
|
||||||
|
|
||||||
## Project layout
|
HTTP create/join/leave/start/action mutations now use POST and require a `requestId`. WebSocket mutation messages require the same ID and a lobbyId where relevant. External clients must update to [the new protocol](docs/PROTOCOL.md). Browser clients are updated.
|
||||||
|
|
||||||
- `gambling.js`: HTTP routes, database access, authentication, lobby lifecycle, and poker state transitions.
|
## Documentation
|
||||||
- `lib/poker.js`: pure hand evaluation, deck generation, and side-pot calculations.
|
|
||||||
- `public/`: HTML, CSS, and browser scripts.
|
- [AUDIT.md](AUDIT.md): completion map and validation evidence for all eight audit items.
|
||||||
- `test/`: poker and HTTP/WebSocket regression tests.
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): module boundaries, transactions, migration, deployment, dependency policy.
|
||||||
- `swagger.js` / `swagger.json`: HTTP documentation generator and output.
|
- [docs/RULES.md](docs/RULES.md): betting, all-ins, timeout, departure, and recovery rules.
|
||||||
- [AUDIT.md](AUDIT.md): findings, completed fixes, and prioritized follow-up work.
|
- [docs/PROTOCOL.md](docs/PROTOCOL.md): HTTP/WebSocket commands, errors, limits, and retry behavior.
|
||||||
|
- `/swagger-ui`: interactive HTTP documentation while the server is running.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const crypto = require('node:crypto');
|
||||||
|
const {createRepository} = require('../lib/repository');
|
||||||
|
const {createLobbyService} = require('../lib/lobbies');
|
||||||
|
(async () => {
|
||||||
|
const repo = await createRepository(process.argv[2]);
|
||||||
|
for (const username of ['a','b']) await repo.register(username,'unused-test-hash',null);
|
||||||
|
let now=1;
|
||||||
|
const lobbies=createLobbyService(repo,{ante:10,now:()=>now,intermissionMs:60000,turnMs:30000});
|
||||||
|
const command=(username,type,fields={})=>lobbies.command(username,{type,requestId:crypto.randomUUID(),...fields});
|
||||||
|
const {lobbyId}=await command('a','lobby:create');
|
||||||
|
await command('b','lobby:join',{lobbyId}); await command('a','poker:start',{lobbyId});
|
||||||
|
const game=lobbies.get(lobbyId).game; now=game.nextRoundStartsAt;
|
||||||
|
await command(null,'timer',{lobbyId,handId:game.id,phase:'intermission',deadline:now});
|
||||||
|
await command('b','poker:action',{lobbyId,action:'bet',payload:{amount:30}});
|
||||||
|
process.send({ready:true,totals:await repo.totals()});
|
||||||
|
})().catch(error=>{console.error(error);process.exit(1);});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const {test,expect} = require('@playwright/test');
|
||||||
|
const {fork} = require('node:child_process');
|
||||||
|
const {once} = require('node:events');
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const {createRepository} = require('../lib/repository');
|
||||||
|
test('a killed process leaves durable escrow that the next owner refunds once',async()=>{
|
||||||
|
const directory=await fs.mkdtemp(path.join(os.tmpdir(),'gambling-crash-'));
|
||||||
|
const filename=path.join(directory,'test.sqlite');
|
||||||
|
const child=fork(path.join(__dirname,'crash-fixture.js'),[filename],{stdio:['ignore','ignore','pipe','ipc'],windowsHide:true});
|
||||||
|
try {
|
||||||
|
const [message]=await once(child,'message');
|
||||||
|
expect(message.totals).toEqual({users:350,escrow:50});
|
||||||
|
const exited=once(child,'exit'); child.kill('SIGKILL'); await exited;
|
||||||
|
const repository=await createRepository(filename);
|
||||||
|
try {await repository.recover();await repository.recover();expect(await repository.totals()).toEqual({users:400,escrow:0});}
|
||||||
|
finally {await repository.close();}
|
||||||
|
} finally {
|
||||||
|
if(child.exitCode===null && child.signalCode===null){const exited=once(child,'exit');child.kill('SIGKILL');await exited;}
|
||||||
|
const relative=path.relative(os.tmpdir(),directory);
|
||||||
|
if(relative.startsWith('..') || path.isAbsolute(relative) || !path.basename(directory).startsWith('gambling-crash-')) throw new Error('Unsafe cleanup path');
|
||||||
|
await fs.rm(directory,{recursive:true,force:true});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -33,3 +33,23 @@ test('migration database failure rolls back every row and records no applied mar
|
|||||||
await assert.rejects(repo.transaction(tx=>tx.run('UPDATE users SET money = -1 WHERE username = ?', 'existing')));
|
await assert.rejects(repo.transaction(tx=>tx.run('UPDATE users SET money = -1 WHERE username = ?', 'existing')));
|
||||||
assert.equal((await repo.user('existing')).money,200);
|
assert.equal((await repo.user('existing')).money,200);
|
||||||
});
|
});
|
||||||
|
const sqlite3 = require('sqlite3');
|
||||||
|
const {open} = require('sqlite');
|
||||||
|
test('existing schema upgrades preserve balances, revoke legacy tokens, and enforce a single owner', async t => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(),'gambling-schema-'));
|
||||||
|
t.after(()=>fs.rm(directory,{recursive:true,force:true}));
|
||||||
|
const filename=path.join(directory,'users.sqlite');
|
||||||
|
const legacy=await open({filename,driver:sqlite3.Database});
|
||||||
|
await legacy.exec("CREATE TABLE users(username TEXT PRIMARY KEY COLLATE NOCASE,password_hash TEXT NOT NULL,token TEXT,money INTEGER NOT NULL,lobby_id TEXT)");
|
||||||
|
await legacy.run('INSERT INTO users VALUES(?,?,?,?,?)','alice','unused-test-hash','old-token',123,'stale-lobby');
|
||||||
|
await legacy.close();
|
||||||
|
const repository=await createRepository(filename);
|
||||||
|
try {
|
||||||
|
assert.equal((await repository.user('alice')).money,123);
|
||||||
|
assert.equal((await repository.user('alice')).sessionHash,null);
|
||||||
|
assert.ok((await fs.readdir(directory)).some(name=>name.endsWith('.backup')));
|
||||||
|
await assert.rejects(createRepository(filename),/in use by process/);
|
||||||
|
} finally {await repository.close();}
|
||||||
|
const reopened=await createRepository(filename);
|
||||||
|
await reopened.close();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user