Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0e5091824 | ||
|
|
64e8ce584f |
@@ -19,6 +19,102 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -1791,6 +1791,23 @@ 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) {
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+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);
|
||||||
|
|||||||
@@ -3647,6 +3647,34 @@ 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