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 };
|
||||
}
|
||||
|
||||
|
||||
+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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user