v0.6.0 — saves survive a release, Employee Rotation is real, and the lobby
asks what game you want Three queued items. The last 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 legal under old rules may not be legal under 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. 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 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 IMPLEMENTED, SISTER TRAINS IS DELETED. Two of the four optional-rule flags were read by nothing at all. Employee Rotation 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, and the Office, district, grid and any trains standing in it stay with the chair. Inheriting 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 built: Q9 records that the Second Section card supersedes it, and that card exists, 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 name, a mode and a table size; every other dial was 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. The seed is honoured, so a game can be reproduced or compared. Verified: 682 tests pass (679 + 3). The rotation tests were mutation-checked both ways — disabling the rotation and turning the table the wrong way each fail the suite. Live: a save stamped 0.4.9-ancient resumed, an injected illegal move was refused by name, and a create with every dial set to a non-default value came back out of game.json with all of them intact, including seed 777. Two of my own assertions were wrong on the way and the tests caught them: the Fedora legitimately passes at Stage 12 (§5) so it cannot be compared against its own earlier value, and dispatchUsedToday is cleared at every Day boundary so it cannot mark a district.
This commit is contained in:
@@ -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
|
## 0.5.6 — 2026-08-21
|
||||||
|
|
||||||
Three things off the first proper look at a live table.
|
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:
|
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
|
4. ~~**The lobby must offer every game parameter the solitaire New Game dialog does**~~ — done in
|
||||||
currently offers none of them. Reasoning in Multiplayer below; carries a live balance bug with
|
v0.6.0.
|
||||||
it (the combined-Revenue floor is sized for four players whatever the table's real size), so
|
5. ~~**Decide what the four `optionalRules` are**~~ — done in v0.6.0: `sisterTrains` deleted,
|
||||||
this is not purely a UI job.
|
`employeeRotation` implemented, the other two were already live.
|
||||||
5. **Decide what the four `optionalRules` are** before either dialog offers them — two are live,
|
6. ~~**Stop every release destroying every game in progress**~~ — done in v0.6.0, by replaying the
|
||||||
two are read by nothing at all. Reasoning in Multiplayer below.
|
save rather than comparing version strings.
|
||||||
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;
|
Nothing else queued at the moment.
|
||||||
the recommendation is to replay-and-see rather than to guess from a version number.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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
|
shift it", "never grows the table, whoever asks", "refuses a chair that is not at the
|
||||||
table").
|
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
|
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.
|
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
|
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.
|
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
|
**(3) was done.** `loadGame` no longer looks at the version; `tryResumeSession` replays the
|
||||||
becomes too slow to do on boot. Decide before the next release that changes a rule, not after.
|
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
|
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`,
|
item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`,
|
||||||
`sisterTrains`, `employeeRotation` and `emergencyToolbox`. Neither the solitaire New Game
|
`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
|
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.
|
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
|
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
|
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.
|
`defaultMultiplayerConfig(mode)` (`web/game.ts`), hardcoded, with no way to change it.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.5.6",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
@@ -1077,6 +1077,7 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
s.clock.stage = 1;
|
s.clock.stage = 1;
|
||||||
s.collisionsToday = 0;
|
s.collisionsToday = 0;
|
||||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||||
|
rotateSeats(s, events);
|
||||||
const finished = checkVictory(s, events);
|
const finished = checkVictory(s, events);
|
||||||
if (finished) return { events, needsInput: false };
|
if (finished) return { events, needsInput: false };
|
||||||
} else {
|
} 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
|
* "the table's score is everyone's Revenue summed" model — winner stays null, the achievement is
|
||||||
* shared — now against the same configurable floor.
|
* 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 {
|
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
|
||||||
const daysElapsed = s.clock.day - 1;
|
const daysElapsed = s.clock.day - 1;
|
||||||
if (daysElapsed < s.config.days) return false;
|
if (daysElapsed < s.config.days) return false;
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import type { CardId, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId }
|
|||||||
export type GameEvent =
|
export type GameEvent =
|
||||||
// -- clock
|
// -- clock
|
||||||
| { type: 'stageBegan'; day: number; stage: number }
|
| { type: 'stageBegan'; day: number; stage: number }
|
||||||
|
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
||||||
|
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
||||||
| { type: 'phaseBegan'; phase: string }
|
| { type: 'phaseBegan'; phase: string }
|
||||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||||
// -- local operations
|
// -- local operations
|
||||||
|
|||||||
@@ -583,7 +583,6 @@ export type GameConfig = {
|
|||||||
pvpCardsAllowed: boolean;
|
pvpCardsAllowed: boolean;
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: boolean;
|
reducedVisibility: boolean;
|
||||||
sisterTrains: boolean;
|
|
||||||
employeeRotation: boolean;
|
employeeRotation: boolean;
|
||||||
emergencyToolbox: boolean;
|
emergencyToolbox: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-2
@@ -337,6 +337,7 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
config?: GameConfig;
|
config?: GameConfig;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
players?: number;
|
players?: number;
|
||||||
|
seed?: number | null;
|
||||||
};
|
};
|
||||||
if (body.secret !== opts.joinSecret) {
|
if (body.secret !== opts.joinSecret) {
|
||||||
sendJson(res, 403, { error: 'bad or missing secret' });
|
sendJson(res, 403, { error: 'bad or missing secret' });
|
||||||
@@ -354,7 +355,8 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const gameCode = freshGameCode((code) => gameCodes.has(code));
|
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 persistLobby(lobby);
|
||||||
await persistSession(session);
|
await persistSession(session);
|
||||||
sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player });
|
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 });
|
sendJson(res, 409, { error: result.code });
|
||||||
return;
|
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);
|
games.set(lobby.gameId, session);
|
||||||
lobbies.delete(lobby.gameId);
|
lobbies.delete(lobby.gameId);
|
||||||
// Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game
|
// Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game
|
||||||
|
|||||||
+19
-10
@@ -13,7 +13,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { startServer } from './http.ts';
|
import { startServer } from './http.ts';
|
||||||
import { gameDir, loadGame, readIndex, readLobby, readSessions } from './persistence.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 { GameSession } from './session.ts';
|
||||||
import type { Lobby, PlayerSession } from './lobby.ts';
|
import type { Lobby, PlayerSession } from './lobby.ts';
|
||||||
|
|
||||||
@@ -60,19 +60,28 @@ for (const entry of index) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaded = await loadGame(gameDir(dataDir, entry.gameId), engineVersion);
|
const loaded = await loadGame(gameDir(dataDir, entry.gameId));
|
||||||
if (loaded.found && loaded.ok) {
|
if (loaded.found) {
|
||||||
initialGames.set(entry.gameId, resumeSession(loaded.saved));
|
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.`);
|
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
|
||||||
} else if (loaded.found && !loaded.ok) {
|
} else {
|
||||||
// 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.
|
* 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(
|
console.error(
|
||||||
`Refusing to resume ${entry.gameId} (${entry.gameCode}): saved under engine version ` +
|
`Refusing to resume ${entry.gameId} (${entry.gameCode}): move ${f.stoppedAt + 1} of ${f.of} ` +
|
||||||
`${loaded.storedVersion}, this server is running ${engineVersion}. Left untouched, and ` +
|
`(${f.intent}) is rejected by the current rules with ${f.code}. Saved under engine ` +
|
||||||
`will not appear as an active game until the version matches again.`,
|
`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
|
// `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).
|
// 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"). */
|
* replacement is unambiguous (`lobby-and-sessions.md` §2: "earliest-joined remaining player"). */
|
||||||
joinOrder: string[];
|
joinOrder: string[];
|
||||||
createdAt: number;
|
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 };
|
export type CreateResult = { lobby: Lobby; session: PlayerSession };
|
||||||
@@ -102,6 +108,7 @@ export function createLobby(
|
|||||||
hostDisplayName: string,
|
hostDisplayName: string,
|
||||||
gameCode: string,
|
gameCode: string,
|
||||||
players: number,
|
players: number,
|
||||||
|
seed: number | null = null,
|
||||||
): CreateResult {
|
): CreateResult {
|
||||||
const gameId = randomUUID();
|
const gameId = randomUUID();
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
@@ -117,6 +124,7 @@ export function createLobby(
|
|||||||
seats,
|
seats,
|
||||||
joinOrder: [token],
|
joinOrder: [token],
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
seed,
|
||||||
};
|
};
|
||||||
return { lobby, session };
|
return { lobby, session };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,23 @@ export async function writeGame(dataDir: string, saved: SavedGame, engineVersion
|
|||||||
|
|
||||||
export type LoadResult =
|
export type LoadResult =
|
||||||
| { found: false }
|
| { found: false }
|
||||||
| { found: true; ok: true; saved: SavedGame }
|
/** The version that wrote the file, for diagnostics — it is no longer what decides. */
|
||||||
/** §12 step 15 — refused explicitly, never silently replayed under the wrong rules. */
|
| { found: true; saved: SavedGame; storedVersion: string };
|
||||||
| { found: true; ok: false; storedVersion: string; currentVersion: 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;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
text = await readFile(join(dataDir, GAME_FILE), 'utf8');
|
text = await readFile(join(dataDir, GAME_FILE), 'utf8');
|
||||||
@@ -50,11 +62,8 @@ export async function loadGame(dataDir: string, currentVersion: string): Promise
|
|||||||
return { found: false };
|
return { found: false };
|
||||||
}
|
}
|
||||||
const payload = JSON.parse(text) as PersistedGame;
|
const payload = JSON.parse(text) as PersistedGame;
|
||||||
if (payload.engineVersion !== currentVersion) {
|
const { engineVersion, ...saved } = payload;
|
||||||
return { found: true, ok: false, storedVersion: payload.engineVersion, currentVersion };
|
return { found: true, saved, storedVersion: engineVersion };
|
||||||
}
|
|
||||||
const { engineVersion: _engineVersion, ...saved } = payload;
|
|
||||||
return { found: true, ok: true, saved };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Appended once per closed turn span (`GameSession.intent`'s `timing` result) — read-modify-write at
|
/** 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
|
* 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.
|
* 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 {
|
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(
|
return buildSession(
|
||||||
game,
|
game,
|
||||||
saved.playerNames,
|
saved.playerNames,
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ const SOLO = (length: GameLength, mode: GameMode): GameConfig => {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ function configFor(mode: GameMode, length: GameLength, players: number): GameCon
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ export type NarrateContext = {
|
|||||||
* game, phrased in internal identifiers.
|
* game, phrased in internal identifiers.
|
||||||
*/
|
*/
|
||||||
trainName?: (trayId: TrayId) => string;
|
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 {
|
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||||
@@ -104,6 +109,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
// -- clock
|
// -- clock
|
||||||
case 'stageBegan':
|
case 'stageBegan':
|
||||||
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
|
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':
|
case 'phaseBegan':
|
||||||
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
||||||
// the log read as one undifferentiated column and you could not see where a phase began.
|
// the log read as one undifferentiated column and you could not see where a phase began.
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
+22
-7
@@ -76,7 +76,6 @@ export const SOLO_CONFIG: GameConfig = {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
@@ -104,7 +103,6 @@ export function defaultMultiplayerConfig(mode: 'competitive' | 'coop', players =
|
|||||||
pvpCardsAllowed: mode === 'competitive',
|
pvpCardsAllowed: mode === 'competitive',
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: 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
|
* out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a
|
||||||
* touch-in-passing.
|
* 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(
|
export function fromMultiplayerSave(
|
||||||
seed: number,
|
seed: number,
|
||||||
config: GameConfig,
|
config: GameConfig,
|
||||||
playerNames: string[],
|
playerNames: string[],
|
||||||
history: Intent[],
|
history: Intent[],
|
||||||
): Game {
|
): { game: Game; stopped: ReplayStop | null } {
|
||||||
const game = newMultiplayerGame(seed, config, playerNames);
|
const game = newMultiplayerGame(seed, config, playerNames);
|
||||||
for (const intent of history) {
|
for (const [index, intent] of history.entries()) {
|
||||||
const actor = currentActor(game);
|
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);
|
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);
|
game.history.push(intent);
|
||||||
record(game, result.events, actor);
|
record(game, result.events, actor);
|
||||||
drain(game);
|
drain(game);
|
||||||
}
|
}
|
||||||
return game;
|
return { game, stopped: null };
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-11
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||||
import { defaultMultiplayerConfig } from './game.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';
|
import { seatLabel } from '../sim/view.ts';
|
||||||
|
|
||||||
export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex };
|
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 = () => {
|
$<HTMLButtonElement>('lb-create').onclick = () => {
|
||||||
const displayName = $<HTMLInputElement>('lb-name').value.trim();
|
const displayName = $<HTMLInputElement>('lb-name').value.trim();
|
||||||
const mode = ($('lb-choice-section').querySelector<HTMLInputElement>('input[name="lb-mode"]:checked')?.value ??
|
const mode = chosenMode();
|
||||||
'competitive') as 'competitive' | 'coop';
|
|
||||||
if (displayName === '') {
|
if (displayName === '') {
|
||||||
setError('lb-create-err', 'enter a display name first');
|
setError('lb-create-err', 'enter a display name first');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const players = Number($<HTMLSelectElement>('lb-players').value) || 4;
|
const players = chosenPlayers();
|
||||||
// The real seat count reaches `defaultMultiplayerConfig`, so the combined-Revenue floor is
|
const hand = $('lb-settings').querySelector<HTMLInputElement>('input[name="lb-hand"]:checked')?.value;
|
||||||
// sized for the table actually being played rather than for an assumed four.
|
// Blank or unparseable both mean "surprise me", which is what leaving the box alone asks for.
|
||||||
void postJson('/api/lobby/create', {
|
const asked = $<HTMLInputElement>('lb-seed').value.trim();
|
||||||
secret: secret(),
|
const seed = asked === '' || !Number.isFinite(Number(asked)) ? null : Math.trunc(Number(asked));
|
||||||
config: defaultMultiplayerConfig(mode, players),
|
|
||||||
displayName,
|
const config: GameConfig = {
|
||||||
players,
|
...defaultMultiplayerConfig(mode, players),
|
||||||
}).then(
|
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 }) => {
|
({ status, body }) => {
|
||||||
if (status !== 200) {
|
if (status !== 200) {
|
||||||
setError('lb-create-err', String(body['error'] ?? 'could not create the game'));
|
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}
|
background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px}
|
||||||
.lb-invite-label{font-size:11px;color:var(--dim)}
|
.lb-invite-label{font-size:11px;color:var(--dim)}
|
||||||
.lb-invite-code{font-size:22px;font-weight:700;letter-spacing:.08em;color:#f2e6cf}
|
.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}
|
.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}
|
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
|
||||||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
.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="3">3</option>
|
||||||
<option value="4" selected>4</option>
|
<option value="4" selected>4</option>
|
||||||
</select></label>
|
</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
|
<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>
|
bot. Pick the size of the table now; it cannot change once the game is created.</p>
|
||||||
<button id="lb-create">Create game</button>
|
<button id="lb-create">Create game</button>
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const config: GameConfig = {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const config: GameConfig = {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const config: GameConfig = {
|
|||||||
maxCollisionsPerDay: 0,
|
maxCollisionsPerDay: 0,
|
||||||
maxCollisionsTotal: 0,
|
maxCollisionsTotal: 0,
|
||||||
pvpCardsAllowed: false,
|
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 game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const KNOWN_UNREDUCED = [
|
|||||||
'dispatchBonusUsed',
|
'dispatchBonusUsed',
|
||||||
'expediteFault',
|
'expediteFault',
|
||||||
'phaseBegan',
|
'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',
|
'stageBegan',
|
||||||
'trainArrived',
|
'trainArrived',
|
||||||
'trainCompleted',
|
'trainCompleted',
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const config: GameConfig = {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const config: GameConfig = {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const config: GameConfig = {
|
|||||||
maxCollisionsPerDay: 0,
|
maxCollisionsPerDay: 0,
|
||||||
maxCollisionsTotal: 0,
|
maxCollisionsTotal: 0,
|
||||||
pvpCardsAllowed: false,
|
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 game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
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,
|
seed: 4,
|
||||||
config: {
|
config: {
|
||||||
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
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'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
@@ -800,7 +800,7 @@ describe('Q13 — a train that catches the one ahead runs into it', () => {
|
|||||||
id: 'rear', seed: 3,
|
id: 'rear', seed: 3,
|
||||||
config: {
|
config: {
|
||||||
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
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'],
|
playerNames: ['bot'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { advance, pump } from '../src/engine/advance.ts';
|
import { advance, pump } from '../src/engine/advance.ts';
|
||||||
import { applyIntent, areaAtSeat, areaOf } from '../src/engine/apply.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 { createGame } from '../src/engine/setup.ts';
|
||||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||||
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } 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 { developerBot, playGame } from '../src/sim/bot.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
import { divisionSvg } from '../src/sim/board-svg.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 { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { actionMenu } from '../src/web/game.ts';
|
import { actionMenu } from '../src/web/game.ts';
|
||||||
@@ -37,7 +37,7 @@ const competitive: GameConfig = {
|
|||||||
maxCollisionsPerDay: 0,
|
maxCollisionsPerDay: 0,
|
||||||
maxCollisionsTotal: 0,
|
maxCollisionsTotal: 0,
|
||||||
pvpCardsAllowed: false,
|
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 =>
|
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);
|
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,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const config: GameConfig = {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const competitive: GameConfig = {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const config: GameConfig = {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
@@ -46,29 +45,30 @@ describe('game persistence (Phase 3)', () => {
|
|||||||
it('writes and reads back exactly what was written', () =>
|
it('writes and reads back exactly what was written', () =>
|
||||||
withTempDir(async (dir) => {
|
withTempDir(async (dir) => {
|
||||||
await writeGame(dir, saved, '1.2.3');
|
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);
|
assert.equal(result.found, true);
|
||||||
if (!result.found) return;
|
if (!result.found) return;
|
||||||
assert.equal(result.ok, true);
|
|
||||||
if (!result.ok) return;
|
|
||||||
assert.deepEqual(result.saved, saved);
|
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) => {
|
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');
|
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);
|
assert.equal(result.found, true);
|
||||||
if (!result.found) return;
|
if (!result.found) return;
|
||||||
assert.equal(result.ok, false);
|
|
||||||
if (result.ok) return;
|
|
||||||
assert.equal(result.storedVersion, '1.2.3');
|
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', () =>
|
it('reports not-found rather than throwing when nothing has been saved yet', () =>
|
||||||
withTempDir(async (dir) => {
|
withTempDir(async (dir) => {
|
||||||
const result = await loadGame(dir, '1.2.3');
|
const result = await loadGame(dir);
|
||||||
assert.deepEqual(result, { found: false });
|
assert.deepEqual(result, { found: false });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -77,11 +77,9 @@ describe('game persistence (Phase 3)', () => {
|
|||||||
await writeGame(dir, saved, '1.2.3');
|
await writeGame(dir, saved, '1.2.3');
|
||||||
const grown: SavedGame = { ...saved, history: [...saved.history, { type: 'draw.end' }] };
|
const grown: SavedGame = { ...saved, history: [...saved.history, { type: 'draw.end' }] };
|
||||||
await writeGame(dir, grown, '1.2.3');
|
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);
|
assert.equal(result.found, true);
|
||||||
if (!result.found) return;
|
if (!result.found) return;
|
||||||
assert.equal(result.ok, true);
|
|
||||||
if (!result.ok) return;
|
|
||||||
assert.equal(result.saved.history.length, 2);
|
assert.equal(result.saved.history.length, 2);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { describe, it } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import type { GameConfig, PlayerIndex } from '../../src/engine/state.ts';
|
import type { GameConfig, PlayerIndex } from '../../src/engine/state.ts';
|
||||||
import type { Push } from '../../src/server/session.ts';
|
import type { GameSession, Push } from '../../src/server/session.ts';
|
||||||
import { createSession, resumeSession } from '../../src/server/session.ts';
|
import { createSession, resumeSession, tryResumeSession } from '../../src/server/session.ts';
|
||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'competitive',
|
mode: 'competitive',
|
||||||
@@ -18,7 +18,6 @@ const config: GameConfig = {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: 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');
|
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,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const config: GameConfig = {
|
|||||||
pvpCardsAllowed: false,
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ function gameWith(area: OfficeArea): GameState {
|
|||||||
id: 'g', seed: 5,
|
id: 'g', seed: 5,
|
||||||
config: {
|
config: {
|
||||||
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
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'],
|
playerNames: ['p'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const config: GameConfig = {
|
|||||||
maxCollisionsPerDay: 0,
|
maxCollisionsPerDay: 0,
|
||||||
maxCollisionsTotal: 0,
|
maxCollisionsTotal: 0,
|
||||||
pvpCardsAllowed: false,
|
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 game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
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,
|
maxCollisionsPerDay: 0,
|
||||||
maxCollisionsTotal: 0,
|
maxCollisionsTotal: 0,
|
||||||
pvpCardsAllowed: false,
|
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),
|
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
|
||||||
});
|
});
|
||||||
@@ -2695,7 +2695,7 @@ describe('the Division map shows the whole route', () => {
|
|||||||
seed: 7,
|
seed: 7,
|
||||||
config: {
|
config: {
|
||||||
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
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'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
@@ -2748,7 +2748,7 @@ describe('the Division map shows the whole route', () => {
|
|||||||
id: 'div-chips', seed: 7,
|
id: 'div-chips', seed: 7,
|
||||||
config: {
|
config: {
|
||||||
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
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'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
@@ -3077,7 +3077,7 @@ describe('the tray is an engine plus its Rolling Stock', () => {
|
|||||||
id: 'eng', seed: 1038389,
|
id: 'eng', seed: 1038389,
|
||||||
config: {
|
config: {
|
||||||
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
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'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
@@ -3127,7 +3127,7 @@ describe('the tray is an engine plus its Rolling Stock', () => {
|
|||||||
id: 'yards', seed: 1038389,
|
id: 'yards', seed: 1038389,
|
||||||
config: {
|
config: {
|
||||||
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
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'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user