/** * 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 = { /** 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; 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; }