Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0e5091824 | ||
|
|
64e8ce584f | ||
|
|
3fca325699 | ||
|
|
fc40fc39ed |
+173
-2
@@ -19,6 +19,177 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.7 — 2026-09-10
|
||||
|
||||
### The Salvage Yard was face up and had nothing to say
|
||||
|
||||
*"Why is salvage deck not face up? I should see the card played onto salvage."* It always was — the
|
||||
tile reads the top card. It just said **"a card"**.
|
||||
|
||||
`apply.ts` pushes a **synthetic id** on `trainScheduled`:
|
||||
|
||||
```ts
|
||||
s.decks.salvageYard.push(`train-${e.trainNumber}`);
|
||||
```
|
||||
|
||||
Nothing in `s.cards` matches that, so `cardName()` fell through to its "a card" default — and since a
|
||||
train is scheduled several times a Day, that id is on top of the pile most of the time. Measured
|
||||
before touching anything: a session's Salvage tile read `"a card"` from the opening frame through 60
|
||||
pushes, never once changing, while its depth climbed from 2 to 8.
|
||||
|
||||
`cardName()` resolves `train-<n>` now, so the tile reads **Train 3**, **Train 2** as cards land.
|
||||
Resolved in `sim/view.ts` because this is a NAME, which is that file's job.
|
||||
|
||||
**The engine half is filed, not fixed — Gitea#23.** `reshuffleIfDepleted()` sweeps the Salvage Yard
|
||||
back into the draw deck, so a synthetic id can be shuffled in and drawn into a hand as an id with no
|
||||
card behind it. Not reachable in ordinary play: eight games driven to 4000 moves across eight seeds
|
||||
produced **zero** reshuffles. There are two defensible fixes and the choice turns on what that
|
||||
synthetic id is *for*, which is not a call to make in passing while fixing a label.
|
||||
|
||||
### Phases scale with the speed control again — at a third of the rate
|
||||
|
||||
*"Phases displayed on the upper line go by too quickly still. Should be 4 times as long — at a guess.
|
||||
Maybe use the speed multiplier for that too?"*
|
||||
|
||||
Two complaints from opposite directions, and the answer is between them. v0.8.0.3 **pinned** phases at
|
||||
their tabled beat because scaling them walled off a player's own turn — *"that makes no sense"*. At
|
||||
10× that pinned beat is too short to read the sentence on it.
|
||||
|
||||
So they scale, damped to a third of the rate: **1× unchanged, 10× lands exactly on the four-times
|
||||
guess**, 20× gives 4400ms. The cost stays bounded because phase beats cluster rather than accumulate
|
||||
— measured over 60 pushes, a push carries **1.0 phase beat on average and 4 at worst**, so the wait
|
||||
after a move is ~2.4s typical and ~10s at its very worst, against the minutes a full multiplier would
|
||||
have cost. A player's own move still outlasts a phase beat at every speed, which is the ordering that
|
||||
matters.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.6 — 2026-09-10
|
||||
|
||||
Playing v0.8.0.5: *"saw bot's office area now — much better."* Three things still wrong, and one of
|
||||
them was mine hiding inside the fix for another.
|
||||
|
||||
### Your move is put away while the board is catching up
|
||||
|
||||
*"Your actions should be hidden while catching up."* Two reasons, and the second is the one that
|
||||
changed my mind about a decision taken early in v0.8.0 ("never block input"). The board on screen is
|
||||
behind the game, so a move offered there is a move against a position that has already moved on — the
|
||||
menu is computed from the CURRENT state and would be acted on while looking at an older one. And the
|
||||
screen had grown to four things competing at once: the district, the history, the catching-up row,
|
||||
and now a lit pile. Taking the action list out of that competition, while there is nothing to decide
|
||||
anyway, is the cheapest way to quieten it.
|
||||
|
||||
Not a block: Skip is one click away at the left of the row, so the wait stays voluntary. The buttons
|
||||
are replaced by the reason they are gone.
|
||||
|
||||
### …which could have locked a player out of their own game
|
||||
|
||||
Hiding actions behind `busy()` makes that flag the thing standing between a player and their turn —
|
||||
and **without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would never
|
||||
clear.** The action list would have been hidden permanently, with Skip the only way to play.
|
||||
|
||||
Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same stub that has been proving this
|
||||
page still starts since long before any of this existed. Two fallbacks now: no `rAF` means draw
|
||||
everything at once (exactly what `pace = 0` does deliberately), and a queue that throws empties
|
||||
itself rather than stranding the player. `test/step-queue.test.ts` pins that a never-advanced queue
|
||||
is still skippable.
|
||||
|
||||
### The lit pile was never brief — it was too quiet
|
||||
|
||||
*"Never saw decks lighting up… caught one flash deck light up for just a very brief moment, but
|
||||
couldn't see that with what bot was doing in office area and history and catch up area all at same
|
||||
time."*
|
||||
|
||||
Measured before changing anything: at 10× a pile stays lit for **6997ms**, just under seven seconds.
|
||||
So the highlight was not brief at all. It was a single 0.45s flash-in over a dark green fill, easy to
|
||||
miss entirely while looking at the district — a state that settles stops asking to be looked at. It
|
||||
pulses now for as long as the move is up, with a ring and a glow. The reduced-motion fallback is loud
|
||||
in a different way rather than simply still, since motion is the whole point here.
|
||||
|
||||
### The ceiling was not theoretical
|
||||
|
||||
*"At 10× — still a bit fast, but followable."* 10× was the top of the ladder, so the control's
|
||||
slowest setting was not slow enough for the person using it. `PACE_LEVELS` now runs to 20 and
|
||||
`MAX_PACE` with it. A control whose limit is reached in ordinary use has the wrong limit, not the
|
||||
right one held firmly.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.5 — 2026-09-10
|
||||
|
||||
**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.
|
||||
@@ -64,7 +235,7 @@ as how long another player's move is held, and that is now what it does. A phase
|
||||
|
||||
## 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
|
||||
|
||||
@@ -136,7 +307,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.3",
|
||||
"version": "0.8.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+33
-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.
|
||||
*/
|
||||
@@ -143,10 +143,15 @@ export function kindOf(cause: StepCause): StepKind {
|
||||
*
|
||||
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
|
||||
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
|
||||
* dwell and look exactly like a frozen board. Ten is far beyond any speed anyone would choose (2 and
|
||||
* 3 are the ones actually asked for) and well short of unusable.
|
||||
* dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
|
||||
* well short of unusable.
|
||||
*
|
||||
* RAISED FROM TEN 2026-09-10, because the ceiling turned out not to be theoretical: Jesse played at
|
||||
* 10× — the top of the ladder — and reported it *"still a bit fast, but followable"*. A control whose
|
||||
* slowest setting is not slow enough for the person using it has the wrong ceiling, not the right one
|
||||
* held firmly.
|
||||
*/
|
||||
export const MAX_PACE = 10;
|
||||
export const MAX_PACE = 20;
|
||||
|
||||
/**
|
||||
* The speeds the on-screen control offers, slowest last.
|
||||
@@ -158,7 +163,7 @@ export const MAX_PACE = 10;
|
||||
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
|
||||
* takes.
|
||||
*/
|
||||
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10] as const;
|
||||
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10, 15, 20] as const;
|
||||
|
||||
/**
|
||||
* How long to show one step, in ms, at a given speed.
|
||||
@@ -172,6 +177,28 @@ export function dwellFor(cause: StepCause, pace = 1): number {
|
||||
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the speed control a PHASE gets — damped, not the full multiplier.
|
||||
*
|
||||
* Phases were pinned at their tabled beat in v0.8.0.3, because scaling them with everything else put
|
||||
* a wall of clock-ticking after a player's own move. That was right about the cost and wrong about
|
||||
* the need: at 10× the caption row goes past faster than the sentence on it can be read. Jesse,
|
||||
* 2026-09-10: *"phases displayed on the upper line go by too quickly still. Should be 4 times as
|
||||
* long — at a guess. Maybe use the speed multiplier for that too?"*
|
||||
*
|
||||
* So they scale, at a third of the rate. That lands exactly on his guess — 10× gives a phase four
|
||||
* times its tabled beat — while leaving 1× untouched, and it stays affordable because phase beats
|
||||
* cluster rather than accumulate: measured over 60 pushes, a push carries **1.0 phase beat on
|
||||
* average and 4 at worst**, so the wait after a move goes to ~2.4s typical and ~10s at its very
|
||||
* worst rather than the minutes a full multiplier would have cost.
|
||||
*
|
||||
* Below 1× it simply follows the multiplier: somebody asking for everything faster means the phases
|
||||
* too.
|
||||
*/
|
||||
function phaseSpeed(pace: number): number {
|
||||
return pace <= 1 ? pace : 1 + (pace - 1) / 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to show one STEP — the form the queue actually uses.
|
||||
*
|
||||
@@ -199,7 +226,7 @@ export function dwellForStep(
|
||||
* 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;
|
||||
const speed = step.player === null ? phaseSpeed(pace) : 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
|
||||
|
||||
@@ -130,3 +130,46 @@ function need<T>(value: T | undefined, what: string): T {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1791,6 +1791,23 @@ export function snapshot(
|
||||
|
||||
/** A card id turned into something a person can read. */
|
||||
export function cardName(s: GameState, id: string): string {
|
||||
/**
|
||||
* A SCHEDULED TRAIN IS IN THE SALVAGE YARD UNDER A SYNTHETIC ID, and without this the pile that is
|
||||
* supposed to be face up reads "a card".
|
||||
*
|
||||
* `trainScheduled` pushes `train-<number>` rather than the id of the card that was played
|
||||
* (`apply.ts`), so there is nothing in `s.cards` to look up — and since a train is scheduled
|
||||
* several times a Day, that synthetic id is on top of the Salvage Yard most of the time. Reported
|
||||
* by Jesse 2026-09-10 as "why is salvage deck not face up. I should see the card played onto
|
||||
* salvage": it was face up all along and simply had nothing to say.
|
||||
*
|
||||
* Resolved here rather than in the engine because this is a NAME, which is this file's job. Whether
|
||||
* the engine should be pushing a real card id instead is a separate question with a separate
|
||||
* consequence — the Salvage Yard is swept back into the draw deck when it runs out — and is filed
|
||||
* rather than answered in passing.
|
||||
*/
|
||||
const scheduled = /^train-(\d+)$/.exec(id);
|
||||
if (scheduled) return `Train ${scheduled[1]}`;
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return 'a card';
|
||||
switch (k.kind) {
|
||||
|
||||
+55
-3
@@ -184,6 +184,20 @@ function drainIntoQueue(): void {
|
||||
const reset = session.takeDisplayReset();
|
||||
if (reset) stepQueue.reset(reset);
|
||||
stepQueue.push(session.takeDisplaySteps());
|
||||
/**
|
||||
* NOWHERE TO ANIMATE MEANS DO NOT QUEUE AT ALL.
|
||||
*
|
||||
* Without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would stay true for
|
||||
* good — and since "Your Move" is now put away while the board is catching up, that would hide a
|
||||
* player's own actions permanently, leaving Skip as the only way to play the game. Drawing
|
||||
* everything at once is exactly what `pace = 0` does deliberately, so that is the honest fallback
|
||||
* rather than a broken page. Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same
|
||||
* stub that has been proving this page still starts since long before any of this existed.
|
||||
*/
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
stepQueue.skip();
|
||||
return;
|
||||
}
|
||||
if (stepQueue.busy()) startAnimationLoop();
|
||||
}
|
||||
|
||||
@@ -316,12 +330,25 @@ function startAnimationLoop(): void {
|
||||
*/
|
||||
console.error('display queue stopped:', err);
|
||||
animating = false;
|
||||
// Do not strand the player behind a queue that can no longer advance: jump the board to the
|
||||
// live position, which brings "Your Move" back with it.
|
||||
try {
|
||||
stepQueue.skip();
|
||||
} catch {
|
||||
/* nothing further to try — the authoritative Frame is still what the rest of the page draws */
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (!stepQueue.busy()) {
|
||||
animating = false;
|
||||
// One last render so the "N behind" row collapses the moment the board is level.
|
||||
renderWatching();
|
||||
/**
|
||||
* A FULL RENDER, not just the row. The board being level again is what brings "Your Move"
|
||||
* back and clears the last lit pile, so redrawing only the catching-up row would leave the
|
||||
* action list hidden until something else happened to trigger a render — which, when the game
|
||||
* is waiting on this player, is nothing at all.
|
||||
*/
|
||||
render();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
@@ -1511,7 +1538,9 @@ function render(): void {
|
||||
* 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.
|
||||
*/
|
||||
$('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);
|
||||
|
||||
@@ -1985,6 +2014,29 @@ function renderActions(
|
||||
renderEnding(el, f);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* YOUR MOVE IS PUT AWAY WHILE THE BOARD IS CATCHING UP — Jesse, 2026-09-10: *"your actions should
|
||||
* be hidden while catching up."*
|
||||
*
|
||||
* Two reasons, and the second is the one that changed my mind about it. The board on screen is
|
||||
* behind the game, so a move offered here is a move against a position that has already moved on —
|
||||
* the menu is computed from the CURRENT state and would be acted on while looking at an older one.
|
||||
* And the display had grown to four things demanding attention at once — the district, the history,
|
||||
* the catching-up row and now a lit pile — which is what made the pile highlight so easy to miss.
|
||||
* Taking the action list out of that competition while there is nothing to decide anyway is the
|
||||
* cheapest way to quieten it.
|
||||
*
|
||||
* NOT A BLOCK. Skip is one click away and sits at the left of the row, so the wait is always
|
||||
* voluntary; this replaces the buttons with the reason they are gone, rather than leaving a live
|
||||
* menu over a stale board.
|
||||
*/
|
||||
if (stepQueue.busy()) {
|
||||
el.innerHTML =
|
||||
'<div class="dim">Catching up on what everyone else did — your move is here when the board is ' +
|
||||
'level with the game. <b>Skip</b> jumps straight to it.</div>';
|
||||
return;
|
||||
}
|
||||
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
|
||||
// put its results up unasked again (Gitea#11).
|
||||
resultsShown = false;
|
||||
|
||||
+64
-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
|
||||
* is out of reach, and choosing where to discard is choosing what to put there.
|
||||
*/
|
||||
export function pilesHtml(f: Frame): string {
|
||||
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
|
||||
export function pilesHtml(f: Frame, lit: readonly string[] = []): 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(' · ');
|
||||
// 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
|
||||
// viewer draws exactly the same markup and nothing there is clickable.
|
||||
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 (
|
||||
`<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>` +
|
||||
`<b>${esc(top)}</b></div>`
|
||||
);
|
||||
};
|
||||
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
|
||||
.map((d, i) => {
|
||||
const depth = f.departmentDepth[i] ?? 0;
|
||||
const under = depth - 1;
|
||||
return pile(
|
||||
`dept${i}`,
|
||||
`Dept ${i + 1}`,
|
||||
d,
|
||||
depth,
|
||||
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
|
||||
})
|
||||
.join('') +
|
||||
pile(
|
||||
'salvage',
|
||||
'Salvage',
|
||||
f.salvage.top,
|
||||
f.salvage.depth,
|
||||
@@ -676,6 +713,30 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
||||
.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;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
|
||||
animation:pilepulse 1.15s ease-in-out infinite}
|
||||
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
|
||||
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
|
||||
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
|
||||
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
|
||||
office area and history and catch up area all at same time." Something still moving keeps drawing
|
||||
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
|
||||
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
|
||||
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
|
||||
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
|
||||
than simply not moving: a solid ring and a brighter fill, held. */
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
|
||||
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
||||
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
|
||||
|
||||
+19
-1
@@ -19,7 +19,8 @@
|
||||
|
||||
import type { PublicFrame } from '../sim/view.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';
|
||||
|
||||
export type StepQueue = {
|
||||
@@ -43,6 +44,13 @@ export type StepQueue = {
|
||||
behind(): number;
|
||||
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
|
||||
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. */
|
||||
busy(): boolean;
|
||||
};
|
||||
@@ -65,6 +73,7 @@ export function createStepQueue(
|
||||
): StepQueue {
|
||||
let shown: PublicFrame | null = null;
|
||||
let last: DisplayStep | null = null;
|
||||
let litPiles: readonly PileKey[] = [];
|
||||
let pending: DisplayStep[] = [];
|
||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||
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. */
|
||||
const show = (step: DisplayStep): void => {
|
||||
const before = shown;
|
||||
shown = applyPublicDelta(shown, step.frame);
|
||||
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 {
|
||||
@@ -84,6 +99,8 @@ export function createStepQueue(
|
||||
shown = frame;
|
||||
pending = [];
|
||||
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
|
||||
// sentence describing the most recent action is still true.
|
||||
},
|
||||
@@ -136,6 +153,7 @@ export function createStepQueue(
|
||||
current: () => shown,
|
||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||
showing: () => last,
|
||||
lit: () => litPiles,
|
||||
/**
|
||||
* STILL SHOWING SOMETHING, not just still holding something back.
|
||||
*
|
||||
|
||||
+36
-12
@@ -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.
|
||||
*/
|
||||
@@ -131,6 +131,12 @@ describe('pacing — dwell by kind', () => {
|
||||
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
|
||||
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
|
||||
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
|
||||
/**
|
||||
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
|
||||
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
|
||||
* somebody actually reached for and found insufficient.
|
||||
*/
|
||||
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
|
||||
for (const p of PACE_LEVELS) {
|
||||
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
|
||||
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
|
||||
@@ -164,23 +170,41 @@ describe('pacing — dwell by kind', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('the speed control stretches other people, not the clock', () => {
|
||||
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
|
||||
/**
|
||||
* Jesse, from a real 5× game: *"after my turn, when I actually execute my turn, I'm still subject
|
||||
* 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.
|
||||
* Two complaints, one from each direction, and the answer is between them.
|
||||
*
|
||||
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
|
||||
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
|
||||
* player's own turn. So they were pinned at their tabled beat.
|
||||
*
|
||||
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
|
||||
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
|
||||
*
|
||||
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
|
||||
* the cost stays bounded because phase beats cluster rather than accumulate.
|
||||
*/
|
||||
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
|
||||
const 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);
|
||||
|
||||
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
|
||||
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
|
||||
|
||||
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
|
||||
const p = dwellForStep(phase, pace);
|
||||
const t = dwellForStep(theirs, pace);
|
||||
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
|
||||
assert.ok(
|
||||
p < DWELL.phase * pace,
|
||||
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
|
||||
);
|
||||
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
|
||||
}
|
||||
// Off still means off, for the clock as much as for anybody's move.
|
||||
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
|
||||
// follows the multiplier straight, because "faster" should mean everything.
|
||||
assert.equal(dwellForStep(phase, 0), 0);
|
||||
assert.equal(dwellForStep(theirs, 0), 0);
|
||||
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
|
||||
});
|
||||
|
||||
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
|
||||
@@ -199,7 +223,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
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { currentActorOfState, publicSnapshot } 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 = {
|
||||
mode: 'competitive',
|
||||
@@ -184,3 +184,73 @@ describe('public frame delta', () => {
|
||||
});
|
||||
|
||||
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)), []);
|
||||
});
|
||||
});
|
||||
|
||||
+20
-1
@@ -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
|
||||
@@ -251,6 +251,25 @@ describe('the step queue', () => {
|
||||
assert.ok(q.showing() !== null, 'the caption should survive a reset');
|
||||
});
|
||||
|
||||
it('can always be emptied, so a player is never stranded behind it', () => {
|
||||
/**
|
||||
* "Your Move" is put away while the board is catching up (v0.8.0.6), which makes `busy()` the
|
||||
* thing standing between a player and their own turn. So the ways it can be cleared matter more
|
||||
* than they did: `skip()` must always work, from any state, including one where the clock has
|
||||
* never advanced — which is exactly the situation a page with no `requestAnimationFrame` is in,
|
||||
* and how this was found.
|
||||
*/
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
// Never advanced at all: no frame has been shown, and the queue is full.
|
||||
assert.equal(q.busy(), true);
|
||||
assert.equal(q.skip(), true, 'a never-advanced queue must still be skippable');
|
||||
assert.equal(q.busy(), false, 'and must be idle afterwards, or the player stays locked out');
|
||||
assert.deepEqual(q.current(), final);
|
||||
});
|
||||
|
||||
it('draws nothing before a reset has arrived', () => {
|
||||
const q = createStepQueue();
|
||||
assert.equal(q.current(), null);
|
||||
|
||||
+91
-1
@@ -23,7 +23,7 @@ import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import type { DivisionView } from '../src/sim/view.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 { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
@@ -3614,6 +3614,96 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
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('names the train on top of the Salvage Yard, instead of "a card"', () => {
|
||||
/**
|
||||
* The Salvage Yard is a FACE-UP pile and its tile reads the top card — but `trainScheduled`
|
||||
* pushes a synthetic `train-<number>` id rather than the id of the card that was played
|
||||
* (`apply.ts`), so there was nothing in `s.cards` to look up and the tile said "a card". A train
|
||||
* is scheduled several times a Day, so that id is on top most of the time: the pile was face up
|
||||
* and had nothing to say. Jesse, 2026-09-10: *"why is salvage deck not face up. I should see the
|
||||
* card played onto salvage."*
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'salv',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
s.decks.salvageYard.push('train-13');
|
||||
const f = snapshot(s, [], null);
|
||||
assert.equal(f.salvage.top, 'Train 13', 'a scheduled train on the pile must be named');
|
||||
|
||||
const html = pilesHtml(f);
|
||||
assert.ok(html.includes('Train 13'), `the Salvage tile does not name the train:\n${html}`);
|
||||
assert.equal(html.includes('>a card<'), false, 'the face-up pile still says "a card"');
|
||||
});
|
||||
|
||||
it('lights only the pile a watched move touched', () => {
|
||||
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', () => {
|
||||
/**
|
||||
* 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