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:
co-authored by
Claude Opus 5
parent
7c9ef8797d
commit
ad277fb994
+66
-3
@@ -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.
|
||||
|
||||
@@ -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"/>',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user