Hashed seat tokens, ledgered inputs, refusable moves, disk-checked codes, host-only bots, and a drift script
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ddb18cb08a
commit
c934bbe434
+12
-5
@@ -25,9 +25,13 @@ README.
|
|||||||
ledger line, so it replays with the game, and the hall counts lines said
|
ledger line, so it replays with the game, and the hall counts lines said
|
||||||
while you were away.
|
while you were away.
|
||||||
- **The ledger** is the append-only JSONL file that *is* the game: room,
|
- **The ledger** is the append-only JSONL file that *is* the game: room,
|
||||||
seats, start (seed and rules revision), turns (inputs only), talk, over.
|
seats, start (seed and rules revision), each seat's submitted input, the
|
||||||
A room is its ledger replayed through a deterministic engine; nothing else
|
turn that resolves them, talk, over. A room is its ledger replayed
|
||||||
is saved. Ledgers survive deploys and restarts and are backed up nightly.
|
through a deterministic engine; nothing else is saved. A half-written
|
||||||
|
round survives a restart because every input is written as it arrives.
|
||||||
|
Ledgers survive deploys and restarts and are backed up nightly, so they
|
||||||
|
hold nothing secret: a seat line carries the SHA-256 of its token, never
|
||||||
|
the token, which goes once to the browser that earned it.
|
||||||
- **Report** in the masthead opens the slip: what happened, what you
|
- **Report** in the masthead opens the slip: what happened, what you
|
||||||
expected, a screenshot if you have one. It is pinned to the room and the
|
expected, a screenshot if you have one. It is pinned to the room and the
|
||||||
round. **The keeper** replies; the reply appears under the report in the
|
round. **The keeper** replies; the reply appears under the report in the
|
||||||
@@ -68,8 +72,11 @@ a sandboxed systemd service, behind Caddy `handle` blocks that route `/api`
|
|||||||
and `/ws` before the static files. Rooms in memory are evicted after a week
|
and `/ws` before the static files. Rooms in memory are evicted after a week
|
||||||
idle and come back from disk on the next visit. Rate limits sit on the doors
|
idle and come back from disk on the next visit. Rate limits sit on the doors
|
||||||
a stranger can knock on (rooms, seats, talk, the desk); a table of friends
|
a stranger can knock on (rooms, seats, talk, the desk); a table of friends
|
||||||
never nears them. Seat tokens never leave the server except to the browser
|
never nears them. A seat token is minted once, sent once, and compared by
|
||||||
that earned them; a shared link carries none.
|
hash in constant time ever after; a shared link carries none. Room codes
|
||||||
|
are checked against the disk as well as memory, so an evicted room's code
|
||||||
|
is never reissued over its ledger. A game may refuse a move with a reason
|
||||||
|
(`validate`), and the player sees the reason.
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,25 @@ Placeholders: `__SLUG__`, `__NAME__`, `__PORT__`, `__DOMAIN__` are filled by
|
|||||||
`__SENTRY_SLACK_INTEGRATION__` and `__SLACK_CHANNEL_ID__` wait until those
|
`__SENTRY_SLACK_INTEGRATION__` and `__SLACK_CHANNEL_ID__` wait until those
|
||||||
things exist.
|
things exist.
|
||||||
|
|
||||||
## Keeping the kit current
|
## What the kit is, and is not
|
||||||
|
|
||||||
When a game grows something every game should have (the gallery and the
|
The model is copy and diverge. A new game starts as a copy of the template
|
||||||
reports desk were both born in one game and ported to the other), bring it
|
and then goes its own way; the two games that came before it are not
|
||||||
back here, with placeholders, so the next game inherits it. The kit is the
|
instances of the template and were not rewritten to be. The kit holds the
|
||||||
canonical copy of the shared parts; the games are its instances.
|
reference copy of the shared parts and the conventions they follow, so a
|
||||||
|
third game starts from the best current version of everything.
|
||||||
|
|
||||||
|
Drift is meant to be visible, not prevented:
|
||||||
|
|
||||||
|
./drift.sh ../waving-hands # how far each shared file has wandered
|
||||||
|
|
||||||
|
`drift-map/<slug>.txt` pairs files when a game's layout differs from the
|
||||||
|
template's. When a game grows something every game should have (the
|
||||||
|
gallery and the reports desk were both born in one game and ported to the
|
||||||
|
other), bring it back here, with placeholders, so the next game inherits
|
||||||
|
it, and read the drift before assuming the kit has it.
|
||||||
|
|
||||||
|
The contract is simultaneous rounds: every seat that needs input submits,
|
||||||
|
then the round resolves; turn-at-a-time games fit as the case where one
|
||||||
|
seat needs input at a time. A game of single commands with out-of-turn
|
||||||
|
interruptions, Wiz-War's shape, does not fit and keeps its own server.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
src/lib/net/client.ts src/lib/game/client.ts
|
||||||
|
src/lib/net/talk.ts src/lib/game/talk.ts
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Show how far a game's shared files have drifted from the kit's template.
|
||||||
|
# ./drift.sh <game-dir> [slug] e.g. ./drift.sh ../waving-hands waving-hands
|
||||||
|
# Files are paired by drift-map/<slug>.txt when a game's layout differs from
|
||||||
|
# the template's ("template/path game/path" per line); otherwise the same
|
||||||
|
# path is compared. Placeholders are filled with the slug before diffing, so
|
||||||
|
# only real divergence shows. Prints changed lines per file, then the total.
|
||||||
|
set -euo pipefail
|
||||||
|
GAME="${1:?usage: drift.sh <game-dir> [slug]}"
|
||||||
|
SLUG="${2:-$(basename "$(cd "$GAME" && pwd)")}"
|
||||||
|
KIT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
MAP="$KIT/drift-map/$SLUG.txt"
|
||||||
|
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts
|
||||||
|
src/lib/net/client.ts src/lib/net/talk.ts src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte
|
||||||
|
deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh
|
||||||
|
deploy/replay-ledgers.ts deploy/pulse.sh deploy/visitors.sh deploy/sentry-slack-alert.sh deploy/pull-reports.sh deploy/report-reply.sh
|
||||||
|
deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh"
|
||||||
|
total=0
|
||||||
|
for t in $SHARED; do
|
||||||
|
g="$t"
|
||||||
|
if [ -f "$MAP" ]; then
|
||||||
|
m=$(awk -v t="$t" '$1 == t { print $2 }' "$MAP")
|
||||||
|
[ -n "$m" ] && g="$m"
|
||||||
|
fi
|
||||||
|
g="${g//__SLUG__/$SLUG}"
|
||||||
|
if [ ! -f "$GAME/$g" ]; then printf '%-44s %s\n' "$t" "missing in game ($g)"; continue; fi
|
||||||
|
n=$(diff <(sed -e "s/__SLUG__/$SLUG/g" "$KIT/template/$t") "$GAME/$g" | grep -c '^[<>]' || true)
|
||||||
|
total=$((total + n))
|
||||||
|
printf '%-44s %4d lines differ\n' "$t" "$n"
|
||||||
|
done
|
||||||
|
echo "total: $total lines differ from the template"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// One-time migration: replace each seat line's raw token with its SHA-256,
|
||||||
|
// so no ledger on disk or in a backup holds a live seat key. Idempotent; a
|
||||||
|
// line already hashed is left alone. Run ON the droplet from the server
|
||||||
|
// directory (tsx is installed there):
|
||||||
|
// cd /opt/__SLUG__/app/server && npx tsx ../deploy/hash-tokens.ts /var/lib/__SLUG__/rooms
|
||||||
|
// Browsers keep their raw tokens; the server hashes what they send and
|
||||||
|
// compares, so nobody loses a seat.
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readdirSync, readFileSync, renameSync, statSync, writeFileSync, chownSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const dir = process.argv[2];
|
||||||
|
if (!dir) {
|
||||||
|
console.error('usage: hash-tokens.ts <ledger-dir>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = 0;
|
||||||
|
let lines = 0;
|
||||||
|
for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) {
|
||||||
|
const path = join(dir, file);
|
||||||
|
const raw = readFileSync(path, 'utf8');
|
||||||
|
let changed = 0;
|
||||||
|
const out = raw
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
if (!line.trim()) return line;
|
||||||
|
const entry = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
if (entry.t !== 'seat' || typeof entry.token !== 'string') return line;
|
||||||
|
const { token, ...rest } = entry;
|
||||||
|
changed += 1;
|
||||||
|
return JSON.stringify({ ...rest, tokenHash: token ? createHash('sha256').update(token).digest('hex') : '' });
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
if (!changed) continue;
|
||||||
|
const { uid, gid } = statSync(path);
|
||||||
|
writeFileSync(path + '.tmp', out);
|
||||||
|
chownSync(path + '.tmp', uid, gid);
|
||||||
|
renameSync(path + '.tmp', path);
|
||||||
|
files += 1;
|
||||||
|
lines += changed;
|
||||||
|
}
|
||||||
|
console.log(`${files} ledger${files === 1 ? '' : 's'} rewritten, ${lines} seat line${lines === 1 ? '' : 's'} hashed`);
|
||||||
@@ -13,7 +13,6 @@ import type { SeatId } from '../src/lib/game/spec';
|
|||||||
interface Seat {
|
interface Seat {
|
||||||
id: SeatId;
|
id: SeatId;
|
||||||
name: string;
|
name: string;
|
||||||
token: string;
|
|
||||||
bot: boolean;
|
bot: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +51,9 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
const outcome = state ? game.over(state) : null;
|
const outcome = state ? game.over(state) : null;
|
||||||
let verdict = state ? `${turns} turns, ${outcome ? 'over' : 'in play'}` : 'not started';
|
let verdict = state ? `${turns} turns, ${outcome ? 'over' : 'in play'}` : 'not started';
|
||||||
const human = seats.find((s) => !s.bot);
|
if (host && state) {
|
||||||
if (host && state && human) {
|
// The gallery's view carries the round and the outcome, which is all the comparison needs.
|
||||||
const res = await fetch(`${host}/api/rooms/${code}?token=${encodeURIComponent(human.token)}`);
|
const res = await fetch(`${host}/api/rooms/${code}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
verdict += `, server ${res.status}`;
|
verdict += `, server ${res.status}`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// POST /api/rooms {name, size?} create a room and take seat A
|
// POST /api/rooms {name, size?} create a room and take seat A
|
||||||
// POST /api/rooms/:id/join {name} take the next seat
|
// POST /api/rooms/:id/join {name} take the next seat
|
||||||
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the game begins)
|
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
|
||||||
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
||||||
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
|
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
|
||||||
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
|
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
|
||||||
@@ -171,8 +171,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|||||||
if (parts.length === 2 && req.method === 'POST') {
|
if (parts.length === 2 && req.method === 'POST') {
|
||||||
if (!doors.allow(clientOf(req))) throw new RoomError('Too many rooms opened from here just now; try again later.', 429);
|
if (!doors.allow(clientOf(req))) throw new RoomError('Too many rooms opened from here just now; try again later.', 429);
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats));
|
const { room, seat, token } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats));
|
||||||
return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
|
return send(res, 201, { seat: seat.id, token, view: rooms.view(room, seat.id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const room = rooms.get(parts[2] ?? '');
|
const room = rooms.get(parts[2] ?? '');
|
||||||
@@ -187,8 +187,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|||||||
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
|
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
|
||||||
if (action === 'join') {
|
if (action === 'join') {
|
||||||
if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429);
|
if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429);
|
||||||
const seat = rooms.join(room, String(body.name ?? ''));
|
const { seat, token: minted } = rooms.join(room, String(body.name ?? ''));
|
||||||
return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
|
return send(res, 200, { seat: seat.id, token: minted, view: rooms.view(room, seat.id) });
|
||||||
}
|
}
|
||||||
if (action === 'bot') {
|
if (action === 'bot') {
|
||||||
rooms.addBot(room, token);
|
rooms.addBot(room, token);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// then the round resolves at once, bots included. Nothing here knows the
|
// then the round resolves at once, bots included. Nothing here knows the
|
||||||
// game beyond the GameSpec it is given.
|
// game beyond the GameSpec it is given.
|
||||||
|
|
||||||
import { randomBytes } from 'node:crypto';
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
import { SPECTATOR, type GameSpec, type SeatId } from '../../src/lib/game/spec';
|
import { SPECTATOR, type GameSpec, type SeatId } from '../../src/lib/game/spec';
|
||||||
import type { ChatLine, RoomView } from '../../src/lib/net/view';
|
import type { ChatLine, RoomView } from '../../src/lib/net/view';
|
||||||
import type { LedgerLine, Store } from './store';
|
import type { LedgerLine, Store } from './store';
|
||||||
@@ -17,10 +17,15 @@ export { SPECTATOR };
|
|||||||
export interface Seat {
|
export interface Seat {
|
||||||
id: SeatId;
|
id: SeatId;
|
||||||
name: string;
|
name: string;
|
||||||
token: string;
|
/** SHA-256 of the seat's token, hex; empty for a bot. */
|
||||||
|
tokenHash: string;
|
||||||
bot: boolean;
|
bot: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
export interface Room<State> {
|
export interface Room<State> {
|
||||||
id: string;
|
id: string;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -122,28 +127,29 @@ export class Rooms<State, Input> {
|
|||||||
return this.rooms.size;
|
return this.rooms.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The seat a token unlocks. */
|
/** The seat a token unlocks, compared by hash in constant time. */
|
||||||
seatOf(room: Room<State>, token: string | undefined): Seat {
|
seatOf(room: Room<State>, token: string | undefined): Seat {
|
||||||
const seat = room.seats.find((s) => !s.bot && s.token === token);
|
if (!token) throw new RoomError('That token opens no seat at this game.', 403);
|
||||||
|
const given = Buffer.from(hashToken(token), 'hex');
|
||||||
|
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
|
||||||
if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
|
if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
|
||||||
return seat;
|
return seat;
|
||||||
}
|
}
|
||||||
|
|
||||||
create(name: string, size: number): { room: Room<State>; seat: Seat } {
|
create(name: string, size: number): { room: Room<State>; seat: Seat; token: string } {
|
||||||
const { minSeats } = this.game;
|
const { minSeats } = this.game;
|
||||||
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
|
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
|
||||||
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
|
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
|
||||||
}
|
}
|
||||||
let id = newCode();
|
let id = newCode();
|
||||||
while (this.rooms.has(id)) id = newCode();
|
while (this.rooms.has(id) || this.store.exists(id)) id = newCode();
|
||||||
const room: Room<State> = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
|
const room: Room<State> = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
|
||||||
this.rooms.set(id, room);
|
this.rooms.set(id, room);
|
||||||
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
|
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
|
||||||
const seat = this.sit(room, name, false);
|
return { room, ...this.sit(room, name, false) };
|
||||||
return { room, seat };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
join(room: Room<State>, name: string): Seat {
|
join(room: Room<State>, name: string): { seat: Seat; token: string } {
|
||||||
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
|
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
|
||||||
return this.sit(room, name, false);
|
return this.sit(room, name, false);
|
||||||
}
|
}
|
||||||
@@ -157,23 +163,27 @@ export class Rooms<State, Input> {
|
|||||||
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
|
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The host seats a bot in an empty chair. */
|
||||||
addBot(room: Room<State>, token: string | undefined): Seat {
|
addBot(room: Room<State>, token: string | undefined): Seat {
|
||||||
this.seatOf(room, token);
|
const host = this.seatOf(room, token);
|
||||||
|
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may seat a bot.', 403);
|
||||||
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
|
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
|
||||||
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
|
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
|
||||||
const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase()));
|
const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase()));
|
||||||
const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct';
|
const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct';
|
||||||
return this.sit(room, name, true);
|
return this.sit(room, name, true).seat;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sit(room: Room<State>, rawName: string, bot: boolean): Seat {
|
/** Take the next free seat. The token is returned once and never stored; the ledger keeps its hash. */
|
||||||
|
private sit(room: Room<State>, rawName: string, bot: boolean): { seat: Seat; token: string } {
|
||||||
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409);
|
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409);
|
||||||
const name = cleanName(rawName);
|
const name = cleanName(rawName);
|
||||||
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
|
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
|
||||||
const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!;
|
const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!;
|
||||||
const seat: Seat = { id, name, token: bot ? '' : newId(18), bot };
|
const token = bot ? '' : newId(18);
|
||||||
this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot });
|
const seat: Seat = { id, name, tokenHash: bot ? '' : hashToken(token), bot };
|
||||||
return seat;
|
this.commit(room, { t: 'seat', id: seat.id, name, tokenHash: seat.tokenHash, bot });
|
||||||
|
return { seat, token };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The host sends a bot away before the game begins. People leave by not coming back. */
|
/** The host sends a bot away before the game begins. People leave by not coming back. */
|
||||||
@@ -186,17 +196,17 @@ export class Rooms<State, Input> {
|
|||||||
this.commit(room, { t: 'unseat', id: seat.id });
|
this.commit(room, { t: 'unseat', id: seat.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record a seat's move; resolve the round once nobody else is awaited. */
|
/** Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. */
|
||||||
submit(room: Room<State>, token: string | undefined, raw: unknown): void {
|
submit(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||||
const seat = this.seatOf(room, token);
|
const seat = this.seatOf(room, token);
|
||||||
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
|
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
|
||||||
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
|
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
|
||||||
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
|
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
|
||||||
room.pending[seat.id] = this.game.cleanInput(raw);
|
const input = this.game.cleanInput(raw);
|
||||||
room.updatedAt = Date.now();
|
const why = this.game.validate?.(room.state, seat.id, input);
|
||||||
room.seq += 1;
|
if (why) throw new RoomError(why);
|
||||||
|
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input });
|
||||||
if (this.waitingOn(room).length === 0) this.resolve(room);
|
if (this.waitingOn(room).length === 0) this.resolve(room);
|
||||||
this.notify(room);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolve(room: Room<State>): void {
|
private resolve(room: Room<State>): void {
|
||||||
@@ -295,7 +305,10 @@ export class Rooms<State, Input> {
|
|||||||
case 'over':
|
case 'over':
|
||||||
break;
|
break;
|
||||||
case 'seat':
|
case 'seat':
|
||||||
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot });
|
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
|
||||||
|
break;
|
||||||
|
case 'input':
|
||||||
|
room.pending[line.id] = this.game.cleanInput(line.input);
|
||||||
break;
|
break;
|
||||||
case 'unseat':
|
case 'unseat':
|
||||||
room.seats = room.seats.filter((s) => s.id !== line.id);
|
room.seats = room.seats.filter((s) => s.id !== line.id);
|
||||||
@@ -321,7 +334,7 @@ export class Rooms<State, Input> {
|
|||||||
const room: Room<State> = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
|
const room: Room<State> = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
this.apply(room, line);
|
this.apply(room, line);
|
||||||
if (line.t === 'turn') room.updatedAt = line.at;
|
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
|
||||||
}
|
}
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import type { SeatId } from '../../src/lib/game/spec';
|
|||||||
|
|
||||||
export type LedgerLine =
|
export type LedgerLine =
|
||||||
| { t: 'room'; id: string; seats: number; createdAt: number }
|
| { t: 'room'; id: string; seats: number; createdAt: number }
|
||||||
| { t: 'seat'; id: SeatId; name: string; token: string; bot: boolean }
|
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
|
||||||
|
| { t: 'seat'; id: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean }
|
||||||
|
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
|
||||||
|
| { t: 'input'; at: number; id: SeatId; input: unknown }
|
||||||
/** A bot sent away by the host before the game began; its seat id is free again. */
|
/** A bot sent away by the host before the game began; its seat id is free again. */
|
||||||
| { t: 'unseat'; id: SeatId }
|
| { t: 'unseat'; id: SeatId }
|
||||||
/** Ledgers from before revisions were recorded resolve under rules 1. */
|
/** Ledgers from before revisions were recorded resolve under rules 1. */
|
||||||
@@ -41,6 +44,11 @@ export class Store {
|
|||||||
.map((l) => JSON.parse(l) as LedgerLine);
|
.map((l) => JSON.parse(l) as LedgerLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a ledger exists on disk, loaded or not; a code must not be reissued over one. */
|
||||||
|
exists(roomId: string): boolean {
|
||||||
|
return existsSync(this.path(roomId));
|
||||||
|
}
|
||||||
|
|
||||||
roomIds(): string[] {
|
roomIds(): string[] {
|
||||||
return readdirSync(this.dir)
|
return readdirSync(this.dir)
|
||||||
.filter((f) => f.endsWith('.jsonl'))
|
.filter((f) => f.endsWith('.jsonl'))
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{#if !room.spectating}
|
{#if !room.spectating}
|
||||||
<div class="ways">
|
<div class="ways">
|
||||||
{#if seatFree}
|
{#if seatFree && room.isHost}
|
||||||
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
|
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if room.isHost}
|
{#if room.isHost}
|
||||||
|
|||||||
@@ -87,9 +87,8 @@ export const game: GameSpec<State, Input> = {
|
|||||||
view: (state, viewer) => (viewer === SPECTATOR ? structuredClone(state) : structuredClone(state)),
|
view: (state, viewer) => (viewer === SPECTATOR ? structuredClone(state) : structuredClone(state)),
|
||||||
// A function of the state alone: the seed, the round and the seat, so a replay draws the same pick.
|
// A function of the state alone: the seed, the round and the seat, so a replay draws the same pick.
|
||||||
botInput: (state, seat) => ({ pick: 1 + Math.floor(random(state.rng + state.rounds.length * 7919 + state.seats.indexOf(seat)) * HIGHEST) }),
|
botInput: (state, seat) => ({ pick: 1 + Math.floor(random(state.rng + state.rounds.length * 7919 + state.seats.indexOf(seat)) * HIGHEST) }),
|
||||||
cleanInput: (raw) => {
|
cleanInput: (raw) => ({ pick: Number((raw as { pick?: unknown })?.pick) }),
|
||||||
const pick = Number((raw as { pick?: unknown })?.pick);
|
validate: (_state, _seat, input) =>
|
||||||
return { pick: Number.isInteger(pick) && pick >= 1 && pick <= HIGHEST ? pick : 1 };
|
Number.isInteger(input.pick) && input.pick >= 1 && input.pick <= HIGHEST ? null : `Name a number from 1 to ${HIGHEST}.`,
|
||||||
},
|
|
||||||
nameOf: (state, seat) => state.players[seat]?.name ?? seat
|
nameOf: (state, seat) => state.players[seat]?.name ?? seat
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
// inputs at a time, ask who still has to move, and hand each viewer the
|
// inputs at a time, ask who still has to move, and hand each viewer the
|
||||||
// view they are allowed to see. A new game implements GameSpec once, in
|
// view they are allowed to see. A new game implements GameSpec once, in
|
||||||
// src/lib/game/index.ts, and the rest of the kit works unchanged.
|
// src/lib/game/index.ts, and the rest of the kit works unchanged.
|
||||||
|
//
|
||||||
|
// The shape is simultaneous rounds: every seat that needs input submits, then
|
||||||
|
// the round resolves. Turn-at-a-time games fit too (only one seat needs input
|
||||||
|
// at once). A game of single commands with out-of-turn interruptions, like
|
||||||
|
// Wiz-War, does not fit this contract and needs its own server.
|
||||||
|
|
||||||
/** A seat at the table: a single letter from GameSpec.seatIds. */
|
/** A seat at the table: a single letter from GameSpec.seatIds. */
|
||||||
export type SeatId = string;
|
export type SeatId = string;
|
||||||
@@ -43,6 +48,8 @@ export interface GameSpec<State, Input> {
|
|||||||
botInput(state: State, seat: SeatId): Input;
|
botInput(state: State, seat: SeatId): Input;
|
||||||
/** Only the shapes the engine understands get through; the engine validates the rest. */
|
/** Only the shapes the engine understands get through; the engine validates the rest. */
|
||||||
cleanInput(raw: unknown): Input;
|
cleanInput(raw: unknown): Input;
|
||||||
|
/** Why this seat may not make this move now, or null when it may. The server answers with the reason. */
|
||||||
|
validate?(state: State, seat: SeatId, input: Input): string | null;
|
||||||
/** A seat's name from the state, for the hall and the chronicle. */
|
/** A seat's name from the state, for the hall and the chronicle. */
|
||||||
nameOf(state: State, seat: SeatId): string;
|
nameOf(state: State, seat: SeatId): string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user