Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072029b1f7 |
@@ -19,6 +19,49 @@ page as `v0.1.0 · <sha> · <date>`, 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-<number>` 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-<n>` 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
|
## 0.8.0.7 — 2026-09-10
|
||||||
|
|
||||||
### The Salvage Yard was face up and had nothing to say
|
### The Salvage Yard was face up and had nothing to say
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.8.0.7",
|
"version": "0.8.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+37
-3
@@ -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
|
// 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
|
// 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.
|
// 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 = [[], [], []];
|
s.decks.departments = [[], [], []];
|
||||||
const order = [...e.order];
|
const order = [...e.order];
|
||||||
for (const pile of s.decks.departments) {
|
for (const pile of s.decks.departments) {
|
||||||
@@ -2473,7 +2474,15 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
case 'trainScheduled':
|
case 'trainScheduled':
|
||||||
s.timetable[e.slot] = e.trainNumber;
|
s.timetable[e.slot] = e.trainNumber;
|
||||||
s.rngState = e.rngState;
|
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-<number>` 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;
|
break;
|
||||||
|
|
||||||
case 'carPlacedOnTrain': {
|
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.
|
* 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.
|
* 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 {
|
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
|
||||||
if (s.decks.homeOffice.length > taking) return 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;
|
if (collected.length === 0) return null;
|
||||||
const rng = createRng(s.rngState);
|
const rng = createRng(s.rngState);
|
||||||
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
|
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
|
||||||
|
|||||||
@@ -1791,23 +1791,6 @@ export function snapshot(
|
|||||||
|
|
||||||
/** A card id turned into something a person can read. */
|
/** A card id turned into something a person can read. */
|
||||||
export function cardName(s: GameState, id: string): string {
|
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-<number>` 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;
|
const k = s.cards.get(id)?.kind;
|
||||||
if (!k) return 'a card';
|
if (!k) return 'a card';
|
||||||
switch (k.kind) {
|
switch (k.kind) {
|
||||||
|
|||||||
+59
-1
@@ -159,6 +159,60 @@ describe('Local Operations: drawing (§6.2)', () => {
|
|||||||
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card');
|
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', () => {
|
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
|
// §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
|
// 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.ok);
|
||||||
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
|
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.homeOffice.length > 0, 'the deck must be re-established');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
s.decks.departments.every((p) => p.length === 1),
|
s.decks.departments.every((p) => p.length === 1),
|
||||||
|
|||||||
@@ -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-<number>` 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', () => {
|
it('lights only the pile a watched move touched', () => {
|
||||||
const s = createEngineGame({
|
const s = createEngineGame({
|
||||||
id: 'piles2',
|
id: 'piles2',
|
||||||
|
|||||||
Reference in New Issue
Block a user