Compare commits
1
Commits
6f2a8dff09
..
v0.8.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d222a7eba |
@@ -19,6 +19,90 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.8.3 — 2026-09-29
|
||||
|
||||
The first of three releases from a code audit (engine, server, client, tests and hygiene, each read
|
||||
by a separate reviewer and every finding re-verified against the code before it was acted on). This
|
||||
one is the engine: seven rules faults and one dealing fault, each pinned by a test written to fail
|
||||
first. **Every save on the test server was replayed under this build before release** — and that is
|
||||
how the dealing fault was found, because under 0.8.2 none of them replayed at all.
|
||||
|
||||
### 0.8.2 stranded every game on the server, and said it stranded ten
|
||||
|
||||
The 0.8.2 notes said three of thirteen saves would resume. The server's own boot log, read for this
|
||||
release, refused all thirteen at **move 3**, `CARD_NOT_IN_HAND` — including the game saved by 0.8.1.0
|
||||
the notes had counted as safe. A refusal at move 3 is not a rule; it is a different deal.
|
||||
|
||||
The **Second Section card went into the deck in 0.8.2** (Q9, one copy — it had been defined and
|
||||
never dealt), after that release's save check had been run and without a line in its notes. A deck
|
||||
one card larger shuffles into a different order from the same seed, so every save older than the
|
||||
card was replaying a different railroad from intent one. `withSavedOpening` could not help: it names
|
||||
the opening, and the opening was not the problem.
|
||||
|
||||
Now `withSavedDeal`, and it names both. `secondSectionCard` is a house rule with no dial — a fact
|
||||
about how a game was dealt, kept for the same reason `startingOffice` is — set false for a saved
|
||||
config that predates the setting and true for everything dealt since. Under this build the thirteen
|
||||
replay exactly as 0.8.2's notes described: **three resume, ten refuse, the same ten at the same moves
|
||||
for the same rules.** The process rule this breaks is already written down ("say which way games in
|
||||
progress go, every time"); what it adds is that the save check has to be the LAST thing before the
|
||||
tag, not a thing done during the work.
|
||||
|
||||
### A player could switch a rival's train, and the rival paid for it
|
||||
|
||||
`check` resolved a switching tray with no seat test at all. The legal-move generator filtered trays
|
||||
by seat; `check` did not; the server validates with `check` alone. Every district opens on the same
|
||||
coordinates, so a destination legal for your own tray at (0,0) was "legal" for a rival's tray at
|
||||
THEIR (0,0) — and the Moves came off the rival's turn, because `trayMoved` charges whoever sits in
|
||||
the district the tray is in. All four switching intents now refuse a tray outside the actor's own
|
||||
district with `NO_SUCH_TRAY`. Invisible in solitaire, which is why it lasted.
|
||||
|
||||
### A rival's crew blocked your own track
|
||||
|
||||
`occupancyFor().trayAt` matched on coordinates alone, so a crew standing at seat 0's (0,2) was
|
||||
"another train standing here" at seat 1's (0,2) — a phantom that refused Moves and closed the Yard
|
||||
Office walk in any game with more than one seat. It asks the seat now.
|
||||
|
||||
### Drawing the last card off a Department could duplicate it and destroy the refill
|
||||
|
||||
When a Department draw emptied the Home Office deck, the reshuffle was computed from the table
|
||||
BEFORE the draw and the refill had been reduced: it swept up the card being drawn and missed the
|
||||
refill card. The reducers then dealt the drawn card into the new deck while the refill card, moved
|
||||
onto a pile the reshuffle wiped a moment later, left the game. Proven by counting — 47 cards in, 46
|
||||
distinct out — and fixed by describing the sweep as the table will be after the events ahead of it.
|
||||
|
||||
### The unjam cleared the first load, not the one you named
|
||||
|
||||
`facilityUnjammed` carried no index, so the reducer cleared the FIRST load on MEN | AT | WORK and the
|
||||
first car of the named type in a box. With an inbound tank load on MEN and a stranded outbound
|
||||
hopper on WORK, unjamming the hopper deleted the tank load, sent a loaded hopper to the yard and
|
||||
left the jam. The event carries the index now; events are regenerated on replay, so no save changes.
|
||||
|
||||
### The collision floor could not fire in Stage 12
|
||||
|
||||
`shiftChange` reset the Day's collision count at the rollover and only then judged it, so a breach
|
||||
reached in the last Stage of a Day read as zero. The limits are judged on the Stage just played,
|
||||
before the Day rolls over. `docs/architecture/game-state.md` had said the check was immediate; it
|
||||
never was, and it now says what happens.
|
||||
|
||||
### The Expedite fault was charged once per question
|
||||
|
||||
The Mainline phase is re-entered from the top after every clearance, Yard Office and Red Flag
|
||||
ruling, and the Q3 fault loop at the top ran unguarded — an Expedited train left on a siding was
|
||||
fined once per interruption. Once per phase now. The rules document had also still described the
|
||||
v0.4.8 reading of Expedite (departs at Shift Change), corrected in the code in v0.4.9; both passages
|
||||
now describe the rule as built.
|
||||
|
||||
### A train held at the Limits was only ever released by another arrival
|
||||
|
||||
`arriveAtOffice` promised "held at the Limits until an A/D track frees up", and the only release
|
||||
was inside `arriveAtOffice` for a DIFFERENT train. An Office that emptied by departures kept its
|
||||
held train at the Limits for the rest of the game, invisible — no transit, no A/D track, no part in
|
||||
the clearance check. At the end of every Mainline phase, held trains now take any free tracks in
|
||||
the order they were held, and the history says a departure freed the track rather than naming an
|
||||
arriving train that does not exist.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.2 — 2026-09-23
|
||||
|
||||
A playtest read back against the save file, and the rules that came out of it. Nine questions were
|
||||
|
||||
@@ -328,8 +328,10 @@ Outcome
|
||||
|
||||
Termination checks, in the order they must be evaluated:
|
||||
|
||||
1. **Collision floor** (Competitive only) — `collisionsToday >= 3` ends the game immediately, all
|
||||
players lose (§3.4). Checked the moment a collision resolves, not at end of Stage.
|
||||
1. **Collision floor** (every mode) — `collisionsToday >= maxCollisionsPerDay` or
|
||||
`collisionsTotal >= maxCollisionsTotal` ends the game, all players lose (§3.4). Judged at the
|
||||
Shift Change that closes the Stage, on the Stage just played and before the Day rolls over — so
|
||||
Load/Unload still scores in the Stage of the breach, and a breach in Stage 12 counts.
|
||||
2. **Target reached** (firstToTarget) — checked whenever Revenue increases.
|
||||
3. **Days elapsed** (highestAfterDays) — at the end of the final Day, apply the collective Revenue
|
||||
floor `3 × players × Days` for Competitive (§3.5), or the mode target for Solitaire and Co-op
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Station Master — Components and Markers
|
||||
|
||||
**Version 0.8.2** · 2026-09-23
|
||||
**Version 0.8.3** · 2026-09-29
|
||||
|
||||
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles,
|
||||
hand state, timetable state and other markers are documented with their cards or in the
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Station Master — Home Deck
|
||||
|
||||
**Version 0.8.2** · 2026-09-23
|
||||
**Version 0.8.3** · 2026-09-29
|
||||
|
||||
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the
|
||||
rules are for playing each kind of card out of it.
|
||||
@@ -381,7 +381,7 @@ an Office without wrecking.
|
||||
**"Players start with Whistle Posts, not Depots"** is the harder game, set when the game is created.
|
||||
A Whistle Post has **one** A/D track and is not a Passenger Facility: no passenger earns anything
|
||||
until somebody draws and plays a Depot upgrade, and a second train arriving is a collision unless an
|
||||
Interlocking holds it at the Limits.
|
||||
Interlocking holds it at the Limits until the track frees.
|
||||
|
||||
The deck follows the choice. Starting on Depots, the **Depot upgrade cards are left out** — an
|
||||
upgrade must be to the next tier up, so a Depot card at a table that already has Depots is a dead
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Station Master — Mainline Deck
|
||||
|
||||
**Version 0.8.2** · 2026-09-23
|
||||
**Version 0.8.3** · 2026-09-29
|
||||
|
||||
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a
|
||||
card does to a train crossing it, and the Home Deck cards played onto one.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Station Master — Quickstart
|
||||
|
||||
**Version 0.8.2** · 2026-09-23
|
||||
**Version 0.8.3** · 2026-09-29
|
||||
|
||||
For a player who has never played.
|
||||
|
||||
|
||||
+12
-9
@@ -1,6 +1,6 @@
|
||||
# Station Master — Rules
|
||||
|
||||
**Version 0.8.2** · 2026-09-23
|
||||
**Version 0.8.3** · 2026-09-29
|
||||
|
||||
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable
|
||||
behaviour differ, this document reports **executable behaviour** and marks unimplemented material.
|
||||
@@ -184,7 +184,7 @@ Each of 12 Stages follows this sequence:
|
||||
**"Proceeding left" is seat order, west to east**, which is how the Division map draws it — the
|
||||
screen says "eastward" for that reason, because a table has no shared left.
|
||||
|
||||
At a Shift Change, Laborers and Porters reset. The Fedora moves after Stages 3, 6, 9, and 12. At Day end, dispatch-device use resets, collision count resets, the Day and Stage roll over, and victory is checked.
|
||||
At a Shift Change, Laborers and Porters reset. The Fedora moves after Stages 3, 6, 9, and 12. The collision limits are judged on the Stage just played, before anything else. At Day end, dispatch-device use resets, collision count resets, the Day and Stage roll over, and victory is checked.
|
||||
|
||||
### 4.2 Local Operations: choose one option
|
||||
|
||||
@@ -207,7 +207,7 @@ roll a tail cut into a connected Freight Facility.
|
||||
> draw actually empties the pile; a pile with cards buried under the one taken is not refilled, or
|
||||
> the Departments would grow without limit and drain the deck into themselves.
|
||||
>
|
||||
**Freight Agent.** Make one of these operations, then the turn ends: stock one green outbound box from a matching loaded Division Yard car; clear one red inbound box to the Classification Yard; unjam one outbound, inbound, or MEN | AT | WORK load to the Classification Yard; or explicitly end without acting. Freight can be stocked only when an unclaimed empty matching car is already spotted at that industry. Passengers may wait in a green Office box without a train present.
|
||||
**Freight Agent.** Make one of these operations, then the turn ends: stock one green outbound box from a matching loaded Division Yard car; clear one red inbound box to the Classification Yard; unjam one outbound, inbound, or MEN | AT | WORK load — the one you name, when a box holds more than one — to the Classification Yard; or explicitly end without acting. Freight can be stocked only when an unclaimed empty matching car is already spotted at that industry. Passengers may wait in a green Office box without a train present.
|
||||
|
||||
> **A car cleared from a red Inbound box comes back empty.** Whatever was in it has arrived — the
|
||||
> passengers are out of the station, or the load is in the industry — and the Revenue for it was
|
||||
@@ -261,9 +261,9 @@ point. On a normal card, a train must check the entire next Subdivision before e
|
||||
**Uncontrolled Siding** is not a passing card: a train arriving to find it occupied takes the
|
||||
siding a region behind, which costs it the extra Stage instead of a collision.
|
||||
|
||||
An Office arrival normally takes a free A/D track. If the Office is full, the inbound train collides and the local Office player loses 5 Revenue; Interlocking instead holds it at the Limits. A coachless inbound train may divert to a Yard Office. Cars fouling the Running Track at the Office also cause a collision.
|
||||
An Office arrival normally takes a free A/D track. If the Office is full, the inbound train collides and the local Office player loses 5 Revenue; Interlocking instead holds it at the Limits. A held train takes the first A/D track that frees — whether another train's arrival or a departure freed it — ahead of anything arriving after it, and no later than the end of the Mainline phase in which the track became free. A coachless inbound train may divert to a Yard Office. Cars fouling the Running Track at the Office also cause a collision.
|
||||
|
||||
An expedited train remains available for this Stage’s Load/Unload work after arriving, then attempts to depart at Shift Change. A non-expedited arrival waits until a later Mainline phase. A train departing an Office must be correctly made up: engine at one end, caboose at the far end if present.
|
||||
An expedited train is released by the ordinary rules like any other; what Expedite restricts is where it may be left. If it is standing anywhere in the district but the Office when a Mainline phase begins, its owner is fined once for that phase. An arrival waits until a later Mainline phase. A train departing an Office must be correctly made up: engine at one end, caboose at the far end if present.
|
||||
|
||||
When a train leaves the far Division Point, its cars are returned to yards, its Crew Tray becomes free, and each player receives the configured train-transit Revenue (zero by default).
|
||||
|
||||
@@ -406,10 +406,12 @@ The Office had no free A/D track and no Interlocking. A full Office is an automa
|
||||
|
||||
It may be on Secondary Track rather than the Office, or be badly made up: its engine is between cars or its caboose is not at the far end.
|
||||
|
||||
### Why did an expedited train leave after passenger/freight work?
|
||||
### Why was I fined for an expedited train?
|
||||
|
||||
Expedite means it departs in the Stage it arrived, but the departure waits until Shift Change so the
|
||||
train is still present for that Stage's Load/Unload phase.
|
||||
Expedite does not change when a train leaves — it leaves when the Mainline rules release it, like any
|
||||
other. It changes where the train may be left: if a Mainline phase begins with it standing on any
|
||||
track in the district but the Office, the owner is fined. The fine is charged once per Mainline
|
||||
phase, however many clearance questions that phase asks.
|
||||
|
||||
### Can I choose the direction of an Extra or a Heavy Grade?
|
||||
|
||||
@@ -470,4 +472,5 @@ lies inside one, so a table of Whistle Posts is a single Subdivision from end to
|
||||
|
||||
**An Office never holds more trains than it has A/D tracks.** A train the Interlocking is holding at
|
||||
the Limits takes the first track to free, ahead of anything arriving afterwards — and the train that
|
||||
arrives to find it taken is held at its own Limits, or collides if there is no Interlocking.
|
||||
arrives to find it taken is held at its own Limits, or collides if there is no Interlocking. A track
|
||||
freed by a departure counts too: the held train takes it at the end of that Mainline phase.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+61
-20
@@ -469,7 +469,13 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
* there, not a one-time slip. A train the ordinary §8.1 rules are holding at the Office itself is
|
||||
* unaffected — this only bites when the train is not even in the queue to leave.
|
||||
*/
|
||||
for (const [, tray] of order) {
|
||||
/**
|
||||
* ONCE PER PHASE, NOT ONCE PER QUESTION (v0.8.3). This function is re-entered from the top after
|
||||
* every clearance, Yard Office and Red Flag ruling, and the loop below ran unguarded — so a train
|
||||
* left on a siding was fined once per interruption. A pending answer is the mark of a resumption:
|
||||
* it is set by the ruling and consumed further down, inside the move it belongs to.
|
||||
*/
|
||||
for (const [, tray] of s.clock.decisionAnswer === null ? order : []) {
|
||||
if (!isExpedited(tray) || tray.position.at !== 'grid') continue;
|
||||
const area = areaAtSeat(s, tray.position.seat);
|
||||
const { coord } = tray.position;
|
||||
@@ -555,10 +561,41 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
s.movedThisPhase.add(id);
|
||||
}
|
||||
|
||||
releaseHeldAtLimits(s, events);
|
||||
s.movedThisPhase = new Set();
|
||||
return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* A TRAIN HELD AT THE LIMITS TAKES A TRACK THAT FREES — whoever freed it (v0.8.3).
|
||||
*
|
||||
* `arriveAtOffice` promises "held at the Limits until an A/D track frees up", and until now the
|
||||
* only release was inside `arriveAtOffice` itself, for a DIFFERENT train arriving. An Office that
|
||||
* emptied by departures alone kept its held train at the Limits for the rest of the game — with no
|
||||
* transit, no place in `adOccupancy` and no part in the clearance check, so nothing on the board or
|
||||
* in the rules could see it. Every train has now attempted its move for this Phase, so any track
|
||||
* still free is genuinely free, and the held trains take them in the order they were held.
|
||||
*/
|
||||
function releaseHeldAtLimits(s: GameState, events: GameEvent[]): void {
|
||||
for (const area of s.officeAreas.values()) {
|
||||
const capacity = officeProfile(area.tier).adTracks;
|
||||
while (area.heldAtLimits.length > 0 && area.adOccupancy.length < capacity) {
|
||||
const id = area.heldAtLimits.shift()!;
|
||||
const held = s.trays.get(id);
|
||||
if (!held) continue;
|
||||
area.adOccupancy.push(id);
|
||||
held.position = { at: 'grid', seat: area.seat, coord: area.officeCoord };
|
||||
events.push({
|
||||
type: 'trainReleasedFromLimits',
|
||||
trainNumber: held.trainNumber ?? 0,
|
||||
office: officeProfile(area.tier).name,
|
||||
owner: playerAtSeat(s, area.seat),
|
||||
freedBy: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MoveOutcome = 'moved' | 'held' | 'needsClearance';
|
||||
|
||||
/**
|
||||
@@ -1803,31 +1840,16 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
}
|
||||
}
|
||||
|
||||
if (s.clock.stage >= STAGES_PER_DAY) {
|
||||
// Telegraph/Telephone/Radio are each usable once a Day.
|
||||
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
||||
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
|
||||
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
|
||||
s.collisionsPrevDay = s.collisionsToday;
|
||||
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 {
|
||||
s.clock.stage += 1;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
||||
}
|
||||
|
||||
/**
|
||||
* §3.4 — EVERY MODE, SOLITAIRE INCLUDED: a Day's collisions against `maxCollisionsPerDay` and the
|
||||
* game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not scaled
|
||||
* by player count — Jesse's call, 2026-08-20: more players is more independent chances to collide,
|
||||
* not a bigger shared budget.
|
||||
*
|
||||
* JUDGED BEFORE THE DAY ROLLS OVER (v0.8.3). This block sat below the rollover, which resets
|
||||
* `collisionsToday` — so a breach reached in Stage 12 was read as zero and the last Stage of every
|
||||
* Day was the one Stage the floor could not fire in. Pinned in `advance.test.ts`.
|
||||
*
|
||||
* SOLITAIRE WAS EXCLUDED UNTIL 2026-08-30 and nothing said so. The gate here read `mode ===
|
||||
* 'competitive' || mode === 'coop'`, while `SOLO_CONFIG` carried both limits and the New Game
|
||||
* dialog offered them as live settings — so a solitaire player could set a collision limit, read
|
||||
@@ -1860,6 +1882,25 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
}
|
||||
}
|
||||
|
||||
if (s.clock.stage >= STAGES_PER_DAY) {
|
||||
// Telegraph/Telephone/Radio are each usable once a Day.
|
||||
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
||||
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
|
||||
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
|
||||
s.collisionsPrevDay = s.collisionsToday;
|
||||
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 {
|
||||
s.clock.stage += 1;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
||||
}
|
||||
|
||||
return { events: [...events, ...enterPhase(s, 'localOps')], needsInput: false };
|
||||
}
|
||||
|
||||
|
||||
+71
-14
@@ -125,6 +125,22 @@ function trayCoord(s: GameState, trayId: TrayId): GridCoord | null {
|
||||
return tray.position.coord;
|
||||
}
|
||||
|
||||
/**
|
||||
* A TRAY THE PLAYER MAY SWITCH: one standing in THEIR OWN district. Null for a tray that does not
|
||||
* exist, is off the grid, or is standing in somebody else's Office Area.
|
||||
*
|
||||
* Until v0.8.3 the switching intents resolved a tray with no seat test at all. `legal.ts`'s
|
||||
* generator filtered by seat, `check` did not — and the server validates with `check` alone. Every
|
||||
* district opens on the same coordinates, so a destination legal for your own tray at (0,0) was
|
||||
* "legal" for a rival's tray at THEIR (0,0), and `trayMoved` then charged the Moves to the rival's
|
||||
* turn. Invisible in solitaire, where there is nobody else's district to reach into.
|
||||
*/
|
||||
function ownTray(s: GameState, player: PlayerIndex, trayId: TrayId): CrewTray | null {
|
||||
const tray = s.trays.get(trayId);
|
||||
if (!tray || tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) return null;
|
||||
return tray;
|
||||
}
|
||||
|
||||
/** A tray sitting on the Office card occupies an A/D track (§2.1). */
|
||||
/**
|
||||
* Exported for `advance.ts`'s Yard Office walk (Gitea#5), which has to ask the SAME occupancy
|
||||
@@ -133,10 +149,19 @@ function trayCoord(s: GameState, trayId: TrayId): GridCoord | null {
|
||||
*/
|
||||
export function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy {
|
||||
const area = areaOf(s, player);
|
||||
const seat = seatOf(s, player);
|
||||
return {
|
||||
// IN THIS DISTRICT. Every Office Area is laid out on the same coordinates, so a coordinate match
|
||||
// alone found a rival's crew standing "here" — a phantom that blocked Moves and the Yard Office
|
||||
// walk in any game with more than one seat (v0.8.3).
|
||||
trayAt: (c) => {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at === 'grid' && tray.position.coord.row === c.row && tray.position.coord.col === c.col) {
|
||||
if (
|
||||
tray.position.at === 'grid' &&
|
||||
tray.position.seat === seat &&
|
||||
tray.position.coord.row === c.row &&
|
||||
tray.position.coord.col === c.col
|
||||
) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -895,7 +920,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
const tray = ownTray(s, player, i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const from = trayCoord(s, i.trayId);
|
||||
if (!from) return 'ILLEGAL_MOVE';
|
||||
@@ -954,7 +979,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
case 'switch.dropCars': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
const tray = ownTray(s, player, i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
@@ -1003,7 +1028,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
const tray = ownTray(s, player, i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
@@ -1125,7 +1150,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'flyingSwitch') return 'WRONG_INTENT';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
const tray = ownTray(s, player, i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'CANNOT_DROP_HERE';
|
||||
@@ -1990,10 +2015,22 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
// empty spot." Only when taking the last card actually empties the pile; refilling on every
|
||||
// draw would grow the Departments without limit and drain the Home Office deck into them.
|
||||
const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1];
|
||||
if (pile.length === 1 && refill) {
|
||||
events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
|
||||
const sweep = reshuffleIfDepleted(s, 1);
|
||||
if (sweep) events.push(sweep);
|
||||
if (pile.length === 1) {
|
||||
if (refill) events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
|
||||
/**
|
||||
* THE SWEEP DESCRIBES THE TABLE AFTER THE DRAW AND THE REFILL, not before (v0.8.3).
|
||||
*
|
||||
* This used to call `reshuffleIfDepleted` on the state as it stood, so the sweep collected
|
||||
* the card being drawn — still on its pile — and missed the refill card — still on the
|
||||
* deck. The reducers then dealt the drawn card into the new deck while the refill card,
|
||||
* moved onto a pile the reshuffle wiped a moment later, left the game: one card in two
|
||||
* places, one card in none. Proven by counting, and pinned in `apply.test.ts`.
|
||||
*/
|
||||
const deckAfter = s.decks.homeOffice.length - (refill ? 1 : 0);
|
||||
if (deckAfter <= 0) {
|
||||
const sweep = sweepDeck(s, { exclude: [pile[pile.length - 1]!], include: refill ? [refill] : [] });
|
||||
if (sweep) events.push(sweep);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -2119,7 +2156,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
i.from === 'menAtWork'
|
||||
? { type: workTrack(f)[i.index]!.type, loaded: true }
|
||||
: (i.from === 'outbound' ? f.outboundBox : f.inboundBox)[i.index]!;
|
||||
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }];
|
||||
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, index: i.index, stock }];
|
||||
}
|
||||
|
||||
case 'newTrain.startExtra': {
|
||||
@@ -2715,12 +2752,20 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
|
||||
case 'facilityUnjammed': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
/**
|
||||
* THE BOX THE PLAYER NAMED (v0.8.3). The event used to carry no index, so this cleared the
|
||||
* FIRST load on MEN | AT | WORK and the first car of the right type in a box — the same
|
||||
* "westmost car" fault `unloadBegan` once had. With an inbound tank on MEN and a stranded
|
||||
* hopper on WORK, unjamming the hopper deleted the tank load and sent a loaded hopper to
|
||||
* the yard, and the jam stayed. Events are regenerated on replay, so old saves carry it.
|
||||
*/
|
||||
if (e.from === 'menAtWork') {
|
||||
const idx = workTrack(f).findIndex((l) => l !== null);
|
||||
if (idx >= 0) workTrack(f)[idx] = null;
|
||||
const track = workTrack(f);
|
||||
const idx = track[e.index] ? e.index : track.findIndex((l) => l !== null);
|
||||
if (idx >= 0) track[idx] = null;
|
||||
} else {
|
||||
const box = e.from === 'outbound' ? f.outboundBox : f.inboundBox;
|
||||
const idx = box.findIndex((c) => c.type === e.stock.type);
|
||||
const idx = box[e.index]?.type === e.stock.type ? e.index : box.findIndex((c) => c.type === e.stock.type);
|
||||
if (idx >= 0) box.splice(idx, 1);
|
||||
}
|
||||
s.yards.classificationYard.push(pooled(e.stock));
|
||||
@@ -3257,11 +3302,23 @@ function isSpentTimetabledTrain(s: GameState, id: CardId): boolean {
|
||||
|
||||
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
|
||||
if (s.decks.homeOffice.length > taking) return null;
|
||||
return sweepDeck(s, { exclude: [], include: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.2's reshuffle: the Salvage Yard and the three Departments come back as one deck.
|
||||
*
|
||||
* `exclude` names cards the events queued ahead of this one are taking OFF the Departments (a card
|
||||
* being drawn), `include` the ones they are putting ON (a refill from the deck) — so the sweep
|
||||
* matches the table the reducer will find, not the one the caller is looking at.
|
||||
*/
|
||||
function sweepDeck(s: GameState, adjust: { exclude: CardId[]; include: CardId[] }): GameEvent | null {
|
||||
const collected = [
|
||||
// The Salvage Yard, less the trains whose slots are already filled — see above.
|
||||
...s.decks.salvageYard.filter((id) => !isSpentTimetabledTrain(s, id)),
|
||||
// Every Department in full: a discarded train was never played, so it is still runnable.
|
||||
...s.decks.departments.flat(),
|
||||
...s.decks.departments.flat().filter((id) => !adjust.exclude.includes(id)),
|
||||
...adjust.include,
|
||||
];
|
||||
if (collected.length === 0) return null;
|
||||
const rng = createRng(s.rngState);
|
||||
|
||||
+35
-13
@@ -1226,6 +1226,17 @@ export type HouseRules = {
|
||||
extraStart: ExtraStartRule;
|
||||
/** Which Office every player opens on — see `StartingOffice`. */
|
||||
startingOffice: StartingOffice;
|
||||
/**
|
||||
* WHETHER THE SECOND SECTION CARD IS IN THE DECK (Q9, one copy).
|
||||
*
|
||||
* Not a table's choice — there is no dial for it — but a fact about how the game was DEALT, kept
|
||||
* here for the same reason `startingOffice` is: the deal has to be replayable. The card went into
|
||||
* the deck in v0.8.2, after that release's save check had been run, and a deck one card larger
|
||||
* shuffles into a different order from the same seed — so every save on the test server refused
|
||||
* at move 3 under 0.8.2 while its release notes said three would resume. `withSavedDeal` sets
|
||||
* this false for a save that predates the card; everything dealt since carries it as true.
|
||||
*/
|
||||
secondSectionCard: boolean;
|
||||
/**
|
||||
* §6.2 — MAY A TIMETABLED TRAIN BE THROWN AWAY? (Gitea#9, superseding Gitea#6.)
|
||||
*
|
||||
@@ -1262,6 +1273,7 @@ export type HouseRuleOverrides = {
|
||||
extraStart?: ExtraStartRule;
|
||||
discardTimetabled?: boolean;
|
||||
startingOffice?: StartingOffice;
|
||||
secondSectionCard?: boolean;
|
||||
};
|
||||
|
||||
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
||||
@@ -1287,6 +1299,7 @@ export const DEFAULT_HOUSE_RULES: HouseRules = {
|
||||
* harder setting for a table that wants it.
|
||||
*/
|
||||
startingOffice: 'depot',
|
||||
secondSectionCard: true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1306,8 +1319,9 @@ export const LEGACY_HOUSE_RULES: HouseRules = {
|
||||
// These games predate Gitea#6 as well as Gitea#9: a train card could simply be discarded. `true`
|
||||
// is what they were played under, and a replay that discards a Timetabled train needs it.
|
||||
discardTimetabled: true,
|
||||
// Every game before 2026-09-23 opened on a Whistle Post.
|
||||
// Every game before 2026-09-23 opened on a Whistle Post, from a deck with no Second Section card.
|
||||
startingOffice: 'whistlePost',
|
||||
secondSectionCard: false,
|
||||
};
|
||||
|
||||
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
||||
@@ -1329,28 +1343,36 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
|
||||
extraStart: given.extraStart ?? d.extraStart,
|
||||
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled,
|
||||
startingOffice: given.startingOffice ?? d.startingOffice,
|
||||
secondSectionCard: given.secondSectionCard ?? d.secondSectionCard,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* THE OPENING A SAVE THAT PREDATES THE SETTING WAS DEALT UNDER.
|
||||
* THE DEAL A SAVE THAT PREDATES THE 0.8.2 SETTINGS WAS DEALT UNDER.
|
||||
*
|
||||
* `startingOffice` is unlike every other house rule: the others change how a game PLAYS, and
|
||||
* getting one wrong stops a replay part-way where it can be seen. This one changes how the game is
|
||||
* DEALT — a different Office, and a deck with four more cards in it — so a save replayed under the
|
||||
* wrong opening is a different railroad from intent one, and the failure is silent.
|
||||
* Two of the house rules change how a game is DEALT rather than how it plays, and getting either
|
||||
* wrong does not stop a replay part-way where it can be seen — it deals a different railroad from
|
||||
* intent one, silently. `startingOffice` is a different Office and a deck with four more cards in
|
||||
* it; `secondSectionCard` is a deck one card larger, which the same seed shuffles into a different
|
||||
* order. Every game saved before 2026-09-23 opened on a Whistle Post from a deck without the
|
||||
* Second Section card, and its `houseRules` cannot say so.
|
||||
*
|
||||
* Every game saved before 2026-09-23 opened on a Whistle Post and its `houseRules` cannot say so.
|
||||
* So a saved config that names house rules but not this one gets what it was played under.
|
||||
* So a saved config that names house rules but not `startingOffice` — the settings form has named
|
||||
* it on every config written since the setting existed — gets both of what it was played under.
|
||||
* A config with no house rules at all is a fresh game, not an old save, and is left alone; so is
|
||||
* one that names the opening, in either direction.
|
||||
*
|
||||
* APPLIED ON THE REPLAY PATHS ONLY, never inside `houseRules()`. A preset, the setup form and the
|
||||
* lobby all build configs that name some rules and not others, and they mean today's default —
|
||||
* putting this in the resolver made a fresh Cutthroat game deal Whistle Posts and read as Custom.
|
||||
* APPLIED ON THE REPLAY PATHS ONLY (`configFor` in `web/game.ts`, `tryResumeSession` in
|
||||
* `server/session.ts`), never in `houseRules()` above: putting the legacy default in the resolver
|
||||
* made a fresh game deal the old railroad and read as "Custom" in the lobby.
|
||||
*/
|
||||
export function withSavedOpening<T extends { houseRules?: HouseRuleOverrides }>(config: T): T {
|
||||
export function withSavedDeal<T extends { houseRules?: HouseRuleOverrides }>(config: T): T {
|
||||
const given = config.houseRules;
|
||||
if (!given || given.startingOffice !== undefined) return config;
|
||||
return { ...config, houseRules: { ...given, startingOffice: 'whistlePost' } };
|
||||
return {
|
||||
...config,
|
||||
houseRules: { ...given, startingOffice: 'whistlePost', secondSectionCard: given.secondSectionCard ?? false },
|
||||
};
|
||||
}
|
||||
|
||||
/** What the dialog calls each option, in the order it offers them. */
|
||||
|
||||
@@ -139,7 +139,8 @@ export type GameEvent =
|
||||
trainNumber: number;
|
||||
office: string;
|
||||
owner: SeatIndex;
|
||||
freedBy: number;
|
||||
/** The train whose arrival freed the track — or null when a departure freed it (v0.8.3). */
|
||||
freedBy: number | null;
|
||||
}
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/**
|
||||
@@ -181,7 +182,7 @@ export type GameEvent =
|
||||
// -- freight agent
|
||||
| { type: 'stockToOutbound'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
|
||||
| { type: 'inboundCleared'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
|
||||
| { type: 'facilityUnjammed'; player: PlayerIndex; at: GridCoord; from: string; stock: RollingStock }
|
||||
| { type: 'facilityUnjammed'; player: PlayerIndex; at: GridCoord; from: string; index: number; stock: RollingStock }
|
||||
// -- trains
|
||||
/**
|
||||
* §7 — a Timetabled Train card played from hand is scheduled by a 1D12 roll. `rngState` carries
|
||||
|
||||
+4
-2
@@ -59,6 +59,8 @@ export function buildDeck(
|
||||
mode: GameConfig['mode'] = 'competitive',
|
||||
pvpCardsAllowed = false,
|
||||
startingOffice: StartingOffice = 'whistlePost',
|
||||
/** False only for a save that predates the card — see `HouseRules.secondSectionCard`. */
|
||||
secondSectionCard = true,
|
||||
): Card[] {
|
||||
/**
|
||||
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK REGARDLESS OF `pvpCardsAllowed`, for now.
|
||||
@@ -116,7 +118,7 @@ export function buildDeck(
|
||||
* Jesse's ruling, 2026-09-23: the action requires the card. Dealing it is the other half — gating
|
||||
* on a card the deck never holds would delete the mechanic rather than fix it.
|
||||
*/
|
||||
for (let i = 0; i < SECOND_SECTION.copies; i++) push({ kind: 'secondSection' });
|
||||
if (secondSectionCard) for (let i = 0; i < SECOND_SECTION.copies; i++) push({ kind: 'secondSection' });
|
||||
if (opponentCardsInDeck) {
|
||||
for (const c of SPACE_USE_CARDS) {
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key });
|
||||
@@ -369,7 +371,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
*/
|
||||
const rules = houseRules(config);
|
||||
const deal = OPENING_DEALS[rules.startingHand];
|
||||
const deck = buildDeck(config.mode, config.pvpCardsAllowed, rules.startingOffice);
|
||||
const deck = buildDeck(config.mode, config.pvpCardsAllowed, rules.startingOffice, rules.secondSectionCard);
|
||||
const cards = new Map<CardId, Card>();
|
||||
for (const c of deck) cards.set(c.id, c);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* checked, before `submit` is ever called — see `intent()` below.
|
||||
*/
|
||||
|
||||
import { withSavedOpening } from '../engine/content.ts';
|
||||
import { withSavedDeal } from '../engine/content.ts';
|
||||
import { check } from '../engine/apply.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
@@ -528,9 +528,9 @@ export type ResumeFailure = { stoppedAt: number; of: number; intent: string; cod
|
||||
|
||||
export function tryResumeSession(saved: SavedGame): { ok: true; session: GameSession } | { ok: false; failure: ResumeFailure } {
|
||||
// A save written before `startingOffice` existed opened on a Whistle Post and cannot say so —
|
||||
// replaying it under today's Depot default would deal a different railroad. See `withSavedOpening`.
|
||||
// replaying it under today's Depot default would deal a different railroad. See `withSavedDeal`.
|
||||
const { game, stopped } = fromMultiplayerSave(
|
||||
saved.seed, withSavedOpening(saved.config), saved.playerNames, saved.history,
|
||||
saved.seed, withSavedDeal(saved.config), saved.playerNames, saved.history,
|
||||
);
|
||||
if (stopped) {
|
||||
return {
|
||||
|
||||
+5
-1
@@ -553,7 +553,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
text:
|
||||
`Train ${e.trainNumber} RELEASED from the Limits into the ${e.office}` +
|
||||
`${who ? ` at ${who}'s district` : ''} — the Interlocking had been holding it clear of a ` +
|
||||
`full Office, and Train ${e.freedBy} arriving freed the A/D track it was waiting for. ` +
|
||||
`full Office, and ${
|
||||
e.freedBy === null
|
||||
? 'a departure freed the A/D track it was waiting for'
|
||||
: `Train ${e.freedBy} arriving freed the A/D track it was waiting for`
|
||||
}. ` +
|
||||
'A held train takes the first track to free, ahead of anything arriving after it.',
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -44,7 +44,7 @@ import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
LEGACY_HOUSE_RULES,
|
||||
withSavedOpening,
|
||||
withSavedDeal,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
industryProfile,
|
||||
@@ -1524,7 +1524,7 @@ export function toSave(game: Game): Save {
|
||||
* in force then — never the current defaults.
|
||||
*/
|
||||
function configFor(save: Save, config: GameConfig): GameConfig {
|
||||
return withSavedOpening({ ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES });
|
||||
return withSavedDeal({ ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1704,3 +1704,74 @@ describe('a train sorted to the nose is judged by §8.2, not by where the engine
|
||||
assert.match(why, /caboose must be at the rear/, `the hold did not say why: ${why}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the collision floor at the end of a Day (v0.8.3)', () => {
|
||||
it('fires on a breach reached in Stage 12, before the Day rolls over', () => {
|
||||
/**
|
||||
* `shiftChange` reset `collisionsToday` at the Day rollover and only THEN asked whether the Day
|
||||
* had breached the limit — so a third wreck in Stage 12 was read as zero, and the one Stage of
|
||||
* the Day in which the floor could not fire was the last one. The tests above all set `stage`
|
||||
* to 1, which never crosses the rollover.
|
||||
*/
|
||||
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 2 });
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished', 'a breach in the last Stage of the Day went unpunished');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Expedite fault is charged once per Mainline Phase (v0.8.3)', () => {
|
||||
it('does not charge again when the phase resumes after a clearance ruling', () => {
|
||||
/**
|
||||
* The Q3 fault loop ran unguarded at the top of `mainlinePhase`, and the phase is re-entered
|
||||
* from the top after every clearance, Yard Office or Red Flag question — so an Expedited train
|
||||
* left on a siding was fined once per QUESTION rather than once per Phase.
|
||||
*/
|
||||
const s = game(7, { days: 5 });
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
const area = areaOf(s, 0);
|
||||
// The Expedited train, parked off the station: one fault is due.
|
||||
s.trays.set('expedited', {
|
||||
id: 'expedited', trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: { row: area.officeCoord.row, col: area.officeCoord.col + 1 } }, movesUsed: 0,
|
||||
});
|
||||
// A departing train that will put a clearance question to the Superintendent mid-phase.
|
||||
s.trays.set('leaving', {
|
||||
id: 'leaving', trainNumber: 12, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push('leaving');
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
|
||||
const node = s.division.nodes[ahead];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e', position: { at: 'mainline', index: ahead }, movesUsed: 0,
|
||||
});
|
||||
if (node?.kind === 'mainline') node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
const before = s.players[0]!.revenue;
|
||||
const first = advance(s);
|
||||
assert.ok(first.needsInput, 'no clearance question was put, so the phase never resumed');
|
||||
assert.equal(first.events.filter((e) => e.type === 'expediteFault').length, 1);
|
||||
|
||||
// Hold the departing train, so the only Revenue that can move is the fault's.
|
||||
const ruling = legalActions(s, s.clock.superintendent).find((i) => i.type === 'mainline.clearance' && !i.allow);
|
||||
assert.ok(ruling, 'no clearance ruling on offer');
|
||||
assert.ok(applyIntent(s, s.clock.superintendent, ruling).ok);
|
||||
const resumed = advance(s);
|
||||
assert.ok(!resumed.events.some((e) => e.type === 'expediteFault'), 'the fault was charged a second time');
|
||||
assert.equal(s.players[0]!.revenue, before - EXPEDITE_FAULT_PENALTY, 'more than one fault was charged');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2480,3 +2480,112 @@ describe('backing up over a cut to something beyond it takes both (v0.4.9d repor
|
||||
empty(s, at(0, 1), at(0, 0));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a Department draw that empties the Home Office deck (v0.8.3)', () => {
|
||||
/**
|
||||
* THE DRAWN CARD WAS DUPLICATED AND THE REFILL CARD DESTROYED.
|
||||
*
|
||||
* `draw.fromDepartment` queued `departmentRefilled` and then asked `reshuffleIfDepleted` to sweep
|
||||
* the Departments — from the state BEFORE either event had been reduced. So the sweep collected
|
||||
* the card being drawn (still on its pile) and missed the refill card (still on the deck), and
|
||||
* the reducers then dealt the drawn card into the new deck while the refill card, moved onto a
|
||||
* pile the reshuffle immediately wiped, left the game. The Home Office path was covered by the
|
||||
* tests above; this path was not.
|
||||
*/
|
||||
it('neither duplicates the drawn card nor loses the refill card', () => {
|
||||
const s = game();
|
||||
const all = [...s.decks.homeOffice];
|
||||
s.decks.salvageYard = all.slice(0, 40);
|
||||
s.decks.homeOffice = all.slice(40, 41); // exactly one card left: the refill card
|
||||
const refill = s.decks.homeOffice[0]!;
|
||||
const drawn = s.decks.departments[0]![s.decks.departments[0]!.length - 1]!;
|
||||
s.decks.departments[0] = [drawn]; // a single card, so taking it empties the pile
|
||||
const everywhere = (): string[] => [
|
||||
...s.decks.homeOffice,
|
||||
...s.decks.departments.flat(),
|
||||
...s.decks.salvageYard,
|
||||
...[...s.decks.hands.values()].flat(),
|
||||
];
|
||||
const before = everywhere().length;
|
||||
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'the deck ran out and was not reshuffled');
|
||||
|
||||
const after = everywhere();
|
||||
assert.equal(after.length, before, 'the reshuffle created or destroyed cards');
|
||||
assert.equal(new Set(after).size, after.length, 'a card ended up in two places');
|
||||
assert.ok(s.decks.hands.get(0)!.includes(drawn), 'the drawn card is not in hand');
|
||||
assert.equal(after.filter((id) => id === drawn).length, 1, 'the drawn card was dealt back into the deck too');
|
||||
assert.equal(after.filter((id) => id === refill).length, 1, 'the refill card left the game');
|
||||
assert.ok(s.decks.departments.every((p) => p.length === 1), 'the Departments were not re-dealt one deep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unjamming the box the player named (v0.8.3)', () => {
|
||||
/**
|
||||
* `facilityUnjammed` cleared the FIRST load on MEN | AT | WORK, whatever index the intent named,
|
||||
* because the event never carried the index — the same shape as the "westmost car" fault
|
||||
* `unloadBegan` once had. With one load on the track it could not be seen.
|
||||
*/
|
||||
it('clears the named MEN | AT | WORK load, not the first one', () => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.set('-1,0', {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, modifiers: [], enhancements: [],
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: true },
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 },
|
||||
// An inbound tank load on MEN, a stranded outbound hopper on WORK.
|
||||
menAtWork: [{ type: 'tank', dir: 'in' }, null, { type: 'hopper', dir: 'out' }],
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
turnOf(s, 0).option = 'freightAgent';
|
||||
const yardBefore = s.yards.classificationYard.length;
|
||||
|
||||
const r = applyIntent(s, 0, { type: 'freightAgent.unjam', at: { row: -1, col: 0 }, from: 'menAtWork', index: 2 });
|
||||
assert.ok(r.ok, 'the jam could not be cleared');
|
||||
const f = area.grid.get('-1,0')!.facility as { menAtWork: ({ type: string } | null)[] };
|
||||
assert.ok(f.menAtWork[0], 'the tank load on MEN was cleared instead of the hopper on WORK');
|
||||
assert.equal(f.menAtWork[2], null, 'the hopper on WORK is still there');
|
||||
const returned = s.yards.classificationYard[yardBefore];
|
||||
assert.equal(returned?.type, 'hopper', `a ${returned?.type} went to the Classification Yard, not the hopper`);
|
||||
});
|
||||
|
||||
it('clears the named car in a green or red box, not the first of its type', () => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.set('-1,0', {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, modifiers: [], enhancements: [],
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [{ type: 'hopper', loaded: true, origin: 1 }, { type: 'hopper', loaded: true, origin: 2 }],
|
||||
inboundBox: [], capacity: { outbound: 2, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
turnOf(s, 0).option = 'freightAgent';
|
||||
|
||||
const r = applyIntent(s, 0, { type: 'freightAgent.unjam', at: { row: -1, col: 0 }, from: 'outbound', index: 1 });
|
||||
assert.ok(r.ok);
|
||||
const f = area.grid.get('-1,0')!.facility!;
|
||||
assert.deepEqual(f.outboundBox.map((c) => c.origin), [1], 'the wrong car left the box');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -941,3 +941,60 @@ describe('defensive enhancements', () => {
|
||||
assert.equal(hasDistrictEnhancement(areaOf(s, 0), 'waterColumn'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a train held at the Limits is released when a track frees (v0.8.3)', () => {
|
||||
it('takes a free A/D track at the end of the Mainline Phase without waiting for another arrival', () => {
|
||||
/**
|
||||
* The only release was inside `arriveAtOffice` for a DIFFERENT train — so an Office that
|
||||
* emptied by departures alone kept the held train at its Limits for the rest of the game,
|
||||
* invisible: no transit, not in `adOccupancy`, not counted by the clearance check.
|
||||
*/
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
const card = straight();
|
||||
card.enhancements.push('interlocking');
|
||||
addCard(s, at(0, 2), card);
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = []; // the Office cleared, and nothing is arriving
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
const r = advance(s);
|
||||
|
||||
assert.ok(area.adOccupancy.includes('waiting'), 'the held train is still at the Limits with the Office empty');
|
||||
assert.deepEqual(area.heldAtLimits, []);
|
||||
assert.deepEqual(s.trays.get('waiting')!.position, { at: 'grid', seat: 0, coord: area.officeCoord });
|
||||
const released = r.events.find((e) => e.type === 'trainReleasedFromLimits');
|
||||
assert.ok(released, 'the release is silent');
|
||||
const line = narrate(released as never, { playerName: () => 'A' });
|
||||
assert.match(line.text, /RELEASED from the Limits/, line.text);
|
||||
});
|
||||
|
||||
it('still waits while the Office is full', () => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = ['blocker'];
|
||||
s.trays.set('blocker', {
|
||||
id: 'blocker', trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
} as never);
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
s.clock.phase = 'mainline';
|
||||
advance(s);
|
||||
assert.deepEqual(area.heldAtLimits, ['waiting']);
|
||||
assert.ok(!area.adOccupancy.includes('waiting'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,12 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaAtSeat, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { applyIntent, areaAtSeat, areaOf, check, occupancyFor } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY, STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { legalActions } from '../src/engine/legal.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, turnOf } 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';
|
||||
@@ -979,3 +979,74 @@ describe('Employee Rotation (Appendix B)', () => {
|
||||
for (const name of ['Alice', 'Bob', 'Carol']) assert.match(line, new RegExp(name));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29): a player's switching stays in their own district
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("switching is confined to the actor's own district (v0.8.3)", () => {
|
||||
/**
|
||||
* `check` resolved the tray with no seat test at all: the legal-move GENERATOR filtered trays by
|
||||
* seat, `check` did not, and the server validates with `check` alone. Every district opens on the
|
||||
* same coordinates, so a destination legal for a tray of your own at (0,0) was "legal" for a
|
||||
* rival's tray at THEIR (0,0) — and `trayMoved` then charged the Moves to the rival.
|
||||
*/
|
||||
const placeOwn = (s: GameState, owner: PlayerIndex, id: string): void => {
|
||||
const area = areaOf(s, owner);
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', position: { at: 'grid', seat: seatOf(s, owner), coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
};
|
||||
const switching = (s: GameState, player: PlayerIndex): void => {
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = player;
|
||||
applyIntent(s, player, { type: 'localOps.choose', option: 'switch' });
|
||||
assert.equal(turnOf(s, player).option, 'switch', 'could not choose Switch');
|
||||
};
|
||||
|
||||
it("refuses a switch.move on a rival's tray that would have been legal on your own", () => {
|
||||
const s = game(2);
|
||||
// Find a move the actor could make with a tray of THEIR OWN standing at the Office.
|
||||
placeOwn(s, 0, 'mine');
|
||||
switching(s, 0);
|
||||
const own = legalActions(s, 0).find((i) => i.type === 'switch.move' && i.trayId === 'mine');
|
||||
assert.ok(own && own.type === 'switch.move', 'no switching move to test with');
|
||||
s.trays.delete('mine');
|
||||
// Now the same move named against seat 1's tray, standing at seat 1's Office.
|
||||
placeOwn(s, 1, 'theirs');
|
||||
const movesBefore = turnOf(s, 0).movesRemaining;
|
||||
const theirMovesBefore = turnOf(s, 1).movesRemaining;
|
||||
const code = check(s, 0, { ...own, trayId: 'theirs' });
|
||||
assert.equal(code, 'NO_SUCH_TRAY', `a rival's tray was accepted (${code ?? 'null'})`);
|
||||
assert.ok(!applyIntent(s, 0, { ...own, trayId: 'theirs' }).ok, 'the move was applied');
|
||||
assert.equal(turnOf(s, 0).movesRemaining, movesBefore);
|
||||
assert.equal(turnOf(s, 1).movesRemaining, theirMovesBefore, "the rival's Moves were charged");
|
||||
assert.deepEqual(s.trays.get('theirs')!.position, { at: 'grid', seat: seatOf(s, 1), coord: areaOf(s, 1).officeCoord });
|
||||
});
|
||||
|
||||
it("refuses dropCars and sortConsist on a rival's tray too", () => {
|
||||
const s = game(2);
|
||||
placeOwn(s, 0, 'mine'); // Switch is only on offer with a tray of your own to switch
|
||||
placeOwn(s, 1, 'theirs');
|
||||
s.trays.get('theirs')!.consist.push({ type: 'boxcar', loaded: false });
|
||||
switching(s, 0);
|
||||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 'theirs', count: 1 }), 'NO_SUCH_TRAY');
|
||||
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 'theirs', order: [0] }), 'NO_SUCH_TRAY');
|
||||
});
|
||||
|
||||
it("does not see a rival's crew as standing in your district", () => {
|
||||
/**
|
||||
* `occupancyFor().trayAt` matched on coordinates alone, so a crew at seat 0's (0,2) blocked
|
||||
* seat 1's (0,2) as "another train standing here". Invisible in solitaire.
|
||||
*/
|
||||
const s = game(2);
|
||||
const spot = { row: areaOf(s, 0).officeCoord.row, col: areaOf(s, 0).officeCoord.col + 2 };
|
||||
s.trays.set('crew0', {
|
||||
id: 'crew0', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', position: { at: 'grid', seat: seatOf(s, 0), coord: spot }, movesUsed: 0,
|
||||
});
|
||||
assert.equal(occupancyFor(s, 0, 'other').trayAt(spot), 'crew0', 'the owner cannot see their own crew');
|
||||
assert.equal(occupancyFor(s, 1, 'other').trayAt(spot), null, "a rival's crew is standing in the wrong district");
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'freightAgentIdled', player: 0 },
|
||||
{ type: 'trainReleasedFromLimits', trainNumber: 8, office: 'Whistle Post', owner: 0, freedBy: 14 },
|
||||
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', index: 0, stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
|
||||
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false }, trainNumber: 10, isExtra: false },
|
||||
{ type: 'carPassed', player: 0, trayId: 't0', trainNumber: 10, isExtra: false },
|
||||
|
||||
+26
-6
@@ -7,7 +7,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { CarType } from '../src/engine/content.ts';
|
||||
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK, withSavedOpening } from '../src/engine/content.ts';
|
||||
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK, withSavedDeal } from '../src/engine/content.ts';
|
||||
import {
|
||||
DECK_SIZE,
|
||||
EXTRA_TRAINS,
|
||||
@@ -680,16 +680,36 @@ describe('the starting Office, and the deck that goes with it', () => {
|
||||
it('replays a save written before the setting as the Whistle Post game it was', () => {
|
||||
/**
|
||||
* The one house rule that changes how a game is DEALT rather than how it plays, so replaying it
|
||||
* under the wrong opening is a different railroad from intent one — silently. `withSavedOpening`
|
||||
* under the wrong opening is a different railroad from intent one — silently. `withSavedDeal`
|
||||
* fills it for a save that names other rules and cannot name this one.
|
||||
*/
|
||||
const saved: { houseRules: { startingHand: 'sixRandom'; startingOffice?: 'depot' | 'whistlePost' } } =
|
||||
const saved: { houseRules: { startingHand: 'sixRandom'; startingOffice?: 'depot' | 'whistlePost'; secondSectionCard?: boolean } } =
|
||||
{ houseRules: { startingHand: 'sixRandom' } };
|
||||
assert.equal(withSavedOpening(saved).houseRules.startingOffice, 'whistlePost');
|
||||
assert.equal(withSavedDeal(saved).houseRules.startingOffice, 'whistlePost');
|
||||
// ...and from a deck without the Second Section card, which went in at the same time (v0.8.3).
|
||||
assert.equal(withSavedDeal(saved).houseRules.secondSectionCard, false);
|
||||
|
||||
// A config that names it is left exactly as it is, in both directions.
|
||||
assert.equal(withSavedOpening({ houseRules: { startingOffice: 'depot' as const } }).houseRules.startingOffice, 'depot');
|
||||
assert.equal(withSavedDeal({ houseRules: { startingOffice: 'depot' as const } }).houseRules.startingOffice, 'depot');
|
||||
assert.ok(!('secondSectionCard' in withSavedDeal({ houseRules: { startingOffice: 'depot' as const } }).houseRules));
|
||||
// And a config with no house rules at all is a fresh game, not an old save.
|
||||
assert.deepEqual(withSavedOpening({}), {});
|
||||
assert.deepEqual(withSavedDeal({}), {});
|
||||
});
|
||||
|
||||
it('deals the same deck a pre-0.8.2 save was dealt from — no Second Section card (v0.8.3)', () => {
|
||||
/**
|
||||
* The card went into the deck in v0.8.2 after that release's save check had been run. A deck one
|
||||
* card larger shuffles into a different order from the same seed, so every save on the test
|
||||
* server refused at move 3 while the release notes said three would resume. Pinned here: the
|
||||
* legacy deal has no such card and the fresh deal has exactly one.
|
||||
*/
|
||||
const count = (g: ReturnType<typeof createGame>): number =>
|
||||
[...g.cards.values()].filter((c) => c.kind.kind === 'secondSection').length;
|
||||
const fresh = createGame({ id: 'f', seed: 7, config: { ...solitaireConfig, houseRules: {} } as never, playerNames: ['A'] });
|
||||
assert.equal(count(fresh), 1, 'a fresh deal should carry one Second Section card');
|
||||
const legacy = createGame({
|
||||
id: 'l', seed: 7, config: withSavedDeal({ ...solitaireConfig, houseRules: { startingHand: 'sixRandom' } }) as never, playerNames: ['A'],
|
||||
});
|
||||
assert.equal(count(legacy), 0, 'a pre-0.8.2 save was dealt from a deck with no Second Section card');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user