v0.8.0.11 — sixteen fixes from the second multiplayer playtest
Arrivals name whose Office they reached, and no longer tell every seat they can work the train. The turn chart follows the animation queue, so being five behind looks five behind across the whole screen rather than half of it. Pause sits beside Skip and preserves the dwell a held step still owed. A one-render look at another player's Office Area. The district summary counts the board being shown. LIMITS is printed beneath its card instead of through its border. The Mainline region divider is visible. Only Hilly mentions FAST/SLOW, because it is the only card that reads it. A passenger Modifier on a Whistle Post reports itself dormant rather than claiming the facility "only receives". An automatic phase says what the Division is doing instead of answering by negation. The version appears once in the header rather than twice on every .s9pk. Save files carry the join code, the Stage and the date. The New Train phase, reviewed before being changed: the make-up panel now says what the train STILL needs rather than only what its card calls for, explains that a player adds one car before the round passes on, marks the train being loaded on the Division map, and gives an addable car in the yard the same amber every other clickable thing on the page wears. Reasoning, measurements and the reports behind each are in CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
This commit is contained in:
co-authored by
Claude Opus 5
parent
4adf149ba5
commit
9a9e50b3c6
@@ -1484,6 +1484,7 @@ function arriveAtOffice(
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
consist: tray.consist.map((c) => ({ ...c })),
|
||||
office: officeProfile(area.tier).name,
|
||||
owner: playerAtSeat(s, seat),
|
||||
expedited: isExpedited(tray),
|
||||
});
|
||||
|
||||
|
||||
+11
-1
@@ -772,7 +772,17 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
|
||||
`${stages(run({}))}.`,
|
||||
);
|
||||
} else {
|
||||
parts.push(`${stages(run({}))} for every train — the printed speed is scenery.`);
|
||||
/**
|
||||
* NOT A WORD ABOUT SPEED HERE — Jesse, playtest 2026-09-16.
|
||||
*
|
||||
* This read "the printed speed is scenery", which sent a player hunting the card for a number
|
||||
* that is not drawn on it. The first rewrite said "fast or slow alike", which is true but raises
|
||||
* the question on thirteen cards in order to answer it. **Exactly one card reads FAST/SLOW**:
|
||||
* Hilly, the only profile with `speedStarts` (see the note above it). So the explanation belongs
|
||||
* on that card, where the branch above already gives it, and everywhere else says nothing —
|
||||
* silence is the honest answer when the rating genuinely does not apply.
|
||||
*/
|
||||
parts.push(`${stages(run({}))} for every train.`);
|
||||
}
|
||||
|
||||
if (kind === 'uncontrolledSiding') {
|
||||
|
||||
@@ -159,7 +159,13 @@ export type GameEvent =
|
||||
* switched normally like any other arrival, but it has to be back on the Office square before the
|
||||
* next Mainline Phase begins, or `expediteFault` fires.
|
||||
*/
|
||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
|
||||
/**
|
||||
* `owner` is WHOSE Office it reached — the district's player, not whoever is acting. The Mainline
|
||||
* Phase has no actor, so nothing else in the line could name the seat, and the narration said only
|
||||
* "ARRIVED at the Whistle Post" — every seat's Office has a tier, and at a four-seat table three of
|
||||
* them are somebody else's (Jesse, playtest 2026-09-16).
|
||||
*/
|
||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; owner: PlayerIndex; expedited: boolean }
|
||||
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
||||
/**
|
||||
* The train ran the length of the Division and left it. `side` is the Division Point it left by,
|
||||
|
||||
+32
-6
@@ -137,6 +137,8 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
region?: number;
|
||||
direction?: string;
|
||||
stagesLeft?: number;
|
||||
/** Being made up at a Division Point right now, so the map can mark the train you are loading. */
|
||||
beingMadeUp?: boolean;
|
||||
}[];
|
||||
cap: number | null;
|
||||
tip: string;
|
||||
@@ -515,13 +517,18 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
||||
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
|
||||
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
|
||||
// THE TRAIN THE MAKE-UP PANEL IS TALKING ABOUT. Amber, because that is what the rest of the
|
||||
// page uses for "this is the thing you are acting on" (Jesse, playtest 2026-09-16).
|
||||
const building = t.beingMadeUp === true;
|
||||
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
||||
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
|
||||
const stages =
|
||||
typeof t.stagesLeft === 'number'
|
||||
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card`
|
||||
: '';
|
||||
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
||||
out += `<g class="bs-train${building ? ' bs-building' : ''}" data-tip="${esc(t.label)}${
|
||||
building ? ' \u2014 BEING MADE UP NOW: add cars from the Division Yard' : ''
|
||||
} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
||||
cars.length ? ` (${loaded} loaded)` : ''
|
||||
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}, counted west to east${dir}` : ''}${esc(stages)}${
|
||||
// What the card prints. A train on the Mainline is exactly where "why did that leave without
|
||||
@@ -682,7 +689,17 @@ export function officeSvg(
|
||||
const c0 = Math.min(...cols);
|
||||
const c1 = Math.max(...cols);
|
||||
const width = (c1 - c0 + 1) * (W + PAD);
|
||||
const height = (r1 - r0 + 1) * (H + PAD) + 4;
|
||||
/**
|
||||
* A BAND BENEATH THE BOTTOM ROW FOR THE LIMITS LABELS, and only when there are labels to put in it.
|
||||
*
|
||||
* The bottom card's lower edge lands at `height - 7`, and the label's baseline was `height - 4` —
|
||||
* so its 8px glyphs spanned `height - 12` to `height - 4` and the card's own border ran straight
|
||||
* through the middle of the word (Jesse, playtest 2026-09-16: *"the text is split by the bottom
|
||||
* border of the limits card… it should be printed directly beneath the card"*). Raising the text
|
||||
* instead would have pushed it onto the card, over the rails; the room has to be made below.
|
||||
*/
|
||||
const limitBand = limits ? 14 : 0;
|
||||
const height = (r1 - r0 + 1) * (H + PAD) + 4 + limitBand;
|
||||
|
||||
// Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest
|
||||
// and the district hangs beneath it, as the rules describe it.
|
||||
@@ -1218,7 +1235,9 @@ export function officeSvg(
|
||||
if (limits) {
|
||||
const edge = (x: number, side: string): string =>
|
||||
`<line class="bs-limitline" x1="${x}" y1="0" x2="${x}" y2="${height}"/>` +
|
||||
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 4}" ` +
|
||||
// Baseline inside the band below the cards: the glyphs run from `height - 19` to `height - 11`
|
||||
// and the bottom row's edge is at `height - 21`, so the whole word clears the card border.
|
||||
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 11}" ` +
|
||||
`text-anchor="${side === 'w' ? 'start' : 'end'}">LIMITS</text>`;
|
||||
out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e');
|
||||
}
|
||||
@@ -1267,9 +1286,12 @@ export const BOARD_CSS = `
|
||||
.bs-dcell.bs-changed rect{stroke:#e0a060;stroke-width:2.4;animation:bs-changed-pulse 1.1s ease-in-out infinite}
|
||||
@keyframes bs-changed-pulse{0%,100%{stroke-opacity:1}50%{stroke-opacity:.35}}
|
||||
@media (prefers-reduced-motion: reduce){.bs-dcell.bs-changed rect{animation:none}}
|
||||
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
|
||||
train is measured against, not something to look at instead of the train. */
|
||||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||
/* The vertical bars a Mainline card is divided into (§2.1). They are the ruler the train is measured
|
||||
against, not something to look at instead of the train — but they were drawn so faint they could
|
||||
not be made out at all (Jesse, playtest 2026-09-16: "the dividing line is barely visible"). A
|
||||
ruler you cannot read is not restraint, so this is lifted to the tie colour and given a longer
|
||||
dash: still quieter than the rail, and now actually there. */
|
||||
.bs-region{stroke:#98a3b2;stroke-width:1.6;stroke-dasharray:4 2}
|
||||
/* #94 — the one red mark on the Division map, so it reads as a stop rather than as decoration. */
|
||||
.bs-flag line{stroke:#9aa3b0;stroke-width:1.6}
|
||||
.bs-flag polygon{fill:#d2453f;stroke:#7d211d;stroke-width:0.8}
|
||||
@@ -1302,6 +1324,10 @@ export const BOARD_CSS = `
|
||||
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)}
|
||||
.bs-slot.bs-car-cab.bs-loaded{fill:rgba(192,90,90,.85)}
|
||||
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
||||
/* The train the New Train phase is loading, in the page's action amber, so the make-up panel on the
|
||||
right and the train on the map at the top left are visibly the same subject. */
|
||||
.bs-train.bs-building rect{fill:#4a3a1c;stroke:#c8912f;stroke-width:2}
|
||||
.bs-train.bs-building .bs-tlab{fill:#f2d49a}
|
||||
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
||||
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
|
||||
engine is the one that carries the arrow — which is what makes "reverse" mean something. */
|
||||
|
||||
+19
-6
@@ -343,12 +343,25 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
* arrival; the only difference is what happens if it is left on Secondary Track when the next
|
||||
* Mainline Phase begins (`expediteFault`).
|
||||
*/
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.expedited
|
||||
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so keep it on the Office square: parked anywhere else in the district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
|
||||
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
|
||||
};
|
||||
/**
|
||||
* WHOSE OFFICE, AND WHOSE TRAIN TO WORK — Jesse, playtest 2026-09-16.
|
||||
*
|
||||
* This said "ARRIVED at the Whistle Post" and then "You can work it in Cargo now". Both halves
|
||||
* are wrong at a table of four: every seat has an Office, so the tier alone does not say which
|
||||
* district the train is standing in, and the reader is usually NOT its Station Master — the
|
||||
* line was telling three players they could work a train they cannot touch.
|
||||
*/
|
||||
{
|
||||
const name = ctx.playerName?.(e.owner) ?? null;
|
||||
const whose = name === null ? `the ${e.office}` : `${name}'s ${e.office}`;
|
||||
const worker = name === null ? 'Its Station Master' : name;
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.expedited
|
||||
? `Train ${e.trainNumber} ARRIVED at ${whose} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it must stay on the Office square: parked anywhere else in that district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
|
||||
: `Train ${e.trainNumber} ARRIVED at ${whose} carrying ${carsLabel(e.consist)} — it stands there for the rest of this Stage. ${worker} can work it in Cargo now and switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
|
||||
};
|
||||
}
|
||||
case 'expediteFault':
|
||||
return {
|
||||
tone: 'bad',
|
||||
|
||||
@@ -79,6 +79,8 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
const narrateCtx = {
|
||||
cardName: (id: string) => cardName(s, id),
|
||||
trainName: (id: string) => trainName(s, id),
|
||||
// A player index is not a seat index, so the fallback names no number at all — see `web/game.ts`.
|
||||
playerName: (p: number) => s.players[p]?.name ?? 'another player',
|
||||
};
|
||||
|
||||
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
|
||||
|
||||
+20
-2
@@ -106,7 +106,25 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
||||
* and answers who; `awaiting` says what, because "waiting on Bob" with no more than that is a
|
||||
* game that looks stuck to everyone except Bob.
|
||||
*/
|
||||
const who = actorName ?? 'nobody — the Division is running itself';
|
||||
/**
|
||||
* AN AUTOMATIC PHASE WAITS ON NOBODY, so it says what it is DOING instead of apologising.
|
||||
*
|
||||
* "nobody — the Division is running itself" reached the answer by negation, and left a player
|
||||
* reading a line whose subject was an absence (Jesse, playtest 2026-09-16: it should say "waiting
|
||||
* on <player>", or describe what the engine is doing — "the Division is moving trains during the
|
||||
* mainline phase"). The phase's NAME is already printed on the line directly above this one, so
|
||||
* these describe the work rather than repeating the label.
|
||||
*/
|
||||
const DOING: Record<string, string> = {
|
||||
mainline: 'the Division is moving trains',
|
||||
newTrain: "the Division is building this Stage's trains",
|
||||
loadUnload: 'the Division is working cargo',
|
||||
shiftChange: 'the Division is changing shifts',
|
||||
};
|
||||
// Local Operations always has an actor, so its entry is the fallback rather than a case.
|
||||
const who = actorName ?? DOING[f.phaseKey] ?? 'the Division is running itself';
|
||||
// Only a person is WAITED ON. The Division is not waiting; it is working.
|
||||
const waiting = actorName === null ? '' : 'waiting on ';
|
||||
const asked = f.awaiting
|
||||
? ` <span class="tc-asks">${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}</span>`
|
||||
: '';
|
||||
@@ -120,7 +138,7 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
||||
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
||||
`<span class="dim">${esc(f.clock)}</span></div>` +
|
||||
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
||||
`<div class="tc-who">waiting on <b>${esc(who)}</b>${asked}</div>` +
|
||||
`<div class="tc-who">${waiting}<b>${esc(who)}</b>${asked}</div>` +
|
||||
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
|
||||
// between the phases and everything above them, which put a thing that changes every third
|
||||
// Stage in the middle of the things that change every Stage. The row it belongs beside is the
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
areaOf,
|
||||
destinationsFor,
|
||||
facilityCarType,
|
||||
isBeingMadeUp,
|
||||
laborersLeft,
|
||||
movesFor,
|
||||
ownCutFor,
|
||||
@@ -276,6 +277,15 @@ export type TrainChip = {
|
||||
* it belongs in the tooltip, where there is room to say which it is.
|
||||
*/
|
||||
stagesLeft?: number;
|
||||
/**
|
||||
* BEING MADE UP RIGHT NOW — §7's round, one car at a time, at a Division Point.
|
||||
*
|
||||
* The make-up panel names the train and the yard chips load it, and both are in the right-hand
|
||||
* column; the train itself is drawn on the Division strip at the top left, looking exactly like
|
||||
* every other chip on the map. So the two halves of the same activity never pointed at each other
|
||||
* (Jesse, playtest 2026-09-16). Absent rather than false everywhere else, like `region` above.
|
||||
*/
|
||||
beingMadeUp?: true;
|
||||
};
|
||||
/**
|
||||
* One card of a player's Running Track, as the Division sees it.
|
||||
@@ -720,6 +730,26 @@ function suppressedGrants(modifiers: string[], f: Facility): string[] {
|
||||
for (const key of modifiers) {
|
||||
const m = MODIFIER_PROFILES.find((p) => p.kind === key);
|
||||
if (!m) continue;
|
||||
/**
|
||||
* A WHISTLE POST TAKES NOTHING AT ALL, AND SAYING "IT ONLY RECEIVES" WOULD BE A LIE.
|
||||
*
|
||||
* Jesse, playtest 2026-09-16: a Restaurant appeared to do nothing. It does nothing — a Whistle
|
||||
* Post is not a Passenger Facility, so it allows neither direction and has 0 capacity each way;
|
||||
* the engine's `usableGrant` discards the capacity while the porter is granted regardless, which
|
||||
* leaves a porter with nothing to carry. Playing it there stays LEGAL on Jesse's call, so the
|
||||
* card is not wasted — it starts working the moment the Office is upgraded — but the panel has
|
||||
* to say so, or the player is left believing the card is broken.
|
||||
*
|
||||
* Only a Whistle Post can reach this: every freight flow allows at least one direction, and
|
||||
* every Office above the first allows both.
|
||||
*/
|
||||
if (f.kind === 'passenger' && !f.allows.outbound && !f.allows.inbound) {
|
||||
out.push(
|
||||
`${m.name}: DORMANT — a Whistle Post works no passengers at all, so nothing this card ` +
|
||||
`grants is in use yet. It all starts working when the Office is upgraded to a Depot.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (m.addOut > 0 && !f.allows.outbound) {
|
||||
out.push(`${m.name}: +${m.addOut} outbound has no effect here — this facility only receives`);
|
||||
}
|
||||
@@ -2367,6 +2397,9 @@ function trainChip(s: GameState, id: string): TrainChip {
|
||||
engineAt: at,
|
||||
facing: railFacingOf(t),
|
||||
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
|
||||
// Conditional spread, not `beingMadeUp: isBeingMadeUp(t)`: the field is optional-and-true, and
|
||||
// `exactOptionalPropertyTypes` refuses an explicit `false` for it.
|
||||
...(isBeingMadeUp(t) ? { beingMadeUp: true as const } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -663,6 +663,14 @@ export type Menu = {
|
||||
makeUp: {
|
||||
trayId: string;
|
||||
title: string;
|
||||
/**
|
||||
* What the card still wants, after what is already coupled up — "1 boxcar/hopper + 1 caboose".
|
||||
*
|
||||
* The title says what the card CALLS FOR and never changes as cars go on, so a player had to
|
||||
* diff it against the consist drawn on the Division map, in the other column. Null when the
|
||||
* train is complete and only the send-it-out button is left.
|
||||
*/
|
||||
needs: string | null;
|
||||
cars: MakeUpAction[];
|
||||
pass: number | null;
|
||||
/**
|
||||
@@ -803,6 +811,7 @@ export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
|
||||
? {
|
||||
trayId: filling,
|
||||
title: consistTitle(game, filling) ?? 'Making up the train',
|
||||
needs: consistNeeds(game, filling),
|
||||
cars: makeUpCars,
|
||||
pass,
|
||||
advice: makeUpAdvice(game, filling, makeUpCars),
|
||||
@@ -955,6 +964,40 @@ function consistTitle(game: Game, trayId: string): string | null {
|
||||
return trainCardTitle(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT THE TRAIN STILL WANTS — the card's demand minus what is already on it.
|
||||
*
|
||||
* The heading says "its card calls for 3 boxcar/hopper + 1 caboose" and goes on saying it whether
|
||||
* you have added none or three; the cars themselves are drawn on the Division map, in the other
|
||||
* column. So the one question a player actually has while clicking — what is left? — was the one
|
||||
* thing on screen that had to be worked out by eye, across two panels (Jesse, playtest 2026-09-16).
|
||||
*
|
||||
* BY CATEGORY, exactly as `acceptsCar` counts them, so this cannot promise a car the engine would
|
||||
* then refuse. Null when nothing is outstanding.
|
||||
*/
|
||||
function consistNeeds(game: Game, trayId: string): string | null {
|
||||
const tray = game.state.trays.get(trayId);
|
||||
if (!tray) return null;
|
||||
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!p) return null;
|
||||
|
||||
const cat = (t: string): 'coach' | 'caboose' | 'freight' =>
|
||||
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
||||
const have = (k: 'coach' | 'caboose' | 'freight'): number =>
|
||||
tray.consist.filter((c) => cat(c.type) === k).length;
|
||||
|
||||
const parts: string[] = [];
|
||||
const freight = p.consist.freight - have('freight');
|
||||
const coach = p.consist.coach - have('coach');
|
||||
const caboose = p.consist.caboose - have('caboose');
|
||||
if (freight > 0) {
|
||||
parts.push(`${freight} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
|
||||
}
|
||||
if (coach > 0) parts.push(`${coach} coach${coach > 1 ? 'es' : ''}`);
|
||||
if (caboose > 0) parts.push(`${caboose} caboose`);
|
||||
return parts.length > 0 ? parts.join(' + ') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Making up Extra X22 “Pee-Dee”: its card calls for 1 caboose — Per-diem train…"
|
||||
*
|
||||
@@ -1234,6 +1277,13 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
const n = narrate(e, {
|
||||
cardName: (id) => cardName(game.state, id),
|
||||
trainName: (id) => trainName(game.state, id),
|
||||
// Whose district a train reached is not the actor — the Mainline Phase has none — so the
|
||||
// narration resolves the name itself rather than being prefixed with one by the code below.
|
||||
// NO NUMBER IN THE FALLBACK. This is a PLAYER index, and a player is not a seat — seats rotate
|
||||
// under Employee Rotation, which is why `seatOf` exists — so "Seat 3" here would be a wrong
|
||||
// number dressed as a right one, and `session.test.ts` rightly refuses any raw index shown to
|
||||
// a person. Every caller passes real names; an unnamed player is anonymous rather than mislabelled.
|
||||
playerName: (p) => game.state.players[p]?.name ?? 'another player',
|
||||
});
|
||||
/**
|
||||
* A BLIND DRAW IS PUBLIC; WHICH CARD CAME UP IS NOT (Gitea#20 step 1).
|
||||
|
||||
+178
-11
@@ -231,7 +231,9 @@ function renderWatching(f?: Frame): void {
|
||||
return;
|
||||
}
|
||||
row.hidden = false;
|
||||
$('watching-behind').textContent = behind === 0 ? 'catching up' : `${behind} behind`;
|
||||
// A held queue stops counting down, so the counter has to say why rather than look stuck.
|
||||
$('watching-behind').textContent =
|
||||
(behind === 0 ? 'catching up' : `${behind} behind`) + (stepQueue.paused() ? ' · paused' : '');
|
||||
/**
|
||||
* THE CAPTION IS #15, and this is where that item lands rather than as a line of its own.
|
||||
*
|
||||
@@ -273,6 +275,56 @@ function renderWatching(f?: Frame): void {
|
||||
$('watching-skip').onclick = () => {
|
||||
if (stepQueue.skip()) render();
|
||||
};
|
||||
/**
|
||||
* PAUSE IS SKIP'S OPPOSITE, and shares its row for that reason.
|
||||
*
|
||||
* The label says what pressing it DOES, so it flips to Resume while held — the same rule the
|
||||
* district's three-mode control settled on, for the same reason: a label that reports state reads
|
||||
* as a status line and gets skipped over.
|
||||
*
|
||||
* `performance.now()` because that is the clock `requestAnimationFrame` hands `advance()`; mixing
|
||||
* in `Date.now()` would shift the deadline by the page's whole lifetime. Guarded because the
|
||||
* static build is loaded head-first against a DOM stub with no `performance`.
|
||||
*/
|
||||
const pauseBtn = $('watching-pause');
|
||||
pauseBtn.textContent = stepQueue.paused() ? 'Resume' : 'Pause';
|
||||
pauseBtn.onclick = () => {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
if (stepQueue.paused()) {
|
||||
stepQueue.resume(now);
|
||||
// The loop exits whenever the queue stops being busy; restart it rather than assume it survived.
|
||||
startAnimationLoop();
|
||||
} else {
|
||||
stepQueue.pause(now);
|
||||
}
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* THE TABLE AS IT IS ON SCREEN — the Day, the Stage, the clock, the phase and the Fedora.
|
||||
*
|
||||
* "WHEN PLAYER JESSE IS 5 BEHIND, IT SHOULD ALWAYS LOOK LIKE HE'S 5 BEHIND" (playtest, 2026-09-16).
|
||||
* The board, the district panel and the caption row have followed the queue since v0.8.0; the turn
|
||||
* chart never did. So a player watching three bots play out a Stage saw their cards moving under a
|
||||
* chart that had already ticked over to the next phase — the one part of the screen quietly
|
||||
* insisting the game was somewhere else. Being behind is fine and is stated plainly by the counter;
|
||||
* being behind on some of the screen and level on the rest is what makes it unreadable.
|
||||
*
|
||||
* Only while the queue is actually behind. At rest this IS the live frame, so nothing downstream
|
||||
* needs to know which of the two it was handed.
|
||||
*/
|
||||
function shownTable(f: Frame): Pick<Frame, 'day' | 'stage' | 'clock' | 'phase' | 'phaseKey' | 'superintendent'> {
|
||||
const pub = stepQueue.current();
|
||||
if (!pub || !stepQueue.busy()) return f;
|
||||
return {
|
||||
day: pub.day,
|
||||
stage: pub.stage,
|
||||
clock: pub.clock,
|
||||
phase: pub.phase,
|
||||
phaseKey: pub.phaseKey,
|
||||
superintendent: pub.superintendent,
|
||||
};
|
||||
}
|
||||
|
||||
function watchedDistrict(f: Frame): PublicDistrict | null {
|
||||
@@ -285,6 +337,10 @@ function watchedDistrict(f: Frame): PublicDistrict | null {
|
||||
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
|
||||
* which keeps the board where it was instead of snapping home mid-sequence.
|
||||
*/
|
||||
// A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`).
|
||||
if (peekPlayer !== null && peekPlayer !== f.viewer) {
|
||||
return pub.districts.find((d) => d.player === peekPlayer) ?? null;
|
||||
}
|
||||
let player: PlayerIndex | null = f.actor;
|
||||
if (stepQueue.busy()) {
|
||||
const acting = stepQueue.showing()?.player;
|
||||
@@ -430,6 +486,22 @@ let lastDay: number | null = null;
|
||||
* ones. Cleared whenever the named crew stops being one of the choices.
|
||||
*/
|
||||
let selectedCrew: string | null = null;
|
||||
|
||||
/**
|
||||
* A ONE-RENDER LOOK AT SOMEBODY ELSE'S OFFICE AREA — Jesse, playtest 2026-09-16.
|
||||
*
|
||||
* Deliberately NOT a mode. It survives exactly the render its own click causes and is cleared at the
|
||||
* end of `renderDistrict`, so the panel is back to following whoever is acting the next time
|
||||
* anything redraws. That is the whole design, in his words: *"if you want to study someone else's
|
||||
* office area, you should do it while it's your turn to move, or put the backlog on pause, then look
|
||||
* at their area, and when you're done looking, resume."*
|
||||
*
|
||||
* A sticky pin would have to answer what happens when the game moves on beneath it — and the honest
|
||||
* answers are all bad: silently snap home, or leave a player staring at a stale board with the game
|
||||
* waiting on them. Pause already means "hold everything", so it is the right lever for a long look,
|
||||
* and this stays a glance.
|
||||
*/
|
||||
let peekPlayer: PlayerIndex | null = null;
|
||||
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
|
||||
|
||||
/** The crew whose squares the board is drawing: the chosen one, or the only one there is. */
|
||||
@@ -496,11 +568,17 @@ function renderTurnChart(f: Frame): void {
|
||||
// The move on screen, not the live one, while the board is still catching up (Gitea#25).
|
||||
const { actor, replaying } = actorOnScreen(stepQueue, f.actor);
|
||||
const actorName = actor === null ? null : (f.players[actor]?.name ?? null);
|
||||
// The Day, Stage and phase of the step being shown, so the whole screen reports one moment.
|
||||
const table = shownTable(f);
|
||||
// Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a
|
||||
// chip that can never change is a chip to read past.
|
||||
const superName =
|
||||
f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null;
|
||||
$('turnchart').innerHTML = turnChartHtml(replaying ? { ...f, awaiting: null } : f, actorName, superName);
|
||||
f.players.length > 1 ? (f.players.find((p) => p.index === table.superintendent)?.name ?? null) : null;
|
||||
$('turnchart').innerHTML = turnChartHtml(
|
||||
replaying ? { ...f, ...table, awaiting: null } : f,
|
||||
actorName,
|
||||
superName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1793,17 +1871,34 @@ function renderYards(f: Frame): void {
|
||||
}
|
||||
|
||||
function renderDistrict(f: Frame): void {
|
||||
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
||||
// The phase ON SCREEN, so the panel opens for the Local Operations being WATCHED rather than for
|
||||
// one the game has already moved past — same rule as the turn chart, see `shownTable`.
|
||||
const open =
|
||||
districtMode === 'auto' ? FOCUS_PHASES.has(shownTable(f).phaseKey) : districtMode === 'open';
|
||||
const sec = $('district');
|
||||
if (open) sec.classList.remove('folded');
|
||||
else sec.classList.add('folded');
|
||||
|
||||
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
|
||||
/**
|
||||
* THE SUMMARY COUNTS THE BOARD ON SCREEN, WHICH IS NOT ALWAYS YOUR OWN.
|
||||
*
|
||||
* The panel has drawn somebody else's district since v0.8.0 — `watchedDistrict` follows whoever is
|
||||
* acting — and the heading beside this line says whose it is. The counts were read from `f`, the
|
||||
* viewer's own Frame, every time: so while a bot's turn played out, the header read "Bot 2's Office
|
||||
* Area" over a board of Bot 2's cards, with a summary counting YOUR cards, facilities and trains
|
||||
* (Jesse, playtest 2026-09-16 — "the hidden office summary line describes my district, not the one
|
||||
* being shown"). One source for the drawing and the counting, so the two cannot disagree again.
|
||||
*/
|
||||
const watched = watchedDistrict(f);
|
||||
const cells = watched?.cells ?? f.cells;
|
||||
const facilityCount = watched ? watched.facilities.length : f.facilities.length;
|
||||
|
||||
const cars = cells.reduce((n, c) => n + c.cars.length, 0);
|
||||
// Trains, not cards-with-a-crew: the Office is the one card that may hold more than one, and a
|
||||
// card-count silently read "1 crew on the board" with two trains standing at a busy Station.
|
||||
const crew = f.cells.reduce((n, c) => n + c.trains.length, 0);
|
||||
const crew = cells.reduce((n, c) => n + c.trains.length, 0);
|
||||
$('districtsummary').textContent =
|
||||
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
||||
`${cells.length} cards · ${facilityCount} facilities · ${cars} cars standing` +
|
||||
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
||||
|
||||
/**
|
||||
@@ -1835,6 +1930,35 @@ function renderDistrict(f: Frame): void {
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE BUTTON PER OPPONENT, and nothing at a table of one.
|
||||
*
|
||||
* Built with `createElement` and `textContent` rather than interpolated into `innerHTML`, because
|
||||
* a player's NAME is whatever they typed in the lobby — the one string on this page that comes
|
||||
* from another person, and so the one that must never be pasted into markup.
|
||||
*/
|
||||
const peek = $('districtpeek');
|
||||
const others = f.players.filter((p) => p.index !== f.viewer);
|
||||
peek.innerHTML = '';
|
||||
peek.hidden = others.length === 0;
|
||||
for (const p of others) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'ghost';
|
||||
b.textContent = p.name;
|
||||
b.title =
|
||||
`Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
|
||||
`redraws — press Pause first if you want to study it.`;
|
||||
b.onclick = () => {
|
||||
peekPlayer = p.index;
|
||||
render();
|
||||
};
|
||||
peek.appendChild(b);
|
||||
}
|
||||
|
||||
// SPENT. The look lasted the render it asked for; the next one follows the game again.
|
||||
peekPlayer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2203,9 +2327,32 @@ function renderActions(
|
||||
const addable = menu.makeUp.cars.length;
|
||||
html +=
|
||||
`<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` +
|
||||
/**
|
||||
* WHAT IS STILL WANTED, which is not what the heading says.
|
||||
*
|
||||
* The heading names what the card CALLS FOR and goes on saying it unchanged as cars go on, so
|
||||
* the one question a player has while clicking — what is left? — was the only thing on screen
|
||||
* that had to be worked out by eye, against a consist drawn in the other column (Jesse,
|
||||
* playtest 2026-09-16). `consistNeeds` counts by the same categories `acceptsCar` does, so it
|
||||
* can never ask for a car the engine would then refuse.
|
||||
*/
|
||||
(menu.makeUp.needs !== null
|
||||
? `<div class="makeup-needs">Still needs <b>${esc(menu.makeUp.needs)}</b></div>`
|
||||
: `<div class="makeup-needs done">Its card's consist is complete — nothing further may be added.</div>`) +
|
||||
`<div class="dim makeup-note">` +
|
||||
(addable > 0
|
||||
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.`
|
||||
? `Click a car in the Division Yard below to add it — the ${addable} kind${addable === 1 ? '' : 's'} it may take ` +
|
||||
`${addable === 1 ? 'is' : 'are'} highlighted in amber there. ` +
|
||||
/**
|
||||
* WHY YOUR TURN ENDS AFTER ONE CAR — §7's round, said where the clicking happens.
|
||||
*
|
||||
* It was written down only in the turn chart's New Train chip tooltip: hovered once, early
|
||||
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
|
||||
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
|
||||
*/
|
||||
`<b>One car each:</b> you add a single car, then the round passes to the next player — ` +
|
||||
`starting from the Superintendent and working left, coming round again until the train is ` +
|
||||
`full or the Division Yard holds nothing it can take.`
|
||||
: menu.makeUp.pass !== null
|
||||
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
||||
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
|
||||
@@ -2349,9 +2496,27 @@ function writeFile(name: string, data: string): void {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH GAME, HOW FAR IN, AND WHEN — Jesse, playtest 2026-09-16.
|
||||
*
|
||||
* The name was `station-master-day1-stage5.json` for every server game at that point in every
|
||||
* Stage, so two saves off the same table collided in the downloads folder and neither said which
|
||||
* table it came from. The join code is the one thing a player already says out loud to identify a
|
||||
* game, so it leads: `whistle-6945.day1.stage5.2026.09.16.json`.
|
||||
*
|
||||
* Lowercased because a filename is not a thing you shout, and dotted because that is the shape
|
||||
* Jesse asked for. A solitaire game has no join code and falls back to its seed, which is the
|
||||
* equivalent identity for a game nobody else is sitting at.
|
||||
*/
|
||||
function saveFileName(prefix: string, f: { day: number; stage: number }): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
const date = `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())}`;
|
||||
return `${prefix}.day${f.day}.stage${f.stage}.${date}.json`;
|
||||
}
|
||||
|
||||
async function downloadSave(): Promise<void> {
|
||||
const f = session.view();
|
||||
const stamp = `day${f.day}-stage${f.stage}`;
|
||||
/**
|
||||
* A SERVER-BACKED GAME HAS NO LOCAL SAVE TO HAND OVER, so it asks the server for its own — the seat's
|
||||
* token is the gate (`/api/save`), the same one the stream and every intent already use. The StartOS
|
||||
@@ -2363,14 +2528,16 @@ async function downloadSave(): Promise<void> {
|
||||
const res = await fetch(`/api/save?token=${encodeURIComponent(remoteToken)}`);
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { save: unknown };
|
||||
writeFile(`station-master-${stamp}.json`, JSON.stringify(body.save, null, 1));
|
||||
const code = gameCode === '' ? 'station-master' : gameCode.toLowerCase();
|
||||
writeFile(saveFileName(code, f), JSON.stringify(body.save, null, 1));
|
||||
} catch {
|
||||
// Offline, or the game has been ended under us: the button simply does nothing, which is the
|
||||
// same thing every other server call on this page does when the server is not there.
|
||||
}
|
||||
return;
|
||||
}
|
||||
writeFile(`station-master-seed${session.seed()}-${stamp}.json`, JSON.stringify(session.save(), null, 1));
|
||||
const solo = gameCode === '' ? `station-master-seed${session.seed()}` : gameCode.toLowerCase();
|
||||
writeFile(saveFileName(solo, f), JSON.stringify(session.save(), null, 1));
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
|
||||
+12
-3
@@ -777,9 +777,18 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
/* And the piles that are NOT targets step back while a discard is being aimed, so the three that
|
||||
are stand out from the Salvage Yard beside them. */
|
||||
.cardrow.aiming .handcard:not(.target){opacity:.4}
|
||||
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
|
||||
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
|
||||
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
|
||||
/* A CAR YOU MAY ADD IS AN ACTION, SO IT WEARS THE ACTION COLOUR — Jesse, playtest 2026-09-16: the
|
||||
highlight was "just a small bold and basically the same color as everything else", which is the
|
||||
whole difficulty with making the yard chip the button. #c8912f is the border colour that the
|
||||
action buttons themselves use, so a clickable car looks like every other thing inviting a click,
|
||||
rather than like a number that happens to be outlined.
|
||||
|
||||
THE LOADED/EMPTY TEXT COLOURS ARE LEFT ALONE. Green and blue-grey are what say which of the two
|
||||
numbers is which; the amber answers "may I click this", which is a different question, and
|
||||
painting over the first to answer the second would cost real information. */
|
||||
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;font-weight:700;
|
||||
outline:2px solid #c8912f;background:rgba(200,145,47,.20);box-shadow:0 0 0 2px rgba(200,145,47,.16)}
|
||||
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(200,145,47,.38)}
|
||||
/* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are
|
||||
dimmed, and a slot the die has just filled flashes once. */
|
||||
.tt{display:flex;gap:3px;flex-wrap:wrap}
|
||||
|
||||
+18
-2
@@ -124,7 +124,7 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
|
||||
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
|
||||
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||
#watching-skip,#watching-pause{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||
#watching-who{color:#c9cee0;font-weight:700}
|
||||
#presence:empty{display:none}
|
||||
/* division strip */
|
||||
@@ -184,6 +184,14 @@ button.cardact.discard{color:#d6b48a}
|
||||
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
|
||||
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
|
||||
.makeup-note{font-size:11px;margin:0 0 5px}
|
||||
/* WHAT THE TRAIN STILL WANTS. Deliberately NOT amber: amber means "you can click this" everywhere
|
||||
else on this page, and this line is the REASON for clicking rather than a thing to click — the
|
||||
cars in the Division Yard are. Brighter than the note beneath it, because it answers the question
|
||||
the player actually has while looking at it. `done` goes green like `.scheduled`: a complete
|
||||
consist is good news, not an instruction. */
|
||||
.makeup-needs{font-size:12px;margin:0 0 5px;color:#e6e9ee}
|
||||
.makeup-needs b{color:#f2e6cf}
|
||||
.makeup-needs.done{color:#bfe8cd}
|
||||
/* WHICH TRAIN AM I SWITCHING. A row of crews rather than a stacked list — they are alternatives,
|
||||
and the chosen one is the crew whose squares the board is drawing, so it wears the same violet
|
||||
"you are here" the rest of the page uses. */
|
||||
@@ -878,7 +886,7 @@ ul.blocked li{padding:2px 0}
|
||||
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
|
||||
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
|
||||
game believing he was at 7x when he was at 1x. -->
|
||||
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Yours are never delayed. Off draws every move at once, as it did before v0.8.0.">
|
||||
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Your own moves are never delayed — only theirs. Starts at 1×, which holds a switching move for one second; the slowest setting, 20×, holds it for twenty. Off draws every move at once.">
|
||||
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
|
||||
</span>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
@@ -919,6 +927,10 @@ ul.blocked li{padding:2px 0}
|
||||
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>
|
||||
<!-- PAUSE BESIDE SKIP, not instead of it: they are opposite answers to "that went past too fast".
|
||||
Skip gives up the animation to reach the game; Pause holds the board on the step being shown
|
||||
for as long as you want to look at it, and gives the step back the dwell it still had. -->
|
||||
<button id="watching-pause" class="ghost" type="button" title="Hold the board on the move being shown. Nothing is lost and nothing is hurried — press again to carry on from the same step.">Pause</button>
|
||||
<span id="watching-behind" class="wbehind"></span>
|
||||
<span id="watching-what"></span>
|
||||
</div>
|
||||
@@ -931,6 +943,10 @@ ul.blocked li{padding:2px 0}
|
||||
<h2><span id="districtwho">Your Office Area</span>
|
||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||
<!-- LOOK AT ANOTHER PLAYER'S OFFICE AREA. Filled by `renderDistrict` with one button per
|
||||
opponent, and collapsed at a table of one. The look is read-only and lasts a single
|
||||
render on purpose — see `peekPlayer` in `main.ts`. -->
|
||||
<span id="districtpeek" class="seg" role="group" aria-label="Look at another player's Office Area"></span>
|
||||
</h2>
|
||||
<div id="districtsummary" class="dim"></div>
|
||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||
|
||||
+6
-1
@@ -56,7 +56,12 @@ type Step = {
|
||||
function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
||||
const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] });
|
||||
const steps: Step[] = [];
|
||||
const ctx = { cardName: (id: string) => cardName(s, id), trainName: (id: string) => trainName(s, id) };
|
||||
const ctx = {
|
||||
cardName: (id: string) => cardName(s, id),
|
||||
trainName: (id: string) => trainName(s, id),
|
||||
// A player index is not a seat index, so the fallback names no number at all — see `game.ts`.
|
||||
playerName: (p: number) => s.players[p]?.name ?? 'another player',
|
||||
};
|
||||
const push = (events: ReturnType<typeof pump>): void => {
|
||||
const lines = events
|
||||
.filter((e) => e.type !== 'actorChanged')
|
||||
|
||||
@@ -65,6 +65,25 @@ export type StepQueue = {
|
||||
pendingLines(): number;
|
||||
/** Division nodes whose card changed in the step now on screen, for the map to flash. */
|
||||
flashing(): readonly number[];
|
||||
/**
|
||||
* HOLD THE PLAYBACK ON THE STEP NOW SHOWING — Jesse, playtest 2026-09-16, asking for a pause
|
||||
* beside Skip.
|
||||
*
|
||||
* Skip is the only control the row has had, and it is one-way and total: the way to look harder at
|
||||
* a move that just went past was to not be too slow about it. Pause is the opposite lever — the
|
||||
* board stops where it is and nothing is consumed, so a player can read the caption, look at the
|
||||
* district and then carry on from exactly that step.
|
||||
*
|
||||
* TAKES `now` BECAUSE THE QUEUE OWNS NO CLOCK (see the note at the top of this file). The dwell
|
||||
* still owing is preserved across the hold rather than being spent while nobody was watching:
|
||||
* `resume` pushes the deadline out by however long the pause lasted, so a step paused with 200ms
|
||||
* left resumes with 200ms left instead of vanishing on the next frame.
|
||||
*
|
||||
* Returns false when there is nothing to hold, or nothing being held.
|
||||
*/
|
||||
pause(now: number): boolean;
|
||||
resume(now: number): boolean;
|
||||
paused(): boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -113,6 +132,8 @@ export function createStepQueue(
|
||||
let pending: DisplayStep[] = [];
|
||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||
let dueAt: number | null = null;
|
||||
/** When the player pressed Pause, so `resume` can give the current step back the time it had. */
|
||||
let pausedAt: number | null = null;
|
||||
|
||||
/** How long this step holds the screen — zero for the viewer's own moves; see above. */
|
||||
const dwell = (step: DisplayStep): number =>
|
||||
@@ -138,6 +159,9 @@ export function createStepQueue(
|
||||
shown = frame;
|
||||
pending = [];
|
||||
dueAt = null;
|
||||
// A reconnect, an undo or a fresh deal replaces the board outright; a hold on the playback that
|
||||
// no longer exists would leave the row stuck reading "paused" with nothing behind it.
|
||||
pausedAt = null;
|
||||
// Nothing was watched arriving at this board, so nothing on it is lit.
|
||||
litPiles = [];
|
||||
flashedCards = [];
|
||||
@@ -150,6 +174,9 @@ export function createStepQueue(
|
||||
},
|
||||
|
||||
advance(now) {
|
||||
// Held. Nothing is shown and, crucially, nothing is CONSUMED — `dueAt` is left where it was
|
||||
// and `resume` moves it, so the hold costs the current step none of its dwell.
|
||||
if (pausedAt !== null) return false;
|
||||
if (pending.length === 0) {
|
||||
// The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue
|
||||
// idle the instant that step was shown, which snapped the district panel home before anyone
|
||||
@@ -183,6 +210,9 @@ export function createStepQueue(
|
||||
},
|
||||
|
||||
skip() {
|
||||
// Skipping while held is a decision to stop watching, so it also lifts the hold — otherwise the
|
||||
// board would jump to the game and then sit there paused, with a Resume button that does nothing.
|
||||
pausedAt = null;
|
||||
if (pending.length === 0) return false;
|
||||
for (const step of pending) show(step);
|
||||
pending = [];
|
||||
@@ -190,6 +220,24 @@ export function createStepQueue(
|
||||
return true;
|
||||
},
|
||||
|
||||
pause(now) {
|
||||
if (pausedAt !== null) return false;
|
||||
// Nothing on screen owes any time and nothing is queued: there is no playback to hold.
|
||||
if (pending.length === 0 && dueAt === null) return false;
|
||||
pausedAt = now;
|
||||
return true;
|
||||
},
|
||||
|
||||
resume(now) {
|
||||
if (pausedAt === null) return false;
|
||||
// Give the step on screen back exactly the dwell it was holding when the player pressed Pause.
|
||||
if (dueAt !== null) dueAt += now - pausedAt;
|
||||
pausedAt = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
paused: () => pausedAt !== null,
|
||||
|
||||
current: () => shown,
|
||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||
showing: () => last,
|
||||
|
||||
Reference in New Issue
Block a user