v0.7.1 — a caboose is not a load, a Day that says it ended, and a train you may throw away

Four issues off the Gitea tracker, all of them things a player saw at the board. Reasoning for
every item, and what was verified how: CHANGELOG.md.

- Gitea#8: X22 Pee-Dee refused every caboose, including the one it was made up with, so setting it
  out stranded the train. All six cabooses are minted loaded because §2.2's "coloured is loaded,
  white is empty" doubles as a piece count in the supply table; one read of the flag took that
  literally. A caboose carries the crew, not freight, so it is never a load.
- Gitea#10: a Day turns over inside the phases that run themselves, so it passes between one click
  and the next — and both transient signals fade before a player reading the board notices. A modal
  stops and waits, carrying the standings, the Days left and the combined target. Suppressed on the
  first frame, on Undo stepping back across a rollover, and on the Day the game ends.
- Gitea#9, which SUPERSEDES Gitea#6 from three days ago: a Timetabled train may be tossed face-up
  to a Department slot, where a rival may pick it up — the second half of the ruling needed no code,
  since that is where every discard already goes. An Extra still may not. A New Game setting on this
  line (discardTimetabled, on by default), the plain rule on the 0.4.9 line.
- Gitea#2 is not an engine bug: the rules are implemented exactly, and running the coach pool dry is
  Jesse's ruling to keep — "part of the strategy". What was wrong is that the game said nothing. A
  blocked platform now gives its reason, from the engine's own predicate, including how many coaches
  are stranded in Classification and what brings them back.

The same four ship as v0.4.9g on the playtest line.

