Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072029b1f7 | ||
|
|
d0e5091824 |
@@ -19,6 +19,94 @@ 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
|
||||
|
||||
### The Salvage Yard was face up and had nothing to say
|
||||
|
||||
*"Why is salvage deck not face up? I should see the card played onto salvage."* It always was — the
|
||||
tile reads the top card. It just said **"a card"**.
|
||||
|
||||
`apply.ts` pushes a **synthetic id** on `trainScheduled`:
|
||||
|
||||
```ts
|
||||
s.decks.salvageYard.push(`train-${e.trainNumber}`);
|
||||
```
|
||||
|
||||
Nothing in `s.cards` matches that, so `cardName()` fell through to its "a card" default — and since a
|
||||
train is scheduled several times a Day, that id is on top of the pile most of the time. Measured
|
||||
before touching anything: a session's Salvage tile read `"a card"` from the opening frame through 60
|
||||
pushes, never once changing, while its depth climbed from 2 to 8.
|
||||
|
||||
`cardName()` resolves `train-<n>` now, so the tile reads **Train 3**, **Train 2** as cards land.
|
||||
Resolved in `sim/view.ts` because this is a NAME, which is that file's job.
|
||||
|
||||
**The engine half is filed, not fixed — Gitea#23.** `reshuffleIfDepleted()` sweeps the Salvage Yard
|
||||
back into the draw deck, so a synthetic id can be shuffled in and drawn into a hand as an id with no
|
||||
card behind it. Not reachable in ordinary play: eight games driven to 4000 moves across eight seeds
|
||||
produced **zero** reshuffles. There are two defensible fixes and the choice turns on what that
|
||||
synthetic id is *for*, which is not a call to make in passing while fixing a label.
|
||||
|
||||
### Phases scale with the speed control again — at a third of the rate
|
||||
|
||||
*"Phases displayed on the upper line go by too quickly still. Should be 4 times as long — at a guess.
|
||||
Maybe use the speed multiplier for that too?"*
|
||||
|
||||
Two complaints from opposite directions, and the answer is between them. v0.8.0.3 **pinned** phases at
|
||||
their tabled beat because scaling them walled off a player's own turn — *"that makes no sense"*. At
|
||||
10× that pinned beat is too short to read the sentence on it.
|
||||
|
||||
So they scale, damped to a third of the rate: **1× unchanged, 10× lands exactly on the four-times
|
||||
guess**, 20× gives 4400ms. The cost stays bounded because phase beats cluster rather than accumulate
|
||||
— measured over 60 pushes, a push carries **1.0 phase beat on average and 4 at worst**, so the wait
|
||||
after a move is ~2.4s typical and ~10s at its very worst, against the minutes a full multiplier would
|
||||
have cost. A player's own move still outlasts a phase beat at every speed, which is the ordering that
|
||||
matters.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.6 — 2026-09-10
|
||||
|
||||
Playing v0.8.0.5: *"saw bot's office area now — much better."* Three things still wrong, and one of
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.0.6",
|
||||
"version": "0.8.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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
|
||||
// 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-<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;
|
||||
|
||||
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() };
|
||||
|
||||
+23
-1
@@ -177,6 +177,28 @@ export function dwellFor(cause: StepCause, pace = 1): number {
|
||||
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the speed control a PHASE gets — damped, not the full multiplier.
|
||||
*
|
||||
* Phases were pinned at their tabled beat in v0.8.0.3, because scaling them with everything else put
|
||||
* a wall of clock-ticking after a player's own move. That was right about the cost and wrong about
|
||||
* the need: at 10× the caption row goes past faster than the sentence on it can be read. Jesse,
|
||||
* 2026-09-10: *"phases displayed on the upper line go by too quickly still. Should be 4 times as
|
||||
* long — at a guess. Maybe use the speed multiplier for that too?"*
|
||||
*
|
||||
* So they scale, at a third of the rate. That lands exactly on his guess — 10× gives a phase four
|
||||
* times its tabled beat — while leaving 1× untouched, and it stays affordable because phase beats
|
||||
* cluster rather than accumulate: measured over 60 pushes, a push carries **1.0 phase beat on
|
||||
* average and 4 at worst**, so the wait after a move goes to ~2.4s typical and ~10s at its very
|
||||
* worst rather than the minutes a full multiplier would have cost.
|
||||
*
|
||||
* Below 1× it simply follows the multiplier: somebody asking for everything faster means the phases
|
||||
* too.
|
||||
*/
|
||||
function phaseSpeed(pace: number): number {
|
||||
return pace <= 1 ? pace : 1 + (pace - 1) / 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to show one STEP — the form the queue actually uses.
|
||||
*
|
||||
@@ -204,7 +226,7 @@ export function dwellForStep(
|
||||
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
|
||||
* to watch a bot shunt cars.
|
||||
*/
|
||||
const speed = step.player === null ? 1 : pace;
|
||||
const speed = step.player === null ? phaseSpeed(pace) : pace;
|
||||
if (step.lines.length > 0) return dwellFor(step.cause, speed);
|
||||
/**
|
||||
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
|
||||
|
||||
+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');
|
||||
});
|
||||
|
||||
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),
|
||||
|
||||
+28
-10
@@ -170,23 +170,41 @@ describe('pacing — dwell by kind', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('the speed control stretches other people, not the clock', () => {
|
||||
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
|
||||
/**
|
||||
* Jesse, from a real 5× game: *"after my turn, when I actually execute my turn, I'm still subject
|
||||
* to that same delay before it moves on. That makes no sense."* It was not his move being
|
||||
* replayed — it was the automatic phases behind it, which were scaling with `pace` along with
|
||||
* everything else. Measured over 40 turns, the waiting split almost evenly between other players
|
||||
* and phases turning over, so a 5× game spent 105 seconds on the clock alone.
|
||||
* Two complaints, one from each direction, and the answer is between them.
|
||||
*
|
||||
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
|
||||
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
|
||||
* player's own turn. So they were pinned at their tabled beat.
|
||||
*
|
||||
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
|
||||
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
|
||||
*
|
||||
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
|
||||
* the cost stays bounded because phase beats cluster rather than accumulate.
|
||||
*/
|
||||
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
|
||||
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
|
||||
for (const pace of [1, 3, 5, 7]) {
|
||||
assert.equal(dwellForStep(phase, pace), DWELL.phase, `a phase beat grew at ${pace}x`);
|
||||
assert.equal(dwellForStep(theirs, pace), DWELL.switching * pace);
|
||||
|
||||
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
|
||||
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
|
||||
|
||||
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
|
||||
const p = dwellForStep(phase, pace);
|
||||
const t = dwellForStep(theirs, pace);
|
||||
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
|
||||
assert.ok(
|
||||
p < DWELL.phase * pace,
|
||||
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
|
||||
);
|
||||
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
|
||||
}
|
||||
// Off still means off, for the clock as much as for anybody's move.
|
||||
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
|
||||
// follows the multiplier straight, because "faster" should mean everything.
|
||||
assert.equal(dwellForStep(phase, 0), 0);
|
||||
assert.equal(dwellForStep(theirs, 0), 0);
|
||||
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
|
||||
});
|
||||
|
||||
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
|
||||
|
||||
Reference in New Issue
Block a user