v0.8.0.13 — the board on screen is the board you can act on

Nine reports from the Day 1-2 playtest of v0.8.0.12. One moved a car, one was a
rule working correctly with nothing on screen to say so, and the rest are things
the table could not see.

The real bug: the Division Yard chips stayed lit and clickable while the board
was catching up. `renderActions` puts the action list away while the queue is
behind — a move offered against a position that has already moved on is a move
made blind — but the make-up wiring sat outside that guard. A chip was clicked
during a bot's make-up, a coach left the yard, and the train ended up with three
cars: a real intent submitted against a board several moves stale. The chips now
follow the queue like every other control, and the yard COUNTS are drawn from the
shown board rather than the live game — they were the one panel still reporting a
future the player had not been shown.

The Office Area picker had a button per opponent and none for yourself, so the
one player who could not reach their own district was the player waiting on
everybody else. Your own seat is in the row now, and the row is ordered by SEAT,
west to east as the Division map draws it, rather than by join order — sorted
from the Frame's own `seat` on every render, so it rotates with Employee Rotation
instead of having to be told.

§5's handover of the Fedora rode on `actorChanged`, which is turn bookkeeping and
which `record()` drops as noise, so the one moment it carried that a player needed
went past in silence. It is its own event now, narrated and announced. The phase
keeps its name: the Supervisor Shift refreshes every Laborer and Porter EVERY
Stage and the Fedora moves only every third.

A collision now names whose Office it was and who paid the 5 Revenue, which rode
in a separate `revenueChanged`; a Mainline collision is phrased differently
because §10 makes it the Superintendent's.

Passengers, reported as a bug and ruled not one after replaying the save: the
Depot's capacity and modifiers were fine, and §6.3 stocking wants a LOADED coach
out of the Division Yard, which held none while six sat in Classification. The
shortage stays — running out is part of the game, the same ruling Gitea#2 got —
but the blocked panel says so now instead of the action being silently absent.

Smaller: "working left" is "working eastward" in the make-up panel and the New
Train tip, because the map runs west to east and the table does not; the history
panel keeps 90 lines instead of 60 in the same 230px box.

`git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is NOT empty this time:
`events.ts` declares `superintendentChanged` and `advance.ts` emits it. Both are
additive — `check()`, `legal.ts` and every predicate are untouched, and events are
derived by replaying a save rather than stored — so no once-legal move became
illegal and games in progress resume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
This commit is contained in:
Jesse.Markowitz
2026-09-17 04:43:59 -04:00
co-authored by Claude Opus 5
parent 7c9ef8797d
commit ad277fb994
12 changed files with 340 additions and 29 deletions
+66 -3
View File
@@ -127,6 +127,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
.join(' → ')}`,
};
case 'superintendentChanged':
/**
* The Fedora is the only thing in the game that changes hands on a clock rather than because
* somebody did something, so it is the one handover nobody at the table watches happen.
*/
return {
tone: 'clock',
text:
`SUPERINTENDENT — the Fedora passes to ${ctx.playerName?.(e.player) ?? 'the next player'} ` +
`at the end of Stage ${e.stage}. They rule on clearances, take the Yard Office and Red Flag ` +
`questions, and every round that goes round the table now starts with them.`,
};
case 'phaseBegan':
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
// the log read as one undifferentiated column and you could not see where a phase began.
@@ -435,12 +447,31 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
const wrecked = e.trains
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
.join(' and ');
/**
* WHOSE OFFICE, AND WHO PAYS (playtest, 2026-09-16: "it doesn't say who suffers the revenue
* loss… we need to know which player received the penalty and why").
*
* `player` is the seat at fault, and for everything that happens inside a district that is the
* district's owner — so it names the place as well as the payer. A Mainline collision is the
* Superintendent's by rule (§10), which is a different sentence: it happened on open road, not
* in anybody's Office. The 5 points ride in a separate `revenueChanged`, which is why the line
* never mentioned them; a player should not have to add two log entries together.
*/
const who = ctx.playerName?.(e.player) ?? null;
const mainline = e.where === 'the Mainline';
const place = who === null || mainline ? e.where : `${who}'s ${e.where.replace(/^the /, '')}`;
const cost =
who === null
? ' 5 Revenue is lost.'
: mainline
? ` ${who} loses 5 Revenue: §10 makes a Mainline collision the Superintendent's fault.`
: ` ${who} loses 5 Revenue — it happened in their district.`;
return {
tone: 'bad',
text:
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
`Yard, all other cars to the Classification Yard. A Timetabled train card returns ` +
`to its slot and runs again next Day; an Extra is gone for good.`,
`COLLISION at ${place} — ${wrecked} destroyed: ${why}.${cost} Engines and cabooses go back ` +
`to the Division Yard, all other cars to the Classification Yard. A Timetabled train card ` +
`returns to its slot and runs again next Day; an Extra is gone for good.`,
};
}
@@ -660,6 +691,38 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
* that actually refused rather than a second guess at it.
*/
if (f.kind === 'passenger') {
/**
* NOBODY TO PUT ON THE PLATFORM, AND NO WAY TO SEE WHY (playtest, 2026-09-16).
*
* "He would like to have two passengers waiting in his depot… but the only option he had was
* bringing a tank load into the refinery." His Depot had a Restaurant and a Hotel beside it and
* three outbound slots — capacity was never the problem. §6.3 stocking takes a LOADED car of
* the facility's type out of the Division Yard, and there was not a loaded coach in it: six
* were sitting in Classification, which §2.2 returns only when the Division Yard runs bare.
*
* Jesse's ruling (2026-09-16) is the same one Gitea#2 got: the shortage stays, because running
* out is part of the game. What must not stay is the silence — an action with no legal target
* is simply absent from the menu, so the player is left to guess whether they misunderstood the
* rules or the game is broken.
*/
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) {
const loadedCoaches = s.yards.divisionYard.filter((c) => c.type === 'coach' && c.loaded).length;
if (loadedCoaches === 0) {
const waiting = s.yards.classificationYard.filter((c) => c.type === 'coach' && c.loaded).length;
out.push({
where: `${name} ${key}`,
why:
`room for ${f.capacity.outbound - f.outboundBox.length} more passenger` +
`${f.capacity.outbound - f.outboundBox.length === 1 ? '' : 's'} to wait, but no loaded ` +
`coach in the Division Yard for the Freight Agent to bring over` +
(waiting > 0
? ` — ${waiting} ${waiting === 1 ? 'is' : 'are'} in the Classification Yard, which comes ` +
'back only when the Division Yard is bare'
: ''),
severity: 'waiting',
});
}
}
if (portersLeft(f) > 0) {
const coord = uncoordKey(key);
// Passengers standing on the platform with nothing carrying them away.
+1 -1
View File
@@ -55,7 +55,7 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
// Says WHO builds, which is the question this phase actually raises at a table: the round
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
tip: 'Timetabled trains for this Stage are built: starting with the Superintendent and working left, each player adds ONE car, going round again until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
tip: 'Timetabled trains for this Stage are built: each player adds ONE car at a time, starting with the Superintendent and working eastward, repeating until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
// a locomotive being made up
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
},