Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072029b1f7 | ||
|
|
d0e5091824 | ||
|
|
64e8ce584f |
+139
@@ -19,6 +19,145 @@ 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
|
||||||
|
them was mine hiding inside the fix for another.
|
||||||
|
|
||||||
|
### Your move is put away while the board is catching up
|
||||||
|
|
||||||
|
*"Your actions should be hidden while catching up."* Two reasons, and the second is the one that
|
||||||
|
changed my mind about a decision taken early in v0.8.0 ("never block input"). The board on screen is
|
||||||
|
behind the game, so a move offered there is a move against a position that has already moved on — the
|
||||||
|
menu is computed from the CURRENT state and would be acted on while looking at an older one. And the
|
||||||
|
screen had grown to four things competing at once: the district, the history, the catching-up row,
|
||||||
|
and now a lit pile. Taking the action list out of that competition, while there is nothing to decide
|
||||||
|
anyway, is the cheapest way to quieten it.
|
||||||
|
|
||||||
|
Not a block: Skip is one click away at the left of the row, so the wait stays voluntary. The buttons
|
||||||
|
are replaced by the reason they are gone.
|
||||||
|
|
||||||
|
### …which could have locked a player out of their own game
|
||||||
|
|
||||||
|
Hiding actions behind `busy()` makes that flag the thing standing between a player and their turn —
|
||||||
|
and **without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would never
|
||||||
|
clear.** The action list would have been hidden permanently, with Skip the only way to play.
|
||||||
|
|
||||||
|
Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same stub that has been proving this
|
||||||
|
page still starts since long before any of this existed. Two fallbacks now: no `rAF` means draw
|
||||||
|
everything at once (exactly what `pace = 0` does deliberately), and a queue that throws empties
|
||||||
|
itself rather than stranding the player. `test/step-queue.test.ts` pins that a never-advanced queue
|
||||||
|
is still skippable.
|
||||||
|
|
||||||
|
### The lit pile was never brief — it was too quiet
|
||||||
|
|
||||||
|
*"Never saw decks lighting up… caught one flash deck light up for just a very brief moment, but
|
||||||
|
couldn't see that with what bot was doing in office area and history and catch up area all at same
|
||||||
|
time."*
|
||||||
|
|
||||||
|
Measured before changing anything: at 10× a pile stays lit for **6997ms**, just under seven seconds.
|
||||||
|
So the highlight was not brief at all. It was a single 0.45s flash-in over a dark green fill, easy to
|
||||||
|
miss entirely while looking at the district — a state that settles stops asking to be looked at. It
|
||||||
|
pulses now for as long as the move is up, with a ring and a glow. The reduced-motion fallback is loud
|
||||||
|
in a different way rather than simply still, since motion is the whole point here.
|
||||||
|
|
||||||
|
### The ceiling was not theoretical
|
||||||
|
|
||||||
|
*"At 10× — still a bit fast, but followable."* 10× was the top of the ladder, so the control's
|
||||||
|
slowest setting was not slow enough for the person using it. `PACE_LEVELS` now runs to 20 and
|
||||||
|
`MAX_PACE` with it. A control whose limit is reached in ordinary use has the wrong limit, not the
|
||||||
|
right one held firmly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.8.0.5 — 2026-09-10
|
## 0.8.0.5 — 2026-09-10
|
||||||
|
|
||||||
**Somewhere to look.** Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast for
|
**Somewhere to look.** Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast for
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.8.0.5",
|
"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() };
|
||||||
|
|||||||
+32
-5
@@ -143,10 +143,15 @@ export function kindOf(cause: StepCause): StepKind {
|
|||||||
*
|
*
|
||||||
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
|
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
|
||||||
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
|
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
|
||||||
* dwell and look exactly like a frozen board. Ten is far beyond any speed anyone would choose (2 and
|
* dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
|
||||||
* 3 are the ones actually asked for) and well short of unusable.
|
* well short of unusable.
|
||||||
|
*
|
||||||
|
* RAISED FROM TEN 2026-09-10, because the ceiling turned out not to be theoretical: Jesse played at
|
||||||
|
* 10× — the top of the ladder — and reported it *"still a bit fast, but followable"*. A control whose
|
||||||
|
* slowest setting is not slow enough for the person using it has the wrong ceiling, not the right one
|
||||||
|
* held firmly.
|
||||||
*/
|
*/
|
||||||
export const MAX_PACE = 10;
|
export const MAX_PACE = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The speeds the on-screen control offers, slowest last.
|
* The speeds the on-screen control offers, slowest last.
|
||||||
@@ -158,7 +163,7 @@ export const MAX_PACE = 10;
|
|||||||
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
|
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
|
||||||
* takes.
|
* takes.
|
||||||
*/
|
*/
|
||||||
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10] as const;
|
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10, 15, 20] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long to show one step, in ms, at a given speed.
|
* How long to show one step, in ms, at a given speed.
|
||||||
@@ -172,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)));
|
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.
|
* How long to show one STEP — the form the queue actually uses.
|
||||||
*
|
*
|
||||||
@@ -199,7 +226,7 @@ export function dwellForStep(
|
|||||||
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
|
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
|
||||||
* to watch a bot shunt cars.
|
* 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);
|
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
|
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
|
||||||
|
|||||||
+52
-2
@@ -184,6 +184,20 @@ function drainIntoQueue(): void {
|
|||||||
const reset = session.takeDisplayReset();
|
const reset = session.takeDisplayReset();
|
||||||
if (reset) stepQueue.reset(reset);
|
if (reset) stepQueue.reset(reset);
|
||||||
stepQueue.push(session.takeDisplaySteps());
|
stepQueue.push(session.takeDisplaySteps());
|
||||||
|
/**
|
||||||
|
* NOWHERE TO ANIMATE MEANS DO NOT QUEUE AT ALL.
|
||||||
|
*
|
||||||
|
* Without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would stay true for
|
||||||
|
* good — and since "Your Move" is now put away while the board is catching up, that would hide a
|
||||||
|
* player's own actions permanently, leaving Skip as the only way to play the game. Drawing
|
||||||
|
* everything at once is exactly what `pace = 0` does deliberately, so that is the honest fallback
|
||||||
|
* rather than a broken page. Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same
|
||||||
|
* stub that has been proving this page still starts since long before any of this existed.
|
||||||
|
*/
|
||||||
|
if (typeof requestAnimationFrame !== 'function') {
|
||||||
|
stepQueue.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (stepQueue.busy()) startAnimationLoop();
|
if (stepQueue.busy()) startAnimationLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,12 +330,25 @@ function startAnimationLoop(): void {
|
|||||||
*/
|
*/
|
||||||
console.error('display queue stopped:', err);
|
console.error('display queue stopped:', err);
|
||||||
animating = false;
|
animating = false;
|
||||||
|
// Do not strand the player behind a queue that can no longer advance: jump the board to the
|
||||||
|
// live position, which brings "Your Move" back with it.
|
||||||
|
try {
|
||||||
|
stepQueue.skip();
|
||||||
|
} catch {
|
||||||
|
/* nothing further to try — the authoritative Frame is still what the rest of the page draws */
|
||||||
|
}
|
||||||
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!stepQueue.busy()) {
|
if (!stepQueue.busy()) {
|
||||||
animating = false;
|
animating = false;
|
||||||
// One last render so the "N behind" row collapses the moment the board is level.
|
/**
|
||||||
renderWatching();
|
* A FULL RENDER, not just the row. The board being level again is what brings "Your Move"
|
||||||
|
* back and clears the last lit pile, so redrawing only the catching-up row would leave the
|
||||||
|
* action list hidden until something else happened to trigger a render — which, when the game
|
||||||
|
* is waiting on this player, is nothing at all.
|
||||||
|
*/
|
||||||
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
@@ -1987,6 +2014,29 @@ function renderActions(
|
|||||||
renderEnding(el, f);
|
renderEnding(el, f);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YOUR MOVE IS PUT AWAY WHILE THE BOARD IS CATCHING UP — Jesse, 2026-09-10: *"your actions should
|
||||||
|
* be hidden while catching up."*
|
||||||
|
*
|
||||||
|
* Two reasons, and the second is the one that changed my mind about it. The board on screen is
|
||||||
|
* behind the game, so a move offered here is a move against a position that has already moved on —
|
||||||
|
* the menu is computed from the CURRENT state and would be acted on while looking at an older one.
|
||||||
|
* And the display had grown to four things demanding attention at once — the district, the history,
|
||||||
|
* the catching-up row and now a lit pile — which is what made the pile highlight so easy to miss.
|
||||||
|
* Taking the action list out of that competition while there is nothing to decide anyway is the
|
||||||
|
* cheapest way to quieten it.
|
||||||
|
*
|
||||||
|
* NOT A BLOCK. Skip is one click away and sits at the left of the row, so the wait is always
|
||||||
|
* voluntary; this replaces the buttons with the reason they are gone, rather than leaving a live
|
||||||
|
* menu over a stale board.
|
||||||
|
*/
|
||||||
|
if (stepQueue.busy()) {
|
||||||
|
el.innerHTML =
|
||||||
|
'<div class="dim">Catching up on what everyone else did — your move is here when the board is ' +
|
||||||
|
'level with the game. <b>Skip</b> jumps straight to it.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
|
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
|
||||||
// put its results up unasked again (Gitea#11).
|
// put its results up unasked again (Gitea#11).
|
||||||
resultsShown = false;
|
resultsShown = false;
|
||||||
|
|||||||
+14
-4
@@ -723,10 +723,20 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
|||||||
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
|
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
|
||||||
border and background stay for exactly as long as the step is up, because the class is on the
|
border and background stay for exactly as long as the step is up, because the class is on the
|
||||||
element only while that step is the one being shown. */
|
element only while that step is the one being shown. */
|
||||||
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;animation:pileflash .45s ease-out 1}
|
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
|
||||||
@keyframes pileflash{0%{background:#2f6b47;border-color:#c7f0d4;transform:scale(1.1)}
|
animation:pilepulse 1.15s ease-in-out infinite}
|
||||||
100%{background:#1d3327;border-color:#8fd6a0;transform:scale(1)}}
|
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
|
||||||
@media(prefers-reduced-motion:reduce){.handcard.pilelit{animation:none}}
|
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
|
||||||
|
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
|
||||||
|
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
|
||||||
|
office area and history and catch up area all at same time." Something still moving keeps drawing
|
||||||
|
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
|
||||||
|
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
|
||||||
|
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
|
||||||
|
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
|
||||||
|
than simply not moving: a solid ring and a brighter fill, held. */
|
||||||
|
@media(prefers-reduced-motion:reduce){
|
||||||
|
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
|
||||||
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
||||||
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
||||||
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
|
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
|
||||||
|
|||||||
+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),
|
||||||
|
|||||||
+34
-10
@@ -131,6 +131,12 @@ describe('pacing — dwell by kind', () => {
|
|||||||
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
|
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
|
||||||
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
|
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
|
||||||
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
|
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
|
||||||
|
/**
|
||||||
|
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
|
||||||
|
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
|
||||||
|
* somebody actually reached for and found insufficient.
|
||||||
|
*/
|
||||||
|
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
|
||||||
for (const p of PACE_LEVELS) {
|
for (const p of PACE_LEVELS) {
|
||||||
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
|
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
|
||||||
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
|
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
|
||||||
@@ -164,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
|
* Two complaints, one from each direction, and the answer is between them.
|
||||||
* 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
|
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
|
||||||
* everything else. Measured over 40 turns, the waiting split almost evenly between other players
|
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
|
||||||
* and phases turning over, so a 5× game spent 105 seconds on the clock alone.
|
* 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 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: {} } };
|
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(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
|
||||||
assert.equal(dwellForStep(theirs, pace), DWELL.switching * pace);
|
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(phase, 0), 0);
|
||||||
assert.equal(dwellForStep(theirs, 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', () => {
|
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
|
||||||
|
|||||||
@@ -251,6 +251,25 @@ describe('the step queue', () => {
|
|||||||
assert.ok(q.showing() !== null, 'the caption should survive a reset');
|
assert.ok(q.showing() !== null, 'the caption should survive a reset');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can always be emptied, so a player is never stranded behind it', () => {
|
||||||
|
/**
|
||||||
|
* "Your Move" is put away while the board is catching up (v0.8.0.6), which makes `busy()` the
|
||||||
|
* thing standing between a player and their own turn. So the ways it can be cleared matter more
|
||||||
|
* than they did: `skip()` must always work, from any state, including one where the clock has
|
||||||
|
* never advanced — which is exactly the situation a page with no `requestAnimationFrame` is in,
|
||||||
|
* and how this was found.
|
||||||
|
*/
|
||||||
|
const { steps, final } = realSteps(1917398, 400);
|
||||||
|
const q = createStepQueue();
|
||||||
|
q.reset(baseline(1917398));
|
||||||
|
q.push(steps);
|
||||||
|
// Never advanced at all: no frame has been shown, and the queue is full.
|
||||||
|
assert.equal(q.busy(), true);
|
||||||
|
assert.equal(q.skip(), true, 'a never-advanced queue must still be skippable');
|
||||||
|
assert.equal(q.busy(), false, 'and must be idle afterwards, or the player stays locked out');
|
||||||
|
assert.deepEqual(q.current(), final);
|
||||||
|
});
|
||||||
|
|
||||||
it('draws nothing before a reset has arrived', () => {
|
it('draws nothing before a reset has arrived', () => {
|
||||||
const q = createStepQueue();
|
const q = createStepQueue();
|
||||||
assert.equal(q.current(), null);
|
assert.equal(q.current(), null);
|
||||||
|
|||||||
Reference in New Issue
Block a user