Seats are banked in a cookie the server sets, and a lost seat comes back by name to the address it was last used from
A seat lived in the browser's storage alone, and Safari clears that after a week away, or at a word from the person on another device. Now every seat taken is banked as well in an HttpOnly cookie the server writes, which such clearing leaves alone, and the wallet takes its seats back from the bank on arrival. For a browser that lost even the cookie, the ledger notes the address each seat was last used from, as a salted hash, at most once in ten minutes, and a name given in the lobby or from the gallery gets its seats back from the same address, with a new token; where the address differs, two people at the table share it, or the host gave the seat to the clockwork, the table is only counted, and the host's phrase or the keeper's link is the way back. A name refused as taken is looked for the same way before the refusal shows. The keeper's script finds a name across tables. The seat endpoints answer only this site's own pages, under a limit of their own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
1 parent
dbc02a8d8f
commit
ecfee5d9c2
8 files changed
+388
-7
No files matched your search
+12
-2
@@ -4,17 +4,27 @@
|
||||
# Whether the one asking holds that seat is the keeper's call; send the link
|
||||
# only to them. Runs ON the droplet, through the game server's keeper door,
|
||||
# which listens on the loopback alone.
|
||||
# wizwar-reseat.sh find NAME every table not finished where a wizard of that name sits, for one who lost the codes too
|
||||
# wizwar-reseat.sh ROOM who sits at the table, and when each person last moved or spoke
|
||||
# wizwar-reseat.sh ROOM NAME re-seat the wizard NAME and print the link
|
||||
set -euo pipefail
|
||||
ROOM="${1:?usage: wizwar-reseat.sh ROOM [NAME]}"
|
||||
ROOM="${1:?usage: wizwar-reseat.sh find NAME | ROOM [NAME]}"
|
||||
DOOR="http://127.0.0.1:${KEEPER_PORT:-9787}/keeper/rooms/${ROOM^^}"
|
||||
KEY_FILE="${KEEPER_KEY_FILE:-/etc/wizwar/keeper.key}"
|
||||
[ -s "$KEY_FILE" ] || { echo "No keeper's key at $KEY_FILE; 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 "$KEY_FILE")")
|
||||
if [ $# -lt 2 ]; then
|
||||
if [ "$ROOM" = find ]; then
|
||||
curl -s -H "$KEY" --get --data-urlencode "name=${2:?a name to look for}" "http://127.0.0.1:${KEEPER_PORT:-9787}/keeper/seats" | python3 -c '
|
||||
import json, sys
|
||||
r = json.load(sys.stdin)
|
||||
if "error" in r: sys.exit(r["error"])
|
||||
if not r["tables"]: print("no table still in play has a seat under that name")
|
||||
for t in r["tables"]:
|
||||
print(" %s %s %s with %s, last moved %s" % (t["roomId"], t["name"], "(the clockwork stands in)" if t["standIn"] else "", ", ".join(t["players"]) or "nobody yet", t["lastMoveAt"] or "never"))
|
||||
'
|
||||
elif [ $# -lt 2 ]; then
|
||||
curl -s -H "$KEY" "$DOOR" | python3 -c '
|
||||
import json, sys
|
||||
r = json.load(sys.stdin)
|
||||
|
||||
@@ -35,10 +35,16 @@
|
||||
// {type:"rematched", roomId} the caller's own seat at the new table
|
||||
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"|"feedbackList"}
|
||||
// {type:"error", message}
|
||||
// plain HTTP, beside the pages:
|
||||
// POST /api/seats {seats: [{roomId, token}], forget?: [roomId]} bank this browser's seats in a cookie the server sets,
|
||||
// and get back every seat the cookie holds: a browser whose storage was cleared gets its seats from
|
||||
// the cookie, which clearing storage leaves alone
|
||||
// POST /api/seats/find {name, roomId?} the seats a name holds at tables not finished: those last used from this address
|
||||
// are re-seated and returned; the rest only counted
|
||||
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { readFileSync, existsSync, realpathSync, statSync, createReadStream, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { extname, join, normalize, sep } from "node:path";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
@@ -79,9 +85,12 @@ import {
|
||||
abandonRoom,
|
||||
KEEPER,
|
||||
MAX_SEATS,
|
||||
findSeats,
|
||||
noteSeen,
|
||||
seatOfToken,
|
||||
} from "./rooms";
|
||||
import { engagementStats, recordHotseat } from "./stats";
|
||||
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store";
|
||||
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, dataRoot, feedbackImageDir } from "./store";
|
||||
import { clipsIndexHtml, clipPageHtml } from "./clips";
|
||||
import { openKeeperDoor } from "./keeper";
|
||||
import { SlidingLimit, clientAddress } from "./ratelimit";
|
||||
@@ -115,6 +124,8 @@ const imagesPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
||||
const IMAGE_MAX_BYTES = 2_500_000;
|
||||
/** Crash reports from browsers: a crashing tab reloads and crashes again, so a few per address per hour say all there is. */
|
||||
const crashReportsPerAddress = new SlidingLimit(10, 60 * 60 * 1000);
|
||||
/** Searches for lost seats: each reads every table written to lately, and a person loses their seats rarely. */
|
||||
const seatSearchesPerAddress = new SlidingLimit(5, 10 * 60 * 1000);
|
||||
const CRASH_REPORT_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/** Anyone may post a report, so its strings reach the log on one line,
|
||||
@@ -127,6 +138,129 @@ function printable(value: unknown, max: number): string {
|
||||
* from the browser itself: a tab that crashes takes the page's own error
|
||||
* reporting down with it. Each crash is logged and filed in Sentry as a
|
||||
* warning, which rings no bell. Other report types are ignored. */
|
||||
// --- Seats banked in a cookie, and found again by name ---
|
||||
|
||||
/** Addresses go on the ledgers as salted hashes. The salt is made once, beside the ledgers, and never leaves the host. */
|
||||
function addrSalt(): string {
|
||||
const file = join(dataRoot(), "addr.salt");
|
||||
try {
|
||||
const salt = readFileSync(file, "utf8").trim();
|
||||
if (salt) return salt;
|
||||
} catch {
|
||||
// No salt yet: this is the first start.
|
||||
}
|
||||
mkdirSync(dataRoot(), { recursive: true });
|
||||
const salt = randomBytes(32).toString("base64url");
|
||||
writeFileSync(file, salt + "\n", { mode: 0o600 });
|
||||
return salt;
|
||||
}
|
||||
const salt = addrSalt();
|
||||
/** An address as the ledgers note it: a hash a reader of the ledger cannot turn back into the address. */
|
||||
function addrHash(address: string): string {
|
||||
return createHmac("sha256", salt).update(address).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/** The seats a browser holds, banked in a cookie: the browser keeps it for the server alone, and sends it to the API only. */
|
||||
const SEATS_COOKIE = "seats";
|
||||
const SEATS_COOKIE_MAX_AGE = 400 * 24 * 60 * 60;
|
||||
const SEATS_COOKIE_KEEP = 60;
|
||||
const JSON_BODY_MAX_BYTES = 16 * 1024;
|
||||
|
||||
/** Whether the request was sent by this site's own pages; a page elsewhere, which the browser names, is refused. */
|
||||
function fromThisSite(req: IncomingMessage): boolean {
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) return true;
|
||||
try { return new URL(origin).host === req.headers.host; } catch { return false; }
|
||||
}
|
||||
|
||||
function readJson(req: IncomingMessage): Promise<Record<string, unknown> | null> {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
let tooBig = false;
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk: string) => {
|
||||
if (tooBig) return;
|
||||
raw += chunk;
|
||||
if (raw.length > JSON_BODY_MAX_BYTES) tooBig = true;
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (tooBig) return resolve(null);
|
||||
try {
|
||||
const body = JSON.parse(raw || "{}");
|
||||
resolve(typeof body === "object" && body !== null ? body as Record<string, unknown> : null);
|
||||
} catch { resolve(null); }
|
||||
});
|
||||
req.on("error", () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }).end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
/** The seats the browser's cookie banks, as room and token; a cookie this server never wrote reads as none. */
|
||||
function bankedSeats(req: IncomingMessage): { roomId: string; token: string }[] {
|
||||
const pair = (req.headers.cookie ?? "").split(";").map((c) => c.trim()).find((c) => c.startsWith(`${SEATS_COOKIE}=`));
|
||||
if (!pair) return [];
|
||||
return pair.slice(SEATS_COOKIE.length + 1).split(":").map((entry) => entry.split("."))
|
||||
.filter((e): e is [string, string] => e.length === 2 && /^[A-Z0-9]{4}$/.test(e[0]!) && /^[A-Za-z0-9_-]{1,64}$/.test(e[1]!))
|
||||
.map(([roomId, token]) => ({ roomId, token }));
|
||||
}
|
||||
|
||||
/** The cookie written for the seats given, newest last; sent only to the API, only over https where there is any, never to a script. */
|
||||
function bankSeats(req: IncomingMessage, res: ServerResponse, seats: { roomId: string; token: string }[]): void {
|
||||
const value = seats.slice(-SEATS_COOKIE_KEEP).map((s) => `${s.roomId}.${s.token}`).join(":");
|
||||
const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
|
||||
res.setHeader("set-cookie", `${SEATS_COOKIE}=${value}; Path=/api; Max-Age=${SEATS_COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax${secure}`);
|
||||
}
|
||||
|
||||
/** The seats a browser claims, from its cookie and its own storage, kept to the ones their tokens still open, each room once. */
|
||||
function provenBank(req: IncomingMessage, claimed: unknown, forget: unknown): { roomId: string; name: PlayerId; token: string }[] {
|
||||
const dropped = new Set(Array.isArray(forget) ? forget.slice(0, 50).map((r) => String(r).toUpperCase()) : []);
|
||||
const own = Array.isArray(claimed) ? claimed.slice(0, SEATS_COOKIE_KEEP) : [];
|
||||
const byRoom = new Map<string, string>();
|
||||
// The browser's own copy is the fresher where the two differ, so it is taken last.
|
||||
for (const c of [...bankedSeats(req), ...own]) {
|
||||
if (typeof c !== "object" || c === null) continue;
|
||||
const { roomId, token } = c as Record<string, unknown>;
|
||||
if (typeof roomId !== "string" || typeof token !== "string" || !token || !/^[A-Za-z0-9]{4}$/.test(roomId)) continue;
|
||||
if (!dropped.has(roomId.toUpperCase())) byRoom.set(roomId.toUpperCase(), token);
|
||||
}
|
||||
const proven: { roomId: string; name: PlayerId; token: string }[] = [];
|
||||
for (const [roomId, token] of byRoom) {
|
||||
const room = getRoom(roomId);
|
||||
const name = room ? seatOfToken(room, token) : null;
|
||||
if (room && name) proven.push({ roomId: room.id, name, token });
|
||||
}
|
||||
return proven;
|
||||
}
|
||||
|
||||
async function handleSeats(req: IncomingMessage, res: ServerResponse, path: string): Promise<void> {
|
||||
if (!fromThisSite(req) || !String(req.headers["content-type"] ?? "").startsWith("application/json")) return sendJson(res, 403, { error: "not from here" });
|
||||
const body = await readJson(req);
|
||||
if (!body) return sendJson(res, 400, { error: "the request was not JSON" });
|
||||
if (path === "/api/seats") {
|
||||
const seats = provenBank(req, body.seats, body.forget);
|
||||
bankSeats(req, res, seats);
|
||||
return sendJson(res, 200, { seats });
|
||||
}
|
||||
const address = clientAddress(req.headers, req.socket.remoteAddress);
|
||||
if (!seatSearchesPerAddress.allow(address)) return sendJson(res, 429, { error: "too many searches from here just now; try again later" });
|
||||
const name = cleanName(body.name);
|
||||
if (!name) return sendJson(res, 400, { error: "a name to look for" });
|
||||
const found = findSeats(name, addrHash(address), typeof body.roomId === "string" ? body.roomId : undefined);
|
||||
for (const s of found.seated) {
|
||||
const room = getRoom(s.roomId);
|
||||
if (!room) continue;
|
||||
// The seat answers to its new token alone: a socket still seated by the old one is closed, and the table hears of a return from the clockwork.
|
||||
closeSeat(room, s.name);
|
||||
if (s.returned) broadcast(room, () => ({ type: "events", events: [s.returned] }));
|
||||
broadcastRoomState(room);
|
||||
}
|
||||
if (found.seated.length) bankSeats(req, res, provenBank(req, found.seated, []));
|
||||
return sendJson(res, 200, { seated: found.seated.map(({ roomId, name: seat, token }) => ({ roomId, name: seat, token })), elsewhere: found.elsewhere });
|
||||
}
|
||||
|
||||
function receiveBrowserReports(req: IncomingMessage, res: ServerResponse): void {
|
||||
const address = clientAddress(req.headers, req.socket.remoteAddress);
|
||||
let raw = "";
|
||||
@@ -419,6 +553,14 @@ const httpServer = createServer((req, res) => {
|
||||
const picture = (req.url ?? "").split("?")[0]!.match(/^\/api\/feedback-image\/([0-9a-f]{8})$/);
|
||||
if (picture) { receiveFeedbackImage(req, res, picture[1]!); return; }
|
||||
if ((req.url ?? "").split("?")[0] === "/api/reports") { receiveBrowserReports(req, res); return; }
|
||||
const path = (req.url ?? "").split("?")[0]!;
|
||||
if (path === "/api/seats" || path === "/api/seats/find") {
|
||||
handleSeats(req, res, path).catch((e) => {
|
||||
console.error(e);
|
||||
if (!res.headersSent) sendJson(res, 500, { error: "something went wrong on the server" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
@@ -900,6 +1042,7 @@ wss.on("connection", (socket, req) => {
|
||||
session.playerId = name;
|
||||
session.roomId = room.id;
|
||||
session.token = result.token;
|
||||
noteSeen(room, name, addrHash(session.address));
|
||||
send(socket, { type: "seat", playerId: name, token: result.token });
|
||||
// Rejoining a running game: replay the chronicle so far. The
|
||||
// replayed flag keeps one-time fanfare (the opening roll-off)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// port Caddy forwards to, so it answers only on the host itself, and only to
|
||||
// the key in its key file, so another service on a shared host cannot use
|
||||
// it. Its one caller is deploy/wizwar-reseat.sh, which sends the key:
|
||||
// GET /keeper/seats?name= every table not finished where a person of that name sits, for one who lost the codes too
|
||||
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
|
||||
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
|
||||
|
||||
@@ -9,7 +10,7 @@ import { timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { GameEvent, PlayerId } from "@wizwar/engine";
|
||||
import { getRoom, reseat, seatsForKeeper, type Room } from "./rooms";
|
||||
import { getRoom, reseat, seatsForKeeper, tablesForKeeper, type Room } from "./rooms";
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
||||
@@ -54,6 +55,10 @@ async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, ke
|
||||
const parts = (req.url ?? "/").split("?")[0]!.split("/").filter(Boolean);
|
||||
// Nothing proxied belongs here: a forwarded request is turned away even if a proxy is someday pointed at this port.
|
||||
if (req.headers["x-forwarded-for"] || !keyGiven(req, keyFile)) return send(res, 404, { error: "not here" });
|
||||
if (parts[0] === "keeper" && parts[1] === "seats" && parts.length === 2 && req.method === "GET") {
|
||||
const name = new URL(req.url ?? "/", "http://localhost").searchParams.get("name") ?? "";
|
||||
return send(res, 200, { tables: tablesForKeeper(name.trim().slice(0, 40)) });
|
||||
}
|
||||
if (parts[0] !== "keeper" || parts[1] !== "rooms" || !parts[2]) return send(res, 404, { error: "not here" });
|
||||
const room = getRoom(parts[2]);
|
||||
if (!room) return send(res, 404, { error: "no such room" });
|
||||
|
||||
@@ -62,9 +62,17 @@ export interface Room {
|
||||
challenge?: { by: PlayerId; at: string };
|
||||
/** The host lets the Peanut Gallery talk at the table. */
|
||||
galleryTalk: boolean;
|
||||
/** The address each person's seat was last used from, as a salted hash; a seat a browser lost goes back to the same address. */
|
||||
addrs: Map<PlayerId, string>;
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
/** A seat's address is noted at most this often: one seat open on two networks at once must not write a line per reclaim. */
|
||||
const SEEN_EVERY_MS = 10 * 60 * 1000;
|
||||
/** When each seat's address was last noted, by room, seat and token: a seat given a new token is noted afresh at once. */
|
||||
const seenAt = new Map<string, number>();
|
||||
/** A table whose ledger has not been written in this long is not looked through for a lost seat. */
|
||||
const LOST_SEAT_LOOKBACK_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Six wizards fit at a table: the physical set's standees. */
|
||||
export const MAX_SEATS = 6;
|
||||
@@ -131,6 +139,7 @@ export function createRoom(
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
galleryTalk: false,
|
||||
addrs: new Map(),
|
||||
touchedAt: Date.now(),
|
||||
...(rematch ? { expected: [...rematch.expected] } : {}),
|
||||
};
|
||||
@@ -349,6 +358,68 @@ export function reseat(room: Room, name: PlayerId): { token: string; returned?:
|
||||
return { token, ...(returned ? { returned } : {}) };
|
||||
}
|
||||
|
||||
/** The address a seat is used from, noted when it changes, and not more often than SEEN_EVERY_MS. */
|
||||
export function noteSeen(room: Room, name: PlayerId, addr: string): void {
|
||||
if (!personSeat(room, name) || room.addrs.get(name) === addr) return;
|
||||
const key = `${room.id}/${name}/${room.tokens.get(name) ?? ""}`;
|
||||
const now = Date.now();
|
||||
if ((seenAt.get(key) ?? 0) > now - SEEN_EVERY_MS) return;
|
||||
seenAt.set(key, now);
|
||||
room.addrs.set(name, addr);
|
||||
appendLine(room.id, { kind: "seen", name, addr, at: new Date(now).toISOString() });
|
||||
}
|
||||
|
||||
/** The tables not yet finished, written to lately, where a person of this name sits; one alone when it is named. Sleeping rooms with the name at them wake for it. */
|
||||
function tablesOf(name: PlayerId, only?: string): Room[] {
|
||||
const wanted = name.toLowerCase();
|
||||
const since = Date.now() - LOST_SEAT_LOOKBACK_MS;
|
||||
const ids = only ? [only.toUpperCase()] : listRoomIds().filter((id) => (ledgerStat(id)?.mtimeMs ?? 0) > since);
|
||||
const found: Room[] = [];
|
||||
for (const id of ids) {
|
||||
const stub = sleepingStubs.get(id);
|
||||
if (stub && (stub.base.finished || !stub.base.players.some((p) => p.toLowerCase() === wanted))) continue;
|
||||
const room = getRoom(id);
|
||||
if (!room || room.state?.phase === "finished") continue;
|
||||
if (room.players.some((p) => personSeat(room, p) && p.toLowerCase() === wanted)) found.push(room);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seats a name holds at tables not yet finished, for a browser that lost them. A seat last used from the
|
||||
* asker's own address, at a table where no other person shares that address, is theirs again with a new
|
||||
* token, the old one dead. The rest are only counted: a name proves nothing, so neither the tables nor the
|
||||
* company at them are told to the asker, who can get a phrase from the host, or a link from the keeper. A
|
||||
* seat the host gave to the clockwork is the host's to give back, by a phrase, never taken back by name.
|
||||
*/
|
||||
export function findSeats(name: PlayerId, addr: string, only?: string): { seated: { roomId: string; name: PlayerId; token: string; returned?: GameEvent }[]; elsewhere: number } {
|
||||
const seated: { roomId: string; name: PlayerId; token: string; returned?: GameEvent }[] = [];
|
||||
let elsewhere = 0;
|
||||
for (const room of tablesOf(name, only)) {
|
||||
const mine = room.players.find((p) => personSeat(room, p) && p.toLowerCase() === name.toLowerCase())!;
|
||||
const shared = room.players.some((p) => p !== mine && personSeat(room, p) && room.addrs.get(p) === addr);
|
||||
if (room.addrs.get(mine) === addr && !shared && !room.bots.get(mine)?.standIn) {
|
||||
const result = reseat(room, mine);
|
||||
if (!("error" in result)) seated.push({ roomId: room.id, name: mine, token: result.token, ...(result.returned ? { returned: result.returned } : {}) });
|
||||
} else elsewhere += 1;
|
||||
}
|
||||
return { seated, elsewhere };
|
||||
}
|
||||
|
||||
/** For the keeper: every table not yet finished where a person of this name sits, with the company there, to re-seat them by. */
|
||||
export function tablesForKeeper(name: PlayerId): { roomId: string; name: PlayerId; standIn: boolean; players: PlayerId[]; lastMoveAt: string | null }[] {
|
||||
return tablesOf(name).map((room) => {
|
||||
const mine = room.players.find((p) => personSeat(room, p) && p.toLowerCase() === name.toLowerCase())!;
|
||||
return { roomId: room.id, name: mine, standIn: !!room.bots.get(mine)?.standIn, players: room.players.filter((p) => p !== mine), lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null };
|
||||
});
|
||||
}
|
||||
|
||||
/** The person whose seat a token opens, at this table; nobody when it opens none. */
|
||||
export function seatOfToken(room: Room, token: string): PlayerId | null {
|
||||
for (const name of room.players) if (personSeat(room, name) && tokenMatches(room, name, token)) return name;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A seat answers to a new token; one the clockwork was standing in for
|
||||
* goes back to its person, and the chronicle says so. */
|
||||
function seatToPerson(room: Room, name: PlayerId, tokenHash: string): GameEvent | undefined {
|
||||
@@ -925,6 +996,7 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
galleryTalk: false,
|
||||
addrs: new Map(),
|
||||
touchedAt: Date.now(),
|
||||
...(meta.expected ? { expected: [...meta.expected] } : {}),
|
||||
...(meta.expansion !== undefined ? { expansion: meta.expansion } : {}),
|
||||
@@ -938,6 +1010,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||
room.colorChoices.delete(line.name);
|
||||
} else if (line.kind === "reseat") {
|
||||
if (personSeat(room, line.name)) seatToPerson(room, line.name, line.tokenHash);
|
||||
} else if (line.kind === "seen") {
|
||||
room.addrs.set(line.name, line.addr);
|
||||
} else if (line.kind === "standIn") {
|
||||
seatToClockwork(room, line.name, line.by, line.style as AutomatonStyle, line.tier as AutomatonTier);
|
||||
} else if (line.kind === "join") {
|
||||
|
||||
@@ -121,7 +121,16 @@ export interface AbandonLine {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | StandInLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
|
||||
/** The address a person's seat was last used from, as a salted hash, so a
|
||||
* seat a browser lost can go back to the same address. */
|
||||
export interface SeenLine {
|
||||
kind: "seen";
|
||||
name: string;
|
||||
addr: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | StandInLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine | SeenLine;
|
||||
|
||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||
|
||||
|
||||
@@ -152,3 +152,38 @@ describe("a player's own transfer phrase", () => {
|
||||
expect(claimed).toEqual({ roomId: room.id, name: "Bo", token: bo, reseated: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a seat a browser lost", () => {
|
||||
it("notes the address a seat is used from, once per change and not on its heels, and gives the seat back to that address by name", () => {
|
||||
const { room, bo } = playing();
|
||||
rooms.noteSeen(room, "Bo", "home");
|
||||
rooms.noteSeen(room, "Bo", "home");
|
||||
rooms.noteSeen(room, "Bo", "office");
|
||||
expect(room.addrs.get("Bo")).toBe("home");
|
||||
expect(rooms.findSeats("bo", "away", room.id)).toEqual({ seated: [], elsewhere: 1 });
|
||||
const found = rooms.findSeats("bo", "home", room.id);
|
||||
expect(found.elsewhere).toBe(0);
|
||||
expect(found.seated.map((s) => [s.roomId, s.name])).toEqual([[room.id, "Bo"]]);
|
||||
expect(rooms.seatOfToken(room, found.seated[0]!.token)).toBe("Bo");
|
||||
expect(rooms.seatOfToken(room, bo)).toBeNull();
|
||||
// Given a new token, the seat is noted afresh at once.
|
||||
rooms.noteSeen(room, "Bo", "office");
|
||||
expect(room.addrs.get("Bo")).toBe("office");
|
||||
});
|
||||
|
||||
it("only counts the table when another person at it shares the address, or the clockwork stands in, or the game is finished", () => {
|
||||
const { room, ann, bo } = playing();
|
||||
rooms.noteSeen(room, "Bo", "home");
|
||||
rooms.noteSeen(room, "Ann", "home");
|
||||
expect(rooms.findSeats("Bo", "home", room.id)).toEqual({ seated: [], elsewhere: 1 });
|
||||
expect(rooms.findSeats("Dee", "home", room.id)).toEqual({ seated: [], elsewhere: 0 });
|
||||
expect(rooms.seatOfToken(room, bo)).toBe("Bo");
|
||||
rooms.noteSeen(room, "Ann", "office");
|
||||
rooms.standIn(room, "Ann", "Bo");
|
||||
expect(rooms.findSeats("Bo", "home", room.id)).toEqual({ seated: [], elsewhere: 1 });
|
||||
expect(rooms.tablesForKeeper("bo").filter((t) => t.roomId === room.id)).toMatchObject([{ roomId: room.id, name: "Bo", standIn: true, players: ["Ann", "Cy"] }]);
|
||||
expect(rooms.seatOfToken(room, ann)).toBe("Ann");
|
||||
room.state!.phase = "finished";
|
||||
expect(rooms.findSeats("Cy", "home", room.id)).toEqual({ seated: [], elsewhere: 0 });
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,22 @@
|
||||
const hall = $derived(kin.hall);
|
||||
let joinCode = $state("");
|
||||
let claimPhrase = $state("");
|
||||
/** What a search for lost seats turned up, in words. */
|
||||
let seatsFound = $state("");
|
||||
let findingSeats = $state(false);
|
||||
/** Seats under this name at tables still in play: those last used from here are taken back; the rest are only counted. */
|
||||
async function findSeats(forName: string, roomId?: string) {
|
||||
findingSeats = true;
|
||||
seatsFound = "";
|
||||
const found = await net.findSeats(forName.trim(), roomId);
|
||||
findingSeats = false;
|
||||
if (!found) { seatsFound = "The search did not go through; try again in a moment."; return; }
|
||||
const back = found.seated.length ? `Seated again at ${found.seated.map((s) => s.roomId).join(", ")}.` : "";
|
||||
const n = found.elsewhere;
|
||||
const ask = n ? ` ${n === 1 ? "One more table" : `${n} more tables`} in play ${n === 1 ? "has" : "have"} a seat under your name, last used from another address, or given to the clockwork by the host: ask the host there for a transfer phrase, or write to the keeper.` : "";
|
||||
seatsFound = back + ask || "No table still in play has a seat under that name.";
|
||||
if (roomId && found.seated.some((s) => s.roomId === roomId)) seatsFound = "";
|
||||
}
|
||||
/** The host's seats panel: hand a seat to the clockwork, or make a transfer phrase for it. */
|
||||
let seatsOpen = $state(false);
|
||||
/** The seat the host asked to give the clockwork, waiting on a second click. */
|
||||
@@ -2768,6 +2784,8 @@
|
||||
Claim a transferred seat
|
||||
</button>
|
||||
</div>
|
||||
<p class="lost-seats">Lost your games? If this browser forgot them, <button class="linkish" disabled={!name.trim() || findingSeats} onclick={() => findSeats(name)}>find the seats under your name</button>.</p>
|
||||
{#if seatsFound}<p class="lost-seats">{seatsFound}</p>{/if}
|
||||
</details>
|
||||
|
||||
{#if net.seats.length > 0}
|
||||
@@ -3844,7 +3862,15 @@
|
||||
{#if net.spectating}
|
||||
<span class="gallery-note">👁 You watch from the Peanut Gallery, and every hand stays secret, even from you.</span>
|
||||
{#if view.phase !== "finished"}
|
||||
<span class="gallery-note">Had a seat at this table on another device? Choose <em>transfer seat</em> there, or ask {net.hostId ?? "the host"} for a phrase, and claim it here. Failing that, <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${net.roomId}`)}">write to the keeper</a> for a link that brings it back.</span>
|
||||
<span class="gallery-note">Had a seat at this table? Give your name, and if the seat was last used from here it is yours again. Otherwise choose <em>transfer seat</em> on the device that still holds it, or ask {net.hostId ?? "the host"} for a phrase, and claim it here. Failing that, <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${net.roomId}`)}">write to the keeper</a> for a link that brings it back.</span>
|
||||
<span class="claim-row gallery-claim">
|
||||
<input class="claim-input" bind:value={name} maxlength="20" placeholder="your name at this table" aria-label="your name at this table" />
|
||||
<button class="stamp tiny" disabled={!name.trim() || findingSeats}
|
||||
onclick={() => { if (net.roomId) void findSeats(name, net.roomId).then(() => { const mine = net.seats.find((s) => s.roomId === net.roomId && s.name === name.trim()); if (mine) net.resume(mine); }); }}>
|
||||
Sit back down
|
||||
</button>
|
||||
</span>
|
||||
{#if seatsFound}<span class="gallery-note">{seatsFound}</span>{/if}
|
||||
<span class="claim-row gallery-claim">
|
||||
<input class="claim-input" bind:value={claimPhrase}
|
||||
placeholder="ember-troll-dagger-moss" aria-label="seat transfer phrase" />
|
||||
@@ -4471,6 +4497,26 @@
|
||||
.seats-name { flex: 1 1 8rem; }
|
||||
.seats-note { font-weight: normal; font-style: italic; opacity: 0.8; margin-left: 0.4em; }
|
||||
.gallery-claim { margin-top: 0.4rem; }
|
||||
.lost-seats {
|
||||
font-size: 0.85rem;
|
||||
color: #8d8672;
|
||||
text-align: center;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
.linkish {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: #6b4f2a;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.linkish:disabled {
|
||||
color: #b3a687;
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
}
|
||||
.claim-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -369,6 +369,24 @@ function saveSeats(seats: Seat[]): void {
|
||||
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
|
||||
}
|
||||
|
||||
/** The server's answer as JSON, or null for a refusal or a lost connection. */
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* The seats this browser holds are banked with the server as well, in a cookie it sets and the browser keeps for
|
||||
* it alone. A browser whose storage is cleared, or that Safari clears for it after a week away, still has the
|
||||
* cookie, and gets its seats back from it. The answer is every seat the bank holds that still opens.
|
||||
*/
|
||||
function bankSeats(seats: Seat[], forget: string[] = []): Promise<{ seats: Seat[] } | null> {
|
||||
return postJson<{ seats: Seat[] }>("/api/seats", { seats: seats.map(({ roomId, token }) => ({ roomId, token })), forget });
|
||||
}
|
||||
|
||||
class Net {
|
||||
status = $state<"disconnected" | "connected">("disconnected");
|
||||
/** When the socket last spoke. A server restart can leave a half-dead
|
||||
@@ -788,6 +806,19 @@ class Net {
|
||||
refused();
|
||||
break;
|
||||
}
|
||||
// A name already seated at the table may be this person's own seat, lost with the browser's storage:
|
||||
// it is looked for by name and address before the refusal is shown.
|
||||
if (this.roomIdPending && this.you && /name is taken/.test(msg.message) && !this.token) {
|
||||
const roomId = this.roomIdPending;
|
||||
const name = this.you;
|
||||
this.roomIdPending = null;
|
||||
void this.findSeats(name, roomId).then((found) => {
|
||||
const mine = found?.seated.find((s) => s.roomId === roomId);
|
||||
if (mine) this.resume(mine);
|
||||
else this.flash(found?.elsewhere ? "A seat here is under that name, but it was last used from another address, or the host gave it to the clockwork. Ask the host for a transfer phrase, or write to the keeper." : msg.message);
|
||||
});
|
||||
break;
|
||||
}
|
||||
// "Name is taken" means THIS token was tried and refused: the
|
||||
// credential itself is dead, and holding it helps nobody. But
|
||||
// "no such room" proves nothing about the seat — a restarting
|
||||
@@ -860,19 +891,46 @@ class Net {
|
||||
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||
}
|
||||
|
||||
/** A seat taken is kept here and banked with the server, each time, so the two agree. */
|
||||
private rememberSeat(seat: Seat): void {
|
||||
const rest = this.seats.filter((s) => !(s.roomId === seat.roomId && s.name === seat.name));
|
||||
this.seats = [seat, ...rest];
|
||||
saveSeats(this.seats);
|
||||
void bankSeats($state.snapshot(this.seats));
|
||||
}
|
||||
|
||||
/** Drop a finished (or abandoned) game from the ledger. */
|
||||
forgetSeat(roomId: string): void {
|
||||
this.seats = this.seats.filter((s) => s.roomId !== roomId);
|
||||
saveSeats(this.seats);
|
||||
void bankSeats($state.snapshot(this.seats), [roomId]);
|
||||
this.games = this.games.filter((g) => g.roomId !== roomId);
|
||||
}
|
||||
|
||||
/** The seats the bank holds that this browser does not, taken into the wallet: a cleared storage has them again before it is missed. */
|
||||
async restoreSeats(): Promise<void> {
|
||||
const banked = await bankSeats($state.snapshot(this.seats));
|
||||
if (!banked) return;
|
||||
const missing = banked.seats.filter((b) => !this.seats.some((s) => s.roomId === b.roomId && s.name === b.name));
|
||||
if (missing.length === 0) return;
|
||||
this.seats = [...this.seats, ...missing];
|
||||
saveSeats(this.seats);
|
||||
this.refreshGames();
|
||||
this.refreshFeedback();
|
||||
}
|
||||
|
||||
/**
|
||||
* The seats under a name at tables not finished, for a browser that lost them: those last used from this
|
||||
* address come back seated, with new tokens; the rest are only counted. One table alone when it is named.
|
||||
*/
|
||||
async findSeats(name: string, roomId?: string): Promise<{ seated: Seat[]; elsewhere: number } | null> {
|
||||
const found = await postJson<{ seated: Seat[]; elsewhere: number }>("/api/seats/find", { name, ...(roomId ? { roomId } : {}) });
|
||||
if (!found) return null;
|
||||
for (const seat of found.seated) this.rememberSeat(seat);
|
||||
if (found.seated.length) this.refreshGames();
|
||||
return found;
|
||||
}
|
||||
|
||||
/** A surprise report: the server pins it to the room and move number. */
|
||||
/** A screenshot waiting for its report's id, then posted beside it. */
|
||||
private pendingImage: File | null = null;
|
||||
@@ -1168,3 +1226,4 @@ function describeCommand(c: Command): string {
|
||||
}
|
||||
|
||||
export const net = new Net();
|
||||
if (typeof localStorage !== "undefined") void net.restoreSeats();
|
||||
Reference in new issue
Block a user