The Salvage Yard was face up all along; its tile just read "a card". apply.ts pushes a synthetic train-<n> id on trainScheduled, nothing in s.cards matches it, and cardName fell through to its default — and since a train is scheduled several times a Day that id is on top most of the time. Measured before touching anything: the tile read "a card" from the opening frame through 60 pushes while its depth climbed from 2 to 8. cardName resolves it now, in sim/view.ts, because this is a name. The engine half is filed as Gitea#23 rather than fixed here. reshuffleIfDepleted sweeps the Salvage Yard back into the draw deck, so that synthetic id can be shuffled in and drawn into a hand as an id with no card behind it. Eight games driven to 4000 moves across eight seeds produced zero reshuffles, so it is latent; there are two defensible fixes and the choice turns on what the synthetic id is for, which is not a call to make while fixing a label. And phases scale with the speed control again, damped to a third of the rate. They were pinned in v0.8.0.3 because scaling them walled off a player's own turn; pinned turns out to be too short to read at 10x. Damped satisfies both: 1x unchanged, 10x lands exactly on the four-times guess. Bounded because phase beats cluster rather than accumulate — 1.0 per push on average, 4 at worst, so the wait after a move is ~2.4s typical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
257 lines
13 KiB
TypeScript
257 lines
13 KiB
TypeScript
/**
|
||
* HOW LONG EACH STEP IS SHOWN — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
|
||
*
|
||
* Shared rather than living in `src/web/`, so the 0.8.1 seatless board paces identically to a
|
||
* player's own screen. Two views of one game that disagreed about how fast it looks would be worse
|
||
* than either alone.
|
||
*
|
||
* WHY BY KIND RATHER THAN BY BUDGET. The obvious scheme is to give the whole backlog a time budget
|
||
* and divide it by the queue length. Measured against real games, that does exactly the wrong
|
||
* thing. From `public/replays/`: ~60 stages per game and ~5 intents per player per stage, so a
|
||
* four-player table produces ~15 other-player steps per stage — but 44 of a 307-intent game are
|
||
* `draw.end` and 60 are `loadUnload.end`, bookkeeping nobody wants to watch, while the thing that
|
||
* is worth watching is rare and clustered. Two of the three published replays contain no
|
||
* `switch.move` at all; the third has bursts of 14, 6, 6 and 6, and `trayMoved`'s own narration says
|
||
* "N of 6 Moves left" because six is the engine's cap per crew. So a uniform budget spends the
|
||
* player's attention on `draw.end` and rushes the switching.
|
||
*
|
||
* Assigning dwell by kind and letting the total fall out costs ~40s of animation across a whole
|
||
* 60-stage game, against ~3.6 minutes for a flat 700ms — better switching visibility for a fifth of
|
||
* the time. Jesse, 2026-09-09, on what matters: *"I definitely want to watch other players struggle
|
||
* with the switching exercises … I don't think reading the switching in the log will be anywhere
|
||
* nearly as interesting as watching the trains actually move on the board."*
|
||
*
|
||
* PACING IS CLIENT-SIDE ONLY. The server emits steps as fast as it likes and the client decides how
|
||
* to show them, which is what keeps Gitea#20's "do not slow the authoritative game" true.
|
||
*/
|
||
|
||
import type { StepCause } from './display-step.ts';
|
||
|
||
export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
|
||
|
||
/**
|
||
* THE TUNING TABLE — dwell in milliseconds per kind.
|
||
*
|
||
* Start generous and tune down by playing; Jesse, 2026-09-09: *"start at 1s and tune down."* This is
|
||
* the committed default and changing it needs a web rebuild, which in the `.s9pk` is a release — so
|
||
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
|
||
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
|
||
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
|
||
* hundred times will want it off". **Multipliers above 1 are supported and expected** — Jesse asked
|
||
* for 2 and 3 explicitly after the first play — up to `MAX_PACE`, and every tier scales together so
|
||
* their relative weighting survives.
|
||
*
|
||
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
|
||
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
|
||
* table's multiplier — the table, the classification and the queue do not change.
|
||
*/
|
||
export const DWELL: Record<StepKind, number> = {
|
||
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
|
||
switching: 1000,
|
||
/**
|
||
* A card, a car or a load changing hands somewhere visible — and the announcement of what a
|
||
* player is about to do.
|
||
*
|
||
* 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 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.
|
||
*/
|
||
action: 700,
|
||
/**
|
||
* An automatic phase that DID something — TODO #18.
|
||
*
|
||
* Only reached when the phase actually narrated: `submit()` collects no step for a phase that
|
||
* changed nothing, so this is never spent on the empty ones Jesse is content to guess at. Between
|
||
* an ordinary action and a switching move, because the Mainline phase moves trains the length of
|
||
* the Division and is the clearest case of "stuff just happened without being able to see how".
|
||
*/
|
||
phase: 600,
|
||
/** Turn and phase bookkeeping. Nothing moved; do not spend the player's attention on it. */
|
||
bookkeeping: 0,
|
||
};
|
||
|
||
/**
|
||
* Which kind an intent is.
|
||
*
|
||
* Exhaustive over `Intent['type']` on purpose — a `default` would silently drop a newly added intent
|
||
* into whatever tier the fallback names, and the failure mode is invisible (a move that never gets
|
||
* a beat, or bookkeeping that stalls the queue for a second). `test/pacing.test.ts` walks every
|
||
* member of the union so a new intent cannot land here unclassified.
|
||
*/
|
||
export function kindOf(cause: StepCause): StepKind {
|
||
switch (cause) {
|
||
// The Division advancing itself — New Train, the Mainline, the shift change (TODO #18).
|
||
case 'phase':
|
||
return 'phase';
|
||
|
||
// The crew and its train moving, coupling, setting out and re-ordering — §6.1 and Appendix A.
|
||
case 'switch.move':
|
||
case 'switch.dropCars':
|
||
case 'switch.sortConsist':
|
||
case 'maneuver.flyingSwitch':
|
||
case 'maneuver.redFlags':
|
||
return 'switching';
|
||
|
||
// Something visible changed hands or position, but no train drove anywhere.
|
||
case 'card.play':
|
||
case 'card.discard':
|
||
case 'draw.fromHomeOffice':
|
||
case 'draw.fromDepartment':
|
||
case 'newTrain.placeCar':
|
||
case 'newTrain.passCar':
|
||
case 'newTrain.secondSection':
|
||
case 'newTrain.startExtra':
|
||
case 'porter.board':
|
||
case 'porter.detrain':
|
||
case 'laborer.startLoad':
|
||
case 'laborer.advanceLoad':
|
||
case 'laborer.beginUnload':
|
||
case 'freightAgent.stockOutbound':
|
||
case 'freightAgent.clearInbound':
|
||
case 'freightAgent.unjam':
|
||
case 'mainline.clearance':
|
||
case 'mainline.modify':
|
||
case 'mainline.redFlag':
|
||
case 'mainline.yardOffice':
|
||
case 'redFlag.play':
|
||
return 'action';
|
||
|
||
/**
|
||
* `localOps.choose` IS AN ANNOUNCEMENT, NOT BOOKKEEPING — moved out 2026-09-09 after the first
|
||
* real play. It is the line that reads "Player Bot 1 chose to SWITCH — six Moves to shunt cars
|
||
* around the yard": the heading for everything that follows, and at zero dwell nobody ever saw
|
||
* it, so a bot's turn began with no indication of what it was about to do.
|
||
*/
|
||
case 'localOps.choose':
|
||
return 'action';
|
||
|
||
// Ending a phase or a turn, and voting. Nothing to see: the consequences were the thing, and
|
||
// there are more of these than of anything else.
|
||
case 'loadUnload.end':
|
||
case 'draw.end':
|
||
case 'switch.end':
|
||
case 'freightAgent.end':
|
||
case 'game.extend':
|
||
return 'bookkeeping';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The widest multiplier that is a speed rather than a mistake.
|
||
*
|
||
* `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. 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 = 20;
|
||
|
||
/**
|
||
* The speeds the on-screen control offers, slowest last.
|
||
*
|
||
* `0` is off: every move is drawn at once, as it was before v0.8.0 — TODO #18's "a player who has
|
||
* seen it a hundred times will want it off". The ladder runs well past 1 because that is what the
|
||
* first real play asked for: Jesse reached for 7×, and although the `?pace=` he used never took
|
||
* effect (the splash replaces the query string, so the play page only ever saw `?lobby`), the wish
|
||
* 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, 15, 20] as const;
|
||
|
||
/**
|
||
* How long to show one step, in ms, at a given speed.
|
||
*
|
||
* `pace` scales every tier by the same factor, so **the tiers stay in proportion at any speed** — a
|
||
* switching move outlasts an ordinary action at 0.5× and at 3× alike. That is deliberate: the
|
||
* relative weighting is the design (a train moving is worth more attention than a card changing
|
||
* hands), and the multiplier is only how fast the whole thing runs. `0` means do not animate at all.
|
||
*/
|
||
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.
|
||
*
|
||
* A step that said nothing gets no dwell, whatever caused it. That is one rule covering two cases
|
||
* arrived at separately: a phase where nothing happened (Jesse, 2026-09-09 — *"if nothing happens
|
||
* during a phase then we shouldn't lose time to it"*), and a phase that only handed the turn on,
|
||
* which changes the board but has nothing on it to look at. Structurally typed so this file does not
|
||
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
|
||
*/
|
||
export function dwellForStep(
|
||
step: { cause: StepCause; player: number | null; lines: readonly unknown[]; frame: { table: object } },
|
||
pace = 1,
|
||
): number {
|
||
// 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 ? 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
|
||
* every phase a visible beat", for New Train, the Mainline and the shift change.
|
||
*
|
||
* Measured, because the obvious rule was wrong twice. "No narration, no dwell" looked right and
|
||
* silently killed #18: a phase can move trains without saying anything, and those steps were being
|
||
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
|
||
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
|
||
* all of them would cost a quarter of an hour a game.
|
||
*/
|
||
const table = step.frame.table as Record<string, unknown>;
|
||
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
|
||
return turned ? dwellFor(step.cause, speed) : 0;
|
||
}
|
||
|
||
/**
|
||
* How many steps in a queue are actually going to be WATCHED.
|
||
*
|
||
* This is the number the "N behind" counter shows, and it is deliberately not `queue.length`. With
|
||
* bookkeeping dwelling at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to 5
|
||
* the instant it started, and then crawl — which is not the steady countdown the counter is for.
|
||
* Thirteen dwelling steps means thirteen things you are going to see.
|
||
*/
|
||
export function watchableCount(causes: readonly StepCause[], pace = 1): number {
|
||
return causes.filter((c) => dwellFor(c, pace) > 0).length;
|
||
}
|