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
@@ -94,6 +94,18 @@ function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number {
|
||||
|
||||
const step = (d: Direction): number => (d === 'east' ? 1 : -1);
|
||||
|
||||
/** The Division node a train is standing on — its Office, its Mainline card, or its Division Point. */
|
||||
function nodeIndexOfTray(s: GameState, tray: CrewTray): number | null {
|
||||
const at = tray.position;
|
||||
if (at.at === 'grid') return nodeIndexOfOffice(s, at.seat);
|
||||
if (at.at === 'mainline') return at.index;
|
||||
if (at.at === 'divisionPoint') {
|
||||
const side = at.side;
|
||||
return s.division.nodes.findIndex((n) => n.kind === 'divisionPoint' && n.side === side);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// advance
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1067,10 +1079,27 @@ function evaluateClearance(
|
||||
* constrained. Each Office upgrade to a Control Point splits one in two and buys capacity.
|
||||
*/
|
||||
const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex];
|
||||
/**
|
||||
* ONLY WHAT IS AHEAD (Gitea#26). §8.1 asks about a train the considered train would FOLLOW, and one
|
||||
* moving TOWARDS it — both of which are ahead of it. A Subdivision runs the length of every Whistle
|
||||
* Post between two Control Points, so it can hold a train BEHIND the one departing: in the reported
|
||||
* game X15 highballed west from an Office while X18, also westbound, was still crossing the card to its
|
||||
* east. Counting X18 put a meaningless ruling to the Superintendent; holding X15 kept the Whistle Post's
|
||||
* one A/D track full, and X18 arrived into it and was destroyed. A train behind and moving away is no
|
||||
* threat at all.
|
||||
*
|
||||
* "Behind" is strictly behind the card the departing train stands on. A train on that same card is still
|
||||
* counted, exactly as before: which of two trains sharing a card is in front is `entryConflict`'s region
|
||||
* question, and this is not the place to answer it.
|
||||
*/
|
||||
const from = nodeIndexOfTray(s, tray);
|
||||
const behind = (onCard: number): boolean =>
|
||||
from !== null && from >= 0 && (tray.direction === 'east' ? onCard < from : onCard > from);
|
||||
const occupants: { tray: TrayId; onCard: number }[] = [];
|
||||
for (const i of subdivision) {
|
||||
const n = s.division.nodes[i];
|
||||
if (!n || n.kind !== 'mainline') continue;
|
||||
if (behind(i)) continue;
|
||||
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -1889,7 +1889,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
? REALIGNMENTS.find((r) => r.from === node.card)?.to
|
||||
: undefined;
|
||||
return [
|
||||
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
|
||||
{
|
||||
type: 'mainlineModified',
|
||||
player,
|
||||
cardId: i.cardId,
|
||||
node: i.node,
|
||||
key,
|
||||
...(node?.kind === 'mainline' ? { from: node.card } : {}),
|
||||
...(became ? { became } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,13 @@ export type GameEvent =
|
||||
| { type: 'deckReshuffled'; order: CardId[]; rngState: number }
|
||||
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
|
||||
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
||||
/**
|
||||
* `from` is the card's kind BEFORE the change, carried so the log can say what was realigned
|
||||
* rather than only what it turned into (playtest, 2026-09-15: "it should state that the mainline
|
||||
* card 3 curves was converted to plains"). Events are derived by replaying a save, never stored,
|
||||
* so widening one strands nothing on disk.
|
||||
*/
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; from?: string; became?: string }
|
||||
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
|
||||
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
|
||||
|
||||
@@ -656,6 +656,27 @@ export function startServer(opts: ServerOptions): void {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and
|
||||
* just save it as a JSON file in my Downloads folder").
|
||||
*
|
||||
* The administrative export at `/api/games/<id>/save` is gated on the admin secret, which a player
|
||||
* does not have and should not need: a save is the seed and the moves, and every one of those moves
|
||||
* is already on this player's screen. So the seat's own session token is the gate, exactly as it is
|
||||
* for `/api/stream` and `/api/intent` — it proves which game and which chair, and nothing else is
|
||||
* disclosed. The page turns the JSON into a file (`main.ts`'s `downloadSave`).
|
||||
*/
|
||||
if (url.pathname === '/api/save' && req.method === 'GET') {
|
||||
const ps = sessions.get(url.searchParams.get('token') ?? '');
|
||||
const session = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { gameId: ps.gameId, save: session.exportSave() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/stream' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
|
||||
+24
-2
@@ -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.
|
||||
|
||||
+18
-12
@@ -15,7 +15,7 @@
|
||||
* panel cannot drift from the rules.
|
||||
*/
|
||||
|
||||
import { MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
|
||||
import { MAINLINE_PROFILES, MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
|
||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
||||
@@ -134,7 +134,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
case 'actorChanged':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`,
|
||||
text: e.player === null ? 'No player acts — automatic phase' : 'to act',
|
||||
};
|
||||
|
||||
// -- local operations
|
||||
@@ -211,13 +211,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
? `Played ${card(e.cardId)} onto ${at(e.placement)}`
|
||||
: `Played ${card(e.cardId)}`,
|
||||
};
|
||||
case 'mainlineModified':
|
||||
case 'mainlineModified': {
|
||||
// WHICH CARD, NOT JUST WHICH WAY IT WENT. "Mainline card 3 converted to plains" left the reader
|
||||
// to remember what card 3 had been (playtest, 2026-09-15), and the card it WAS is the half that
|
||||
// says what the play was worth.
|
||||
const kindName = (k: string | undefined): string =>
|
||||
MAINLINE_PROFILES.find((m) => m.kind === k)?.name ?? k ?? 'that card';
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.became
|
||||
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
|
||||
: `Played ${e.key} on Mainline card ${e.node}`,
|
||||
? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}`
|
||||
: `Played ${e.key} on Mainline card ${e.node}${e.from ? ` (${kindName(e.from)})` : ''}`,
|
||||
};
|
||||
}
|
||||
case 'redFlagSpent':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -225,8 +231,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
case 'redFlagRuled':
|
||||
return e.flag
|
||||
? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` }
|
||||
: { tone: 'plain', text: `Player ${e.player} waved the train through` };
|
||||
? { tone: 'plain', text: 'Flagged the approaching train' }
|
||||
: { tone: 'plain', text: 'Waved the train through' };
|
||||
case 'redFlagsSet':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -496,13 +502,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
|
||||
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
|
||||
case 'phaseEnded':
|
||||
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
|
||||
return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` };
|
||||
|
||||
// -- §3.3, extended play (Gitea#11)
|
||||
case 'extensionVoted':
|
||||
return e.agree
|
||||
? { tone: 'plain', text: `Player ${e.player} would play one more Day` }
|
||||
: { tone: 'plain', text: `Player ${e.player} called time — the game ends here` };
|
||||
? { tone: 'plain', text: 'Would play one more Day' }
|
||||
: { tone: 'plain', text: 'Called time — the game ends here' };
|
||||
case 'dayExtended':
|
||||
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
|
||||
case 'playConcluded':
|
||||
@@ -511,8 +517,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
// -- §11, the Yard Office (Gitea#5)
|
||||
case 'yardOfficeRuled':
|
||||
return e.take
|
||||
? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` }
|
||||
: { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` };
|
||||
? { tone: 'plain', text: `Sent ${train(e.trainId)} into the Yard Office` }
|
||||
: { tone: 'plain', text: `Kept ${train(e.trainId)} at the Train Order Office` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,25 @@ export type PileKey = 'home' | 'salvage' | `dept${number}`;
|
||||
* | `card.play` | `salvage`, or nothing here when it lands on the board |
|
||||
* | switching, new trains | nothing here — those show on the board itself |
|
||||
*/
|
||||
/**
|
||||
* Mainline cards that became a different card between two public boards — a Realignment, which is the
|
||||
* one play that changes the Division itself.
|
||||
*
|
||||
* Playtest, 2026-09-15: *"is it possible to flash the mainline card when it gets changed by realignment?
|
||||
* This would be more obvious to see what's happening on the map."* Detected the same way `changedPiles`
|
||||
* detects a pile moving — by comparing the two boards the queue already holds — rather than by reading
|
||||
* the event, so the flash lands with the step that shows it and not when the intent arrived.
|
||||
*/
|
||||
export function changedDivisionCards(before: PublicFrame | null, after: PublicFrame): number[] {
|
||||
if (before === null) return [];
|
||||
const out: number[] = [];
|
||||
after.division.forEach((node, i) => {
|
||||
const was = before.division[i];
|
||||
if (was && was.kind === 'ml' && node.kind === 'ml' && was.label !== node.label) out.push(i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
|
||||
if (before === null) return [];
|
||||
const out: PileKey[] = [];
|
||||
|
||||
+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