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 hall 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 hall or at the duel gets its seats back from the same address, with a new token; where the address differs, two people at the duel share it, or the host gave the seat to a bot, the duel is only counted, and the host's phrase or the keeper's link is the way back. The keeper's script finds a name across duels. 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:
Eric WagonerandClaude Fable 5.1 committed 2026-11-10 21:35:43 -05:00
1 parent b8f65a74d3
commit 29a68f4d9d
8 files changed
+443 -13

No files matched your search

+33 -1
View File
@@ -42,8 +42,32 @@ export function forgetSeat(roomId: string): void {
} catch {
// Nothing to forget without storage.
}
void bankSeats([roomId]).catch(() => {});
}
/**
* 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.
*/
export async function bankSeats(forget: string[] = []): Promise<{ roomId: string; seat: WizardId; token: string }[]> {
const seats = allSeats().map(({ roomId, token }) => ({ roomId, token }));
const { seats: banked } = await call<{ seats: { roomId: string; seat: WizardId; token: string }[] }>('POST', '/api/seats', { seats, forget });
return banked;
}
/** The seats the bank holds that this browser does not, taken into storage; true when there were any. */
export async function restoreSeats(): Promise<boolean> {
let found = false;
for (const s of await bankSeats()) {
if (seatFor(s.roomId)) continue;
store(s.roomId, { seat: s.seat, token: s.token });
found = true;
}
return found;
}
/**
* A seat the server refused is doubted, not dropped: a restarting server, a
* stale answer or the wrong backend all answer with ignorance, and ignorance
@@ -92,7 +116,7 @@ export function trustSeat(roomId: string): void {
}
}
export function rememberSeat(roomId: string, held: HeldSeat): void {
function store(roomId: string, held: HeldSeat): void {
try {
localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held }));
} catch {
@@ -100,6 +124,12 @@ export function rememberSeat(roomId: string, held: HeldSeat): void {
}
}
/** A seat taken is kept here and banked with the server, each time, so the two agree. */
export function rememberSeat(roomId: string, held: HeldSeat): void {
store(roomId, held);
void bankSeats().catch(() => {});
}
export class ServerError extends Error {
constructor(
message: string,
@@ -173,6 +203,8 @@ export const api = {
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 }),
/** The seats a name holds at duels not over: those last used from this address come back seated; the rest are only counted. */
findSeats: (name: string, roomId?: string) => call<{ seated: { roomId: string; seat: WizardId; token: string }[]; elsewhere: number }>('POST', '/api/seats/find', { name, ...(roomId ? { roomId } : {}) }),
report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
reportImage: async (id: string, file: File) => {