Closes #2
Closes #8
Closes #9
Closes #10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
This commit is contained in:
Jesse.Markowitz
2026-08-25 10:46:22 -04:00
co-authored by Claude Opus 5
parent 603d38602c
commit 441447648d
17 changed files with 977 additions and 115 deletions
+61 -11
View File
@@ -10,7 +10,7 @@ import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import type { Frame } from '../sim/view.ts';
import { seatLabel } from '../sim/view.ts';
import type { Menu, Save } from './game.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts';
import {
@@ -158,6 +158,13 @@ let zoom = settings.zoom;
* ended or what happened in between. Reported exactly that way.
*/
let lastPhase: string | null = null;
/**
* The Day the page last drew, so a Day rolling over can be shown as a dialog (Gitea#10).
*
* Null until the first frame: arriving in a game already on Day 3 is not Day 2 ending, and a page
* reloaded mid-game would otherwise announce a rollover that happened before it was watching.
*/
let lastDay: number | null = null;
/**
* WHICH CREW THE BOARD IS DRAWING, when the district holds more than one.
*
@@ -302,6 +309,10 @@ function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions {
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
const extra = params.get('extra');
if (EXTRA_START_LABELS.some((o) => o.value === extra)) rules.extraStart = extra as ExtraStartRule;
// §6.2 (Gitea#9). This one defaults ON, so the URL only ever has to carry the OFF case — `?toss=0`.
// Read the same way the optional rules are: present and not "0" means on.
const toss = params.get('toss');
if (toss !== null) rules.discardTimetabled = toss !== '0';
const revenue: Partial<RevenueRules> = {};
for (const [param, key] of Object.entries(RULE_PARAMS)) {
@@ -358,6 +369,7 @@ function solitaireDefaults(options: NewGameOptions): NewGameOptions {
houseRules: {
startingHand: options.houseRules?.startingHand ?? p.startingHand,
extraStart: options.houseRules?.extraStart ?? p.extraStart,
discardTimetabled: options.houseRules?.discardTimetabled ?? p.discardTimetabled,
revenue: {
passengerPerCoach: revenue.passengerPerCoach ?? p.passengerPerCoach,
freightPerLoad: revenue.freightPerLoad ?? p.freightPerLoad,
@@ -374,6 +386,9 @@ function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): s
// The dialog answers reach `start()` through the URL and nowhere else, so a setting missing from
// here is a setting the dialog silently discards.
params.set('extra', rules.extraStart);
// Written only when OFF, for the reason the optional rules are written only when on: this one
// defaults to on, so `toss=1` on every link would say nothing and cost a parameter.
if (!rules.discardTimetabled) params.set('toss', '0');
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
const value = options[key];
@@ -561,6 +576,34 @@ function flashAnnounce(text: string): void {
}, 4200);
}
/**
* THE DAY ROLLING OVER, as a dialog that has to be dismissed (Gitea#10).
*
* "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of
* time." Nothing on screen was wrong — the clock, the turn chart and the timetable all said which
* Day it was — but a Day turns over inside the phases that run themselves, so it happens while the
* player is looking at the board waiting for their next turn. The two transient signals the page
* already had are both gone in under five seconds.
*
* Not shown when:
* - this is the first frame (`lastDay === null`) — arriving on Day 3 is not Day 2 ending;
* - the Day went DOWN, which is Undo stepping back across the rollover, not a Day passing;
* - the game finished on that rollover, when the outcome panel is the thing to read instead.
*/
function noteDayEnd(f: Frame): void {
const previous = lastDay;
lastDay = f.day;
if (previous === null || f.day <= previous) return;
if (f.status !== 'active') return;
const dlg = document.getElementById('dayenddlg') as HTMLDialogElement | null;
const body = document.getElementById('dayendbody');
if (!dlg || !body) return;
body.innerHTML = dayEndHtml(f);
// A second rollover cannot happen while this one is open, but a redraw can — `showModal` throws
// on an already-open dialog rather than doing nothing.
if (!dlg.open) dlg.showModal();
}
/**
* THE FIRST FRAME OF A MULTIPLAYER GAME — the one moment nobody had ever seen drawn.
*
@@ -1186,6 +1229,8 @@ function render(): void {
}
lastPhase = f.phaseKey;
noteDayEnd(f);
/**
* A train completing its run pays every player and nobody took a turn to cause it, so it is said
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
@@ -1236,6 +1281,9 @@ function renderUndo(): void {
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
// different phase is not that event, so the banner is suppressed rather than fired backwards.
lastPhase = null;
// Same for the Day: `noteDayEnd` already ignores a Day going down, but undoing back across a
// rollover and then replaying forward through it would announce the same Day ending twice.
lastDay = null;
};
}
@@ -1577,20 +1625,21 @@ function renderActions(
/**
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
*
* §6.2 as ruled by Jesse (Gitea#6): a train card is never discarded, so a player holding four
* trains has exactly one way forward — play one onto the timetable. The rule creates that corner
* deliberately and needs no machinery, but it must not be a corner the player has to infer from
* a discard button that has quietly stopped appearing.
* §6.2 (Gitea#9) leaves two kinds of undiscardable card — an Extra always, and a Timetabled
* train in a game whose `discardTimetabled` rule is off. Either way a player holding nothing but
* those has exactly one way forward: play one. The rule creates that corner deliberately and
* needs no machinery, but it must not be a corner the player has to infer from a discard button
* that has quietly stopped appearing. The reason is the card's own (`handKeepWhy`), so this says
* what actually refused rather than assuming which of the two rules is in force.
*/
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
const why = f.handKeepWhy.find((w) => w !== null) ?? '';
const tip = stuck
? 'You may not end a turn holding more than three cards, and a TRAIN CARD IS NEVER ' +
'DISCARDED. Every card you hold is a train, so the only way on is to play one onto the ' +
'timetable. A train may be held for as many Stages and Days as you like; it just cannot be ' +
'thrown away.'
? 'You may not end a turn holding more than three cards, and every card you hold is one that ' +
`cannot be thrown away. ${why} The only way on is to play one.`
: 'You may not end a turn holding more than three cards (four with a Red Flag). Play ' +
'one onto the board, or discard one face-up to a Department slot. A train card is never ' +
'discardable and can only be played.';
'one onto the board, or discard one face-up to a Department slot — where a rival may pick ' +
'it up.';
html +=
`<div class="grp"><button class="act blocked" disabled data-tip="${tip.replace(/"/g, '&quot;')}">` +
(stuck
@@ -1888,6 +1937,7 @@ if (newBtn && dlg) {
houseRules: {
startingHand: settings.startingHand,
extraStart: settings.extraStart,
discardTimetabled: settings.discardTimetabled,
revenue: {
passengerPerCoach: settings.passengerPerCoach,
freightPerLoad: settings.freightPerLoad,
+78 -6
View File
@@ -31,16 +31,16 @@ export function cardRow(name: string, why: string, playable: boolean | null): st
}
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
// §6.2 (Gitea#6) — say so on the card itself. A player who cannot discard a train needs to read
// that on the train, not deduce it from a button that is not there.
const held = 'You may hold this for as many Stages and Days as you like — but a train card is ' +
'never discarded. The only way it leaves your hand is onto the timetable.';
// §6.2 — say so on the card itself. A player who cannot discard a train needs to read that on the
// train, not deduce it from a button that is not there. The sentence comes off the Frame
// (`handKeepWhy`) rather than being written here, because since Gitea#9 there are two of them and
// which one applies depends on the card AND the game's rules.
return f.hand.length
? f.hand
.map((h, i) => {
const what = f.handWhat[i] ?? '';
const keep = f.handDiscardable[i] === false;
return cardRow(h, keep ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
const held = f.handKeepWhy[i];
return cardRow(h, held ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
})
.join('')
: '<span class="dim">empty</span>';
@@ -153,6 +153,70 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
return `<div class="tt">${slots}</div>`;
}
/**
* THE DAY THAT JUST ENDED — the body of the dialog `main.ts` puts up at every Day rollover.
*
* Reported as Gitea#10: "as the game rolls off the end of the day, you get a dialog saying such.
* Hard to keep track of time." The clock was on screen the whole time, but a Day turns over inside
* the automatic phases — between one click and the next — and neither the phase banner (2.6s) nor
* the announcement flash (4.2s) survives long enough to be noticed by someone reading the board.
* A modal is the point: it stops, and it waits to be dismissed.
*
* It is written from the FRAME AFTER the rollover, so `f.day` is the Day about to start and the one
* that ended is the Day before it. Standings are in Revenue order rather than seat order: the
* question at the end of a Day is who is ahead.
*/
export function dayEndHtml(f: Frame): string {
const ended = f.day - 1;
const left = f.days - ended;
const standings = [...f.players]
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
.map(
(p) =>
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}` +
`${p.index === f.viewer ? ' <span class="dim">(you)</span>' : ''}</td>` +
`<td class="num">${p.revenue}</td></tr>`,
)
.join('');
// The target is a COMBINED floor in every mode that sets one, so it is reported against the whole
// table's Revenue rather than the viewer's — showing one player's score against a four-player
// target reads as a hopeless position when the table may be comfortably ahead.
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
const target =
f.minCombinedRevenue > 0
? `<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>.</p>`
: '';
/**
* Only when the game is actually scored on collisions.
*
* Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the
* checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on
* its config and enforces neither, so reporting a collision budget there would put a rule on
* screen that this game does not have.
*/
const scoredOnCollisions =
(f.mode === 'competitive' || f.mode === 'coop') &&
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
const collisions = scoredOnCollisions
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
: '';
const ahead =
left <= 0
? '<p>That was the last Day on the timetable.</p>'
: `<p><b>Day ${f.day} of ${f.days}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
return (
`<h3 class="dayend-h">Day ${ended} has ended</h3>` +
ahead +
`<table class="dayend-t"><tbody>${standings}</tbody></table>` +
target +
collisions
);
}
export function blockedHtml(f: Frame): string {
return f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
@@ -407,4 +471,12 @@ ul.blocked{margin:0;padding-left:18px}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
.fstat.idle{opacity:.6}
/* THE DAY-END DIALOG (Gitea#10). The dialog chrome is play.html's; these are its contents, here
because dayEndHtml is here — a panel and its styling stay together. */
.dayend-h{font-size:15px;text-transform:none;letter-spacing:0;color:#e6e9ee;margin:0 0 8px}
.dayend-t{border-collapse:collapse;margin:9px 0;min-width:210px}
.dayend-t td{padding:3px 12px 3px 0;border-top:1px solid #2c333d}
.dayend-t tr:first-child td{border-top:0}
.dayend-t .num{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;padding-right:0}
.dayend-t .you td{color:#8fd6a0}
`;
+27
View File
@@ -534,6 +534,13 @@ ul.blocked li{padding:2px 0}
<input id="lb-toolbox" type="checkbox"></label>
<span class="set-hint" id="lb-toolbox-hint"></span>
</div>
<div class="set-row" id="lb-tossloco-row">
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
be played onto the timetable. An Extra is never discardable either way</span>
<input id="lb-tossloco" type="checkbox"></label>
<span class="set-hint" id="lb-tossloco-hint"></span>
</div>
</div>
</div>
@@ -850,6 +857,13 @@ ul.blocked li{padding:2px 0}
<input id="ng-toolbox" type="checkbox"></label>
<span class="set-hint" id="ng-toolbox-hint"></span>
</div>
<div class="set-row" id="ng-tossloco-row">
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
be played onto the timetable. An Extra is never discardable either way</span>
<input id="ng-tossloco" type="checkbox"></label>
<span class="set-hint" id="ng-tossloco-hint"></span>
</div>
</div>
</div>
@@ -863,6 +877,19 @@ ul.blocked li{padding:2px 0}
</form>
</dialog>
<!-- THE DAY ROLLING OVER (Gitea#10). A Day turns inside the automatic phases, so it happens
between one click and the next; the phase banner and the announcement flash both fade before
someone reading the board notices them. A modal stops and waits, which is the whole request:
"hard to keep track of time". Filled by `dayEndHtml` and opened from `render()`. -->
<dialog id="dayenddlg" aria-labelledby="de-title">
<form method="dialog">
<div id="dayendbody"></div>
<menu class="ng-buttons">
<button value="ok" id="de-ok" type="submit">Carry on</button>
</menu>
</form>
</dialog>
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
`beginRemote` used to write "… connecting to the game" into `#presence` — the DISCONNECT banner,
whose job is `⚠ waiting on Alice`. It worked only because the first render overwrote it, and it
+15 -1
View File
@@ -52,6 +52,8 @@ export type Settings = {
reducedVisibility: boolean;
employeeRotation: boolean;
emergencyToolbox: boolean;
/** §6.2 (Gitea#9) — may a Timetabled train be thrown away? An Extra never may, whatever this says. */
discardTimetabled: boolean;
};
export const SETTING_KEYS: readonly (keyof Settings)[] = [
@@ -66,6 +68,7 @@ export const SETTING_KEYS: readonly (keyof Settings)[] = [
'reducedVisibility',
'employeeRotation',
'emergencyToolbox',
'discardTimetabled',
];
export type Preset = {
@@ -94,6 +97,12 @@ const NO_OPTIONAL_RULES = {
reducedVisibility: false,
employeeRotation: false,
emergencyToolbox: false,
/**
* ON in every type (Gitea#9). Jesse's ruling is the rule now, and the setting exists so a table
* can put Gitea#6's pressure back rather than so a type can choose for them — the reasoning is
* about how long a game runs, which is a dial the table already sets for itself.
*/
discardTimetabled: true,
} as const;
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a
@@ -211,6 +220,7 @@ export function settingsOf(config: GameConfig): Settings {
reducedVisibility: config.optionalRules.reducedVisibility,
employeeRotation: config.optionalRules.employeeRotation,
emergencyToolbox: config.optionalRules.emergencyToolbox,
discardTimetabled: rules.discardTimetabled,
};
}
@@ -258,7 +268,7 @@ export function configFromFrame(f: {
maxCollisionsPerDay: number;
maxCollisionsTotal: number;
optionalRules: GameConfig['optionalRules'];
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules };
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean };
}): GameConfig {
return {
mode: f.mode,
@@ -272,6 +282,9 @@ export function configFromFrame(f: {
houseRules: {
startingHand: f.houseRules.startingHand,
extraStart: f.houseRules.extraStart,
// Carried like the rest: this path describes SOMEONE ELSE'S game to a joiner, so a setting
// dropped here shows them a rule the table is not playing (§6.2, Gitea#9).
discardTimetabled: f.houseRules.discardTimetabled,
revenue: f.houseRules.revenue,
},
};
@@ -335,6 +348,7 @@ export function configFromSettings(
houseRules: {
startingHand: settings.startingHand,
extraStart: settings.extraStart,
discardTimetabled: settings.discardTimetabled,
revenue: {
passengerPerCoach: settings.passengerPerCoach,
freightPerLoad: settings.freightPerLoad,
+7 -2
View File
@@ -1,7 +1,7 @@
/**
* THE RULES BLOCK, driven identically on both screens.
*
* The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same eleven questions.
* The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same twelve questions.
* They used to ask them in two hand-written copies and had already drifted — the dialog had "where
* an Extra may start" and no optional rules, the lobby the reverse — so this module owns reading,
* writing, comparing and annotating the block, and each screen supplies only the id prefix its
@@ -47,10 +47,11 @@ export const FIELDS: readonly Field[] = [
{ key: 'reducedVisibility', kind: 'checkbox', id: 'visibility' },
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
{ key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' },
];
/**
* What `play.html` must contain for a screen to be able to ask all eleven questions — the exact
* What `play.html` must contain for a screen to be able to ask all twelve questions — the exact
* attribute text, so `test/web.test.ts` can assert it against the built page.
*
* THIS IS THE DRIFT GUARD. The two blocks are generated from one template today; this is what says
@@ -71,6 +72,7 @@ export function fieldSelectors(prefix: string): string[] {
const FIELD_LABELS: Record<keyof Settings, string> = {
startingHand: 'Starting hand',
extraStart: 'An Extra may start at',
discardTimetabled: 'A Timetabled train may be discarded',
passengerPerCoach: 'Passenger per coach',
freightPerLoad: 'Freight per load',
trainPerTransit: 'Train per transit',
@@ -115,6 +117,7 @@ export function rulesListHtml(config: GameConfig, players: number, days: number)
return (
head +
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart'])}</dl>` +
`<h4>Train cards</h4><dl>${rows(['discardTimetabled'])}</dl>` +
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
`<h4>Optional rules</h4><dl>${rows(['reducedVisibility', 'employeeRotation', 'emergencyToolbox'])}</dl>`
@@ -205,6 +208,7 @@ export function settingsForm(prefix: string): SettingsForm {
reducedVisibility: el<HTMLInputElement>('visibility')?.checked === true,
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
discardTimetabled: el<HTMLInputElement>('tossloco')?.checked === true,
};
}
@@ -220,6 +224,7 @@ export function settingsForm(prefix: string): SettingsForm {
setChecked('visibility', values.reducedVisibility);
setChecked('rotation', values.employeeRotation);
setChecked('toolbox', values.emergencyToolbox);
setChecked('tossloco', values.discardTimetabled);
}
function setNumber(id: string, value: number): void {