Two issues off the tracker, and they are halves of one thing: the end of a game. Neither ships on the 0.4.9 line — Jesse's call, that line may be complete and these are not fixes people mid-playtest need. EXTENDED PLAY (#11). The official result is settled at the original game length and never changes: in a five-Day game extended to eight, the winner is whoever led at the end of Day 5. Extending grants exactly one Day and the question is put again at the end of it — solitaire the player decides alone, multiplayer it is unanimous and one refusal ends it there. Only days-based endings offer it; a §3.4 collision breach is final, during an extended Day exactly as during the scheduled game. It could not be a client-side change. `check` refused every intent once `status` left `active`; the server never loads a `finished` game back into memory; and a save is `{ seed, config, history }` replayed through the engine, so a "continue" the history does not record did not happen. Hence a fourth status, `awaitingExtension`, and a `game.extend` intent. `config.days` never moves — `extraDays` counts the borrowed Days and `official` freezes the outcome, the standings and the statistics at the first ending. THE RESULTS SCREEN (#16). `GAME OVER — revenueFloor` was `outcome.reason`, an internal enum interpolated into the page at the one moment the game has the player's whole attention. Every reason now has a sentence with the game's own numbers in it. Around it: the result and winner, standings, the rules the game was dealt under, a per-player breakdown, and the railroad — trains through the Division and how many worked en route, loads made up and broken, passengers, cars switched, trains destroyed. It shares the Day-end dialog's blocks rather than reimplementing them, and stays reopenable so continuing does not cost you the results. Statistics are folded, not recorded: `state.tally` counts what the event stream says happened, hooked at `applyIntent` and `advance` because `reduce` never sees the phase driver's events — and those are the interesting ones. Nothing in the rules reads it, and it rides the Frame, so multiplayer gets the same numbers as solitaire from one implementation. THREE BUGS FOUND IN TESTING, all of which would have shipped: - a saved game containing a vote could not be resumed (NO_ACTOR). A history is a flat Intent[] with no seat recorded; the replay derives who acted from the turn order, which cannot work for an intent every seat may send in any order. `game.extend` carries its voter, checked against the authenticated seat. - an all-bot game hung on the question for ever. `driveBots` loops on `currentActor`, null the moment the game stops, so it cannot cast a vote, and the bot-vote driver returned early with no humans to follow. - the balance harness became unbounded — `test/sim.test.ts` went from under a second to never finishing. `randomBot` took another Day about half the time, so every seeded game ran to playGame's 50,000-turn cap. Fixed in the driver, not in a policy, so it holds for bots not yet written. All three have regression tests. 832 tests pass, against 793 before this change. NOT BUILT, and a correction. #16's own comment said `trainStoodStill` "is emitted per Stage, so a run of them is exactly the sat-on-a-siding streak". It is not: reading advance.ts, it fires once per game and only for a train whose profile sets `stopEarnsPoint` — the X18 Circus — with `stopPointClaimed` preventing a second. The streak was built, rendered "1 Stage at (0,0)", and was taken out again. There is no per-Stage "this train did not move" signal in the engine, so "longest an engine sat on a siding" needs one first; TODO.md #36 records what it would take, and the Circus set-up is reported instead. Badges remain the second pass #16 asks for (TODO.md #33), and because the statistics are derived rather than recorded, that pass can add any of them retroactively to games already played and saved. Extended play has not yet been played at a real table (TODO.md #35): the multiplayer vote has only been driven through `session.intent`, never through two browsers. Closes #11 Closes #16 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
/**
|
|
* The boundary between the page and the game.
|
|
*
|
|
* The page draws a `Frame` and offers a `Menu`, and submits intents. It does not care whether the
|
|
* rules are being applied a function call away or across a network — which is the whole point:
|
|
*
|
|
* - `LocalSession` runs the engine in this browser. Solitaire, exactly as it has always worked,
|
|
* with no server involved at any point.
|
|
* - `RemoteSession` (not built yet — see `docs/architecture/multiplayer.md` Phase 2) will hold no
|
|
* authoritative state at all. It cannot: it has neither the deck order nor the other players'
|
|
* hands, and if it did the game would be cheatable.
|
|
*
|
|
* So this interface is deliberately the SMALLER of the two — everything a remote client could
|
|
* possibly offer, and nothing that only a local one can do. What a local session can do beyond it is
|
|
* declared in `capabilities`, and the page hides those controls rather than calling them and failing.
|
|
*/
|
|
|
|
import type { Intent } from '../engine/intents.ts';
|
|
import type { Frame } from '../sim/view.ts';
|
|
import type { PlayerIndex } from '../engine/state.ts';
|
|
import { applyDelta } from '../sim/frame-delta.ts';
|
|
import type { FrameDelta } from '../sim/frame-delta.ts';
|
|
import type { Game, Menu, NewGameOptions, Save } from './game.ts';
|
|
import {
|
|
actionMenu,
|
|
configWith,
|
|
currentActor,
|
|
fromSave,
|
|
handPlayable,
|
|
isOutOfTurn,
|
|
newGame,
|
|
overHandLimit,
|
|
submit,
|
|
toSave,
|
|
undo,
|
|
view,
|
|
} from './game.ts';
|
|
|
|
/**
|
|
* What this session can do beyond the common interface.
|
|
*
|
|
* None of these survive a server. Undo would have to un-see what other players have already seen;
|
|
* a local save is meaningless when the server is the store; and dealing a new game is the lobby's
|
|
* job. The page reads these rather than assuming, so the same code drives both.
|
|
*/
|
|
export type Capabilities = {
|
|
undo: boolean;
|
|
saveLocal: boolean;
|
|
newGame: boolean;
|
|
};
|
|
|
|
export type Session = {
|
|
/** The board as this seat sees it. */
|
|
view(): Frame;
|
|
/** What this seat may do right now. */
|
|
menu(): Menu;
|
|
/** Which seat this client is playing. */
|
|
seat(): PlayerIndex;
|
|
/** Whose turn it is, or null when the game is over or waiting on nothing. */
|
|
actor(): PlayerIndex | null;
|
|
/** True when the hand is over §6.2's limit and the turn cannot be ended. */
|
|
overHandLimit(): boolean;
|
|
/** Which cards in hand are playable right now, in hand order. */
|
|
handPlayable(): boolean[];
|
|
/**
|
|
* Propose an action. Resolves false if the rules refused it.
|
|
*
|
|
* Async because a remote session must be, even though the local one answers immediately — a page
|
|
* written against a synchronous `submit` would have to be rewritten for the server.
|
|
*/
|
|
submit(intent: Intent): Promise<boolean>;
|
|
/** Called whenever something changed and the page should redraw. Returns an unsubscribe. */
|
|
subscribe(fn: () => void): () => void;
|
|
capabilities: Capabilities;
|
|
|
|
/**
|
|
* WHAT JUST HAPPENED — three transient signals the page uses to draw a moment rather than a state.
|
|
*
|
|
* They are separate from `view()` because two of them are CONSUMED: a Stage flash and a sound play
|
|
* once and are then gone, whereas the Frame can be rebuilt any number of times per render. Putting
|
|
* them on the Frame would mean re-flashing on every redraw.
|
|
*
|
|
* A remote session fills these from the server's pushes rather than from a local event log; the
|
|
* page cannot tell the difference. Whether they eventually ride on the Frame as animation hints is
|
|
* a Phase 2 question (`docs/architecture/multiplayer.md` D3).
|
|
*/
|
|
/** The narrated history, newest last. */
|
|
lines(): { text: string; tone: string }[];
|
|
/** Sounds earned since the last call. Draining. */
|
|
takeCues(): string[];
|
|
/** The timetable slot the last 1D12 filled, once. Draining. */
|
|
takeScheduled(): number | null;
|
|
/** A one-line announcement to flash, once. Draining. */
|
|
takeAnnouncement(): string | null;
|
|
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
|
|
justDrawn(): string | null;
|
|
/**
|
|
* Which OTHER seats are currently connected, as last reported by the server — always empty for a
|
|
* `LocalSession` (there is nobody else to track). `lobby-and-sessions.md` §5: a disconnect keeps
|
|
* the seat and waits; this is what lets the page say why, instead of going quiet with no
|
|
* explanation. Reflects the last presence push for each seat the server has ever mentioned, not
|
|
* only the ones currently disconnected — a seat that reconnects updates its own entry rather than
|
|
* disappearing.
|
|
*
|
|
* `seen` is what separates the two absences the page must not conflate: a seat that has connected
|
|
* at some point and dropped, against one that has never opened the game at all. The server now
|
|
* reports every other seat on connect (2026-08-23), so this is answerable at the moment a game
|
|
* starts — which is exactly when "is everyone here?" is the question.
|
|
*/
|
|
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
|
/**
|
|
* Stop listening, for good.
|
|
*
|
|
* Only a remote session has anything to close, and only one caller needs it: a player LEAVING a
|
|
* running game (2026-08-23). Without it the page went back to the lobby with its `EventSource`
|
|
* still open, so the server — and therefore every other seat — went on reporting them as present
|
|
* at a table they had walked away from.
|
|
*/
|
|
close?(): void;
|
|
};
|
|
|
|
/**
|
|
* Everything a LOCAL session can additionally do. Kept off `Session` so that reaching for one of
|
|
* these in shared page code is a type error rather than a runtime surprise against a server.
|
|
*/
|
|
export type LocalSession = Session & {
|
|
readonly game: Game;
|
|
seed(): number;
|
|
save(): Save;
|
|
/** How many intents have been submitted — what the Undo button counts down. */
|
|
steps(): number;
|
|
/** Steps back one intent by replaying the history without it. Returns false at the start. */
|
|
undo(): boolean;
|
|
restore(save: Save): void;
|
|
};
|
|
|
|
/**
|
|
* A session that owns the engine in this process.
|
|
*
|
|
* `Game` is mutated in place by `submit`, so the wrapper keeps a mutable reference rather than
|
|
* copying — `undo` and `restore` replace the whole game, which is why `game` is a getter.
|
|
*
|
|
* It takes `NewGameOptions` rather than a whole `GameConfig` because DEALING is the only thing on the
|
|
* far side of this that the page is allowed to decide. A config carries the mode and the optional
|
|
* rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build meant importing
|
|
* the engine's own defaults into the page, which is the boundary `session.test.ts` guards. The seed,
|
|
* the house rules and the victory-condition dials are what a player picks when they press New game.
|
|
*/
|
|
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
|
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
|
|
const listeners = new Set<() => void>();
|
|
const changed = (): void => {
|
|
for (const fn of [...listeners]) fn();
|
|
};
|
|
|
|
return {
|
|
get game() {
|
|
return game;
|
|
},
|
|
view: () => view(game),
|
|
menu: () => actionMenu(game),
|
|
seat: () => 0,
|
|
actor: () => currentActor(game),
|
|
overHandLimit: () => overHandLimit(game),
|
|
handPlayable: () => handPlayable(game),
|
|
submit: async (intent: Intent) => {
|
|
// Seat 0 is the solitaire player, and the extension vote (Gitea#11) is the one intent that
|
|
// arrives when `currentActor` is null — so it has to name its seat. See `isOutOfTurn`.
|
|
const ok = submit(game, intent, isOutOfTurn(intent) ? 0 : null);
|
|
if (ok) changed();
|
|
return ok;
|
|
},
|
|
subscribe(fn: () => void) {
|
|
listeners.add(fn);
|
|
return () => listeners.delete(fn);
|
|
},
|
|
capabilities: { undo: true, saveLocal: true, newGame: true },
|
|
|
|
lines: () => game.log,
|
|
takeCues: () => game.cues.splice(0, game.cues.length),
|
|
takeScheduled: () => {
|
|
const slot = game.scheduled;
|
|
game.scheduled = null;
|
|
return slot;
|
|
},
|
|
takeAnnouncement: () => {
|
|
const text = game.announced;
|
|
game.announced = null;
|
|
return text;
|
|
},
|
|
justDrawn: () => game.justDrawn,
|
|
presence: () => [],
|
|
|
|
seed: () => game.seed,
|
|
save: () => toSave(game),
|
|
steps: () => game.history.length,
|
|
undo() {
|
|
const back = undo(game);
|
|
if (!back) return false;
|
|
game = back;
|
|
// The rebuilt game replays its own history, so every cue and draw in it is old news.
|
|
back.cues.length = 0;
|
|
back.scheduled = null;
|
|
back.justDrawn = null;
|
|
back.announced = null;
|
|
changed();
|
|
return true;
|
|
},
|
|
restore(save: Save) {
|
|
game = fromSave(save);
|
|
// Restoring replays the whole history and re-records every draw; none of it is news.
|
|
game.justDrawn = null;
|
|
game.announced = null;
|
|
changed();
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The push envelope `src/server/session.ts` sends over SSE — mirrored here rather than imported,
|
|
* so this file never depends on anything under `src/server/` even at the type level. */
|
|
type Push = {
|
|
frame?: FrameDelta;
|
|
menu: Menu | null;
|
|
lines: { text: string; tone: string }[];
|
|
/** One entry for a change; every other seat at once on the connect push. */
|
|
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
|
/**
|
|
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
|
|
*
|
|
* A remote session used to return nothing for any of them, so multiplayer had no sound at all, no
|
|
* timetable flash when the D12 filled a slot, no announcement when a completed run paid the table,
|
|
* and no highlight on the card you had just drawn — four things solitaire has had all along, and
|
|
* most of why a multiplayer game felt inert.
|
|
*
|
|
* `cues` and `announcement` are SHARED events (a collision anywhere, the Stage bell, a train
|
|
* leaving the Division) and reach every seat; `justDrawn` is the recipient's own card and nobody
|
|
* else's — `test/redaction.test.ts` covers exactly that.
|
|
*/
|
|
cues?: string[];
|
|
scheduled?: number | null;
|
|
announcement?: string | null;
|
|
justDrawn?: string | null;
|
|
};
|
|
|
|
/**
|
|
* A session backed by a server (Phase 2, extended Phase 4). Holds no authoritative state — no deck
|
|
* order, no other seat's hand — only the last `Frame`/`Menu` a push actually told it. `capabilities`
|
|
* are all `false`: undo would have to un-see what other players already saw, a local save is
|
|
* meaningless when the server is the store, and dealing a new game is the lobby's job.
|
|
*
|
|
* `token` is `lobby-and-sessions.md` §1's session token, issued at `Lobby.Create`/`Lobby.Join` and
|
|
* stored by the caller (`main.ts`, in `localStorage`) — it alone proves identity to `/api/stream`
|
|
* and `/api/intent`, so this function no longer takes a join secret at all; that gate belongs to
|
|
* the lobby endpoints only. `seat` still has to be passed in rather than learned from a push,
|
|
* because the very FIRST thing this session needs — `seat()` — has to answer before any push has
|
|
* necessarily arrived; the caller already knows it from the join/create/start response.
|
|
*
|
|
* Construction is synchronous (the `Session` interface has no async surface), but the first real
|
|
* `Frame` only exists once the SSE connection's first push arrives — `main.ts` accounts for this by
|
|
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities`
|
|
* are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
|
|
*/
|
|
export function createRemoteSession(
|
|
token: string,
|
|
seat: PlayerIndex,
|
|
/**
|
|
* Called once when this session's game is established to be gone for good, so the page can stop
|
|
* waiting for it. Without this the only symptom is a blank screen: `EventSource` retries a 404
|
|
* forever and reports nothing, and `frame` never becomes non-null.
|
|
*/
|
|
onGone?: () => void,
|
|
): Session {
|
|
let frame: Frame | null = null;
|
|
let menu: Menu | null = null;
|
|
let lines: { text: string; tone: string }[] = [];
|
|
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
|
|
let cues: string[] = [];
|
|
let scheduled: number | null = null;
|
|
let announcement: string | null = null;
|
|
let justDrawnCard: string | null = null;
|
|
let nextSeq = 1;
|
|
const listeners = new Set<() => void>();
|
|
const changed = (): void => {
|
|
for (const fn of [...listeners]) fn();
|
|
};
|
|
|
|
const qs = `token=${encodeURIComponent(token)}`;
|
|
const source = new EventSource(`/api/stream?${qs}`);
|
|
|
|
/**
|
|
* A DROPPED CONNECTION AND A DEAD GAME LOOK IDENTICAL HERE, so ask before giving up.
|
|
*
|
|
* `EventSource` fires `error` for both a transient blip — which it recovers from by itself, and
|
|
* which is the expected shape of a game that sits idle for minutes (multiplayer.md §9) — and a
|
|
* 404 it will nonetheless retry forever. It exposes no status code either way. `/api/session` is
|
|
* the cheap question that separates them: only a definite 404 closes the stream and reports the
|
|
* game gone, so a flaky network still self-heals.
|
|
*/
|
|
let reportedGone = false;
|
|
source.onerror = () => {
|
|
if (reportedGone) return;
|
|
void fetch(`/api/session?${qs}`)
|
|
.then((r) => {
|
|
if (r.status !== 404 || reportedGone) return;
|
|
reportedGone = true;
|
|
source.close();
|
|
onGone?.();
|
|
})
|
|
.catch(() => {
|
|
// The probe itself failed, so this says nothing about the game — leave the retry running.
|
|
});
|
|
};
|
|
source.onmessage = (ev: MessageEvent<string>) => {
|
|
const push = JSON.parse(ev.data) as Push;
|
|
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
|
|
// seat's turn — only a push that actually came from the game (always carries a real `frame`,
|
|
// per `session.ts`'s `Push`) updates the board or the menu.
|
|
if (push.frame) {
|
|
frame = applyDelta(frame, push.frame);
|
|
menu = push.menu;
|
|
}
|
|
lines = [...lines, ...push.lines];
|
|
for (const p of push.presence ?? []) presence.set(p.seat, { connected: p.connected, seen: p.seen });
|
|
// Accumulated rather than replaced: two pushes can arrive between two renders, and a cue that
|
|
// was earned is a cue that should be heard.
|
|
if (push.cues) cues = [...cues, ...push.cues];
|
|
if (push.scheduled !== undefined && push.scheduled !== null) scheduled = push.scheduled;
|
|
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
|
|
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
|
|
if (push.justDrawn !== undefined) justDrawnCard = push.justDrawn;
|
|
changed();
|
|
};
|
|
|
|
const need = (): Frame => {
|
|
if (frame === null) throw new Error('RemoteSession.view() called before the first Frame arrived');
|
|
return frame;
|
|
};
|
|
|
|
return {
|
|
view: need,
|
|
menu: () => menu ?? { options: [], direct: [], placeable: [], hand: [], makeUp: null },
|
|
seat: () => seat,
|
|
actor: () => need().actor,
|
|
overHandLimit: () => need().overHandLimit,
|
|
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
|
async submit(intent: Intent): Promise<boolean> {
|
|
const seq = nextSeq++;
|
|
const res = await fetch(`/api/intent?${qs}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ seq, intent }),
|
|
});
|
|
const result = (await res.json()) as { ok: boolean; code?: string };
|
|
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
|
|
// not from this response — this only reports whether the rules accepted it.
|
|
return result.ok;
|
|
},
|
|
subscribe(fn: () => void) {
|
|
listeners.add(fn);
|
|
return () => listeners.delete(fn);
|
|
},
|
|
capabilities: { undo: false, saveLocal: false, newGame: false },
|
|
|
|
lines: () => lines,
|
|
// Draining, exactly as the local session's are: each of these marks a moment, so it plays once
|
|
// and is gone by the next render rather than re-firing on every redraw.
|
|
takeCues: () => {
|
|
const out = cues;
|
|
cues = [];
|
|
return out;
|
|
},
|
|
takeScheduled: () => {
|
|
const out = scheduled;
|
|
scheduled = null;
|
|
return out;
|
|
},
|
|
takeAnnouncement: () => {
|
|
const out = announcement;
|
|
announcement = null;
|
|
return out;
|
|
},
|
|
justDrawn: () => justDrawnCard,
|
|
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
|
|
close() {
|
|
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
|
|
// a game that vanished — `onGone` must not be called and land the page in "that game is no
|
|
// longer on this server".
|
|
reportedGone = true;
|
|
source.close();
|
|
listeners.clear();
|
|
},
|
|
};
|
|
}
|