Compare commits
3
Commits
fbe110a7bb
...
a673ea9766
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a673ea9766 | ||
|
|
21d9c12f48 | ||
|
|
b51934ecd6 |
No files matched your search
@@ -45,7 +45,11 @@ Around the two hands:
|
||||
A duel against the bot is saved to the browser's local storage after every change, so it
|
||||
survives reading the rules, a refresh, or a phone putting the tab to sleep. Starting a new one
|
||||
asks first if one is under way. The seats this browser holds at shared duels are remembered the
|
||||
same way and listed in the hall with whose move it is. Nothing is rendered on the server.
|
||||
same way and listed in the hall with whose move it is; the hall asks before forgetting one
|
||||
still in play. A player whose browser lost a seat (Safari's seven days, an app's own browser,
|
||||
cleared data) can write to the keeper, whose `waving-hands-reseat.sh` gives the seat a new token
|
||||
and prints a link carrying it in the fragment; the old token opens nothing after. Nothing is
|
||||
rendered on the server.
|
||||
|
||||
## The duel server
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny
|
||||
/etc/cron.d/waving-hands on every deploy, so the live copies are the repo copies:
|
||||
|
||||
- `waving-hands-visitors.sh [day]`: who is here now and who came that day.
|
||||
- `waving-hands-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new
|
||||
token for a player locked out of it, as a link to send them. It answers
|
||||
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
|
||||
and readable by root and the waving-hands user alone.
|
||||
- `waving-hands-pulse.sh`: the weekly health check (service, errors, rollup
|
||||
trend, backup, box).
|
||||
- `waving-hands-rollup.sh [day]`: one JSON line per day of counts, run
|
||||
|
||||
@@ -43,6 +43,13 @@ ssh "root@$HOST" '
|
||||
install -m 755 /opt/waving-hands/app/deploy/pulse.sh /usr/local/bin/waving-hands-pulse.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-reseat.sh /usr/local/bin/waving-hands-reseat.sh
|
||||
# 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
|
||||
install -d -m 750 -o root -g waving-hands /etc/waving-hands
|
||||
(umask 037; openssl rand -hex 32 > /etc/waving-hands/keeper.key)
|
||||
chown root:waving-hands /etc/waving-hands/keeper.key
|
||||
fi
|
||||
# On a host the hall shares, its hall-nightly.sh runs the rollup and backup
|
||||
# of every game and checks in with Sentry once for all; a game on a box of
|
||||
# its own schedules its own.
|
||||
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# A way back for a player whose browser lost their seat: the seat gets a new
|
||||
# token, the old one stops working, and the link printed carries the new one.
|
||||
# Whether the one asking holds that seat is the keeper's call; send the link
|
||||
# only to them. Runs ON the droplet, through the duel server's keeper door.
|
||||
# waving-hands-reseat.sh ROOM who sits at the table, and when each wizard last moved or spoke
|
||||
# waving-hands-reseat.sh ROOM SEAT re-seat SEAT and print the link
|
||||
set -euo pipefail
|
||||
ROOM="${1:?usage: waving-hands-reseat.sh ROOM [SEAT]}"
|
||||
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)")
|
||||
if [ $# -lt 2 ]; then
|
||||
curl -s -H "$KEY" "$DOOR" | python3 -c '
|
||||
import datetime, json, sys
|
||||
r = json.load(sys.stdin)
|
||||
if "error" in r: sys.exit(r["error"])
|
||||
print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun"))
|
||||
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")
|
||||
print(" %-3s %-24s %s" % (s["id"], s["name"], when))
|
||||
'
|
||||
else
|
||||
curl -s -X POST -H "$KEY" -H 'content-type: application/json' -d "{\"seat\":\"${2^^}\"}" "$DOOR/reseat" | python3 -c '
|
||||
import json, sys
|
||||
r = json.load(sys.stdin)
|
||||
if "error" in r: sys.exit(r["error"])
|
||||
print("%s has a new token; their old one opens nothing now. Send them this link:" % r["name"])
|
||||
print(r["link"])
|
||||
'
|
||||
fi
|
||||
+40
-1
@@ -23,10 +23,17 @@
|
||||
// GET /api/tally what the ledgers add up to, for the hall
|
||||
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
|
||||
//
|
||||
// Caddy serves the static site and proxies /api and /ws here.
|
||||
// Caddy serves the static site and proxies /api and /ws here. The keeper's
|
||||
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
|
||||
// (deploy/waving-hands-reseat.sh sends it):
|
||||
//
|
||||
// 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
|
||||
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { RateLimit } from './ratelimit';
|
||||
import { IMAGE_MAX_BYTES, Reports } from './reports';
|
||||
@@ -41,6 +48,8 @@ const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms';
|
||||
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
|
||||
const KEEPER = process.env.KEEPER ?? 'the keeper';
|
||||
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
|
||||
/** The keeper's key, readable by root and this game's user alone, so another service on the host cannot use the keeper's door. Absent, the door stays shut. */
|
||||
const KEEPER_KEY_FILE = process.env.KEEPER_KEY_FILE ?? '/etc/waving-hands/keeper.key';
|
||||
const BODY_LIMIT = 16 * 1024;
|
||||
|
||||
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
|
||||
@@ -195,9 +204,39 @@ function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
});
|
||||
}
|
||||
|
||||
/** The key the keeper's door asks for, read on each knock so a key written after the server started is honoured. */
|
||||
function keeperKeyGiven(req: IncomingMessage): boolean {
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = Buffer.from(readFileSync(KEEPER_KEY_FILE, 'utf8').trim());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const given = Buffer.from(String(req.headers['x-keeper-key'] ?? ''));
|
||||
return key.length > 0 && given.length === key.length && timingSafeEqual(given, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy
|
||||
* forwarded is turned away besides, and so is one without the keeper's key.
|
||||
*/
|
||||
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
|
||||
if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms' || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404);
|
||||
const room = rooms.get(parts[2] ?? '');
|
||||
if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) });
|
||||
if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const { seat, token } = rooms.reseat(room, body.seat);
|
||||
// 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}` });
|
||||
}
|
||||
throw new RoomError('Not here.', 404);
|
||||
}
|
||||
|
||||
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'keeper') return handleKeeper(req, res, parts);
|
||||
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
|
||||
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
|
||||
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { Rooms } from './rooms';
|
||||
import { Store } from './store';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A table of three with Ann, who opened it, and Bo seated, the duel begun. */
|
||||
function table() {
|
||||
const rooms = new Rooms(new Store(dir));
|
||||
const host = rooms.create('Ann', 3);
|
||||
const guest = rooms.join(host.room, 'Bo');
|
||||
rooms.begin(host.room, host.token);
|
||||
return { rooms, room: host.room, ann: host.token, bo: guest.token };
|
||||
}
|
||||
|
||||
describe('rooms', () => {
|
||||
it("gives a seat a new token at the keeper's word: the old one opens nothing, and a restart keeps the change", () => {
|
||||
const { rooms, room, bo } = table();
|
||||
const phrase = rooms.transfer(room, bo).phrase;
|
||||
const { seat, token } = rooms.reseat(room, 'B');
|
||||
expect(seat.name).toBe('Bo');
|
||||
expect(rooms.seatOf(room, token).id).toBe('B');
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||
// A phrase minted with the old token carries nothing now.
|
||||
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||
const reopened = new Rooms(new Store(dir)).get(room.id);
|
||||
expect(rooms.seatOf(reopened, token).id).toBe('B');
|
||||
expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/);
|
||||
});
|
||||
|
||||
it('re-seats only a wizard, and says when each last acted', () => {
|
||||
const { rooms, room, ann } = table();
|
||||
expect(() => rooms.reseat(room, 'Z')).toThrow(/No wizard sits/);
|
||||
rooms.submit(room, ann, { left: 'P', right: '-', casts: [] }, 0);
|
||||
const seats = rooms.seatsForKeeper(room);
|
||||
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
|
||||
expect(seats[0].lastActed).toBeGreaterThan(0);
|
||||
expect(seats[1].lastActed).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -307,6 +307,30 @@ export class Rooms {
|
||||
return { seat, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* The keeper gives a wizard's seat a new token, for a player whose browser lost the old one. Whether the one
|
||||
* asking holds the seat is the keeper's judgment; this carries it out. The old token opens nothing after.
|
||||
*/
|
||||
reseat(room: Room, seatId: unknown): { seat: Seat; token: string } {
|
||||
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
|
||||
if (!seat) throw new RoomError('No wizard sits in that seat.', 404);
|
||||
const token = newId(18);
|
||||
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.
|
||||
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seat.id) this.transfers.delete(phrase);
|
||||
return { seat, token };
|
||||
}
|
||||
|
||||
/** 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 }[] {
|
||||
const last = new Map<WizardId, number>();
|
||||
for (const line of this.store.read(room.id)) {
|
||||
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);
|
||||
}
|
||||
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, lastActed: s.bot ? null : (last.get(s.id) ?? null) }));
|
||||
}
|
||||
|
||||
/** The host sends a bot away before the duel begins. People leave by not coming back. */
|
||||
unseat(room: Room, token: string | undefined, seatId: unknown): void {
|
||||
const host = this.seatOf(room, token);
|
||||
@@ -437,6 +461,11 @@ function apply(room: Room, line: LedgerLine): void {
|
||||
case 'seat':
|
||||
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
|
||||
break;
|
||||
case 'reseat': {
|
||||
const seat = room.seats.find((s) => s.id === line.id);
|
||||
if (seat) seat.tokenHash = line.tokenHash;
|
||||
break;
|
||||
}
|
||||
case 'input':
|
||||
room.pending[line.id] = line.input;
|
||||
break;
|
||||
|
||||
@@ -11,6 +11,8 @@ export type LedgerLine =
|
||||
| { 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. */
|
||||
| { 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. */
|
||||
| { t: 'reseat'; at: number; id: WizardId; tokenHash: string }
|
||||
/** 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 }
|
||||
/** A bot sent away by the host before the duel began; its seat id is free again. */
|
||||
|
||||
@@ -167,7 +167,14 @@
|
||||
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. */
|
||||
let forgetting = $state<string | null>(null);
|
||||
function forget(roomId: string) {
|
||||
if (!games[roomId]?.state?.over && forgetting !== roomId) {
|
||||
forgetting = roomId;
|
||||
return;
|
||||
}
|
||||
forgetting = null;
|
||||
forgetSeat(roomId);
|
||||
seats = allSeats();
|
||||
}
|
||||
@@ -279,12 +286,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#each seats as held (held.roomId)}
|
||||
<div class="ledger-row" class:your-move={yourMove(held)}>
|
||||
<div class="ledger-row" class:your-move={yourMove(held)} class:asking={forgetting === held.roomId}>
|
||||
<a class="ledger-resume" href={`/join/${held.roomId}`}>
|
||||
<span class="ledger-code">{held.roomId}</span>
|
||||
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
|
||||
</a>
|
||||
{#if forgetting === held.roomId}
|
||||
<span class="forget-ask">This browser may be all that holds your seat. <button type="button" class="quiet" onclick={() => forget(held.roomId)}>Forget it</button> <button type="button" class="quiet" onclick={() => (forgetting = null)}>Keep it</button></span>
|
||||
{:else}
|
||||
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if reports.length}
|
||||
@@ -675,6 +686,16 @@
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
/* The question takes a line of its own under the duel on a narrow screen. */
|
||||
.ledger-row.asking {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.forget-ask {
|
||||
font-size: 0.85em;
|
||||
padding: 0 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.ledger-forget {
|
||||
background: none;
|
||||
border: 0;
|
||||
|
||||
@@ -19,6 +19,12 @@ 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. */
|
||||
export function seatInLink(hash: string): HeldSeat | null {
|
||||
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
|
||||
return m ? { seat: m[1], token: m[2] } : null;
|
||||
}
|
||||
|
||||
export function seatFor(roomId: string): HeldSeat | null {
|
||||
return heldSeats()[roomId] ?? null;
|
||||
}
|
||||
|
||||
@@ -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><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>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.</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>
|
||||
</section>
|
||||
|
||||
<section id="first">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import Board from '$lib/components/Board.svelte';
|
||||
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
||||
import TableTalk from '$lib/components/TableTalk.svelte';
|
||||
import { seatFor } from '$lib/game/client';
|
||||
import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/game/client';
|
||||
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
|
||||
|
||||
const roomId = $derived((page.params.code ?? '').toUpperCase());
|
||||
@@ -71,23 +71,37 @@
|
||||
/** A watcher may still sit while the table is laid and a seat is free. */
|
||||
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.
|
||||
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
|
||||
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
|
||||
// still opens, so a link someone else sends cannot take a working seat away.
|
||||
$effect(() => {
|
||||
const id = roomId;
|
||||
room = null;
|
||||
error = '';
|
||||
const sent = seatInLink(location.hash);
|
||||
// The browser's own call: this runs as the page first loads, before the router could take one.
|
||||
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
|
||||
const held = seatFor(id);
|
||||
if (!held) {
|
||||
name = playerName();
|
||||
joining = true;
|
||||
Duel.watchRoom(id)
|
||||
.then((remote) => (room = new Duel(remote)))
|
||||
.catch((e: Error) => (error = e.message));
|
||||
return;
|
||||
}
|
||||
joining = false;
|
||||
Duel.resumeRoom(id, held)
|
||||
.then((remote) => (room = new Duel(remote)))
|
||||
const take = (s: HeldSeat) =>
|
||||
Duel.resumeRoom(id, s).then((remote) => {
|
||||
rememberSeat(id, s);
|
||||
trustSeat(id);
|
||||
return remote;
|
||||
});
|
||||
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat.
|
||||
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
|
||||
const arrive = held
|
||||
? Duel.resumeRoom(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : Promise.reject(e)))
|
||||
: sent
|
||||
? take(sent).catch(() => Duel.watchRoom(id))
|
||||
: Duel.watchRoom(id);
|
||||
joining = !held && !sent;
|
||||
if (!held) name = playerName();
|
||||
arrive
|
||||
.then((remote) => {
|
||||
room = new Duel(remote);
|
||||
if (remote.spectating) joining = true;
|
||||
})
|
||||
.catch((e: Error) => (error = e.message));
|
||||
});
|
||||
|
||||
@@ -191,6 +205,10 @@
|
||||
<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 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>
|
||||
{/if}
|
||||
|
||||
{#if room?.remote?.started && room.state.over && !watching}
|
||||
{@const r = room.remote}
|
||||
<p class="notice muted">
|
||||
|
||||
+1
-1
@@ -22,6 +22,6 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts']
|
||||
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user