Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40f07b0710 |
@@ -19,6 +19,62 @@ page as `v0.1.0 · <sha> · <date>`, 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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<PlayerIndex>(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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -583,7 +583,6 @@ export type GameConfig = {
|
||||
pvpCardsAllowed: boolean;
|
||||
optionalRules: {
|
||||
reducedVisibility: boolean;
|
||||
sisterTrains: boolean;
|
||||
employeeRotation: boolean;
|
||||
emergencyToolbox: boolean;
|
||||
};
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
+22
-13
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<LoadResult> {
|
||||
/**
|
||||
* 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<LoadResult> {
|
||||
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
|
||||
|
||||
+34
-1
@@ -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,
|
||||
|
||||
@@ -74,7 +74,6 @@ const SOLO = (length: GameLength, mode: GameMode): GameConfig => {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -76,7 +76,6 @@ function configFor(mode: GameMode, length: GameLength, players: number): GameCon
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+22
-7
@@ -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 };
|
||||
}
|
||||
|
||||
+68
-11
@@ -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<HTMLInputElement>('input[name="lb-mode"]:checked')?.value ??
|
||||
'competitive') as 'competitive' | 'coop';
|
||||
const chosenPlayers = (): number => Number($<HTMLSelectElement>('lb-players').value) || 4;
|
||||
const num = (id: string): number => Number($<HTMLInputElement>(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);
|
||||
$<HTMLInputElement>('lb-days').value = String(c.days);
|
||||
$<HTMLInputElement>('lb-minrev').value = String(c.minCombinedRevenue);
|
||||
$<HTMLInputElement>('lb-colday').value = String(c.maxCollisionsPerDay);
|
||||
$<HTMLInputElement>('lb-coltotal').value = String(c.maxCollisionsTotal);
|
||||
$<HTMLInputElement>('lb-passenger').value = String(rules.revenue.passengerPerCoach);
|
||||
$<HTMLInputElement>('lb-freight').value = String(rules.revenue.freightPerLoad);
|
||||
$<HTMLInputElement>('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 = $<HTMLInputElement>('lb-pvp');
|
||||
pvp.checked = c.pvpCardsAllowed;
|
||||
pvp.disabled = chosenMode() !== 'competitive';
|
||||
}
|
||||
for (const input of Array.from(
|
||||
$('lb-choice-section').querySelectorAll<HTMLInputElement>('input[name="lb-mode"]'),
|
||||
)) {
|
||||
input.onchange = applyDefaults;
|
||||
}
|
||||
$<HTMLSelectElement>('lb-players').onchange = applyDefaults;
|
||||
applyDefaults();
|
||||
|
||||
$<HTMLButtonElement>('lb-create').onclick = () => {
|
||||
const displayName = $<HTMLInputElement>('lb-name').value.trim();
|
||||
const mode = ($('lb-choice-section').querySelector<HTMLInputElement>('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($<HTMLSelectElement>('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<HTMLInputElement>('input[name="lb-hand"]:checked')?.value;
|
||||
// Blank or unparseable both mean "surprise me", which is what leaving the box alone asks for.
|
||||
const asked = $<HTMLInputElement>('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: $<HTMLInputElement>('lb-pvp').checked && mode === 'competitive',
|
||||
optionalRules: {
|
||||
reducedVisibility: $<HTMLInputElement>('lb-visibility').checked,
|
||||
employeeRotation: $<HTMLInputElement>('lb-rotation').checked,
|
||||
emergencyToolbox: $<HTMLInputElement>('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'));
|
||||
|
||||
@@ -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}
|
||||
<option value="3">3</option>
|
||||
<option value="4" selected>4</option>
|
||||
</select></label>
|
||||
<details id="lb-settings">
|
||||
<summary>Game settings</summary>
|
||||
<p class="ng-note">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.</p>
|
||||
|
||||
<h3>Seed</h3>
|
||||
<p class="ng-note">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.</p>
|
||||
<input id="lb-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
|
||||
|
||||
<h3>Starting hand</h3>
|
||||
<p class="ng-note">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.</p>
|
||||
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeRandom" checked>
|
||||
<span>Three random cards</span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-hand" value="sixRandom">
|
||||
<span>Six random cards</span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeTrackThreeOther">
|
||||
<span>Three track and three other</span></label>
|
||||
|
||||
<h3>Revenue</h3>
|
||||
<p class="ng-note">What each piece of work pays, 0 to 5. Zero switches an economy off so the
|
||||
others can be read.</p>
|
||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||
<input id="lb-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<label class="ng-num"><span>Freight revenue per load</span>
|
||||
<input id="lb-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<label class="ng-num"><span>Train revenue per transit</span>
|
||||
<input id="lb-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||
|
||||
<h3>Victory conditions</h3>
|
||||
<p class="ng-note">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.</p>
|
||||
<label class="ng-num"><span>Days</span>
|
||||
<input id="lb-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||
<label class="ng-num"><span>Minimum combined Revenue to avoid a loss</span>
|
||||
<input id="lb-minrev" type="number" min="0" step="1" value="60"></label>
|
||||
<label class="ng-num"><span>Collisions in one Day that end the game</span>
|
||||
<input id="lb-colday" type="number" min="0" step="1" value="3"></label>
|
||||
<label class="ng-num"><span>Collisions across the whole game that end it</span>
|
||||
<input id="lb-coltotal" type="number" min="0" step="1" value="5"></label>
|
||||
<label class="ng-num"><span>Allow the opponent-directed cards</span>
|
||||
<input id="lb-pvp" type="checkbox"></label>
|
||||
<p class="ng-note" id="lb-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect
|
||||
either way until then.</p>
|
||||
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Appendix B. Off by default; each one changes how the game plays.</p>
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
<input id="lb-visibility" type="checkbox"></label>
|
||||
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one chair
|
||||
left and takes over the next station up the line. Your Revenue and the Fedora go with you;
|
||||
the district stays where it is</span>
|
||||
<input id="lb-rotation" type="checkbox"></label>
|
||||
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand of
|
||||
four; play or discard down to three on the first turn</span>
|
||||
<input id="lb-toolbox" type="checkbox"></label>
|
||||
</details>
|
||||
<p class="ng-note">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.</p>
|
||||
<button id="lb-create">Create game</button>
|
||||
|
||||
@@ -25,7 +25,6 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -25,7 +25,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -17,7 +17,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -36,7 +36,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -25,7 +25,6 @@ const competitive: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<GameSession['exportSave']> => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,6 @@ const solitaireConfig: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
+1
-1
@@ -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'],
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+5
-5
@@ -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'],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user