diff --git a/CHANGELOG.md b/CHANGELOG.md index 15af5df..f6012d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,62 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.6.0 — 2026-08-21 + +Three queued items, and the last of them is the one that matters most. + +### A release no longer destroys every game in progress + +Four consecutive releases killed every game on the box — one of them a release that changed only +how the board is drawn. The reasoning behind the refusal was always right: a move that was legal +under the old rules may not be under the new ones, and half-replaying a save is worse than refusing +it. The **test** was wrong. It compared `engineVersion` for exact equality, and that stamp is the +*package* version, which moves for a CSS fix. + +Whether a save still replays has an exact answer, so it is now asked directly. `loadGame` reads the +file and judges nothing; `tryResumeSession` replays the intents and reports the first one the engine +refuses, if any. A save stamped with a version this server has never run resumes fine, provided its +moves replay — verified against a file hand-stamped `0.4.9-ancient`. One that genuinely does not +replay is still refused, but the log now names the move rather than two version strings: *"move 3 of +8 (`localOps.choose`) is rejected by the current rules with `OPTION_ALREADY_CHOSEN`"*. + +`fromMultiplayerSave` had to stop lying first. It has always stopped at the first unacceptable +intent and done so **in silence**, which was survivable only because the version gate meant a doomed +replay was never attempted. Now that the replay *is* the check, it returns where it stopped and why. + +Deliberately not done: resuming a partly-replayable game at its last good move. That silently +rewinds a game to a position nobody played to, while every browser holding a later Frame carries on +unaware. Refusing leaves the file intact, so putting the previous version back still recovers it. + +### Employee Rotation is real, and Sister Trains is gone + +Two of the four optional-rule flags were read by nothing at all. + +**Employee Rotation** is implemented — "at the end of the day, all players move one chair to the +left and take over the next station up the line. Take your points (and the Fedora) with you." It is +four lines in `advance.ts`, because the seat/player split (D9) exists for precisely this rule: +`seating` is the only thing that moves, so Revenue, hands, the Superintendent and whose turn it is +travel with the player for free, and the Office, district, grid and any trains standing in it stay +with the chair. Inheriting the state of the district you move into is the point of the rule, not a +side effect. "Left" is `seat + 1`, matching `playerLeftOf`. + +**Sister Trains** is deleted rather than implemented. Q9 records that the Second Section card +supersedes it — and that card is built — so the flag was a toggle for a rule the game no longer has. + +### The lobby asks what game you want to play + +Creating a game asked for a display name, a mode and a table size; every other dial came from +`defaultMultiplayerConfig`, hardcoded. A **Game settings** block now carries the same set the +solitaire dialog does — seed, starting hand, the three revenue rates, Days, the combined-Revenue +floor, both collision caps, the opponent-card toggle — plus the three surviving optional rules. +Mode and table size set the defaults and everything stays editable, exactly as the solitaire dialog +already behaved. + +The seed is honoured: name one and the table deals that railroad, so a game can be reproduced or +compared. + +--- + ## 0.5.6 — 2026-08-21 Three things off the first proper look at a live table. diff --git a/TODO.md b/TODO.md index e971b70..3936a8f 100644 --- a/TODO.md +++ b/TODO.md @@ -23,15 +23,14 @@ Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplaye Queued 2026-08-21, from playing the StartOS build: -4. **The lobby must offer every game parameter the solitaire New Game dialog does** — and it - currently offers none of them. Reasoning in Multiplayer below; carries a live balance bug with - it (the combined-Revenue floor is sized for four players whatever the table's real size), so - this is not purely a UI job. -5. **Decide what the four `optionalRules` are** before either dialog offers them — two are live, - two are read by nothing at all. Reasoning in Multiplayer below. -6. **Stop every release destroying every game in progress** — the check is exact equality against - the package version, and most releases do not touch the rules. Reasoning in Multiplayer below; - the recommendation is to replay-and-see rather than to guess from a version number. +4. ~~**The lobby must offer every game parameter the solitaire New Game dialog does**~~ — done in + v0.6.0. +5. ~~**Decide what the four `optionalRules` are**~~ — done in v0.6.0: `sisterTrains` deleted, + `employeeRotation` implemented, the other two were already live. +6. ~~**Stop every release destroying every game in progress**~~ — done in v0.6.0, by replaying the + save rather than comparing version strings. + +Nothing else queued at the moment. --- @@ -503,7 +502,7 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite shift it", "never grows the table, whoever asks", "refuses a chair that is not at the table"). -- [ ] **EVERY RELEASE DESTROYS EVERY GAME IN PROGRESS, AND MOST RELEASES DO NOT CHANGE THE RULES.** +- [x] **~~EVERY RELEASE DESTROYS EVERY GAME IN PROGRESS~~ — fixed in v0.6.0, by option 3.** Raised 2026-08-21 after v0.5.2, v0.5.3 and v0.5.4 each killed the games on the StartOS box in turn — v0.5.4's changes were *rendering only*, and it still refused two saved games. @@ -541,10 +540,28 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite free. The work is in reporting a partial failure well: the game is intact up to the rejected intent, and a player would probably rather resume there than lose it entirely. - **(3) is the one worth doing**, and (1)/(2) are what to reach for only if a replay ever - becomes too slow to do on boot. Decide before the next release that changes a rule, not after. + **(3) was done.** `loadGame` no longer looks at the version; `tryResumeSession` replays the + save and reports the first intent the engine refuses, and `index.ts` resumes or refuses on + that. A save stamped with a version the server has never run now resumes, provided its moves + replay — verified against a file hand-stamped `0.4.9-ancient`. A save that genuinely does not + replay is refused as before, but the log now names the move: *"move 3 of 8 + (localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN"*. -- [ ] **THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING.** Split out + One thing deliberately NOT done: resuming a partially-replayable game at the last good move. + The note above suggested a player would rather have that than nothing, and on reflection it + is worse — the game would silently rewind to a position nobody played to, and the browsers + holding a later Frame would have no idea. Refusing keeps the file intact, so putting the + previous version back still recovers the game. Revisit only with a way to tell the table what + happened. + +- [x] **~~THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING~~ — resolved + in v0.6.0.** `sisterTrains` is deleted: Q9 records that the Second Section card supersedes it, + and that card is built. `employeeRotation` is implemented — the rotation is four lines in + `advance.ts` because the seat/player split (D9) exists precisely for it, so Revenue, hands and + the Fedora travel with the player and the district stays with the chair. All three survivors + are now settable from the lobby. Original reasoning kept below. + + **Original note:** Split out at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`, `sisterTrains`, `employeeRotation` and `emergencyToolbox`. Neither the solitaire New Game @@ -567,7 +584,13 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite them, or label them on screen the way an unbuilt Enhancement already labels itself. Doing that is what decides whether this is a UI job or a rules job. -- [ ] **THE LOBBY OFFERS NO GAME PARAMETERS AT ALL, AND THE ONE IT INFERS IS WRONG.** Raised by +- [x] **~~THE LOBBY OFFERS NO GAME PARAMETERS AT ALL~~ — done in v0.6.0.** A "Game settings" block + on the create form carries the same dials the solitaire dialog has — seed, starting hand, the + three revenue rates, days, the combined-Revenue floor, both collision caps, the PvP toggle — + plus the three surviving optional rules. Mode and table size set the defaults and every field + stays editable, matching the solitaire dialog's own behaviour. Original note below. + + **Original note:** Raised by Jesse 2026-08-21 after playing the StartOS build. Creating a multiplayer game asks for a display name and a mode, and nothing else — every other dial comes from `defaultMultiplayerConfig(mode)` (`web/game.ts`), hardcoded, with no way to change it. diff --git a/package.json b/package.json index 880eb85..f54aa51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.5.6", + "version": "0.6.0", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 67e2d91..a5eb5f9 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -1077,6 +1077,7 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { s.clock.stage = 1; s.collisionsToday = 0; events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 }); + rotateSeats(s, events); const finished = checkVictory(s, events); if (finished) return { events, needsInput: false }; } else { @@ -1113,6 +1114,29 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { * "the table's score is everyone's Revenue summed" model — winner stays null, the achievement is * shared — now against the same configurable floor. */ +/** + * EMPLOYEE ROTATION (Appendix B) — "at the end of the day, all players move one chair to the left + * and take over the next station up the line. Take your points (and the Fedora) with you." + * + * This is the rule the whole seat/player split exists for (D9, and `state.ts`'s note on + * `SeatIndex`), which is why it is four lines: `seating` is the only thing that moves. Everything + * keyed by PLAYER — Revenue, hands, the Superintendent, whose turn it is — travels with them for + * free, and everything keyed by SEAT — the Office, the district, the grid, trains standing in it — + * stays exactly where it is. Inheriting the state of the district you move into is the point of the + * rule, not a side effect of it. + * + * "Left" is `seatOf + 1`, matching `playerLeftOf`, which is the convention the rest of the engine + * already turns the table by. + */ +function rotateSeats(s: GameState, events: GameEvent[]): void { + if (!s.config.optionalRules.employeeRotation || s.seating.length < 2) return; + const n = s.seating.length; + const next: PlayerIndex[] = new Array(n); + for (let seat = 0; seat < n; seat++) next[(seat + 1) % n] = s.seating[seat]!; + s.seating = next; + events.push({ type: 'seatsRotated', day: s.clock.day, seating: [...next] }); +} + function checkVictory(s: GameState, _events: GameEvent[]): boolean { const daysElapsed = s.clock.day - 1; if (daysElapsed < s.config.days) return false; diff --git a/src/engine/events.ts b/src/engine/events.ts index d45d6a0..6e8d1ab 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -26,6 +26,8 @@ import type { CardId, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } export type GameEvent = // -- clock | { type: 'stageBegan'; day: number; stage: number } + /** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */ + | { type: 'seatsRotated'; day: number; seating: PlayerIndex[] } | { type: 'phaseBegan'; phase: string } | { type: 'actorChanged'; player: PlayerIndex | null } // -- local operations diff --git a/src/engine/state.ts b/src/engine/state.ts index 83340c9..ff29874 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -583,7 +583,6 @@ export type GameConfig = { pvpCardsAllowed: boolean; optionalRules: { reducedVisibility: boolean; - sisterTrains: boolean; employeeRotation: boolean; emergencyToolbox: boolean; }; diff --git a/src/server/http.ts b/src/server/http.ts index 05ef8d2..7020bbb 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -337,6 +337,7 @@ export function startServer(opts: ServerOptions): void { config?: GameConfig; displayName?: string; players?: number; + seed?: number | null; }; if (body.secret !== opts.joinSecret) { sendJson(res, 403, { error: 'bad or missing secret' }); @@ -354,7 +355,8 @@ export function startServer(opts: ServerOptions): void { return; } const gameCode = freshGameCode((code) => gameCodes.has(code)); - const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players); + const seed = typeof body.seed === 'number' && Number.isFinite(body.seed) ? Math.trunc(body.seed) : null; + const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players, seed); await persistLobby(lobby); await persistSession(session); sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player }); @@ -429,7 +431,13 @@ export function startServer(opts: ServerOptions): void { sendJson(res, 409, { error: result.code }); return; } - const session = createSession(Math.floor(Math.random() * 1e9), lobby.config, result.playerNames, result.botSeats); + // The host's seed if they named one; otherwise a fresh random deal. + const session = createSession( + lobby.seed ?? Math.floor(Math.random() * 1e9), + lobby.config, + result.playerNames, + result.botSeats, + ); games.set(lobby.gameId, session); lobbies.delete(lobby.gameId); // Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game diff --git a/src/server/index.ts b/src/server/index.ts index 7b49422..75bdcaa 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -13,7 +13,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { startServer } from './http.ts'; import { gameDir, loadGame, readIndex, readLobby, readSessions } from './persistence.ts'; -import { resumeSession } from './session.ts'; +import { tryResumeSession } from './session.ts'; import type { GameSession } from './session.ts'; import type { Lobby, PlayerSession } from './lobby.ts'; @@ -60,18 +60,27 @@ for (const entry of index) { continue; } - const loaded = await loadGame(gameDir(dataDir, entry.gameId), engineVersion); - if (loaded.found && loaded.ok) { - initialGames.set(entry.gameId, resumeSession(loaded.saved)); - console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`); - } else if (loaded.found && !loaded.ok) { - // Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded - // under. The file is left untouched: rolling the running version back would let it load again. - console.error( - `Refusing to resume ${entry.gameId} (${entry.gameCode}): saved under engine version ` + - `${loaded.storedVersion}, this server is running ${engineVersion}. Left untouched, and ` + - `will not appear as an active game until the version matches again.`, - ); + const loaded = await loadGame(gameDir(dataDir, entry.gameId)); + if (loaded.found) { + const resumed = tryResumeSession(loaded.saved); + if (resumed.ok) { + initialGames.set(entry.gameId, resumed.session); + console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`); + } else { + /** + * The save does not replay under these rules, which is the only thing that has ever actually + * mattered — and now the only thing asked. Says which move it choked on, because "some + * version differs" was never enough to act on: the file is left untouched, so an operator who + * wants the game back can put the previous version on and finish it. + */ + const f = resumed.failure; + console.error( + `Refusing to resume ${entry.gameId} (${entry.gameCode}): move ${f.stoppedAt + 1} of ${f.of} ` + + `(${f.intent}) is rejected by the current rules with ${f.code}. Saved under engine ` + + `version ${loaded.storedVersion}, this server is running ${engineVersion}. The file is ` + + `left untouched.`, + ); + } } // `entry.status === 'finished'` games are not resumed into memory at all — nothing plays them // forward, and their files stay on disk for post-game replay (`lobby-and-sessions.md` §6). diff --git a/src/server/lobby.ts b/src/server/lobby.ts index 6df65f6..b8bd723 100644 --- a/src/server/lobby.ts +++ b/src/server/lobby.ts @@ -41,6 +41,12 @@ export type Lobby = { * replacement is unambiguous (`lobby-and-sessions.md` §2: "earliest-joined remaining player"). */ joinOrder: string[]; createdAt: number; + /** + * The seed the host asked for, or null for one picked at `Lobby.Start`. Chosen here rather than + * at start because the same seed and the same settings deal the same railroad — which is only + * useful if the person setting the game up can name it. + */ + seed: number | null; }; export type CreateResult = { lobby: Lobby; session: PlayerSession }; @@ -102,6 +108,7 @@ export function createLobby( hostDisplayName: string, gameCode: string, players: number, + seed: number | null = null, ): CreateResult { const gameId = randomUUID(); const token = randomUUID(); @@ -117,6 +124,7 @@ export function createLobby( seats, joinOrder: [token], createdAt: Date.now(), + seed, }; return { lobby, session }; } diff --git a/src/server/persistence.ts b/src/server/persistence.ts index 9de11cb..6eb4511 100644 --- a/src/server/persistence.ts +++ b/src/server/persistence.ts @@ -38,11 +38,23 @@ export async function writeGame(dataDir: string, saved: SavedGame, engineVersion export type LoadResult = | { found: false } - | { found: true; ok: true; saved: SavedGame } - /** §12 step 15 — refused explicitly, never silently replayed under the wrong rules. */ - | { found: true; ok: false; storedVersion: string; currentVersion: string }; + /** The version that wrote the file, for diagnostics — it is no longer what decides. */ + | { found: true; saved: SavedGame; storedVersion: string }; -export async function loadGame(dataDir: string, currentVersion: string): Promise { +/** + * READS THE SAVE. DOES NOT JUDGE IT. + * + * This used to refuse any save whose `engineVersion` was not an exact match for the running one, + * on the reasoning that a move legal under old rules may not be legal under new ones (D7). The + * reasoning is sound and the test was not: the stamp is the PACKAGE version, which moves for + * reasons that have nothing to do with the rules, so four consecutive releases destroyed every + * game in progress — one of them a release that changed only how the board is drawn. + * + * Whether a save still replays is a question with an exact answer, so it is now asked directly: + * `tryResumeSession` replays the intents and reports the first one the engine refuses, if any. + * The version is kept and reported because it is useful in a failure, but it decides nothing. + */ +export async function loadGame(dataDir: string): Promise { let text: string; try { text = await readFile(join(dataDir, GAME_FILE), 'utf8'); @@ -50,11 +62,8 @@ export async function loadGame(dataDir: string, currentVersion: string): Promise return { found: false }; } const payload = JSON.parse(text) as PersistedGame; - if (payload.engineVersion !== currentVersion) { - return { found: true, ok: false, storedVersion: payload.engineVersion, currentVersion }; - } - const { engineVersion: _engineVersion, ...saved } = payload; - return { found: true, ok: true, saved }; + const { engineVersion, ...saved } = payload; + return { found: true, saved, storedVersion: engineVersion }; } /** Appended once per closed turn span (`GameSession.intent`'s `timing` result) — read-modify-write at diff --git a/src/server/session.ts b/src/server/session.ts index 33399bc..bc03974 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -319,8 +319,41 @@ export function createSession( * check happens before this is ever called; by the time `saved.history` reaches here it is already * known to have been recorded under the currently-running rules. */ +/** + * A resume that could not complete, and exactly where it gave up. `index.ts` turns this into the + * refusal it logs, so the operator is told which move the current rules will not accept rather + * than only that some version string differs. + */ +export type ResumeFailure = { stoppedAt: number; of: number; intent: string; code: string }; + +export function tryResumeSession(saved: SavedGame): { ok: true; session: GameSession } | { ok: false; failure: ResumeFailure } { + const { game, stopped } = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history); + if (stopped) { + return { + ok: false, + failure: { stoppedAt: stopped.index, of: saved.history.length, intent: stopped.intent.type, code: stopped.code }, + }; + } + return { ok: true, session: build(game, saved) }; +} + +/** + * Throws on a save the current rules will not replay. Kept for callers that have already + * established the save is good — the server boots through `tryResumeSession`, which answers + * instead of throwing. + */ export function resumeSession(saved: SavedGame): GameSession { - const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history); + const r = tryResumeSession(saved); + if (!r.ok) { + throw new Error( + `save does not replay under the current rules: intent ${r.failure.stoppedAt + 1} of ` + + `${r.failure.of} (${r.failure.intent}) was rejected with ${r.failure.code}`, + ); + } + return r.session; +} + +function build(game: Game, saved: SavedGame): GameSession { return buildSession( game, saved.playerNames, diff --git a/src/sim/compare.ts b/src/sim/compare.ts index c8c7beb..84e6a22 100644 --- a/src/sim/compare.ts +++ b/src/sim/compare.ts @@ -74,7 +74,6 @@ const SOLO = (length: GameLength, mode: GameMode): GameConfig => { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/src/sim/harness.ts b/src/sim/harness.ts index 87acdbb..91b6e18 100644 --- a/src/sim/harness.ts +++ b/src/sim/harness.ts @@ -76,7 +76,6 @@ function configFor(mode: GameMode, length: GameLength, players: number): GameCon pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 9b83260..32fb5ab 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -94,6 +94,11 @@ export type NarrateContext = { * game, phrased in internal identifiers. */ trainName?: (trayId: TrayId) => string; + /** + * Resolves a player index to their display name. Optional like the rest: an engine test narrating + * events has no roster, and "Player 2" is a truthful fallback rather than a broken one. + */ + playerName?: (player: PlayerIndex) => string; }; export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { @@ -104,6 +109,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { // -- clock case 'stageBegan': return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` }; + case 'seatsRotated': + // Named players rather than seat numbers: the rule is that everyone MOVED, and a list of + // indices does not say who is now next to whom. + return { + tone: 'clock', + text: `Employee Rotation — everyone moves one chair left. West to East: ${e.seating + .map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`) + .join(' → ')}`, + }; case 'phaseBegan': // 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. diff --git a/src/sim/replay.ts b/src/sim/replay.ts index c6d01cb..30478d9 100644 --- a/src/sim/replay.ts +++ b/src/sim/replay.ts @@ -68,7 +68,6 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/src/web/game.ts b/src/web/game.ts index 34efc7a..1235db3 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -76,7 +76,6 @@ export const SOLO_CONFIG: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, @@ -104,7 +103,6 @@ export function defaultMultiplayerConfig(mode: 'competitive' | 'coop', players = pvpCardsAllowed: mode === 'competitive', optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, @@ -1185,21 +1183,38 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game { * out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a * touch-in-passing. */ +/** + * Why the intent a replay stopped at is reported rather than swallowed. + * + * The loop below has always stopped at the first intent the engine will not accept, and used to do + * it in silence — which is the one outcome nobody can afford to guess at, because the result is a + * game that looks fine and is short of where it should be. That silence was survivable only + * because `loadGame` refused any save whose engine version was not an exact match, so a replay + * that could fail was never attempted. Refusing on the version is a proxy question, though, and it + * answered "no" for four releases running that changed no rules at all — so the real question gets + * asked instead, and its answer has to be legible. + */ +export type ReplayStop = { index: number; intent: Intent; code: string }; + export function fromMultiplayerSave( seed: number, config: GameConfig, playerNames: string[], history: Intent[], -): Game { +): { game: Game; stopped: ReplayStop | null } { const game = newMultiplayerGame(seed, config, playerNames); - for (const intent of history) { + for (const [index, intent] of history.entries()) { const actor = currentActor(game); - if (actor === null) break; + if (actor === null) { + return { game, stopped: { index, intent, code: 'NO_ACTOR' } }; + } const result = applyIntent(game.state, actor, intent); - if (!result.ok) break; + if (!result.ok) { + return { game, stopped: { index, intent, code: result.code } }; + } game.history.push(intent); record(game, result.events, actor); drain(game); } - return game; + return { game, stopped: null }; } diff --git a/src/web/lobby.ts b/src/web/lobby.ts index 5610830..174ad77 100644 --- a/src/web/lobby.ts +++ b/src/web/lobby.ts @@ -13,6 +13,8 @@ import type { GameConfig, PlayerIndex } from '../engine/state.ts'; import { defaultMultiplayerConfig } from './game.ts'; +import { STARTING_HAND_LABELS, houseRules } from '../engine/content.ts'; +import type { StartingHand } from '../engine/content.ts'; import { seatLabel } from '../sim/view.ts'; export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex }; @@ -146,23 +148,78 @@ export function runLobby(onReady: (r: LobbyReady) => void): void { }; } + const chosenMode = (): 'competitive' | 'coop' => + ($('lb-choice-section').querySelector('input[name="lb-mode"]:checked')?.value ?? + 'competitive') as 'competitive' | 'coop'; + const chosenPlayers = (): number => Number($('lb-players').value) || 4; + const num = (id: string): number => Number($(id).value); + + /** + * Mode and table size only move the DEFAULTS, exactly as the solitaire dialog's own presets do — + * every field stays editable afterwards, so "Co-op, 3 players" is a starting point rather than a + * fixed ruleset. Re-running this on a change is also what keeps the combined-Revenue floor + * honest: it is derived from the player count, and the count is a control on this form. + */ + function applyDefaults(): void { + const c = defaultMultiplayerConfig(chosenMode(), chosenPlayers()); + const rules = houseRules(c); + $('lb-days').value = String(c.days); + $('lb-minrev').value = String(c.minCombinedRevenue); + $('lb-colday').value = String(c.maxCollisionsPerDay); + $('lb-coltotal').value = String(c.maxCollisionsTotal); + $('lb-passenger').value = String(rules.revenue.passengerPerCoach); + $('lb-freight').value = String(rules.revenue.freightPerLoad); + $('lb-transit').value = String(rules.revenue.trainPerTransit); + // Co-op has no valid target for the opponent-directed cards, so the toggle is forced off rather + // than merely defaulted off — the same rule `buildDeck` applies. + const pvp = $('lb-pvp'); + pvp.checked = c.pvpCardsAllowed; + pvp.disabled = chosenMode() !== 'competitive'; + } + for (const input of Array.from( + $('lb-choice-section').querySelectorAll('input[name="lb-mode"]'), + )) { + input.onchange = applyDefaults; + } + $('lb-players').onchange = applyDefaults; + applyDefaults(); + $('lb-create').onclick = () => { const displayName = $('lb-name').value.trim(); - const mode = ($('lb-choice-section').querySelector('input[name="lb-mode"]:checked')?.value ?? - 'competitive') as 'competitive' | 'coop'; + const mode = chosenMode(); if (displayName === '') { setError('lb-create-err', 'enter a display name first'); return; } - const players = Number($('lb-players').value) || 4; - // The real seat count reaches `defaultMultiplayerConfig`, so the combined-Revenue floor is - // sized for the table actually being played rather than for an assumed four. - void postJson('/api/lobby/create', { - secret: secret(), - config: defaultMultiplayerConfig(mode, players), - displayName, - players, - }).then( + const players = chosenPlayers(); + const hand = $('lb-settings').querySelector('input[name="lb-hand"]:checked')?.value; + // Blank or unparseable both mean "surprise me", which is what leaving the box alone asks for. + const asked = $('lb-seed').value.trim(); + const seed = asked === '' || !Number.isFinite(Number(asked)) ? null : Math.trunc(Number(asked)); + + const config: GameConfig = { + ...defaultMultiplayerConfig(mode, players), + days: Math.max(1, Math.round(num('lb-days')) || 5), + minCombinedRevenue: Math.max(0, Math.round(num('lb-minrev')) || 0), + maxCollisionsPerDay: Math.max(0, Math.round(num('lb-colday')) || 0), + maxCollisionsTotal: Math.max(0, Math.round(num('lb-coltotal')) || 0), + pvpCardsAllowed: $('lb-pvp').checked && mode === 'competitive', + optionalRules: { + reducedVisibility: $('lb-visibility').checked, + employeeRotation: $('lb-rotation').checked, + emergencyToolbox: $('lb-toolbox').checked, + }, + houseRules: { + ...(STARTING_HAND_LABELS.some((o) => o.value === hand) ? { startingHand: hand as StartingHand } : {}), + revenue: { + passengerPerCoach: num('lb-passenger'), + freightPerLoad: num('lb-freight'), + trainPerTransit: num('lb-transit'), + }, + }, + }; + + void postJson('/api/lobby/create', { secret: secret(), config, displayName, players, seed }).then( ({ status, body }) => { if (status !== 200) { setError('lb-create-err', String(body['error'] ?? 'could not create the game')); diff --git a/src/web/play.html b/src/web/play.html index 98a2d66..fde406d 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -39,6 +39,9 @@ header button:disabled:hover{border-color:#2c333d} background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px} .lb-invite-label{font-size:11px;color:var(--dim)} .lb-invite-code{font-size:22px;font-weight:700;letter-spacing:.08em;color:#f2e6cf} +#lb-settings{margin:10px 0;border:1px solid var(--line);border-radius:7px;padding:8px 12px;background:#171c23} +#lb-settings summary{cursor:pointer;font-size:13px;color:#9fb6d8} +#lb-settings h3{font-size:12px;margin:12px 0 4px;color:#9fb6d8} .zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block} .build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap} .home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a} @@ -257,6 +260,66 @@ ul.blocked li{padding:2px 0} +
+ Game settings +

