Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad277fb994 | ||
|
|
7c9ef8797d |
+144
@@ -19,6 +19,150 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.8.0.13 — 2026-09-16
|
||||||
|
|
||||||
|
Nine reports from the Day 1–2 playtest of v0.8.0.12. One was a real bug that cost a car, one was a
|
||||||
|
rule working correctly with nothing on screen to say so, and the rest are things the table could not
|
||||||
|
see.
|
||||||
|
|
||||||
|
### The Division Yard no longer takes a click while your board is behind
|
||||||
|
|
||||||
|
The worst of the batch, because it moved a car. `renderActions` puts the action list away while the
|
||||||
|
queue is catching up — a move offered against a position that has already moved on is a move made
|
||||||
|
blind — but the make-up wiring sat OUTSIDE that guard, so the yard chips stayed lit and clickable.
|
||||||
|
Reported exactly as it happens: a bot was adding the last car, the board lagged, a chip was clicked,
|
||||||
|
a coach left the Division Yard and the train still ended up with three cars. The click had submitted
|
||||||
|
a real intent against a board that was several moves stale.
|
||||||
|
|
||||||
|
Both halves are fixed. The chips are wired only when the queue is idle, and the yard COUNTS are now
|
||||||
|
drawn from the board on screen rather than the live game — they were the one panel still reporting a
|
||||||
|
future the player had not been shown, which is what "the yards may not be in sync with the turns
|
||||||
|
behind" was describing.
|
||||||
|
|
||||||
|
### Every seat has a button, in map order
|
||||||
|
|
||||||
|
The Office Area picker had a button per opponent and none for yourself, so the one player who could
|
||||||
|
not reach their own district was the player waiting on everybody else — watching the board follow
|
||||||
|
whoever was acting, with no way back. Your own seat is now in the row, and `watchedDistrict` returns
|
||||||
|
your board for it rather than falling through to the actor, which is the same bug seen from the other
|
||||||
|
side.
|
||||||
|
|
||||||
|
The buttons are ordered by SEAT — west to east, exactly as the Division map draws it — instead of by
|
||||||
|
player index, which is the order people joined. Seat is not player index and must not be assumed to
|
||||||
|
be: under Employee Rotation the seating moves, and because the row is sorted from the Frame's own
|
||||||
|
`seat` on every render, the buttons rotate with the players rather than having to be told.
|
||||||
|
|
||||||
|
### The Fedora passing is said out loud
|
||||||
|
|
||||||
|
§5 moves the Superintendent at the end of Stages 3, 6, 9 and 12, and the log never mentioned it. The
|
||||||
|
rotation was riding on `actorChanged` — turn bookkeeping, fired every time the cursor moves, which
|
||||||
|
`record()` drops as noise — so the one moment that event carried something a player needed went past
|
||||||
|
in silence, with only "Supervisor Shift" in the history to hint at it.
|
||||||
|
|
||||||
|
It is its own event now (`superintendentChanged`), narrated in the log and announced on screen the
|
||||||
|
way a completed run already is. The phase keeps its name: the Supervisor Shift refreshes every
|
||||||
|
Laborer and Porter EVERY Stage, and the Fedora moves only every third — naming the phase after the
|
||||||
|
rarer event would mislead about the common one.
|
||||||
|
|
||||||
|
### A collision says whose Office it was and who paid for it
|
||||||
|
|
||||||
|
The line named the wreck and the reason and stopped there: the 5-point penalty rides in a separate
|
||||||
|
`revenueChanged`, so a player had to add two log entries together to learn who had just lost five
|
||||||
|
Revenue, and "COLLISION at the Office" never said whose. The faulting seat is in the event — and for
|
||||||
|
anything inside a district that seat IS the district's owner — so the line now reads "COLLISION at
|
||||||
|
Tom's Office … Tom loses 5 Revenue — it happened in their district." A Mainline collision is phrased
|
||||||
|
differently because §10 makes it the Superintendent's, which is a different kind of fault.
|
||||||
|
|
||||||
|
### Passengers: the rule stands, the silence goes
|
||||||
|
|
||||||
|
Reported as a bug and it is not one, which took a replay of the save to establish. The Depot in
|
||||||
|
question had a Restaurant and a Hotel beside it and `cap{out:3}` — capacity was never the problem,
|
||||||
|
and the modifiers grant exactly what they print. What stopped a second passenger was §6.3: stocking
|
||||||
|
takes a LOADED car of the facility's type out of the Division Yard, and there was not a loaded coach
|
||||||
|
in it. Six were sitting in the Classification Yard, which §2.2 returns only when the Division Yard
|
||||||
|
runs bare, and it was holding sixty-odd cars.
|
||||||
|
|
||||||
|
Jesse's ruling is the same one Gitea#2 got: the shortage stays, because running out is part of the
|
||||||
|
game. So the blocked panel now says it — room for N more passengers, no loaded coach in the Division
|
||||||
|
Yard, and how many are waiting in Classification — instead of the action simply being absent from the
|
||||||
|
menu with no reason given.
|
||||||
|
|
||||||
|
### Smaller
|
||||||
|
|
||||||
|
"Working left" is now "working eastward" in the make-up panel and the New Train tip. It was always the
|
||||||
|
same rule — `playerLeftOf` is increasing seat index — but "left" describes a table nobody is looking
|
||||||
|
at, while the map runs west to east, so at a real three-player game the second car went to the player
|
||||||
|
sitting EAST and the text read as wrong. The history panel keeps 90 lines instead of 60, in the same
|
||||||
|
230px box: more to scroll back through, no more screen taken, and the newest line stays where the eye
|
||||||
|
already is.
|
||||||
|
|
||||||
|
### Note for the packaging repo
|
||||||
|
|
||||||
|
`git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is NOT empty this time: `events.ts` declares
|
||||||
|
`superintendentChanged` and `advance.ts` emits it. Both are additive — `check()`, `legal.ts` and
|
||||||
|
every predicate are untouched, and events are derived by replaying a save rather than stored — so no
|
||||||
|
once-legal move became illegal and games in progress resume.
|
||||||
|
|
||||||
|
## 0.8.0.12 — 2026-09-16
|
||||||
|
|
||||||
|
A player who has lost their browser storage can be put back in their seat (Gitea#33). No rule
|
||||||
|
changed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is empty, so games in progress resume.
|
||||||
|
|
||||||
|
### The failure this fixes, and the four things it was not
|
||||||
|
|
||||||
|
Reported from the table after the 0.8.0.11 update: of two humans in one game, the host reloaded
|
||||||
|
straight back into it and the player who had JOINED found an empty lobby — no join secret, no display
|
||||||
|
name, no game code. Their seat was never lost. `sessions.json` for that game held both seats, and the
|
||||||
|
server logged it resuming with 80 intents replayed.
|
||||||
|
|
||||||
|
Four explanations were ruled out with evidence before any code was written, and two of them were
|
||||||
|
theories of mine that had to be retracted:
|
||||||
|
|
||||||
|
- **Not the update.** `git diff v0.8.0.10..v0.8.0.11 -- src/web/` contains no storage change at all;
|
||||||
|
both tags declare identical `SECRET_KEY` and `NAME_KEY`.
|
||||||
|
- **Not a create-vs-join asymmetry in the client.** `lb-secret` and `lb-name` sit above both doors in
|
||||||
|
`play.html`, so a joiner writes the same three keys a host does.
|
||||||
|
- **Not the server forgetting joiners.** Create calls `persistSession` and so does join; it writes
|
||||||
|
every session for the game. Two-seat session files plainly work.
|
||||||
|
- **Not a second origin.** Both players used the identical URL.
|
||||||
|
|
||||||
|
What is left is the thing §1 has always said: the token lives in one browser's `localStorage`, scoped
|
||||||
|
to the origin. A cleared profile, a private window or a different browser ends the seat while the game
|
||||||
|
runs on without it. Nothing in the client can detect that — origin isolation is the point — and
|
||||||
|
nothing in it can repair it either.
|
||||||
|
|
||||||
|
### A recovery link carries a code, never the token
|
||||||
|
|
||||||
|
`lobby-and-sessions.md` §1: *"Keep it out of URLs so it is not shoulder-surfed or pasted into a
|
||||||
|
chat."* A recovery link is precisely what gets pasted into a chat, so the URL carries a **single-use
|
||||||
|
code that expires in 30 minutes** and the page trades it for the real token over a POST, then strips
|
||||||
|
it from the address bar. A spent code is worth nothing; a token in a chat log is the seat for the rest
|
||||||
|
of the game.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/games/<id>/claim { player } → { code, expiresAt, … } admin secret
|
||||||
|
POST /api/claim { code } → { token, gameId, player, gameCode }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Minting is administrative; spending is not.** Deciding that a particular person has lost a
|
||||||
|
particular seat is a judgement no route can make safely — anyone able to mint their own code could
|
||||||
|
take any chair at the table. Spending needs no secret because the player following the link is the one
|
||||||
|
person in the story who holds none: the code *is* the authorisation, unguessable and one-time, which
|
||||||
|
is the same shape as the token it returns.
|
||||||
|
|
||||||
|
`server/claims.ts` is a pure store — no clock, no sockets, no disk — so its rules are actually tested
|
||||||
|
rather than asserted: single use, lazy expiry, and one identical answer for unknown, spent and expired
|
||||||
|
codes so it cannot be probed. The codes are held in memory on purpose. They are minted on demand and
|
||||||
|
spent within minutes with the administrator present, so a restart dropping them is the right failure;
|
||||||
|
persisting them would put a credential-equivalent on disk to solve a problem measured in seconds.
|
||||||
|
|
||||||
|
### The administrator picks a seat, not a string
|
||||||
|
|
||||||
|
The admin game listing now reports `seatedPlayers` — the seats a HUMAN holds a token for, read from
|
||||||
|
the server's session map rather than guessed by matching "Bot 1" against a display name. That is what
|
||||||
|
lets the StartOS side offer real players to choose from instead of chairs no token was ever issued
|
||||||
|
for.
|
||||||
|
|
||||||
## 0.8.0.11 — 2026-09-16
|
## 0.8.0.11 — 2026-09-16
|
||||||
|
|
||||||
Fourteen reports from the second multiplayer playtest of v0.8.0.10, the WHISTLE-6945 table. Eleven are
|
Fourteen reports from the second multiplayer playtest of v0.8.0.10, the WHISTLE-6945 table. Eleven are
|
||||||
|
|||||||
@@ -36,6 +36,34 @@ and `<ip>:<port>` are both expected — and browser storage is scoped to the ori
|
|||||||
at one address must come back to that address, or they are a stranger with no token. Say so in the
|
at one address must come back to that address, or they are a stranger with no token. Say so in the
|
||||||
UI at join time rather than letting someone discover it when they cannot get back in.
|
UI at join time rather than letting someone discover it when they cannot get back in.
|
||||||
|
|
||||||
|
**A lost token is recoverable, administratively** (Gitea#33). Everything above makes the token the
|
||||||
|
single point of failure: it lives in one browser's storage, and a cleared profile, a private window or
|
||||||
|
a different browser ends the seat with the game still running and the session still on disk. Seen at a
|
||||||
|
real table — the returning player met an empty lobby while their token sat intact in `sessions.json`,
|
||||||
|
and the only way back was an administrator reading the file off the volume and the player pasting it
|
||||||
|
into a devtools console.
|
||||||
|
|
||||||
|
So there is a supported path, in two halves that are gated differently on purpose:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/games/<id>/claim { player } → { code, expiresAt, … } admin secret
|
||||||
|
POST /api/claim { code } → { token, gameId, player, gameCode }
|
||||||
|
```
|
||||||
|
|
||||||
|
**The link carries the code, never the token** — which is the rule three paragraphs up, applied. A
|
||||||
|
recovery link is exactly the sort of thing that gets pasted into a chat, so what travels in the URL is
|
||||||
|
single-use and expires in thirty minutes (`server/claims.ts`), and the page trades it for the real
|
||||||
|
token over a POST as it loads (`?claim=` in `web/main.ts`, which strips it from the address bar either
|
||||||
|
way). A leaked code is worthless once spent; a leaked token is the seat for the rest of the game.
|
||||||
|
|
||||||
|
**Minting is administrative; spending is not.** Deciding that a particular person has lost a
|
||||||
|
particular seat is a judgement no route can make safely — anyone able to mint their own code could
|
||||||
|
take any chair at the table. Spending needs no secret because the player following the link is the one
|
||||||
|
person in the story who holds none; the code *is* the authorisation, and it is the same shape
|
||||||
|
(unguessable, one-time) as the token it hands back. The codes are held in memory: they are minted on
|
||||||
|
demand and spent within minutes, so a restart dropping them is the right failure, and persisting them
|
||||||
|
would put a credential-equivalent on the volume to solve a problem measured in seconds.
|
||||||
|
|
||||||
Real accounts can be layered on later without touching the rules engine, which is exactly why
|
Real accounts can be layered on later without touching the rules engine, which is exactly why
|
||||||
[`overview.md`](overview.md) keeps that boundary sharp.
|
[`overview.md`](overview.md) keeps that boundary sharp.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.8.0.11",
|
"version": "0.8.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
@@ -1656,6 +1656,13 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
||||||
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
||||||
events.push({ type: 'actorChanged', player: s.clock.superintendent });
|
events.push({ type: 'actorChanged', player: s.clock.superintendent });
|
||||||
|
/**
|
||||||
|
* SAID OUT LOUD, as well as recorded. `actorChanged` is turn bookkeeping and the log discards it,
|
||||||
|
* so this — the one time in three Stages that it means the Fedora moved — had no line anywhere
|
||||||
|
* (playtest, 2026-09-16). Emitted alongside rather than instead: `actorChanged` still carries the
|
||||||
|
* cursor, and anything reading it keeps working.
|
||||||
|
*/
|
||||||
|
events.push({ type: 'superintendentChanged', player: s.clock.superintendent, stage: s.clock.stage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
|
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export type GameEvent =
|
|||||||
| { type: 'stageBegan'; day: number; stage: number }
|
| { type: 'stageBegan'; day: number; stage: number }
|
||||||
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
||||||
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
||||||
|
/**
|
||||||
|
* §5 — the Fedora passed, at the end of Stage 3, 6, 9 or 12.
|
||||||
|
*
|
||||||
|
* ITS OWN EVENT RATHER THAN THE `actorChanged` THIS USED TO RIDE ON. That one is turn bookkeeping,
|
||||||
|
* fired every time the cursor moves, and `record()` drops it on the floor as noise — so the one
|
||||||
|
* moment it carried that a player actually needed to see went past in silence. Reported from the
|
||||||
|
* table (2026-09-16): the Supervisor Shift appears in the history and the handover never does.
|
||||||
|
*/
|
||||||
|
| { type: 'superintendentChanged'; player: PlayerIndex; stage: number }
|
||||||
| { type: 'phaseBegan'; phase: string }
|
| { type: 'phaseBegan'; phase: string }
|
||||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||||
// -- local operations
|
// -- local operations
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* SEAT RECOVERY CODES — Gitea#33.
|
||||||
|
*
|
||||||
|
* A session token is the only identity the game has (`lobby-and-sessions.md` §1) and it lives in
|
||||||
|
* exactly one place the player controls: their browser's `localStorage`, scoped to the origin they
|
||||||
|
* joined at. Lose that — a different browser, a cleared profile, a private window — and the seat is
|
||||||
|
* unreachable, because there is nothing else on the server that will accept a claim to it. Seen at a
|
||||||
|
* real table on 2026-09-16: the joining player came back to an empty lobby while their token sat
|
||||||
|
* intact in `sessions.json`, and the only way in was an administrator reading the file off the data
|
||||||
|
* volume and the player pasting it into a devtools console.
|
||||||
|
*
|
||||||
|
* THE CODE IS NOT THE TOKEN, AND THAT IS THE WHOLE POINT. §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 the kind of thing
|
||||||
|
* that gets pasted into a chat. So an administrator mints a SHORT-LIVED, SINGLE-USE code, the player
|
||||||
|
* opens a link carrying that, and the page trades it for the real token over the same connection it
|
||||||
|
* would have used anyway. A code that leaks after it is spent is worth nothing; a token that leaks is
|
||||||
|
* worth the seat for the rest of the game.
|
||||||
|
*
|
||||||
|
* PURE ON PURPOSE, like `lobby.ts` beside it: no sockets, no filesystem, no clock of its own. `now`
|
||||||
|
* is passed in so expiry is testable without faking timers, which is the only reason this file can be
|
||||||
|
* tested at all — nothing in this repo stands an HTTP server up to make requests against it.
|
||||||
|
*
|
||||||
|
* IN MEMORY, NOT ON DISK, which is a deliberate limit rather than an oversight. A restart drops every
|
||||||
|
* outstanding code, and that is the right failure: the codes are minted on demand and spent within
|
||||||
|
* minutes, the administrator is by definition present, and persisting them would put a credential-
|
||||||
|
* equivalent on the volume to solve a problem measured in seconds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long enough to walk to the other room and read it out; short enough that a link left in a chat
|
||||||
|
* window is useless by the time anyone scrolls back to it.
|
||||||
|
*/
|
||||||
|
export const CLAIM_TTL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
export type ClaimStore = {
|
||||||
|
/** Mint a code for one seat's token. Returns the code and when it stops working. */
|
||||||
|
mint(token: string, gameId: string, now: number, ttlMs?: number): { code: string; expiresAt: number };
|
||||||
|
/**
|
||||||
|
* Spend a code. Returns the seat it names, or null when the code is unknown, already spent or
|
||||||
|
* expired — deliberately one answer for all three, so a caller cannot probe which it was.
|
||||||
|
*/
|
||||||
|
redeem(code: string, now: number): { token: string; gameId: string } | null;
|
||||||
|
/** Outstanding, unexpired codes. For tests and for anything that wants to report the store's size. */
|
||||||
|
outstanding(now: number): number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createClaimStore(): ClaimStore {
|
||||||
|
const claims = new Map<string, { token: string; gameId: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
/** Expiry is lazy: there is no timer to own, start, stop or leak across a server's lifetime. */
|
||||||
|
const prune = (now: number): void => {
|
||||||
|
for (const [code, claim] of claims) if (claim.expiresAt <= now) claims.delete(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mint(token, gameId, now, ttlMs = CLAIM_TTL_MS) {
|
||||||
|
prune(now);
|
||||||
|
// The same primitive the session tokens themselves use (`lobby.ts`), for the same reason: it
|
||||||
|
// has to be unguessable, and inventing a second scheme here would be inventing a weaker one.
|
||||||
|
const code = randomUUID();
|
||||||
|
const expiresAt = now + ttlMs;
|
||||||
|
claims.set(code, { token, gameId, expiresAt });
|
||||||
|
return { code, expiresAt };
|
||||||
|
},
|
||||||
|
|
||||||
|
redeem(code, now) {
|
||||||
|
prune(now);
|
||||||
|
const claim = claims.get(code);
|
||||||
|
if (!claim) return null;
|
||||||
|
// SINGLE USE. Deleted before the caller can do anything with it, so two browsers racing on the
|
||||||
|
// same link cannot both be seated — and a link that stays in someone's history is spent.
|
||||||
|
claims.delete(code);
|
||||||
|
return { token: claim.token, gameId: claim.gameId };
|
||||||
|
},
|
||||||
|
|
||||||
|
outstanding(now) {
|
||||||
|
prune(now);
|
||||||
|
return claims.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+87
-2
@@ -37,6 +37,7 @@ import {
|
|||||||
writeLobby,
|
writeLobby,
|
||||||
writeSessions,
|
writeSessions,
|
||||||
} from './persistence.ts';
|
} from './persistence.ts';
|
||||||
|
import { createClaimStore } from './claims.ts';
|
||||||
import { createSession } from './session.ts';
|
import { createSession } from './session.ts';
|
||||||
import type { GameSession, Push } from './session.ts';
|
import type { GameSession, Push } from './session.ts';
|
||||||
import {
|
import {
|
||||||
@@ -170,6 +171,15 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
const games = opts.initialGames;
|
const games = opts.initialGames;
|
||||||
const lobbies = opts.initialLobbies;
|
const lobbies = opts.initialLobbies;
|
||||||
const sessions = opts.initialSessions;
|
const sessions = opts.initialSessions;
|
||||||
|
/**
|
||||||
|
* Outstanding seat recovery codes — Gitea#33, `claims.ts`.
|
||||||
|
*
|
||||||
|
* In memory and not on the volume, deliberately: a code is minted on demand and spent within
|
||||||
|
* minutes with the administrator standing right there, so a restart dropping them all is the right
|
||||||
|
* failure. Persisting them would put a credential-equivalent on disk to solve a problem measured
|
||||||
|
* in seconds.
|
||||||
|
*/
|
||||||
|
const claims = createClaimStore();
|
||||||
const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join
|
const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join
|
||||||
for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
|
for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
|
||||||
|
|
||||||
@@ -322,6 +332,17 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
gameId,
|
gameId,
|
||||||
gameCode: codes.get(gameId) ?? null,
|
gameCode: codes.get(gameId) ?? null,
|
||||||
state: 'running' as const,
|
state: 'running' as const,
|
||||||
|
/**
|
||||||
|
* WHICH SEATS A PERSON IS SITTING IN — Gitea#33.
|
||||||
|
*
|
||||||
|
* `playerNames` cannot answer it: a bot's name is just a name, and telling the two apart
|
||||||
|
* by matching "Bot 1" would be guessing at a label. `sessions` holds humans and only
|
||||||
|
* humans, so this is the fact rather than an inference — and it is what lets the seat
|
||||||
|
* recovery action offer real players instead of chairs no token was ever issued for.
|
||||||
|
*/
|
||||||
|
seatedPlayers: [...sessions.values()]
|
||||||
|
.filter((s) => s.gameId === gameId)
|
||||||
|
.map((s) => s.player),
|
||||||
...g.summary(),
|
...g.summary(),
|
||||||
}));
|
}));
|
||||||
// A lobby has no game to summarize yet — it is reported as what it is, so an
|
// A lobby has no game to summarize yet — it is reported as what it is, so an
|
||||||
@@ -340,14 +361,48 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname);
|
const match = /^\/api\/games\/([^/]+)(\/save|\/claim)?$/.exec(url.pathname);
|
||||||
const gameId = match?.[1];
|
const gameId = match?.[1];
|
||||||
|
// Compared explicitly rather than tested for truthiness: with two suffixes in the group, a
|
||||||
|
// bare `match?.[2]` would let a GET on `/claim` fall into the `/save` branch below.
|
||||||
|
const suffix = match?.[2];
|
||||||
if (!gameId) {
|
if (!gameId) {
|
||||||
sendJson(res, 404, { error: 'no such route' });
|
sendJson(res, 404, { error: 'no such route' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (match?.[2] && req.method === 'GET') {
|
/**
|
||||||
|
* MINT A SEAT RECOVERY CODE FOR ONE PLAYER — Gitea#33.
|
||||||
|
*
|
||||||
|
* The token is the only identity this game has and it lives in one browser's `localStorage`;
|
||||||
|
* lose it and the seat is unreachable, because nothing else here will accept a claim to it.
|
||||||
|
* This is the supported way back, and it is administrative on purpose: whoever runs the
|
||||||
|
* server decides that a particular player has lost their seat, which is a judgement no
|
||||||
|
* automated route can make safely.
|
||||||
|
*
|
||||||
|
* IT HANDS BACK A CODE, NOT THE TOKEN. §1 says keep the token out of URLs, and the code is
|
||||||
|
* going into one. Short-lived and single-use (`claims.ts`), so a link left in a chat window
|
||||||
|
* is worth nothing by the time anyone finds it.
|
||||||
|
*/
|
||||||
|
if (suffix === '/claim' && req.method === 'POST') {
|
||||||
|
const body = (await readJson(req)) as { player?: number };
|
||||||
|
const ps = [...sessions.values()].find((s) => s.gameId === gameId && s.player === body.player);
|
||||||
|
if (!ps) {
|
||||||
|
sendJson(res, 404, { error: 'no such seat' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { code, expiresAt } = claims.mint(ps.token, gameId, Date.now());
|
||||||
|
sendJson(res, 200, {
|
||||||
|
code,
|
||||||
|
expiresAt,
|
||||||
|
player: ps.player,
|
||||||
|
displayName: ps.displayName,
|
||||||
|
gameCode: codes.get(gameId) ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suffix === '/save' && req.method === 'GET') {
|
||||||
const session = games.get(gameId);
|
const session = games.get(gameId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
sendJson(res, 404, { error: 'no such game' });
|
sendJson(res, 404, { error: 'no such game' });
|
||||||
@@ -656,6 +711,36 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPEND A SEAT RECOVERY CODE — Gitea#33, the other half of `/api/games/<id>/claim`.
|
||||||
|
*
|
||||||
|
* NOT GATED BY THE ADMIN SECRET, and it must not be: the player following the link is the one
|
||||||
|
* person in this story who holds no secret at all. The code IS the authorisation — unguessable,
|
||||||
|
* single-use and short-lived — which is the same shape as the session token it hands back, and
|
||||||
|
* why minting one is the administrative act rather than spending one.
|
||||||
|
*
|
||||||
|
* The token travels in the response BODY of a POST, never in a URL (`lobby-and-sessions.md`
|
||||||
|
* §1). One answer for unknown, spent and expired codes, so this cannot be used to probe which.
|
||||||
|
*/
|
||||||
|
if (url.pathname === '/api/claim' && req.method === 'POST') {
|
||||||
|
const body = (await readJson(req)) as { code?: string };
|
||||||
|
const claimed = typeof body.code === 'string' ? claims.redeem(body.code, Date.now()) : null;
|
||||||
|
const ps = claimed ? sessions.get(claimed.token) : undefined;
|
||||||
|
const live = ps ? games.get(ps.gameId) : undefined;
|
||||||
|
if (!claimed || !ps || !live) {
|
||||||
|
sendJson(res, 404, { error: 'no such claim' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode]));
|
||||||
|
sendJson(res, 200, {
|
||||||
|
token: ps.token,
|
||||||
|
gameId: ps.gameId,
|
||||||
|
player: ps.player,
|
||||||
|
gameCode: codes.get(ps.gameId) ?? '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and
|
* THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and
|
||||||
* just save it as a JSON file in my Downloads folder").
|
* just save it as a JSON file in my Downloads folder").
|
||||||
|
|||||||
+66
-3
@@ -127,6 +127,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
|
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
|
||||||
.join(' → ')}`,
|
.join(' → ')}`,
|
||||||
};
|
};
|
||||||
|
case 'superintendentChanged':
|
||||||
|
/**
|
||||||
|
* The Fedora is the only thing in the game that changes hands on a clock rather than because
|
||||||
|
* somebody did something, so it is the one handover nobody at the table watches happen.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
tone: 'clock',
|
||||||
|
text:
|
||||||
|
`SUPERINTENDENT — the Fedora passes to ${ctx.playerName?.(e.player) ?? 'the next player'} ` +
|
||||||
|
`at the end of Stage ${e.stage}. They rule on clearances, take the Yard Office and Red Flag ` +
|
||||||
|
`questions, and every round that goes round the table now starts with them.`,
|
||||||
|
};
|
||||||
case 'phaseBegan':
|
case 'phaseBegan':
|
||||||
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
||||||
// the log read as one undifferentiated column and you could not see where a phase began.
|
// the log read as one undifferentiated column and you could not see where a phase began.
|
||||||
@@ -435,12 +447,31 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
const wrecked = e.trains
|
const wrecked = e.trains
|
||||||
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
|
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
|
||||||
.join(' and ');
|
.join(' and ');
|
||||||
|
/**
|
||||||
|
* WHOSE OFFICE, AND WHO PAYS (playtest, 2026-09-16: "it doesn't say who suffers the revenue
|
||||||
|
* loss… we need to know which player received the penalty and why").
|
||||||
|
*
|
||||||
|
* `player` is the seat at fault, and for everything that happens inside a district that is the
|
||||||
|
* district's owner — so it names the place as well as the payer. A Mainline collision is the
|
||||||
|
* Superintendent's by rule (§10), which is a different sentence: it happened on open road, not
|
||||||
|
* in anybody's Office. The 5 points ride in a separate `revenueChanged`, which is why the line
|
||||||
|
* never mentioned them; a player should not have to add two log entries together.
|
||||||
|
*/
|
||||||
|
const who = ctx.playerName?.(e.player) ?? null;
|
||||||
|
const mainline = e.where === 'the Mainline';
|
||||||
|
const place = who === null || mainline ? e.where : `${who}'s ${e.where.replace(/^the /, '')}`;
|
||||||
|
const cost =
|
||||||
|
who === null
|
||||||
|
? ' 5 Revenue is lost.'
|
||||||
|
: mainline
|
||||||
|
? ` ${who} loses 5 Revenue: §10 makes a Mainline collision the Superintendent's fault.`
|
||||||
|
: ` ${who} loses 5 Revenue — it happened in their district.`;
|
||||||
return {
|
return {
|
||||||
tone: 'bad',
|
tone: 'bad',
|
||||||
text:
|
text:
|
||||||
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
|
`COLLISION at ${place} — ${wrecked} destroyed: ${why}.${cost} Engines and cabooses go back ` +
|
||||||
`Yard, all other cars to the Classification Yard. A Timetabled train card returns ` +
|
`to the Division Yard, all other cars to the Classification Yard. A Timetabled train card ` +
|
||||||
`to its slot and runs again next Day; an Extra is gone for good.`,
|
`returns to its slot and runs again next Day; an Extra is gone for good.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,6 +691,38 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
|||||||
* that actually refused rather than a second guess at it.
|
* that actually refused rather than a second guess at it.
|
||||||
*/
|
*/
|
||||||
if (f.kind === 'passenger') {
|
if (f.kind === 'passenger') {
|
||||||
|
/**
|
||||||
|
* NOBODY TO PUT ON THE PLATFORM, AND NO WAY TO SEE WHY (playtest, 2026-09-16).
|
||||||
|
*
|
||||||
|
* "He would like to have two passengers waiting in his depot… but the only option he had was
|
||||||
|
* bringing a tank load into the refinery." His Depot had a Restaurant and a Hotel beside it and
|
||||||
|
* three outbound slots — capacity was never the problem. §6.3 stocking takes a LOADED car of
|
||||||
|
* the facility's type out of the Division Yard, and there was not a loaded coach in it: six
|
||||||
|
* were sitting in Classification, which §2.2 returns only when the Division Yard runs bare.
|
||||||
|
*
|
||||||
|
* Jesse's ruling (2026-09-16) is the same one Gitea#2 got: the shortage stays, because running
|
||||||
|
* out is part of the game. What must not stay is the silence — an action with no legal target
|
||||||
|
* is simply absent from the menu, so the player is left to guess whether they misunderstood the
|
||||||
|
* rules or the game is broken.
|
||||||
|
*/
|
||||||
|
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) {
|
||||||
|
const loadedCoaches = s.yards.divisionYard.filter((c) => c.type === 'coach' && c.loaded).length;
|
||||||
|
if (loadedCoaches === 0) {
|
||||||
|
const waiting = s.yards.classificationYard.filter((c) => c.type === 'coach' && c.loaded).length;
|
||||||
|
out.push({
|
||||||
|
where: `${name} ${key}`,
|
||||||
|
why:
|
||||||
|
`room for ${f.capacity.outbound - f.outboundBox.length} more passenger` +
|
||||||
|
`${f.capacity.outbound - f.outboundBox.length === 1 ? '' : 's'} to wait, but no loaded ` +
|
||||||
|
`coach in the Division Yard for the Freight Agent to bring over` +
|
||||||
|
(waiting > 0
|
||||||
|
? ` — ${waiting} ${waiting === 1 ? 'is' : 'are'} in the Classification Yard, which comes ` +
|
||||||
|
'back only when the Division Yard is bare'
|
||||||
|
: ''),
|
||||||
|
severity: 'waiting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (portersLeft(f) > 0) {
|
if (portersLeft(f) > 0) {
|
||||||
const coord = uncoordKey(key);
|
const coord = uncoordKey(key);
|
||||||
// Passengers standing on the platform with nothing carrying them away.
|
// Passengers standing on the platform with nothing carrying them away.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
|||||||
// Says WHO builds, which is the question this phase actually raises at a table: the round
|
// Says WHO builds, which is the question this phase actually raises at a table: the round
|
||||||
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
|
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
|
||||||
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
|
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
|
||||||
tip: 'Timetabled trains for this Stage are built: starting with the Superintendent and working left, each player adds ONE car, going round again until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
tip: 'Timetabled trains for this Stage are built: each player adds ONE car at a time, starting with the Superintendent and working eastward, repeating until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
||||||
// a locomotive being made up
|
// a locomotive being made up
|
||||||
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1337,6 +1337,18 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
|||||||
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
||||||
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
|
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* THE FEDORA MOVING IS ANNOUNCED, NOT JUST LOGGED (playtest, 2026-09-16).
|
||||||
|
*
|
||||||
|
* It is the one thing in the game that changes hands on the clock rather than because somebody
|
||||||
|
* did something, so nobody is watching for it — and it decides who rules on clearances and who
|
||||||
|
* every round starts with. A line in the history is where you find it afterwards; this is what
|
||||||
|
* tells the table as it happens, the same treatment a completed run already gets.
|
||||||
|
*/
|
||||||
|
if (e.type === 'superintendentChanged') {
|
||||||
|
const name = game.state.players[e.player]?.name ?? 'the next player';
|
||||||
|
game.announced = `${name} is now the Superintendent — the Fedora passed at the end of Stage ${e.stage}.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||||
|
|||||||
+2
-1
@@ -107,7 +107,8 @@ function explain(code: unknown, fallback: string): string {
|
|||||||
return messages[key] ?? (key !== '' ? key : fallback);
|
return messages[key] ?? (key !== '' ? key : fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
|
/** Exported for `main.ts`'s seat-recovery path (Gitea#33), so there is one JSON POST on this page. */
|
||||||
|
export async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
+134
-22
@@ -27,7 +27,7 @@ import type { PlayerIndex } from '../engine/state.ts';
|
|||||||
import type { PublicDistrict } from '../sim/view.ts';
|
import type { PublicDistrict } from '../sim/view.ts';
|
||||||
import { actorOnScreen, createStepQueue } from './step-queue.ts';
|
import { actorOnScreen, createStepQueue } from './step-queue.ts';
|
||||||
import { PACE_LEVELS } from '../sim/pacing.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 type { LobbyReady } from './lobby.ts';
|
||||||
import {
|
import {
|
||||||
closestPreset,
|
closestPreset,
|
||||||
@@ -337,9 +337,17 @@ function watchedDistrict(f: Frame): PublicDistrict | null {
|
|||||||
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
|
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
|
||||||
* which keeps the board where it was instead of snapping home mid-sequence.
|
* which keeps the board where it was instead of snapping home mid-sequence.
|
||||||
*/
|
*/
|
||||||
// A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`).
|
/**
|
||||||
if (peekPlayer !== null && peekPlayer !== f.viewer) {
|
* A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`)
|
||||||
return pub.districts.find((d) => d.player === peekPlayer) ?? null;
|
* — INCLUDING A LOOK AT YOUR OWN BOARD, which is why this returns rather than falling through.
|
||||||
|
*
|
||||||
|
* Falling through sent "show me mine" to the actor logic below, so the one player who could not
|
||||||
|
* reach their own Office Area was the player waiting on everybody else (playtest, 2026-09-16: Tom,
|
||||||
|
* hanging about while the board followed Jesse). Null IS your own district: it is what the caller
|
||||||
|
* draws from `f.cells` when nobody else is being watched.
|
||||||
|
*/
|
||||||
|
if (peekPlayer !== null) {
|
||||||
|
return peekPlayer === f.viewer ? null : (pub.districts.find((d) => d.player === peekPlayer) ?? null);
|
||||||
}
|
}
|
||||||
let player: PlayerIndex | null = f.actor;
|
let player: PlayerIndex | null = f.actor;
|
||||||
if (stepQueue.busy()) {
|
if (stepQueue.busy()) {
|
||||||
@@ -1126,9 +1134,66 @@ function abandonRemote(): void {
|
|||||||
* into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the
|
* 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.
|
* 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 {
|
function start(): void {
|
||||||
const params = new URLSearchParams(location.search);
|
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.
|
* ASKING FOR THE LOBBY BEATS RESUMING A GAME.
|
||||||
*
|
*
|
||||||
@@ -1692,8 +1757,17 @@ function render(): void {
|
|||||||
$('depts').classList.remove('aiming');
|
$('depts').classList.remove('aiming');
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- making up a train: the Division Yard chip that shows the car IS the button.
|
/**
|
||||||
if (menu.makeUp) {
|
* -- making up a train: the Division Yard chip that shows the car IS the button.
|
||||||
|
*
|
||||||
|
* NOT WHILE THE BOARD IS BEHIND (playtest, 2026-09-16). `renderActions` puts the action list away
|
||||||
|
* while the queue is catching up — a move offered against a position that has already moved on is
|
||||||
|
* a move made blind — but this wiring sat outside that guard, so the yard chips stayed lit and
|
||||||
|
* clickable. Jesse clicked one during a bot's make-up, a coach left the yard, and the train ended
|
||||||
|
* up with three cars: he had submitted a real intent against a board he could not see. The chips
|
||||||
|
* follow the same rule as every other control now.
|
||||||
|
*/
|
||||||
|
if (menu.makeUp && !stepQueue.busy()) {
|
||||||
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
|
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
|
||||||
const node = el as HTMLElement;
|
const node = el as HTMLElement;
|
||||||
const car = menu.makeUp!.cars.find(
|
const car = menu.makeUp!.cars.find(
|
||||||
@@ -1734,7 +1808,16 @@ function render(): void {
|
|||||||
*/
|
*/
|
||||||
const heldBack = stepQueue.pendingLines();
|
const heldBack = stepQueue.pendingLines();
|
||||||
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
|
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
|
||||||
const shownLines = allLines.slice(-60);
|
/**
|
||||||
|
* NINETY LINES, IN THE SAME BOX (Jesse, 2026-09-16: "increase to 90, keep the box the same size").
|
||||||
|
*
|
||||||
|
* The panel scrolls already, so a longer tail costs no screen and lets a player scroll further
|
||||||
|
* back through a Stage they were not watching. It is capped at all only because the list is
|
||||||
|
* rebuilt on every render; the log itself is uncapped in memory, so the number is a display
|
||||||
|
* choice rather than a limit. The BOX stays 230px on purpose — growing it would push the newest
|
||||||
|
* line, the one being read, further from where the eye already is.
|
||||||
|
*/
|
||||||
|
const shownLines = allLines.slice(-90);
|
||||||
/**
|
/**
|
||||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||||
* by the time the board paints the log already has several turns in it and nothing says which of
|
* by the time the board paints the log already has several turns in it and nothing says which of
|
||||||
@@ -1857,16 +1940,26 @@ function renderUndo(): void {
|
|||||||
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
||||||
*/
|
*/
|
||||||
function renderYards(f: Frame): void {
|
function renderYards(f: Frame): void {
|
||||||
$('divyard').innerHTML = yardHtml(f.yards.division);
|
/**
|
||||||
$('clsyard').innerHTML = yardHtml(f.yards.classification);
|
* THE YARDS BELONG TO THE BOARD ON SCREEN, NOT TO THE GAME (playtest, 2026-09-16).
|
||||||
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
|
*
|
||||||
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
|
* They were drawn from the live Frame while everything around them was held back, so a player
|
||||||
|
* five moves behind read yard counts from a future they had not been shown — "the yards may not
|
||||||
|
* be in sync with the turns behind", and they were not. Same rule as the turn chart: while the
|
||||||
|
* queue is behind, this is the shown board's yards; at rest the two are the same object.
|
||||||
|
*/
|
||||||
|
const pub = stepQueue.current();
|
||||||
|
const yards = pub && stepQueue.busy() ? pub.yards : f.yards;
|
||||||
|
$('divyard').innerHTML = yardHtml(yards.division);
|
||||||
|
$('clsyard').innerHTML = yardHtml(yards.classification);
|
||||||
|
$('divtot').textContent = `${yards.divisionTotal} cars`;
|
||||||
|
$('clstot').textContent = `${yards.classificationTotal} cars`;
|
||||||
|
|
||||||
// The one thing worth calling out: the yard about to turn over.
|
// The one thing worth calling out: the yard about to turn over.
|
||||||
const bare = f.yards.divisionTotal === 0;
|
const bare = yards.divisionTotal === 0;
|
||||||
$('divyard').classList.toggle('bare', bare);
|
$('divyard').classList.toggle('bare', bare);
|
||||||
$('yardnote').textContent = bare
|
$('yardnote').textContent = bare
|
||||||
? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.`
|
? `The Division Yard is bare — the ${yards.classificationTotal} cars in Classification return to it now.`
|
||||||
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1939,17 +2032,37 @@ function renderDistrict(f: Frame): void {
|
|||||||
* from another person, and so the one that must never be pasted into markup.
|
* from another person, and so the one that must never be pasted into markup.
|
||||||
*/
|
*/
|
||||||
const peek = $('districtpeek');
|
const peek = $('districtpeek');
|
||||||
const others = f.players.filter((p) => p.index !== f.viewer);
|
/**
|
||||||
|
* EVERY SEAT, YOURS INCLUDED, IN MAP ORDER (playtest, 2026-09-16).
|
||||||
|
*
|
||||||
|
* Two faults, both reported from one game. There was no button for your OWN district, so a player
|
||||||
|
* waiting on everybody else could look at any board except the one they were playing — and the
|
||||||
|
* buttons came out in player order, which is the order people joined, not the order they sit.
|
||||||
|
*
|
||||||
|
* Sorted by SEAT, which is west-to-east along the Division exactly as the map draws it, so the row
|
||||||
|
* reads left to right the way the railroad does. Seat is not player index and must not be assumed
|
||||||
|
* to be: under Employee Rotation the seating moves, and because this sorts the Frame's own `seat`
|
||||||
|
* on every render, the buttons rotate with the players rather than having to be told.
|
||||||
|
*/
|
||||||
|
const seats = [...f.players].sort((a, b) => a.seat - b.seat);
|
||||||
peek.innerHTML = '';
|
peek.innerHTML = '';
|
||||||
peek.hidden = others.length === 0;
|
peek.hidden = seats.length < 2;
|
||||||
for (const p of others) {
|
const watchedNow = watchedDistrict(f);
|
||||||
|
for (const p of seats) {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.type = 'button';
|
b.type = 'button';
|
||||||
b.className = 'ghost';
|
b.className = 'ghost';
|
||||||
|
// NAMES GO IN AS TEXT, NEVER MARKUP: a display name is whatever somebody typed in the lobby.
|
||||||
b.textContent = p.name;
|
b.textContent = p.name;
|
||||||
b.title =
|
const isYou = p.index === f.viewer;
|
||||||
`Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
|
// Which board is up right now — yours when nothing is being watched, otherwise the watched one.
|
||||||
`redraws — press Pause first if you want to study it.`;
|
const showing = watchedNow === null ? f.viewer : watchedNow.player;
|
||||||
|
// `.seg button[aria-pressed="true"]` already lights the current one — no extra class to style.
|
||||||
|
b.setAttribute('aria-pressed', String(p.index === showing));
|
||||||
|
b.title = isYou
|
||||||
|
? 'Back to your own Office Area.'
|
||||||
|
: `Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
|
||||||
|
`redraws — press Pause first if you want to study it.`;
|
||||||
b.onclick = () => {
|
b.onclick = () => {
|
||||||
peekPlayer = p.index;
|
peekPlayer = p.index;
|
||||||
render();
|
render();
|
||||||
@@ -2350,9 +2463,8 @@ function renderActions(
|
|||||||
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
|
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
|
||||||
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
|
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
|
||||||
*/
|
*/
|
||||||
`<b>One car each:</b> you add a single car, then the round passes to the next player — ` +
|
`<b>Each player adds one car at a time</b>, starting from the Superintendent and working ` +
|
||||||
`starting from the Superintendent and working left, coming round again until the train is ` +
|
`eastward, repeating until the train is full or the Division Yard holds nothing it can take.`
|
||||||
`full or the Division Yard holds nothing it can take.`
|
|
||||||
: menu.makeUp.pass !== null
|
: menu.makeUp.pass !== null
|
||||||
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
||||||
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
|
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ const KNOWN_UNREDUCED = [
|
|||||||
// the pattern every entry on this list follows.
|
// the pattern every entry on this list follows.
|
||||||
'seatsRotated',
|
'seatsRotated',
|
||||||
'stageBegan',
|
'stageBegan',
|
||||||
|
/**
|
||||||
|
* §5's Fedora handover, emitted by `shiftChange` on the same mutate-then-describe path as its
|
||||||
|
* neighbours here: the clock moves the Superintendent and then says so. Added 2026-09-16 because
|
||||||
|
* riding on `actorChanged` meant the log dropped it as turn bookkeeping.
|
||||||
|
*/
|
||||||
|
'superintendentChanged',
|
||||||
'trainArrived',
|
'trainArrived',
|
||||||
'trainCompleted',
|
'trainCompleted',
|
||||||
'trainDiverted',
|
'trainDiverted',
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ const SAMPLES: GameEvent[] = [
|
|||||||
{ type: 'stageBegan', day: 1, stage: 7 },
|
{ type: 'stageBegan', day: 1, stage: 7 },
|
||||||
{ type: 'phaseBegan', phase: 'mainline' },
|
{ type: 'phaseBegan', phase: 'mainline' },
|
||||||
{ type: 'actorChanged', player: 0 },
|
{ type: 'actorChanged', player: 0 },
|
||||||
|
// Sampled rather than left to swell the unsampled count: this sentence is one a player reads at
|
||||||
|
// the table every third Stage, so its text is worth exercising.
|
||||||
|
{ type: 'superintendentChanged', player: 1, stage: 6 },
|
||||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||||
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||||
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Seat recovery codes — Gitea#33.
|
||||||
|
*
|
||||||
|
* The properties worth pinning are the ones that make a code safe to put in a link: it is spendable
|
||||||
|
* exactly once, it stops working on its own, and a bad code is indistinguishable from a spent one.
|
||||||
|
* `now` is a parameter rather than a clock, so expiry is tested without faking timers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { CLAIM_TTL_MS, createClaimStore } from '../../src/server/claims.ts';
|
||||||
|
|
||||||
|
describe('seat recovery codes', () => {
|
||||||
|
it('mints a code that names the seat it was minted for', () => {
|
||||||
|
const claims = createClaimStore();
|
||||||
|
const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 1000);
|
||||||
|
assert.equal(expiresAt, 1000 + CLAIM_TTL_MS);
|
||||||
|
assert.deepEqual(claims.redeem(code, 1000), { token: 'tok-abc', gameId: 'game-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spends a code exactly once — a link in a chat log is worth nothing afterwards', () => {
|
||||||
|
const claims = createClaimStore();
|
||||||
|
const { code } = claims.mint('tok-abc', 'game-1', 0);
|
||||||
|
assert.ok(claims.redeem(code, 1));
|
||||||
|
assert.equal(claims.redeem(code, 2), null, 'the same code was accepted twice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops working once its time is up, without anything having to sweep it', () => {
|
||||||
|
const claims = createClaimStore();
|
||||||
|
const { code } = claims.mint('tok-abc', 'game-1', 0);
|
||||||
|
assert.equal(claims.redeem(code, CLAIM_TTL_MS - 1)?.token, 'tok-abc', 'expired early');
|
||||||
|
const again = claims.mint('tok-abc', 'game-1', 0).code;
|
||||||
|
assert.equal(claims.redeem(again, CLAIM_TTL_MS), null, 'a code outlived its expiry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers the same way for unknown, spent and expired codes', () => {
|
||||||
|
const claims = createClaimStore();
|
||||||
|
const { code } = claims.mint('tok-abc', 'game-1', 0);
|
||||||
|
claims.redeem(code, 1);
|
||||||
|
const expired = claims.mint('tok-abc', 'game-1', 0).code;
|
||||||
|
|
||||||
|
assert.equal(claims.redeem('never-existed', 1), null);
|
||||||
|
assert.equal(claims.redeem(code, 1), null);
|
||||||
|
assert.equal(claims.redeem(expired, CLAIM_TTL_MS + 1), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every mint its own code', () => {
|
||||||
|
const claims = createClaimStore();
|
||||||
|
const codes = new Set([0, 1, 2, 3, 4].map(() => claims.mint('tok-abc', 'game-1', 0).code));
|
||||||
|
assert.equal(codes.size, 5, 'two mints produced the same code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgets expired codes rather than accumulating them', () => {
|
||||||
|
const claims = createClaimStore();
|
||||||
|
claims.mint('tok-a', 'game-1', 0);
|
||||||
|
claims.mint('tok-b', 'game-1', 0);
|
||||||
|
assert.equal(claims.outstanding(0), 2);
|
||||||
|
assert.equal(claims.outstanding(CLAIM_TTL_MS), 0, 'expired codes were still being held');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a short-lived code short-lived when asked for one', () => {
|
||||||
|
const claims = createClaimStore();
|
||||||
|
const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 500, 60_000);
|
||||||
|
assert.equal(expiresAt, 60_500);
|
||||||
|
assert.equal(claims.redeem(code, 60_500), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -310,6 +310,33 @@ describe('steps reach a seated player — TODO #13', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the Fedora passing is visible (playtest 2026-09-16)', () => {
|
||||||
|
it('names the new Superintendent in the history at the Stage it happens', async () => {
|
||||||
|
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||||
|
const { legalActions } = await import('../src/engine/legal.ts');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It used to ride on `actorChanged`, which `record()` drops as turn bookkeeping — so the one
|
||||||
|
* moment that event meant something never reached a player. Driven far enough to cross a shift
|
||||||
|
* boundary (Stages 3, 6, 9, 12) rather than asserted on a hand-built event, because the point is
|
||||||
|
* that a real game produces the line.
|
||||||
|
*/
|
||||||
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
for (let i = 0; i < 900; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||||
|
if (game.state.clock.stage > 3 || game.state.clock.day > 1) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handover = game.log.filter((l) => /SUPERINTENDENT — the Fedora passes to/.test(l.text));
|
||||||
|
assert.ok(handover.length > 0, 'the game crossed a shift change and the log never said so');
|
||||||
|
assert.match(handover[0]!.text, /Alice|Bob|Carol/, 'the handover did not name the new Superintendent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
||||||
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
||||||
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||||
|
|||||||
+72
-3
@@ -1522,6 +1522,15 @@ describe('the static build', () => {
|
|||||||
attrs,
|
attrs,
|
||||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||||
getAttribute: (k: string) => attrs[k] ?? null,
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||||
|
* `setAttribute` taught this factory above, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel throws on every render.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||||
@@ -1779,6 +1788,15 @@ describe('the static build', () => {
|
|||||||
getAttribute: (k: string) => attrs[k] ?? null,
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||||
|
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel throws on every render.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||||
showModal() {
|
showModal() {
|
||||||
@@ -1894,6 +1912,15 @@ describe('the static build', () => {
|
|||||||
getAttribute: (k: string) => attrs[k] ?? null,
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||||
|
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel throws on every render.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
showModal() {},
|
showModal() {},
|
||||||
close() {},
|
close() {},
|
||||||
@@ -2680,10 +2707,15 @@ describe('the static build', () => {
|
|||||||
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
||||||
/**
|
/**
|
||||||
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
||||||
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up "starting
|
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up starting
|
||||||
* with the Superintendent and working left" — but nothing on the board said who the
|
* with the Superintendent and working EASTWARD — but nothing on the board said who the
|
||||||
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
|
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
|
||||||
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
|
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
|
||||||
|
*
|
||||||
|
* The rule text said "working left" until 2026-09-16. It is the same rule — `playerLeftOf` is
|
||||||
|
* increasing seat index — but "left" describes a table nobody is looking at, while the map on
|
||||||
|
* screen runs west to east, so at a real three-player game it read as plainly wrong: the second
|
||||||
|
* car went to the player sitting to the EAST. The word changed; the order did not.
|
||||||
*/
|
*/
|
||||||
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
||||||
const table = turnChartHtml(frame, 'Bob', 'Bob');
|
const table = turnChartHtml(frame, 'Bob', 'Bob');
|
||||||
@@ -2697,7 +2729,7 @@ describe('the static build', () => {
|
|||||||
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/starting with the Superintendent and working left/,
|
/starting with the Superintendent and working eastward/,
|
||||||
'the New Train pill does not say whose turn the make-up round starts on',
|
'the New Train pill does not say whose turn the make-up round starts on',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -4170,6 +4202,16 @@ describe('the lobby screen', () => {
|
|||||||
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
|
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
|
||||||
querySelectorAll: (sel: string) => matching(sel),
|
querySelectorAll: (sel: string) => matching(sel),
|
||||||
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the same
|
||||||
|
* lesson `setAttribute` taught this factory in 2026-08-30, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel threw on every render, which took out 21 tests across three suites
|
||||||
|
* — and the thing it was hiding was a control nothing had ever exercised.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
};
|
};
|
||||||
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||||
return node;
|
return node;
|
||||||
@@ -4228,6 +4270,30 @@ describe('the lobby screen', () => {
|
|||||||
const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined =>
|
const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined =>
|
||||||
groups[name]!.find((r) => r.checked)?.value;
|
groups[name]!.find((r) => r.checked)?.value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A SEAT RECOVERY LINK — Gitea#33.
|
||||||
|
*
|
||||||
|
* The properties that make this safe to hand round are the ones worth pinning: the page trades the
|
||||||
|
* CODE for the token (so no token is ever in a URL), and it does not keep the code afterwards. The
|
||||||
|
* store's own single-use and expiry rules are proven in `test/server/claims.test.ts`; this is the
|
||||||
|
* client half, which is the part that could silently stop asking.
|
||||||
|
*/
|
||||||
|
it('trades a ?claim= code for a seat, and does not leave the code in the address bar', async () => {
|
||||||
|
const { sent } = await open('?claim=code-123', {
|
||||||
|
'/api/claim': { token: 'tok-restored', gameId: 'game-9', player: 1, gameCode: 'WHISTLE-6945' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const claim = sent.find((r) => r.url.includes('/api/claim'));
|
||||||
|
assert.ok(claim, 'the page never redeemed the code');
|
||||||
|
assert.deepEqual(claim.body, { code: 'code-123' }, 'the code was not sent as the request body');
|
||||||
|
// The token must never travel in a URL (`lobby-and-sessions.md` §1) — it comes back in the
|
||||||
|
// response, and the only thing that went out was the one-time code.
|
||||||
|
assert.ok(
|
||||||
|
!sent.some((r) => r.url.includes('tok-restored')),
|
||||||
|
'a session token appeared in a request URL',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('opens on the join door, with the create form behind it', async () => {
|
it('opens on the join door, with the create form behind it', async () => {
|
||||||
// Somebody who was handed a code used to have to scroll past the entire create form to find the
|
// Somebody who was handed a code used to have to scroll past the entire create form to find the
|
||||||
// box to type it into.
|
// box to type it into.
|
||||||
@@ -4543,6 +4609,9 @@ describe('the solitaire setup screen', () => {
|
|||||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
||||||
checked: false, disabled: false, hidden: false, className: '',
|
checked: false, disabled: false, hidden: false, className: '',
|
||||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||||
|
// The Office Area's per-seat buttons are created and appended rather than interpolated, so a
|
||||||
|
// node that cannot be appended to throws on every render — see the note in the other factory.
|
||||||
|
appendChild: () => {},
|
||||||
addEventListener: (type: string, fn: () => void) =>
|
addEventListener: (type: string, fn: () => void) =>
|
||||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||||
showModal: () => void ((node as { open: boolean }).open = true),
|
showModal: () => void ((node as { open: boolean }).open = true),
|
||||||
|
|||||||
Reference in New Issue
Block a user