Compare commits
9
Commits
a673ea9766
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9de0a55d54 | ||
|
|
42144c1cc9 | ||
|
|
22979afd35 | ||
|
|
4c224a8e22 | ||
|
|
26d9b7f5d6 | ||
|
|
fa5e9cfc08 | ||
|
|
f21b2c3b16 | ||
|
|
745b8e99b9 | ||
|
|
f98bfba107 |
No files matched your search
@@ -43,7 +43,10 @@ __HOST__ {
|
|||||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
@mutable not path /_app/immutable/*
|
@mutable not path /_app/immutable/*
|
||||||
header @mutable Cache-Control "no-cache"
|
header @mutable Cache-Control "no-cache"
|
||||||
try_files {path} {path}.html /index.html
|
# try_files passes the root over as a directory and would fall to the shell; the hall's
|
||||||
|
# own page is index.html.
|
||||||
|
rewrite / /index.html
|
||||||
|
try_files {path} {path}.html /shell.html
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,9 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny
|
|||||||
through the duel server's `/keeper` door, which Caddy does not proxy and
|
through the duel server's `/keeper` door, which Caddy does not proxy and
|
||||||
which asks for the key in /etc/waving-hands/keeper.key, made by the deploy
|
which asks for the key in /etc/waving-hands/keeper.key, made by the deploy
|
||||||
and readable by root and the waving-hands user alone.
|
and readable by root and the waving-hands user alone.
|
||||||
|
- `waving-hands-say.sh ROOM "text"`: a word from the keeper in a table's
|
||||||
|
talk, signed as the keeper, through the same door; it is heard whether or
|
||||||
|
not the gallery may talk, so a call that came too late can be answered.
|
||||||
- `waving-hands-pulse.sh`: the weekly health check (service, errors, rollup
|
- `waving-hands-pulse.sh`: the weekly health check (service, errors, rollup
|
||||||
trend, backup, box).
|
trend, backup, box).
|
||||||
- `waving-hands-rollup.sh [day]`: one JSON line per day of counts, run
|
- `waving-hands-rollup.sh [day]`: one JSON line per day of counts, run
|
||||||
|
|||||||
+8
-1
@@ -25,7 +25,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
|
|||||||
# it needs only its code and the engine. Ledgers live outside this tree.
|
# it needs only its code and the engine. Ledgers live outside this tree.
|
||||||
rsync -az --delete --exclude='/server/node_modules' \
|
rsync -az --delete --exclude='/server/node_modules' \
|
||||||
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' \
|
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' \
|
||||||
--include='/deploy/' --include='/deploy/waving-hands.service' --include='/deploy/waving-hands.cron' --include='/deploy/*.sh' --include='/deploy/hash-tokens.ts' --include='/deploy/traffic.py' \
|
--include='/deploy/' --include='/deploy/waving-hands.service' --include='/deploy/waving-hands.cron' --include='/deploy/*.sh' --include='/deploy/Caddyfile.tmpl' --include='/deploy/hash-tokens.ts' --include='/deploy/traffic.py' \
|
||||||
--exclude='*' \
|
--exclude='*' \
|
||||||
./ "root@$HOST:/opt/waving-hands/app/"
|
./ "root@$HOST:/opt/waving-hands/app/"
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ ssh "root@$HOST" '
|
|||||||
install -m 755 /opt/waving-hands/app/deploy/waving-hands-rollup.sh /usr/local/bin/waving-hands-rollup.sh
|
install -m 755 /opt/waving-hands/app/deploy/waving-hands-rollup.sh /usr/local/bin/waving-hands-rollup.sh
|
||||||
install -m 755 /opt/waving-hands/app/deploy/waving-hands-backup.sh /usr/local/bin/waving-hands-backup.sh
|
install -m 755 /opt/waving-hands/app/deploy/waving-hands-backup.sh /usr/local/bin/waving-hands-backup.sh
|
||||||
install -m 755 /opt/waving-hands/app/deploy/waving-hands-reseat.sh /usr/local/bin/waving-hands-reseat.sh
|
install -m 755 /opt/waving-hands/app/deploy/waving-hands-reseat.sh /usr/local/bin/waving-hands-reseat.sh
|
||||||
|
install -m 755 /opt/waving-hands/app/deploy/waving-hands-say.sh /usr/local/bin/waving-hands-say.sh
|
||||||
# The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone.
|
# The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone.
|
||||||
if [ ! -s /etc/waving-hands/keeper.key ]; then
|
if [ ! -s /etc/waving-hands/keeper.key ]; then
|
||||||
install -d -m 750 -o root -g waving-hands /etc/waving-hands
|
install -d -m 750 -o root -g waving-hands /etc/waving-hands
|
||||||
@@ -59,6 +60,12 @@ ssh "root@$HOST" '
|
|||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now waving-hands
|
systemctl enable --now waving-hands
|
||||||
systemctl restart waving-hands
|
systemctl restart waving-hands
|
||||||
|
# The site file is rendered from the template on every deploy, with the address line the box was set up with.
|
||||||
|
ADDR=$(head -1 /etc/caddy/sites/waving-hands.caddy | sed "s/ {\$//")
|
||||||
|
sed "s|__HOST__|$ADDR|" /opt/waving-hands/app/deploy/Caddyfile.tmpl > /etc/caddy/sites/waving-hands.caddy
|
||||||
|
caddy validate --config /etc/caddy/Caddyfile >/dev/null
|
||||||
|
# Validation as root touches the access logs; Caddy runs as caddy and must own them.
|
||||||
|
chown -R caddy:caddy /var/lib/caddy
|
||||||
systemctl reload caddy
|
systemctl reload caddy
|
||||||
sleep 1
|
sleep 1
|
||||||
systemctl --no-pager -l status waving-hands | head -3
|
systemctl --no-pager -l status waving-hands | head -3
|
||||||
|
|||||||
+8
-2
@@ -44,7 +44,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
|
|||||||
continue
|
continue
|
||||||
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
|
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
|
||||||
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
|
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
|
||||||
names = {}
|
names, seat_changes = {}, []
|
||||||
for x in lines[1:]:
|
for x in lines[1:]:
|
||||||
if x["t"] == "seat":
|
if x["t"] == "seat":
|
||||||
(bots_seated if x["bot"] else humans).append(x["name"])
|
(bots_seated if x["bot"] else humans).append(x["name"])
|
||||||
@@ -56,6 +56,10 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
|
|||||||
elif x["t"] == "turn":
|
elif x["t"] == "turn":
|
||||||
turns += 1
|
turns += 1
|
||||||
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
|
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
|
||||||
|
elif x["t"] == "standIn":
|
||||||
|
seat_changes.append("%s %s's seat to a bot" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
|
||||||
|
elif x["t"] == "reseat":
|
||||||
|
seat_changes.append("%s %s re-seated" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
|
||||||
elif x["t"] == "chat":
|
elif x["t"] == "chat":
|
||||||
talk += 1
|
talk += 1
|
||||||
elif x["t"] == "over":
|
elif x["t"] == "over":
|
||||||
@@ -63,7 +67,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
|
|||||||
for h in humans:
|
for h in humans:
|
||||||
if h not in first_seen or created < first_seen[h]:
|
if h not in first_seen or created < first_seen[h]:
|
||||||
first_seen[h] = created
|
first_seen[h] = created
|
||||||
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk))
|
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk, seat_changes=seat_changes))
|
||||||
|
|
||||||
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
|
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
|
||||||
names = sorted({h for r in today for h in r["humans"]})
|
names = sorted({h for r in today for h in r["humans"]})
|
||||||
@@ -80,4 +84,6 @@ for r in today:
|
|||||||
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
|
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
|
||||||
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
|
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
|
||||||
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
|
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
|
||||||
|
for change in r["seat_changes"]:
|
||||||
|
print(" %s" % change)
|
||||||
PY
|
PY
|
||||||
@@ -19,7 +19,7 @@ r = json.load(sys.stdin)
|
|||||||
if "error" in r: sys.exit(r["error"])
|
if "error" in r: sys.exit(r["error"])
|
||||||
print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun"))
|
print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun"))
|
||||||
for s in r["seats"]:
|
for s in r["seats"]:
|
||||||
when = "bot" if s["bot"] else "never moved or spoke" if s["lastActed"] is None else "last acted " + datetime.datetime.fromtimestamp(s["lastActed"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
when = "bot" if s["bot"] else ("a bot stands in; " if s.get("standIn") else "") + ("never moved or spoke" if s["lastActed"] is None else "last acted " + datetime.datetime.fromtimestamp(s["lastActed"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"))
|
||||||
print(" %-3s %-24s %s" % (s["id"], s["name"], when))
|
print(" %-3s %-24s %s" % (s["id"], s["name"], when))
|
||||||
'
|
'
|
||||||
else
|
else
|
||||||
|
|||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# A word from the keeper in a table's talk, signed as the keeper, whether or
|
||||||
|
# not the host lets the gallery talk: the answer to a call that came too late,
|
||||||
|
# or anything else the table should hear from the keeper. Runs ON the droplet,
|
||||||
|
# through the duel server's keeper door.
|
||||||
|
# waving-hands-say.sh ROOM "text"
|
||||||
|
set -euo pipefail
|
||||||
|
ROOM="${1:?usage: waving-hands-say.sh ROOM \"text\"}"
|
||||||
|
TEXT="${2:?usage: waving-hands-say.sh ROOM \"text\"}"
|
||||||
|
DOOR="http://127.0.0.1:8788/keeper/rooms/${ROOM^^}"
|
||||||
|
[ -s /etc/waving-hands/keeper.key ] || { echo "No keeper's key at /etc/waving-hands/keeper.key; a deploy makes it." >&2; exit 1; }
|
||||||
|
# The key goes as a header read from a file descriptor, never on a command line another user could see.
|
||||||
|
KEY="@/dev/fd/3"
|
||||||
|
exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/waving-hands/keeper.key)")
|
||||||
|
BODY=$(TEXT="$TEXT" python3 -c 'import json, os; print(json.dumps({"text": os.environ["TEXT"]}))')
|
||||||
|
CODE=$(curl -s -o /tmp/waving-hands-say.$$ -w '%{http_code}' -X POST -H "$KEY" -H 'content-type: application/json' -d "$BODY" "$DOOR/say")
|
||||||
|
if [ "$CODE" = 204 ]; then echo "said at ${ROOM^^}"; else cat /tmp/waving-hands-say.$$ >&2; echo >&2; rm -f /tmp/waving-hands-say.$$; exit 1; fi
|
||||||
|
rm -f /tmp/waving-hands-say.$$
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
// POST /api/rooms/:id/challenge {token} call the keeper of the site to a held seat; their phone rings
|
// POST /api/rooms/:id/challenge {token} call the keeper of the site to a held seat; their phone rings
|
||||||
// POST /api/rooms/:id/rematch {token} a finished table opens another with the same company
|
// POST /api/rooms/:id/rematch {token} a finished table opens another with the same company
|
||||||
// POST /api/rooms/:id/transfer {token} a four-word phrase that carries this seat to another device
|
// POST /api/rooms/:id/transfer {token} a four-word phrase that carries this seat to another device
|
||||||
|
// POST /api/rooms/:id/phrase {token, seat} the host mints one for another wizard's seat, which re-seats it when claimed
|
||||||
|
// POST /api/rooms/:id/standin {token, seat} the host gives a wizard's seat to a bot for the rest of the duel
|
||||||
// POST /api/transfer {phrase} claim a phrase: the seat and its token, once
|
// POST /api/transfer {phrase} claim a phrase: the seat and its token, once
|
||||||
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
|
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
|
||||||
// a seat in the Peanut Gallery, counted for the table
|
// a seat in the Peanut Gallery, counted for the table
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
//
|
//
|
||||||
// GET /keeper/rooms/:id who sits at the table, and when each wizard last moved or spoke
|
// GET /keeper/rooms/:id who sits at the table, and when each wizard last moved or spoke
|
||||||
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
|
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
|
||||||
|
// POST /keeper/rooms/:id/say {text} a word from the keeper in the table's talk
|
||||||
|
|
||||||
import * as Sentry from '@sentry/node';
|
import * as Sentry from '@sentry/node';
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
@@ -79,6 +82,8 @@ const bells = new RateLimit(3, 60 * 60 * 1000);
|
|||||||
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
|
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
|
||||||
const claims = new RateLimit(10, 10 * 60 * 1000);
|
const claims = new RateLimit(10, 10 * 60 * 1000);
|
||||||
const MAX_AUDIENCE = 30;
|
const MAX_AUDIENCE = 30;
|
||||||
|
/** How often each socket is asked to answer; one silent through a whole round is a browser gone, and is closed. */
|
||||||
|
const PING_MS = Number(process.env.PING_MS ?? 30_000);
|
||||||
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
|
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const n = rooms.evictIdle(IDLE_ROOM_MS);
|
const n = rooms.evictIdle(IDLE_ROOM_MS);
|
||||||
@@ -230,6 +235,11 @@ async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: st
|
|||||||
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server.
|
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server.
|
||||||
return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` });
|
return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` });
|
||||||
}
|
}
|
||||||
|
if (parts[3] === 'say' && parts.length === 4 && req.method === 'POST') {
|
||||||
|
const body = await readBody(req);
|
||||||
|
rooms.keeperSays(room, KEEPER, body.text);
|
||||||
|
return send(res, 204, {});
|
||||||
|
}
|
||||||
throw new RoomError('Not here.', 404);
|
throw new RoomError('Not here.', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +349,14 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|||||||
if (action === 'transfer') {
|
if (action === 'transfer') {
|
||||||
return send(res, 200, rooms.transfer(room, typeof body.token === 'string' ? body.token : undefined));
|
return send(res, 200, rooms.transfer(room, typeof body.token === 'string' ? body.token : undefined));
|
||||||
}
|
}
|
||||||
|
if (action === 'phrase') {
|
||||||
|
return send(res, 200, rooms.hostPhrase(room, typeof body.token === 'string' ? body.token : undefined, body.seat));
|
||||||
|
}
|
||||||
|
if (action === 'standin') {
|
||||||
|
const token = typeof body.token === 'string' ? body.token : undefined;
|
||||||
|
rooms.standIn(room, token, body.seat);
|
||||||
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||||
|
}
|
||||||
throw new RoomError('Not here.', 404);
|
throw new RoomError('Not here.', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,12 +390,31 @@ server.on('upgrade', (req, socket, head) => {
|
|||||||
};
|
};
|
||||||
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
|
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
|
||||||
ws.on('close', unsubscribe);
|
ws.on('close', unsubscribe);
|
||||||
|
answered.set(ws, true);
|
||||||
|
ws.on('pong', () => answered.set(ws, true));
|
||||||
// A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
|
// A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
|
||||||
ws.on('error', () => ws.terminate());
|
ws.on('error', () => ws.terminate());
|
||||||
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
|
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
|
||||||
|
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
|
||||||
|
* answers by itself, finds it within two rounds.
|
||||||
|
*/
|
||||||
|
const answered = new WeakMap<WebSocket, boolean>();
|
||||||
|
setInterval(() => {
|
||||||
|
for (const ws of wss.clients) {
|
||||||
|
if (!answered.get(ws)) {
|
||||||
|
ws.terminate();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
answered.set(ws, false);
|
||||||
|
ws.ping();
|
||||||
|
}
|
||||||
|
}, PING_MS).unref();
|
||||||
|
|
||||||
server.listen(PORT, HOST, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
|
console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
|
||||||
});
|
});
|
||||||
@@ -3,7 +3,9 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { Rooms } from './rooms';
|
import { Rooms } from './rooms';
|
||||||
|
import { chooseBotTurn } from '../../src/lib/game/bot';
|
||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
|
import { SPECTATOR } from '../../src/lib/game/state';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -37,6 +39,15 @@ describe('rooms', () => {
|
|||||||
expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/);
|
expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("puts the keeper's word in the table's talk though the gallery may not talk, and a restart keeps it", () => {
|
||||||
|
const { rooms, room } = table();
|
||||||
|
expect(room.galleryTalk).toBe(false);
|
||||||
|
rooms.keeperSays(room, 'Kestrel', 'I came too late for the seat; call me again.');
|
||||||
|
const said = { id: SPECTATOR, name: 'Kestrel', keeper: true, text: 'I came too late for the seat; call me again.' };
|
||||||
|
expect(room.chat.at(-1)).toMatchObject(said);
|
||||||
|
expect(new Rooms(new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said);
|
||||||
|
});
|
||||||
|
|
||||||
it('re-seats only a wizard, and says when each last acted', () => {
|
it('re-seats only a wizard, and says when each last acted', () => {
|
||||||
const { rooms, room, ann } = table();
|
const { rooms, room, ann } = table();
|
||||||
expect(() => rooms.reseat(room, 'Z')).toThrow(/No wizard sits/);
|
expect(() => rooms.reseat(room, 'Z')).toThrow(/No wizard sits/);
|
||||||
@@ -47,3 +58,66 @@ describe('rooms', () => {
|
|||||||
expect(seats[1].lastActed).toBeNull();
|
expect(seats[1].lastActed).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the host and the other seats', () => {
|
||||||
|
it("gives a wizard's seat to a bot, which duels the turn it was waiting on, and the wizard's token opens nothing", () => {
|
||||||
|
const { rooms, room, ann, bo } = table();
|
||||||
|
rooms.submit(room, ann, chooseBotTurn(room.state!, 'A'), 0);
|
||||||
|
rooms.standIn(room, ann, 'B');
|
||||||
|
expect(room.resolved).toBe(1);
|
||||||
|
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
|
||||||
|
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the gestures a wizard made before the bot stood in', () => {
|
||||||
|
const { rooms, room, ann, bo } = table();
|
||||||
|
const mine = { left: 'P', right: 'W', casts: [] } as const;
|
||||||
|
rooms.submit(room, bo, mine, 0);
|
||||||
|
rooms.standIn(room, ann, 'B');
|
||||||
|
rooms.submit(room, ann, chooseBotTurn(room.state!, 'A'), 0);
|
||||||
|
expect(room.state!.wizards.B.history.at(-1)).toMatchObject({ left: 'P', right: 'W' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is the host's call alone, in a duel under way, for a wizard other than the host", () => {
|
||||||
|
const { rooms, room, ann, bo } = table();
|
||||||
|
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the wizard who opened/);
|
||||||
|
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
|
||||||
|
expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No wizard sits/);
|
||||||
|
const lobby = rooms.create('Ann', 2);
|
||||||
|
rooms.join(lobby.room, 'Bo');
|
||||||
|
expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the seat back by the host's phrase, with a new token, and a restart keeps both changes", () => {
|
||||||
|
const { rooms, room, ann, bo } = table();
|
||||||
|
rooms.standIn(room, ann, 'B');
|
||||||
|
expect(new Rooms(new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
|
||||||
|
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
|
||||||
|
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
|
||||||
|
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||||
|
const reopened = new Rooms(new Store(dir)).get(room.id);
|
||||||
|
expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-seats a wizard whose browser lost the seat, by the host's phrase, once, and only the host makes one", () => {
|
||||||
|
const { rooms, room, ann, bo } = table();
|
||||||
|
expect(() => rooms.hostPhrase(room, bo, 'A')).toThrow(/Only the wizard who opened/);
|
||||||
|
expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other wizard/);
|
||||||
|
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||||
|
expect(rooms.claim(phrase).token).not.toBe(bo);
|
||||||
|
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||||
|
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('voids a phrase for the seat once a bot stands in, and holds the wizard\'s seat at a rematch without the stand-in', () => {
|
||||||
|
const { rooms, room, ann } = table();
|
||||||
|
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||||
|
rooms.standIn(room, ann, 'B');
|
||||||
|
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||||
|
room.state = { ...room.state!, over: { winner: 'A', reason: 'test' } } as typeof room.state;
|
||||||
|
const next = rooms.get(rooms.rematch(room, ann).roomId);
|
||||||
|
expect(next.expected).toEqual(['Bo']);
|
||||||
|
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
+81
-18
@@ -23,9 +23,12 @@ const CHAT_KEEP = 200;
|
|||||||
export interface Seat {
|
export interface Seat {
|
||||||
id: WizardId;
|
id: WizardId;
|
||||||
name: string;
|
name: string;
|
||||||
/** SHA-256 of the seat's token, hex; empty for a bot. */
|
/** SHA-256 of the seat's token, hex; empty for a bot. A seat a bot stands in for keeps the wizard's, which opens nothing while it does. */
|
||||||
tokenHash: string;
|
tokenHash: string;
|
||||||
|
/** A bot plays this seat: one seated as a bot, or one standing in for a wizard. */
|
||||||
bot: boolean;
|
bot: boolean;
|
||||||
|
/** The host gave this wizard's seat to a bot; a phrase from the host, or the keeper, gives it back. */
|
||||||
|
standIn?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hashToken(token: string): string {
|
export function hashToken(token: string): string {
|
||||||
@@ -153,7 +156,8 @@ export class Rooms {
|
|||||||
seatOf(room: Room, token: string | undefined): Seat {
|
seatOf(room: Room, token: string | undefined): Seat {
|
||||||
if (!token) throw new RoomError('That token opens no seat at this duel.', 403);
|
if (!token) throw new RoomError('That token opens no seat at this duel.', 403);
|
||||||
const given = Buffer.from(hashToken(token), 'hex');
|
const given = Buffer.from(hashToken(token), 'hex');
|
||||||
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
|
const seat = room.seats.find((s) => s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
|
||||||
|
if (seat?.standIn) throw new RoomError('The host gave your seat to a bot, which duels on in your place. Ask them for a transfer phrase to take it back.', 403);
|
||||||
if (!seat) throw new RoomError('That token opens no seat at this duel.', 403);
|
if (!seat) throw new RoomError('That token opens no seat at this duel.', 403);
|
||||||
return seat;
|
return seat;
|
||||||
}
|
}
|
||||||
@@ -199,6 +203,11 @@ export class Rooms {
|
|||||||
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name, text: cleanTalk(raw) });
|
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name, text: cleanTalk(raw) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The keeper of the site says a word at a table from the keeper's door, whether or not the gallery may talk: the answer to a call they could not take up. */
|
||||||
|
keeperSays(room: Room, keeper: string, raw: unknown): void {
|
||||||
|
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name: keeper, text: cleanTalk(raw), keeper: true });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A seated wizard calls the keeper of the site to the table: a seat is
|
* A seated wizard calls the keeper of the site to the table: a seat is
|
||||||
* held under the keeper's name and the call goes on the ledger. Once per
|
* held under the keeper's name and the call goes on the ledger. Once per
|
||||||
@@ -223,21 +232,38 @@ export class Rooms {
|
|||||||
const caller = this.seatOf(room, token);
|
const caller = this.seatOf(room, token);
|
||||||
if (!room.state?.over) throw new RoomError('The duel is not over yet.', 409);
|
if (!room.state?.over) throw new RoomError('The duel is not over yet.', 409);
|
||||||
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
|
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
|
||||||
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name);
|
// A wizard a bot stood in for is still one of the company; the stand-in does not follow.
|
||||||
|
const others = room.seats.filter((s) => (!s.bot || s.standIn) && s.id !== caller.id).map((s) => s.name);
|
||||||
const next = this.open(caller.name, room.size, { rematchOf: room.id, expected: others });
|
const next = this.open(caller.name, room.size, { rematchOf: room.id, expected: others });
|
||||||
for (const bot of room.seats.filter((s) => s.bot)) this.sit(next.room, bot.name, true);
|
for (const bot of room.seats.filter((s) => s.bot && !s.standIn)) this.sit(next.room, bot.name, true);
|
||||||
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
|
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
|
||||||
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
|
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Phrases waiting to be claimed, with the seat and the token they carry. */
|
/** Phrases waiting to be claimed, with the seat and the token they carry; the host's carry none, and re-seat on claim. */
|
||||||
private transfers = new Map<string, { roomId: string; seatId: WizardId; token: string; expiresAt: number }>();
|
private transfers = new Map<string, { roomId: string; seatId: WizardId; token: string | null; expiresAt: number }>();
|
||||||
private failedClaims = 0;
|
private failedClaims = 0;
|
||||||
private failWindowStart = 0;
|
private failWindowStart = 0;
|
||||||
|
|
||||||
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
|
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
|
||||||
transfer(room: Room, token: string | undefined): { phrase: string; expiresAt: number } {
|
transfer(room: Room, token: string | undefined): { phrase: string; expiresAt: number } {
|
||||||
const seat = this.seatOf(room, token);
|
const seat = this.seatOf(room, token);
|
||||||
|
return this.mint(room.id, seat.id, token!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host mints a phrase for another wizard's seat: for a player whose browser lost it, or to take it back
|
||||||
|
* from a bot standing in. Claimed, it gives the seat a new token, so the old one opens nothing after.
|
||||||
|
*/
|
||||||
|
hostPhrase(room: Room, token: string | undefined, seatId: unknown): { phrase: string; expiresAt: number } {
|
||||||
|
const host = this.seatOf(room, token);
|
||||||
|
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may make a phrase for another seat.', 403);
|
||||||
|
const seat = room.seats.find((s) => s.id === seatId && s.id !== host.id && (!s.bot || s.standIn));
|
||||||
|
if (!seat) throw new RoomError('No other wizard sits in that seat.', 404);
|
||||||
|
return this.mint(room.id, seat.id, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mint(roomId: string, seatId: WizardId, token: string | null): { phrase: string; expiresAt: number } {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
|
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
|
||||||
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
|
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
|
||||||
@@ -246,7 +272,7 @@ export class Rooms {
|
|||||||
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
|
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
|
||||||
} while (this.transfers.has(phrase));
|
} while (this.transfers.has(phrase));
|
||||||
const expiresAt = now + TRANSFER_TTL_MS;
|
const expiresAt = now + TRANSFER_TTL_MS;
|
||||||
this.transfers.set(phrase, { roomId: room.id, seatId: seat.id, token: token!, expiresAt });
|
this.transfers.set(phrase, { roomId, seatId, token, expiresAt });
|
||||||
return { phrase, expiresAt };
|
return { phrase, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +293,10 @@ export class Rooms {
|
|||||||
}
|
}
|
||||||
this.transfers.delete(phrase);
|
this.transfers.delete(phrase);
|
||||||
const room = this.get(t.roomId);
|
const room = this.get(t.roomId);
|
||||||
|
if (t.token === null) {
|
||||||
|
const { seat, token } = this.reseat(room, t.seatId);
|
||||||
|
return { roomId: room.id, seat: seat.id, token };
|
||||||
|
}
|
||||||
const seat = this.seatOf(room, t.token);
|
const seat = this.seatOf(room, t.token);
|
||||||
return { roomId: room.id, seat: seat.id, token: t.token };
|
return { roomId: room.id, seat: seat.id, token: t.token };
|
||||||
}
|
}
|
||||||
@@ -308,27 +338,49 @@ export class Rooms {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The keeper gives a wizard's seat a new token, for a player whose browser lost the old one. Whether the one
|
* A wizard's seat gets a new token, for a player whose browser lost the old one, or one taking it back from a
|
||||||
* asking holds the seat is the keeper's judgment; this carries it out. The old token opens nothing after.
|
* bot standing in. Whether the one asking holds the seat is the keeper's judgment, or the host's in the phrase
|
||||||
|
* they made; this carries it out. The old token opens nothing after.
|
||||||
*/
|
*/
|
||||||
reseat(room: Room, seatId: unknown): { seat: Seat; token: string } {
|
reseat(room: Room, seatId: unknown): { seat: Seat; token: string } {
|
||||||
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
|
const seat = room.seats.find((s) => s.id === seatId && (!s.bot || s.standIn));
|
||||||
if (!seat) throw new RoomError('No wizard sits in that seat.', 404);
|
if (!seat) throw new RoomError('No wizard sits in that seat.', 404);
|
||||||
const token = newId(18);
|
const token = newId(18);
|
||||||
this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) });
|
this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) });
|
||||||
// A phrase minted with the old token would carry a seat that no longer answers to it.
|
this.voidPhrases(room, seat.id);
|
||||||
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seat.id) this.transfers.delete(phrase);
|
|
||||||
return { seat, token };
|
return { seat, token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host gives a wizard's seat to a bot, for a player who cannot go on: the bot plays it from the gestures the
|
||||||
|
* seat owes, and the wizard's token opens nothing until a phrase from the host gives it back.
|
||||||
|
*/
|
||||||
|
standIn(room: Room, token: string | undefined, seatId: unknown): void {
|
||||||
|
const host = this.seatOf(room, token);
|
||||||
|
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may give a seat to a bot.', 403);
|
||||||
|
if (!room.state || room.state.over) throw new RoomError('Only a duel under way needs a stand-in.', 409);
|
||||||
|
if (seatId === host.id) throw new RoomError('The host keeps their own seat.', 409);
|
||||||
|
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
|
||||||
|
if (!seat) throw new RoomError('No wizard sits in that seat.', 404);
|
||||||
|
this.commit(room, { t: 'standIn', at: Date.now(), id: seat.id, by: host.id });
|
||||||
|
this.voidPhrases(room, seat.id);
|
||||||
|
// The turn may have been waiting on this seat alone.
|
||||||
|
if (waitingOn(room).length === 0) this.resolve(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A phrase minted for a seat whose token has changed hands would carry a seat that no longer answers to it. */
|
||||||
|
private voidPhrases(room: Room, seatId: WizardId): void {
|
||||||
|
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seatId) this.transfers.delete(phrase);
|
||||||
|
}
|
||||||
|
|
||||||
/** The table as the keeper sees it before re-seating: who sits where, and when each wizard last moved or spoke. */
|
/** The table as the keeper sees it before re-seating: who sits where, and when each wizard last moved or spoke. */
|
||||||
seatsForKeeper(room: Room): { id: WizardId; name: string; bot: boolean; lastActed: number | null }[] {
|
seatsForKeeper(room: Room): { id: WizardId; name: string; bot: boolean; standIn: boolean; lastActed: number | null }[] {
|
||||||
const last = new Map<WizardId, number>();
|
const last = new Map<WizardId, number>();
|
||||||
for (const line of this.store.read(room.id)) {
|
for (const line of this.store.read(room.id)) {
|
||||||
if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at);
|
if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at);
|
||||||
else if (line.t === 'turn') for (const id of Object.keys(line.inputs)) last.set(id, line.at);
|
else if (line.t === 'turn') for (const id of Object.keys(line.inputs)) last.set(id, line.at);
|
||||||
}
|
}
|
||||||
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, lastActed: s.bot ? null : (last.get(s.id) ?? null) }));
|
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot && !s.standIn, standIn: !!s.standIn, lastActed: s.bot && !s.standIn ? null : (last.get(s.id) ?? null) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The host sends a bot away before the duel begins. People leave by not coming back. */
|
/** The host sends a bot away before the duel begins. People leave by not coming back. */
|
||||||
@@ -361,8 +413,9 @@ export class Rooms {
|
|||||||
private resolve(room: Room): void {
|
private resolve(room: Room): void {
|
||||||
const state = room.state!;
|
const state = room.state!;
|
||||||
const inputs: Record<WizardId, TurnInput> = { ...room.pending };
|
const inputs: Record<WizardId, TurnInput> = { ...room.pending };
|
||||||
|
// Gestures a wizard made before a bot stood in for them stand.
|
||||||
for (const seat of room.seats) {
|
for (const seat of room.seats) {
|
||||||
if (seat.bot && needsInput(state, seat.id)) inputs[seat.id] = chooseBotTurn(state, seat.id);
|
if (seat.bot && needsInput(state, seat.id) && !(seat.id in inputs)) inputs[seat.id] = chooseBotTurn(state, seat.id);
|
||||||
}
|
}
|
||||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||||
// A time stop owed to a bot resolves at once; a human's waits for them.
|
// A time stop owed to a bot resolves at once; a human's waits for them.
|
||||||
@@ -387,7 +440,7 @@ export class Rooms {
|
|||||||
size: room.size,
|
size: room.size,
|
||||||
me: seatId,
|
me: seatId,
|
||||||
host: room.seats[0]?.id ?? seatId,
|
host: room.seats[0]?.id ?? seatId,
|
||||||
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
|
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, ...(s.standIn ? { standIn: true as const } : {}) })),
|
||||||
waitingOn: waitingOn(room),
|
waitingOn: waitingOn(room),
|
||||||
resolved: room.resolved,
|
resolved: room.resolved,
|
||||||
state: room.state ? viewFor(room.state, seatId) : null,
|
state: room.state ? viewFor(room.state, seatId) : null,
|
||||||
@@ -463,7 +516,17 @@ function apply(room: Room, line: LedgerLine): void {
|
|||||||
break;
|
break;
|
||||||
case 'reseat': {
|
case 'reseat': {
|
||||||
const seat = room.seats.find((s) => s.id === line.id);
|
const seat = room.seats.find((s) => s.id === line.id);
|
||||||
if (seat) seat.tokenHash = line.tokenHash;
|
if (!seat) break;
|
||||||
|
seat.tokenHash = line.tokenHash;
|
||||||
|
seat.bot = false;
|
||||||
|
delete seat.standIn;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'standIn': {
|
||||||
|
const seat = room.seats.find((s) => s.id === line.id);
|
||||||
|
if (!seat) break;
|
||||||
|
seat.bot = true;
|
||||||
|
seat.standIn = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'input':
|
case 'input':
|
||||||
@@ -494,7 +557,7 @@ function apply(room: Room, line: LedgerLine): void {
|
|||||||
case 'over':
|
case 'over':
|
||||||
break;
|
break;
|
||||||
case 'chat':
|
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, ...(line.name ? { name: line.name } : {}), ...(line.keeper ? { keeper: true as const } : {}) });
|
||||||
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -11,8 +11,13 @@ export type LedgerLine =
|
|||||||
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[] }
|
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[] }
|
||||||
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
|
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
|
||||||
| { t: 'seat'; id: WizardId; name: string; tokenHash?: string; token?: string; bot: boolean }
|
| { t: 'seat'; id: WizardId; name: string; tokenHash?: string; token?: string; bot: boolean }
|
||||||
/** The keeper gave a wizard's seat a new token, for a player whose browser lost the old one; the old one opens nothing after. */
|
/**
|
||||||
|
* A wizard's seat got a new token, from the keeper or a phrase the host made, for a player whose browser lost the
|
||||||
|
* old one; the old one opens nothing after. A seat a bot was standing in for is the wizard's again.
|
||||||
|
*/
|
||||||
| { t: 'reseat'; at: number; id: WizardId; tokenHash: string }
|
| { t: 'reseat'; at: number; id: WizardId; tokenHash: string }
|
||||||
|
/** The host gave a wizard's seat to a bot, which duels in it from here on; a reseat gives it back. */
|
||||||
|
| { t: 'standIn'; at: number; id: WizardId; by: WizardId }
|
||||||
/** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */
|
/** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */
|
||||||
| { t: 'input'; at: number; id: WizardId; input: TurnInput }
|
| { t: 'input'; at: number; id: WizardId; input: TurnInput }
|
||||||
/** A bot sent away by the host before the duel began; its seat id is free again. */
|
/** A bot sent away by the host before the duel began; its seat id is free again. */
|
||||||
@@ -23,7 +28,7 @@ export type LedgerLine =
|
|||||||
/** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */
|
/** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */
|
||||||
| { t: 'over'; at: number; winner: WizardId | null; reason: string }
|
| { t: 'over'; at: number; winner: WizardId | null; reason: string }
|
||||||
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
||||||
| { t: 'chat'; at: number; id: WizardId; text: string; name?: string }
|
| { t: 'chat'; at: number; id: WizardId; text: string; name?: string; keeper?: true }
|
||||||
/** The host let the gallery talk, or hushed it. */
|
/** The host let the gallery talk, or hushed it. */
|
||||||
| { t: 'galleryTalk'; at: number; on: boolean; by: WizardId }
|
| { t: 'galleryTalk'; at: number; on: boolean; by: WizardId }
|
||||||
/** A seat is held for the keeper of the site, called to the table. */
|
/** A seat is held for the keeper of the site, called to the table. */
|
||||||
|
|||||||
+94
@@ -108,6 +108,100 @@ button:disabled {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The smaller reveal used in forms and slips. Qualified with the element so it
|
||||||
|
outranks a component's scoped `.small` text rule on the same button. */
|
||||||
|
button.reveal.small {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A secondary action: an outlined button or link. */
|
||||||
|
.quiet {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--bone);
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiet:hover:not(:disabled) {
|
||||||
|
border-color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The page head on the play and join pages: title and standfirst on the left, actions on the right. */
|
||||||
|
.masthead {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem var(--gutter) 0.6rem;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead h1 {
|
||||||
|
font-size: clamp(2rem, 4vw, 2.7rem);
|
||||||
|
font-weight: 300;
|
||||||
|
font-variation-settings: 'opsz' 144;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standfirst {
|
||||||
|
max-width: 38em;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standfirst em {
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead .actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colophon {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem var(--gutter) 3rem;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colophon a {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Must match COMPACT_QUERY in duel.svelte.ts. The extra foot padding clears MobileBar. */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.masthead {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colophon {
|
||||||
|
padding-bottom: 6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A modal backdrop that centres its dialog. */
|
||||||
|
.scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* An italic prompt beside a control, e.g. "at" before a target list. */
|
/* An italic prompt beside a control, e.g. "at" before a target list. */
|
||||||
.field {
|
.field {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The about panel: the game's story, its keeping and its company, a tab
|
// A tabbed modal for the about content. Opened from the landing page's
|
||||||
// at a time rather than one long page. Opened from the hall's "about this
|
// "about this game" link or the #about hash; closes on its button, a click
|
||||||
// game" link (and #about); closes with its button, a click outside it, or
|
// on the backdrop, or Escape. Keeps the last-read tab while mounted, so
|
||||||
// Escape. The tab last read is the one it reopens on.
|
// reopening shows it.
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props();
|
let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props();
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<svelte:window onkeydown={onKey} />
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="about-scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
|
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
|
||||||
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="about-title">
|
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="about-title">
|
||||||
<header>
|
<header>
|
||||||
<h2 id="about-title">{title}</h2>
|
<h2 id="about-title">{title}</h2>
|
||||||
@@ -71,16 +71,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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 {
|
.panel {
|
||||||
width: min(100%, 44rem);
|
width: min(100%, 44rem);
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The host's announcement, shown by every game of Kestrel's Hall: the keeper
|
// The site-family announcement, shown by every game: the maintainer writes
|
||||||
// writes /var/lib/hall/announcement.json on the host (hall-announce), and each
|
// /var/lib/hall/announcement.json on the host (hall-announce), and each game's
|
||||||
// game's site serves it at /hall/announcement.json. It is checked when the page
|
// site serves it at /hall/announcement.json. It is fetched on load, every few
|
||||||
// loads, every few minutes, and whenever the page is looked at again. A reader
|
// minutes, and whenever the tab becomes visible again. Dismissal is per
|
||||||
// may dismiss one announcement; the next one still shows.
|
// announcement id, so the next one still shows.
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface Announcement {
|
interface Announcement {
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Its own colors, not the game's: the same notice reads the same in every game of the hall. */
|
/* Fixed colours rather than the game's tokens, so the notice looks the same on every site that shows it. */
|
||||||
.hall-announcement {
|
.hall-announcement {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
+170
-170
@@ -18,9 +18,9 @@
|
|||||||
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
|
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
|
||||||
const outcome = $derived(duel.state.over);
|
const outcome = $derived(duel.state.over);
|
||||||
const won = $derived(outcome?.winner === duel.me);
|
const won = $derived(outcome?.winner === duel.me);
|
||||||
/** Past three wizards the rail lists the others on a line each. */
|
/** With three or more opponents the rail shows a one-line roster instead of a status card each. */
|
||||||
const crowded = $derived(duel.others.length >= 3);
|
const crowded = $derived(duel.others.length >= 3);
|
||||||
/** The button's word for what the player is about to do. */
|
/** The seal button's label for the current state. */
|
||||||
const sealLabel = $derived(
|
const sealLabel = $derived(
|
||||||
duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Seal the turn'
|
duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Seal the turn'
|
||||||
);
|
);
|
||||||
@@ -275,260 +275,260 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
details.next summary {
|
details.next summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
details.next summary::before {
|
details.next summary::before {
|
||||||
content: '\25B8 ';
|
content: '\25B8 ';
|
||||||
color: var(--bone-faint);
|
color: var(--bone-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
details.next[open] summary::before {
|
details.next[open] summary::before {
|
||||||
content: '\25BE ';
|
content: '\25BE ';
|
||||||
}
|
}
|
||||||
|
|
||||||
.board {
|
.board {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
|
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
|
||||||
padding: 0 var(--gutter);
|
padding: 0 var(--gutter);
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play {
|
.play {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail {
|
.rail {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0.5rem;
|
width: 0.5rem;
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 0.3rem;
|
margin-right: 0.3rem;
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intent {
|
.intent {
|
||||||
margin: 0.6rem 0 0.2rem;
|
margin: 0.6rem 0 0.2rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
}
|
}
|
||||||
|
|
||||||
.move {
|
.move {
|
||||||
padding: 0.6rem 0 1.5rem;
|
padding: 0.6rem 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.move h2 {
|
.move h2 {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.threats {
|
.threats {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-bottom: 0.7rem;
|
margin-bottom: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.threats ul {
|
.threats ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0.15rem 0 0.4rem;
|
margin: 0.15rem 0 0.4rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem 0.5rem;
|
gap: 0.3rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.threat {
|
.threat {
|
||||||
background: var(--slate);
|
background: var(--slate);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.threat.soon {
|
.threat.soon {
|
||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
}
|
}
|
||||||
|
|
||||||
.threat:hover,
|
.threat:hover,
|
||||||
.threat.active {
|
.threat.active {
|
||||||
border-color: var(--frost);
|
border-color: var(--frost);
|
||||||
}
|
}
|
||||||
|
|
||||||
.threat.active {
|
.threat.active {
|
||||||
background: rgba(140, 200, 224, 0.12);
|
background: rgba(140, 200, 224, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thand {
|
.thand {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tseq {
|
.tseq {
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tseq strong {
|
.tseq strong {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prefs {
|
.prefs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem 1.5rem;
|
gap: 0.3rem 1.5rem;
|
||||||
margin-top: 0.7rem;
|
margin-top: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pref {
|
.pref {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hands {
|
.hands {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1.25rem 2rem;
|
gap: 1.25rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders {
|
.orders {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem 1.5rem;
|
gap: 0.5rem 1.5rem;
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
color: var(--blood);
|
color: var(--blood);
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.haste-note {
|
.haste-note {
|
||||||
color: var(--frost);
|
color: var(--frost);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
margin: 1rem 0 0.6rem;
|
margin: 1rem 0 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Your hands, framed apart from the ledger's columns above them. */
|
/* Box the hand pickers so they read apart from the ledger table above. */
|
||||||
.hands {
|
.hands {
|
||||||
background: var(--slate);
|
background: var(--slate);
|
||||||
border: 1px solid var(--rule-strong);
|
border: 1px solid var(--rule-strong);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.7rem 0.9rem 0.9rem;
|
padding: 0.7rem 0.9rem 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The turn about to be sealed, kept in view while the hands and orders scroll. */
|
/* Sticky at the foot so the chosen pair and the seal button stay in view while the pickers scroll. */
|
||||||
.commit {
|
.commit {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem 1rem;
|
gap: 0.4rem 1rem;
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
padding: 0.6rem 0.2rem;
|
padding: 0.6rem 0.2rem;
|
||||||
background: var(--slate-deep);
|
background: var(--slate-deep);
|
||||||
border-top: 1px solid var(--rule-strong);
|
border-top: 1px solid var(--rule-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pair {
|
.pair {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hand-pair {
|
.hand-pair {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.which {
|
.which {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--bone-faint);
|
color: var(--bone-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pair .letter {
|
.pair .letter {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
min-width: 1ch;
|
min-width: 1ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pair .letter.empty {
|
.pair .letter.empty {
|
||||||
color: var(--bone-faint);
|
color: var(--bone-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes p {
|
.notes p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes p + p {
|
.notes p + p {
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keys {
|
.keys {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verdict {
|
.verdict {
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verdict h2 {
|
.verdict h2 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--blood);
|
color: var(--blood);
|
||||||
}
|
}
|
||||||
|
|
||||||
.verdict.won h2 {
|
.verdict.won h2 {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
.verdict p {
|
.verdict p {
|
||||||
margin: 0.3rem 0 0.8rem;
|
margin: 0.3rem 0 0.8rem;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.board {
|
.board {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On phones the bar at the foot of the screen carries the pair and the button. */
|
/* Narrow screens: MobileBar carries the pair and the button instead. */
|
||||||
.commit {
|
.commit {
|
||||||
position: static;
|
position: static;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commit .pair,
|
.commit .pair,
|
||||||
.commit .reveal,
|
.commit .reveal,
|
||||||
.keys {
|
.keys {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail {
|
.rail {
|
||||||
order: 2;
|
order: 2;
|
||||||
border-top: 1px solid var(--rule-strong);
|
border-top: 1px solid var(--rule-strong);
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hands {
|
.hands {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.waiting {
|
.waiting {
|
||||||
|
|||||||
@@ -18,14 +18,14 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (location.hash === '#about') aboutOpen = true;
|
if (location.hash === '#about') aboutOpen = true;
|
||||||
});
|
});
|
||||||
/** The ledgers' tally, fetched when the about panel opens. */
|
/** Site-wide statistics, fetched the first time the about panel opens. */
|
||||||
let tally = $state<Tally | null>(null);
|
let tally = $state<Tally | null>(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
|
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
|
||||||
});
|
});
|
||||||
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
|
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
|
||||||
|
|
||||||
/** The tally as the about panel tells it: this game's own story on cards, the rest in detail. */
|
/** The tally split for the about panel: headline figures for the cards, every other count as a detail line. */
|
||||||
const told = $derived.by(() => {
|
const told = $derived.by(() => {
|
||||||
const t = tally;
|
const t = tally;
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
@@ -36,19 +36,19 @@
|
|||||||
{ n: t.byGame["duels won by a rival's surrender"] ?? 0, label: "won by a rival's surrender" },
|
{ n: t.byGame["duels won by a rival's surrender"] ?? 0, label: "won by a rival's surrender" },
|
||||||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||||||
];
|
];
|
||||||
const told = new Set<string>(['duels won with every rival slain', "duels won by a rival's surrender"]);
|
const onCards = new Set<string>(['duels won with every rival slain', "duels won by a rival's surrender"]);
|
||||||
const details = [
|
const details = [
|
||||||
{ n: t.gamesBegun, label: `duels begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
|
{ n: t.gamesBegun, label: `duels begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
|
||||||
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
|
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
|
||||||
{ n: t.turnsPlayed, label: `turns of gestures written; ${t.longestGameTurns} in the longest duel` },
|
{ n: t.turnsPlayed, label: `turns of gestures written; ${t.longestGameTurns} in the longest duel` },
|
||||||
{ n: t.gamesWithBot, label: `duels with the bot seated` },
|
{ n: t.gamesWithBot, label: `duels with the bot seated` },
|
||||||
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
||||||
...Object.entries(t.byGame).filter(([label]) => !told.has(label)).map(([label, n]) => ({ n, label }))
|
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label }))
|
||||||
];
|
];
|
||||||
const first = t.firstGameAt ? `The first duel began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
const first = t.firstGameAt ? `The first duel began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
||||||
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between reveals, so a duel left open overnight adds nothing for the night.` };
|
return { headline, details, note: `${first}The count is of names, so one player under two names is two here. Table time counts only the minutes between reveals, so a duel left open overnight adds nothing for the night.` };
|
||||||
});
|
});
|
||||||
/** The other games of Kestrel's Hall, for the about panel. */
|
/** The sibling games listed in the about panel, and the family's landing page. */
|
||||||
let family = $state<FamilyGame[]>([]);
|
let family = $state<FamilyGame[]>([]);
|
||||||
let hall = $state('');
|
let hall = $state('');
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -57,12 +57,12 @@
|
|||||||
hall = f.hall;
|
hall = f.hall;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
/** Reports this browser's seats have filed, with the keeper's replies. */
|
/** Bug reports filed from this browser's seats, with the maintainer's replies. */
|
||||||
let reports = $state<Report[]>([]);
|
let reports = $state<Report[]>([]);
|
||||||
let answers = $state<Record<string, string>>({});
|
let answers = $state<Record<string, string>>({});
|
||||||
let answerError = $state('');
|
let answerError = $state('');
|
||||||
|
|
||||||
// The desk is asked once per visit for every seat this browser holds.
|
// Fetch the reports for every held seat; re-runs when the held seats change.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const held = seats;
|
const held = seats;
|
||||||
if (!held.length) return;
|
if (!held.length) return;
|
||||||
@@ -85,13 +85,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The single-player duel saved in this browser, if one is under way. */
|
/** The offline game saved in this browser, if one is unfinished. */
|
||||||
const local = $derived(duel.inProgress ? duel : null);
|
const local = $derived(duel.inProgress ? duel : null);
|
||||||
const ready = $derived(name.trim().length > 0);
|
const ready = $derived(name.trim().length > 0);
|
||||||
|
|
||||||
// Each held seat is looked up once, so the ledger can say whose move it
|
// Each held seat is fetched once so the list can say whose move it is. A 403 or 404 marks
|
||||||
// is. A seat the server refuses outright is doubted; a second refusal in
|
// the seat doubtful, and a second refusal in a row drops it; a network failure changes nothing.
|
||||||
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
for (const held of seats) {
|
for (const held of seats) {
|
||||||
if (held.roomId in games) continue;
|
if (held.roomId in games) continue;
|
||||||
@@ -111,7 +110,7 @@
|
|||||||
let phrase = $state('');
|
let phrase = $state('');
|
||||||
let claiming = $state(false);
|
let claiming = $state(false);
|
||||||
|
|
||||||
/** Bring a seat from another device by the phrase it minted. */
|
/** Claim a seat transferred from another device, by its transfer phrase. */
|
||||||
async function claim(e: SubmitEvent) {
|
async function claim(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
claiming = true;
|
claiming = true;
|
||||||
@@ -167,7 +166,7 @@
|
|||||||
if (c) void goto(`/join/${c}`);
|
if (c) void goto(`/join/${c}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A duel in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
|
/** Forgetting an unfinished game asks first: this browser's token may be the only copy of the seat. */
|
||||||
let forgetting = $state<string | null>(null);
|
let forgetting = $state<string | null>(null);
|
||||||
function forget(roomId: string) {
|
function forget(roomId: string) {
|
||||||
if (!games[roomId]?.state?.over && forgetting !== roomId) {
|
if (!games[roomId]?.state?.over && forgetting !== roomId) {
|
||||||
@@ -183,7 +182,7 @@
|
|||||||
const g = games[held.roomId];
|
const g = games[held.roomId];
|
||||||
if (g === undefined) return 'looking';
|
if (g === undefined) return 'looking';
|
||||||
if (g === null) return 'unreachable';
|
if (g === null) return 'unreachable';
|
||||||
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.bot ? `${s.name} (the bot)` : s.name));
|
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a bot stands in)` : s.bot ? `${s.name} (the bot)` : s.name));
|
||||||
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
|
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
|
||||||
if (g.state.over) {
|
if (g.state.over) {
|
||||||
if (g.state.over.winner === held.seat) return 'you won';
|
if (g.state.over.winner === held.seat) return 'you won';
|
||||||
@@ -195,7 +194,7 @@
|
|||||||
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
|
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lines of table talk said since this browser last looked in. */
|
/** Chat lines posted since this browser last opened the room. */
|
||||||
function unread(held: { roomId: string }): number {
|
function unread(held: { roomId: string }): number {
|
||||||
const g = games[held.roomId];
|
const g = games[held.roomId];
|
||||||
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
|
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
|
||||||
@@ -206,7 +205,7 @@
|
|||||||
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A few turns of a duel, as the ledger writes them. */
|
/** Fixed sample rows for the illustration table. */
|
||||||
const sample = [
|
const sample = [
|
||||||
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
|
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
|
||||||
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
|
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
|
||||||
@@ -220,7 +219,7 @@
|
|||||||
<h1>Waving Hands</h1>
|
<h1>Waving Hands</h1>
|
||||||
<p class="pitch">Wizards duel with their hands, and the right run of gestures is a spell.</p>
|
<p class="pitch">Wizards duel with their hands, and the right run of gestures is a spell.</p>
|
||||||
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture for each hand, and all are shown together. A Fireball is F-S-S-D-D on one hand, and a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</p>
|
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture for each hand, and all are shown together. A Fireball is F-S-S-D-D on one hand, and a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</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></p>
|
<p class="links" id="about"><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></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lid-play">
|
<div class="lid-play">
|
||||||
@@ -332,7 +331,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
|
<!-- The about panel's tabs. Their copy is this game's own, in its in-world voice by design. -->
|
||||||
{#snippet aboutGame()}
|
{#snippet aboutGame()}
|
||||||
<div class="about-body">
|
<div class="about-body">
|
||||||
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
|
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
|
||||||
@@ -342,7 +341,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet aboutTally()}
|
{#snippet aboutTally()}
|
||||||
{#if told}
|
{#if told}
|
||||||
<TallyBoard title="How much love the duel is getting" headline={told.headline} details={told.details} note={told.note} />
|
<TallyBoard title="What the ledgers add up to" headline={told.headline} details={told.details} note={told.note} />
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted">Counting…</p>
|
<p class="muted">Counting…</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -358,7 +357,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<p class="hosting">
|
<p class="hosting">
|
||||||
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
|
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
|
||||||
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it.
|
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.
|
||||||
</p>
|
</p>
|
||||||
<p class="made">Waving Hands is Richard Bartle's, 1977.</p>
|
<p class="made">Waving Hands is Richard Bartle's, 1977.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,18 +499,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiet {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--rule-strong);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.45rem 0.9rem;
|
|
||||||
color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet:hover:not(:disabled) {
|
|
||||||
border-color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
color: var(--blood);
|
color: var(--blood);
|
||||||
}
|
}
|
||||||
@@ -576,7 +563,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the games ledger */
|
/* The list of held seats, and the reports under it. */
|
||||||
.ledger {
|
.ledger {
|
||||||
grid-area: ledger;
|
grid-area: ledger;
|
||||||
border-top: 1px solid var(--rule-strong);
|
border-top: 1px solid var(--rule-strong);
|
||||||
@@ -686,7 +673,7 @@
|
|||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The question takes a line of its own under the duel on a narrow screen. */
|
/* The confirmation wraps onto its own line when the row is narrow. */
|
||||||
.ledger-row.asking {
|
.ledger-row.asking {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
cells: Record<WizardId, Placed[]>;
|
cells: Record<WizardId, Placed[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** You first, then the others in table order; the gallery sees the table as laid. */
|
/** Column order: the viewer first, then the others in seat order; a spectator gets no column of their own. */
|
||||||
const columns = $derived<WizardId[]>(duel.spectating ? duel.others : [duel.me, ...duel.others]);
|
const columns = $derived<WizardId[]>(duel.spectating ? duel.others : [duel.me, ...duel.others]);
|
||||||
|
|
||||||
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
|
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
|
||||||
@@ -68,9 +68,42 @@
|
|||||||
return !!h && h.who === who && h.hand === hand && index >= h.from && index <= h.to;
|
return !!h && h.who === who && h.hand === hand && index >= h.from && index <= h.to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The stylesheet caps the box at a share of the viewport, which is rarely a whole number of
|
||||||
|
// rows, so after each turn the cap is trimmed to the header plus as many of the newest rows
|
||||||
|
// as fit under it, and the box is scrolled to the newest. Older rows then sit wholly under
|
||||||
|
// the sticky header rather than showing a cut-off sliver. The second header row sticks at
|
||||||
|
// the measured height of the first, so the two meet exactly whatever the font or wrapping.
|
||||||
|
// Row heights change with the viewport and the web font, so this is redone on resize and
|
||||||
|
// once fonts are in.
|
||||||
|
function fitRows() {
|
||||||
|
if (!scroller) return;
|
||||||
|
scroller.style.maxHeight = '';
|
||||||
|
const names = scroller.querySelector('thead tr');
|
||||||
|
if (names) scroller.style.setProperty('--names-row', `${names.getBoundingClientRect().height}px`);
|
||||||
|
const cap = parseFloat(getComputedStyle(scroller).maxHeight);
|
||||||
|
if (Number.isFinite(cap)) {
|
||||||
|
const head = scroller.querySelector('thead')?.getBoundingClientRect().height ?? 0;
|
||||||
|
const chrome = scroller.offsetHeight - scroller.clientHeight;
|
||||||
|
let inner = head;
|
||||||
|
for (const row of [...scroller.querySelectorAll('tbody tr')].reverse()) {
|
||||||
|
const h = row.getBoundingClientRect().height;
|
||||||
|
if (inner > head && inner + h + chrome > cap) break;
|
||||||
|
inner += h;
|
||||||
|
}
|
||||||
|
scroller.style.maxHeight = `${Math.ceil(inner + chrome)}px`;
|
||||||
|
}
|
||||||
|
scroller.scrollTop = scroller.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void turns;
|
void turns;
|
||||||
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
fitRows();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
window.addEventListener('resize', fitRows);
|
||||||
|
void document.fonts?.ready.then(fitRows);
|
||||||
|
return () => window.removeEventListener('resize', fitRows);
|
||||||
});
|
});
|
||||||
|
|
||||||
const crowded = $derived(duel.others.length >= 3);
|
const crowded = $derived(duel.others.length >= 3);
|
||||||
@@ -221,7 +254,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scroller {
|
.scroller {
|
||||||
max-height: clamp(170px, 30vh, 400px);
|
max-height: clamp(170px, 38vh, 420px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-top: 1px solid var(--rule-strong);
|
border-top: 1px solid var(--rule-strong);
|
||||||
border-bottom: 1px solid var(--rule-strong);
|
border-bottom: 1px solid var(--rule-strong);
|
||||||
@@ -259,16 +292,13 @@
|
|||||||
left: 7.4rem;
|
left: 7.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
thead th.pin-left,
|
|
||||||
thead th.pin-right {
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead th.gone {
|
thead th.gone {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The header must stack above every body cell, including the pinned ones (z-index 1 and 2 below),
|
||||||
|
or a row scrolled under it shows its turn number through the header while its letters are hidden. */
|
||||||
thead th {
|
thead th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -277,7 +307,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.5rem 0 0.15rem;
|
padding: 0.5rem 0 0.15rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
z-index: 1;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
thead th.you,
|
thead th.you,
|
||||||
@@ -287,7 +317,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
thead tr.hands th {
|
thead tr.hands th {
|
||||||
top: 2.2rem;
|
/* The measured height of the row above (set by fitRows), so the two sticky rows meet with no gap. */
|
||||||
|
top: var(--names-row, 2.2rem);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
@@ -312,6 +343,13 @@
|
|||||||
padding-top: 0.6rem;
|
padding-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The header's own pinned cells sit above the rest of the header, which slides under them sideways. */
|
||||||
|
thead th.turn,
|
||||||
|
thead th.pin-left,
|
||||||
|
thead th.pin-right {
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
.rownote {
|
.rownote {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
|
|||||||
@@ -88,16 +88,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.scrim {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slip {
|
.slip {
|
||||||
width: min(100%, 30rem);
|
width: min(100%, 30rem);
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
@@ -168,11 +158,6 @@
|
|||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reveal.small {
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
background: none;
|
background: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// A crowded table's rail: every other wizard on one line, with the one
|
// The rail for three or more opponents: each on one line, with the focused
|
||||||
// you are looking at opened out. Your own panel stays in full above.
|
// one expanded. The player's own status card stays above in full.
|
||||||
import WizardStatus from './WizardStatus.svelte';
|
import WizardStatus from './WizardStatus.svelte';
|
||||||
import type { Duel } from '$lib/game/duel.svelte';
|
import type { Duel } from '$lib/game/duel.svelte';
|
||||||
import { seatColor } from '$lib/game/colors';
|
import { seatColor } from '$lib/game/colors';
|
||||||
@@ -20,8 +20,9 @@
|
|||||||
if (!duel.remote?.started || duel.state.over) return '';
|
if (!duel.remote?.started || duel.state.over) return '';
|
||||||
const w = duel.state.wizards[id];
|
const w = duel.state.wizards[id];
|
||||||
if (!w.alive || w.surrendered) return 'out';
|
if (!w.alive || w.surrendered) return 'out';
|
||||||
const bot = duel.remote.seats.find((s) => s.id === id)?.bot;
|
const seat = duel.remote.seats.find((s) => s.id === id);
|
||||||
if (bot) return '';
|
if (seat?.standIn) return 'a bot duels for them';
|
||||||
|
if (seat?.bot) return '';
|
||||||
return waitingOn.includes(id) ? 'writing' : 'moved';
|
return waitingOn.includes(id) ? 'writing' : 'moved';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Host-only controls over the other seats during a game: a transfer phrase
|
||||||
|
// for a player whose browser lost its seat, and handing a seat to a bot until
|
||||||
|
// a phrase claims it back.
|
||||||
|
import type { Duel } from '$lib/game/duel.svelte';
|
||||||
|
import type { WizardId } from '$lib/game/state';
|
||||||
|
|
||||||
|
let { duel, open = $bindable(false) }: { duel: Duel; open?: boolean } = $props();
|
||||||
|
|
||||||
|
const people = $derived((duel.remote?.seats ?? []).filter((s) => s.id !== duel.me && (!s.bot || s.standIn)));
|
||||||
|
let asked = $state<WizardId | null>(null);
|
||||||
|
let phrase = $state<{ name: string; phrase: string; expiresAt: number } | null>(null);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
asked = null;
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makePhrase(seat: WizardId, name: string) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const made = await duel.hostPhrase(seat);
|
||||||
|
phrase = { name, ...made };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (phrase?.phrase === made.phrase) phrase = null;
|
||||||
|
}, Math.max(0, made.expiresAt - Date.now()));
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'The phrase could not be made.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function standIn(seat: WizardId) {
|
||||||
|
asked = null;
|
||||||
|
if (!(await duel.standIn(seat))) error = duel.remote?.error ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function phraseTitle(name: string, standIn: boolean): string {
|
||||||
|
const why = standIn ? `Brings ${name} back from the bot.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`;
|
||||||
|
return `${why} You get four words to pass to them. Within ten minutes, they enter them in the hall, or in the claim box while watching this duel, and sit back down as ${name}. The phrase works once, and the browser they used before stops opening the seat.`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
|
||||||
|
<div class="slip" role="dialog" aria-labelledby="seats-heading">
|
||||||
|
<h2 id="seats-heading">The other seats</h2>
|
||||||
|
<p class="muted small">As the host, you can bring a wizard back to a seat their browser lost, or give the seat of one who cannot go on to a bot, which duels in it from this turn.</p>
|
||||||
|
{#if people.length === 0}
|
||||||
|
<p class="muted">Every other wizard at this table is a bot.</p>
|
||||||
|
{/if}
|
||||||
|
<ul>
|
||||||
|
{#each people as s (s.id)}
|
||||||
|
<li>
|
||||||
|
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a bot duels for them</span>{/if}</span>
|
||||||
|
<span class="ways">
|
||||||
|
<button type="button" class="quiet" title={phraseTitle(s.name, !!s.standIn)} onclick={() => makePhrase(s.id, s.name)}>{s.standIn ? 'Transfer phrase to take it back' : 'Make a transfer phrase'}</button>
|
||||||
|
{#if !s.standIn && !duel.state.over}
|
||||||
|
{#if asked === s.id}
|
||||||
|
<button type="button" class="reveal small" onclick={() => standIn(s.id)}>Yes, a bot plays for {s.name}</button>
|
||||||
|
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="quiet" title="A bot duels in {s.name}'s seat from this turn, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a bot</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if phrase}
|
||||||
|
<p class="made">Give {phrase.name} this phrase to claim within ten minutes, in the hall or while watching this duel: <b class="phrase">{phrase.phrase}</b>. It works once, and their old link stops opening the seat.</p>
|
||||||
|
{/if}
|
||||||
|
{#if error}<p class="warning">{error}</p>{/if}
|
||||||
|
<div class="actions"><button type="button" class="link" onclick={close}>close</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* A definite row, so the slip's max-height resolves and it scrolls instead of overflowing the viewport. */
|
||||||
|
.scrim {
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slip {
|
||||||
|
width: min(100%, 34rem);
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--slate);
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem 1.2rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.8rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ways {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.made {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase {
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,12 +7,12 @@
|
|||||||
const remote = $derived(duel.remote);
|
const remote = $derived(duel.remote);
|
||||||
const lines = $derived(remote?.chat ?? []);
|
const lines = $derived(remote?.chat ?? []);
|
||||||
let draft = $state('');
|
let draft = $state('');
|
||||||
/** The gallery signs its lines; the name is the one the hall remembers. */
|
/** Spectators sign their lines; the default is the name saved in local storage. */
|
||||||
let galleryName = $state(playerName());
|
let galleryName = $state(playerName());
|
||||||
let sending = $state(false);
|
let sending = $state(false);
|
||||||
let list = $state<HTMLElement | null>(null);
|
let list = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
// New talk scrolls into view, as at any table.
|
// Keep the list scrolled to the newest line.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void lines.length;
|
void lines.length;
|
||||||
void tick().then(() => {
|
void tick().then(() => {
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<li class="muted">Nobody has said a word.</li>
|
<li class="muted">Nobody has said a word.</li>
|
||||||
{/if}
|
{/if}
|
||||||
{#each lines as line, i (line.at + ':' + i)}
|
{#each lines as line, i (line.at + ':' + i)}
|
||||||
<li class:you={!line.name && line.id === duel.me}><em>{line.name ? `${line.name} (gallery)` : line.id === duel.me ? 'You' : duel.speaker(line.id)}</em> {line.text}</li>
|
<li class:you={!line.name && line.id === duel.me}><em>{line.keeper ? `${line.name}, the keeper` : line.name ? `${line.name} (gallery)` : line.id === duel.me ? 'You' : duel.speaker(line.id)}</em> {line.text}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{#if remote.spectating && remote.galleryTalk}
|
{#if remote.spectating && remote.galleryTalk}
|
||||||
@@ -128,14 +128,6 @@
|
|||||||
flex: 0 1 8rem;
|
flex: 0 1 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiet {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--rule-strong);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.3rem 0.8rem;
|
|
||||||
color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet:disabled {
|
.quiet:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
/** One count told: the number, and what it counts. */
|
/** One statistic: the figure and its label. */
|
||||||
export interface TallyLine {
|
export interface TallyLine {
|
||||||
n: number | string;
|
n: number | string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// What the ledgers add up to: a few counts that are the game's own story,
|
// A statistics panel: a few headline counts on large cards, the rest in a
|
||||||
// large, on cards; then the rest in a column of figures; then the fine
|
// two-column list, then an optional note. The caller supplies the counts
|
||||||
// print. Each game chooses its counts and words.
|
// and their labels.
|
||||||
let { headline, details, note, title = 'How much love the table is getting' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
|
let { headline, details, note, title = 'What the ledgers add up to' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
|
||||||
$props();
|
$props();
|
||||||
|
|
||||||
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
|
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
|
||||||
|
|||||||
+4
-5
@@ -1,7 +1,6 @@
|
|||||||
// The other games of Kestrel's Hall, from the family list the kit keeps
|
// The sibling games, from the family list the kit ships (family.json, one copy
|
||||||
// (family.json, one copy shipped with every game), so a player who liked one
|
// per game), so a player can find the next game and the family's landing page.
|
||||||
// table can find the next, and the hall's front door. This game is left out
|
// This game is left out of its own list.
|
||||||
// of its own list.
|
|
||||||
|
|
||||||
export interface FamilyGame {
|
export interface FamilyGame {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -12,7 +11,7 @@ export interface FamilyGame {
|
|||||||
origin: string;
|
origin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The family list: the hall's front door and every game but this one. */
|
/** The family list: the landing page's URL and every game but this one. */
|
||||||
export interface Family {
|
export interface Family {
|
||||||
hall: string;
|
hall: string;
|
||||||
games: FamilyGame[];
|
games: FamilyGame[];
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function heldSeats(): Record<string, HeldSeat> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A seat the keeper sent in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of every log. */
|
/** A seat carried in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of server logs. */
|
||||||
export function seatInLink(hash: string): HeldSeat | null {
|
export function seatInLink(hash: string): HeldSeat | null {
|
||||||
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
|
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
|
||||||
return m ? { seat: m[1], token: m[2] } : null;
|
return m ? { seat: m[1], token: m[2] } : null;
|
||||||
@@ -122,8 +122,7 @@ async function call<T>(method: string, path: string, body?: unknown): Promise<T>
|
|||||||
|
|
||||||
type Seated = { seat: WizardId; token: string; view: RoomView };
|
type Seated = { seat: WizardId; token: string; view: RoomView };
|
||||||
|
|
||||||
/** A report to the keeper, with the exchange under it. */
|
/** Site-wide statistics. */
|
||||||
/** What the ledgers add up to. */
|
|
||||||
export interface Tally {
|
export interface Tally {
|
||||||
tablesOpened: number;
|
tablesOpened: number;
|
||||||
gamesBegun: number;
|
gamesBegun: number;
|
||||||
@@ -138,6 +137,7 @@ export interface Tally {
|
|||||||
byGame: Record<string, number>;
|
byGame: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A bug report, with the exchange of replies under it. */
|
||||||
export interface Report {
|
export interface Report {
|
||||||
id: string;
|
id: string;
|
||||||
at: string;
|
at: string;
|
||||||
@@ -169,6 +169,9 @@ export const api = {
|
|||||||
rematch: (roomId: string, token: string) =>
|
rematch: (roomId: string, token: string) =>
|
||||||
call<{ roomId: string; seat: WizardId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
|
call<{ roomId: string; seat: WizardId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
|
||||||
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
|
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
|
||||||
|
/** The host's phrase for another wizard's seat; claimed, it re-seats them. */
|
||||||
|
hostPhrase: (roomId: string, token: string, seat: WizardId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }),
|
||||||
|
standIn: (roomId: string, token: string, seat: WizardId) => call<RoomView>('POST', `/api/rooms/${roomId}/standin`, { token, seat }),
|
||||||
claim: (phrase: string) => call<{ roomId: string; seat: WizardId; token: string }>('POST', '/api/transfer', { phrase }),
|
claim: (phrase: string) => call<{ roomId: string; seat: WizardId; token: string }>('POST', '/api/transfer', { phrase }),
|
||||||
report: (roomId: string, token: string, happened: string, expected: string) =>
|
report: (roomId: string, token: string, happened: string, expected: string) =>
|
||||||
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
|
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
|
||||||
@@ -181,7 +184,7 @@ export const api = {
|
|||||||
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
|
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */
|
/** A socket that only ever signals "fetch the view again"; reconnects on its own. An empty token spectates. */
|
||||||
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
|
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
|
||||||
let socket: WebSocket | null = null;
|
let socket: WebSocket | null = null;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { chooseBotTurn } from './bot';
|
import { chooseBotTurn } from './bot';
|
||||||
import { api, rememberSeat, watchRoom, type HeldSeat } from './client';
|
import { api, rememberSeat, ServerError, watchRoom, type HeldSeat } from './client';
|
||||||
import { markTalkSeen } from './talk';
|
import { markTalkSeen } from './talk';
|
||||||
import {
|
import {
|
||||||
cellsOverlap,
|
cellsOverlap,
|
||||||
@@ -219,6 +219,8 @@ export interface Remote extends HeldSeat {
|
|||||||
started: boolean;
|
started: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
/** Set when the server stopped accepting this seat's token while the page was open, with the reason; the page then spectates. */
|
||||||
|
lost: string;
|
||||||
chat: ChatLine[];
|
chat: ChatLine[];
|
||||||
/** How many watch from the Peanut Gallery. */
|
/** How many watch from the Peanut Gallery. */
|
||||||
audience: number;
|
audience: number;
|
||||||
@@ -338,7 +340,7 @@ export class Duel {
|
|||||||
|
|
||||||
/** Watching from the gallery, with no seat at the table. */
|
/** Watching from the gallery, with no seat at the table. */
|
||||||
spectating = $derived(this.remote?.spectating ?? false);
|
spectating = $derived(this.remote?.spectating ?? false);
|
||||||
/** The gallery has no wizard of its own; it borrows the first seat for the fields that need one, and shows none of them. */
|
/** A spectator has no wizard of their own; borrow the first seat for the fields that need one (none of them are shown). */
|
||||||
you = $derived(this.state.wizards[this.me] ?? this.state.wizards[this.state.seats[0]]);
|
you = $derived(this.state.wizards[this.me] ?? this.state.wizards[this.state.seats[0]]);
|
||||||
/** The opponent spells go at by default: the first still standing. */
|
/** The opponent spells go at by default: the first still standing. */
|
||||||
foeId = $derived(enemyOf(this.state, this.me));
|
foeId = $derived(enemyOf(this.state, this.me));
|
||||||
@@ -848,12 +850,12 @@ export class Duel {
|
|||||||
return this.remoteAct((r) => api.setGalleryTalk(r.roomId, r.token, on), 'The gallery could not be told.');
|
return this.remoteAct((r) => api.setGalleryTalk(r.roomId, r.token, on), 'The gallery could not be told.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Call the keeper of the site to a held seat; their phone rings. */
|
/** Invite the site maintainer to the seat held for them; the server notifies them. */
|
||||||
callKeeper(): Promise<boolean> {
|
callKeeper(): Promise<boolean> {
|
||||||
return this.remoteAct((r) => api.challenge(r.roomId, r.token), 'The keeper could not be called.');
|
return this.remoteAct((r) => api.challenge(r.roomId, r.token), 'The keeper could not be called.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A finished table opens another with the same company; the caller is seated as its host. */
|
/** Open a rematch room for the same players; the caller becomes its host. */
|
||||||
async rematch(): Promise<string> {
|
async rematch(): Promise<string> {
|
||||||
const r = this.remote!;
|
const r = this.remote!;
|
||||||
const next = await api.rematch(r.roomId, r.token);
|
const next = await api.rematch(r.roomId, r.token);
|
||||||
@@ -867,6 +869,26 @@ export class Duel {
|
|||||||
return api.transfer(r.roomId, r.token);
|
return api.transfer(r.roomId, r.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Host only: a phrase for another wizard's seat, which gives it a new token when claimed. */
|
||||||
|
hostPhrase(seat: WizardId): Promise<{ phrase: string; expiresAt: number }> {
|
||||||
|
const r = this.remote!;
|
||||||
|
return api.hostPhrase(r.roomId, r.token, seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host only: a bot duels in this wizard's seat for the rest of the duel, until a phrase gives it back. */
|
||||||
|
async standIn(seat: WizardId): Promise<boolean> {
|
||||||
|
const r = this.remote;
|
||||||
|
if (!r) return false;
|
||||||
|
r.error = '';
|
||||||
|
try {
|
||||||
|
this.apply(await api.standIn(r.roomId, r.token, seat));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
r.error = e instanceof Error ? e.message : 'The seat could not be given to a bot.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Say something to the whole table. Resolves false when the words did not arrive. */
|
/** Say something to the whole table. Resolves false when the words did not arrive. */
|
||||||
async say(text: string): Promise<boolean> {
|
async say(text: string): Promise<boolean> {
|
||||||
const r = this.remote;
|
const r = this.remote;
|
||||||
@@ -913,12 +935,15 @@ export class Duel {
|
|||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
fetchView()
|
fetchView()
|
||||||
.then((v) => this.apply(v))
|
.then((v) => this.apply(v))
|
||||||
.catch((e: Error) => (r.error = e.message));
|
.catch((e: Error) => {
|
||||||
|
if (e instanceof ServerError && e.status === 403 && !r.spectating) r.lost = e.message;
|
||||||
|
else r.error = e.message;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
|
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** You opened this table, so the duel begins when you say. */
|
/** The viewer holds the host seat, so may begin the game. */
|
||||||
isHost = $derived(!!this.remote && this.remote.host === this.remote.seat);
|
isHost = $derived(!!this.remote && this.remote.host === this.remote.seat);
|
||||||
|
|
||||||
/** Begin the duel with whoever is seated. */
|
/** Begin the duel with whoever is seated. */
|
||||||
@@ -1001,7 +1026,7 @@ export class Duel {
|
|||||||
this.resetDrafts();
|
this.resetDrafts();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a table on the server, seated as its host; it takes up to a dozen and begins when the host says. */
|
/** Create a room on the server with the caller as host; it takes up to `size` seats and starts when the host says. */
|
||||||
static async createRoom(name: string, size = 12): Promise<Remote> {
|
static async createRoom(name: string, size = 12): Promise<Remote> {
|
||||||
const seated = await api.create(name, size);
|
const seated = await api.create(name, size);
|
||||||
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
||||||
@@ -1027,7 +1052,7 @@ export class Duel {
|
|||||||
return remoteFrom(await api.view(roomId, held.token), held.token);
|
return remoteFrom(await api.view(roomId, held.token), held.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Take a place in the Peanut Gallery: watch a table with no seat and no voice. */
|
/** Open a room as a spectator: no seat and no token. */
|
||||||
static async watchRoom(roomId: string): Promise<Remote> {
|
static async watchRoom(roomId: string): Promise<Remote> {
|
||||||
return remoteFrom(await api.watch(roomId), '');
|
return remoteFrom(await api.watch(roomId), '');
|
||||||
}
|
}
|
||||||
@@ -1045,6 +1070,7 @@ function remoteFrom(view: RoomView, token: string): Remote {
|
|||||||
started: view.state !== null,
|
started: view.state !== null,
|
||||||
connected: false,
|
connected: false,
|
||||||
error: '',
|
error: '',
|
||||||
|
lost: '',
|
||||||
chat: view.chat,
|
chat: view.chat,
|
||||||
audience: view.audience,
|
audience: view.audience,
|
||||||
spectating: view.me === SPECTATOR,
|
spectating: view.me === SPECTATOR,
|
||||||
|
|||||||
+22
-22
@@ -312,7 +312,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
record(w, l2, r2, 'haste');
|
record(w, l2, r2, 'haste');
|
||||||
entries[id].push(w.history.length - 1);
|
entries[id].push(w.history.length - 1);
|
||||||
}
|
}
|
||||||
if (w.surrendered) log(`${w.name} lowers both hands and shows empty palms: a surrender.`, 'death');
|
if (w.surrendered) log(`${w.name} lowers both hands and shows empty palms in surrender.`, 'death');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 2. Which completed spells are actually loosed ----
|
// ---- 2. Which completed spells are actually loosed ----
|
||||||
@@ -336,7 +336,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
options.find((c) => choice.seqIndex === undefined || c.seqIndex === choice.seqIndex) ?? options[0];
|
options.find((c) => choice.seqIndex === undefined || c.seqIndex === choice.seqIndex) ?? options[0];
|
||||||
if (!completion) continue;
|
if (!completion) continue;
|
||||||
if (completion.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) {
|
if (completion.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) {
|
||||||
log(`${w.name}'s quick lightning bolt sputters: it has already been used this duel.`, 'fizzle');
|
log(`${w.name}'s quick lightning bolt sputters and dies, spent earlier in this duel.`, 'fizzle');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cells = usedCells(completion, choice.hand, index + 1);
|
const cells = usedCells(completion, choice.hand, index + 1);
|
||||||
@@ -404,7 +404,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const countered = new Set<TargetId>();
|
const countered = new Set<TargetId>();
|
||||||
const dispels = live().filter((c) => c.spell.id === 'dispel_magic');
|
const dispels = live().filter((c) => c.spell.id === 'dispel_magic');
|
||||||
if (dispels.length > 0) {
|
if (dispels.length > 0) {
|
||||||
log('A wave of stillness rolls across the field. Dispel magic: every other spell fails, every enchantment ends, and every creature will fade after this turn.', 'spell');
|
log('A wave of stillness rolls across the field as the magic is dispelled. Every other spell fails, every enchantment ends, and every creature will fade after this turn.', 'spell');
|
||||||
for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled';
|
for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled';
|
||||||
for (const d of dispels) shielded.set(d.target, 'dispel magic');
|
for (const d of dispels) shielded.set(d.target, 'dispel magic');
|
||||||
for (const id of seats) clearEnchantments(W[id]);
|
for (const id of seats) clearEnchantments(W[id]);
|
||||||
@@ -476,7 +476,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
if (chosen) {
|
if (chosen) {
|
||||||
chosen.permanent = true;
|
chosen.permanent = true;
|
||||||
extendedNow.add(id);
|
extendedNow.add(id);
|
||||||
log(`${w.name}'s ${chosen.spell.name} sets hard, like wax cooling: it will not fade.`, 'spell');
|
log(`${w.name}'s ${chosen.spell.name} sets hard, like wax cooling, and will not fade.`, 'spell');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -545,7 +545,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
};
|
};
|
||||||
if (fireStorm && iceStorm) {
|
if (fireStorm && iceStorm) {
|
||||||
fireStorm = iceStorm = false;
|
fireStorm = iceStorm = false;
|
||||||
log('Fire storm meets ice storm: a great hiss of steam, and the two storms cancel. Nothing is harmed.', 'fizzle');
|
log('Fire storm meets ice storm in a great hiss of steam, and the two cancel. Nothing is harmed.', 'fizzle');
|
||||||
}
|
}
|
||||||
if (fireStorm && elementals('ice_elemental').length > 0) {
|
if (fireStorm && elementals('ice_elemental').length > 0) {
|
||||||
fireStorm = false;
|
fireStorm = false;
|
||||||
@@ -621,14 +621,14 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.pending.amnesia = true;
|
wiz.pending.amnesia = true;
|
||||||
if (c.permanent) wiz.permanent.amnesia = true;
|
if (c.permanent) wiz.permanent.amnesia = true;
|
||||||
log(`A grey haze drifts behind ${wiz.name}'s eyes: next turn the hands will repeat what they just did${c.permanent ? ', and every turn after' : ''}.`);
|
log(`A grey haze drifts behind ${wiz.name}'s eyes. Next turn the hands will repeat what they just did${c.permanent ? ', and every turn after' : ''}.`);
|
||||||
} else if (mon) mon.pending.forcedTarget = orders.get(mon.id);
|
} else if (mon) mon.pending.forcedTarget = orders.get(mon.id);
|
||||||
break;
|
break;
|
||||||
case 'confusion':
|
case 'confusion':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.pending.confusion = true;
|
wiz.pending.confusion = true;
|
||||||
if (c.permanent) wiz.permanent.confusion = true;
|
if (c.permanent) wiz.permanent.confusion = true;
|
||||||
log(`The field tilts and swims around ${wiz.name}: next turn one hand will go astray${c.permanent ? ', and every turn after' : ''}.`);
|
log(`The field tilts and swims around ${wiz.name}. One hand will go astray next turn${c.permanent ? ', and every turn after' : ''}.`);
|
||||||
} else if (mon) {
|
} else if (mon) {
|
||||||
const candidates = [...seats.filter((id) => W[id].alive), ...state.monsters.filter((o) => o.id !== mon.id).map((o) => o.id)];
|
const candidates = [...seats.filter((id) => W[id].alive), ...state.monsters.filter((o) => o.id !== mon.id).map((o) => o.id)];
|
||||||
const t = pick(candidates as TargetId[]);
|
const t = pick(candidates as TargetId[]);
|
||||||
@@ -641,7 +641,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const hand = c.choice.chosenHand ?? pick(HANDS);
|
const hand = c.choice.chosenHand ?? pick(HANDS);
|
||||||
wiz.pending.charmed = { hand, by: c.caster };
|
wiz.pending.charmed = { hand, by: c.caster };
|
||||||
if (c.permanent) wiz.permanent.charmed = { hand, by: c.caster };
|
if (c.permanent) wiz.permanent.charmed = { hand, by: c.caster };
|
||||||
log(`A warm, easy trust settles on ${wiz.name}: the ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`);
|
log(`A warm, easy trust comes over ${wiz.name}, whose ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`);
|
||||||
} else {
|
} else {
|
||||||
c.nullified = 'monsters cannot be charmed as people';
|
c.nullified = 'monsters cannot be charmed as people';
|
||||||
log(`The charm slides off ${target}, finding no mind to hold.`, 'fizzle');
|
log(`The charm slides off ${target}, finding no mind to hold.`, 'fizzle');
|
||||||
@@ -651,7 +651,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
if (mon && !isElemental(mon.kind)) {
|
if (mon && !isElemental(mon.kind)) {
|
||||||
mon.owner = c.caster;
|
mon.owner = c.caster;
|
||||||
orders.set(mon.id, input(c.caster).monsterOrders?.[mon.id] ?? enemyOf(state, c.caster));
|
orders.set(mon.id, input(c.caster).monsterOrders?.[mon.id] ?? enemyOf(state, c.caster));
|
||||||
log(`${mon.name} turns, blinks, and pads over to ${W[c.caster].name}'s side: it serves a new master.`);
|
log(`${mon.name} turns, blinks, and pads over to ${W[c.caster].name}'s side to serve a new master.`);
|
||||||
} else {
|
} else {
|
||||||
c.nullified = 'not a charmable monster';
|
c.nullified = 'not a charmable monster';
|
||||||
log(`The charm slides off ${target} and takes no hold.`, 'fizzle');
|
log(`The charm slides off ${target} and takes no hold.`, 'fizzle');
|
||||||
@@ -662,10 +662,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const hand = wiz.constraints.paralysedHand ?? c.choice.chosenHand ?? 'left';
|
const hand = wiz.constraints.paralysedHand ?? c.choice.chosenHand ?? 'left';
|
||||||
wiz.pending.paralysedHand = hand;
|
wiz.pending.paralysedHand = hand;
|
||||||
if (c.permanent) wiz.permanent.paralysedHand = hand;
|
if (c.permanent) wiz.permanent.paralysedHand = hand;
|
||||||
log(`A cold stiffness creeps into ${wiz.name}'s ${hand} hand: it will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`);
|
log(`A cold stiffness creeps into ${wiz.name}'s ${hand} hand, which will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`);
|
||||||
} else if (mon && !isElemental(mon.kind)) {
|
} else if (mon && !isElemental(mon.kind)) {
|
||||||
mon.pending.skipAttack = true;
|
mon.pending.skipAttack = true;
|
||||||
log(`${mon.name}'s limbs stiffen: it will stand paralysed next turn.`);
|
log(`${mon.name}'s limbs stiffen, and it will stand paralysed next turn.`);
|
||||||
} else {
|
} else {
|
||||||
c.nullified = 'elementals cannot be paralysed';
|
c.nullified = 'elementals cannot be paralysed';
|
||||||
}
|
}
|
||||||
@@ -686,7 +686,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
case 'protection_from_evil':
|
case 'protection_from_evil':
|
||||||
if (wiz) wiz.protectionTurns = duration(4);
|
if (wiz) wiz.protectionTurns = duration(4);
|
||||||
else if (mon) mon.protectionTurns = duration(4);
|
else if (mon) mon.protectionTurns = duration(4);
|
||||||
log(`A pale circle of light rises around ${target}: protected from evil${c.permanent ? forever : ' for this turn and the next three'}.`);
|
log(`A pale circle of light rises around ${target}, protected from evil${c.permanent ? forever : ' for this turn and the next three'}.`);
|
||||||
break;
|
break;
|
||||||
case 'resist_heat':
|
case 'resist_heat':
|
||||||
if (wiz) wiz.resistHeat = true;
|
if (wiz) wiz.resistHeat = true;
|
||||||
@@ -696,7 +696,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
case 'resist_cold':
|
case 'resist_cold':
|
||||||
if (wiz) wiz.resistCold = true;
|
if (wiz) wiz.resistCold = true;
|
||||||
else if (mon) mon.resistCold = true;
|
else if (mon) mon.resistCold = true;
|
||||||
log(`A warm rosy aura settles over ${target}, who now resists cold.`);
|
log(`A warm rosy aura wraps ${target}, who now resists cold.`);
|
||||||
break;
|
break;
|
||||||
case 'disease':
|
case 'disease':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
@@ -707,13 +707,13 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
case 'poison':
|
case 'poison':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.poisonTurns = 6;
|
wiz.poisonTurns = 6;
|
||||||
log(`${wiz.name} goes grey about the lips: poison is in the blood. Unless cured, ${wiz.name} will die at the end of turn ${state.turn + 6}.`);
|
log(`${wiz.name} goes grey about the lips as poison takes the blood. Unless cured, ${wiz.name} will die at the end of turn ${state.turn + 6}.`);
|
||||||
} else c.nullified = 'monsters do not sicken';
|
} else c.nullified = 'monsters do not sicken';
|
||||||
break;
|
break;
|
||||||
case 'blindness':
|
case 'blindness':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.blindTurns = duration(4);
|
wiz.blindTurns = duration(4);
|
||||||
log(`A milky film clouds ${wiz.name}'s eyes: blind${c.permanent ? forever : ' for three turns'}.`);
|
log(`A milky film clouds ${wiz.name}'s eyes and leaves them blind${c.permanent ? forever : ' for three turns'}.`);
|
||||||
} else if (mon) destroyBefore(mon, 'blinded and unmade');
|
} else if (mon) destroyBefore(mon, 'blinded and unmade');
|
||||||
break;
|
break;
|
||||||
case 'invisibility':
|
case 'invisibility':
|
||||||
@@ -725,25 +725,25 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
case 'haste':
|
case 'haste':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.hasteTurns = duration(4);
|
wiz.hasteTurns = duration(4);
|
||||||
log(`The air about ${wiz.name} quickens; everyone else seems to move through honey. Hastened${c.permanent ? forever : ' for the next three turns'}: two pairs of gestures a turn.`);
|
log(`The air about ${wiz.name} quickens; everyone else seems to move through honey. Hastened${c.permanent ? forever : ' for the next three turns'}, ${wiz.name} makes two pairs of gestures a turn.`);
|
||||||
} else c.nullified = 'monsters cannot be hastened';
|
} else c.nullified = 'monsters cannot be hastened';
|
||||||
break;
|
break;
|
||||||
case 'time_stop':
|
case 'time_stop':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
state.timeStops.push(wiz.id);
|
state.timeStops.push(wiz.id);
|
||||||
log(`The moment catches on ${wiz.name} like a snagged thread: when this turn ends, time will stop, and ${wiz.name} alone will move.`);
|
log(`The moment catches on ${wiz.name} like a snagged thread. When this turn ends, time will stop, and ${wiz.name} alone will move.`);
|
||||||
} else c.nullified = 'only wizards can stop time';
|
} else c.nullified = 'only wizards can stop time';
|
||||||
break;
|
break;
|
||||||
case 'delayed_effect':
|
case 'delayed_effect':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.delayedTurns = bankedNow.has(wiz.id) ? 0 : duration(4);
|
wiz.delayedTurns = bankedNow.has(wiz.id) ? 0 : duration(4);
|
||||||
if (!bankedNow.has(wiz.id)) log(`A hollow of waiting opens around ${wiz.name}'s hands: the next spell will be caught and held.`);
|
if (!bankedNow.has(wiz.id)) log(`A hollow of waiting opens around ${wiz.name}'s hands, ready to catch and hold the next spell.`);
|
||||||
} else c.nullified = 'only wizards can delay a spell';
|
} else c.nullified = 'only wizards can delay a spell';
|
||||||
break;
|
break;
|
||||||
case 'permanency':
|
case 'permanency':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.permanencyTurns = extendedNow.has(wiz.id) ? 0 : 4;
|
wiz.permanencyTurns = extendedNow.has(wiz.id) ? 0 : 4;
|
||||||
if (!extendedNow.has(wiz.id)) log(`Something steady and unhurried settles on ${wiz.name}: the next enchantment will be permanent.`);
|
if (!extendedNow.has(wiz.id)) log(`Something steady and unhurried takes hold of ${wiz.name}, and the next enchantment will be permanent.`);
|
||||||
} else c.nullified = 'only a wizard can hold a permanency';
|
} else c.nullified = 'only a wizard can hold a permanency';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -756,10 +756,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const mon = monsterOf(c.target);
|
const mon = monsterOf(c.target);
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
clearEnchantments(wiz);
|
clearEnchantments(wiz);
|
||||||
log(`Threads of old magic lift from ${wiz.name} and drift away: every enchantment is gone.`);
|
log(`Threads of old magic lift from ${wiz.name} and drift away. Every enchantment is gone.`);
|
||||||
} else if (mon && c.spell.id === 'remove_enchantment') {
|
} else if (mon && c.spell.id === 'remove_enchantment') {
|
||||||
destroyedAfterAttack.add(mon.id);
|
destroyedAfterAttack.add(mon.id);
|
||||||
log(`${mon.name} begins to fray at the edges: it will be unmade once it has attacked.`);
|
log(`${mon.name} begins to fray at the edges, and will be unmade once it has attacked.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,7 +820,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
heal(t, 2, 'Cure heavy wounds');
|
heal(t, 2, 'Cure heavy wounds');
|
||||||
if (isWizard(t) && W[t].diseaseTurns !== null) {
|
if (isWizard(t) && W[t].diseaseTurns !== null) {
|
||||||
W[t].diseaseTurns = null;
|
W[t].diseaseTurns = null;
|
||||||
log(`${W[t].name}'s fever breaks, and the flush fades from the skin: the disease is cured.`);
|
log(`${W[t].name}'s fever breaks, and the flush fades from the skin. The disease is cured.`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'raise_dead':
|
case 'raise_dead':
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ export function visibleHistory(state: GameState, viewer: WizardId, subject: Wiza
|
|||||||
return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, viewer) ? { ...h, left: '-', right: '-' } : h));
|
return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, viewer) ? { ...h, left: '-', right: '-' } : h));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The gallery holds no seat: it is shown only what every wizard at the table could see. */
|
/** A spectator holds no seat and is shown only what every seated player could see. */
|
||||||
export const SPECTATOR: WizardId = '';
|
export const SPECTATOR: WizardId = '';
|
||||||
|
|
||||||
export function hiddenFromViewer(hiddenFrom: WizardId[] | undefined, viewer: WizardId): boolean {
|
export function hiddenFromViewer(hiddenFrom: WizardId[] | undefined, viewer: WizardId): boolean {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// How much of each table's talk this browser has read, so the hall can say what is new.
|
// How many chat lines of each room this browser has read, so the landing page can count what is new.
|
||||||
|
|
||||||
const SEEN_KEY = 'wh:talk-seen';
|
const SEEN_KEY = 'wh:talk-seen';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// The game's own lines for the hall's tally, read off a finished duel. Only
|
// This game's lines for the site-wide tally, read off a finished game: only
|
||||||
// what the final state records for certain: who is dead, who yielded, and
|
// what the final state records for certain (who is dead, who yielded, and
|
||||||
// how the duel was decided.
|
// how the game was decided).
|
||||||
|
|
||||||
import type { GameState } from './state';
|
import type { GameState } from './state';
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`). */
|
/** One chat line: from a seat, from a spectator when the host allows it (id SPECTATOR, signed with `name`), or from the site maintainer (`keeper`). */
|
||||||
export interface ChatLine {
|
export interface ChatLine {
|
||||||
id: WizardId;
|
id: WizardId;
|
||||||
text: string;
|
text: string;
|
||||||
at: number;
|
at: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
keeper?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a seated player, or the gallery, is told by the server. Other seats' moves and tokens never leave it. */
|
/** What a seated player, or the gallery, is told by the server. Other seats' moves and tokens never leave it. */
|
||||||
@@ -33,7 +34,8 @@ export interface RoomView {
|
|||||||
me: WizardId;
|
me: WizardId;
|
||||||
/** The wizard who opened the table and may begin the duel. */
|
/** The wizard who opened the table and may begin the duel. */
|
||||||
host: WizardId;
|
host: WizardId;
|
||||||
seats: { id: WizardId; name: string; bot: boolean }[];
|
/** Who sits where; `standIn` marks a wizard's seat the host gave to a bot, which duels in it until they take it back. */
|
||||||
|
seats: { id: WizardId; name: string; bot: boolean; standIn?: true }[];
|
||||||
/** Seats that still have to move before the turn resolves. */
|
/** Seats that still have to move before the turn resolves. */
|
||||||
waitingOn: WizardId[];
|
waitingOn: WizardId[];
|
||||||
/** Turns played so far, time stops included; gestures are sent with the count they were made against. */
|
/** Turns played so far, time stops included; gestures are sent with the count they were made against. */
|
||||||
@@ -44,15 +46,15 @@ export interface RoomView {
|
|||||||
audience: number;
|
audience: number;
|
||||||
/** The host lets the gallery talk. */
|
/** The host lets the gallery talk. */
|
||||||
galleryTalk: boolean;
|
galleryTalk: boolean;
|
||||||
/** The keeper of the site was called to a seat, and by whom. */
|
/** The site maintainer was invited to a held seat, and by whom. */
|
||||||
challenge: { by: WizardId; at: number } | null;
|
challenge: { by: WizardId; at: number } | null;
|
||||||
/** Names with a seat held who have not yet sat: the last table's wizards, or the keeper. */
|
/** Names with a seat held who have not yet sat: the previous room's players, or the site maintainer. */
|
||||||
expected: string[];
|
expected: string[];
|
||||||
/** A finished table's rematch: where it went, and who called. */
|
/** A finished table's rematch: where it went, and who called. */
|
||||||
rematch: { to: string; by: WizardId } | null;
|
rematch: { to: string; by: WizardId } | null;
|
||||||
/** The table this one is the rematch of. */
|
/** The table this one is the rematch of. */
|
||||||
rematchOf: string | null;
|
rematchOf: string | null;
|
||||||
/** The keeper of the site, and where they live, for a table that calls them. */
|
/** The site maintainer's name and time zone, for rooms that invite them. */
|
||||||
keeper: string;
|
keeper: string;
|
||||||
keeperZone: string;
|
keeperZone: string;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// The duel lives in the browser and is saved to local storage, so nothing is rendered on the
|
// The duel lives in the browser and is saved to local storage, so by default a route is
|
||||||
// server. Each route is prerendered as an empty shell that the client fills in.
|
// prerendered as an empty shell that the client fills in. The hall, the rules and the guide
|
||||||
|
// turn rendering on in their own +page.ts, since their text is the same for everyone.
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
|
||||||
|
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
|
||||||
|
export const ssr = true;
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
<p>A room's link is an invitation to sit while the table is laid and to watch once the duel has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery. They see the ledger, the result of each turn and the chronicle, and the table can see how many are watching. The gallery is shown only what every wizard at the table could see. A gesture hidden from any one wizard is a question mark to the whole gallery, and the turn being written shows nothing at all.</p>
|
<p>A room's link is an invitation to sit while the table is laid and to watch once the duel has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery. They see the ledger, the result of each turn and the chronicle, and the table can see how many are watching. The gallery is shown only what every wizard at the table could see. A gesture hidden from any one wizard is a question mark to the whole gallery, and the turn being written shows nothing at all.</p>
|
||||||
<p><em>Table talk</em>, in the rail, is for the wizards seated. A line goes to everyone at the table and in the gallery, and it is kept with the duel. When you come back, the hall says how many lines were said while you were away. The gallery only listens unless the host lets it talk. Then a watcher signs a name, and their lines read <em>Name (gallery)</em>.</p>
|
<p><em>Table talk</em>, in the rail, is for the wizards seated. A line goes to everyone at the table and in the gallery, and it is kept with the duel. When you come back, the hall says how many lines were said while you were away. The gallery only listens unless the host lets it talk. Then a watcher signs a name, and their lines read <em>Name (gallery)</em>.</p>
|
||||||
<p>Before a duel begins, any seated wizard may <strong>challenge the keeper</strong>, who looks after this site. A seat is held under the keeper's name and the keeper's phone rings. The note beside the button gives the hour where the keeper lives, so you know whether to wait or come back later.</p>
|
<p>Before a duel begins, any seated wizard may <strong>challenge the keeper</strong>, who looks after this site. A seat is held under the keeper's name and the keeper's phone rings. The note beside the button gives the hour where the keeper lives, so you know whether to wait or come back later.</p>
|
||||||
<p>When a duel is over, anyone at the table may call for <strong>a rematch</strong>. That opens a new table of the same size with the same bots, and holds a seat for everyone who played. <strong>Transfer seat</strong> at the top of the page gives you four words that move your seat to another device. They work for ten minutes, and you enter them in the hall on the other device. If your browser has lost your seat altogether, write to the keeper from the room's page, and the keeper can send you a link that brings it back.</p>
|
<p>When a duel is over, anyone at the table may call for <strong>a rematch</strong>. That opens a new table of the same size with the same bots, and holds a seat for everyone who played. <strong>Transfer seat</strong> at the top of the page gives you four words that move your seat to another device. They work for ten minutes, and you enter them in the hall on the other device. If your browser has lost your seat altogether, ask the host: <strong>Seats</strong> at the top of their page makes the four words for you. Failing that, write to the keeper from the room's page for a link that brings the seat back. A wizard who cannot go on can have their seat given to a bot by the host, and four words from the host bring them back to it.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="first">
|
<section id="first">
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
|
||||||
|
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
|
||||||
|
export const ssr = true;
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import Board from '$lib/components/Board.svelte';
|
import Board from '$lib/components/Board.svelte';
|
||||||
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
||||||
|
import SeatsSlip from '$lib/components/SeatsSlip.svelte';
|
||||||
import TableTalk from '$lib/components/TableTalk.svelte';
|
import TableTalk from '$lib/components/TableTalk.svelte';
|
||||||
import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/game/client';
|
import { api, rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/game/client';
|
||||||
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
|
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
|
||||||
|
|
||||||
const roomId = $derived((page.params.code ?? '').toUpperCase());
|
const roomId = $derived((page.params.code ?? '').toUpperCase());
|
||||||
@@ -12,15 +13,22 @@
|
|||||||
let room = $state<Duel | null>(null);
|
let room = $state<Duel | null>(null);
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let joining = $state(false);
|
let joining = $state(false);
|
||||||
|
/** The host pressed Begin while a seat is still held for someone expected; confirm before going on. */
|
||||||
|
let beginAsked = $state(false);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let reporting = $state(false);
|
let reporting = $state(false);
|
||||||
/** A phrase minted to carry this seat to another device, while it lasts. */
|
/** This seat's transfer phrase, until it expires. */
|
||||||
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
||||||
let rematching = $state(false);
|
let rematching = $state(false);
|
||||||
|
let seatsOpen = $state(false);
|
||||||
|
/** The server's reason for refusing this browser's seat; shown while spectating. */
|
||||||
|
let seatLost = $state('');
|
||||||
|
let claimPhrase = $state('');
|
||||||
|
let claiming = $state(false);
|
||||||
|
|
||||||
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
|
/** The current hour in the site maintainer's time zone, in words, shown beside the challenge button. */
|
||||||
let minute = $state(0);
|
let minute = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = setInterval(() => (minute += 1), 60_000);
|
const id = setInterval(() => (minute += 1), 60_000);
|
||||||
@@ -68,18 +76,18 @@
|
|||||||
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
|
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
|
||||||
const lobby = $derived(room !== null && !room.remote?.started);
|
const lobby = $derived(room !== null && !room.remote?.started);
|
||||||
const watching = $derived(room?.spectating ?? false);
|
const watching = $derived(room?.spectating ?? false);
|
||||||
/** A watcher may still sit while the table is laid and a seat is free. */
|
/** A spectator may still take a seat before the game starts, while one is free. */
|
||||||
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
|
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
|
||||||
|
|
||||||
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
|
// Resume a held seat, else spectate. A link may carry a seat (#seat=...): it is stripped from the address
|
||||||
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
|
// bar at once, saved only once the server accepts it, and never replaces a held seat the server still
|
||||||
// still opens, so a link someone else sends cannot take a working seat away.
|
// accepts, so a forwarded link cannot take a working seat.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = roomId;
|
const id = roomId;
|
||||||
room = null;
|
room = null;
|
||||||
error = '';
|
error = '';
|
||||||
const sent = seatInLink(location.hash);
|
const sent = seatInLink(location.hash);
|
||||||
// The browser's own call: this runs as the page first loads, before the router could take one.
|
// The History API directly, not SvelteKit's replaceState: this runs on first load, before the router is ready.
|
||||||
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
|
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
|
||||||
const held = seatFor(id);
|
const held = seatFor(id);
|
||||||
const take = (s: HeldSeat) =>
|
const take = (s: HeldSeat) =>
|
||||||
@@ -88,10 +96,16 @@
|
|||||||
trustSeat(id);
|
trustSeat(id);
|
||||||
return remote;
|
return remote;
|
||||||
});
|
});
|
||||||
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat.
|
// Only a 403 on the held token lets the link's seat in; a dropped connection is not a lost seat.
|
||||||
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
|
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
|
||||||
|
// A refused seat with no link to replace it falls back to spectating, keeping the server's reason.
|
||||||
|
const watchRefused = (e: Error) => {
|
||||||
|
seatLost = e.message;
|
||||||
|
return Duel.watchRoom(id);
|
||||||
|
};
|
||||||
|
seatLost = '';
|
||||||
const arrive = held
|
const arrive = held
|
||||||
? Duel.resumeRoom(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : Promise.reject(e)))
|
? Duel.resumeRoom(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : refused(e) ? watchRefused(e) : Promise.reject(e)))
|
||||||
: sent
|
: sent
|
||||||
? take(sent).catch(() => Duel.watchRoom(id))
|
? take(sent).catch(() => Duel.watchRoom(id))
|
||||||
: Duel.watchRoom(id);
|
: Duel.watchRoom(id);
|
||||||
@@ -113,6 +127,37 @@
|
|||||||
return stop;
|
return stop;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The seat was revoked while the page was open (the host gave it to a bot): switch to spectating.
|
||||||
|
$effect(() => {
|
||||||
|
const r = room;
|
||||||
|
const lost = r?.remote?.lost;
|
||||||
|
if (!r || !lost) return;
|
||||||
|
seatLost = lost;
|
||||||
|
Duel.watchRoom(r.remote!.roomId)
|
||||||
|
.then((remote) => (room = new Duel(remote)))
|
||||||
|
.catch((e: Error) => (error = e.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Claim a seat by transfer phrase: the host's, or one this seat made on another device. */
|
||||||
|
async function claim(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
claiming = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const got = await api.claim(claimPhrase.trim());
|
||||||
|
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
|
||||||
|
trustSeat(got.roomId);
|
||||||
|
claimPhrase = '';
|
||||||
|
seatLost = '';
|
||||||
|
if (got.roomId === roomId) room = new Duel(await Duel.resumeRoom(roomId, { seat: got.seat, token: got.token }));
|
||||||
|
else await goto(`/join/${got.roomId}`);
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'The phrase opened no seat.';
|
||||||
|
} finally {
|
||||||
|
claiming = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
|
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const r = room;
|
const r = room;
|
||||||
@@ -183,6 +228,9 @@
|
|||||||
{#if !watching}
|
{#if !watching}
|
||||||
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if room.isHost && room.remote?.started}
|
||||||
|
<button type="button" class="quiet" title="Bring a wizard back to a seat their browser lost, or give a seat to a bot" onclick={() => (seatsOpen = true)}>Seats</button>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<a class="quiet" href="/guide">How to play</a>
|
<a class="quiet" href="/guide">How to play</a>
|
||||||
<a class="quiet" href="/rules">Rules</a>
|
<a class="quiet" href="/rules">Rules</a>
|
||||||
@@ -195,6 +243,7 @@
|
|||||||
|
|
||||||
{#if room}
|
{#if room}
|
||||||
<ReportSlip duel={room} bind:open={reporting} />
|
<ReportSlip duel={room} bind:open={reporting} />
|
||||||
|
{#if room.isHost}<SeatsSlip duel={room} bind:open={seatsOpen} />{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -205,8 +254,20 @@
|
|||||||
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
|
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if room?.remote?.started && !seatLost && room.remote.seats.some((s) => s.standIn)}
|
||||||
|
<p class="notice muted">A bot duels for {room.remote.seats.filter((s) => s.standIn).map((s) => s.name).join(', ')}, at the host's word, until a transfer phrase from the host brings them back.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if watching && room?.remote?.started && !room.state.over}
|
{#if watching && room?.remote?.started && !room.state.over}
|
||||||
<p class="notice muted">Had a seat at this table on another device? Transfer it from there and claim the phrase in the hall, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a> for a link that brings it back.</p>
|
<div class="notice muted">
|
||||||
|
{#if seatLost}<p>{seatLost} Claim it here, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a>.</p>
|
||||||
|
{:else}{#if room.remote.expected.length > 0}<p>{room.speaker(room.remote.host)} began the duel before {room.remote.expected.join(' and ')} arrived, and the seat held for {room.remote.expected.length === 1 ? 'them' : 'each'} went with it.</p>{/if}
|
||||||
|
<p>Had a seat at this duel? Ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a> for a link that brings it back.</p>{/if}
|
||||||
|
<form class="rename" onsubmit={claim}>
|
||||||
|
<input type="text" bind:value={claimPhrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||||||
|
<button type="submit" class="quiet" disabled={claiming || claimPhrase.trim().split(/[\s-]+/).length < 4}>Claim the seat</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if room?.remote?.started && room.state.over && !watching}
|
{#if room?.remote?.started && room.state.over && !watching}
|
||||||
@@ -279,8 +340,12 @@
|
|||||||
{#if r.seats.length < r.size && room.isHost}
|
{#if r.seats.length < r.size && room.isHost}
|
||||||
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
|
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if room.isHost}
|
{#if room.isHost && beginAsked && r.expected.length > 0}
|
||||||
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button>
|
<span>A seat is held for {r.expected.join(' and ')}, who {r.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the duel begins.</span>
|
||||||
|
<button type="button" class="reveal small" onclick={() => room?.begin()}>Begin without them</button>
|
||||||
|
<button type="button" class="link" onclick={() => (beginAsked = false)}>wait</button>
|
||||||
|
{:else if room.isHost}
|
||||||
|
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => (r.expected.length > 0 ? (beginAsked = true) : room?.begin())}>Begin the duel</button>
|
||||||
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
|
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
|
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
|
||||||
@@ -315,57 +380,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.masthead {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem var(--gutter) 0.6rem;
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2rem, 4vw, 2.7rem);
|
|
||||||
font-weight: 300;
|
|
||||||
font-variation-settings: 'opsz' 144;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst {
|
|
||||||
max-width: 38em;
|
|
||||||
color: var(--bone-dim);
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst em {
|
|
||||||
color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--rule-strong);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--bone);
|
|
||||||
text-decoration: none;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet:hover {
|
|
||||||
border-color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet.room b {
|
.quiet.room b {
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -452,11 +466,6 @@
|
|||||||
width: 14em;
|
width: 14em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reveal.small {
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster {
|
.roster {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0.9rem 0 0.6rem;
|
margin: 0.9rem 0 0.6rem;
|
||||||
@@ -518,27 +527,4 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.colophon {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem var(--gutter) 3rem;
|
|
||||||
color: var(--bone-faint);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colophon a {
|
|
||||||
color: var(--frost);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.masthead {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colophon {
|
|
||||||
padding-bottom: 6rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -90,116 +90,37 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.masthead {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem var(--gutter) 0.6rem;
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2rem, 4vw, 2.7rem);
|
|
||||||
font-weight: 300;
|
|
||||||
font-variation-settings: 'opsz' 144;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst {
|
|
||||||
max-width: 38em;
|
|
||||||
color: var(--bone-dim);
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst em {
|
|
||||||
color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst .link {
|
.standfirst .link {
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rename {
|
.rename {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem 0.75rem;
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rename input {
|
|
||||||
width: 14em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
gap: 0.5rem 0.75rem;
|
||||||
min-width: 0;
|
margin-top: 0.4rem;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiet {
|
.rename input {
|
||||||
background: none;
|
width: 14em;
|
||||||
border: 1px solid var(--rule-strong);
|
}
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--bone);
|
|
||||||
text-decoration: none;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet:hover {
|
|
||||||
border-color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm {
|
.confirm {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto 0.75rem;
|
margin: 0 auto 0.75rem;
|
||||||
padding: 0.75rem var(--gutter);
|
padding: 0.75rem var(--gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm p {
|
.confirm p {
|
||||||
color: var(--blood);
|
color: var(--blood);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choices {
|
.choices {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
|
||||||
|
|
||||||
.reveal.small {
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colophon {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem var(--gutter) 3rem;
|
|
||||||
color: var(--bone-faint);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colophon a {
|
|
||||||
color: var(--frost);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.colophon {
|
|
||||||
padding-bottom: 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masthead {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
|
||||||
|
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
|
||||||
|
export const ssr = true;
|
||||||
+9
-1
@@ -31,8 +31,16 @@
|
|||||||
"name": "Dip",
|
"name": "Dip",
|
||||||
"url": "https://dip.kestrelsnest.social",
|
"url": "https://dip.kestrelsnest.social",
|
||||||
"pitch": "Seven powers share one map, and none of them has to keep its word.",
|
"pitch": "Seven powers share one map, and none of them has to keep its word.",
|
||||||
"players": "2 to 7",
|
"players": "2 to 10, depending on the map",
|
||||||
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
|
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "wyrm",
|
||||||
|
"name": "Wyrm",
|
||||||
|
"url": "https://wyrm.kestrelsnest.social",
|
||||||
|
"pitch": "Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.",
|
||||||
|
"players": "2",
|
||||||
|
"origin": "Gary Gygax's Dragonchess, Dragon Magazine No. 100, 1985; an unofficial rebuild"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 106 KiB |
+3
-1
@@ -11,7 +11,9 @@ export default defineConfig({
|
|||||||
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||||
},
|
},
|
||||||
// A static build: the duel runs entirely in the browser, so Caddy serves files and nothing else.
|
// A static build: the duel runs entirely in the browser, so Caddy serves files and nothing else.
|
||||||
adapter: adapter({ fallback: 'index.html' })
|
// The fallback is the empty shell Caddy serves for a room address; it is not index.html, so the
|
||||||
|
// hall's own prerendered page is not overwritten by it.
|
||||||
|
adapter: adapter({ fallback: 'shell.html' })
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in new issue
Block a user