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
+116
-13
@@ -20,11 +20,11 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url';
|
||||
|
||||
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||
import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import type { DivisionView } from '../src/sim/view.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { TURNCHART_CSS, turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
import type { Frame } from '../src/sim/view.ts';
|
||||
@@ -56,6 +56,15 @@ const root = join(import.meta.dirname, '..');
|
||||
* other test in this file built it. `npm run test` directly (skipping `npm test`'s `pretest` hook)
|
||||
* will not have run it.
|
||||
*/
|
||||
/**
|
||||
* A solitaire game that opens on a WHISTLE POST rather than the default Depot.
|
||||
*
|
||||
* Two tests below are about the Whistle Post itself — its single A/D track, and the fact that a
|
||||
* Station is not the next tier up from it — so they name the opening rather than inheriting it.
|
||||
*/
|
||||
const whistlePostGame = (seed: number): ReturnType<typeof newGame> =>
|
||||
newGame(seed, { ...SOLO_CONFIG, houseRules: { ...SOLO_CONFIG.houseRules, startingOffice: 'whistlePost' } });
|
||||
|
||||
const dist = join(root, 'dist');
|
||||
/**
|
||||
* A directory the actual "run the build command" test below builds into, kept separate from the
|
||||
@@ -1145,7 +1154,7 @@ describe('the page explains itself', () => {
|
||||
// A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential
|
||||
// (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an
|
||||
// action that did nothing.
|
||||
const game = newGame(111);
|
||||
const game = whistlePostGame(111);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
// A Station upgrade is PUT in hand rather than drawn for. This used to take whatever seed 111
|
||||
// happened to deal, which made it luck: the moment deck composition changed it dealt no upgrade
|
||||
@@ -2570,7 +2579,7 @@ describe('the static build', () => {
|
||||
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
|
||||
// decides whether the next arrival is an automatic collision (§8.3). The Roster Pass replaced
|
||||
// the pips with one roster chip per A/D track (docs/plans/switching-paths.md), free or occupied.
|
||||
const game = newGame(555);
|
||||
const game = whistlePostGame(555);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
const cell = view(game).cells.find((c) => c.kind === 'office')!;
|
||||
assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track');
|
||||
@@ -3116,6 +3125,10 @@ describe('the Division map shows the whole route', () => {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
|
||||
@@ -3123,6 +3136,72 @@ describe('the Division map shows the whole route', () => {
|
||||
return divisionSvg(snapshot(s, [], null).division);
|
||||
};
|
||||
|
||||
it('flashes who is being waited on, but only when it is you', () => {
|
||||
/**
|
||||
* REPORTED FROM A TABLE: "when my turn and waiting on me — make the waiting on flash brightly on
|
||||
* and off." The commonest way a table stalls is a player not noticing their turn came round, and
|
||||
* the chip was the same violet whoever it named.
|
||||
*
|
||||
* ONLY WHEN IT IS ACTUALLY YOUR MOVE. `renderTurnChart` reads the actor ON SCREEN rather than
|
||||
* the live one, so it does not start flashing while your board is still replaying somebody
|
||||
* else's turn and you cannot act yet.
|
||||
*/
|
||||
const frame = { day: 1, stage: 1, clock: '00:00', phase: 'Local Operations', phaseKey: 'localOps', actor: 0 };
|
||||
|
||||
const theirs = turnChartHtml(frame, 'Bob', null, false);
|
||||
assert.match(theirs, /waiting on/, 'the chart stopped saying who is waited on');
|
||||
assert.ok(!/tc-yours/.test(theirs), 'someone else\'s turn is flashing at you');
|
||||
|
||||
const yours = turnChartHtml(frame, 'Alice', null, true);
|
||||
assert.match(yours, /tc-yours/, 'your own turn does not flash');
|
||||
assert.match(yours, /waiting on/, 'the flashing line stopped saying what it is about');
|
||||
|
||||
// An automatic phase waits on nobody, so there is nothing to flash even for the viewer.
|
||||
const auto = turnChartHtml({ ...frame, actor: null, phaseKey: 'mainline', phase: 'Mainline' }, null, null, true);
|
||||
assert.ok(!/tc-yours/.test(auto), 'an automatic phase flashed as though it were your move');
|
||||
|
||||
// And the style is actually shipped, or the class is decoration with no effect.
|
||||
assert.match(TURNCHART_CSS, /\.tc-who\.tc-yours/, 'the flash has no styling');
|
||||
assert.match(TURNCHART_CSS, /@keyframes tc-flash/, 'the flash does not animate');
|
||||
assert.match(TURNCHART_CSS, /prefers-reduced-motion/, 'the flash has no reduced-motion fallback');
|
||||
});
|
||||
|
||||
it('draws a train the Interlocking is holding at the Limits', () => {
|
||||
/**
|
||||
* REPORTED 2026-09-23: "should there be a tooltip on a train holding at limits due to
|
||||
* interlocking that clearly states it is holding at limits because of interlocking?" The
|
||||
* tooltip was already there — the view has carried `heldAtLimits` since #99 — and NO renderer
|
||||
* read the flag, so the train drew like any other chip and nothing told a player to hover.
|
||||
*/
|
||||
const game = newGame(555);
|
||||
const s = game.state;
|
||||
const area = s.officeAreas.get(0)!;
|
||||
// A held train has no grid position at all — that is the whole of #99 — so it is built here and
|
||||
// named only on `heldAtLimits`, exactly as `arriveAtOffice` leaves it.
|
||||
s.trays.set('held1', {
|
||||
id: 'held1', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: false }], direction: 'east',
|
||||
position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['held1'];
|
||||
|
||||
// An eastbound train entered from the west, so it is held at the WESTERN Limits (view.ts).
|
||||
const at = area.limitsWest;
|
||||
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
|
||||
assert.ok(cell.trains?.some((x) => x.heldAtLimits), 'the view lost the held flag');
|
||||
|
||||
const svg = officeSvg([cell], area.runningRow);
|
||||
assert.match(svg, /bs-held/, 'a held train draws like any other');
|
||||
assert.match(svg, /HELD AT THE LIMITS/, 'the held train says nothing about why it stopped');
|
||||
assert.match(svg, /Interlocking/, 'the tooltip does not name what is holding it');
|
||||
assert.match(BOARD_CSS, /\.bs-crew\.bs-held rect/, 'the held mark has no styling');
|
||||
|
||||
// An ordinary train is unmarked, or the cue means nothing.
|
||||
area.heldAtLimits = [];
|
||||
const officeCell = view(game).cells.find((c) => c.kind === 'office')!;
|
||||
assert.ok(!/bs-held/.test(officeSvg([officeCell], area.runningRow)), 'an ordinary square draws as held');
|
||||
});
|
||||
|
||||
it('draws a signal on a Mainline card carrying ABS Signals, not only a tooltip', () => {
|
||||
/**
|
||||
* REPORTED FROM A TABLE, Day 1 Stage 1 of v0.8.0.16: "when played on the trestle, there was no
|
||||
@@ -3136,6 +3215,10 @@ describe('the Division map shows the whole route', () => {
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['A', 'B'],
|
||||
@@ -3173,6 +3256,10 @@ describe('the Division map shows the whole route', () => {
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['A', 'B', 'C', 'D'],
|
||||
@@ -3678,7 +3765,12 @@ describe('the sounds fire on the events they name', () => {
|
||||
// The model names WHAT happened and the page decides what it sounds like. Getting this wrong is
|
||||
// not a silent failure — it is a whistle every few seconds, or a bell that never rings — so the
|
||||
// count is checked against the clock rather than trusted.
|
||||
const game = newGame(555);
|
||||
/**
|
||||
* SEED CHANGED, NOT THE ASSERTION. Adding the Second Section card to the deck (Q9) reshuffles
|
||||
* every seeded deal, and 555 stopped scheduling a train inside the window. This is the fixture
|
||||
* shape TODO #84 is about: the seed means "a game like this", not this exact game.
|
||||
*/
|
||||
const game = newGame(9999);
|
||||
const cues: Record<string, number> = {};
|
||||
let stageBoundaries = 0;
|
||||
let dayBoundaries = 0;
|
||||
@@ -3941,6 +4033,10 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
maxCollisionsPerDay: 3,
|
||||
maxCollisionsTotal: 10,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
@@ -5713,17 +5809,19 @@ describe('the Quickstart guide reaches the site', () => {
|
||||
it('is published into dist and linked from the splash page', () => {
|
||||
const guide = join(dist, 'quickstart.md');
|
||||
assert.ok(existsSync(guide), 'the build did not publish quickstart.md');
|
||||
assert.ok(existsSync(join(dist, 'quickstart.html')), 'the build did not RENDER the guide');
|
||||
|
||||
const text = readFileSync(guide, 'utf8');
|
||||
assert.match(text, /^# Station Master — Quickstart/, 'quickstart.md is not the guide');
|
||||
// The version is the first thing on the page, before anything else — see TODO's process rules.
|
||||
assert.match(
|
||||
text,
|
||||
/Describes the game as built at v/,
|
||||
'the guide does not say which build it describes',
|
||||
text.split('\n').slice(0, 4).join('\n'),
|
||||
/\*\*Version \d+\.\d+/,
|
||||
'the guide does not carry its version at the top',
|
||||
);
|
||||
|
||||
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
assert.match(splash, /href="\.\/quickstart\.md"/, 'the splash page does not link the guide');
|
||||
assert.match(splash, /href="\.\/quickstart\.html"/, 'the splash page does not link the rendered guide');
|
||||
});
|
||||
|
||||
it('brings the lobby doors back every time the lobby is shown', () => {
|
||||
@@ -5769,17 +5867,22 @@ describe('the Quickstart guide reaches the site', () => {
|
||||
* references it guards did.
|
||||
*/
|
||||
const guide = readFileSync(join(dist, 'quickstart.md'), 'utf8');
|
||||
const section = guide.slice(guide.indexOf('## 8. Where to read more'));
|
||||
assert.ok(section.length > 0, 'the guide no longer has a "Where to read more" section');
|
||||
const section = guide.slice(guide.indexOf('## 8. Documentation / References'));
|
||||
assert.ok(section.length > 0, 'the guide no longer has a "Documentation / References" section');
|
||||
|
||||
// Markdown links, minus anchors and absolute URLs — what a reader can actually click.
|
||||
const targets = [...section.matchAll(/\]\(([^)#][^)]*)\)/g)]
|
||||
.map((m) => m[1]!.replace(/^`|`$/g, ''))
|
||||
.filter((t) => !/^https?:/.test(t));
|
||||
assert.ok(targets.length >= 4, `only ${targets.length} references parsed out of the guide`);
|
||||
assert.ok(targets.length >= 3, `only ${targets.length} references parsed out of the guide`);
|
||||
|
||||
for (const t of targets) {
|
||||
assert.ok(existsSync(join(dist, t)), `the guide links ${t}, which the build does not publish`);
|
||||
// And the rendered page it becomes, since that is what a reader actually follows.
|
||||
assert.ok(
|
||||
existsSync(join(dist, t.replace(/\.md$/, '.html'))),
|
||||
`the guide links ${t}, whose rendered page the build does not publish`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5800,7 +5903,7 @@ describe('the Quickstart guide reaches the site', () => {
|
||||
assert.ok(bundle.includes('GUIDE_DOCS') || bundle.includes('quickstart.md'), 'the bundle has no guide links');
|
||||
|
||||
// Every document offered in-game must be a file the build published.
|
||||
const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.md)["'`]/g)].map((m) => m[1]!);
|
||||
const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.html)["'`]/g)].map((m) => m[1]!);
|
||||
assert.ok(hrefs.length >= 5, `only ${hrefs.length} in-game guide links found`);
|
||||
for (const h of hrefs) {
|
||||
assert.ok(existsSync(join(dist, h.replace(/^\.\//, ''))), `the game links ${h}, which is not published`);
|
||||
|
||||
Reference in New Issue
Block a user