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:
co-authored by
Claude Opus 5
parent
76c6e103b3
commit
4adf149ba5
+15
-2
@@ -1255,9 +1255,22 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
|
||||
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
||||
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
||||
/**
|
||||
* A RULING IS MADE AS SUPERINTENDENT, NOT AS YOURSELF (playtest, 2026-09-15: "maybe it could say
|
||||
* 'Superintendent Player Tom', so it's clear they got the move because they're Superintendent").
|
||||
* These three are the only moves a player makes out of turn, by holding the office: §8.1's
|
||||
* clearance, §11's Yard Office offer and §Q's Red Flag prompt. `clearanceGiven` carries no
|
||||
* player at all — the office made it, whoever holds it — so the actor is what names it.
|
||||
*/
|
||||
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
|
||||
const ruling = RULINGS.includes(e.type) && who !== null;
|
||||
const mine = who !== null && 'player' in e;
|
||||
const text = mine ? `Player ${who} ${uncapitalise(said)}` : said;
|
||||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||||
const text = ruling
|
||||
? `Superintendent Player ${who} ${uncapitalise(said)}`
|
||||
: mine
|
||||
? `Player ${who} ${uncapitalise(said)}`
|
||||
: said;
|
||||
game.log.push({ text, tone: mine || ruling ? 'act' : n.tone });
|
||||
|
||||
}
|
||||
game.cues.push(...cuesFor(events));
|
||||
|
||||
+55
-14
@@ -25,7 +25,7 @@ import type { LocalSession, Session } from './session.ts';
|
||||
import { createLocalSession, createRemoteSession } from './session.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import type { PublicDistrict } from '../sim/view.ts';
|
||||
import { createStepQueue } from './step-queue.ts';
|
||||
import { actorOnScreen, createStepQueue } from './step-queue.ts';
|
||||
import { PACE_LEVELS } from '../sim/pacing.ts';
|
||||
import { notice, prefillCode, runLobby } from './lobby.ts';
|
||||
import type { LobbyReady } from './lobby.ts';
|
||||
@@ -493,12 +493,14 @@ function piecePreview(links: string[], label: string): string {
|
||||
* are in the Day the same way and with the same violet highlight. It used to live here alone.
|
||||
*/
|
||||
function renderTurnChart(f: Frame): void {
|
||||
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null);
|
||||
// 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);
|
||||
// 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(f, actorName, superName);
|
||||
$('turnchart').innerHTML = turnChartHtml(replaying ? { ...f, awaiting: null } : f, actorName, superName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1000,6 +1002,7 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
|
||||
// banner (`#presence`), and it holds a beat so the game visibly begins.
|
||||
openHandoff();
|
||||
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
|
||||
remoteToken = ready.token;
|
||||
rejoiningRemote = rejoining;
|
||||
applyCapabilities();
|
||||
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
|
||||
@@ -1167,6 +1170,14 @@ function start(): void {
|
||||
* the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the
|
||||
* question "why not?" every turn, and the honest answer is that the control does not belong there.
|
||||
*/
|
||||
/**
|
||||
* The session token of a server-backed game, or null in solitaire (playtest, 2026-09-15: "most of the
|
||||
* time, I want to go ahead and just save it as a JSON file"). It is the seat's proof of identity to
|
||||
* `/api/save`, exactly as it is to `/api/stream` — a save is the seed and the moves, every one of which
|
||||
* is already on this player's screen.
|
||||
*/
|
||||
let remoteToken: string | null = null;
|
||||
|
||||
function applyCapabilities(): void {
|
||||
const c = session.capabilities;
|
||||
const hide = (id: string, on: boolean): void => {
|
||||
@@ -1174,7 +1185,7 @@ function applyCapabilities(): void {
|
||||
if (el) el.hidden = !on;
|
||||
};
|
||||
hide('undo', c.undo);
|
||||
hide('savefile', c.saveLocal);
|
||||
hide('savefile', c.saveLocal || remoteToken !== null);
|
||||
hide('newgame', c.newGame);
|
||||
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
|
||||
// page offers — same reasoning as `newgame`, and the same capability answers both.
|
||||
@@ -1347,8 +1358,10 @@ function render(): void {
|
||||
// -- division
|
||||
$('division').innerHTML = divisionSvg(f.division, {
|
||||
players: f.players,
|
||||
actor: f.actor,
|
||||
actor: actorOnScreen(stepQueue, f.actor).actor,
|
||||
viewer: f.viewer,
|
||||
// A Realignment changes the Division under everyone; flashed only while the step that did it is up.
|
||||
flash: stepQueue.busy() ? stepQueue.flashing() : [],
|
||||
});
|
||||
renderSeatingChain(f);
|
||||
applyZoom($('division'));
|
||||
@@ -1633,7 +1646,16 @@ function render(): void {
|
||||
|
||||
// -- log
|
||||
const log = $('log');
|
||||
const allLines = session.lines();
|
||||
/**
|
||||
* THE LOG IS HELD BACK WITH THE BOARD (playtest, 2026-09-15).
|
||||
*
|
||||
* A push carries its narration and its display steps together, so every line of a bot's turn was in
|
||||
* this panel before the board had drawn a single move of it — the history ran ahead of the "N behind"
|
||||
* counter it is meant to match. Those lines are the TAIL of the log, so exactly the ones belonging to
|
||||
* steps still queued are withheld, and each appears as its step goes up.
|
||||
*/
|
||||
const heldBack = stepQueue.pendingLines();
|
||||
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
|
||||
const shownLines = allLines.slice(-60);
|
||||
/**
|
||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||
@@ -2317,21 +2339,40 @@ function renderActions(
|
||||
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
|
||||
* where a rendered page would have been megabytes.
|
||||
*/
|
||||
function downloadSave(): void {
|
||||
// The button this fires from is hidden by `applyCapabilities()` for any session that cannot save
|
||||
// (`#savefile`), but nothing stops this function being called directly, so the guard is repeated
|
||||
// here rather than only trusted to the DOM.
|
||||
if (!isLocal(session)) return;
|
||||
const data = JSON.stringify(session.save(), null, 1);
|
||||
function writeFile(name: string, data: string): void {
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
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
|
||||
* Manage Game action cannot do this: an action result is text only, with no file member in the SDK.
|
||||
*/
|
||||
if (!isLocal(session)) {
|
||||
if (remoteToken === null) return;
|
||||
try {
|
||||
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));
|
||||
} 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));
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!isLocal(session)) return;
|
||||
try {
|
||||
@@ -2367,7 +2408,7 @@ document.head.appendChild(pageStyle);
|
||||
installTooltips();
|
||||
|
||||
const saveBtn = document.getElementById('savefile');
|
||||
if (saveBtn) saveBtn.onclick = downloadSave;
|
||||
if (saveBtn) saveBtn.onclick = () => void downloadSave();
|
||||
|
||||
/**
|
||||
* Forget the saved game and deal a fresh one.
|
||||
|
||||
+43
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user