A player who has lost their browser storage can be put back in their seat (Gitea#33 in the game repo). The session token is the only identity the game has and it lives in one browser's localStorage, scoped to the origin joined at, so a cleared profile, a private window or a different browser locks a player out of a game that is still running with their session still on disk. Seen at a real table: of two humans in one game, the host reloaded straight back in and the joiner met an empty lobby. New action, restore-seat: pick a seat from a dropdown of the players actually holding a session — bots never appear, since seatedPlayers is read from the server's session map rather than guessed from display names — and get back a link built from this interface's own address. serverApi gained mintClaim; the dictionary gained 57-64 in all five locales. THE LINK CARRIES A CODE, NOT 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 exactly what gets pasted into a chat. The code is single-use, expires in 30 minutes, and the page trades it for the real token over a POST. Minting is admin-gated because deciding that somebody has lost a seat is a judgement no route can make safely; spending needs no secret, because the player following the link holds none. GAMES IN PROGRESS RESUME NORMALLY, measured rather than assumed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is EMPTY. The release is the server's HTTP surface, the client and the docs. The codes live in memory and are deliberately not persisted, so nothing new reaches the volume and there is nothing to migrate. README, instructions.md and the release notes in all five locales say so. NOT YET EXERCISED END TO END: the two routes and the action are typechecked and the claim store is unit-tested, but nothing has run them against a live server. This build is what makes that possible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
221 lines
18 KiB
TypeScript
221 lines
18 KiB
TypeScript
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||
|
||
export const current = VersionInfo.of({
|
||
// `<upstream>:<downstream>` — the upstream half tracks the bundled Station Master tag exactly
|
||
// (`versions.md`'s consistency checklist), so bumping the submodule pin and bumping this are one
|
||
// action. The downstream half moves only for packaging-only changes, and RESETS to `:0` when the
|
||
// upstream half moves.
|
||
//
|
||
// FOUR COMPONENTS IS FINE. ExVer's upstream half is an arbitrary-length dotted number, not semver
|
||
// — checked against the SDK's own parser rather than assumed, since every example in `versions.md`
|
||
// has three: `0.7.9.8:0` parses to upstream `[0,7,9,8]`, which sorts above `[0,7,9]`, so a box on
|
||
// 0.7.9:0 sees this as an update rather than refusing it as a downgrade.
|
||
version: '0.8.0.12:0',
|
||
releaseNotes: {
|
||
en_US:
|
||
'Bundles Station Master v0.8.0.12 — a player who has lost their browser storage can be put ' +
|
||
'back in their seat. **Games already running on this server resume normally**: no rule ' +
|
||
'changed. **The problem this fixes.** The only proof of who you are is kept by your own ' +
|
||
'browser, so clearing your browsing data, returning in a private window, or sitting down at a ' +
|
||
'different browser leaves you looking like a stranger — an empty lobby, no game code, no name ' +
|
||
'— while the game is still running on the server and your seat is still yours. It happened at ' +
|
||
'a real table: of two players in one game, one reloaded straight back in and the other could ' +
|
||
'not get in at all. **The fix is a new Restore a Seat action.** Pick the player from a list of ' +
|
||
'whoever is actually holding a seat, and it hands you a link. Send it to them, and opening it ' +
|
||
'in their own browser puts them straight back where they were. The link works once and expires ' +
|
||
'after 30 minutes, and whoever opens it takes that seat — so send it to that person rather ' +
|
||
'than posting it somewhere public. If it expires first, just run the action again. The link ' +
|
||
'carries a one-time code and never the seat itself, so a link left in a chat window is worth ' +
|
||
'nothing once it has been used.',
|
||
es_ES:
|
||
'Incluye Station Master v0.8.0.12 — un jugador que haya perdido los datos de su navegador ' +
|
||
'puede volver a su asiento. **Las partidas en curso en este servidor se reanudan con ' +
|
||
'normalidad**: no ha cambiado ninguna regla. **El problema que corrige.** La única prueba de ' +
|
||
'quién eres la guarda tu propio navegador: si borras los datos de navegación, vuelves en una ' +
|
||
'ventana privada o te sientas ante otro navegador, apareces como un desconocido — una sala ' +
|
||
'vacía, sin código de partida ni nombre — aunque la partida siga en el servidor y el asiento ' +
|
||
'siga siendo tuyo. Ocurrió en una mesa real: de dos jugadores, uno volvió sin problemas y el ' +
|
||
'otro no pudo entrar. **La solución es la nueva acción Restaurar un asiento.** Elige al ' +
|
||
'jugador de la lista de quienes ocupan un asiento y se te entrega un enlace. Envíaselo y, al ' +
|
||
'abrirlo en su propio navegador, volverá justo donde estaba. El enlace funciona una sola vez y ' +
|
||
'caduca a los 30 minutos, y quien lo abra ocupará ese asiento: envíaselo a esa persona en ' +
|
||
'lugar de publicarlo. Si caduca antes, ejecuta la acción de nuevo. El enlace lleva un código ' +
|
||
'de un solo uso y nunca el asiento en sí, así que un enlace olvidado en un chat no sirve de ' +
|
||
'nada una vez usado.',
|
||
de_DE:
|
||
'Enthält Station Master v0.8.0.12 — wer die Daten seines Browsers verloren hat, kann wieder ' +
|
||
'auf seinen Platz gesetzt werden. **Laufende Spiele auf diesem Server werden normal ' +
|
||
'fortgesetzt**: keine Regel wurde geändert. **Das behobene Problem.** Der einzige Nachweis, ' +
|
||
'wer Sie sind, liegt in Ihrem eigenen Browser. Browserdaten gelöscht, im privaten Fenster ' +
|
||
'zurückgekehrt oder an einem anderen Browser gesessen — und Sie erscheinen als Fremder: leere ' +
|
||
'Lobby, kein Spielcode, kein Name, obwohl das Spiel weiterläuft und der Platz Ihnen gehört. An ' +
|
||
'einem echten Tisch geschehen: von zwei Spielern kam einer sofort zurück, der andere gar ' +
|
||
'nicht. **Die Lösung ist die neue Aktion „Einen Platz wiederherstellen".** Wählen Sie den ' +
|
||
'Spieler aus der Liste der tatsächlich besetzten Plätze, und Sie erhalten einen Link. ' +
|
||
'Schicken Sie ihn; wird er im eigenen Browser geöffnet, sitzt der Spieler wieder genau dort, ' +
|
||
'wo er war. Der Link funktioniert einmal und verfällt nach 30 Minuten, und wer ihn öffnet, ' +
|
||
'nimmt diesen Platz ein — schicken Sie ihn also dieser Person und posten Sie ihn nicht ' +
|
||
'öffentlich. Verfällt er vorher, führen Sie die Aktion einfach erneut aus. Der Link enthält ' +
|
||
'einen Einmal-Code und niemals den Platz selbst; ein Link in einem Chatfenster ist nach dem ' +
|
||
'Einlösen wertlos.',
|
||
pl_PL:
|
||
'Zawiera Station Master v0.8.0.12 — gracz, który stracił dane swojej przeglądarki, może ' +
|
||
'wrócić na swoje miejsce. **Gry w toku na tym serwerze wznawiają się normalnie**: żadna zasada ' +
|
||
'się nie zmieniła. **Naprawiony problem.** Jedynym dowodem tożsamości jest to, co trzyma twoja ' +
|
||
'przeglądarka. Wyczyść dane, wróć w oknie prywatnym albo usiądź przy innej przeglądarce, a ' +
|
||
'wyglądasz jak ktoś obcy — pusta poczekalnia, brak kodu gry i nazwy — chociaż gra nadal działa ' +
|
||
'na serwerze, a miejsce wciąż należy do ciebie. Zdarzyło się to przy prawdziwym stole: z ' +
|
||
'dwóch graczy jeden wrócił od razu, a drugi w ogóle nie mógł wejść. **Rozwiązaniem jest nowa ' +
|
||
'akcja „Przywróć miejsce".** Wybierz gracza z listy osób faktycznie zajmujących miejsca, a ' +
|
||
'otrzymasz link. Wyślij mu go — po otwarciu we własnej przeglądarce wróci dokładnie tam, gdzie ' +
|
||
'był. Link działa raz i wygasa po 30 minutach, a kto go otworzy, zajmie to miejsce — wyślij ' +
|
||
'go więc tej osobie, zamiast publikować. Jeśli wygaśnie, po prostu uruchom akcję ponownie. ' +
|
||
'Link zawiera jednorazowy kod, a nigdy samo miejsce, więc link zostawiony na czacie po ' +
|
||
'wykorzystaniu jest bezwartościowy.',
|
||
fr_FR:
|
||
'Inclut Station Master v0.8.0.12 — un joueur qui a perdu les données de son navigateur peut ' +
|
||
'être remis à sa place. **Les parties en cours sur ce serveur reprennent normalement** : ' +
|
||
'aucune règle n’a changé. **Le problème corrigé.** La seule preuve de votre identité est ' +
|
||
'conservée par votre propre navigateur : effacez vos données, revenez dans une fenêtre privée ' +
|
||
'ou installez-vous devant un autre navigateur, et vous apparaissez comme un inconnu — salle ' +
|
||
'vide, ni code de partie ni nom — alors que la partie tourne toujours et que la place est ' +
|
||
'toujours la vôtre. C’est arrivé à une vraie table : de deux joueurs, l’un est revenu ' +
|
||
'directement, l’autre n’a pas pu entrer. **La solution est la nouvelle action « Restaurer une ' +
|
||
'place ».** Choisissez le joueur dans la liste de ceux qui occupent réellement une place, et ' +
|
||
'vous obtenez un lien. Envoyez-le-lui : ouvert dans son propre navigateur, il le remet ' +
|
||
'exactement où il était. Le lien fonctionne une seule fois et expire au bout de 30 minutes, et ' +
|
||
'quiconque l’ouvre prend cette place — envoyez-le donc à cette personne plutôt que de le ' +
|
||
'publier. S’il expire avant, relancez simplement l’action. Le lien porte un code à usage ' +
|
||
'unique et jamais la place elle-même : un lien laissé dans une conversation ne vaut plus rien ' +
|
||
'une fois utilisé.',
|
||
},
|
||
migrations: {
|
||
/**
|
||
* NOTHING TO CARRY, AND CHECKED THE SAME NARROW WAY v0.7.9.8's ENTRY WAS.
|
||
*
|
||
* The rule this package lives by is in the game's README: a save is a seed plus the moves
|
||
* played, replayed through the CURRENT rules, so the only thing that strands an older save is a
|
||
* change that makes a once-legal move illegal. So the question is never "how big was the
|
||
* release" — v0.8.0 is a large one — but "did any predicate change its answer".
|
||
*
|
||
* Measured with `git diff v0.7.9.8..v0.8.0.1 -- src/engine/`. The WHOLE engine diff is eight
|
||
* lines:
|
||
* four `GameEvent` types (`trayMoved`, `carsCoupled`, `carsDropped`, `consistSorted`) gained a
|
||
* `player` field, and the four places that build them pass it. `check()` is untouched, `legal.ts`
|
||
* and `advance.ts` are untouched, and no predicate anywhere was edited. Games in progress resume,
|
||
* and the release notes say so in all five locales rather than leaving it to be discovered.
|
||
*
|
||
* EVENTS ARE NOT PERSISTED, so widening four of them cannot invalidate anything on disk: a
|
||
* `SavedGame` is `{ seed, config, history: Intent[], … }` and the events are derived by replaying
|
||
* it. The narration TEXT of two lines changed with them ("Moved Train 3 …" where it used to read
|
||
* "CREW moved …"), which a resumed game simply regenerates — the log is not stored either.
|
||
*
|
||
* WHAT IS NOT COVERED BY THAT ARGUMENT, and why it still does not need a migration: the wire
|
||
* between this server and a browser gained a lot. `Push` now carries ordered presentation steps
|
||
* and a public-board baseline, and the board deltas changed shape. None of it is on disk, and a
|
||
* browser reloads onto the new page as part of the update, so there is no version skew to
|
||
* migrate — the same reasoning v0.7.9.8 used for its own `Frame` changes.
|
||
*
|
||
* v0.8.0.1 ADDED NOTHING TO THIS ARGUMENT: `git diff v0.8.0..v0.8.0.1 -- src/engine/` is empty.
|
||
* It changed `sim/pacing.ts`, `web/step-queue.ts` and `web/main.ts` — how long a step is shown
|
||
* and when the queue calls itself idle — which is presentation and touches neither the rules nor
|
||
* anything on disk.
|
||
*
|
||
* v0.8.0.2 DOES TOUCH THE ENGINE, and it is worth being exact about why that is still nothing to
|
||
* migrate. `git diff v0.8.0.1..v0.8.0.2 -- src/engine/` is three added lines: `collisionsPrevDay`
|
||
* declared on `GameState`, initialised to 0 in `setup.ts`, and assigned at the Day rollover in
|
||
* `advance.ts`. It is a counter that nothing checks — no predicate reads it, so no once-legal move
|
||
* became illegal.
|
||
*
|
||
* v0.8.0.3 THROUGH v0.8.0.7 ADDED NOTHING: `git diff v0.8.0.2..v0.8.0.7 -- src/engine/` is empty.
|
||
* One moved the Skip button, changed what the waiting row says, and stopped the speed control
|
||
* stretching the clock; one took a hostname out of comments; the last drew the Home Office deck
|
||
* and lit the pile a watched move touched, which is a diff of two frames the client already
|
||
* holds. None of it is a rule, and none of it is on disk.
|
||
*
|
||
* v0.8.0.8 IS DIFFERENT AND IS THE ONE TO READ CAREFULLY: it changes a RULE (Gitea#23). When the
|
||
* Home Office deck is reshuffled, a timetabled train sitting in the SALVAGE YARD is no longer
|
||
* recovered — it was played, its number is on the timetable, and it cannot be run twice. The same
|
||
* card in a DEPARTMENT was discarded rather than played and still comes back, as does any Extra.
|
||
* A duplicate went with it: `trainScheduled` had been pushing a synthetic `train-<n>` beside the
|
||
* real card, so four scheduled trains left eight entries in a pile of four.
|
||
*
|
||
* GAMES IN PROGRESS STILL RESUME, and the reason is the narrow one this package always asks
|
||
* (README § Design notes): a save is a seed plus the moves played, replayed through the CURRENT
|
||
* rules, so what strands one is a change that makes a once-legal move ILLEGAL. Nothing here does.
|
||
* `check()` is untouched; a draw is a draw whatever lies on top of the deck. What differs is the
|
||
* Salvage Yard's depth, which was double-counting, and which cards a reshuffle returns — and a
|
||
* reshuffle is effectively unreachable in ordinary play: eight games driven to 4000 moves across
|
||
* eight seeds produced ZERO. A game that had somehow reshuffled would replay to a different deck
|
||
* order rather than refusing to replay, which is worth knowing and is not the same as breaking.
|
||
*
|
||
* v0.8.0.9 TOUCHES THE ENGINE FOR SPEED, AND PROVED IT CHANGED NOTHING. `git diff v0.8.0.8..v0.8.0.9
|
||
* -- src/engine/` adds a route cache scoped to one unchanged position (`withRouteCache`), splits
|
||
* `applyIntent` into `prepareIntent` + `commitEvents`, trims allocation in `exploreMoves`, adds
|
||
* `legalSwitchingActions`, and exports `badlyMadeUp`, `protoCard` and `extendLimitsIfNeeded` for the
|
||
* bot. No predicate's answer changed: every event and intent of 32 seeded games hashed identical before
|
||
* and after each step, and the game's `test/route-cache.test.ts` pins the split. Everything else is the
|
||
* developer bot, whose past moves are already intents in a save. Games in progress resume.
|
||
*
|
||
* v0.8.0.10 IS THE FIRST BUMP THAT BREAKS RESUMES, AND IT DOES SO DELIBERATELY. It corrects WHEN §8.1
|
||
* asks the Superintendent to rule (Gitea#26): a train on a card BEHIND the one departing is nobody's
|
||
* concern, and was being counted. A save is the seed and the moves replayed through the CURRENT rules,
|
||
* so a game holding a `mainline.clearance` the rules no longer ask for is refused at that move with
|
||
* NO_PENDING_DECISION — `tryResumeSession` reports which move, and `index.ts` logs the refusal and
|
||
* leaves the file alone. Measured before shipping: 40 four-seat co-op games recorded by bots under
|
||
* v0.8.0.9 and replayed under this release — 28 stop, usually 5-40% of the way in. Jesse's call
|
||
* (2026-09-15): no games in progress need keeping, so this ships without a per-game switch for the old
|
||
* rule, and the release notes say so in all five locales. A game worth finishing can be finished by
|
||
* putting v0.8.0.9 back — the save file is untouched.
|
||
*
|
||
* The rest of the release is the screen and the log — who the board says it is waiting on, what the
|
||
* history says and when it says it, a flash on a realigned Mainline card — plus one new read-only
|
||
* route (`GET /api/save?token=…`) that hands a seated player their own save. None of it is on disk.
|
||
*
|
||
* A NEW STATE FIELD CANNOT STRAND A SAVE HERE, which is the property worth naming rather than
|
||
* re-deriving: a `SavedGame` holds `{ seed, config, history }` and NOT a serialised `GameState`.
|
||
* Resuming calls `createGame` — which fills the new field from `setup.ts` — and then replays the
|
||
* intents through the current rules, so the field is populated on the way past. There is no old
|
||
* shape on disk for a new field to be missing from.
|
||
*
|
||
* THE ONE THING THAT WOULD HAVE NEEDED CARE, had it been built the way the plan first described:
|
||
* the display-step collector runs inside `submit()`, and the resume path (`fromMultiplayerSave`)
|
||
* deliberately does not go through `submit()`. So replaying a saved game emits no steps and burns
|
||
* no sequence numbers. A test in the game pins that, because it is a property rather than an
|
||
* accident.
|
||
*
|
||
* v0.8.0.11 RESUMES, AND IT WAS MEASURED THE SAME NARROW WAY — which matters more than usual
|
||
* here, because the release immediately before it is the one that did NOT.
|
||
* `git diff v0.8.0.10..v0.8.0.11 -- src/engine/` is three files and nineteen insertions:
|
||
* `trainArrived` gains an `owner` field (`events.ts`), the one line in `advance.ts` that fills
|
||
* it from `playerAtSeat`, and a Mainline card's printed description losing a sentence about a
|
||
* speed that is not drawn on the card (`content.ts`). `check()` and `legal.ts` are untouched and
|
||
* no predicate was edited, so nothing once legal became illegal. Events are not persisted — a
|
||
* `SavedGame` is `{ seed, config, history }` and the events are derived by replaying it — so
|
||
* widening one of them cannot invalidate anything on disk, exactly as the v0.8.0.1 entry above
|
||
* argues for four of them.
|
||
*
|
||
* THE REST OF THE RELEASE IS THE SCREEN: a Pause control for the catch-up queue, a one-render
|
||
* look at another player's Office Area, the turn chart following the step being replayed rather
|
||
* than the live game, "still needs" on the make-up panel, the amber on an addable yard chip, and
|
||
* save filenames carrying the join code. None of it is a rule and none of it is on disk.
|
||
*
|
||
* v0.8.0.12 TOUCHES THE ENGINE NOT AT ALL, which is the shortest entry this comment has ever
|
||
* had a right to: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is EMPTY. The release is the
|
||
* server's HTTP surface, the browser client and the docs — a seat recovery flow (Gitea#33) for
|
||
* a player whose browser has lost the token that is their only identity.
|
||
*
|
||
* WHAT IT ADDS, AND WHY NONE OF IT IS A MIGRATION: two routes (`POST /api/games/<id>/claim`
|
||
* behind the admin secret, `POST /api/claim` for the player following the link), a `?claim=`
|
||
* entry path in the client, and `seatedPlayers` on the administrative game listing. The codes
|
||
* themselves live in memory and are deliberately NOT persisted — minted on demand and spent
|
||
* within minutes, so a restart dropping them is the right failure rather than a loss. Nothing
|
||
* new is written to the volume, no predicate was edited, and `check()` and `legal.ts` are
|
||
* untouched, so no once-legal move became illegal. Games in progress resume, and the release
|
||
* notes say so in all five locales.
|
||
*/
|
||
up: async ({ effects }) => {},
|
||
down: IMPOSSIBLE,
|
||
},
|
||
})
|