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',