v0.8.2 — every district opens on a Depot, and the docs are pages now
A second-digit bump for a playtest read back against the save file. Nine questions were asked of one three-Day game; three were bugs, three were the rules working and undocumented, three were decisions. Every save on the test server was replayed against this build BEFORE release, which is how the cost of each rule was known before it was chosen rather than discovered after. EVERY DISTRICT OPENS ON A DEPOT. A Whistle Post has one A/D track and is not a Passenger Facility, so the opening of every game was spent unable to work a passenger and one arrival away from a collision. Two A/D tracks and passengers from Stage 1 now; "Players start with Whistle Posts, not Depots" is the harder game, set when the game is created. The deck follows the choice — starting on Depots the four Depot upgrade cards are left out, because an upgrade must be to the next tier and a Depot card at a table of Depots is a dead draw. How much easier it is showed up as a test failure rather than an argument: the cue-coverage pool needed widening from 24 seeded games to 60 before it held one collision. NO SAVE WAS STRANDED BY IT, which took care. This is the one house rule that changes how a game is DEALT rather than how it plays, so replaying a save under the wrong opening is a different railroad from intent one — silently, with no error. `withSavedOpening` fills it on the replay paths ONLY. Putting it in the resolver instead made a fresh Cutthroat game deal Whistle Posts and read as Custom, which is how the distinction was found. THREE BUGS, ALL REPORTED FROM ONE GAME AND ALL CONFIRMED ON ITS SAVE. An Office held TWO TRAINS ON ONE A/D TRACK. The capacity test passed with nothing standing, the train the Interlocking had been holding at the Limits was moved into the free slot, and the arriving train was pushed in after it without anyone asking again whether there was room — so the collision §8.3 calls for never happened. The held train keeps priority; the newcomer now takes the consequence it would have met had the held train arrived first. THE HISTORY FROZE, permanently, and the log cap was not really the cause. Each seat's "what have I sent you" bookmark was an INDEX into an array the game trims, so once a seat's bookmark reached the limit the slice returned nothing for the rest of the game — at a different moment per seat, because each holds its own. That game's log ended at exactly the cap. Lines carry a sequence number now, which survives trimming; proven by pushing twice the cap through a simulated seat. §8.1 ASKED THE WRONG QUESTION TWICE. "Trains may pass" returned `clear` before the Subdivision was looked at, so a train entering a Double Track was released however busy the rest of it was — that, not anything about Control Points, is what let Train 8 out with no ruling. And a train standing at an Office was invisible to the scan, so one about to re-enter the very Subdivision being entered counted for nothing. Capacity is the test, not presence: a Depot with a track free is not in the way; a Whistle Post with its one track taken is. THINGS THAT HAPPENED SILENTLY NOW SAY SO — a train held against a facing one, a train released from the Limits (a side effect of somebody else's arrival, so it simply appeared at the Office), and the train an Interlocking is holding, whose explanatory tooltip has existed since #99 with NO renderer ever reading the flag. WHERE A MOVE IS REFUSED, AND WHY. `exploreMoves` decides where the rails go and the pick-up restrictions are enforced afterwards in `check`, so a square the rails reached and the card forbade was reachable, un-offered, and absent from the block list with nothing said. Those squares are blocked with the rule that blocks them now, and the reasons are got by ASKING `check` rather than re-deriving: a second implementation of the rules is exactly the failure the block list exists to avoid. A train may also always recover its own caboose — X13 prints "may drop but not pick up anything", and a train needs its caboose to be made up, so one that parted with it could never legally leave again. RULES DECIDED IN SEPTEMBER AND APPLIED HERE. A Modifier must sit square against its host, no diagonals. A passenger Modifier may not be played at a Whistle Post. Both were built, measured, held back for a fortnight so a playtest could finish, and applied now. A Second Section costs its card: `SECOND_SECTION` was declared in content.ts and never dealt, so the action was free and the bot ordered 26 accidental ones in a measured round. The card is dealt and spent — gating on a card the deck never holds would have deleted the mechanic rather than fixed it. THE DOCUMENTATION IS A SET OF PAGES, not five text files served as text/plain — a card reference is mostly tables, and as plain text a table is rows of pipes. Markdown is still the one copy; the build renders it, and publishes the .md beside each page. No Markdown library: this project has no runtime dependencies and one would be a poor first. The pages add what Markdown cannot carry without drifting — a nav across the set, a contents list built from the headings actually rendered, an anchor on every heading, a 70-character measure, and tables that are tables. They print as ink on paper. The references caught up with the rules, checked rather than assumed: two statements had gone from stale to misleading (the Quickstart told a new player to "get a Depot down as soon as one appears"), and four rules nobody could look up are written down — the Office tier table, §8.1 in practice, what the Circus Train pays for, and that a Realignment can be a card with no legal target. Adding one card to the deck reshuffles every seeded deal, which broke five fixtures. Each was a seed meaning "a game like this" — TODO #84, exactly — so seeds moved and pools widened rather than assertions weakening, and the clearance fixture pins its terrain the way `enhancements.test.ts` already does. The three published replays were re-recorded. Closes TODO #40, #42a, #108, #109 and #110. 1046 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
This commit is contained in:
co-authored by
Claude Opus 5
parent
517238a727
commit
3befc420da
+15
-1
@@ -1259,7 +1259,17 @@ export function officeSvg(
|
||||
const tx = W / 2 - tw / 2;
|
||||
const facingWord = t.facing === 'e' ? 'east' : 'west';
|
||||
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
|
||||
out += `<g class="bs-crew" data-tip="${esc(
|
||||
/**
|
||||
* HELD AT THE LIMITS, DRAWN RATHER THAN ONLY DESCRIBED (Jesse, 2026-09-23).
|
||||
*
|
||||
* The view has carried this flag since #99 and NO renderer read it, so the train drew like
|
||||
* any other crew and nothing told a player to hover — and hovering was the only way to learn
|
||||
* why a train had stopped short of the Office for several Stages. The tooltip text is already
|
||||
* on `t.what`; this is the mark that sends you to it. Red, the same "stopped, and not by
|
||||
* choice" the Red Flag means elsewhere on this map, and dashed because it is waiting.
|
||||
*/
|
||||
const held = t.heldAtLimits === true;
|
||||
out += `<g class="bs-crew${held ? ' bs-held' : ''}" data-tip="${esc(
|
||||
`${t.label} — engine pointing ${facingWord}, carrying ${consistWords}` + (t.what ? `\n\n${t.what}` : ''),
|
||||
)}"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
|
||||
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
|
||||
@@ -1470,6 +1480,10 @@ text.bs-mod{fill:#c8a04a}
|
||||
/* A signal standing on a Mainline card carrying ABS Signals. Green is otherwise unused on the
|
||||
Division row apart from the Division Point's dashed border, so a lit lamp does not compete with
|
||||
the amber "it is happening here" or the red flag for meaning. */
|
||||
/* A train the Interlocking is holding on the Limit Track. Red, matching the Red Flag's "stopped and
|
||||
not by choice"; dashed, because it is waiting rather than parked. */
|
||||
.bs-crew.bs-held rect{fill:#2c1d1d;stroke:#d2453f;stroke-width:1.6;stroke-dasharray:4 2}
|
||||
.bs-crew.bs-held .bs-tlab{fill:#f0c2be}
|
||||
.bs-abs-mast{stroke:#9aa3b0;stroke-width:1.6}
|
||||
.bs-abs-lit{fill:#4fae6a;stroke:#2c6b40;stroke-width:0.8}
|
||||
.bs-abs-dark{fill:#2a3038;stroke:#59626f;stroke-width:0.8}
|
||||
|
||||
+39
-5
@@ -441,11 +441,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
text: `${e.isExtra ? `EXTRA X${e.trainNumber}` : `TRAIN ${e.trainNumber}`} MADE UP at the ${e.at}, running ${e.direction} — crew assigned, now taking cars`,
|
||||
};
|
||||
case 'trainStoodStill':
|
||||
/**
|
||||
* SPELLS OUT WHAT EARNED IT (Jesse, 2026-09-23: "make sure history calls out when point
|
||||
* earned"). The line said the train had stood still and a point arrived; it did not say that
|
||||
* the point is per DISTRICT and can be earned again in the next one, which is the whole of
|
||||
* how the card is played.
|
||||
*/
|
||||
return {
|
||||
tone: 'good',
|
||||
text:
|
||||
`Train ${e.trainNumber} stood still for a whole Stage at ${e.where} and earned a point — ` +
|
||||
'its card pays for the stop, not for the run (circus set-up)',
|
||||
`CIRCUS SET-UP — Train ${e.trainNumber} stood still for a whole Stage at ${e.where}, ` +
|
||||
'fully loaded, and earned 1 Revenue. Its card pays for the STOP, not for the run: one ' +
|
||||
'point per district, and it can earn again in the next district it stands a Stage in.',
|
||||
};
|
||||
case 'trainHeld':
|
||||
return {
|
||||
@@ -535,6 +542,21 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
text: `UNJAMMED ${place(e.player, e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
|
||||
where: e.at,
|
||||
};
|
||||
case 'trainReleasedFromLimits': {
|
||||
/**
|
||||
* WHY NOW, which is the whole question. The train has been sitting at the Limits for however
|
||||
* many Stages, and what changed is that somebody else's train cleared the Office.
|
||||
*/
|
||||
const who = ctx.playerName?.(e.owner as never) ?? null;
|
||||
return {
|
||||
tone: 'good',
|
||||
text:
|
||||
`Train ${e.trainNumber} RELEASED from the Limits into the ${e.office}` +
|
||||
`${who ? ` at ${who}'s district` : ''} — the Interlocking had been holding it clear of a ` +
|
||||
`full Office, and Train ${e.freedBy} arriving freed the A/D track it was waiting for. ` +
|
||||
'A held train takes the first track to free, ahead of anything arriving after it.',
|
||||
};
|
||||
}
|
||||
case 'freightAgentIdled':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
@@ -701,10 +723,22 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
|
||||
// -- consequences
|
||||
case 'revenueChanged':
|
||||
case 'revenueChanged': {
|
||||
/**
|
||||
* WHOSE REVENUE, NAMED HERE RATHER THAN LEFT TO THE PREFIX.
|
||||
*
|
||||
* Reported from a table: the line gives the change and the running total and no player. The
|
||||
* history's own prefix names the ACTOR, and revenue is not always the actor's — a train
|
||||
* completing its run pays every player with no actor at all, so those lines carried no name
|
||||
* whatsoever. `e.player` is the seat that earned it, which is the only right answer, so the
|
||||
* line resolves its own name and `record` leaves it alone (`SELF_NAMED` in `web/game.ts`).
|
||||
*/
|
||||
const whose = ctx.playerName?.(e.player) ?? null;
|
||||
const owner = whose === null ? '' : `${whose} `;
|
||||
return e.delta < 0
|
||||
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
|
||||
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
|
||||
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${owner}${e.delta} Revenue (now ${e.total})` }
|
||||
: { tone: 'good', text: `${owner}+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
|
||||
}
|
||||
case 'phaseEnded':
|
||||
return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` };
|
||||
|
||||
|
||||
+28
-2
@@ -40,7 +40,17 @@ export type TurnChartFrame = {
|
||||
* screen. `Frame` has carried `superintendent` all along and the standalone replay printed it; the
|
||||
* live game never did.
|
||||
*/
|
||||
export function turnChartHtml(f: TurnChartFrame, actorName: string | null, superName: string | null = null): string {
|
||||
/**
|
||||
* `yours` is true when the player reading this is the one being waited on. It makes the line flash,
|
||||
* because the commonest way a table stalls is somebody not noticing their own turn has come round
|
||||
* (reported from a playtest). The replay viewers pass nothing: a recording waits on nobody.
|
||||
*/
|
||||
export function turnChartHtml(
|
||||
f: TurnChartFrame,
|
||||
actorName: string | null,
|
||||
superName: string | null = null,
|
||||
yours = false,
|
||||
): string {
|
||||
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
|
||||
{
|
||||
key: 'localOps',
|
||||
@@ -138,7 +148,8 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
||||
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
||||
`<span class="dim">${esc(f.clock)}</span></div>` +
|
||||
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
||||
`<div class="tc-who">${waiting}<b>${esc(who)}</b>${asked}</div>` +
|
||||
`<div class="tc-who${yours && actorName !== null ? ' tc-yours' : ''}">` +
|
||||
`${waiting}<b>${esc(who)}</b>${asked}</div>` +
|
||||
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
|
||||
// between the phases and everything above them, which put a thing that changes every third
|
||||
// Stage in the middle of the things that change every Stage. The row it belongs beside is the
|
||||
@@ -169,6 +180,21 @@ export const TURNCHART_CSS = `
|
||||
.tc-asks{color:#a99ac4;font-style:italic}
|
||||
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
||||
border-radius:11px;padding:1px 9px;font-size:12px}
|
||||
/* IT IS YOUR TURN. The commonest way a table stalls is a player not noticing their turn came round,
|
||||
so the chip flashes rather than merely changing colour — motion is what catches an eye that is
|
||||
somewhere else on the board. Amber, because that is what "you can act" means everywhere else on
|
||||
this screen, and it separates "waiting on YOU" from the violet "where we are" news around it.
|
||||
A reduced-motion preference holds it steady and lit instead of dropping the cue. */
|
||||
.tc-who.tc-yours{color:#f0b64a}
|
||||
.tc-who.tc-yours b{color:#1a1f27;background:#f0b64a;border-color:#f0b64a;font-weight:700;
|
||||
animation:tc-flash 1s steps(1,end) infinite}
|
||||
@keyframes tc-flash{
|
||||
0%,49%{background:#f0b64a;border-color:#f0b64a;color:#1a1f27;box-shadow:0 0 0 3px rgba(240,182,74,.25)}
|
||||
50%,100%{background:transparent;border-color:#f0b64a;color:#f0b64a;box-shadow:none}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.tc-who.tc-yours b{animation:none;box-shadow:0 0 0 3px rgba(240,182,74,.25)}
|
||||
}
|
||||
/* WHO HOLDS THE FEDORA. Violet like the rest of the chart — this is "where you are" news, not
|
||||
something to press — but unfilled, so the eye still lands on "waiting on" first: that is the one
|
||||
that changes every turn, while this changes four times a Day. */
|
||||
|
||||
+29
-1
@@ -33,6 +33,7 @@ import {
|
||||
MANEUVER_CARDS,
|
||||
MODIFIER_PROFILES,
|
||||
REALIGNMENTS,
|
||||
SECOND_SECTION,
|
||||
OFFICE_ORDER,
|
||||
SPACE_USE_CARDS,
|
||||
STAGES_PER_SHIFT,
|
||||
@@ -1923,6 +1924,8 @@ export function cardName(s: GameState, id: string): string {
|
||||
// The hand is on the card face and decides which diagonal its 45° leg lies on, so it belongs
|
||||
// in the name: "curve" alone does not tell you what it can be joined to.
|
||||
return `${k.hand === 'none' ? '' : `${k.hand}-hand `}${geometryLabel(k.geometry)}`;
|
||||
case 'secondSection':
|
||||
return SECOND_SECTION.name;
|
||||
case 'spaceUse':
|
||||
case 'enhancement':
|
||||
case 'mainlineModifier':
|
||||
@@ -2045,6 +2048,14 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
: '';
|
||||
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}${warn}`;
|
||||
}
|
||||
case 'secondSection':
|
||||
// Q9. Worth spelling out: it is the one card that creates the following-train situation §8.1
|
||||
// makes the Superintendent rule on, so playing it is choosing to put that question to them.
|
||||
return (
|
||||
'order a second, identical train right behind one due out this Stage · it needs its own ' +
|
||||
'Crew Tray, and it deliberately creates the following-train situation the Superintendent ' +
|
||||
'must rule on (§8.1)'
|
||||
);
|
||||
case 'track': {
|
||||
// Track is the largest category in the deck, so a player holds it constantly — and what it
|
||||
// can be joined to is decided by the hand, which is not something the name alone conveys.
|
||||
@@ -2088,7 +2099,24 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
: rule?.effect === 'dormantSolo'
|
||||
? ' · never fires in solitaire — it answers an opponent card the solo deck omits'
|
||||
: '';
|
||||
return `${card.effect} · played on ${card.placement}${note}`;
|
||||
/**
|
||||
* WHICH MAINLINE CARDS A REALIGNMENT CAN ACTUALLY CONVERT (Jesse, 2026-09-23: "how does the
|
||||
* user know what the realignment card can be used on?").
|
||||
*
|
||||
* The card printed "Convert one Mainline type to another. Not while a train is on it", which
|
||||
* is true and useless: only four of the nine types convert at all, and a table whose Division
|
||||
* dealt none of them has a card that can never be played. That is exactly what happened —
|
||||
* Uncontrolled Siding, Heavy Grade and Tunnel, with the Siding already converted, so the
|
||||
* second Realignment had no target and nothing said why.
|
||||
*
|
||||
* Named from `REALIGNMENTS` rather than written out, so the list cannot drift from the rule.
|
||||
*/
|
||||
const converts =
|
||||
card.key === 'realignment'
|
||||
? ` · only converts ${REALIGNMENTS.map((r) => mainlineProfile(r.from).name).join(', ')}` +
|
||||
' — no other Mainline card can be realigned'
|
||||
: '';
|
||||
return `${card.effect} · played on ${card.placement}${converts}${note}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user