v0.8.0.12 — put a player back in their seat after losing their browser storage
Gitea#33. A session token is the only identity the game has, and it lives in exactly one place the player controls: their browser's localStorage, scoped to the origin they joined at. Lose it — a cleared profile, a private window, a different browser — and the seat is unreachable while the game runs on and the session sits intact on disk. Reported from the table: of two humans in one game the host reloaded straight back in, the joiner met an empty lobby. Diagnosed before it was fixed, and two server-side theories of mine were retracted on the evidence: no storage key changed in 0.8.0.11, nothing in the app deletes the secret or name, create and join both call persistSession, that game's sessions.json held both seats, and it resumed with 80 intents replayed. Both players used the same URL, so it was not a second origin either. The fix is a recovery link. An administrator mints a code for a named seat (admin-gated: deciding somebody lost a seat is a judgement no route can make); the player opens the link and the page trades the code for the token over a POST, then strips it from the address bar. The link never carries the token — lobby-and-sessions.md §1 says keep it out of URLs, and a recovery link is exactly what gets pasted into a chat. Single use, 30-minute expiry, held in memory because a restart dropping them is the right failure. server/claims.ts is a pure store, so single use, lazy expiry and one identical answer for unknown/spent/expired codes are tested rather than asserted. The admin game listing gained seatedPlayers — the seats a human holds a token for, read from the session map rather than guessed from player names — so the StartOS action can offer real players instead of bot chairs. No rule changed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is empty, so games in progress resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
This commit is contained in:
co-authored by
Claude Opus 5
parent
9a9e50b3c6
commit
7c9ef8797d
+58
-1
@@ -27,7 +27,7 @@ import type { PlayerIndex } from '../engine/state.ts';
|
||||
import type { PublicDistrict } from '../sim/view.ts';
|
||||
import { actorOnScreen, createStepQueue } from './step-queue.ts';
|
||||
import { PACE_LEVELS } from '../sim/pacing.ts';
|
||||
import { notice, prefillCode, runLobby } from './lobby.ts';
|
||||
import { notice, postJson, prefillCode, runLobby } from './lobby.ts';
|
||||
import type { LobbyReady } from './lobby.ts';
|
||||
import {
|
||||
closestPreset,
|
||||
@@ -1126,9 +1126,66 @@ function abandonRemote(): void {
|
||||
* into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the
|
||||
* common case and the only one a bare page load has ever needed a decision for), or the lobby.
|
||||
*/
|
||||
/**
|
||||
* A SEAT RECOVERY LINK — Gitea#33.
|
||||
*
|
||||
* The token is the only identity this game has, and it lives in one browser's `localStorage`. Lose
|
||||
* that and the seat is unreachable: nothing else on the server will accept a claim to it. This is the
|
||||
* supported way back — an administrator mints a short-lived, single-use code (`server/claims.ts`) and
|
||||
* the player opens a link carrying it.
|
||||
*
|
||||
* THE LINK CARRIES A CODE, NEVER THE TOKEN. `lobby-and-sessions.md` §1 says to keep the token out of
|
||||
* URLs so it is not shoulder-surfed or pasted into a chat — and a recovery link is precisely the sort
|
||||
* of thing that ends up in a chat. So the code is traded for the token here, over the connection the
|
||||
* page was going to open anyway, and is dead the moment it is spent.
|
||||
*
|
||||
* THE CODE IS STRIPPED FROM THE URL EITHER WAY, so a reload does not re-spend a code that is already
|
||||
* gone and the address bar stops carrying a credential-shaped string. `replaceState` rather than
|
||||
* assigning `location.search`, which everywhere else on this page means "navigate" — it reloads, and
|
||||
* reloading is exactly what must not happen to the session we have just been handed. Guarded like
|
||||
* `requestAnimationFrame` and `performance` are, because the static build is imported head-first by
|
||||
* `test/web.test.ts` against a DOM stub that provides neither.
|
||||
*/
|
||||
async function claimSeat(code: string): Promise<void> {
|
||||
showScreen('lobby');
|
||||
const { status, body } = await postJson('/api/claim', { code });
|
||||
if (typeof history !== 'undefined' && typeof history.replaceState === 'function') {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
if (status !== 200) {
|
||||
runLobby(lobbyHandlers);
|
||||
notice(
|
||||
'That restore link has already been used, or it has expired. Ask whoever runs the server for a ' +
|
||||
'fresh one — each link works once.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// `beginRemote` writes the seat into this browser's storage itself, which is the whole point of
|
||||
// the exercise: the next ordinary reload finds it and goes straight back into the game.
|
||||
beginRemote(
|
||||
{
|
||||
token: body['token'] as string,
|
||||
gameId: body['gameId'] as string,
|
||||
gameCode: (body['gameCode'] as string | undefined) ?? '',
|
||||
seat: body['player'] as PlayerIndex,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
/**
|
||||
* A RECOVERY LINK OUTRANKS EVERYTHING, including a game this browser already remembers: someone
|
||||
* arriving on one is being handed a seat deliberately, and that is never the load to second-guess.
|
||||
*/
|
||||
const claimCode = params.get('claim');
|
||||
if (claimCode !== null && claimCode !== '') {
|
||||
void claimSeat(claimCode);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* ASKING FOR THE LOBBY BEATS RESUMING A GAME.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user