Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fca325699 | ||
|
|
fc40fc39ed | ||
|
|
ff629c0708 |
+120
-2
@@ -19,9 +19,127 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
me to see."* At 10× an action holds the screen for seven seconds, so this was never about duration —
|
||||||
|
it was that a bot drawing a card changes one number in a panel nobody is watching, and the board sits
|
||||||
|
unchanged for those seven seconds. **Raising the dwell was the wrong lever, and it had been pulled
|
||||||
|
three times.** His diagnosis was the right one: mark WHERE, not longer.
|
||||||
|
|
||||||
|
### The Home Office deck was never drawn
|
||||||
|
|
||||||
|
`f.deck` has carried the face-down count since the Frame existed and **nothing in `src/web/` read
|
||||||
|
it** — the exact display gap `test/display-gaps.test.ts` was written to sweep for, surviving in the
|
||||||
|
one panel that draws every other pile. It is a tile now, first in the row, because that is the order
|
||||||
|
a card travels: out of the deck, into a hand, then onto a Department or the Salvage Yard. Face down,
|
||||||
|
so its card slot says so rather than naming one — not knowing what is on top is the point of the
|
||||||
|
pile.
|
||||||
|
|
||||||
|
### What lights, and why that is exactly what is public
|
||||||
|
|
||||||
|
The piles a move touched are now lit for as long as that move is on screen. **Derived, never sent**:
|
||||||
|
the client already holds the frame before a step and the frame after it, so `changedPiles()` is a
|
||||||
|
diff. Nothing is added to the protocol, nothing can drift out of step with the projection, and the
|
||||||
|
0.8.1 seatless board gets it for free.
|
||||||
|
|
||||||
|
Measured across four seeds rather than reasoned about, and pinned by a test that requires each case
|
||||||
|
to have actually occurred rather than passing on whichever the bot happened to play:
|
||||||
|
|
||||||
|
| action | lights | why that is public |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `draw.fromHomeOffice` | the deck | the count, never the card — a blind draw stays the drawer's |
|
||||||
|
| `draw.fromDepartment` | that Department, and the deck when it refills | the pile is face up, so the card taken is public |
|
||||||
|
| `card.discard` | that Department | face up, and which pile it went on is the point |
|
||||||
|
| `card.play` | the Salvage Yard | where a played card that did not stay on the board lands |
|
||||||
|
| switching, new trains | nothing here | they move the board, which the district panel already follows |
|
||||||
|
| `*.end`, `localOps.choose` | nothing | no card moved |
|
||||||
|
|
||||||
|
**It is a state, not a flash**, and that distinction is the whole reason it works. The timetable's
|
||||||
|
existing `.tt-slot.fresh` animates for a fixed 1.5s — right for a die roll nobody is waiting on, and
|
||||||
|
wrong here, because a step can hold for seven seconds and the animation would be long over before
|
||||||
|
the pause it belongs to. A brief flash-in marks the moment; the lit border and background stay for
|
||||||
|
exactly as long as the step is up.
|
||||||
|
|
||||||
|
**Not for your own moves.** You drew that card — the same rule that already gives your own steps no
|
||||||
|
dwell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
## 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
|
### `?pace=` never worked, and a whole game was played at the wrong speed
|
||||||
|
|
||||||
@@ -93,7 +211,7 @@ the suite until it was declared out loud.
|
|||||||
|
|
||||||
## 0.8.0.1 — 2026-09-09
|
## 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
|
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 —
|
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.
|
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
|
## 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
|
> **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
|
> 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
|
> 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
|
> 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
|
## Step 6 — Chromium publisher supervisor
|
||||||
|
|
||||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
> **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
|
> 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
|
> 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
|
> 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
|
## 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
|
> **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
|
> 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
|
> 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
|
> 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",
|
"name": "station-master",
|
||||||
"version": "0.8.0.2",
|
"version": "0.8.0.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"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
|
* 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**.
|
* 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
|
* 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.
|
* 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.
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -130,3 +130,46 @@ function need<T>(value: T | undefined, what: string): T {
|
|||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A face-up or face-down pile a card can move to or from, as the display addresses it. */
|
||||||
|
export type PileKey = 'home' | 'salvage' | `dept${number}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHICH PILES A STEP MOVED — derived, never sent.
|
||||||
|
*
|
||||||
|
* The receiver already holds the frame before a step and the frame after it, so which pile changed
|
||||||
|
* is a diff rather than something the wire has to carry. That matters twice over: nothing is added
|
||||||
|
* to the protocol, and it cannot drift out of step with the projection the way a hand-maintained
|
||||||
|
* hint would.
|
||||||
|
*
|
||||||
|
* WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an
|
||||||
|
* unchanged board — the step holds the screen, and the only thing that moved is a number in a panel
|
||||||
|
* they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast
|
||||||
|
* for me to see"*, which was never about duration. Lighting the pile is what tells the eye where.
|
||||||
|
*
|
||||||
|
* WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about:
|
||||||
|
*
|
||||||
|
* | intent | piles |
|
||||||
|
* | ----------------------- | -------------------------------------------------------- |
|
||||||
|
* | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private |
|
||||||
|
* | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it |
|
||||||
|
* | `card.discard` | that `dept` |
|
||||||
|
* | `card.play` | `salvage`, or nothing here when it lands on the board |
|
||||||
|
* | switching, new trains | nothing here — those show on the board itself |
|
||||||
|
*/
|
||||||
|
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
|
||||||
|
if (before === null) return [];
|
||||||
|
const out: PileKey[] = [];
|
||||||
|
if (before.deck !== after.deck) out.push('home');
|
||||||
|
after.departmentDepth.forEach((depth, i) => {
|
||||||
|
// The TOP as well as the depth: taking the face-up card and replacing it leaves the count alone
|
||||||
|
// and changes the card everybody can see, which is the half that matters to a watcher.
|
||||||
|
if (before.departmentDepth[i] !== depth || before.departments[i] !== after.departments[i]) {
|
||||||
|
out.push(`dept${i}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (before.salvage.depth !== after.salvage.depth || before.salvage.top !== after.salvage.top) {
|
||||||
|
out.push('salvage');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
+37
-8
@@ -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.
|
||||||
@@ -1484,7 +1511,9 @@ function render(): void {
|
|||||||
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
|
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
|
||||||
* action rather than a click on the card itself.
|
* action rather than a click on the card itself.
|
||||||
*/
|
*/
|
||||||
$('depts').innerHTML = pilesHtml(f);
|
// The pile the move being WATCHED just touched, lit for as long as that step is on screen. Empty
|
||||||
|
// whenever the board is level with the game, or when the move was this player's own.
|
||||||
|
$('depts').innerHTML = pilesHtml(f, stepQueue.busy() ? stepQueue.lit() : []);
|
||||||
|
|
||||||
renderYards(f);
|
renderYards(f);
|
||||||
|
|
||||||
|
|||||||
+54
-3
@@ -52,25 +52,61 @@ export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
|||||||
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
|
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
|
||||||
* is out of reach, and choosing where to discard is choosing what to put there.
|
* is out of reach, and choosing where to discard is choosing what to put there.
|
||||||
*/
|
*/
|
||||||
export function pilesHtml(f: Frame): string {
|
export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
|
||||||
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
|
const pile = (
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
top: string,
|
||||||
|
depth: number,
|
||||||
|
why: string,
|
||||||
|
extra = '',
|
||||||
|
slot = -1,
|
||||||
|
faceDown = false,
|
||||||
|
): string => {
|
||||||
const tip = [why, extra].filter(Boolean).join(' · ');
|
const tip = [why, extra].filter(Boolean).join(' · ');
|
||||||
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
|
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
|
||||||
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
|
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
|
||||||
// viewer draws exactly the same markup and nothing there is clickable.
|
// viewer draws exactly the same markup and nothing there is clickable.
|
||||||
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
|
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
|
||||||
|
// `lit` marks the pile the move being watched just touched — see `changedPiles`.
|
||||||
|
const cls = `handcard${faceDown ? ' facedown' : ''}${lit.includes(key) ? ' pilelit' : ''}`;
|
||||||
return (
|
return (
|
||||||
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
`<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||||
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
||||||
`<b>${esc(top)}</b></div>`
|
`<b>${esc(top)}</b></div>`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
/**
|
||||||
|
* THE HOME OFFICE DECK, which the screen had never drawn.
|
||||||
|
*
|
||||||
|
* `f.deck` has carried the face-down count since the Frame existed and nothing read it — the
|
||||||
|
* exact shape of display gap `test/display-gaps.test.ts` was written to sweep for, surviving in
|
||||||
|
* the panel that draws every OTHER pile. Asked for by Jesse 2026-09-10 for a second reason: a
|
||||||
|
* player drawing from it is the commonest move nobody can see, so it needs somewhere to flash.
|
||||||
|
*
|
||||||
|
* FIRST, because that is the order a card travels: out of here, into a hand, then onto a
|
||||||
|
* Department or the Salvage Yard. Face down, so the card slot says so rather than naming a card
|
||||||
|
* — the whole point of this pile is that nobody knows what is on top.
|
||||||
|
*/
|
||||||
|
pile(
|
||||||
|
'home',
|
||||||
|
'Home Office',
|
||||||
|
'face down',
|
||||||
|
f.deck,
|
||||||
|
'The draw deck. Face down — nobody sees what is on top, and a card drawn from here is private ' +
|
||||||
|
'to whoever drew it. When it runs out, the Salvage Yard and the Departments are swept back ' +
|
||||||
|
'into it.',
|
||||||
|
'',
|
||||||
|
-1,
|
||||||
|
true,
|
||||||
|
) +
|
||||||
f.departments
|
f.departments
|
||||||
.map((d, i) => {
|
.map((d, i) => {
|
||||||
const depth = f.departmentDepth[i] ?? 0;
|
const depth = f.departmentDepth[i] ?? 0;
|
||||||
const under = depth - 1;
|
const under = depth - 1;
|
||||||
return pile(
|
return pile(
|
||||||
|
`dept${i}`,
|
||||||
`Dept ${i + 1}`,
|
`Dept ${i + 1}`,
|
||||||
d,
|
d,
|
||||||
depth,
|
depth,
|
||||||
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
|
|||||||
})
|
})
|
||||||
.join('') +
|
.join('') +
|
||||||
pile(
|
pile(
|
||||||
|
'salvage',
|
||||||
'Salvage',
|
'Salvage',
|
||||||
f.salvage.top,
|
f.salvage.top,
|
||||||
f.salvage.depth,
|
f.salvage.depth,
|
||||||
@@ -676,6 +713,20 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
|||||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||||
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
||||||
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
||||||
|
/* THE HOME OFFICE DECK. Face down, so its card slot names no card — it says so instead, in the
|
||||||
|
dimmed voice the rest of the panel uses for "nothing to read here". */
|
||||||
|
.handcard.facedown > b{color:#6f7885;font-style:italic;font-weight:400}
|
||||||
|
/* THE PILE A WATCHED MOVE JUST TOUCHED (v0.8.1).
|
||||||
|
A STATE, NOT A FLASH, and that is the whole point. The .tt-slot.fresh rule above animates for a fixed
|
||||||
|
1.5s, which is right for a die roll nobody is waiting on — but a step can hold the screen for
|
||||||
|
seven seconds at 10x, so a fixed animation would be over long before the pause it belongs to and
|
||||||
|
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
|
||||||
|
element only while that step is the one being shown. */
|
||||||
|
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;animation:pileflash .45s ease-out 1}
|
||||||
|
@keyframes pileflash{0%{background:#2f6b47;border-color:#c7f0d4;transform:scale(1.1)}
|
||||||
|
100%{background:#1d3327;border-color:#8fd6a0;transform:scale(1)}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.handcard.pilelit{animation:none}}
|
||||||
.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
|
||||||
|
|||||||
+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>
|
||||||
|
|||||||
+19
-1
@@ -19,7 +19,8 @@
|
|||||||
|
|
||||||
import type { PublicFrame } from '../sim/view.ts';
|
import type { PublicFrame } from '../sim/view.ts';
|
||||||
import type { DisplayStep } from '../sim/display-step.ts';
|
import type { DisplayStep } from '../sim/display-step.ts';
|
||||||
import { applyPublicDelta } from '../sim/public-delta.ts';
|
import { applyPublicDelta, changedPiles } from '../sim/public-delta.ts';
|
||||||
|
import type { PileKey } from '../sim/public-delta.ts';
|
||||||
import { dwellForStep } from '../sim/pacing.ts';
|
import { dwellForStep } from '../sim/pacing.ts';
|
||||||
|
|
||||||
export type StepQueue = {
|
export type StepQueue = {
|
||||||
@@ -43,6 +44,13 @@ export type StepQueue = {
|
|||||||
behind(): number;
|
behind(): number;
|
||||||
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
|
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
|
||||||
showing(): DisplayStep | null;
|
showing(): DisplayStep | null;
|
||||||
|
/**
|
||||||
|
* The piles the step now on screen moved, for the display to light.
|
||||||
|
*
|
||||||
|
* Here because this is the only place that holds both the frame before a step and the frame after
|
||||||
|
* it — deriving it anywhere else would mean keeping a second copy of the board in step.
|
||||||
|
*/
|
||||||
|
lit(): readonly PileKey[];
|
||||||
/** True while there is anything left to show. */
|
/** True while there is anything left to show. */
|
||||||
busy(): boolean;
|
busy(): boolean;
|
||||||
};
|
};
|
||||||
@@ -65,6 +73,7 @@ export function createStepQueue(
|
|||||||
): StepQueue {
|
): StepQueue {
|
||||||
let shown: PublicFrame | null = null;
|
let shown: PublicFrame | null = null;
|
||||||
let last: DisplayStep | null = null;
|
let last: DisplayStep | null = null;
|
||||||
|
let litPiles: readonly PileKey[] = [];
|
||||||
let pending: DisplayStep[] = [];
|
let pending: DisplayStep[] = [];
|
||||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||||
let dueAt: number | null = null;
|
let dueAt: number | null = null;
|
||||||
@@ -75,8 +84,14 @@ export function createStepQueue(
|
|||||||
|
|
||||||
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
||||||
const show = (step: DisplayStep): void => {
|
const show = (step: DisplayStep): void => {
|
||||||
|
const before = shown;
|
||||||
shown = applyPublicDelta(shown, step.frame);
|
shown = applyPublicDelta(shown, step.frame);
|
||||||
last = step;
|
last = step;
|
||||||
|
/**
|
||||||
|
* NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule
|
||||||
|
* that gives your own steps no dwell — the display is for watching everybody else.
|
||||||
|
*/
|
||||||
|
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -84,6 +99,8 @@ export function createStepQueue(
|
|||||||
shown = frame;
|
shown = frame;
|
||||||
pending = [];
|
pending = [];
|
||||||
dueAt = null;
|
dueAt = null;
|
||||||
|
// Nothing was watched arriving at this board, so nothing on it is lit.
|
||||||
|
litPiles = [];
|
||||||
// `last` deliberately survives: a reconnect should not blank the caption line, and the
|
// `last` deliberately survives: a reconnect should not blank the caption line, and the
|
||||||
// sentence describing the most recent action is still true.
|
// sentence describing the most recent action is still true.
|
||||||
},
|
},
|
||||||
@@ -136,6 +153,7 @@ export function createStepQueue(
|
|||||||
current: () => shown,
|
current: () => shown,
|
||||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||||
showing: () => last,
|
showing: () => last,
|
||||||
|
lit: () => litPiles,
|
||||||
/**
|
/**
|
||||||
* STILL SHOWING SOMETHING, not just still holding something back.
|
* STILL SHOWING SOMETHING, not just still holding something back.
|
||||||
*
|
*
|
||||||
|
|||||||
+23
-4
@@ -52,7 +52,7 @@ describe('pacing — dwell by kind', () => {
|
|||||||
assert.equal(kindOf('switch.end'), 'bookkeeping');
|
assert.equal(kindOf('switch.end'), 'bookkeeping');
|
||||||
/**
|
/**
|
||||||
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
|
* `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
|
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
|
||||||
* to do.
|
* 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
|
* 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.
|
||||||
@@ -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", () => {
|
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
|
* *"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
|
* 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
|
* switching is not legal until there is track down — and under the original values it came to
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'
|
|||||||
import { applyIntent } from '../src/engine/apply.ts';
|
import { applyIntent } from '../src/engine/apply.ts';
|
||||||
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
|
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
|
||||||
import type { PublicFrame } from '../src/sim/view.ts';
|
import type { PublicFrame } from '../src/sim/view.ts';
|
||||||
import { applyPublicDelta, deltaPublicFrame } from '../src/sim/public-delta.ts';
|
import { applyPublicDelta, changedPiles, deltaPublicFrame } from '../src/sim/public-delta.ts';
|
||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'competitive',
|
mode: 'competitive',
|
||||||
@@ -184,3 +184,73 @@ describe('public frame delta', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
|
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
|
||||||
|
|
||||||
|
describe('which piles a step moved', () => {
|
||||||
|
/**
|
||||||
|
* MEASURED FROM REAL PLAY, then pinned. The table in `changedPiles` claims what each action moves,
|
||||||
|
* and a claim in a comment is worth nothing unless something checks it — so this drives real games
|
||||||
|
* and asserts the mapping holds, action by action.
|
||||||
|
*/
|
||||||
|
it('maps each action to the piles it actually touches', () => {
|
||||||
|
const seen = new Map<string, Set<string>>();
|
||||||
|
/**
|
||||||
|
* TWO PASSES, because a single driver cannot reach every case. Left to itself the bot almost
|
||||||
|
* never takes a Department card, and a driver that prefers one then never draws from the deck —
|
||||||
|
* so each preference is played out separately and the assertions below require BOTH to have
|
||||||
|
* been observed rather than passing on whichever happened to occur.
|
||||||
|
*/
|
||||||
|
for (const prefer of ['draw.fromDepartment', 'draw.fromHomeOffice'] as const) {
|
||||||
|
for (const seed of [1917398, 191056, 4242]) {
|
||||||
|
const s = newState(seed);
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
const actor = currentActorOfState(s);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(s, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
const chosen =
|
||||||
|
options.find((o) => o.type === prefer) ??
|
||||||
|
options.find((o) => o.type === 'card.discard') ??
|
||||||
|
options[i % options.length]!;
|
||||||
|
const before = publicSnapshot(s);
|
||||||
|
const r = applyIntent(s, actor, chosen);
|
||||||
|
if (!r.ok) break;
|
||||||
|
pump(s);
|
||||||
|
const piles = changedPiles(before, publicSnapshot(s)).map((p) => p.replace(/dept\d/, 'dept'));
|
||||||
|
if (!seen.has(chosen.type)) seen.set(chosen.type, new Set());
|
||||||
|
for (const p of piles) seen.get(chosen.type)!.add(p);
|
||||||
|
if (piles.length === 0) seen.get(chosen.type)!.add('(none)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const of = (t: string): Set<string> => seen.get(t) ?? new Set();
|
||||||
|
// NOT VACUOUS: the four cases the mapping is actually about must all have happened.
|
||||||
|
for (const needed of ['draw.fromHomeOffice', 'draw.fromDepartment', 'card.discard', 'card.play']) {
|
||||||
|
assert.ok(of(needed).size > 0, `${needed} never occurred, so its rule proved nothing`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A HOME OFFICE DRAW MOVES THE COUNT AND NOTHING ELSE ON A PILE. The card is private; the deck
|
||||||
|
// getting shorter is not, and it is the only thing a watcher can be shown.
|
||||||
|
assert.deepEqual([...of('draw.fromHomeOffice')].sort(), ['home']);
|
||||||
|
// A DEPARTMENT DRAW touches that Department, and sometimes the deck too — the pile refills from
|
||||||
|
// it. Both are public, so both may light.
|
||||||
|
for (const p of of('draw.fromDepartment')) {
|
||||||
|
assert.ok(p === 'dept' || p === 'home', `a Department draw moved "${p}"`);
|
||||||
|
}
|
||||||
|
assert.ok(of('draw.fromDepartment').has('dept'), 'a Department draw must light its Department');
|
||||||
|
// A DISCARD lands face up on a Department, and which one is public.
|
||||||
|
assert.deepEqual([...of('card.discard')].sort(), ['dept']);
|
||||||
|
// A PLAYED CARD that does not stay on the board lands face up in the Salvage Yard.
|
||||||
|
assert.ok(of('card.play').has('salvage'), 'a played card must be able to light the Salvage Yard');
|
||||||
|
// ENDING A PHASE moves no card anywhere, so nothing should light for it.
|
||||||
|
for (const quiet of ['draw.end', 'loadUnload.end', 'switch.end', 'localOps.choose']) {
|
||||||
|
if (of(quiet).size > 0) assert.deepEqual([...of(quiet)], ['(none)'], `${quiet} lit a pile`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lights nothing without a previous frame to compare against', () => {
|
||||||
|
// A reset has nothing to have watched arriving, so nothing on it is lit.
|
||||||
|
const s = newState(4242);
|
||||||
|
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ describe('the step queue', () => {
|
|||||||
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was
|
* 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
|
* 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
|
* 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".
|
* 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
|
* The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
|
||||||
|
|||||||
+63
-1
@@ -23,7 +23,7 @@ import { variantsFor } from '../src/engine/track.ts';
|
|||||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||||
import type { DivisionView } from '../src/sim/view.ts';
|
import type { DivisionView } from '../src/sim/view.ts';
|
||||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||||
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||||
@@ -3614,6 +3614,68 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
|||||||
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
|
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws the Home Office deck, face down, with its count', () => {
|
||||||
|
/**
|
||||||
|
* `f.deck` has carried the face-down count since the Frame existed and NOTHING drew it — the
|
||||||
|
* exact display gap `test/display-gaps.test.ts` sweeps for, surviving in the panel that draws
|
||||||
|
* every other pile. Asked for by Jesse 2026-09-10, who also wanted somewhere for a draw to
|
||||||
|
* flash: taking a card off this deck is the commonest move nobody can see.
|
||||||
|
*/
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'piles',
|
||||||
|
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'],
|
||||||
|
});
|
||||||
|
const f = snapshot(s, [], null);
|
||||||
|
assert.ok(f.deck > 0, 'the deal should leave cards in the Home Office deck');
|
||||||
|
|
||||||
|
const html = pilesHtml(f);
|
||||||
|
assert.ok(html.includes('Home Office'), `no Home Office pile:\n${html}`);
|
||||||
|
assert.ok(html.includes(`>${f.deck}<`), 'the face-down count is not shown');
|
||||||
|
// Face down means the card slot must NOT name a card — that is the whole point of the pile.
|
||||||
|
assert.ok(html.includes('facedown'), 'the Home Office pile is not marked face down');
|
||||||
|
assert.ok(html.includes('face down'), 'the card slot should say so rather than naming a card');
|
||||||
|
// It comes first: a card travels out of here, then onto a Department or the Salvage Yard.
|
||||||
|
assert.ok(
|
||||||
|
html.indexOf('Home Office') < html.indexOf('Dept 1'),
|
||||||
|
'the draw deck should be read before the piles cards land on',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lights only the pile a watched move touched', () => {
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'piles2',
|
||||||
|
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'],
|
||||||
|
});
|
||||||
|
const f = snapshot(s, [], null);
|
||||||
|
|
||||||
|
assert.equal(pilesHtml(f).includes('pilelit'), false, 'nothing is lit when nothing was watched');
|
||||||
|
|
||||||
|
const home = pilesHtml(f, ['home']);
|
||||||
|
assert.equal((home.match(/pilelit/g) ?? []).length, 1, 'exactly one pile should light');
|
||||||
|
assert.ok(
|
||||||
|
home.indexOf('pilelit') < home.indexOf('Dept 1'),
|
||||||
|
'a Home Office draw must light the Home Office pile, not a Department',
|
||||||
|
);
|
||||||
|
|
||||||
|
const dept2 = pilesHtml(f, ['dept1']);
|
||||||
|
assert.equal((dept2.match(/pilelit/g) ?? []).length, 1);
|
||||||
|
assert.ok(dept2.indexOf('Dept 2') > dept2.indexOf('Dept 1'), 'order sanity');
|
||||||
|
// Two piles can move at once — a Department draw that refills from the deck.
|
||||||
|
assert.equal((pilesHtml(f, ['home', 'dept0']).match(/pilelit/g) ?? []).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
|
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
|
||||||
/**
|
/**
|
||||||
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two
|
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two
|
||||||
|
|||||||
Reference in New Issue
Block a user