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
+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}
`;