v0.8.0.3 — Skip on the left, a caption that says who, and a clock that stops
stretching Three things from playing v0.8.0.2, all about the row rather than the mechanism. Skip was on the far right and a player's eye is on the countdown. Moved to the left, in front of the count. The caption said what but never who. Measured over 40 turns of a real 3-seat game, half the waiting is automatic phases — 21.0s of phases against 21.7s of other players — and a phase narrates as "Mainline", which is accurate and no answer at all to "who am I waiting on". A phase introduces itself now: "The Division: Mainline phase". A player's move already carries its name from record(), so it is left alone. The row was also hiding one step early, because it showed only while behind > 0 — which goes false exactly when the last step of a burst goes up, so the step most likely to be read lost its caption. And the speed control was stretching the clock along with the players. It was not his own move being replayed — own moves have cost nothing since v0.8.0.1 — it was the phases behind it, which put 105 seconds of clock-ticking into a 5x game. pace now scales a player's move and leaves a phase at its tabled beat, which is what the control has always claimed to do. Off still means off for both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
This commit is contained in:
co-authored by
Claude Opus 5
parent
c10f52791e
commit
ff629c0708
@@ -19,6 +19,49 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.8.0.3 — 2026-09-09
|
||||||
|
|
||||||
|
Three things from playing v0.8.0.2, all of them about the row rather than the mechanism.
|
||||||
|
|
||||||
|
### Skip was at the wrong end of the row
|
||||||
|
|
||||||
|
Jesse: *"the skip button should be on the far left, in front of where it says [the count], so it's
|
||||||
|
always close to where people are looking."* It was on the far right, and a player's eye is on the
|
||||||
|
countdown. Moved.
|
||||||
|
|
||||||
|
### The caption said what, but never who
|
||||||
|
|
||||||
|
*"I saw 2 behind, 1 behind, and then it was caught up, but it didn't tell me what the actual action
|
||||||
|
was, like who I was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I
|
||||||
|
was supposed to be looking for."*
|
||||||
|
|
||||||
|
The caption was there. It was the wrong half of the sentence. **Measured over 40 turns of a real
|
||||||
|
3-seat game, half the waiting is automatic phases** — 21.0s of phases against 21.7s of other players —
|
||||||
|
and a phase narrates as "Mainline", which is accurate and no answer at all to "who am I waiting on".
|
||||||
|
A phase now introduces itself: **"The Division: ▸ Mainline phase"**. A player's move already carries
|
||||||
|
its name from `record()`, so it is left alone rather than stuttering it twice.
|
||||||
|
|
||||||
|
**And the row was hiding a step early.** It was shown only while `behind > 0` — which goes false the
|
||||||
|
moment the LAST step of a burst goes up, so the one step a player was most likely to be reading about
|
||||||
|
lost its caption. It now stays up while the queue is still showing something, and reads "catching up"
|
||||||
|
once nothing is queued behind.
|
||||||
|
|
||||||
|
### The speed control was stretching the clock, not just the other players
|
||||||
|
|
||||||
|
*"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. Since I've just done my turn, I don't need to wait after it."*
|
||||||
|
|
||||||
|
He was right, and it was not his move being replayed — own moves have cost nothing since v0.8.0.1. It
|
||||||
|
was the automatic phases behind it, which were scaling with `pace` along with everything else. At 5×
|
||||||
|
that put **105 seconds of clock-ticking** into the game, all of it after a player's own move and none
|
||||||
|
of it anything to watch.
|
||||||
|
|
||||||
|
**`pace` now scales a player's move and leaves a phase at its tabled beat.** The control is labelled
|
||||||
|
as how long another player's move is held, and that is now what it does. A phase still gets its beat
|
||||||
|
(TODO #18) and still vanishes entirely at `pace = 0`, because off has to mean off.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.8.0.2 — 2026-09-09
|
## 0.8.0.2 — 2026-09-09
|
||||||
|
|
||||||
Two things found by playing v0.8.0.1 on `phoenix.local`, neither of them in the mechanism itself.
|
Two things found by playing v0.8.0.1 on `phoenix.local`, neither of them in the mechanism itself.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.8.0.2",
|
"version": "0.8.0.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+19
-5
@@ -182,10 +182,25 @@ export function dwellFor(cause: StepCause, pace = 1): number {
|
|||||||
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
|
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
|
||||||
*/
|
*/
|
||||||
export function dwellForStep(
|
export function dwellForStep(
|
||||||
step: { cause: StepCause; lines: readonly unknown[]; frame: { table: object } },
|
step: { cause: StepCause; player: number | null; lines: readonly unknown[]; frame: { table: object } },
|
||||||
pace = 1,
|
pace = 1,
|
||||||
): number {
|
): number {
|
||||||
if (step.lines.length > 0) return dwellFor(step.cause, pace);
|
// Off means off, for the clock as much as for anybody's move.
|
||||||
|
if (pace <= 0) return 0;
|
||||||
|
/**
|
||||||
|
* THE SPEED CONTROL IS ABOUT OTHER PEOPLE, NOT ABOUT THE CLOCK.
|
||||||
|
*
|
||||||
|
* A phase keeps its tabled beat at every speed. Measured over 40 turns of a real 3-seat game, the
|
||||||
|
* waiting split almost evenly — 21.0s of other players against 21.0s of phases turning over — so
|
||||||
|
* scaling both put 105 seconds of clock-ticking into a 5× game, all of it after the player's own
|
||||||
|
* move and none of it anything to watch. Jesse, from that 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."*
|
||||||
|
*
|
||||||
|
* 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;
|
||||||
|
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
|
||||||
* every phase a visible beat", for New Train, the Mainline and the shift change.
|
* every phase a visible beat", for New Train, the Mainline and the shift change.
|
||||||
@@ -194,12 +209,11 @@ export function dwellForStep(
|
|||||||
* silently killed #18: a phase can move trains without saying anything, and those steps were being
|
* silently killed #18: a phase can move trains without saying anything, and those steps were being
|
||||||
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
|
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
|
||||||
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
|
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
|
||||||
* all of them would cost a quarter of an hour a game. The phase turning over is the thing a player
|
* all of them would cost a quarter of an hour a game.
|
||||||
* is being shown, and there are about 180 of those in a full game.
|
|
||||||
*/
|
*/
|
||||||
const table = step.frame.table as Record<string, unknown>;
|
const table = step.frame.table as Record<string, unknown>;
|
||||||
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
|
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
|
||||||
return turned ? dwellFor(step.cause, pace) : 0;
|
return turned ? dwellFor(step.cause, speed) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+34
-7
@@ -201,17 +201,23 @@ function drainIntoQueue(): void {
|
|||||||
* drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and
|
* drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and
|
||||||
* has never needed a private viewer.
|
* has never needed a private viewer.
|
||||||
*/
|
*/
|
||||||
function renderWatching(): void {
|
function renderWatching(f?: Frame): void {
|
||||||
const behind = stepQueue.behind();
|
const behind = stepQueue.behind();
|
||||||
const row = $('watching');
|
const row = $('watching');
|
||||||
// Collapsed whenever the board is level with the game — which in solitaire is nearly always, and
|
/**
|
||||||
// between turns in multiplayer too. A row that is always there would be a row nobody reads.
|
* VISIBLE WHILE THE BOARD IS BEHIND **OR** STILL SHOWING SOMETHING.
|
||||||
if (behind === 0) {
|
*
|
||||||
|
* It used to hide the moment `behind` hit zero — which is the moment the LAST step of a burst goes
|
||||||
|
* up, so the one step a player was most likely to be reading about lost its caption. Collapsed
|
||||||
|
* otherwise: in solitaire that is nearly always, and between turns in multiplayer too, and a row
|
||||||
|
* that is always there is a row nobody reads.
|
||||||
|
*/
|
||||||
|
if (behind === 0 && !stepQueue.busy()) {
|
||||||
row.hidden = true;
|
row.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
row.hidden = false;
|
row.hidden = false;
|
||||||
$('watching-behind').textContent = `${behind} behind`;
|
$('watching-behind').textContent = behind === 0 ? 'catching up' : `${behind} behind`;
|
||||||
/**
|
/**
|
||||||
* THE CAPTION IS #15, and this is where that item lands rather than as a line of its own.
|
* THE CAPTION IS #15, and this is where that item lands rather than as a line of its own.
|
||||||
*
|
*
|
||||||
@@ -220,8 +226,29 @@ function renderWatching(): void {
|
|||||||
* The queue IS that, so the caption simply names the step being shown, and the counter beside it
|
* The queue IS that, so the caption simply names the step being shown, and the counter beside it
|
||||||
* says how much of the wait is left.
|
* says how much of the wait is left.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* WHO, THEN WHAT — Jesse, 2026-09-09: *"it didn't tell me what the actual action was, like who I
|
||||||
|
* was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I was
|
||||||
|
* supposed to be looking for."*
|
||||||
|
*
|
||||||
|
* The caption was there; it was the wrong half of the sentence. Half the waiting is automatic
|
||||||
|
* phases, whose narration reads "Mainline" — accurate, and no answer at all to "who am I waiting
|
||||||
|
* on". So the name goes first, and a phase says so in as many words rather than leaving the reader
|
||||||
|
* to infer that nobody is acting.
|
||||||
|
*
|
||||||
|
* The narrated line is used as it stands otherwise, because `record()` already prefixes it with the
|
||||||
|
* player — "Player Bot 1 moved Train 3 (−1,−2) → (−1,1)" — so a second name would stutter.
|
||||||
|
*/
|
||||||
const showing = stepQueue.showing();
|
const showing = stepQueue.showing();
|
||||||
$('watching-what').textContent = showing?.lines[0]?.text ?? '';
|
const said = showing?.lines[0]?.text ?? '';
|
||||||
|
const who =
|
||||||
|
showing === null || showing === undefined
|
||||||
|
? ''
|
||||||
|
: showing.player === null
|
||||||
|
? 'The Division'
|
||||||
|
: (f?.players[showing.player]?.name ?? `Seat ${seatLabel(showing.player)}`);
|
||||||
|
// A player action already names its actor; a phase does not, so it is introduced.
|
||||||
|
$('watching-what').textContent = showing?.player === null && said !== '' ? `${who}: ${said}` : said;
|
||||||
/**
|
/**
|
||||||
* SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION.
|
* SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION.
|
||||||
*
|
*
|
||||||
@@ -1264,7 +1291,7 @@ function render(): void {
|
|||||||
|
|
||||||
renderTurnChart(f);
|
renderTurnChart(f);
|
||||||
renderPresence(f);
|
renderPresence(f);
|
||||||
renderWatching();
|
renderWatching(f);
|
||||||
$('revenue').textContent = String(f.revenue);
|
$('revenue').textContent = String(f.revenue);
|
||||||
/**
|
/**
|
||||||
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
||||||
|
|||||||
+5
-1
@@ -125,6 +125,7 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
|||||||
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
|
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
|
||||||
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
|
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
|
||||||
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||||
|
#watching-who{color:#c9cee0;font-weight:700}
|
||||||
#presence:empty{display:none}
|
#presence:empty{display:none}
|
||||||
/* division strip */
|
/* division strip */
|
||||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
||||||
@@ -914,9 +915,12 @@ ul.blocked li{padding:2px 0}
|
|||||||
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
|
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
|
||||||
almost always. -->
|
almost always. -->
|
||||||
<div id="watching" hidden>
|
<div id="watching" hidden>
|
||||||
|
<!-- SKIP FIRST, on the left. It sat on the far right and a player's eye is on the countdown, not at
|
||||||
|
the other end of the row — Jesse, 2026-09-09: "the skip button should be on the far left, in
|
||||||
|
front of where it says [the count], so it's always close to where people are looking." -->
|
||||||
|
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
|
||||||
<span id="watching-behind" class="wbehind"></span>
|
<span id="watching-behind" class="wbehind"></span>
|
||||||
<span id="watching-what"></span>
|
<span id="watching-what"></span>
|
||||||
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
+21
-2
@@ -152,18 +152,37 @@ describe('pacing — dwell by kind', () => {
|
|||||||
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
|
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
|
||||||
* times per intent — which came to a quarter of an hour a game.
|
* times per intent — which came to a quarter of an hour a game.
|
||||||
*/
|
*/
|
||||||
const silent = { cause: 'phase' as const, lines: [] as string[] };
|
const silent = { cause: 'phase' as const, player: null, lines: [] as string[] };
|
||||||
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
|
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
|
||||||
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
|
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
|
||||||
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
|
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
|
||||||
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
|
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
|
||||||
// Narration always earns the dwell of whatever caused it, clock or no clock.
|
// Narration always earns the dwell of whatever caused it, clock or no clock.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
dwellForStep({ cause: 'switch.move', lines: ['moved'], frame: { table: {} } }),
|
dwellForStep({ cause: 'switch.move', player: 1, lines: ['moved'], frame: { table: {} } }),
|
||||||
DWELL.switching,
|
DWELL.switching,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('the speed control stretches other people, not the clock', () => {
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// Off still means off, for the clock as much as for anybody's move.
|
||||||
|
assert.equal(dwellForStep(phase, 0), 0);
|
||||||
|
assert.equal(dwellForStep(theirs, 0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
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', () => {
|
||||||
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
|
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
|
||||||
// case for one crew: the announcement, six moves, and an end that shows nothing.
|
// case for one crew: the announcement, six moves, and an end that shows nothing.
|
||||||
|
|||||||
Reference in New Issue
Block a user