v0.8.0.17 — four things the game knew and the screen did not say
All four reported from a table on Day 1 of v0.8.0.16, and all the same shape. ABS SIGNALS COULD ONLY BE PLAYED ON ONE MAINLINE CARD, while its tooltip said "any Mainline card". The engine was never wrong: check accepts any node whose kind is mainline and legalActions filters by check, so all of them were legal. The failure was the LABEL — describeIntent named i.placement and never i.node, so every placement described itself as plain "play ABS Signals", and the action list drops duplicate labels. All but the lowest-index node were discarded before the menu saw them. This is the THIRD time that trap has fired and the file documents the other two three lines apart: a turnout's two rotations, and three Department discards. Same fix — name what distinguishes them. The card is also called what the card face calls it. prettyKey rendered absSignals as "Abs Signals" beside a tooltip saying ABS, an acronym no key-splitter can recover, so the authored names now win. Three of those names were transcribed in sentence case and were CORRECTED rather than adopted: the repository says "Yard Office" 36 times against "Yard office" twice. A lookup that imports its own source's typos is the drift it exists to prevent. NOTHING ON A MAINLINE CARD SHOWED WHAT WAS STANDING ON IT. Played, ABS left no mark and you found out by hovering — the same complaint the Heavy Grade wedge answered, and it matters more here because ABS decides whether a second train on that card is safe. It draws a signal mast with a lit lamp now; a signal is the literal object and needs no room for words, which is what lets it sit clear of a name as long as "Uncontrolled Siding" on a 152px cell. The Mainline modifiers draw as BRK, AIR and HLP. Realignment is deliberately not among them: reduce takes the `became` branch and changes node.card, so a realigned Trestle IS an Uncontrolled Siding afterwards. Asserted, so the absence reads as a finding. A FREIGHT AGENT TURN SAID A CAR MOVED WHEN NONE HAD. Three faults behind one line. It asserted an outcome, where §6.3 requires no action and the bot declines deliberately — unjamming a healthy box destroys a load that cost a whole action to stock. An idle Agent was then silent, which read as a dropped turn; a new freightAgentIdled event says so and why, reducing to nothing exactly like switchingEnded. And the work named a coordinate rather than the industry, though a `place` helper has existed for precisely that since the switching lines moved to it. "Loaded a loaded boxcar INTO the green Outbound box at the Freight House", with the direction in capitals because to-or-from was the question asked. THE LOG AND THE ACTION MENU SPELLED THE SAME SQUARE DIFFERENTLY. view.ts wrote (col,row) — X,Y, east/west then north/south — with a comment saying why; narrate.ts wrote the internal storage order with no comment at all. So the menu offered a move to "(1,-1)" and the log reported it at "(-1,1)", side by side. Pinned by a test that renders one square through BOTH describers and compares them to each other: a test written against either file alone would have passed. THE DOCUMENTATION IS REACHABLE FROM A RUNNING GAME, AND ALL OF IT IS PUBLISHED. v0.8.0.16 published the Quickstart and nothing it points at — its §8 links five documents by relative path and every one 404'd on the package, verified against the running container. The build publishes the full set, and the test reads the links OUT OF the guide rather than listing them. They are linked from the This Game card, where reference already lives, rather than the header that must not wrap; no mode awareness is needed, because solitaire and multiplayer are the same page on the same origin. THE REFERENCES DROPPED THE VERSION FROM THEIR NAMES. Four described v0.8.0.16 and had since the v0.8.0.15 audit; the v0.4.5 was the prototype edition they were first written against, kept only because 36 citations pointed at it — and it read as documentation five minor versions stale. They are quickstart.md, rules.md, home-deck.md, mainline-deck.md and components.md now, kept current with each release rather than published as editions. Two errors surfaced while checking them against this release, which is the argument for doing it: home-deck.md filed ABS Signals under Enhancements "played into your district" that "change what a square does" — it does neither, this release's bug written down — and mainline-deck.md, which lists everything playable onto a Mainline card, never mentioned it at all. 1010 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
dc31555625
commit
b90c0413d2
+242
-1
@@ -22,7 +22,7 @@ import { cardDescription, cardName, describeIntent, variantLabel } from '../src/
|
||||
import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import type { DivisionView } from '../src/sim/view.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.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 { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
@@ -310,6 +310,119 @@ describe('the action menu presents choices the way they are made', () => {
|
||||
assert.deepEqual(shapes, ['en|ew', 'we|ws'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
|
||||
});
|
||||
|
||||
it('offers ABS Signals on EVERY Mainline card, each one named', () => {
|
||||
/**
|
||||
* REGRESSION — reported from a table on Day 1 Stage 1 of v0.8.0.16, and the THIRD instance of
|
||||
* one trap. The action list drops duplicate labels, and `describeIntent` for a card play named
|
||||
* the grid placement but never `node` — so every Mainline card produced the identical label
|
||||
* "play ABS Signals" and all but the lowest-index one were discarded before the menu saw them.
|
||||
* The card's own tooltip says "any Mainline card" while exactly one was ever on offer.
|
||||
*
|
||||
* The engine was never wrong: `check` accepts any node whose kind is 'mainline', and
|
||||
* `legalActions` filters by `check`. The whole failure was in the label.
|
||||
*/
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
|
||||
let absId: string | undefined;
|
||||
for (const [id, card] of game.state.cards) {
|
||||
const k = card.kind as { kind: string; key?: string };
|
||||
if (k.kind === 'enhancement' && k.key === 'absSignals') { absId = id; break; }
|
||||
}
|
||||
assert.ok(absId, 'the deck has no ABS Signals card');
|
||||
game.state.decks.hands.set(0, [absId]);
|
||||
|
||||
const mainlineNodes = game.state.division.nodes
|
||||
.map((n, i) => ({ n, i }))
|
||||
.filter(({ n }) => n.kind === 'mainline');
|
||||
assert.ok(mainlineNodes.length > 1, 'this division has only one Mainline card — nothing to distinguish');
|
||||
|
||||
// The engine offers one per Mainline card ...
|
||||
const offered = actionGroups(game).options.filter(
|
||||
(o) => o.type === 'card.play' && o.cardId === absId && o.node !== undefined,
|
||||
);
|
||||
assert.equal(
|
||||
offered.length,
|
||||
mainlineNodes.length,
|
||||
`the engine offers ${offered.length} placements for ${mainlineNodes.length} Mainline cards`,
|
||||
);
|
||||
|
||||
// ... and every one of them must survive into the menu, which means distinct labels.
|
||||
const labels = offered.map((o) => describeIntent(game.state, o));
|
||||
assert.equal(
|
||||
new Set(labels).size,
|
||||
offered.length,
|
||||
`the labels collapse, so the menu drops all but one: ${[...new Set(labels)].join(' / ')}`,
|
||||
);
|
||||
|
||||
const spots = actionMenu(game)
|
||||
.placeable.flatMap((g) => g.items)
|
||||
.filter((it) => it.subjectKey === `card:${absId}`)
|
||||
.flatMap((it) => it.spots);
|
||||
assert.equal(
|
||||
spots.length,
|
||||
mainlineNodes.length,
|
||||
`only ${spots.length} of ${mainlineNodes.length} Mainline cards can be chosen`,
|
||||
);
|
||||
|
||||
/**
|
||||
* And the card is called what the card face calls it. `prettyKey` rendered `absSignals` as
|
||||
* "Abs Signals" on a button while the tooltip beside it said ABS — an acronym no key-splitter
|
||||
* can recover, so the authored name in `ENHANCEMENT_CARDS` has to win.
|
||||
*/
|
||||
assert.ok(
|
||||
labels.every((l) => l.includes('ABS Signals')),
|
||||
`the card is not called by its printed name: ${labels[0]}`,
|
||||
);
|
||||
|
||||
// Each spot names the card it would go on, so the choice is legible rather than positional.
|
||||
for (const { n } of mainlineNodes) {
|
||||
const name = mainlineProfile((n as { card: Parameters<typeof mainlineProfile>[0] }).card).name;
|
||||
assert.ok(
|
||||
spots.some((sp) => sp.label.includes(name)),
|
||||
`no spot names the ${name}: ${spots.map((sp) => sp.label).join(' / ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('spells a square the same way in the action menu and in the log', () => {
|
||||
/**
|
||||
* REPORTED 2026-09-21. `view.ts` wrote "(col,row)" — X,Y, east/west then north/south, with a
|
||||
* comment saying so — and `narrate.ts` wrote "(row,col)", the internal storage order, with no
|
||||
* comment at all. So the action menu offered a move to "(1,-1)" and the log then reported it at
|
||||
* "(-1,1)", in two panels a player reads side by side.
|
||||
*
|
||||
* PINNED AGAINST EACH OTHER rather than against a literal: a test asserting one format would
|
||||
* have passed all along on whichever file it was written against. This compares the two
|
||||
* renderers on the same square, which is the property that was actually broken.
|
||||
*/
|
||||
const game = newGame(555);
|
||||
const square = { row: -1, col: 2 };
|
||||
|
||||
const logged = describeIntent(game.state, {
|
||||
type: 'switch.move',
|
||||
trayId: [...game.state.trays.keys()][0]!,
|
||||
to: square,
|
||||
reverse: false,
|
||||
});
|
||||
const menu = describeIntent(game.state, {
|
||||
type: 'card.play',
|
||||
cardId: game.state.decks.hands.get(0)![0]!,
|
||||
placement: square,
|
||||
});
|
||||
|
||||
// Both must render the square, and render it identically.
|
||||
const coord = /\((-?\d+,-?\d+)\)/;
|
||||
const inLog = coord.exec(logged)?.[1];
|
||||
const inMenu = coord.exec(menu)?.[1];
|
||||
assert.ok(inLog, `the log line names no square: ${logged}`);
|
||||
assert.ok(inMenu, `the menu line names no square: ${menu}`);
|
||||
assert.equal(inMenu, inLog, 'the action menu and the log spell the same square differently');
|
||||
|
||||
// And the shared spelling is X,Y — east/west first, which is the order the map is drawn in.
|
||||
assert.equal(inMenu, '2,-1', `not X,Y order: ${inMenu}`);
|
||||
});
|
||||
|
||||
it('says how deep a Department pile is, so a discard can be aimed', () => {
|
||||
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
|
||||
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
|
||||
@@ -3010,6 +3123,82 @@ describe('the Division map shows the whole route', () => {
|
||||
return divisionSvg(snapshot(s, [], null).division);
|
||||
};
|
||||
|
||||
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
|
||||
* on-the-card indication. It's only when you look at the tooltip for trestle that you see that
|
||||
* ABS exists." The same complaint the Heavy Grade wedge below answers, and it matters more
|
||||
* here — ABS is what decides whether running a second train onto that card is safe.
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'div-abs',
|
||||
seed: 7,
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['A', 'B'],
|
||||
});
|
||||
|
||||
const before = divisionSvg(snapshot(s, [], null).division);
|
||||
assert.ok(!before.includes('bs-abs'), 'a signal is drawn before ABS Signals was ever played');
|
||||
|
||||
const node = s.division.nodes.find((n) => n.kind === 'mainline');
|
||||
assert.ok(node, 'this division has no Mainline card');
|
||||
(node as { absSignals?: boolean }).absSignals = true;
|
||||
|
||||
const after = divisionSvg(snapshot(s, [], null).division);
|
||||
assert.ok(after.includes('bs-abs-mast'), 'the card carrying ABS Signals draws no signal mast');
|
||||
assert.ok(after.includes('bs-abs-lit'), 'the signal has no lit aspect');
|
||||
// Exactly one card carries it, so the mark cannot be a row-wide decoration.
|
||||
assert.equal((after.match(/bs-abs-mast/g) ?? []).length, 1, 'the signal is drawn on more than one card');
|
||||
// And it stays in the tooltip too — the mark says THAT, the tip still says what it does.
|
||||
assert.ok(after.includes('ABS Signals'), 'the tooltip stopped naming ABS Signals');
|
||||
});
|
||||
|
||||
it('draws the Mainline modifiers on the card, not only in the tooltip', () => {
|
||||
/**
|
||||
* The other half of the ABS report (Jesse, 2026-09-21): "Brakeman, Airbrakes, Helpers and
|
||||
* Realignment should also be drawn on the card, not just the tooltip."
|
||||
*
|
||||
* REALIGNMENT IS NOT IN THIS LIST ON PURPOSE. It never sits on a card — `reduce` takes the
|
||||
* `became` branch and changes `node.card` outright — so a realigned card already announces
|
||||
* itself by being a different card. Asserted below so the absence is a recorded finding rather
|
||||
* than something that looks forgotten.
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'div-mods',
|
||||
seed: 7,
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['A', 'B', 'C', 'D'],
|
||||
});
|
||||
|
||||
const node = s.division.nodes.find((n) => n.kind === 'mainline');
|
||||
assert.ok(node, 'this division has no Mainline card');
|
||||
|
||||
assert.ok(!divisionSvg(snapshot(s, [], null).division).includes('bs-mod'), 'a tag is drawn with no modifier on');
|
||||
|
||||
(node as { modifiers?: string[] }).modifiers = ['brakeman', 'airbrakes', 'helpers'];
|
||||
const svg = divisionSvg(snapshot(s, [], null).division);
|
||||
for (const tag of ['BRK', 'AIR', 'HLP']) {
|
||||
assert.ok(svg.includes(tag), `the ${tag} modifier is not drawn on the card`);
|
||||
}
|
||||
// And the tooltip still names them in full — the tag says THAT, the tip says WHICH.
|
||||
assert.match(svg, /Brakeman/, 'the tooltip stopped naming the modifiers');
|
||||
|
||||
// Realignment converts the card instead of sitting on it, so it must never produce a tag.
|
||||
(node as { modifiers?: string[] }).modifiers = ['realignment'];
|
||||
assert.ok(
|
||||
!divisionSvg(snapshot(s, [], null).division).includes('bs-mod'),
|
||||
'Realignment drew a tag, but it changes the card rather than standing on it',
|
||||
);
|
||||
});
|
||||
|
||||
it('draws which way a Heavy Grade climbs, instead of only saying it in the tooltip', () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card
|
||||
@@ -5537,6 +5726,58 @@ describe('the Quickstart guide reaches the site', () => {
|
||||
assert.match(splash, /href="\.\/quickstart\.md"/, 'the splash page does not link the guide');
|
||||
});
|
||||
|
||||
it('publishes everything the guide links, so "Where to read more" is not five dead links', () => {
|
||||
/**
|
||||
* v0.8.0.16 published the Quickstart alone. Its §8 links five further documents by relative
|
||||
* path, and every one of them 404'd on the package — verified against the running container,
|
||||
* 5 of 6 paths missing. Publishing a guide without what it points at is the same broken-link
|
||||
* failure as the test above, one hop further out, so it is pinned the same way: the links are
|
||||
* read OUT OF THE GUIDE rather than listed here, or this test goes stale exactly as the
|
||||
* 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');
|
||||
|
||||
// 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`);
|
||||
|
||||
for (const t of targets) {
|
||||
assert.ok(existsSync(join(dist, t)), `the guide links ${t}, which the build does not publish`);
|
||||
}
|
||||
});
|
||||
|
||||
it('reaches the documentation from inside a game, in solitaire and multiplayer alike', () => {
|
||||
/**
|
||||
* Asked from a table, 2026-09-21: "how can we link the documentation so it can be reached from
|
||||
* the gameplay, whether someone is playing solitaire or multiplayer?" The links live in the
|
||||
* This Game card (Jesse's call) — and the point is that they need NO mode awareness, because
|
||||
* both modes are the same page on the same origin. So this asserts the links exist and resolve,
|
||||
* which is the whole of the mechanism.
|
||||
*
|
||||
* Read out of the built bundle rather than the source: what matters is what the shipped page
|
||||
* offers, and a link that resolves in `src/` and not in `dist/` is the exact failure the two
|
||||
* tests above exist to catch.
|
||||
*/
|
||||
const bundle = readFileSync(join(dist, 'web', 'main.js'), 'utf8');
|
||||
const guide = bundle.slice(bundle.indexOf('GUIDE_DOCS'), bundle.indexOf('GUIDE_DOCS') + 4000);
|
||||
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]!);
|
||||
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`);
|
||||
}
|
||||
|
||||
// A reference opened mid-turn must not take the game with it.
|
||||
assert.ok(guide.includes('_blank'), 'the guide links would navigate away from a game in progress');
|
||||
assert.ok(guide.includes('noopener'), 'a new-tab link without rel=noopener hands out a window handle');
|
||||
});
|
||||
|
||||
it('is served as text rather than handed over as a download', () => {
|
||||
/**
|
||||
* The server's MIME fallback is `application/octet-stream`, which a browser downloads instead of
|
||||
|
||||
Reference in New Issue
Block a user