Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc40fc39ed | ||
|
|
ff629c0708 |
+73
-2
@@ -19,9 +19,80 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.4 — 2026-09-09
|
||||
|
||||
**Housekeeping: the test server's name is out of the ten places this session put it.**
|
||||
|
||||
Both of this project's repositories allow anonymous clone — checked, not assumed: `info/refs` for
|
||||
`git-upload-pack` answers 200 for `station-master` and for `station-master-startos` alike, while
|
||||
`git-receive-pack` answers 401. So everything committed here is public, and the standing rule is that
|
||||
tracked files carry placeholders rather than real hosts.
|
||||
|
||||
Ten mentions added while building v0.8.0 are now "the test server" or "the target hardware":
|
||||
`CHANGELOG.md`, `docs/plans/jitsi-common-board.md` (three identical deferral banners), `sim/pacing.ts`,
|
||||
`test/pacing.test.ts` and `test/step-queue.test.ts`. Prose and comments only — no behaviour, and the
|
||||
quotes they carry are unchanged, because what a player said about bot pacing is the part worth
|
||||
keeping.
|
||||
|
||||
**What is deliberately left, and why it is not an oversight:**
|
||||
|
||||
- **Nineteen older mentions**, in entries about v0.7.5, v0.7.6 and v0.7.8 and in `TODO.md`. Rewriting
|
||||
a changelog after the fact makes the record less true, and these describe verification that
|
||||
genuinely happened on that machine.
|
||||
- **`scripts/deploy-web.ts` is FUNCTIONAL, not prose.** It carries the host as the default for
|
||||
`FB_URL`, so a placeholder there would break the deploy for the person the default exists to serve.
|
||||
Same for the public address it publishes to. If those should move to required environment variables
|
||||
with no default, that is a change to how deploying works and wants deciding on its own rather than
|
||||
being smuggled in beside a comment sweep.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
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 the test server, neither of them in the mechanism itself.
|
||||
|
||||
### `?pace=` never worked, and a whole game was played at the wrong speed
|
||||
|
||||
@@ -93,7 +164,7 @@ the suite until it was declared out loud.
|
||||
|
||||
## 0.8.0.1 — 2026-09-09
|
||||
|
||||
**Bot play was way too fast.** v0.8.0 was installed on `phoenix.local` and played within the hour;
|
||||
**Bot play was way too fast.** v0.8.0 was installed on the test server and played within the hour;
|
||||
Jesse: *"I briefly saw that it was the bot's office area then their turn was done and it pointed back
|
||||
to my office area"*, and the countdown row appeared "very briefly". Everything else looked right —
|
||||
the bots were visibly doing things — so this is calibration and one real bug, not a redesign.
|
||||
|
||||
@@ -888,7 +888,7 @@ Cover:
|
||||
## Step 5 — Minimal visual-only Jitsi engine
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
@@ -1028,7 +1028,7 @@ Port/adapt the sibling repository’s proven tests for:
|
||||
## Step 6 — Chromium publisher supervisor
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
@@ -1161,7 +1161,7 @@ Use fake child processes and fake control sockets to test:
|
||||
## Step 7 — Configuration, lifecycle, packaging, and observability
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.0.2",
|
||||
"version": "0.8.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+20
-6
@@ -54,7 +54,7 @@ export const DWELL: Record<StepKind, number> = {
|
||||
*
|
||||
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
|
||||
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
|
||||
* Jesse, from the first real play on `phoenix.local`: *"bot play was way too fast. I briefly saw
|
||||
* Jesse, from the first real play on the test server: *"bot play was way too fast. I briefly saw
|
||||
* that it was the bot's office area then their turn was done."* His instruction had been "start at
|
||||
* 1s and tune down", and that was applied only to switching while this number was invented.
|
||||
*/
|
||||
@@ -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.
|
||||
*/
|
||||
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,
|
||||
): 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
|
||||
* 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
|
||||
* 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
|
||||
* all of them would cost a quarter of an hour a game. The phase turning over is the thing a player
|
||||
* is being shown, and there are about 180 of those in a full game.
|
||||
* all of them would cost a quarter of an hour a game.
|
||||
*/
|
||||
const table = step.frame.table as Record<string, unknown>;
|
||||
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
|
||||
* has never needed a private viewer.
|
||||
*/
|
||||
function renderWatching(): void {
|
||||
function renderWatching(f?: Frame): void {
|
||||
const behind = stepQueue.behind();
|
||||
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.
|
||||
if (behind === 0) {
|
||||
/**
|
||||
* VISIBLE WHILE THE BOARD IS BEHIND **OR** STILL SHOWING SOMETHING.
|
||||
*
|
||||
* 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;
|
||||
return;
|
||||
}
|
||||
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.
|
||||
*
|
||||
@@ -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
|
||||
* 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();
|
||||
$('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.
|
||||
*
|
||||
@@ -1264,7 +1291,7 @@ function render(): void {
|
||||
|
||||
renderTurnChart(f);
|
||||
renderPresence(f);
|
||||
renderWatching();
|
||||
renderWatching(f);
|
||||
$('revenue').textContent = String(f.revenue);
|
||||
/**
|
||||
* 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;
|
||||
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-who{color:#c9cee0;font-weight:700}
|
||||
#presence:empty{display:none}
|
||||
/* division strip */
|
||||
#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
|
||||
almost always. -->
|
||||
<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-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>
|
||||
|
||||
<main>
|
||||
|
||||
+23
-4
@@ -52,7 +52,7 @@ describe('pacing — dwell by kind', () => {
|
||||
assert.equal(kindOf('switch.end'), 'bookkeeping');
|
||||
/**
|
||||
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
|
||||
* play on `phoenix.local`. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
|
||||
* play on the test server. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
|
||||
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
|
||||
* to do.
|
||||
*/
|
||||
@@ -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
|
||||
* 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: {} } }), 0, 'a step that changed nothing shows nothing');
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), 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.
|
||||
assert.equal(
|
||||
dwellForStep({ cause: 'switch.move', lines: ['moved'], frame: { table: {} } }),
|
||||
dwellForStep({ cause: 'switch.move', player: 1, lines: ['moved'], frame: { table: {} } }),
|
||||
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', () => {
|
||||
// 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.
|
||||
@@ -180,7 +199,7 @@ describe('pacing — dwell by kind', () => {
|
||||
|
||||
it("a bot's ordinary turn is followable, which is what the first real play was not", () => {
|
||||
/**
|
||||
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on `phoenix.local`:
|
||||
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on the test server:
|
||||
* *"bot play was way too fast. I briefly saw that it was the bot's office area then their turn
|
||||
* was done."* This is the shape that turn actually had — no switching in it at all, because
|
||||
* switching is not legal until there is track down — and under the original values it came to
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('the step queue', () => {
|
||||
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was
|
||||
* shown the queue reported idle: the animation loop stopped and the district panel snapped back
|
||||
* to the viewer's own board without that step ever being looked at. Jesse, from the first real
|
||||
* play on `phoenix.local`: *"I briefly saw that it was the bot's office area then their turn was
|
||||
* play on the test server: *"I briefly saw that it was the bot's office area then their turn was
|
||||
* done and it pointed back to my office area"*, and the countdown row appeared "very briefly".
|
||||
*
|
||||
* The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
|
||||
|
||||
Reference in New Issue
Block a user