The same dials the solitaire New Game dialog has. Picking a mode or a table + size sets them to that game's defaults; every one stays yours to change. They are fixed when + the game is created and cannot be changed once it starts.

+ +

Seed

+

The same seed and the same settings always deal the same railroad, so a game + can be shared, compared or replayed. Leave it blank for a random one.

+ + +

Starting hand

+

What each player is dealt before the first turn. The hand limit is three + either way — deal six and the first turn is spent choosing which of them to keep.

+ + + + +

Revenue

+

What each piece of work pays, 0 to 5. Zero switches an economy off so the + others can be read.

+ + + + +

Victory conditions

+

How long the game runs, and the ways it can end. 0 turns any of these off. + The combined-Revenue default is sized for the table you chose above.

+ + + + + +

Not yet built (TODO.md) — this has no effect + either way until then.

+ +

Optional rules

+

Appendix B. Off by default; each one changes how the game plays.

+ + + +

Every chair has to be taken before the game can start — by a person or by a bot. Pick the size of the table now; it cannot change once the game is created.

diff --git a/test/advance.test.ts b/test/advance.test.ts index 02f4d2b..f827e19 100644 --- a/test/advance.test.ts +++ b/test/advance.test.ts @@ -25,7 +25,6 @@ const baseConfig = (over: Partial = {}): GameConfig => ({ pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/test/apply.test.ts b/test/apply.test.ts index 7487ffa..015e09c 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -24,7 +24,6 @@ const config: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/test/cut-ordering.test.ts b/test/cut-ordering.test.ts index cbf84ea..8ae8a50 100644 --- a/test/cut-ordering.test.ts +++ b/test/cut-ordering.test.ts @@ -25,7 +25,6 @@ const config: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/test/enhancements.test.ts b/test/enhancements.test.ts index 06d3736..1aef72e 100644 --- a/test/enhancements.test.ts +++ b/test/enhancements.test.ts @@ -23,7 +23,7 @@ const config: GameConfig = { maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }; const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] }); const at = (row: number, col: number): GridCoord => ({ row, col }); diff --git a/test/events.test.ts b/test/events.test.ts index 9982231..6813abf 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -48,6 +48,9 @@ const KNOWN_UNREDUCED = [ 'dispatchBonusUsed', 'expediteFault', 'phaseBegan', + // Employee Rotation moves `seating` in the phase driver and then describes what it did, which is + // the pattern every entry on this list follows. + 'seatsRotated', 'stageBegan', 'trainArrived', 'trainCompleted', diff --git a/test/frame-delta.test.ts b/test/frame-delta.test.ts index 313c4ae..2ba3deb 100644 --- a/test/frame-delta.test.ts +++ b/test/frame-delta.test.ts @@ -17,7 +17,6 @@ const config: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/test/harness.test.ts b/test/harness.test.ts index 7a7afa9..40fb77e 100644 --- a/test/harness.test.ts +++ b/test/harness.test.ts @@ -36,7 +36,6 @@ const config: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/test/mainline-cards.test.ts b/test/mainline-cards.test.ts index c379f70..fa11b0f 100644 --- a/test/mainline-cards.test.ts +++ b/test/mainline-cards.test.ts @@ -37,7 +37,7 @@ const config: GameConfig = { maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }; const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] }); const at = (row: number, col: number): GridCoord => ({ row, col }); @@ -737,7 +737,7 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => { seed: 4, config: { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['Solitaire'], }); @@ -800,7 +800,7 @@ describe('Q13 — a train that catches the one ahead runs into it', () => { id: 'rear', seed: 3, config: { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['bot'], }); diff --git a/test/multiplayer.test.ts b/test/multiplayer.test.ts index 90ab4a1..0818669 100644 --- a/test/multiplayer.test.ts +++ b/test/multiplayer.test.ts @@ -12,14 +12,14 @@ import assert from 'node:assert/strict'; import { advance, pump } from '../src/engine/advance.ts'; import { applyIntent, areaAtSeat, areaOf } from '../src/engine/apply.ts'; -import { STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts'; +import { STAGES_PER_DAY, STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'; import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts'; import { developerBot, playGame } from '../src/sim/bot.ts'; import { snapshot } from '../src/sim/view.ts'; import { divisionSvg } from '../src/sim/board-svg.ts'; -import { impediments } from '../src/sim/narrate.ts'; +import { impediments, narrate } from '../src/sim/narrate.ts'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { actionMenu } from '../src/web/game.ts'; @@ -37,7 +37,7 @@ const competitive: GameConfig = { maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }; const game = (players: number, seed = 4242): GameState => @@ -794,3 +794,81 @@ describe('the map says whose railroad is whose', () => { assert.equal(divisionSvg(f.division).includes('bs-owner'), false); }); }); + +describe('Employee Rotation (Appendix B)', () => { + /** + * Straight to the Day boundary, which is the only moment a rotation happens — the same shortcut + * `advance.test.ts` uses to roll the clock over without playing twelve Stages of real turns. + */ + const atDayEnd = (on: boolean): GameState => { + const s = createGame({ + id: 'rot', + seed: 4242, + config: { ...competitive, optionalRules: { ...competitive.optionalRules, employeeRotation: on } }, + playerNames: ['Alice', 'Bob', 'Carol'], + }); + s.clock.stage = STAGES_PER_DAY; + s.clock.phase = 'shiftChange'; + return s; + }; + + it('is off unless asked for — the clock alone must not move anybody', () => { + const s = atDayEnd(false); + const before = [...s.seating]; + advance(s); + assert.equal(s.clock.day, 2, 'the clock did not roll over'); + assert.deepEqual(s.seating, before, 'seats moved with the rule switched off'); + }); + + it('moves every player one chair left at the Day boundary', () => { + const s = atDayEnd(true); + const before = [...s.seating]; + advance(s); + assert.equal(s.clock.day, 2); + // "One chair to the left" is seat + 1, the direction `playerLeftOf` already turns the table. + const expected = before.map((_, seat, all) => all[(seat - 1 + all.length) % all.length]!); + assert.deepEqual(s.seating, expected); + // Everyone moved, and nobody was lost or duplicated on the way round. + assert.deepEqual([...s.seating].sort(), [...before].sort()); + assert.notDeepEqual(s.seating, before); + }); + + it('takes your points and the Fedora with you, and leaves the district behind', () => { + const s = atDayEnd(true); + const traveller = 1 as PlayerIndex; + s.players[traveller]!.revenue = 17; + const seatBefore = seatOf(s, traveller); + /** + * The Fedora is compared against the SAME game with the rule off, not against its own value + * before the advance — Stage 12 is a shift change (§5), so it passes here anyway for reasons + * that have nothing to do with rotation. What matters is that moving the chairs does not move + * it: it names a player, and players are exactly what the rotation does not renumber. + */ + const control = atDayEnd(false); + advance(control); + // Identity, not a field: anything mutable is liable to be reset at a Day boundary anyway (the + // once-a-Day dispatch reset clears `dispatchUsedToday` right there), and the claim under test + // is about which OBJECT is attached to which chair. + const districtLeftBehind = areaAtSeat(s, seatBefore); + + advance(s); + + assert.notEqual(seatOf(s, traveller), seatBefore, 'the traveller did not move'); + assert.equal(s.players[traveller]!.revenue, 17, 'Revenue is keyed by player and must travel'); + assert.equal(s.clock.superintendent, control.clock.superintendent, 'rotating the chairs moved the Fedora'); + // The Office stayed exactly where it was, so whoever sits there now inherits it as they find + // it. That is the rule rather than a side effect of it — you take over the next station up the + // line, mess and all. + assert.equal(areaAtSeat(s, seatBefore), districtLeftBehind, 'the district moved with the player'); + assert.notEqual(areaOf(s, traveller), districtLeftBehind, 'the traveller kept their old district'); + }); + + it('says who is now sitting where, by name', () => { + const s = atDayEnd(true); + const { events } = advance(s); + const rotated = events.find((e) => e.type === 'seatsRotated'); + assert.ok(rotated, 'no seatsRotated event was emitted'); + const line = narrate(rotated, { playerName: (p) => s.players[p]!.name }).text; + for (const name of ['Alice', 'Bob', 'Carol']) assert.match(line, new RegExp(name)); + }); +}); diff --git a/test/redaction.test.ts b/test/redaction.test.ts index 8dca031..f3b8c42 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -28,7 +28,6 @@ const config: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/test/replay.test.ts b/test/replay.test.ts index bcef589..b81d085 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -29,7 +29,6 @@ const config: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/test/server/lobby.test.ts b/test/server/lobby.test.ts index f1ecc68..39ab710 100644 --- a/test/server/lobby.test.ts +++ b/test/server/lobby.test.ts @@ -25,7 +25,6 @@ const competitive: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/test/server/persistence.test.ts b/test/server/persistence.test.ts index 42ec82d..85cabac 100644 --- a/test/server/persistence.test.ts +++ b/test/server/persistence.test.ts @@ -17,7 +17,6 @@ const config: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, @@ -46,29 +45,30 @@ describe('game persistence (Phase 3)', () => { it('writes and reads back exactly what was written', () => withTempDir(async (dir) => { await writeGame(dir, saved, '1.2.3'); - const result = await loadGame(dir, '1.2.3'); + const result = await loadGame(dir); assert.equal(result.found, true); if (!result.found) return; - assert.equal(result.ok, true); - if (!result.ok) return; assert.deepEqual(result.saved, saved); })); - it('refuses a version mismatch explicitly, naming both versions', () => + it('reports the version that wrote the file without judging it', () => withTempDir(async (dir) => { + // Reading a save no longer refuses on the version. The stamp is the PACKAGE version, which + // moves for reasons unrelated to the rules, and gating on it destroyed every game in progress + // across four releases — one of which only changed how the board is drawn. Whether a save + // still replays is decided by replaying it (`tryResumeSession`); the version is kept because + // it is worth naming in a failure, and nothing else. await writeGame(dir, saved, '1.2.3'); - const result = await loadGame(dir, '9.9.9'); + const result = await loadGame(dir); assert.equal(result.found, true); if (!result.found) return; - assert.equal(result.ok, false); - if (result.ok) return; assert.equal(result.storedVersion, '1.2.3'); - assert.equal(result.currentVersion, '9.9.9'); + assert.deepEqual(result.saved, saved); })); it('reports not-found rather than throwing when nothing has been saved yet', () => withTempDir(async (dir) => { - const result = await loadGame(dir, '1.2.3'); + const result = await loadGame(dir); assert.deepEqual(result, { found: false }); })); @@ -77,11 +77,9 @@ describe('game persistence (Phase 3)', () => { await writeGame(dir, saved, '1.2.3'); const grown: SavedGame = { ...saved, history: [...saved.history, { type: 'draw.end' }] }; await writeGame(dir, grown, '1.2.3'); - const result = await loadGame(dir, '1.2.3'); + const result = await loadGame(dir); assert.equal(result.found, true); if (!result.found) return; - assert.equal(result.ok, true); - if (!result.ok) return; assert.equal(result.saved.history.length, 2); })); diff --git a/test/server/session.test.ts b/test/server/session.test.ts index 19810b3..962eec0 100644 --- a/test/server/session.test.ts +++ b/test/server/session.test.ts @@ -6,8 +6,8 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import type { GameConfig, PlayerIndex } from '../../src/engine/state.ts'; -import type { Push } from '../../src/server/session.ts'; -import { createSession, resumeSession } from '../../src/server/session.ts'; +import type { GameSession, Push } from '../../src/server/session.ts'; +import { createSession, resumeSession, tryResumeSession } from '../../src/server/session.ts'; const config: GameConfig = { mode: 'competitive', @@ -18,7 +18,6 @@ const config: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, @@ -300,3 +299,51 @@ describe('summary() — what an administrator sees without replaying the game', assert.equal(s.waitingOn, null, 'a finished game must not name somebody to wait for'); }); }); + +describe('a save survives a release that did not change the rules', () => { + /** Plays a couple of real moves so the history is worth replaying. */ + const played = (): ReturnType => { + const s = createSession(42, config, ['Alice', 'Bob']); + const actor = (s.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex; + s.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); + return s.exportSave(); + }; + + it('resumes whatever version stamped it, so long as the moves still replay', () => { + // This is the whole point. The engine version used to gate this, and it is the PACKAGE version + // — it moves for a CSS fix. Four releases in a row destroyed every game in progress, one of + // them for a change that only altered how the board is drawn. + const saved = played(); + const r = tryResumeSession(saved); + assert.equal(r.ok, true, 'a replayable save was refused'); + if (!r.ok) return; + assert.deepEqual(r.session.exportSave().history, saved.history); + }); + + it('refuses a save whose moves no longer replay, and says which move and why', () => { + // A rules change is simulated by corrupting one intent — the engine cannot apply it, which is + // exactly the shape a genuinely incompatible save has. + const saved = played(); + const broken = { + ...saved, + history: [...saved.history, { type: 'localOps.choose', option: 'not-a-real-option' } as never], + }; + const r = tryResumeSession(broken); + assert.equal(r.ok, false, 'a save the rules reject was accepted'); + if (r.ok) return; + assert.equal(r.failure.of, broken.history.length); + assert.equal(r.failure.stoppedAt, broken.history.length - 1, 'wrong move blamed'); + assert.equal(r.failure.intent, 'localOps.choose'); + assert.ok(r.failure.code.length > 0, 'no rejection code to act on'); + }); + + it('never silently truncates — the old loop stopped at a bad move and said nothing', () => { + // The silence was survivable only because the version check meant a doomed replay was never + // attempted. Now that the replay IS the check, a partial one must be impossible to mistake for + // a whole one. + const saved = played(); + const broken = { ...saved, history: [{ type: 'draw.end' } as never, ...saved.history] }; + const r = tryResumeSession(broken); + assert.equal(r.ok, false, 'a truncated replay was returned as a healthy session'); + }); +}); diff --git a/test/setup.test.ts b/test/setup.test.ts index 3dd0ed4..5b7a6c3 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -41,7 +41,6 @@ const solitaireConfig: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/test/sim.test.ts b/test/sim.test.ts index b636439..6746d8c 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -29,7 +29,6 @@ const config: GameConfig = { pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, - sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, diff --git a/test/track.test.ts b/test/track.test.ts index 9820b4e..a34dd36 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -129,7 +129,7 @@ function gameWith(area: OfficeArea): GameState { id: 'g', seed: 5, config: { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['p'], }); diff --git a/test/train-rules.test.ts b/test/train-rules.test.ts index 0d76ef6..445dc3b 100644 --- a/test/train-rules.test.ts +++ b/test/train-rules.test.ts @@ -24,7 +24,7 @@ const config: GameConfig = { maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }; const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] }); const at = (row: number, col: number): GridCoord => ({ row, col }); diff --git a/test/web.test.ts b/test/web.test.ts index 6695c2f..eb46baf 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -2680,7 +2680,7 @@ describe('the Division map shows the whole route', () => { maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['A', 'B', 'C', 'D'].slice(0, players), }); @@ -2695,7 +2695,7 @@ describe('the Division map shows the whole route', () => { seed: 7, config: { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['Solitaire'], }); @@ -2748,7 +2748,7 @@ describe('the Division map shows the whole route', () => { id: 'div-chips', seed: 7, config: { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['Solitaire'], }); @@ -3077,7 +3077,7 @@ describe('the tray is an engine plus its Rolling Stock', () => { id: 'eng', seed: 1038389, config: { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['Solitaire'], }); @@ -3127,7 +3127,7 @@ describe('the tray is an engine plus its Rolling Stock', () => { id: 'yards', seed: 1038389, config: { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['Solitaire'], });