Compare commits
21
Commits
No files matched your search
+60
-7
@@ -10,10 +10,14 @@ README.
|
||||
- **The hall** is the landing page (`/`). Name, *Play now against the bot*,
|
||||
*Open a table*, *or join one* with a four-letter code, then *your games*
|
||||
(the ledger of tables this browser holds a seat at, with whose move it
|
||||
is and unread talk), *your reports to the keeper*, and *about this game —
|
||||
is and unread talk; for someone coming back it comes first, and finished
|
||||
games wait further down), *your reports to the keeper*, and *about this game —
|
||||
a labor of love*, which opens the about panel: a dialog of tabs (The
|
||||
game, The tally, More games, Send word; a game may add its own, as Dip's
|
||||
Playing here), `About.svelte`, with each game's words in its hall.
|
||||
Playing here), `TabbedDialog.svelte`, with each game's words in its hall.
|
||||
The address names the dialog (`#about`, `#about/tally`), so a plain link
|
||||
opens it; a game may put its guide and rules in dialogs the same way, as
|
||||
Dip does. Its strip of tabs is `Tabs.svelte`.
|
||||
- **A table** is a room. Its code is four letters or digits from an alphabet
|
||||
with no look-alikes, and its link is `/join/CODE`. The masthead button
|
||||
reads *invite friends · room CODE* and copies the link. Whoever opened
|
||||
@@ -27,6 +31,10 @@ README.
|
||||
*lets the Peanut Gallery talk*; then a watcher signs a name no seat holds
|
||||
and their lines read *Name (gallery)*. Each line is a ledger line, so it
|
||||
replays with the game, and the hall counts lines said while you were away.
|
||||
A line said while a game is running carries its round (`turn`). A seat
|
||||
may also speak privately, naming seats (`room.say(text, to)`): the line
|
||||
is shown to its sender and those seats alone, never to the gallery, and
|
||||
a game whose talk is its heart puts the choice of whom in its talk box.
|
||||
- **A rematch**: a finished table may call for one. A new table of the same
|
||||
size opens with the same bots, seats held for the same people, and the
|
||||
caller as host; a second call finds it already open. Held seats refuse
|
||||
@@ -92,11 +100,16 @@ README.
|
||||
- **The tally** sits in the about panel: what the ledgers add up to
|
||||
(tables, games begun and finished, names seated, turns, time at the
|
||||
table, games with a bot, talk), counted on demand from the files and
|
||||
cached a minute. A game adds its own lines through `GameSpec.tally`.
|
||||
cached a minute. A game adds its own lines through `GameSpec.tally` and
|
||||
`GameSpec.tallyBegun`.
|
||||
- **Table options** are the host's choices when opening a table (a
|
||||
variant, a side, a length): the game declares them in `GameSpec.options`,
|
||||
the hall renders them as selects, the room line records them, and
|
||||
`create` receives them. A game with one way to play declares none.
|
||||
`create` receives them. A game with one way to play declares none. An
|
||||
option that changes how many can sit (a smaller map) says so through
|
||||
`GameSpec.seatsFor`. The server, the tally and the ledger replay all read
|
||||
a room line through `cleanOptions` and size a table with `tableSize`,
|
||||
both in `spec.ts`, so each creates the same game from the same ledger.
|
||||
|
||||
## The engine
|
||||
|
||||
@@ -107,7 +120,12 @@ README.
|
||||
- Simultaneous by default: every seat that `needsInput` submits, then the
|
||||
round resolves at once, bots included. A seat that owes an extra turn
|
||||
alone (a time stop, a bonus move) is just a state where only it needs
|
||||
input; the server resolves rounds owed only to bots straight away.
|
||||
input; the server resolves rounds owed only to bots straight away. A few
|
||||
such rounds are played at once; past that (a table every person is out
|
||||
of), the bots play on a round every two seconds so the table can be
|
||||
watched and the server answers everyone else between rounds. The view
|
||||
says so (`playingOn`), and a seat may ask for the rest at once
|
||||
(`room.hurry()`).
|
||||
- Hidden information is the engine's business: `view(state, viewer)`
|
||||
returns what one seat may see, and the SPECTATOR view is the
|
||||
intersection of everyone's. The server sends nothing else.
|
||||
@@ -116,14 +134,49 @@ README.
|
||||
round resolves; keep the old path behind `state.rules < N` and pin both
|
||||
with tests. A fix that diverges from no ledger ships without a bump.
|
||||
|
||||
## Optional hooks
|
||||
|
||||
A game implements only the hooks it needs; without one, the kit behaves as
|
||||
if it were never there.
|
||||
|
||||
- `validate(state, seat, input)`: a reason to refuse a move, shown to the player.
|
||||
- `seatsFor(options)`: fewer seats for some table options.
|
||||
- `settled(input)`: whether an input is final. A game with drafts answers
|
||||
false until the player says it is done; a draft may be replaced (and a
|
||||
settled move taken back as a draft) until the round resolves, and the
|
||||
round waits for every seat's settled input. A seat's view carries its
|
||||
own input in waiting (`yourInput`), so a page reopened shows the draft,
|
||||
and its input in the last round it played (`yourLastInput`), so the page
|
||||
can tell it what became of its own choice.
|
||||
- `rankable(options)`: the choices a table asks each seat to rank before
|
||||
the deal (sides, roles, colours). The lobby shows a seat its own ranking
|
||||
and how many have ranked, nothing more; the start line records every
|
||||
ranking and `create` receives them. A solo table asking for one waits in
|
||||
the lobby for the player's.
|
||||
- `botPress(state, seat, press, people, trigger)`: bots that talk. A bot
|
||||
speaks when a round opens and when a person writes to it (or says a first
|
||||
word to the table); its lines go privately to the people it names, as
|
||||
ledger lines, with a `note` for its own memory that no view ever carries.
|
||||
`botInput` receives the same press, so a bot can keep what was agreed.
|
||||
`parting(press, viewer)` gives the bots' last words once the game is
|
||||
over. Without `botPress`, private talk to a bot is refused.
|
||||
- `tally(state)`: the game's own lines for the tally, from a finished game;
|
||||
`tallyBegun(options)`: lines from every game begun, finished or not, by
|
||||
its table's options (games begun on each variant).
|
||||
- `preferences`, `options`: the preferences panel's settings and the
|
||||
host's table choices (above).
|
||||
|
||||
## The server
|
||||
|
||||
One Node process per game, on its own port, run from source with `tsx` as
|
||||
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
|
||||
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
|
||||
never nears them. A seat token is minted once, sent once, and compared by
|
||||
a stranger can knock on (rooms, seats, talk, the desk), and a looser one
|
||||
on a seat's own actions, each a ledger line; a table of friends never nears
|
||||
them. An address holds at most twenty sockets, a socket that stops
|
||||
answering pings is closed, and a request has seconds, not minutes, to
|
||||
arrive. A seat token is minted once, sent once, and compared by
|
||||
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
|
||||
|
||||
@@ -14,6 +14,15 @@ The game itself is one file. `src/lib/game/index.ts` implements `GameSpec`
|
||||
the ops tools never look past it. The template ships with a demo game,
|
||||
High Card, so everything runs before you have written a line.
|
||||
|
||||
Beyond the required methods, a game opts into what it needs through
|
||||
optional hooks, and without one the kit behaves as if it were not there:
|
||||
`validate` (refuse a move with a reason), `seatsFor` (fewer seats for some
|
||||
options), `settled` (drafts a player may replace until final), `rankable`
|
||||
(a secret ranking before the deal, read by `create`), `botPress` and
|
||||
`parting` (bots that talk, remember by notes and read the talk in
|
||||
`botInput`), and `tally` / `tallyBegun` (the game's own lines in the tally).
|
||||
`CONVENTIONS.md` says what each does.
|
||||
|
||||
## Start a game
|
||||
|
||||
./new-game.sh <slug> "<Name>" [port] [domain]
|
||||
@@ -26,6 +35,7 @@ the new project as `docs/conventions.md`; it is the house style.
|
||||
|
||||
Run the demo locally from a new project:
|
||||
|
||||
npm run dev:all # server and site together; Ctrl-C stops both
|
||||
npm run server # the game server on its port
|
||||
npm run dev -- --host
|
||||
|
||||
@@ -37,7 +47,7 @@ Run the demo locally from a new project:
|
||||
src/lib/net/ client.ts (calls, held seats, socket), room.svelte.ts (the room store), talk.ts, view.ts
|
||||
src/lib/components/ Hall, Lobby, TableTalk, ReportSlip, Board (demo; replace it)
|
||||
src/routes/ the hall, /join/[code], /rules, /guide
|
||||
server/src/ index (routes), rooms (game-agnostic), store (ledger), reports (the desk), ratelimit
|
||||
server/src/ index (routes), rooms (game-agnostic), store (ledger), reports (the desk), tally, ratelimit, and their tests
|
||||
deploy/ deploy, setup, Caddy, systemd, cron, gate + replay, backup, rollup, pulse, visitors, Slack alert, reports tools
|
||||
.claude/skills/ <slug>-visitors, <slug>-pulse, <slug>-reports
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ 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
|
||||
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts server/src/tally.ts
|
||||
src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view.ts src/lib/net/room.svelte.ts
|
||||
src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte src/lib/components/TabbedDialog.svelte src/lib/components/Tabs.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"
|
||||
|
||||
@@ -57,7 +57,7 @@ deploy, so the live copies are the repo copies:
|
||||
nightly at 00:12 UTC into /var/lib/__SLUG__/rollup.jsonl.
|
||||
- `__SLUG__-backup.sh`: nightly at 07:23 UTC, mirrors /var/lib/__SLUG__
|
||||
to the `kestrel-wizwar-backups` Space under `__SLUG__/` (a current copy
|
||||
and dated snapshots kept 90 days). It needs rclone with the Spaces
|
||||
less the rollup, and dated snapshots kept 90 days). It needs rclone with the Spaces
|
||||
credentials in /root/.config/rclone/rclone.conf, copied by hand from the
|
||||
wizwar droplet; until then it logs "skipped".
|
||||
|
||||
@@ -84,14 +84,16 @@ deploy: the server would rewrite that game on restart.
|
||||
|
||||
Runs the type-checks, the tests and the build locally, rsyncs `build/` to
|
||||
the droplet keeping the previous week's hashed assets, rsyncs the server and
|
||||
engine sources, installs dependencies, and restarts the game server. A room
|
||||
is replayed from its ledger when the server comes back, which takes a few
|
||||
seconds; Caddy holds requests that land in the gap.
|
||||
engine sources, installs dependencies, and restarts the game server, then
|
||||
waits up to twenty seconds for it to answer HTTP and fails with the tail of
|
||||
its journal if it does not. A room is replayed from its ledger when the
|
||||
server comes back, which takes a few seconds; Caddy holds requests that
|
||||
land in the gap.
|
||||
|
||||
## Operations
|
||||
|
||||
- Logs: `ssh root@<ip> journalctl -u __SLUG__ -f` for the game server,
|
||||
`journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
|
||||
`journalctl -u caddy -f` and /var/lib/caddy/__SLUG__-access.log for the web side.
|
||||
- Restart: `ssh root@<ip> systemctl restart __SLUG__`
|
||||
- Rooms: `/var/lib/__SLUG__/rooms/<CODE>.jsonl`, one ledger per game; the
|
||||
nightly backup keeps them in the Space.
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nightly ledger backup to DigitalOcean Spaces, one prefix per game in the shared bucket.
|
||||
# current/ - exact mirror of /var/lib/__SLUG__
|
||||
# current/ - mirror of /var/lib/__SLUG__, less the rollup (rebuilt from logs)
|
||||
# snapshots/ - one dated copy per day, pruned after 90 days
|
||||
# Ledgers are append-only JSONL; the game server never needs stopping.
|
||||
# Sentry Crons check-in: /root/.__SLUG__-sentry-cron holds the check-in
|
||||
# URL (absent file = no check-ins). SCHEDULE must match /etc/cron.d/__SLUG__.
|
||||
set -u
|
||||
SCHEDULE="23 7 * * *"
|
||||
BUCKET="kestrel-wizwar-backups"
|
||||
BUCKET="kestrel-wizwar-backups" # the hall's shared Space, named for its first tenant
|
||||
PREFIX="__SLUG__"
|
||||
SRC="/var/lib/__SLUG__"
|
||||
LOG="/var/log/__SLUG__/backup.log"
|
||||
|
||||
@@ -49,7 +49,17 @@ ssh "root@$HOST" '
|
||||
systemctl enable --now __SLUG__
|
||||
systemctl restart __SLUG__
|
||||
systemctl reload caddy
|
||||
sleep 1
|
||||
systemctl --no-pager -l status __SLUG__ | head -3
|
||||
# "active" means only that the process started: wait for it to answer HTTP,
|
||||
# so a server that dies loading its code fails the deploy instead of looping.
|
||||
for i in $(seq 20); do
|
||||
if curl -s -o /dev/null http://127.0.0.1:__PORT__/api/rooms/ZZZZ; then
|
||||
systemctl --no-pager -l status __SLUG__ | head -3
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "the game server is not answering:" >&2
|
||||
journalctl -u __SLUG__ -n 25 --no-pager >&2
|
||||
exit 1
|
||||
'
|
||||
echo "deployed."
|
||||
@@ -8,7 +8,7 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { game } from '../src/lib/game';
|
||||
import type { SeatId } from '../src/lib/game/spec';
|
||||
import { cleanOptions, type SeatId } from '../src/lib/game/spec';
|
||||
|
||||
interface Seat {
|
||||
id: SeatId;
|
||||
@@ -34,13 +34,16 @@ async function main(): Promise<void> {
|
||||
const seats: Seat[] = [];
|
||||
let state: ReturnType<typeof game.create> | null = null;
|
||||
let turns = 0;
|
||||
// The host's table options ride the room line; a game created without them is a different game.
|
||||
const options = (lines[0]?.t === 'room' ? lines[0].options : undefined) as Record<string, string> | undefined;
|
||||
// The host's table options ride the room line, read exactly as the server reads them.
|
||||
const options = cleanOptions(game, lines[0]?.t === 'room' ? lines[0].options : undefined);
|
||||
try {
|
||||
for (const line of lines) {
|
||||
if (line.t === 'seat') seats.push(line);
|
||||
else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
|
||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options);
|
||||
else if (line.t === 'unseat') {
|
||||
const i = seats.findIndex((s) => s.id === line.id);
|
||||
if (i >= 0) seats.splice(i, 1);
|
||||
}
|
||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options, line.rankings);
|
||||
else if (line.t === 'turn' && state) {
|
||||
state = game.resolve(state, line.inputs);
|
||||
turns += 1;
|
||||
|
||||
@@ -8,7 +8,7 @@ STATUS="${3:?usage: report-reply.sh <host> <reportId> <status> <text...>}"
|
||||
shift 3
|
||||
TEXT="$*"
|
||||
case "$STATUS" in resolved|by-design|open) ;; *) echo "status must be resolved, by-design, or open" >&2; exit 1;; esac
|
||||
case "$REPORT_ID" in *[!0-9a-f]*|"") echo "a report id is eight hex characters" >&2; exit 1;; esac
|
||||
[[ "$REPORT_ID" =~ ^[0-9a-f]{8}$ ]] || { echo "a report id is eight hex characters" >&2; exit 1; }
|
||||
LINE=$(REPORT_ID="$REPORT_ID" STATUS="$STATUS" TEXT="$TEXT" python3 -c '
|
||||
import json, os, datetime
|
||||
print(json.dumps({"reportId": os.environ["REPORT_ID"], "at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), "status": os.environ["STATUS"], "text": os.environ["TEXT"]}))')
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:all": "node scripts/dev-all.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// The game server and the site together, for playing locally: one terminal,
|
||||
// each line marked with its side, and Ctrl-C (or either one stopping or
|
||||
// failing to get its port) stops both.
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const sides = [
|
||||
['server', ['run', 'server']],
|
||||
['site', ['run', 'dev', '--', ...process.argv.slice(2)]]
|
||||
];
|
||||
const children = sides.map(([name, args]) => {
|
||||
const child = spawn('npm', args, { stdio: ['inherit', 'pipe', 'pipe'] });
|
||||
for (const stream of [child.stdout, child.stderr]) {
|
||||
let rest = '';
|
||||
stream.on('data', (chunk) => {
|
||||
const lines = (rest + chunk).split('\n');
|
||||
rest = lines.pop();
|
||||
for (const line of lines) {
|
||||
console.log(`[${name}] ${line}`);
|
||||
// A watcher outlives the crash it watches, so a port already taken would leave one side running alone.
|
||||
if (line.includes('EADDRINUSE')) {
|
||||
console.log(`[${name}] its port is taken: is another copy already running?`);
|
||||
stop(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
child.on('exit', (code) => stop(code ?? 0));
|
||||
return child;
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
function stop(code) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
for (const c of children) if (c.exitCode === null) c.kill('SIGTERM');
|
||||
process.exitCode = code;
|
||||
}
|
||||
process.on('SIGINT', () => stop(0));
|
||||
process.on('SIGTERM', () => stop(0));
|
||||
@@ -7,8 +7,10 @@
|
||||
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
|
||||
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
|
||||
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
||||
// POST /api/rooms/:id/rank {token, ranking} a seat ranks the table's choices, best first, where the table asks for it
|
||||
// POST /api/rooms/:id/turn {token, input} this seat's move
|
||||
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room;
|
||||
// POST /api/rooms/:id/hurry {token} a table the bots are playing on plays the rest at once
|
||||
// POST /api/rooms/:id/say {token, text, to?} table talk, from a seat to the whole room, or privately to the seats in `to`;
|
||||
// {name, text} or from the gallery, signed, when the host allows it
|
||||
// POST /api/rooms/:id/gallery {token, on} the host lets the Peanut Gallery talk, or hushes it
|
||||
// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings
|
||||
@@ -230,6 +232,14 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
rooms.begin(room, token);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'rank') {
|
||||
rooms.rank(room, token, body.ranking);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'hurry') {
|
||||
rooms.hurry(room, token);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'turn') {
|
||||
rooms.submit(room, token, body.input, body.resolved);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
@@ -241,7 +251,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
return send(res, 200, rooms.view(room, SPECTATOR));
|
||||
}
|
||||
if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429);
|
||||
rooms.say(room, token, body.text);
|
||||
rooms.say(room, token, body.text, body.to);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'gallery') {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { appendFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { game, type Input, type State } from '../../src/lib/game';
|
||||
import type { GameSpec } from '../../src/lib/game/spec';
|
||||
import { Rooms } from './rooms';
|
||||
import { Store } from './store';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */
|
||||
function table(spec: GameSpec<State, Input> = game, withCy = false) {
|
||||
const rooms = new Rooms(spec, new Store(dir));
|
||||
const host = rooms.create('Ann', 3);
|
||||
const guest = rooms.join(host.room, 'Bo');
|
||||
const third = withCy ? rooms.join(host.room, 'Cy') : null;
|
||||
rooms.begin(host.room, host.token);
|
||||
return { rooms, room: host.room, ann: host.token, bo: guest.token, cy: third?.token };
|
||||
}
|
||||
|
||||
describe('rooms', () => {
|
||||
it('resolves a round once every seat has moved, and refuses a move for a round already played', () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
expect(rooms.waitingOn(room)).toEqual(['B']);
|
||||
rooms.submit(room, bo, { pick: 2 }, 0);
|
||||
expect(room.resolved).toBe(1);
|
||||
expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/);
|
||||
});
|
||||
|
||||
it('leaves a room with an unreadable ledger unloaded and opens the rest', () => {
|
||||
const { room } = table();
|
||||
appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn');
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const reopened = new Rooms(game, new Store(dir));
|
||||
expect(errors).toHaveBeenCalledWith(expect.stringMatching(/ZZZZ: ledger unreadable/));
|
||||
expect(reopened.get(room.id).state).not.toBeNull();
|
||||
errors.mockRestore();
|
||||
});
|
||||
|
||||
it('holds a draft until it is settled, and lets it be replaced until then', () => {
|
||||
type Draft = Input & { final?: boolean };
|
||||
const drafts: GameSpec<State, Input> = {
|
||||
...game,
|
||||
cleanInput: (raw) => ({ ...game.cleanInput(raw), final: (raw as Draft)?.final === true }) as Draft,
|
||||
settled: (input) => (input as Draft).final === true
|
||||
};
|
||||
const { rooms, room, ann, bo } = table(drafts);
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
rooms.submit(room, bo, { pick: 2, final: true }, 0);
|
||||
expect(rooms.waitingOn(room)).toEqual(['A']);
|
||||
rooms.submit(room, ann, { pick: 4 }, 0);
|
||||
expect(room.resolved).toBe(0);
|
||||
rooms.submit(room, ann, { pick: 3, final: true }, 0);
|
||||
expect(room.resolved).toBe(1);
|
||||
expect(room.state!.rounds[0].picks).toEqual({ A: 3, B: 2 });
|
||||
rooms.submit(room, bo, { pick: 1, final: true }, 1);
|
||||
expect(() => rooms.submit(room, bo, { pick: 2, final: true }, 1)).toThrow(/already in/);
|
||||
rooms.submit(room, bo, { pick: 2 }, 1);
|
||||
expect(rooms.waitingOn(room)).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it("shows a seat its own move in waiting and its last move played, and nobody else's", () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
expect(rooms.view(room, 'A').yourInput).toEqual({ pick: 5 });
|
||||
expect(rooms.view(room, 'B').yourInput).toBeNull();
|
||||
expect(rooms.view(room, '').yourInput).toBeNull();
|
||||
rooms.submit(room, bo, { pick: 2 }, 0);
|
||||
expect(rooms.view(room, 'A').yourInput).toBeNull();
|
||||
expect(rooms.view(room, 'A').yourLastInput).toEqual({ pick: 5 });
|
||||
expect(rooms.view(room, 'B').yourLastInput).toEqual({ pick: 2 });
|
||||
expect(rooms.view(room, '').yourLastInput).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a private line to its sender and the seats it names, never to anyone else or the gallery', () => {
|
||||
const { rooms, room, ann, bo } = table(game, true);
|
||||
rooms.say(room, ann, 'Everyone, hello.');
|
||||
rooms.say(room, ann, 'Bo, lay a five.', ['B']);
|
||||
const heard = (seat: string) => rooms.view(room, seat).chat.map((l) => l.text);
|
||||
expect(heard('A')).toEqual(['Everyone, hello.', 'Bo, lay a five.']);
|
||||
expect(heard('B')).toEqual(['Everyone, hello.', 'Bo, lay a five.']);
|
||||
expect(heard('C')).toEqual(['Everyone, hello.']);
|
||||
expect(heard('')).toEqual(['Everyone, hello.']);
|
||||
expect(rooms.view(room, 'B').chat[1]).toMatchObject({ id: 'A', to: ['B'], turn: 1 });
|
||||
expect(() => rooms.say(room, bo, 'Anyone?', ['Q'])).toThrow(/Nobody at this table/);
|
||||
});
|
||||
|
||||
it('keeps each ranking secret until the deal, which reads them all from the start line', () => {
|
||||
const dealt: Record<string, string[]>[] = [];
|
||||
const ranked: GameSpec<State, Input> = {
|
||||
...game,
|
||||
rankable: () => [
|
||||
{ value: 'red', label: 'Red' },
|
||||
{ value: 'blue', label: 'Blue' }
|
||||
],
|
||||
create: (names, seed, rules, options, rankings) => {
|
||||
dealt.push(rankings ?? {});
|
||||
return game.create(names, seed, rules, options);
|
||||
}
|
||||
};
|
||||
const rooms = new Rooms(ranked, new Store(dir));
|
||||
const host = rooms.create('Ann', 2);
|
||||
const guest = rooms.join(host.room, 'Bo');
|
||||
const room = host.room;
|
||||
expect(() => rooms.rank(room, host.token, ['red'])).toThrow(/every choice once/);
|
||||
rooms.rank(room, host.token, ['blue', 'red']);
|
||||
expect(rooms.view(room, 'A').yourRanking).toEqual(['blue', 'red']);
|
||||
expect(rooms.view(room, 'B')).toMatchObject({ yourRanking: null, ranked: 1 });
|
||||
rooms.begin(room, host.token);
|
||||
expect(dealt).toEqual([{ A: ['blue', 'red'] }]);
|
||||
expect(() => rooms.rank(room, guest.token, ['red', 'blue'])).toThrow(/ranking is closed/);
|
||||
new Rooms(ranked, new Store(dir));
|
||||
expect(dealt[1]).toEqual({ A: ['blue', 'red'] });
|
||||
});
|
||||
|
||||
it('refuses a ranking at a table that asks for none', () => {
|
||||
const rooms = new Rooms(game, new Store(dir));
|
||||
const host = rooms.create('Ann', 2);
|
||||
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
|
||||
});
|
||||
|
||||
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', () => {
|
||||
const heard: string[][] = [];
|
||||
const talking: GameSpec<State, Input> = {
|
||||
...game,
|
||||
botInput: (state, seat, press) => {
|
||||
heard.push((press ?? []).map((l) => l.text));
|
||||
return game.botInput(state, seat);
|
||||
},
|
||||
botPress: (state, seat, press, people, trigger) =>
|
||||
trigger === 'phase' ? [{ to: people, text: `Round ${game.turn(state)}.`, note: 'secret' }] : [{ to: [press[press.length - 1].id], text: 'I hear you.' }],
|
||||
parting: (_press, viewer) => [{ id: 'B', text: `Farewell, ${viewer}.` }]
|
||||
};
|
||||
const rooms = new Rooms(talking, new Store(dir));
|
||||
const host = rooms.create('Ann', 3);
|
||||
rooms.addBot(host.room, host.token);
|
||||
const cy = rooms.join(host.room, 'Cy');
|
||||
const room = host.room;
|
||||
rooms.begin(room, host.token);
|
||||
rooms.say(room, host.token, 'Psst.', ['B']);
|
||||
rooms.say(room, cy.token, 'Hello, all.');
|
||||
const annSees = rooms.view(room, 'A').chat;
|
||||
expect(annSees.map((l) => l.text)).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.']);
|
||||
expect(annSees.some((l) => 'note' in l)).toBe(false);
|
||||
expect(rooms.view(room, 'C').chat.map((l) => l.text)).toEqual(['Round 1.', 'Hello, all.', 'I hear you.']);
|
||||
expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']);
|
||||
rooms.submit(room, host.token, { pick: 1 }, 0);
|
||||
rooms.submit(room, cy.token, { pick: 2 }, 0);
|
||||
expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']);
|
||||
expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
|
||||
expect(rooms.view(room, 'A').parting).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses private talk to a bot when the game gives bots no words', () => {
|
||||
const rooms = new Rooms(game, new Store(dir));
|
||||
const host = rooms.create('Ann', 2);
|
||||
rooms.addBot(host.room, host.token);
|
||||
rooms.begin(host.room, host.token);
|
||||
expect(() => rooms.say(host.room, host.token, 'Psst.', ['B'])).toThrow(/read no private talk/);
|
||||
});
|
||||
|
||||
describe('a table the bots play on alone', () => {
|
||||
/** A count to ten that only the bots play: the host is never owed a move. */
|
||||
type Count = { n: number; over: { winner: null; reason: string } | null };
|
||||
const count: GameSpec<Count, null> = {
|
||||
seatIds: ['A', 'B'],
|
||||
minSeats: 2,
|
||||
botNames: ['Aldric'],
|
||||
currentRules: 1,
|
||||
create: () => ({ n: 0, over: null }),
|
||||
resolve: (s) => ({ n: s.n + 1, over: s.n + 1 >= 10 ? { winner: null, reason: 'Ten.' } : null }),
|
||||
needsInput: (s, seat) => seat === 'B' && !s.over,
|
||||
over: (s) => s.over,
|
||||
turn: (s) => s.n + 1,
|
||||
view: (s) => s,
|
||||
botInput: () => null,
|
||||
cleanInput: () => null,
|
||||
nameOf: (_s, seat) => seat
|
||||
};
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('plays a few rounds at once, then one at a pace to watch, and the rest at once when hurried', () => {
|
||||
const rooms = new Rooms(count, new Store(dir));
|
||||
const host = rooms.create('Ann', 2);
|
||||
rooms.addBot(host.room, host.token);
|
||||
rooms.begin(host.room, host.token);
|
||||
const room = host.room;
|
||||
expect(room.state!.n).toBe(4);
|
||||
expect(rooms.view(room, 'A').playingOn).toBe(true);
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(room.state!.n).toBe(5);
|
||||
rooms.hurry(room, host.token);
|
||||
vi.runAllTimers();
|
||||
expect(room.state!.n).toBe(10);
|
||||
expect(room.over).toBe(true);
|
||||
expect(rooms.view(room, 'A').playingOn).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+169
-33
@@ -5,13 +5,20 @@
|
||||
// game beyond the GameSpec it is given.
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { SPECTATOR, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
|
||||
import { cleanOptions, SPECTATOR, tableSize, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
|
||||
import type { ChatLine, RoomView } from '../../src/lib/net/view';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
|
||||
/** The longest line of talk. A game of long negotiation raises this, and the talk box's maxlength with it. */
|
||||
const CHAT_MAX_LENGTH = 300;
|
||||
/** Lines of talk kept and sent; the ledger keeps them all. */
|
||||
/** Lines of talk kept and sent; the ledger keeps them all. A game that runs for weeks of talk raises this. */
|
||||
const CHAT_KEEP = 200;
|
||||
/** Bots' rounds played in one go, when nobody else owes a move, before the rest are paced. */
|
||||
const PLAYED_AT_ONCE = 4;
|
||||
/** Between paced bots' rounds: long enough to watch the table move. */
|
||||
const PACE_MS = 2000;
|
||||
/** Bots' rounds played between answers to everyone else, once hurried. */
|
||||
const HURRIED_STEP = 3;
|
||||
export { SPECTATOR };
|
||||
|
||||
export interface Seat {
|
||||
@@ -48,6 +55,10 @@ export interface Room<State> {
|
||||
state: State | null;
|
||||
/** Inputs received for the round in progress, by seat. */
|
||||
pending: Record<SeatId, unknown>;
|
||||
/** Each seat's ranking of the table's choices, where the table asks for one; secret before the deal and after. */
|
||||
rankings: Record<SeatId, string[]>;
|
||||
/** Each seat's input in the last round it played, kept through rounds it had no part in; each seat is shown only its own. */
|
||||
played: Record<SeatId, unknown>;
|
||||
chat: ChatLine[];
|
||||
/** Rounds played so far; a move names the count it was made against. */
|
||||
resolved: number;
|
||||
@@ -112,6 +123,10 @@ export class Rooms<State, Input> {
|
||||
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
|
||||
private failedClaims = 0;
|
||||
private failWindowStart = 0;
|
||||
/** Tables whose bots are playing on, paced: the next round's timer. */
|
||||
private pacing = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Tables asked to play the rest at once. */
|
||||
private hurried = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private game: GameSpec<State, Input>,
|
||||
@@ -120,7 +135,14 @@ export class Rooms<State, Input> {
|
||||
private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' }
|
||||
) {
|
||||
for (const id of store.roomIds()) {
|
||||
const room = this.replay(id, store.read(id));
|
||||
let room: Room<State> | null;
|
||||
try {
|
||||
room = this.replay(id, store.read(id));
|
||||
} catch (e) {
|
||||
// One unreadable ledger (a torn last line) keeps its room closed, not the server down.
|
||||
console.error(`room ${id}: ledger unreadable, left unloaded: ${e instanceof Error ? e.message : String(e)}`);
|
||||
continue;
|
||||
}
|
||||
if (!room) continue;
|
||||
this.rooms.set(id, room);
|
||||
// A table that stopped with a bot's move owed picks up where it left off.
|
||||
@@ -175,26 +197,17 @@ export class Rooms<State, Input> {
|
||||
|
||||
create(name: string, size: number, rawOptions?: unknown): { room: Room<State>; seat: Seat; token: string } {
|
||||
const { minSeats } = this.game;
|
||||
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
|
||||
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
|
||||
const options = cleanOptions(this.game, rawOptions);
|
||||
const most = tableSize(this.game, options);
|
||||
if (!Number.isInteger(size) || size < minSeats || size > most) {
|
||||
throw new RoomError(`A table seats ${minSeats} to ${most} players.`);
|
||||
}
|
||||
return this.open(name, size, this.cleanOptions(rawOptions));
|
||||
}
|
||||
|
||||
/** The host's choices, kept to the options the game declares and the choices it offers; the rest default. */
|
||||
private cleanOptions(raw: unknown): TableOptions {
|
||||
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const options: TableOptions = {};
|
||||
for (const o of this.game.options ?? []) {
|
||||
const v = given[o.key];
|
||||
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
|
||||
}
|
||||
return options;
|
||||
return this.open(name, size, options);
|
||||
}
|
||||
|
||||
/** A table with nobody seated yet. */
|
||||
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room<State> {
|
||||
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, chat: [], resolved: 0, seq, updatedAt: createdAt };
|
||||
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, played: {}, rankings: {}, chat: [], resolved: 0, seq, updatedAt: createdAt };
|
||||
}
|
||||
|
||||
/** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its players. */
|
||||
@@ -306,23 +319,66 @@ export class Rooms<State, Input> {
|
||||
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may begin.', 403);
|
||||
if (room.state) throw new RoomError('The game has already begun.', 409);
|
||||
if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409);
|
||||
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
|
||||
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) });
|
||||
// A game whose first move belongs to a bot must not wait for a human who is not owed a move.
|
||||
this.driveBots(room);
|
||||
this.botsSpeak(room, 'phase');
|
||||
}
|
||||
|
||||
/** Rounds only bots owe (an opening move, an extra turn) resolve at once; a human's waits for them. */
|
||||
private driveBots(room: Room<State>): void {
|
||||
/**
|
||||
* Rounds only bots owe (an opening move, an extra turn, a table the
|
||||
* people are out of) resolve without waiting; a human's waits for them.
|
||||
* A few are played at once; past that, the bots play on one round at a
|
||||
* time, so the table can be watched and the server answers everyone else
|
||||
* between rounds. Hurried, they play in larger steps.
|
||||
*/
|
||||
private driveBots(room: Room<State>, burst = PLAYED_AT_ONCE): void {
|
||||
if (this.pacing.has(room.id)) return;
|
||||
const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id));
|
||||
let played = 0;
|
||||
while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) {
|
||||
if (played === burst) return this.playOnLater(room);
|
||||
const inputs: Record<SeatId, unknown> = {};
|
||||
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id);
|
||||
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id, this.heardBy(room, seat.id));
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||
played += 1;
|
||||
}
|
||||
this.hurried.delete(room.id);
|
||||
const outcome = room.state && this.game.over(room.state);
|
||||
if (outcome && !room.over) this.commit(room, { t: 'over', at: Date.now(), winner: outcome.winner, reason: outcome.reason });
|
||||
}
|
||||
|
||||
/** The bots' next round, after a pause to watch it by, or at once when hurried. */
|
||||
private playOnLater(room: Room<State>): void {
|
||||
const hurried = this.hurried.has(room.id);
|
||||
const next = setTimeout(() => {
|
||||
this.pacing.delete(room.id);
|
||||
// A room forgotten and loaded again drives itself; this one is no longer the table.
|
||||
if (this.rooms.get(room.id) !== room) return;
|
||||
this.driveBots(room, hurried ? HURRIED_STEP : 1);
|
||||
// Played out: the bots have their say once, as after any round, which ends a game with parting words.
|
||||
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
|
||||
}, hurried ? 0 : PACE_MS);
|
||||
next.unref();
|
||||
this.pacing.set(room.id, next);
|
||||
}
|
||||
|
||||
/** A seat at a table the bots are playing on asks for the rest at once. */
|
||||
hurry(room: Room<State>, token: string | undefined): void {
|
||||
this.seatOf(room, token);
|
||||
this.hurried.add(room.id);
|
||||
const waiting = this.pacing.get(room.id);
|
||||
if (!waiting) return;
|
||||
clearTimeout(waiting);
|
||||
this.pacing.delete(room.id);
|
||||
this.playOnLater(room);
|
||||
}
|
||||
|
||||
/** Whether the bots are playing on by themselves at this table, one paced round at a time. */
|
||||
playingOn(room: Room<State>): boolean {
|
||||
return this.pacing.has(room.id);
|
||||
}
|
||||
|
||||
/** The host seats a bot in an empty chair. */
|
||||
addBot(room: Room<State>, token: string | undefined): Seat {
|
||||
const host = this.seatOf(room, token);
|
||||
@@ -360,8 +416,22 @@ export class Rooms<State, Input> {
|
||||
this.commit(room, { t: 'unseat', id: seat.id });
|
||||
}
|
||||
|
||||
/** A seat's ranking of the choices the table asks for, best first, before the game begins. */
|
||||
rank(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (room.state) throw new RoomError('The game has begun; the ranking is closed.', 409);
|
||||
const choices = this.game.rankable?.(room.options);
|
||||
if (!choices) throw new RoomError('This table asks for no ranking.', 409);
|
||||
const ranking = Array.isArray(raw) ? raw.map(String) : [];
|
||||
const values = choices.map((c) => c.value);
|
||||
if (ranking.length !== values.length || new Set(ranking).size !== values.length || !ranking.every((r) => values.includes(r))) throw new RoomError('Rank every choice once.');
|
||||
this.commit(room, { t: 'rank', id: seat.id, ranking });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited.
|
||||
* A draft (an input the game does not call settled) may be replaced, and a settled move taken back
|
||||
* as a draft, until the round resolves.
|
||||
* `resolved` is how many rounds the page had seen played when the move was made: a move that
|
||||
* arrives after its round was played (a slow connection, a second click, a stale tab) is refused
|
||||
* rather than taken as the move for the next round.
|
||||
@@ -373,8 +443,10 @@ export class Rooms<State, Input> {
|
||||
if (typeof resolved !== 'number') throw new RoomError('This page is out of date. Reload it to see the table as it stands.', 409);
|
||||
if (resolved !== room.resolved) throw new RoomError('That move was made for a turn that has already been played. Your page will catch up.', 409);
|
||||
|
||||
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
|
||||
if (!this.game.needsInput(room.state, seat.id)) throw new RoomError('It is not your move.', 409);
|
||||
const input = this.game.cleanInput(raw);
|
||||
// A settled move is final; a draft may replace it while the round still waits on others.
|
||||
if (seat.id in room.pending && this.settled(room.pending[seat.id]) && this.settled(input)) throw new RoomError('Your move is already in.', 409);
|
||||
const why = this.game.validate?.(room.state, seat.id, input);
|
||||
if (why) throw new RoomError(why);
|
||||
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input });
|
||||
@@ -383,15 +455,58 @@ export class Rooms<State, Input> {
|
||||
|
||||
private resolve(room: Room<State>): void {
|
||||
const inputs: Record<SeatId, unknown> = { ...room.pending };
|
||||
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id);
|
||||
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id, this.heardBy(room, seat.id));
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||
this.driveBots(room);
|
||||
this.botsSpeak(room, 'phase');
|
||||
}
|
||||
|
||||
/** Table talk, from a seat to everyone at the table and in the gallery. */
|
||||
say(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||
/**
|
||||
* Bots' talk, for a game that gives them words: when a round opens, and to
|
||||
* a person who has just written to them. Their lines go on the ledger like
|
||||
* anyone's, so a replay repeats them rather than asking again.
|
||||
*/
|
||||
private botsSpeak(room: Room<State>, trigger: 'phase' | 'spoken', only?: SeatId[]): void {
|
||||
if (!this.game.botPress || !room.state) return;
|
||||
const people = room.seats.filter((s) => !s.bot).map((s) => s.id);
|
||||
for (const seat of room.seats) {
|
||||
if (!seat.bot || (only && !only.includes(seat.id))) continue;
|
||||
for (const line of this.game.botPress(room.state, seat.id, this.heardBy(room, seat.id), people, trigger)) {
|
||||
const to = line.to.filter((id) => people.includes(id));
|
||||
if (!to.length || !line.text.trim()) continue;
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(line.text), to, ...(line.note !== undefined ? { note: line.note } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The talk a seat has heard: the table's, and private lines to it or from it. */
|
||||
private heardBy(room: Room<State>, seatId: SeatId): ChatLine[] {
|
||||
return room.chat.filter((line) => !line.to || line.id === seatId || line.to.includes(seatId));
|
||||
}
|
||||
|
||||
/** Whether an input is final; a game without drafts makes every input final. */
|
||||
private settled(input: unknown): boolean {
|
||||
return this.game.settled ? this.game.settled(input as Input) : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table talk, from a seat: to everyone at the table and in the gallery,
|
||||
* or, naming seats, privately to them alone. A private line is never
|
||||
* shown to anyone else, the gallery included.
|
||||
*/
|
||||
say(room: Room<State>, token: string | undefined, raw: unknown, rawTo?: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw) });
|
||||
const to = Array.isArray(rawTo)
|
||||
? [...new Set(rawTo.filter((id): id is SeatId => typeof id === 'string' && id !== seat.id && room.seats.some((s) => s.id === id)))]
|
||||
: [];
|
||||
if (Array.isArray(rawTo) && rawTo.length && !to.length) throw new RoomError('Nobody at this table answers to that.');
|
||||
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id)?.bot);
|
||||
if (botsTo.length && !this.game.botPress) throw new RoomError('The bots at this table read no private talk.');
|
||||
// A person's first word of the game, said to everyone, is heard by the bots too: the game chooses who answers.
|
||||
const first = !to.length && !room.chat.some((l) => l.id === seat.id);
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw), ...(to.length ? { to } : {}) });
|
||||
if (botsTo.length) this.botsSpeak(room, 'spoken', botsTo);
|
||||
else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot).map((s) => s.id));
|
||||
}
|
||||
|
||||
private cleanTalk(raw: unknown): string {
|
||||
@@ -404,16 +519,17 @@ export class Rooms<State, Input> {
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Seats whose input the next resolution needs and has not yet received. */
|
||||
/** Seats whose input the next resolution needs and has not yet received settled; a draft still waits. */
|
||||
waitingOn(room: Room<State>): SeatId[] {
|
||||
if (!room.state || this.game.over(room.state)) return [];
|
||||
return room.seats
|
||||
.filter((s) => !s.bot && this.game.needsInput(room.state!, s.id) && !(s.id in room.pending))
|
||||
.filter((s) => !s.bot && this.game.needsInput(room.state!, s.id) && !(s.id in room.pending && this.settled(room.pending[s.id])))
|
||||
.map((s) => s.id);
|
||||
}
|
||||
|
||||
/** The view for a seat, or for the gallery when the seat is SPECTATOR. */
|
||||
view(room: Room<State>, seatId: SeatId): RoomView<State> {
|
||||
const seated = seatId !== SPECTATOR;
|
||||
return {
|
||||
roomId: room.id,
|
||||
seq: room.seq,
|
||||
@@ -426,7 +542,14 @@ export class Rooms<State, Input> {
|
||||
resolved: room.resolved,
|
||||
over: room.state ? this.game.over(room.state) : null,
|
||||
state: room.state ? this.game.view(room.state, seatId) : null,
|
||||
chat: room.chat,
|
||||
yourRanking: seated ? (room.rankings[seatId] ?? null) : null,
|
||||
ranked: Object.keys(room.rankings).length,
|
||||
yourInput: seated && seatId in room.pending ? room.pending[seatId] : null,
|
||||
yourLastInput: seated && seatId in room.played ? room.played[seatId] : null,
|
||||
// Private lines only to their sender and the seats they name; a note is a bot's own memory, never sent.
|
||||
chat: this.heardBy(room, seatId).map(({ note: _, ...line }) => line),
|
||||
parting: room.state && seated && this.game.parting && this.game.over(room.state) ? this.game.parting(room.chat, seatId) : [],
|
||||
playingOn: this.playingOn(room),
|
||||
audience: this.gallery.get(room.id) ?? 0,
|
||||
galleryTalk: room.galleryTalk,
|
||||
challenge: room.challenge,
|
||||
@@ -499,20 +622,33 @@ export class Rooms<State, Input> {
|
||||
case 'input':
|
||||
room.pending[line.id] = this.game.cleanInput(line.input);
|
||||
break;
|
||||
case 'rank':
|
||||
room.rankings[line.id] = line.ranking;
|
||||
break;
|
||||
case 'unseat':
|
||||
room.seats = room.seats.filter((s) => s.id !== line.id);
|
||||
delete room.rankings[line.id];
|
||||
break;
|
||||
case 'start':
|
||||
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options);
|
||||
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options, line.rankings);
|
||||
break;
|
||||
case 'turn':
|
||||
if (!room.state) return;
|
||||
room.state = this.game.resolve(room.state, line.inputs as Record<SeatId, Input>);
|
||||
room.played = { ...room.played, ...(line.inputs as Record<SeatId, unknown>) };
|
||||
room.pending = {};
|
||||
room.resolved += 1;
|
||||
break;
|
||||
case 'chat':
|
||||
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) });
|
||||
room.chat.push({
|
||||
id: line.id,
|
||||
text: line.text,
|
||||
at: line.at,
|
||||
...(room.state ? { turn: this.game.turn(room.state) } : {}),
|
||||
...(line.name ? { name: line.name } : {}),
|
||||
...(line.to ? { to: line.to } : {}),
|
||||
...(line.note !== undefined ? { note: line.note } : {})
|
||||
});
|
||||
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
||||
break;
|
||||
}
|
||||
@@ -521,7 +657,7 @@ export class Rooms<State, Input> {
|
||||
private replay(id: string, lines: LedgerLine[]): Room<State> | null {
|
||||
const first = lines[0];
|
||||
if (!first || first.t !== 'room') return null;
|
||||
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], this.cleanOptions(first.options), lines.length);
|
||||
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], cleanOptions(this.game, first.options), lines.length);
|
||||
for (const line of lines) {
|
||||
this.apply(room, line);
|
||||
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
|
||||
|
||||
@@ -15,13 +15,22 @@ export type LedgerLine =
|
||||
| { 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. */
|
||||
| { t: 'unseat'; id: SeatId }
|
||||
/** The seed and the rules revision the game began under; a later fix keeps this revision's path for it. */
|
||||
| { t: 'start'; seed: number; rules: number }
|
||||
/** A seat's ranking of the table's choices, best first, before the game begins; the latest for a seat stands. */
|
||||
| { t: 'rank'; id: SeatId; ranking: string[] }
|
||||
/**
|
||||
* The seed and the rules revision the game began under (a later fix keeps
|
||||
* this revision's path for it), and at a table that asks for rankings,
|
||||
* the seats' rankings as the deal read them.
|
||||
*/
|
||||
| { t: 'start'; seed: number; rules: number; rankings?: Record<SeatId, string[]> }
|
||||
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
|
||||
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */
|
||||
| { t: 'over'; at: number; winner: SeatId | null; reason: string }
|
||||
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
||||
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string }
|
||||
/**
|
||||
* A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR).
|
||||
* `to` names the seats a private line was said to; `note` is a bot's memory of what it said, never shown.
|
||||
*/
|
||||
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string; to?: SeatId[]; note?: unknown }
|
||||
/** The host let the gallery talk, or hushed it. */
|
||||
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
|
||||
/** A seat is held for the keeper of the site, called to the table. */
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { game, type Input, type State } from '../../src/lib/game';
|
||||
import type { GameSpec } from '../../src/lib/game/spec';
|
||||
import { Rooms } from './rooms';
|
||||
import { Store } from './store';
|
||||
import { Tallies } from './tally';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'tally-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('the tally', () => {
|
||||
it("adds a game's own lines from every game begun, finished or not", () => {
|
||||
const counted: GameSpec<State, Input> = { ...game, tallyBegun: () => ({ 'games of High Card begun': 1 }) };
|
||||
const rooms = new Rooms(counted, new Store(dir));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const host = rooms.create('Ann', 2);
|
||||
rooms.addBot(host.room, host.token);
|
||||
rooms.begin(host.room, host.token);
|
||||
}
|
||||
rooms.create('Bo', 2);
|
||||
const t = new Tallies(counted, new Store(dir)).get();
|
||||
expect(t).toMatchObject({ tablesOpened: 3, gamesBegun: 2, gamesFinished: 0 });
|
||||
expect(t.byGame).toEqual({ 'games of High Card begun': 2 });
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
// The tally: what the ledgers add up to, for the hall's about panel. Read
|
||||
// from the files on demand and cached for a minute, so a curious visitor
|
||||
// costs nothing and a busy hall costs one scan a minute. The counts are
|
||||
// the kit's; a game may add its own lines through GameSpec.tally.
|
||||
// the kit's; a game may add its own lines through GameSpec.tally (from a
|
||||
// finished game) and GameSpec.tallyBegun (from any game begun).
|
||||
|
||||
import type { GameSpec, SeatId } from '../../src/lib/game/spec';
|
||||
import { cleanOptions, type GameSpec, type SeatId } from '../../src/lib/game/spec';
|
||||
import type { Tally } from '../../src/lib/net/view';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
|
||||
@@ -30,7 +31,12 @@ export class Tallies<State, Input> {
|
||||
const names = new Set<string>();
|
||||
let firstStart = Infinity;
|
||||
for (const id of this.store.roomIds()) {
|
||||
const lines = this.store.read(id);
|
||||
let lines: LedgerLine[];
|
||||
try {
|
||||
lines = this.store.read(id);
|
||||
} catch {
|
||||
continue; // an unreadable ledger is left out of the count, as its room is left unloaded
|
||||
}
|
||||
if (lines[0]?.t !== 'room') continue;
|
||||
t.tablesOpened += 1;
|
||||
const seats: { id: SeatId; name: string; bot: boolean }[] = [];
|
||||
@@ -44,13 +50,16 @@ export class Tallies<State, Input> {
|
||||
seats.push(line);
|
||||
if (line.bot) hasBot = true;
|
||||
else names.add(line.name.toLowerCase());
|
||||
} else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
|
||||
else if (line.t === 'start') {
|
||||
} else if (line.t === 'unseat') {
|
||||
const i = seats.findIndex((s) => s.id === line.id);
|
||||
if (i >= 0) seats.splice(i, 1);
|
||||
} else if (line.t === 'start') {
|
||||
t.gamesBegun += 1;
|
||||
if (hasBot) t.gamesWithBot += 1;
|
||||
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
||||
for (const [k, v] of Object.entries(this.game.tallyBegun?.(cleanOptions(this.game, lines[0].options)) ?? {})) t.byGame[k] = (t.byGame[k] ?? 0) + v;
|
||||
try {
|
||||
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, lines[0].options);
|
||||
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, cleanOptions(this.game, lines[0].options), line.rankings);
|
||||
} catch {
|
||||
state = null;
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
<script lang="ts" module>
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
/** One section of the about panel: a tab and what it shows. */
|
||||
export interface AboutTab {
|
||||
id: string;
|
||||
label: string;
|
||||
body: Snippet;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// The about panel: the game's story, its keeping and its company, a tab
|
||||
// at a time rather than one long page. Opened from the hall's "about this
|
||||
// game" link (and #about); closes with its button, a click outside it, or
|
||||
// Escape. The tab last read is the one it reopens on.
|
||||
import { tick } from 'svelte';
|
||||
|
||||
let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props();
|
||||
|
||||
let current = $state('');
|
||||
const shown = $derived(tabs.find((t) => t.id === current) ?? tabs[0]);
|
||||
let tabButtons: HTMLButtonElement[] = $state([]);
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (open && e.key === 'Escape') open = false;
|
||||
}
|
||||
|
||||
/** Arrow keys walk the tabs, as a tab list should; Home and End go to the ends. */
|
||||
async function onTabKey(e: KeyboardEvent, i: number) {
|
||||
const to = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
const next = (to + tabs.length) % tabs.length;
|
||||
current = tabs[next].id;
|
||||
await tick();
|
||||
tabButtons[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if open}
|
||||
<div class="about-scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
|
||||
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="about-title">
|
||||
<header>
|
||||
<h2 id="about-title">{title}</h2>
|
||||
<button type="button" class="close" aria-label="Close" onclick={() => (open = false)}>×</button>
|
||||
</header>
|
||||
<div class="tabs" role="tablist" aria-label="About this game">
|
||||
{#each tabs as t, i (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="about-tab-{t.id}"
|
||||
aria-controls="about-panel"
|
||||
aria-selected={shown.id === t.id}
|
||||
tabindex={shown.id === t.id ? 0 : -1}
|
||||
class:on={shown.id === t.id}
|
||||
bind:this={tabButtons[i]}
|
||||
onclick={() => (current = t.id)}
|
||||
onkeydown={(e) => onTabKey(e, i)}>{t.label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="body" id="about-panel" role="tabpanel" aria-labelledby="about-tab-{shown.id}" tabindex="0">
|
||||
{@render shown.body()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Its own backdrop: not every game has another dialog to share one with. */
|
||||
.about-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.panel {
|
||||
width: min(100%, 44rem);
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--slate);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 6px;
|
||||
padding: 1rem 1.4rem 1.2rem;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem 0.4rem;
|
||||
border-bottom: 1px solid var(--rule-strong);
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.tabs button {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
font: inherit;
|
||||
padding: 0.35rem 0.5rem 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabs button.on {
|
||||
opacity: 1;
|
||||
border-bottom-color: currentColor;
|
||||
}
|
||||
.body {
|
||||
overflow: auto;
|
||||
min-height: 12rem;
|
||||
}
|
||||
.body :global(p + p) {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
.body :global(h3) {
|
||||
font-size: 1.05rem;
|
||||
margin: 1rem 0 0.4rem;
|
||||
}
|
||||
.body :global(h3:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -51,8 +51,15 @@
|
||||
<div class="verdict">
|
||||
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
|
||||
<p>{g.over.reason}</p>
|
||||
{#each room.view.parting as last, i (i)}<p><em>{room.nameOf(last.id)}</em> {last.text}</p>{/each}
|
||||
<a class="commit" href="/">Back to the hall</a>
|
||||
</div>
|
||||
{:else if room.view.playingOn}
|
||||
<section class="move">
|
||||
<h2>The bots play on</h2>
|
||||
<p class="muted">Nobody else owes a move, so the bots play a round every few seconds for you to watch.</p>
|
||||
{#if !room.spectating}<button type="button" class="quiet" onclick={() => room.hurry()}>Play the rest at once</button>{/if}
|
||||
</section>
|
||||
{:else if room.spectating}
|
||||
<section class="move">
|
||||
<h2>You watch from the Peanut Gallery</h2>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// about panel with the rules, the guide and a way to reach the keeper.
|
||||
import { goto } from '$app/navigation';
|
||||
import { loadFamily, type FamilyGame } from '$lib/family';
|
||||
import About from './About.svelte';
|
||||
import TabbedDialog from './TabbedDialog.svelte';
|
||||
import TallyBoard from './Tally.svelte';
|
||||
import Preferences from './Preferences.svelte';
|
||||
import { prefs, setPref } from '$lib/prefs.svelte';
|
||||
@@ -14,6 +14,7 @@
|
||||
import type { Report, RoomView, Tally } from '$lib/net/view';
|
||||
import type { State } from '$lib/game';
|
||||
import { game } from '$lib/game';
|
||||
import { tableSize } from '$lib/game/spec';
|
||||
|
||||
let name = $state(playerName());
|
||||
let code = $state('');
|
||||
@@ -27,9 +28,6 @@
|
||||
let seats = $state(allSeats());
|
||||
let games = $state<Record<string, RoomView<State> | null>>({});
|
||||
let aboutOpen = $state(false);
|
||||
$effect(() => {
|
||||
if (location.hash === '#about') aboutOpen = true;
|
||||
});
|
||||
/** The ledgers' tally, fetched when the about panel opens. */
|
||||
let tally = $state<Tally | null>(null);
|
||||
$effect(() => {
|
||||
@@ -77,12 +75,14 @@
|
||||
const ready = $derived(name.trim().length > 0);
|
||||
|
||||
// Each held seat is looked up once, so the ledger can say whose move it
|
||||
// is. A seat the server refuses outright is doubted; a second refusal in
|
||||
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
|
||||
// is; until the answer comes, its game is absent and reads "looking". A
|
||||
// seat the server refuses outright is doubted; a second refusal in a row
|
||||
// forgets it. Silence (a restart, a stale answer) forgets nothing.
|
||||
const asked = new Set<string>();
|
||||
$effect(() => {
|
||||
for (const held of seats) {
|
||||
if (held.roomId in games) continue;
|
||||
games[held.roomId] = null;
|
||||
if (asked.has(held.roomId)) continue;
|
||||
asked.add(held.roomId);
|
||||
api.view(held.roomId, held.token)
|
||||
.then((v) => {
|
||||
trustSeat(held.roomId);
|
||||
@@ -149,6 +149,10 @@
|
||||
seats = allSeats();
|
||||
}
|
||||
|
||||
/** A game counts as finished once its table says so; until then, even while loading, it is in play. */
|
||||
const finished = $derived(seats.filter((h) => !!games[h.roomId]?.over));
|
||||
const active = $derived(seats.filter((h) => !games[h.roomId]?.over));
|
||||
|
||||
function status(held: { roomId: string; seat: string }): string {
|
||||
const g = games[held.roomId];
|
||||
if (g === undefined) return 'looking';
|
||||
@@ -190,12 +194,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="lid">
|
||||
<section class="lid" class:returning={active.length > 0}>
|
||||
<div class="lid-head">
|
||||
<h1>__NAME__</h1>
|
||||
<p class="pitch">One line that says what the game is.</p>
|
||||
<p class="tag">Two or three sentences for someone who has never heard of it: what you do on a turn, what makes it fun, and that it is free to play here against the bot or with friends.</p>
|
||||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
|
||||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about">about this game — a labor of love</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
|
||||
<Preferences bind:open={prefsOpen} />
|
||||
</div>
|
||||
|
||||
@@ -222,7 +226,7 @@
|
||||
{/if}
|
||||
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against the bot</button>
|
||||
<div class="ways">
|
||||
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), game.seatIds.length, options))}>Open a table</button>
|
||||
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), tableSize(game, options), options))}>Open a table</button>
|
||||
<form class="joinform" onsubmit={join}>
|
||||
<span class="or">or join one</span>
|
||||
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
|
||||
@@ -243,18 +247,30 @@
|
||||
<p class="caption">A picture of the game goes here.</p>
|
||||
</div>
|
||||
|
||||
{#if seats.length}
|
||||
{#snippet gameRow(held: (typeof seats)[number])}
|
||||
<div class="ledger-row" class:your-move={yourMove(held)}>
|
||||
<a class="ledger-resume" href={`/join/${held.roomId}`}>
|
||||
<span class="ledger-code">{held.roomId}</span>
|
||||
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
|
||||
</a>
|
||||
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- The games still in play, first thing for someone coming back; finished ones wait further down. -->
|
||||
{#if active.length}
|
||||
<div class="ledger">
|
||||
<div class="ledger-head">your games</div>
|
||||
{#each seats as held (held.roomId)}
|
||||
<div class="ledger-row" class:your-move={yourMove(held)}>
|
||||
<a class="ledger-resume" href={`/join/${held.roomId}`}>
|
||||
<span class="ledger-code">{held.roomId}</span>
|
||||
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
|
||||
</a>
|
||||
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#each active as held (held.roomId)}{@render gameRow(held)}{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if finished.length || reports.length}
|
||||
<div class="ledger past">
|
||||
{#if finished.length}
|
||||
<div class="ledger-head">finished games</div>
|
||||
{#each finished as held (held.roomId)}{@render gameRow(held)}{/each}
|
||||
{/if}
|
||||
{#if reports.length}
|
||||
<div class="ledger-head reports-head">your reports to the keeper</div>
|
||||
{#each reports as r (r.id)}
|
||||
@@ -325,7 +341,18 @@
|
||||
<p>A rule read wrong, a bug, a game you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
<About bind:open={aboutOpen} title="About __NAME__" tabs={[{ id: 'game', label: 'The game', body: aboutGame }, { id: 'tally', label: 'The tally', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
|
||||
<TabbedDialog
|
||||
address="about"
|
||||
bind:open={aboutOpen}
|
||||
title="About __NAME__"
|
||||
label="About this game"
|
||||
tabs={[
|
||||
{ id: 'game', label: 'The game', body: aboutGame },
|
||||
{ id: 'tally', label: 'The tally', body: aboutTally },
|
||||
{ id: 'family', label: 'More games', body: aboutFamily },
|
||||
{ id: 'word', label: 'Send word', body: aboutWord }
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -338,14 +365,21 @@
|
||||
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas: 'head' 'play' 'look' 'ledger';
|
||||
grid-template-areas: 'head' 'play' 'look' 'ledger' 'past';
|
||||
gap: 1.2rem 2.2rem;
|
||||
}
|
||||
/* Someone coming back sees their games first. */
|
||||
.lid.returning {
|
||||
grid-template-areas: 'head' 'ledger' 'play' 'look' 'past';
|
||||
}
|
||||
|
||||
@media (min-width: 901px) {
|
||||
.lid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas: 'head head' 'play look' 'ledger ledger';
|
||||
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'past past';
|
||||
}
|
||||
.lid.returning {
|
||||
grid-template-areas: 'head head' 'ledger ledger' 'play look' 'past past';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +499,10 @@
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ledger.past {
|
||||
grid-area: past;
|
||||
}
|
||||
|
||||
.ledger-head {
|
||||
font-style: italic;
|
||||
color: var(--bone-dim);
|
||||
@@ -570,7 +608,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* about: the panel's own words; About.svelte is the dialog around them */
|
||||
/* about: the panel's own words; TabbedDialog.svelte is the dialog around them */
|
||||
.about-body {
|
||||
max-width: 44em;
|
||||
color: var(--bone-dim);
|
||||
@@ -581,17 +619,6 @@
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.about-body a {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.about-body h3 {
|
||||
font-size: 1rem;
|
||||
font-style: italic;
|
||||
margin: 0.9rem 0 0.2rem;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.about-body .made {
|
||||
font-size: 0.85rem;
|
||||
color: var(--bone-faint);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TableTalk from './TableTalk.svelte';
|
||||
import { game } from '$lib/game';
|
||||
import type { Room } from '$lib/net/room.svelte';
|
||||
|
||||
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
|
||||
@@ -8,6 +10,28 @@
|
||||
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
||||
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
||||
|
||||
// A table that asks for rankings: this seat's, best first, kept on the server as it changes.
|
||||
const choices = $derived(game.rankable?.(v.options) ?? null);
|
||||
const people = $derived(v.seats.filter((s) => !s.bot).length);
|
||||
const labelOf = (value: string) => choices?.find((c) => c.value === value)?.label ?? value;
|
||||
let order = $state<string[]>([]);
|
||||
// Follows the server's copy; the local order is read untracked, so a click is not undone before its reply.
|
||||
$effect(() => {
|
||||
if (!choices) return;
|
||||
const values = choices.map((c) => c.value);
|
||||
const mine = v.yourRanking;
|
||||
if (mine && mine.length === values.length) order = [...mine];
|
||||
else if (untrack(() => order.length) !== values.length) order = values;
|
||||
});
|
||||
/** Move the choice at `i` to place `j`, the others closing up behind it. */
|
||||
async function move(i: number, j: number) {
|
||||
if (j < 0 || j >= order.length || j === i) return;
|
||||
const next = [...order];
|
||||
next.splice(j, 0, ...next.splice(i, 1));
|
||||
order = next;
|
||||
await room.rank(next);
|
||||
}
|
||||
|
||||
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
|
||||
let minute = $state(0);
|
||||
$effect(() => {
|
||||
@@ -62,6 +86,28 @@
|
||||
{#if v.rematchOf}
|
||||
<p class="muted">A rematch of table {v.rematchOf}: the same company, seats held for those not yet back.</p>
|
||||
{/if}
|
||||
{#if choices && !room.spectating}
|
||||
<section class="ranking" aria-label="Your ranking">
|
||||
<h3>Your ranking</h3>
|
||||
<p class="muted small">
|
||||
Best first. When the game begins, the deal reads everyone's ranking; nobody sees another player's.
|
||||
{#if !v.yourRanking}Until you move one, you have no preference and take what is left.{:else}Your ranking is kept.{/if}
|
||||
</p>
|
||||
<ol>
|
||||
{#each order as value, i (value)}
|
||||
<li>
|
||||
<span class="place">{i + 1}.</span>
|
||||
<span class="choice">{labelOf(value)}</span>
|
||||
<button type="button" class="quiet" aria-label="Move {labelOf(value)} to the top" title="To the top" disabled={i === 0} onclick={() => move(i, 0)}>⤒</button>
|
||||
<button type="button" class="quiet" aria-label="Move {labelOf(value)} up" disabled={i === 0} onclick={() => move(i, i - 1)}>↑</button>
|
||||
<button type="button" class="quiet" aria-label="Move {labelOf(value)} down" disabled={i === order.length - 1} onclick={() => move(i, i + 1)}>↓</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if !v.yourRanking}<button type="button" class="quiet" onclick={() => room.rank(order)}>Keep this order</button>{/if}
|
||||
{#if people > 1}<p class="muted small">{v.ranked} of the {people} players {v.ranked === 1 ? 'has' : 'have'} ranked.</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{#if !room.spectating}
|
||||
<div class="ways">
|
||||
{#if room.seatEmpty && room.isHost}
|
||||
@@ -170,6 +216,45 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ranking {
|
||||
max-width: 26rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.ranking h3 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.ranking ol {
|
||||
list-style: none;
|
||||
margin: 0.4rem 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ranking li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
|
||||
.ranking .place {
|
||||
width: 1.6rem;
|
||||
color: var(--bone-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ranking .choice {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ranking .quiet {
|
||||
min-width: 2.6rem;
|
||||
min-height: 2.4rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
.keeper {
|
||||
margin-top: 0.8rem;
|
||||
max-width: 38em;
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
|
||||
|
||||
{#if open}
|
||||
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
|
||||
<form class="slip" aria-labelledby="report-heading" onsubmit={send}>
|
||||
@@ -92,6 +94,8 @@
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
// A dialog of tabs: the about panel, how to play, the rules. The address
|
||||
// names it, so a plain link opens it: #rules opens the rules, #guide/notation
|
||||
// how to play at its notation. It closes with its button, a click outside
|
||||
// it, or Escape. The tab last read is the one it reopens on. `open` tells
|
||||
// the page whether it is showing.
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Tabs, { type Tab } from './Tabs.svelte';
|
||||
|
||||
let {
|
||||
address,
|
||||
title,
|
||||
label = title,
|
||||
tabs,
|
||||
intro,
|
||||
open = $bindable(false)
|
||||
}: { address: string; title: string; label?: string; tabs: Tab[]; intro?: Snippet; open?: boolean } = $props();
|
||||
|
||||
let current = $state('');
|
||||
|
||||
// The dialog is open exactly while the router's address names it. Opening, choosing a tab and
|
||||
// closing all navigate, so the address and the dialog cannot disagree.
|
||||
const [named, tab] = $derived.by(() => {
|
||||
const [name, t] = page.url.hash.slice(1).split('/');
|
||||
return [name === address, t] as const;
|
||||
});
|
||||
$effect(() => {
|
||||
open = named;
|
||||
});
|
||||
$effect(() => {
|
||||
if (named && tabs.some((t) => t.id === tab)) untrack(() => (current = tab));
|
||||
});
|
||||
// The address follows the chosen tab, so what is being read can be linked to.
|
||||
$effect(() => {
|
||||
if (named && current) untrack(() => current !== tab && show(current));
|
||||
});
|
||||
|
||||
const bare = () => `${page.url.pathname}${page.url.search}`;
|
||||
const quietly = { replaceState: true, noScroll: true, keepFocus: true };
|
||||
function show(id: string) {
|
||||
goto(`${bare()}#${address}/${id}`, quietly);
|
||||
}
|
||||
function close() {
|
||||
goto(bare(), quietly);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (named && e.key === 'Escape') close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if named}
|
||||
<div class="scrim anchored" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
|
||||
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="{address}-title">
|
||||
<header>
|
||||
<h2 id="{address}-title">{title}</h2>
|
||||
<button type="button" class="close" aria-label="Close" onclick={close}>×</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
{#if intro}<div class="intro">{@render intro()}</div>{/if}
|
||||
<Tabs {tabs} {label} bind:current />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Its own backdrop: not every game has another dialog to share one with. */
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
/* Held near the top, not centered: a tab of another length must not move the tabs under the pointer. */
|
||||
.anchored {
|
||||
place-items: start center;
|
||||
padding-top: max(16px, 6vh);
|
||||
}
|
||||
/* min-width 0 all the way down, so wide content scrolls in place instead of widening the dialog past a phone's screen. */
|
||||
.panel {
|
||||
width: min(100%, 44rem);
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--slate);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 6px;
|
||||
padding: 1rem 1.4rem 1.2rem;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
.body {
|
||||
overflow: auto;
|
||||
min-height: 12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.intro {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
.body :global(a) {
|
||||
color: var(--frost);
|
||||
}
|
||||
.body :global(h3) {
|
||||
font-size: 1rem;
|
||||
font-style: italic;
|
||||
margin: 0.9rem 0 0.2rem;
|
||||
color: var(--bone);
|
||||
}
|
||||
.body :global(h3:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* The tab strip stays in view while a long tab scrolls beneath it. */
|
||||
.body :global([role='tablist']) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--slate);
|
||||
}
|
||||
</style>
|
||||
@@ -47,7 +47,10 @@
|
||||
<li class="muted">Nobody has said a word.</li>
|
||||
{/if}
|
||||
{#each lines as line, i (line.at + ':' + i)}
|
||||
<li class:you={!line.name && line.id === room.me}><em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
|
||||
<li class:you={!line.name && line.id === room.me} class:private={!!line.to}>
|
||||
<em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}<span class="to">, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone</span>{/if}</em>
|
||||
{line.text}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if room.spectating && room.view.galleryTalk}
|
||||
@@ -110,6 +113,10 @@
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
li.private {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts" module>
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
/** One tab and what it shows. */
|
||||
export interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
body: Snippet;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// A strip of tabs over one panel, for a dialog that would otherwise be one
|
||||
// long read. Arrow keys walk the tabs, Home and End go to the ends.
|
||||
import { tick } from 'svelte';
|
||||
|
||||
let { tabs, label, current = $bindable('') }: { tabs: Tab[]; label: string; current?: string } = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
const shown = $derived(tabs.find((t) => t.id === current) ?? tabs[0]);
|
||||
let tabButtons: HTMLButtonElement[] = $state([]);
|
||||
|
||||
async function onKey(e: KeyboardEvent, i: number) {
|
||||
const to = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
const next = (to + tabs.length) % tabs.length;
|
||||
current = tabs[next].id;
|
||||
await tick();
|
||||
tabButtons[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tabs" role="tablist" aria-label={label}>
|
||||
{#each tabs as t, i (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="{uid}-{t.id}"
|
||||
aria-controls="{uid}-panel"
|
||||
aria-selected={shown.id === t.id}
|
||||
tabindex={shown.id === t.id ? 0 : -1}
|
||||
class:on={shown.id === t.id}
|
||||
bind:this={tabButtons[i]}
|
||||
onclick={() => (current = t.id)}
|
||||
onkeydown={(e) => onKey(e, i)}>{t.label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="panel" id="{uid}-panel" role="tabpanel" aria-labelledby="{uid}-{shown.id}" tabindex="0">
|
||||
{@render shown.body()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Small capitals in a row that may wrap, the open tab boxed: a second row reads as more buttons, not a broken line. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--rule-strong);
|
||||
margin: 0.6rem 0 0.8rem;
|
||||
}
|
||||
.tabs button {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: 3px;
|
||||
color: var(--bone-dim);
|
||||
padding: 0.3rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabs button:hover {
|
||||
color: var(--bone);
|
||||
}
|
||||
.tabs button.on {
|
||||
border-color: var(--bone);
|
||||
color: var(--bone);
|
||||
background: var(--slate-deep);
|
||||
}
|
||||
.panel :global(p + p) {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
@@ -7,9 +7,9 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// What the ledgers add up to, told as Wiz-War tells it: a few counts that
|
||||
// are the game's own story, large, on cards; then the rest in a column of
|
||||
// figures; then the fine print. Each game chooses its counts and words.
|
||||
// What the ledgers add up to: a few counts that are the game's own story,
|
||||
// large, on cards; then the rest in a column of figures; then the fine
|
||||
// print. Each game chooses its counts and words.
|
||||
let { headline, details, note, title = 'How much love the table is getting' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
|
||||
$props();
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanOptions, tableSize, type TableOption } from './spec';
|
||||
|
||||
const options: TableOption[] = [
|
||||
{ key: 'length', label: 'Length', choices: [{ value: 'short', label: 'Short' }, { value: 'long', label: 'Long' }], default: 'short' }
|
||||
];
|
||||
|
||||
describe('table options', () => {
|
||||
it('keeps only declared options and offered choices, the rest at their defaults', () => {
|
||||
expect(cleanOptions({ options }, { length: 'long', colour: 'red' })).toEqual({ length: 'long' });
|
||||
expect(cleanOptions({ options }, { length: 'endless' })).toEqual({ length: 'short' });
|
||||
expect(cleanOptions({ options }, null)).toEqual({ length: 'short' });
|
||||
});
|
||||
|
||||
it('seats as many as the options allow, never more than the seats there are', () => {
|
||||
const seatIds = ['A', 'B', 'C', 'D'];
|
||||
expect(tableSize({ seatIds, options })).toBe(4);
|
||||
const seatsFor = (o: Record<string, string>) => (o.length === 'short' ? 2 : 9);
|
||||
expect(tableSize({ seatIds, options, seatsFor }, { length: 'short' })).toBe(2);
|
||||
expect(tableSize({ seatIds, options, seatsFor }, { length: 'long' })).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,29 @@
|
||||
/** A seat at the table: a single letter from GameSpec.seatIds. */
|
||||
export type SeatId = string;
|
||||
|
||||
/**
|
||||
* A line of table talk as a game reads it: who, what, when, the round it was
|
||||
* said in, to whom (absent: everyone), and a bot's note on its own line.
|
||||
*/
|
||||
export interface PressLine {
|
||||
id: SeatId;
|
||||
text: string;
|
||||
at: number;
|
||||
/** The seats a private line was said to. */
|
||||
to?: SeatId[];
|
||||
/** The round the line was said in, from one; absent before the game begins. */
|
||||
turn?: number;
|
||||
/** What a bot noted about its own line; kept in the ledger, never shown. */
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
/** A bot's line of talk: to the people it names, with a note for its own memory. */
|
||||
export interface BotLine {
|
||||
to: SeatId[];
|
||||
text: string;
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
/** The viewer with no seat: the Peanut Gallery. Views built for it show only what every seat could see. */
|
||||
export const SPECTATOR: SeatId = '';
|
||||
|
||||
@@ -48,6 +71,8 @@ export interface Preference {
|
||||
export interface GameSpec<State, Input> {
|
||||
/** Seats in table order; the table takes at most this many. */
|
||||
seatIds: SeatId[];
|
||||
/** The most seats a table with these options takes, when an option makes it fewer than `seatIds`. */
|
||||
seatsFor?(options: TableOptions): number;
|
||||
/** What the host may choose when opening a table; empty for a game with one way to play. */
|
||||
options?: TableOption[];
|
||||
/** The game's own settings for the preferences panel. */
|
||||
@@ -63,17 +88,49 @@ export interface GameSpec<State, Input> {
|
||||
*/
|
||||
currentRules: number;
|
||||
|
||||
create(names: Record<SeatId, string>, seed: number, rules: number, options?: TableOptions): State;
|
||||
/**
|
||||
* Begin the game. `rankings` are the seats' preferences, best first, at a
|
||||
* table that asks for them (`rankable`); the ledger's start line keeps them.
|
||||
*/
|
||||
create(names: Record<SeatId, string>, seed: number, rules: number, options?: TableOptions, rankings?: Record<SeatId, string[]>): State;
|
||||
/**
|
||||
* Whether a table with these options asks each seat to rank something
|
||||
* before the deal (sides, roles, colours): the choices to rank, or null.
|
||||
* Each seat ranks in secret; `create` reads every ranking at once.
|
||||
*/
|
||||
rankable?(options: TableOptions): { value: string; label: string }[] | null;
|
||||
/** Resolve one round. Must be a pure function of its arguments: the ledger is replayed through it. */
|
||||
resolve(state: State, inputs: Record<SeatId, Input>): State;
|
||||
/** Whether this seat's input is needed before the next resolution. */
|
||||
needsInput(state: State, seat: SeatId): boolean;
|
||||
/**
|
||||
* Whether a submitted input is final. A game that lets a player save a
|
||||
* draft and change it answers false until the player says it is done;
|
||||
* the round waits for every seat's settled input. Absent, every input is final.
|
||||
*/
|
||||
settled?(input: Input): boolean;
|
||||
over(state: State): Outcome | null;
|
||||
/** The round being written, counted from one, for the ledger and the report pin. */
|
||||
turn(state: State): number;
|
||||
/** What one viewer may see. The server sends nothing else. */
|
||||
view(state: State, viewer: SeatId): State;
|
||||
botInput(state: State, seat: SeatId): Input;
|
||||
/**
|
||||
* A bot's move. `press` is the talk its seat has heard (the table's, and
|
||||
* private lines to it or from it, notes included), for a game whose bots
|
||||
* keep what was agreed; a function of the state and the press, which the
|
||||
* turn line records, so a replay never asks again.
|
||||
*/
|
||||
botInput(state: State, seat: SeatId, press?: PressLine[]): Input;
|
||||
/**
|
||||
* What a bot says: when a round opens ('phase'), and when a person has
|
||||
* just written to it, or said a first word to the table ('spoken').
|
||||
* `press` is what its seat has heard; `people` the seats played by people.
|
||||
* Each line goes on the ledger, to the people it names and no one else.
|
||||
* Absent, bots say nothing and a private line to one is refused.
|
||||
*/
|
||||
botPress?(state: State, seat: SeatId, press: PressLine[], people: SeatId[], trigger: 'phase' | 'spoken'): BotLine[];
|
||||
/** The bots' last words to `viewer` once the game is over, read from the whole of the talk. */
|
||||
parting?(press: PressLine[], viewer: SeatId): { id: SeatId; text: string }[];
|
||||
/** Only the shapes the engine understands get through; the engine validates the rest. */
|
||||
cleanInput(raw: unknown): Input;
|
||||
/** Why this seat may not make this move now, or null when it may. The server answers with the reason. */
|
||||
@@ -82,4 +139,27 @@ export interface GameSpec<State, Input> {
|
||||
nameOf(state: State, seat: SeatId): string;
|
||||
/** The game's own lines for the hall's tally, counted from a finished game: a label and how many it adds. */
|
||||
tally?(state: State): Record<string, number>;
|
||||
/** The game's own lines counted from every game begun, finished or not, by its table's options (games begun on each variant). */
|
||||
tallyBegun?(options: TableOptions): Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A table's options as the game will play them: only the options it
|
||||
* declares, only the choices it offers, the rest at their defaults. The
|
||||
* server, the tally and the ledger replay all read a room line through this,
|
||||
* so each creates the same game from the same ledger.
|
||||
*/
|
||||
export function cleanOptions(game: Pick<GameSpec<unknown, unknown>, 'options'>, raw: unknown): TableOptions {
|
||||
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const options: TableOptions = {};
|
||||
for (const o of game.options ?? []) {
|
||||
const v = given[o.key];
|
||||
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The most seats a table with these options takes. */
|
||||
export function tableSize(game: Pick<GameSpec<unknown, unknown>, 'options' | 'seatIds' | 'seatsFor'>, raw?: unknown): number {
|
||||
return Math.min(game.seatIds.length, game.seatsFor?.(cleanOptions(game, raw)) ?? game.seatIds.length);
|
||||
}
|
||||
@@ -132,7 +132,9 @@ export function makeApi<State, Input>() {
|
||||
/** The gallery's view: no token, no seat, nothing any player could not see. */
|
||||
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
|
||||
turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
|
||||
say: (roomId: string, token: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text }),
|
||||
hurry: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/hurry`, { token }),
|
||||
rank: (roomId: string, token: string, ranking: string[]) => call<View>('POST', `/api/rooms/${roomId}/rank`, { token, ranking }),
|
||||
say: (roomId: string, token: string, text: string, to?: SeatId[]) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text, ...(to?.length ? { to } : {}) }),
|
||||
/** A word from the gallery, signed with a name, when the host allows it. */
|
||||
sayFromGallery: (roomId: string, name: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { name, text }),
|
||||
setGalleryTalk: (roomId: string, token: string, on: boolean) => call<View>('POST', `/api/rooms/${roomId}/gallery`, { token, on }),
|
||||
|
||||
@@ -112,9 +112,20 @@ export class Room {
|
||||
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.');
|
||||
}
|
||||
|
||||
say(text: string): Promise<boolean> {
|
||||
/** Out of the game, with the bots playing on: play the rest at once. */
|
||||
hurry(): Promise<boolean> {
|
||||
return this.act(() => api.hurry(this.roomId, this.token), 'The table did not hear you.');
|
||||
}
|
||||
|
||||
/** This seat's ranking of the table's choices, best first, before the game begins. */
|
||||
rank(ranking: string[]): Promise<boolean> {
|
||||
return this.act(() => api.rank(this.roomId, this.token, ranking), 'Your ranking was not kept.');
|
||||
}
|
||||
|
||||
/** Talk to the whole table, or, naming seats, to them alone. */
|
||||
say(text: string, to?: SeatId[]): Promise<boolean> {
|
||||
if (this.spectating) return Promise.resolve(false);
|
||||
return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.');
|
||||
return this.act(() => api.say(this.roomId, this.token, text, to), 'The table did not hear you.');
|
||||
}
|
||||
|
||||
/** A word from the gallery, signed; only when the host has let the gallery talk. */
|
||||
@@ -165,7 +176,9 @@ export class Room {
|
||||
static async createWithBot(name: string, options?: TableOptions): Promise<Room> {
|
||||
const seated = await api.create(name, 2, options);
|
||||
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
||||
await api.addBot(seated.view.roomId, seated.token);
|
||||
const view = await api.addBot(seated.view.roomId, seated.token);
|
||||
// A table that asks for rankings waits in the lobby for the player's; the rest begin at once.
|
||||
if (game.rankable?.(view.options)) return new Room(view, seated.token);
|
||||
return new Room(await api.begin(seated.view.roomId, seated.token), seated.token);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ export interface ChatLine {
|
||||
text: string;
|
||||
at: number;
|
||||
name?: string;
|
||||
/** A private line: the seats it was said to. Absent, the whole table and the gallery heard it. */
|
||||
to?: SeatId[];
|
||||
/** The round the line was said in, counted from one; absent before the game begins. */
|
||||
turn?: number;
|
||||
/** A bot's memory of the line (an offer made, an answer heard). Kept in the ledger; never sent. */
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
export interface RoomView<State> {
|
||||
@@ -29,7 +35,19 @@ export interface RoomView<State> {
|
||||
turn: number | null;
|
||||
over: Outcome | null;
|
||||
state: State | null;
|
||||
/** This seat's ranking of the table's choices, at a table that asks for one; nobody sees another's. */
|
||||
yourRanking: string[] | null;
|
||||
/** How many seats have ranked, and nothing more about them. */
|
||||
ranked: number;
|
||||
/** This seat's input for the round in progress, as the server holds it (a draft, or a settled move); null for none, and for the gallery. */
|
||||
yourInput: unknown;
|
||||
/** This seat's input in the last round it played, for telling it what became of its own choice; null for none, and for the gallery. */
|
||||
yourLastInput: unknown;
|
||||
chat: ChatLine[];
|
||||
/** The bots' last words to this seat once the game is over, for a game that gives them some. */
|
||||
parting: { id: SeatId; text: string }[];
|
||||
/** The bots are playing on by themselves, a round at a time, with no person owing a move. */
|
||||
playingOn: boolean;
|
||||
/** How many watch from the Peanut Gallery. */
|
||||
audience: number;
|
||||
/** The host lets the gallery talk. */
|
||||
|
||||
@@ -21,6 +21,6 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts']
|
||||
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user