From 072029b1f7710c58a58e1f21b9c073e2c245f8e5 Mon Sep 17 00:00:00 2001 From: "Jesse.Markowitz" Date: Thu, 10 Sep 2026 07:19:47 -0400 Subject: [PATCH] =?UTF-8?q?v0.8.0.8=20=E2=80=94=20a=20played=20train=20doe?= =?UTF-8?q?s=20not=20come=20back;=20a=20discarded=20one=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jesse's ruling on the question v0.8.0.7 filed: once a regularly scheduled train has been played its number is on the timetable, so putting it back into a reshuffled deck to be played again makes no sense. The same card sitting in a discard pile was never played and its slot is still open, so it should come back. An Extra is a single run rather than a standing slot, so a played one is free to run again. The test is therefore WHERE the card is, not only what it is — which is worth writing down, because it is exactly the rule a later tidy-up would simplify into filtering by kind everywhere. And the duplicate that started it: trainScheduled was pushing a synthetic train- into the Salvage Yard beside the real card cardPlayed had already put there — four scheduled trains left eight entries in a pile holding four cards. Nothing read it, it inflated the depth, it displayed as "a card", and it would have been swept into the draw deck to be drawn as an id with nothing behind it. Removed, which retires the phantom-id class rather than papering over it, so v0.8.0.7's cardName resolver for it goes too. Games in progress resume: no predicate changed its answer, and a draw is a draw whatever is on top. What differs is the Yard's depth, which was double-counting, and what a reshuffle recovers — and reshuffles are effectively unreachable, with zero seen across eight games driven to 4000 moves. Closes #23. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6 --- CHANGELOG.md | 43 ++++++++++++++++++++++++++++++++ package.json | 2 +- src/engine/apply.ts | 40 +++++++++++++++++++++++++++--- src/sim/view.ts | 17 ------------- test/apply.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++- test/web.test.ts | 28 --------------------- 6 files changed, 140 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ced78a..e7cc67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,49 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.8.0.8 — 2026-09-10 + +**A played train does not come back. Gitea#23, ruled and closed.** + +Jesse, on the question v0.8.0.7 filed rather than answered: *"Once you've played a regularly +scheduled train and it's in the salvage deck, that train is already on the timetable. It does not +make sense to put that back into a reshuffled home deck to get played again. By contrast, a regularly +scheduled train that's in a discard pile could potentially get reused later, and so should have that +capability. Extras run one time and then they're done — if they are in the Salvage deck, they should +get shuffled back in so that they could get run again."* + +**The test is WHERE the card is, not only what it is** — which is the part worth writing down, because +it is exactly the rule a later tidy-up would "simplify" into filtering by card kind everywhere. The +same train card is spent in the Salvage Yard and still runnable in a Department: + +| card | where | on a reshuffle | +| --- | --- | --- | +| timetabled train | Salvage Yard — it was **played**, its number is on the timetable | stays out | +| timetabled train | a Department — **discarded**, never played, slot still open | comes back | +| Extra | anywhere | comes back; an Extra is one run, not a standing slot | +| everything else | anywhere | comes back, as before | + +### And the duplicate that started it + +`trainScheduled` was pushing a synthetic `train-` into the Salvage Yard **beside the real +card `cardPlayed` had already put there** — measured: four scheduled trains left eight entries in a +pile holding four cards. Nothing read that id. It inflated the pile's depth, displayed as "a card" +because no such card exists, and would have been swept into the draw deck to be drawn as an id with +nothing behind it. Removed, which retires the whole phantom-id class rather than papering over it — +so v0.8.0.7's `cardName()` resolver for `train-` is gone too, along with its test. Dead code kept +for an id that can no longer exist is worse than no code. + +### Games in progress + +**Resume.** No predicate changed its answer — nothing that was legal became illegal, and a draw is a +draw whatever is on top of the deck. What differs is the Salvage Yard's depth, which was +double-counting, and what a reshuffle would recover. Reshuffles are effectively unreachable in +ordinary play: eight games driven to 4000 moves across eight seeds produced zero. + +Closes #23. + +--- + ## 0.8.0.7 — 2026-09-10 ### The Salvage Yard was face up and had nothing to say diff --git a/package.json b/package.json index 88b38e6..3687ae2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.8.0.7", + "version": "0.8.0.8", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 7cac1ac..9e620ba 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -2242,7 +2242,8 @@ export function reduce(s: GameState, e: GameEvent): void { // Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards // turned face up as the Departments, the rest face down as the Home Office deck. The // Departments start one deep again, exactly as at setup. - s.decks.salvageYard = []; + // The spent trains stay where they are; everything else in the Yard has just been swept up. + s.decks.salvageYard = s.decks.salvageYard.filter((id) => isSpentTimetabledTrain(s, id)); s.decks.departments = [[], [], []]; const order = [...e.order]; for (const pile of s.decks.departments) { @@ -2473,7 +2474,15 @@ export function reduce(s: GameState, e: GameEvent): void { case 'trainScheduled': s.timetable[e.slot] = e.trainNumber; s.rngState = e.rngState; - s.decks.salvageYard.push(`train-${e.trainNumber}`); + /** + * THE CARD IS ALREADY IN THE SALVAGE YARD — `cardPlayed` put it there, by its real id. + * + * This used to push a second, SYNTHETIC `train-` beside it, so scheduling four trains + * left eight entries in a pile holding four cards. Nothing ever read that id: it inflated the + * pile's depth, it displayed as "a card" because no such card exists, and + * `reshuffleIfDepleted` would have swept it into the draw deck to be drawn as an id with + * nothing behind it. Removed 2026-09-10 (Gitea#23). + */ break; case 'carPlacedOnTrain': { @@ -2930,9 +2939,34 @@ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void { * that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile. * Cards played onto the board are NOT recovered: they are on the table, which is where they belong. */ +/** + * §6.2, AND THE RULING THAT SETTLES IT — Jesse, 2026-09-10 (Gitea#23). + * + * "Once you've played a regularly scheduled train and it's in the salvage deck, that train is + * already on the timetable. It does not make sense to put that back into a reshuffled home deck to + * get played again. By contrast, a regularly scheduled train that's in a discard pile could + * potentially get reused later, and so should have that capability. Extras run one time and then + * they're done — if they are in the Salvage deck, they should get shuffled back in so that they + * could get run again." + * + * So the test is WHERE the card is, not only what it is. A timetabled train in the SALVAGE YARD was + * played: its number is on the timetable and cannot be scheduled twice, so the card is spent and + * stays out. The same card sitting in a DEPARTMENT was discarded, never played, and its slot is + * still open — so it comes back with everything else. An Extra is a single run rather than a + * standing slot, so a played one is free to be run again. + */ +function isSpentTimetabledTrain(s: GameState, id: CardId): boolean { + return s.cards.get(id)?.kind.kind === 'timetabledTrain'; +} + function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null { if (s.decks.homeOffice.length > taking) return null; - const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()]; + 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(), + ]; if (collected.length === 0) return null; const rng = createRng(s.rngState); return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() }; diff --git a/src/sim/view.ts b/src/sim/view.ts index 0eed25b..d378716 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -1791,23 +1791,6 @@ export function snapshot( /** A card id turned into something a person can read. */ export function cardName(s: GameState, id: string): string { - /** - * A SCHEDULED TRAIN IS IN THE SALVAGE YARD UNDER A SYNTHETIC ID, and without this the pile that is - * supposed to be face up reads "a card". - * - * `trainScheduled` pushes `train-` rather than the id of the card that was played - * (`apply.ts`), so there is nothing in `s.cards` to look up — and since a train is scheduled - * several times a Day, that synthetic id is on top of the Salvage Yard most of the time. Reported - * by Jesse 2026-09-10 as "why is salvage deck not face up. I should see the card played onto - * salvage": it was face up all along and simply had nothing to say. - * - * Resolved here rather than in the engine because this is a NAME, which is this file's job. Whether - * the engine should be pushing a real card id instead is a separate question with a separate - * consequence — the Salvage Yard is swept back into the draw deck when it runs out — and is filed - * rather than answered in passing. - */ - const scheduled = /^train-(\d+)$/.exec(id); - if (scheduled) return `Train ${scheduled[1]}`; const k = s.cards.get(id)?.kind; if (!k) return 'a card'; switch (k.kind) { diff --git a/test/apply.test.ts b/test/apply.test.ts index c610604..96cc779 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -159,6 +159,60 @@ describe('Local Operations: drawing (§6.2)', () => { assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card'); }); + it('a PLAYED timetabled train never comes back, but a discarded one does — Gitea#23', () => { + /** + * Jesse's ruling, 2026-09-10: *"Once you've played a regularly scheduled train and it's in the + * salvage deck, that train is already on the timetable. It does not make sense to put that back + * into a reshuffled home deck to get played again. By contrast, a regularly scheduled train + * that's in a discard pile could potentially get reused later, and so should have that + * capability. Extras run one time and then they're done — if they are in the Salvage deck, they + * should get shuffled back in so that they could get run again."* + * + * So the test is WHERE the card is, not only what it is: the same card is spent in the Salvage + * Yard and still runnable in a Department. That is what this pins, because it is the kind of rule + * a later tidy-up would happily "simplify" into filtering by card kind everywhere. + */ + const s = game(); + const kindOfCard = (id: string): string => s.cards.get(id)?.kind.kind ?? '?'; + const pool = [...s.decks.homeOffice]; + const trains = pool.filter((id) => kindOfCard(id) === 'timetabledTrain'); + const extras = pool.filter((id) => kindOfCard(id) === 'extraTrain'); + const others = pool.filter((id) => !['timetabledTrain', 'extraTrain'].includes(kindOfCard(id))); + assert.ok(trains.length >= 2 && extras.length >= 1 && others.length >= 5, 'the deal lacks the cards this needs'); + + const spentTrain = trains[0]!; // played: in the Salvage Yard, its slot taken + const discardedTrain = trains[1]!; // never played: sitting in a Department + const playedExtra = extras[0]!; // a single run, free to run again + + s.decks.salvageYard = [spentTrain, playedExtra, ...others.slice(0, 3)]; + s.decks.departments = [[discardedTrain], [others[3]!], [others[4]!]]; + s.decks.homeOffice = [others[5]!]; + + applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); + const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); + assert.ok(r.ok); + assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted'); + + const recovered = new Set([...s.decks.homeOffice, ...s.decks.departments.flat()]); + const hands = new Set([...s.decks.hands.values()].flat()); + + // THE RULING, both halves. + assert.ok(!recovered.has(spentTrain), 'a played timetabled train was shuffled back in'); + assert.ok(!hands.has(spentTrain), 'a played timetabled train was dealt back into a hand'); + assert.ok( + s.decks.salvageYard.includes(spentTrain), + 'a played timetabled train should stay in the Salvage Yard, not vanish', + ); + assert.ok( + recovered.has(discardedTrain) || hands.has(discardedTrain), + 'a DISCARDED timetabled train must come back — it was never played, so its slot is open', + ); + assert.ok( + recovered.has(playedExtra) || hands.has(playedExtra), + 'a played Extra must come back — an Extra is one run, not a standing slot', + ); + }); + it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => { // §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards // from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office @@ -182,7 +236,11 @@ describe('Local Operations: drawing (§6.2)', () => { assert.ok(r.ok); assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted'); - assert.equal(s.decks.salvageYard.length, 0, 'the Salvage Yard must be swept'); + // Swept EXCEPT the trains whose slots are already on the timetable — see the ruling test below. + assert.ok( + s.decks.salvageYard.every((id) => s.cards.get(id)?.kind.kind === 'timetabledTrain'), + 'the Salvage Yard must be swept apart from spent timetabled trains', + ); assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established'); assert.ok( s.decks.departments.every((p) => p.length === 1), diff --git a/test/web.test.ts b/test/web.test.ts index cfda119..ecaee3c 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -3647,34 +3647,6 @@ describe('the Day rolling over says so (Gitea#10)', () => { ); }); - it('names the train on top of the Salvage Yard, instead of "a card"', () => { - /** - * The Salvage Yard is a FACE-UP pile and its tile reads the top card — but `trainScheduled` - * pushes a synthetic `train-` id rather than the id of the card that was played - * (`apply.ts`), so there was nothing in `s.cards` to look up and the tile said "a card". A train - * is scheduled several times a Day, so that id is on top most of the time: the pile was face up - * and had nothing to say. Jesse, 2026-09-10: *"why is salvage deck not face up. I should see the - * card played onto salvage."* - */ - const s = createEngineGame({ - id: 'salv', - seed: 5, - config: { - mode: 'competitive', days: 5, minCombinedRevenue: 60, - maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, - optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, - }, - playerNames: ['Joe', 'Bot 1'], - }); - s.decks.salvageYard.push('train-13'); - const f = snapshot(s, [], null); - assert.equal(f.salvage.top, 'Train 13', 'a scheduled train on the pile must be named'); - - const html = pilesHtml(f); - assert.ok(html.includes('Train 13'), `the Salvage tile does not name the train:\n${html}`); - assert.equal(html.includes('>a card<'), false, 'the face-up pile still says "a card"'); - }); - it('lights only the pile a watched move touched', () => { const s = createEngineGame({ id: 'piles2',