Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4adf149ba5 |
+104
@@ -19,6 +19,110 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.10 — 2026-09-15
|
||||
|
||||
Three reports from the first multiplayer playtest of v0.8.0.9 — one player and three bots — each traced
|
||||
to its cause before it was fixed.
|
||||
|
||||
### The Superintendent is no longer asked about a train BEHIND the one departing (Gitea#26)
|
||||
|
||||
*"Two westbound trains: X15 is further west on Mainline cards than X18. And I still get a Superintendent
|
||||
must rule... held X15 and then collision happened. X18 destroyed, but X15 fine."*
|
||||
|
||||
**Reproduced by replaying the exported save.** At move 78, X15 was highballing west out of seat 3's
|
||||
Office while X18, also westbound, was still crossing the card to its EAST — behind it. Every Office was a
|
||||
Whistle Post, so the Subdivision ran the whole railroad, and `evaluateClearance` counted every train in it
|
||||
without asking which side of the departing train it stood on. The ruling was meaningless; holding X15
|
||||
kept the Whistle Post's only A/D track full, and X18 arrived into it and was destroyed. Unasked, X15 —
|
||||
the lower number, so the first to move — would have left and freed the track.
|
||||
|
||||
§8.1 asks about a train the departing one would FOLLOW and one moving TOWARDS it, and both are ahead of
|
||||
it. So a train on a card strictly behind the departing train's own card is no longer counted, in either
|
||||
pass: a following train behind is no concern, and an oncoming one behind is moving away. A train on the
|
||||
SAME card is still counted, exactly as before — which of two trains sharing a card is in front is
|
||||
`entryConflict`'s region question. The other two rulings in the save (moves 37 and 530) were correct and
|
||||
are unchanged.
|
||||
|
||||
A test had the fault written into it: its "train ahead" of an eastbound train was the first Mainline card
|
||||
in the Division, which is west of the Office. It now stands on a card the train would follow, and three
|
||||
new cases pin the rule: a same-direction train behind is not put to the Superintendent, an opposite-
|
||||
direction train behind does not bar the departure, and a same-direction train ahead still is. The tally
|
||||
test's seed moved from 42 to 44, because the collision seed 42 was chosen for was this bug.
|
||||
|
||||
### Games in progress — MANY WILL NOT RESUME
|
||||
|
||||
**This release changes when the engine asks for a ruling**, so a save holding a ruling it would no longer
|
||||
ask for stops replaying at that move: `mainline.clearance` is refused with `NO_PENDING_DECISION`. The
|
||||
server then refuses to resume that game and logs the move it stopped at, leaving the save untouched —
|
||||
putting v0.8.0.9 back would resume it. **Measured:** 40 four-seat co-op games recorded by bots under
|
||||
v0.8.0.9 and replayed under this release — 28 stop, usually 5–40% of the way in, most at a ruling on a
|
||||
train behind (about ten rulings a game were being asked). The exported playtest game stops at move 78.
|
||||
**Jesse's call (2026-09-15): no games in progress need keeping, so the fix ships as it is,** with no
|
||||
per-game switch preserving the old rule.
|
||||
|
||||
### Six more from the same playtest (Gitea#27–#32)
|
||||
|
||||
**A Realignment says which card it converted (#27).** *"It stated Mainline card 3 converted to plains. It
|
||||
should state that the mainline card 3 curves was converted to plains."* The event carried only what the
|
||||
card became, so the line could not name what it had been; `mainlineModified` now carries `from` as well —
|
||||
events are derived by replaying a save and never stored, so widening one strands nothing — and it reads
|
||||
"Realignment: Mainline card 3, Curves, converted to Plains".
|
||||
|
||||
**And the map flashes it (#28).** *"Is it possible to flash the mainline card when it gets changed by
|
||||
realignment?"* The one play that changes the Division itself was invisible on the map of it.
|
||||
`changedDivisionCards` compares the two public boards the animation queue already holds — the same way a
|
||||
pile is lit — so the pulse lands with the step that shows the change rather than when the intent arrived,
|
||||
and it is shown to the player who made it too, unlike a lit pile. `prefers-reduced-motion` turns it off.
|
||||
|
||||
**The history stops running ahead of the board (#29).** *"Does history display immediately for all bot
|
||||
turns... is it possible to stall history so it stays in sync with the number behind?"* A push carries its
|
||||
narration and its steps together, so every line of a bot's turn was in the panel before the board had
|
||||
drawn any of it. The queue now reports how many lines belong to steps not yet shown, and the panel holds
|
||||
back exactly those, revealing each as its step goes up. Skip still shows everything at once.
|
||||
|
||||
**A ruling reads as the office's (#30).** *"If a player makes a move as a superintendent instead of as
|
||||
themselves maybe it could say 'Superintendent Player Tom'."* The three moves made by holding the office
|
||||
rather than in turn — §8.1's clearance, §11's Yard Office offer, §Q's Red Flag prompt — are prefixed that
|
||||
way. `clearanceGiven` carries no player at all (the office made it, whoever holds it), so the acting seat
|
||||
is what names it.
|
||||
|
||||
**And it names them once (#31).** *"It gives the player's name and then their player number together."*
|
||||
`record()` prefixes the acting player's NAME for every event carrying `player`, and five narration lines
|
||||
embedded `Player <index>` themselves — phase ended, actor changed, the Red Flag ruling, the extension vote
|
||||
and the Yard Office ruling. The name is the log's job; the sentence is the narration's. A test now scans a
|
||||
played game's whole log for a bare player index.
|
||||
|
||||
**A multiplayer game can be saved as a file (#32).** *"In the StartOS actions for the save game, I get a
|
||||
string I can copy. Most of the time, I want to just save it as a JSON file."* Not possible in the action
|
||||
itself: an action result member is text only — copyable, QR or masked, with nested groups — and the SDK
|
||||
has no file member. So it is done where it can be: the play page's "Save replay" button was shown only in
|
||||
solitaire, because a server-backed session has no local save to hand it. `GET /api/save?token=…` — the
|
||||
same seat token `/api/stream` and `/api/intent` use, not the administrative secret — hands a seated player
|
||||
their own game, and the button now appears in multiplayer.
|
||||
|
||||
### "Waiting on" follows the move on screen (Gitea#25)
|
||||
|
||||
*"Playing against 3 bots — waiting on always says me, even when it is someone else's turn."*
|
||||
|
||||
The server plays every bot move the moment a human's turn ends, so the LIVE game is almost always waiting
|
||||
on the human — while the screen is still replaying those bots step by step. The turn chart and the
|
||||
Division map's move marker read the live actor, and contradicted the playback row naming the bot actually
|
||||
moving. `actorOnScreen` (`web/step-queue.ts`) answers with the player of the step on screen while the
|
||||
board is catching up, and the live actor once it has; during playback the chart also leaves off a live
|
||||
"asks … ruling" note that belongs to a position the screen has not reached. Five tests pin it.
|
||||
|
||||
### The name on the Division map is readable again (Gitea#24)
|
||||
|
||||
*"When it shows your player, the font is unreadable... the bold font makes it look fuzzy, and the letters
|
||||
blur together."*
|
||||
|
||||
A name takes the class `bs-turn` while it is that player's move — and `.bs-turn` is also the Division
|
||||
map's turn ARROW, which strokes its shape 2.4px grey with no fill. The name's own rule changed only the
|
||||
fill, so every letter was outlined in grey. It showed for the viewing player almost constantly because of
|
||||
Gitea#25: the map believed it was nearly always their move. `.bs-name` now sets `stroke:none`.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.9 — 2026-09-15
|
||||
|
||||
**The developer bot, re-measured decision by decision — and an engine 2.8× faster.** Across the changes
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.0.9",
|
||||
"version": "0.8.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+72
-1
@@ -1141,7 +1141,12 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
|
||||
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
|
||||
// which is the Superintendent's call rather than an absolute bar.
|
||||
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
//
|
||||
// AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card
|
||||
// in the Division, which is west of the Office — behind the train — and still expected a ruling,
|
||||
// which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow.
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
|
||||
const node = s.division.nodes[ahead];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
@@ -1511,3 +1516,69 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
|
||||
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => {
|
||||
/**
|
||||
* REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing
|
||||
* the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it —
|
||||
* and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was
|
||||
* destroyed. Reproduced by replaying the exported save; the positions below are that situation in a
|
||||
* one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all.
|
||||
*/
|
||||
const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => {
|
||||
const s = game(7, { days: 5 });
|
||||
const area = areaOf(s, 0);
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
s.trays.set('departing', {
|
||||
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push('departing');
|
||||
const card = s.division.nodes.findIndex((n, i) =>
|
||||
n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office));
|
||||
const node = s.division.nodes[card];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('other', {
|
||||
id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w',
|
||||
position: { at: 'mainline', index: card }, movesUsed: 0,
|
||||
});
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction });
|
||||
}
|
||||
s.clock.phase = 'mainline';
|
||||
return s;
|
||||
};
|
||||
|
||||
it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => {
|
||||
const s = setup({ direction: 'west', side: 'east', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent');
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
|
||||
'the departing train did not highball with nothing ahead of it',
|
||||
);
|
||||
assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed');
|
||||
});
|
||||
|
||||
it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => {
|
||||
const s = setup({ direction: 'east', side: 'east', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
|
||||
'a train moving away behind it held the departure',
|
||||
);
|
||||
});
|
||||
|
||||
it('still puts a same-direction train AHEAD to the Superintendent', () => {
|
||||
const s = setup({ direction: 'west', side: 'west', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'),
|
||||
'a train the departing one would follow was not put to the Superintendent',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,3 +254,41 @@ describe('which piles a step moved', () => {
|
||||
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Mainline card that changed under the players (Gitea#28)', () => {
|
||||
it('names the node a Realignment converted, and nothing else', async () => {
|
||||
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
|
||||
const { REALIGNMENTS } = await import('../src/engine/content.ts');
|
||||
|
||||
const s = newState(4242);
|
||||
const before = publicSnapshot(s);
|
||||
|
||||
// Realignment converts a card to another kind (`content.ts`'s table). Applied to the state directly:
|
||||
// what is being tested is the DETECTOR, not the play that reaches it — which needs the card in hand,
|
||||
// the draw option taken and no train on the card.
|
||||
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline' && REALIGNMENTS.some((r) => r.from === n.card));
|
||||
assert.ok(at >= 0, 'no Mainline card in this Division can be realigned at all');
|
||||
const node = s.division.nodes[at];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
if (node?.kind === 'mainline') {
|
||||
node.card = REALIGNMENTS.find((r) => r.from === node.card)!.to;
|
||||
}
|
||||
const after = publicSnapshot(s);
|
||||
|
||||
assert.deepEqual(changedDivisionCards(before, after), [at], 'the realigned card was not the one reported');
|
||||
assert.deepEqual(changedDivisionCards(after, after), [], 'an unchanged Division reported a change');
|
||||
assert.deepEqual(changedDivisionCards(null, after), [], 'a first board has nothing to compare against');
|
||||
});
|
||||
|
||||
it('says nothing when only the trains on a card moved', async () => {
|
||||
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
|
||||
const s = newState(1917398);
|
||||
const before = publicSnapshot(s);
|
||||
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
const node = s.division.nodes[at];
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'tray1', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
}
|
||||
assert.deepEqual(changedDivisionCards(before, publicSnapshot(s)), [], 'a train arriving flashed the card');
|
||||
});
|
||||
});
|
||||
|
||||
+75
-1
@@ -15,7 +15,7 @@ import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||
import { publicSnapshot } from '../src/sim/view.ts';
|
||||
import { takeSteps } from '../src/sim/display-step.ts';
|
||||
import type { DisplayStep } from '../src/sim/display-step.ts';
|
||||
import { createStepQueue } from '../src/web/step-queue.ts';
|
||||
import { actorOnScreen, createStepQueue } from '../src/web/step-queue.ts';
|
||||
import { DWELL } from '../src/sim/pacing.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
@@ -278,3 +278,77 @@ describe('the step queue', () => {
|
||||
assert.equal(q.showing(), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose move the screen is showing (Gitea#25)', () => {
|
||||
const step = (player: number | null) => ({ player }) as DisplayStep;
|
||||
const queue = (behind: number, busy: boolean, showing: DisplayStep | null) => ({
|
||||
behind: () => behind,
|
||||
busy: () => busy,
|
||||
showing: () => showing,
|
||||
});
|
||||
|
||||
it('names the live actor once the board has caught up', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(0, false, step(2)), 0), { actor: 0, replaying: false });
|
||||
});
|
||||
|
||||
it('names the player of the step on screen while the board is behind — not the live actor', () => {
|
||||
// One human (seat 0) against bots: the live game already waits on seat 0 while bot 2's moves replay.
|
||||
assert.deepEqual(actorOnScreen(queue(3, true, step(2)), 0), { actor: 2, replaying: true });
|
||||
});
|
||||
|
||||
it('keeps naming the last step while it is still on screen, after the counter reaches zero', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(0, true, step(1)), 0), { actor: 1, replaying: true });
|
||||
});
|
||||
|
||||
it('names nobody for an automatic phase being shown', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(2, true, step(null)), 0), { actor: null, replaying: true });
|
||||
});
|
||||
|
||||
it('falls back to the live actor before any step has been shown', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(1, true, null), 3), { actor: 3, replaying: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the log is held back with the board, and a changed card flashes (playtest, 2026-09-15)', () => {
|
||||
it('owes exactly the lines of the steps not yet shown', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
assert.equal(q.pendingLines(), 0, 'an empty queue holds nothing back');
|
||||
|
||||
q.push(steps);
|
||||
const owed = steps.reduce((n, s) => n + s.lines.length, 0);
|
||||
assert.equal(q.pendingLines(), owed, 'every queued step still owes its lines');
|
||||
|
||||
// Drive the clock as a render loop would; the debt falls monotonically and ends at nothing.
|
||||
let now = 0;
|
||||
let last = owed;
|
||||
for (let i = 0; i < 20_000 && q.busy(); i++) {
|
||||
q.advance(now);
|
||||
const left = q.pendingLines();
|
||||
assert.ok(left <= last, 'the held-back count grew while the board caught up');
|
||||
last = left;
|
||||
now += 50;
|
||||
}
|
||||
assert.equal(q.pendingLines(), 0, 'the board caught up but lines were still withheld');
|
||||
});
|
||||
|
||||
it('skipping reveals the whole log at once', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
q.skip();
|
||||
assert.equal(q.pendingLines(), 0, 'Skip left lines withheld — the history would stay short');
|
||||
});
|
||||
|
||||
it('flashes nothing on an ordinary step', () => {
|
||||
// Realignment is rare in bot play, so this pins the quiet case: the map must not pulse at random.
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps.slice(0, 5));
|
||||
q.advance(0);
|
||||
assert.deepEqual(q.flashing(), [], 'a step that changed no Mainline card flashed one');
|
||||
});
|
||||
});
|
||||
|
||||
+9
-5
@@ -151,10 +151,14 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
|
||||
it('splits Revenue into what was earned and what was given back', () => {
|
||||
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
|
||||
// lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually
|
||||
// lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually
|
||||
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
|
||||
// being told apart rather than both sitting at zero.
|
||||
for (const seed of [1, 7, 42]) {
|
||||
//
|
||||
// It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over
|
||||
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and
|
||||
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion.
|
||||
for (const seed of [1, 7, 44]) {
|
||||
const { state } = playKeepingEvents(seed);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.equal(
|
||||
@@ -163,10 +167,10 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
|
||||
);
|
||||
}
|
||||
const { state } = playKeepingEvents(42);
|
||||
const { state } = playKeepingEvents(44);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted');
|
||||
assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted');
|
||||
});
|
||||
|
||||
it('records a Circus set-up as the one-off it is, not as a streak', () => {
|
||||
|
||||
@@ -309,3 +309,71 @@ describe('steps reach a seated player — TODO #13', () => {
|
||||
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
||||
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
||||
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||
const { legalActions } = await import('../src/engine/legal.ts');
|
||||
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
}
|
||||
|
||||
assert.ok(game.log.length > 50, 'the game barely ran, so this proved little');
|
||||
for (const line of game.log) {
|
||||
// `record()` prefixes the acting player's NAME; a narration that also named them read
|
||||
// "Player Alice player 0 finished Local Operations" (playtest, 2026-09-15).
|
||||
assert.doesNotMatch(
|
||||
line.text,
|
||||
/\bplayer \d+\b/i,
|
||||
`a line still carries a bare player index: ${line.text}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('attributes a clearance ruling to the office, not to the seat\'s own turn', async () => {
|
||||
const { newMultiplayerGame, drain, submit } = await import('../src/web/game.ts');
|
||||
const { areaOf } = await import('../src/engine/apply.ts');
|
||||
|
||||
const game = newMultiplayerGame(7, config, ['Alice', 'Bob', 'Carol']);
|
||||
const s = game.state;
|
||||
const area = areaOf(s, 0);
|
||||
|
||||
// A westbound train at seat 0's Office, and another westbound AHEAD of it — west of the Office —
|
||||
// which is §8.1's fourth condition and the Superintendent's to rule on (see Gitea#26).
|
||||
s.trays.set('departing', {
|
||||
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
} as never);
|
||||
area.adOccupancy.push('departing');
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const card = s.division.nodes.findIndex((n, i) => i < office && n.kind === 'mainline');
|
||||
const node = s.division.nodes[card];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
|
||||
} as never);
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
drain(game);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'clearance', 'no ruling was called for, so nothing was tested');
|
||||
|
||||
const before = game.log.length;
|
||||
assert.ok(submit(game, { type: 'mainline.clearance', allow: false }, s.clock.superintendent));
|
||||
const said = game.log.slice(before).map((l) => l.text);
|
||||
assert.ok(
|
||||
said.some((text) => text.startsWith('Superintendent Player ')),
|
||||
`a ruling did not read as the office's: ${said.join(' | ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user