The template banks a browser's seats in a cookie the server sets, and gives a lost seat 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, and a name given in the hall or at the table gets its seats back from the same address, with a new token; where the address differs, or two people at the table share it, the table is named so the host can mint a phrase. 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
6a9dc5a496
commit
dcb18b2b7e
7 files changed
+318
-8
No files matched your search
@@ -8,7 +8,7 @@
|
||||
import TallyBoard from './Tally.svelte';
|
||||
import Preferences from './Preferences.svelte';
|
||||
import { prefs, setPref } from '$lib/prefs.svelte';
|
||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
|
||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, restoreSeats, ServerError, trustSeat } from '$lib/net/client';
|
||||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||
import { unreadTalk } from '$lib/net/talk';
|
||||
import type { Report, RoomView, Tally } from '$lib/net/view';
|
||||
@@ -26,6 +26,14 @@
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let seats = $state(allSeats());
|
||||
// The bank is asked once on arrival: a browser whose storage was cleared has its seats again before it misses them.
|
||||
$effect(() => {
|
||||
void restoreSeats()
|
||||
.then((found) => {
|
||||
if (found) seats = allSeats();
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
let games = $state<Record<string, RoomView<State> | null>>({});
|
||||
let aboutOpen = $state(false);
|
||||
/** The ledgers' tally, fetched when the about panel opens. */
|
||||
@@ -112,6 +120,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
let finding = $state(false);
|
||||
/** What a search for lost seats turned up, in words. */
|
||||
let found = $state('');
|
||||
/** Seats under this name at tables still in play: those last used from here are taken back; the rest are named, to ask the host. */
|
||||
async function findSeats() {
|
||||
finding = true;
|
||||
error = '';
|
||||
found = '';
|
||||
try {
|
||||
rememberName(name);
|
||||
const r = await api.findSeats(name.trim());
|
||||
for (const s of r.seated) rememberSeat(s.roomId, { seat: s.seat, token: s.token });
|
||||
if (r.seated.length) seats = allSeats();
|
||||
const back = r.seated.length ? `Seated again at ${r.seated.map((s) => s.roomId).join(', ')}.` : '';
|
||||
const others = r.elsewhere.map((s) => `${s.roomId} with ${s.players.join(', ') || 'nobody yet'}`);
|
||||
const ask = others.length ? ` ${r.seated.length ? 'Also at' : 'At'} ${others.join('; ')}: that seat was last used from another address, so ask the host there for a transfer phrase, or write to the keeper.` : '';
|
||||
found = back + ask || 'No table still in play has a seat under that name.';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'The search did not go through.';
|
||||
} finally {
|
||||
finding = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The desk is asked for every seat this browser holds, again whenever that set changes.
|
||||
$effect(() => {
|
||||
const held = seats;
|
||||
@@ -244,6 +276,8 @@
|
||||
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||||
<button type="submit" class="quiet" disabled={claiming || phrase.trim().split(/[\s-]+/).length < 4}>Claim</button>
|
||||
</form>
|
||||
<p class="muted small lost">Lost your games? If this browser forgot them, <button type="button" class="link" disabled={!ready || finding} onclick={findSeats}>find the seats under your name</button>.</p>
|
||||
{#if found}<p class="muted small">{found}</p>{/if}
|
||||
{#if error}<p class="warning">{error}</p>{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,6 +42,29 @@ 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: SeatId; token: string }[]> {
|
||||
const seats = allSeats().map(({ roomId, token }) => ({ roomId, token }));
|
||||
const { seats: banked } = await call<{ seats: { roomId: string; seat: SeatId; 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +115,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 +123,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,
|
||||
@@ -152,6 +181,9 @@ export function makeApi<State, Input>() {
|
||||
hostPhrase: (roomId: string, token: string, seat: SeatId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }),
|
||||
standIn: (roomId: string, token: string, seat: SeatId) => call<View>('POST', `/api/rooms/${roomId}/standin`, { token, seat }),
|
||||
claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }),
|
||||
/** The seats a name holds at tables not over: those last used from this address come back seated; the rest are named. */
|
||||
findSeats: (name: string, roomId?: string) =>
|
||||
call<{ seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: { roomId: string; seat: SeatId; players: string[] }[] }>('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: (id: string, file: File) =>
|
||||
|
||||
Reference in new issue
Block a user