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
+43 -1
View File
@@ -19,7 +19,7 @@
import type { PublicFrame } from '../sim/view.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import { applyPublicDelta, changedPiles } from '../sim/public-delta.ts';
import { applyPublicDelta, changedDivisionCards, changedPiles } from '../sim/public-delta.ts';
import type { PileKey } from '../sim/public-delta.ts';
import { dwellForStep } from '../sim/pacing.ts';
@@ -53,8 +53,43 @@ export type StepQueue = {
lit(): readonly PileKey[];
/** True while there is anything left to show. */
busy(): boolean;
/**
* How many narrated lines belong to steps NOT yet shown.
*
* The log and the board are two different moments while the queue is behind: a push carries its
* narration and its steps together, so every line of a bot's turn is in the history panel before the
* board has drawn a single move of it (playtest, 2026-09-15: *"is it possible to stall history so it
* stays in sync with the number behind?"*). Those lines are the TAIL of the log — they arrived last —
* so the caller holds back exactly this many and reveals each as its step goes up.
*/
pendingLines(): number;
/** Division nodes whose card changed in the step now on screen, for the map to flash. */
flashing(): readonly number[];
};
/**
* WHOSE MOVE THE SCREEN IS SHOWING (Gitea#25).
*
* The game and the board on screen are two different moments. The server plays every bot move the
* instant a human's turn ends (`driveBots`), so the LIVE game is nearly always waiting on the human —
* while this queue is still replaying the bots, step by step. The turn chart and the Division map's
* move marker read the live actor, so a table of one person and three bots said "waiting on" that
* person throughout, against a playback row naming the bot actually moving.
*
* While the queue is behind or still showing a step, the answer is that step's player — `null` for an
* automatic phase, which is "the Division is running itself". Otherwise it is the live actor, and
* `replaying` is false so a caller can keep live-only detail, such as a ruling the game is waiting on,
* off a screen that has not caught up with it yet.
*/
export function actorOnScreen(
queue: Pick<StepQueue, 'behind' | 'busy' | 'showing'>,
live: number | null,
): { actor: number | null; replaying: boolean } {
if (queue.behind() === 0 && !queue.busy()) return { actor: live, replaying: false };
const shown = queue.showing();
return shown === null ? { actor: live, replaying: false } : { actor: shown.player, replaying: true };
}
/**
* `pace` is read on every step rather than captured, so changing the setting takes effect at once.
*
@@ -74,6 +109,7 @@ export function createStepQueue(
let shown: PublicFrame | null = null;
let last: DisplayStep | null = null;
let litPiles: readonly PileKey[] = [];
let flashedCards: readonly number[] = [];
let pending: DisplayStep[] = [];
/** When the step now on screen is due to give way. Null when nothing is waiting. */
let dueAt: number | null = null;
@@ -92,6 +128,9 @@ export function createStepQueue(
* that gives your own steps no dwell — the display is for watching everybody else.
*/
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
// A Realignment changes the Division under everyone, so it is flashed for the player who did it
// too — unlike a pile, which only tells the drawer what they already know.
flashedCards = changedDivisionCards(before, shown);
};
return {
@@ -101,6 +140,7 @@ export function createStepQueue(
dueAt = null;
// Nothing was watched arriving at this board, so nothing on it is lit.
litPiles = [];
flashedCards = [];
// `last` deliberately survives: a reconnect should not blank the caption line, and the
// sentence describing the most recent action is still true.
},
@@ -165,6 +205,8 @@ export function createStepQueue(
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
* "there is more to come, or what is up has not had its moment yet".
*/
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
flashing: () => flashedCards,
busy: () => pending.length > 0 || dueAt !== null,
};
}