v0.8.3 — the engine half of the audit, and the deal 0.8.2 silently changed
Seven rules faults and one dealing fault, from a four-way code audit (engine, server, client, tests) read against the code before anything was acted on. Each is pinned by a test that failed first. CHANGELOG has the reasoning; this is the list. THE DEAL. 0.8.2 put the Second Section card into the deck after its save check had run and without a line in its notes. A deck one card larger shuffles differently from the same seed, so every save on the test server refused at move 3 — the boot log shows thirteen of thirteen — while the release notes said three would resume. `withSavedDeal` (was `withSavedOpening`) now sets `secondSectionCard: false` for a config that predates the setting, and the thirteen replay exactly as 0.8.2 described: three resume, ten refuse, the same ten at the same moves. THE RULES. `check` never tested that a switching tray was in the actor's own district, so a rival's train could be shunted and the rival charged the Moves. Occupancy matched on coordinates alone, so a rival's crew blocked your track. A Department draw that emptied the deck duplicated the drawn card and destroyed the refill card. The unjam cleared the first load rather than the one named. The collision floor could not fire in Stage 12. The Expedite fault was charged once per clearance question rather than once per phase. A train held at the Limits was only ever released by another arrival, never by a departure. Docs: rules.md describes each as built (and no longer says an Expedited train departs at Shift Change — that was v0.4.8's reading, corrected in v0.4.9's code and never in the document); game-state.md's collision-floor note now matches the code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6f2a8dff09
commit
4d222a7eba
+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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user