v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file

From the first two multiplayer playtests of v0.8.0.9, each traced before fixing.

The engine:

- A train on a card BEHIND the one departing no longer triggers a clearance
  ruling or an opposite-direction bar (#26). Reproduced from the exported
  save: X15 was held over X18 behind it, and X18 then collided into the full
  Whistle Post. Games in progress holding a ruling the engine no longer asks
  for will not resume (28 of 40 recorded four-seat games); shipped as is at
  Jesse's call.
- `mainlineModified` carries the card's previous kind, so the log can say
  what a Realignment converted (#27).

The screen:

- The turn chart and the Division map name the player whose move is on
  screen while bot turns replay, not the live actor (#25).
- The owning player's name is no longer outlined by the turn arrow's stroke,
  which made it unreadable (#24).
- A Mainline card flashes on the map when a Realignment changes it (#28).
- The history is held back with the board and revealed step by step, instead
  of arriving whole while the board is still catching up (#29).
- A ruling made by holding the office reads "Superintendent Player X" (#30),
  and no line names a player twice (#31).
- A seated player can download their own game as a save file: the play
  page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS
  action cannot do this — an action result is text only.

Closes #24
Closes #25
Closes #26
Closes #27
Closes #28
Closes #29
Closes #30
Closes #31
Closes #32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
This commit is contained in:
Jesse.Markowitz
2026-09-15 22:44:20 -04:00
co-authored by Claude Opus 5
parent 76c6e103b3
commit 4adf149ba5
17 changed files with 607 additions and 41 deletions
+24 -2
View File
@@ -42,6 +42,12 @@ export type DivisionRoster = {
actor: number | null;
/** The player this map is being drawn for. */
viewer: number;
/**
* Division nodes to flash — a Mainline card that has just become a different card (Realignment).
* Playtest, 2026-09-15: the log said a card had been converted and the map said nothing, so the one
* play that changes the Division itself was invisible on the map of it.
*/
flash?: readonly number[];
};
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
@@ -134,6 +140,8 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
}[];
cap: number | null;
tip: string;
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
flash?: boolean;
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
seat: number | null;
/** Set on an Office cell when a roster was supplied: whose district this is. */
@@ -168,7 +176,11 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
cells.push({ ...c, x: 0, y: 0 });
};
// The node's own index, so a cell can be matched against `roster.flash`. `continue` below skips the
// rest of the body, never this.
let nodeIndex = -1;
for (const n of nodes) {
nodeIndex++;
if (n.kind === 'office') {
const cap = n.capacity;
const ad = n.trains.flat();
@@ -261,6 +273,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
push({
kind: dp ? 'dp' : 'ml',
label: n.label,
...(roster?.flash?.includes(nodeIndex) ? { flash: true } : {}),
sub: n.capacity === null
? 'no limit — trains queue'
: [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '),
@@ -356,7 +369,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
cells.forEach((c) => {
const full = c.cap !== null && c.trains.length >= c.cap;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}${c.flash ? ' bs-changed' : ''}" data-tip="${esc(c.tip)}">`;
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
/**
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
@@ -1248,6 +1261,12 @@ export const BOARD_CSS = `
and leave at the other, and a seated layout must not be read as a ring. */
.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round}
.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em}
/* A card that has just BECOME a different card (Realignment). The same amber the rest of the page
spends on "it is happening here", pulsing only while the step that did it is on screen — so the
change is seen on the map rather than only read in the log. */
.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}
@@ -1329,7 +1348,10 @@ export const BOARD_CSS = `
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
/* stroke:none (Gitea#24). A name takes the class \`bs-turn\` while it is that player's move, and \`.bs-turn\` is
also the turn ARROW's rule, which strokes its shape 2.4px grey. Declared after it, this keeps that
outline off the letters, which it smeared into an unreadable blur. */
.bs-name{fill:#e6e9ee;stroke:none;font:600 11px ui-monospace,monospace}
.bs-name.bs-you{fill:#5aa9e6}
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
seconds and which railroad is yours never does.