The template's lost-seat search counts the tables it cannot give back rather than naming them, leaves a seat a bot stands in for to the host, and the keeper's door looks a name up across tables
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
dcb18b2b7e
commit
e3f8a03215
7 files changed
+69
-35
No files matched your search
@@ -23,7 +23,7 @@
|
||||
// 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 yet over: those last used from this
|
||||
// address are re-seated and returned; the rest named, to ask the host for a phrase
|
||||
// address are re-seated and returned; the rest only counted
|
||||
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round
|
||||
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
|
||||
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
|
||||
@@ -36,6 +36,7 @@
|
||||
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
|
||||
// (deploy/__SLUG__-reseat.sh sends it):
|
||||
//
|
||||
// GET /keeper/seats?name= every table not yet over where a person of that name sits, for a player 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
|
||||
// POST /keeper/rooms/:id/say {text} a word from the keeper in the table's talk
|
||||
@@ -291,8 +292,10 @@ function keeperKeyGiven(req: IncomingMessage): boolean {
|
||||
* 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);
|
||||
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[], url: URL): Promise<void> {
|
||||
if (req.headers['x-forwarded-for'] || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404);
|
||||
if (parts[1] === 'seats' && parts.length === 2 && req.method === 'GET') return send(res, 200, { tables: rooms.tablesForKeeper(url.searchParams.get('name') ?? '') });
|
||||
if (parts[1] !== 'rooms') 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') {
|
||||
@@ -312,7 +315,7 @@ async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: st
|
||||
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] === 'keeper') return handleKeeper(req, res, parts, url);
|
||||
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') {
|
||||
@@ -331,7 +334,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
if (!claims.allow(clientOf(req))) throw new RoomError('Too many searches from here just now; try again later.', 429);
|
||||
const found = rooms.findSeats(String(body.name ?? ''), addrOf(req), typeof body.roomId === 'string' ? body.roomId : undefined);
|
||||
// The seats given back are banked at once, so the cookie and the browser agree.
|
||||
bankSeats(req, res, provenBank(req, found.seated, []));
|
||||
if (found.seated.length) bankSeats(req, res, provenBank(req, found.seated, []));
|
||||
return send(res, 200, found);
|
||||
}
|
||||
throw new RoomError('Not here.', 404);
|
||||
|
||||
@@ -345,7 +345,7 @@ describe('a seat a browser lost', () => {
|
||||
const { rooms, room, bo } = table();
|
||||
rooms.seen(room, bo, 'home');
|
||||
const found = rooms.findSeats('bo', 'home');
|
||||
expect(found.elsewhere).toEqual([]);
|
||||
expect(found.elsewhere).toBe(0);
|
||||
expect(found.seated).toHaveLength(1);
|
||||
const { roomId, seat, token } = found.seated[0];
|
||||
expect([roomId, seat]).toEqual([room.id, 'B']);
|
||||
@@ -353,15 +353,22 @@ describe('a seat a browser lost', () => {
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||
});
|
||||
|
||||
it('only names the table when the address differs, or another person at it shares the address, or the name is nobody\'s', () => {
|
||||
it('only counts the table when the address differs, or another person at it shares the address, or a bot stands in', async () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.seen(room, bo, 'home');
|
||||
expect(rooms.findSeats('Bo', 'away')).toEqual({ seated: [], elsewhere: [{ roomId: room.id, seat: 'B', players: ['Ann'] }] });
|
||||
expect(rooms.findSeats('Bo', 'away')).toEqual({ seated: [], elsewhere: 1 });
|
||||
// Ann and Bo play from one address: a name alone cannot tell them apart, so neither is re-seated by it.
|
||||
rooms.seen(room, ann, 'home');
|
||||
expect(rooms.findSeats('Bo', 'home').seated).toEqual([]);
|
||||
expect(rooms.findSeats('Dee', 'home')).toEqual({ seated: [], elsewhere: [] });
|
||||
expect(rooms.findSeats('Bo', 'home')).toEqual({ seated: [], elsewhere: 1 });
|
||||
expect(rooms.findSeats('Dee', 'home')).toEqual({ seated: [], elsewhere: 0 });
|
||||
expect(rooms.seatOf(room, bo).id).toBe('B');
|
||||
// The host gave Bo's seat to a bot: it is the host's phrase that gives it back, not Bo's name.
|
||||
rooms.seen(room, ann, 'office');
|
||||
rooms.standIn(room, ann, 'B');
|
||||
await answered();
|
||||
expect(rooms.findSeats('Bo', 'home')).toEqual({ seated: [], elsewhere: 1 });
|
||||
expect(rooms.tablesForKeeper('bo')).toEqual([{ roomId: room.id, seat: 'B', standIn: true, players: ['Ann'], updatedAt: room.updatedAt }]);
|
||||
expect(rooms.tablesForKeeper('Dee')).toEqual([]);
|
||||
});
|
||||
|
||||
it('looks only at the table named when one is, and passes over a table that is over', () => {
|
||||
|
||||
@@ -337,17 +337,12 @@ export class Rooms<State, Input> {
|
||||
this.apply(room, line);
|
||||
}
|
||||
|
||||
/**
|
||||
* The seats a name holds at tables not yet over, 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 named, so the asker can get a phrase from the host there.
|
||||
*/
|
||||
findSeats(rawName: string, addr: string, only?: string): { seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: { roomId: string; seat: SeatId; players: string[] }[] } {
|
||||
const name = cleanName(rawName).toLowerCase();
|
||||
const seated: { roomId: string; seat: SeatId; token: string }[] = [];
|
||||
const elsewhere: { roomId: string; seat: SeatId; players: string[] }[] = [];
|
||||
/** The tables not yet over, written to lately, where a person of this name sits; one table alone when it is named. */
|
||||
private tablesOf(name: string, only?: string): { room: Room<State>; seat: Seat }[] {
|
||||
const wanted = cleanName(name).toLowerCase();
|
||||
const since = Date.now() - LOST_SEAT_LOOKBACK_MS;
|
||||
const ids = only ? [normalizeCode(String(only))] : this.store.roomIds().filter((id) => this.store.updatedAt(id) > since);
|
||||
const found: { room: Room<State>; seat: Seat }[] = [];
|
||||
for (const id of ids) {
|
||||
let room: Room<State>;
|
||||
try {
|
||||
@@ -356,18 +351,37 @@ export class Rooms<State, Input> {
|
||||
continue;
|
||||
}
|
||||
if (room.over) continue;
|
||||
const person = (s: Seat) => !s.bot || s.standIn;
|
||||
const mine = room.seats.find((s) => person(s) && s.name.toLowerCase() === name);
|
||||
if (!mine) continue;
|
||||
const shared = room.seats.some((s) => s !== mine && person(s) && s.addr === addr);
|
||||
if (mine.addr === addr && !shared) {
|
||||
const { token } = this.reseat(room, mine.id);
|
||||
seated.push({ roomId: room.id, seat: mine.id, token });
|
||||
} else elsewhere.push({ roomId: room.id, seat: mine.id, players: room.seats.filter((s) => s !== mine).map((s) => s.name) });
|
||||
const seat = room.seats.find((s) => (!s.bot || s.standIn) && s.name.toLowerCase() === wanted);
|
||||
if (seat) found.push({ room, seat });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seats a name holds at tables not yet over, 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 a bot is the host's to give back, by a phrase, never taken back by name.
|
||||
*/
|
||||
findSeats(rawName: string, addr: string, only?: string): { seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: number } {
|
||||
const seated: { roomId: string; seat: SeatId; token: string }[] = [];
|
||||
let elsewhere = 0;
|
||||
for (const { room, seat } of this.tablesOf(rawName, only)) {
|
||||
const shared = room.seats.some((s) => s !== seat && (!s.bot || s.standIn) && s.addr === addr);
|
||||
if (seat.addr === addr && !shared && !seat.standIn) {
|
||||
const { token } = this.reseat(room, seat.id);
|
||||
seated.push({ roomId: room.id, seat: seat.id, token });
|
||||
} else elsewhere += 1;
|
||||
}
|
||||
return { seated, elsewhere };
|
||||
}
|
||||
|
||||
/** For the keeper: every table not yet over where a person of this name sits, with the company there, to re-seat them by. */
|
||||
tablesForKeeper(name: string): { roomId: string; seat: SeatId; standIn: boolean; players: string[]; updatedAt: number }[] {
|
||||
return this.tablesOf(name).map(({ room, seat }) => ({ roomId: room.id, seat: seat.id, standIn: !!seat.standIn, players: room.seats.filter((s) => s !== seat).map((s) => s.name), updatedAt: room.updatedAt }));
|
||||
}
|
||||
|
||||
/** Claim a phrase: the seat and its token, once. Too many misses void every pending phrase. */
|
||||
claim(raw: unknown): { roomId: string; seat: SeatId; token: string } {
|
||||
const now = Date.now();
|
||||
|
||||
Reference in new issue
Block a user