Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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