v0.4.8 — the Limits bound the whole district, the nine spots really are nine, and an action points at its square

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQAJ4dND7enLj54eLyiF2C
This commit is contained in:
Jesse
2026-08-19 13:38:06 -04:00
co-authored by Claude Opus 5
parent 9f3b92d08e
commit 461c4d9bac
25 changed files with 3264 additions and 4524 deletions
+6 -1
View File
@@ -8,7 +8,12 @@ Thumbs.db
*.swp
# Dependencies / build output
node_modules/
#
# NO TRAILING SLASH on node_modules, deliberately. `node_modules/` matches a directory and not a
# SYMLINK of that name — so one pointing at a shared install was committed in v0.4.7, and because it
# pointed at a path that does not exist, a fresh checkout had no TypeScript: `npm run typecheck` said
# "tsc: not found" and 20 tests failed for a reason that had nothing to do with the code.
node_modules
dist/
build/
target/
+108
View File
@@ -19,6 +19,114 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.4.8 — 2026-08-19
Two reports from the same game, both about squares: which ones a card may go on, and which one a
button in the action list means.
### The Limits bound the district, not just the Running Track
**Reported:**
> "Sidings should not be allowed to be built outside the limits."
The Limits sign "denotes the limit of your control area" (§2.1) and §3 defines Secondary Track as
"all tracks **in your limits** that are not the Running Track" — but the bound was only ever enforced
on the Running Track row itself, with a comment in `canPlaceAt` stating outright that "the district
below the Running Track is unbounded". So a siding could run east past a player's own sign, take
industries with it, and put cars on track that §8.1 and §10 do not consider his territory at all: the
rules reason about "the track between the train and the Limits", and running past another player's
Limits is what makes a collision his fault.
`withinLimits` is now the district's rule at **every row**, and a Facility is bounded by it too —
§11.2 is explicit that "Facility cards carry their own rails: placing a Facility places track". The
refusal is its own code, `OUTSIDE_LIMITS`, rather than `NOT_CONNECTED`, for the same reason
`BREAKS_RUNNING_TRACK` is: the card would join perfectly well, and is refused for a different reason.
**Inclusive of the sign's own column**, which is the half that keeps the game playable. Jesse's call.
The sign stands *on* the boundary rather than beyond it, and a district opens with signs at ±1 around
the Office — so the strict reading would leave exactly one buildable column and break §11.3's promise
that both Secondary rows, and the nine-spot Modifier neighbourhood, are usable from the first Stage.
A siding may run under the sign; nothing may go past it.
**What it costs the bot: nothing measurable.** Out-of-limits building was rare for it to begin with —
5 cards across 100 games, in 4 of them, never more than two columns over — and paired over 200 seeds
the bound moved 4 games and −0.015 Revenue (t = −0.26). It is a human building deliberately who hits
this, which is exactly how it was found.
### The nine spots really are nine — the four diagonals were legal and unofferable
**Reported:**
> "Modifiers should be able to go on any diagonal — we were unable to place it to the south-east."
§9 places a Modifier "adjacent to a Facility, on any of the nine nearby spots", and `check` has
always accepted all eight neighbours. The fault was upstream in `placementCandidates`, which walks
north, south, east and west from every occupied square: a diagonal square with no *orthogonal*
occupied neighbour was never generated, so it was never offered. Measured on a facility below the
Office: `check` says legal at all four diagonals, the menu offered the three orthogonal ones. At a
stub industry — the case in the report — every diagonal is in that position.
Generated in a **second pass, only around a Facility**. Track has to *join* and a diagonal shares no
edge, so a Modifier is the only card those squares can ever take; generating diagonals around every
card instead costs 54% more simulation time producing candidates `check` then rejects. Appending
rather than interleaving leaves the existing candidate order untouched, which matters because the bot
breaks ties by first-best — measured over 200 seeded games, **0 played differently**, so every figure
in `TODO.md` still stands.
### A Modifier may hang outside the Limits — but not in the Running Track's row
Jesse's call, both halves. A Modifier is not track (§9), so unlike a siding it keeps all nine of its
spots when its host stands at the limit: refusing the outer three would make the card unplayable
exactly where the district ends. What it may not do is stand in the row the Running Track grows
along. Inside the Limits that row is always full, so the rule bites only beyond the sign — and that
is the ground the main extends onto, where a parked Modifier would block the player's own sign from
moving outward (§2.1) with nothing on screen to warn him. Industries have been barred from the
Running Track since the "Placed" column was read properly; this is the same rule for the same reason.
### The board draws where the district ends
Two signs on one row cannot say that nothing may be built outside their columns at *any* row: a
player looking at open ground beyond a sign had no way to know it was unbuildable until the square
failed to light up. `officeSvg` now draws a quiet dashed edge down the whole canvas at each limit,
labelled, and just **outside** the sign's own column so the picture agrees with the rule — a siding
may run under the sign. It moves outward on its own as the Running Track is extended, which is the
reward for extending made visible. The Frame carries `limits` for it, resolved server-side like
everything else a remote client cannot look up (`multiplayer.md` §5).
The margin on the viewBox is not cosmetic: the west sign is usually the leftmost card on the canvas,
so the line outside its column lands at x = −1.5 and simply is not there on a canvas starting at 0.
Caught by drawing one, not by reading the code.
### Hovering an action points at the square it means
**Reported:**
> "When I have my mouse over the action for a particular square, could the corresponding square in
> the office area be highlighted to minimize my mistakes of selecting the wrong square — 1,3 versus
> −1,3? In a solitaire game with Undo it is not fatal. In a multiplayer game that could be a major
> disaster."
The action list is a column of near-identical sentences separated by a coordinate, and the board
beside it said nothing about which was which. Every action that names a square now carries it as
`data-square`, and hovering — or focusing, so the keyboard route is not a lesser one — lights that
square on the board, ghost targets included, which is precisely the moment a player is choosing
between coordinates.
The coordinate rides on the **menu**, resolved from the intent by `coordOf`, rather than being parsed
back out of the label: a remote client holds no `GameState` and cannot look up where a tray is
standing, which is the same reason the menu already carries card descriptions. Checked over 12 bot
games: 870 direct actions name a square, and all 870 carry it.
### Also
- The three published replays were re-recorded. A save is a seed and its intents, so tightening a
placement rule kills every replay containing one — `harness.test.ts` catches it rather than letting
them go quietly dead, which has happened twice before. Old saves are not preserved across rule
changes and are not meant to be yet.
- `node_modules` was a **tracked symlink pointing at a path that does not exist**, so `tsc` was
missing and 20 tests failed before any of this was written. Replaced with the real dependencies.
## 0.4.7 — 2026-08-19
Eight play reports and one design that had been written up and not built. The through-line is the
+1 -1
View File
@@ -10,7 +10,7 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed.
## Status
**v0.4.3 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
**v0.4.8 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs.
- **Rules** — specified, with **three open questions** left. Thirteen gaps in the original prototype
+15
View File
@@ -52,6 +52,14 @@ Ordered within each section by how much it is currently costing us.
this needs re-measuring); or move the Express's freight budget into the Cargo phase, where an
expedited train does still get a turn. **Nothing is broken** — this is a contradiction between
two lines on one card, and the timing rule itself is behaving exactly as recorded.
- [ ] **A DISTRICT CAN NOW ONLY WIDEN AS FAR AS ITS MAIN REACHES (v0.4.8) — worth watching in the
rebalance rather than acting on now.** Track stays inside the Limits at every row, so extending
the Running Track is the only way to buy room for sidings, and a straight laid on the sign is
worth more than it was. The bot barely notices — it built outside its own Limits 5 times in 100
games — but the bot also builds close to its Office; a human building deliberately hits this on
the first wide district, which is how it was reported. If territory turns out to be the real
constraint on freight, this is one of the two places to look (the other is the track supply,
below).
- [ ] **REBALANCE, once the rules are right — deliberately deferred.** Card counts, industry counts
and the track mix all need a pass together, and none of them should move until the rules stop
moving. Standing distortions to account for when it happens: offices are doubled (Q12) and
@@ -623,6 +631,13 @@ target is settled and freight carries its intended share.
- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every
frame — but the number itself deserves a reason.
- [ ] **The test suite fails at random under `npm test`, and it is the runner rather than the code.**
`node --test test/**/*.test.ts` runs the files in parallel and three suites write and read the
same `dist/` — the static build, the published-replay check and "the three places a game is
drawn stay in step". Back-to-back full runs measured **9 failures then 0**; run one file at a
time and every suite passes. That is worse than a slow suite: it trains us to shrug at a red
run, which is exactly how a real regression gets waved through. Give the build test its own
output directory, or mark the trio to run serially.
- [ ] **Curves are drawn as two straight segments meeting**, not true arcs. Fine at this size, angular
close up.
- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal
+8 -2
View File
@@ -138,8 +138,14 @@ above and below. Upgrades are drop-in and must never disturb a connection.
## 5. Modifier cards
Five cards, one each. A Modifier is **not track**. It is placed adjacent to a Facility, on any of the
nine nearby spots, and if adjacent to two Facilities its effect may be used on only one per Stage
(§9).
nine nearby spots — **diagonals included** — and if adjacent to two Facilities its effect may be used
on only one per Stage (§9).
Two placement rules follow from it not being track. It is **not bounded by the Limits**: a host
standing at the edge of the district keeps all nine of its spots, or the card would be unplayable
exactly where the district ends. And it may **never stand in the Running Track's row**, which inside
the Limits is always full anyway — beyond the sign that row is the ground the main grows onto, and a
Modifier parked there would block the Limits sign from moving outward (§2.1).
| Card | Effect | Applies to |
| --- | --- | --- |
+7 -1
View File
@@ -593,10 +593,16 @@ horizontal row through the Office, Limit-to-Limit; everything else is Secondary
- Plain track, turnouts and Facility cards are all played from hand into an empty adjacent grid spot,
and **must connect to existing track**. Facility cards carry their own rails — placing a Facility
places track.
- Track stays **inside the Limits at every row**, not only on the Running Track. §2.1 makes the sign
"the limit of your control area" and §3 defines Secondary Track as "all tracks in your limits", so
a siding may run *under* a sign — the sign stands on the boundary — and never past it. Widening a
district therefore means extending the main first.
- Extending the **Running Track** horizontally moves that player's Limits sign outward with it (§2.1).
Growth is uncapped in the prototype.
- Modifier Facility cards are not track. They are placed adjacent to a Facility, on any of the nine
nearby spots (§9).
nearby spots (§9) — diagonals included, and not bounded by the Limits, since a host at the edge of
the district would otherwise have nowhere to put one. The one square they may not take is in the
Running Track's own row, which is where the main grows.
A longer Running Track is more track to keep clear of arriving trains, so territory carries its own
collision risk.
-1
View File
@@ -1 +0,0 @@
../../../node_modules
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "station-master",
"version": "0.0.1",
"version": "0.4.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "station-master",
"version": "0.0.1",
"version": "0.4.8",
"devDependencies": {
"@types/node": "^26.1.2",
"typescript": "^7.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.4.7",
"version": "0.4.8",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20
View File
@@ -77,6 +77,7 @@ import {
opposite,
reachableDestinations,
variantsFor,
withinLimits,
} from './track.ts';
export type ApplyResult =
@@ -1105,6 +1106,9 @@ function checkPlay(
if (placement.row === area.runningRow && !carriesThroughTrack(proto)) {
return 'BREAKS_RUNNING_TRACK';
}
// Same reasoning: a siding reaching past your own sign JOINS perfectly well, and is refused
// because it leaves your territory (§2.1). `canPlaceAt` enforces it too — this only names it.
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
}
@@ -1123,6 +1127,10 @@ function checkPlay(
* main line.
*/
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
// A Facility CARRIES TRACK — "placing a Facility places track" (§11.2) — so it is bounded by
// the Limits exactly as a siding is. An industry outside them is how a district used to leave
// its own territory.
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
// Q4 — a lockout prevents BUILDING both in one district: no duplicate, and never a producer
// alongside the consumer of the same commodity.
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
@@ -1133,6 +1141,18 @@ function checkPlay(
case 'modifier': {
if (!placement) return 'NO_PLACEMENT';
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
/**
* NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both
* halves.
*
* A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility
* standing at the limit has three of its nine spots out there, and refusing them would make
* the card unplayable exactly where the district ends. What it may NOT do is stand in the row
* the Running Track grows along. Inside the Limits that row is always full, so this bites only
* beyond the sign — which is the ground the main extends onto, and a Modifier parked there
* would block your own sign from moving outward (§2.1) with nothing on screen to warn you.
*/
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
+5
View File
@@ -143,6 +143,11 @@ export type RejectionCode =
| 'ON_RUNNING_TRACK'
/** A card with no east-west road would dead-end the Running Track it was laid in. */
| 'BREAKS_RUNNING_TRACK'
/**
* §2.1 — track, and the Facilities that carry it, stay inside your own Limits at every row. A
* Modifier is not track and is exempt (§9's nine spots), which is why this is not shared with it.
*/
| 'OUTSIDE_LIMITS'
/**
* A turnout may be laid on top of a straight, or of a curve on the same arc as its diverging leg
* — but only those, and only while the square is idle. See `checkTurnoutUpgrade`.
+30
View File
@@ -313,5 +313,35 @@ function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
out.push(c);
}
}
/**
* THE NINE-SPOT MODIFIER NEIGHBOURHOOD (§9), which the four-square walk above cannot reach.
*
* A Modifier goes "adjacent to a Facility, on any of the nine nearby spots" — diagonals included —
* and `check` has always accepted all eight. It was the CANDIDATES that were orthogonal-only, so a
* diagonal square with no orthogonal neighbour was legal and never offered: reported by Jesse as
* being unable to place a Modifier to the south-east of his industry.
*
* Only around a Facility, and only in a second pass. Track has to JOIN, and a diagonal shares no
* edge, so a Modifier is the only card these squares can ever take — generating diagonals around
* every card instead costs 54% more simulation time for candidates `check` then rejects. Appending
* rather than interleaving leaves the existing order untouched, which matters because the bot
* breaks ties by first-best: measured over 200 seeded games, 0 played differently.
*/
for (const [key, card] of area.grid) {
if (!card.facility) continue;
const [row, col] = key.split(',').map(Number);
for (const c of [
{ row: row! + 1, col: col! + 1 },
{ row: row! + 1, col: col! - 1 },
{ row: row! - 1, col: col! + 1 },
{ row: row! - 1, col: col! - 1 },
]) {
const k = `${c.row},${c.col}`;
if (seen.has(k) || area.grid.has(k)) continue;
seen.add(k);
out.push(c);
}
}
return out;
}
+28 -5
View File
@@ -559,6 +559,31 @@ export function carriesThroughTrack(card: TrackCard): boolean {
* a TURNOUT laid on the Running Track — the Office no longer carries a stub of its own, because no
* office or industry card on the printed sheet does.
*/
/**
* §2.1 — IS THIS SQUARE INSIDE THE DISTRICT AT ALL?
*
* The Limits sign denotes "the limit of your control area", and §3 defines Secondary Track as "all
* tracks IN YOUR LIMITS that are not the Running Track" — so the bound belongs to the district, not
* to the one row the sign happens to stand in. This used to guard the Running Track alone, on the
* reasoning that the rows above and below were unbounded; the consequence was a siding snaking east
* past your own sign, taking industries with it, and a player switching cars on track outside the
* territory §8.1 and §10 reason about. Reported by Jesse: sidings must not be built outside the
* Limits.
*
* INCLUSIVE OF THE SIGN'S OWN COLUMN. The sign stands ON the boundary rather than beyond it, and the
* opening district is signs at ±1 around the Office — so the strict reading would leave exactly one
* buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier
* neighbourhood, are usable from the first Stage.
*
* MODIFIERS ARE NOT SUBJECT TO THIS, and are not track: §9 places one on any of the nine spots
* around a Facility, and a Facility standing at the limit has three of its nine outside them.
* Jesse's call. `check` bars them from the Running Track ROW instead, which is the ground the main
* grows onto.
*/
export function withinLimits(area: OfficeArea, coord: GridCoord): boolean {
return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col;
}
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
const existing = cardAt(area, coord);
// A Limits sign on the Running Track is the GROWTH POINT, not an obstacle. Extending means laying
@@ -575,14 +600,12 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
// the sign itself is the only growth point. Allowing a placement beyond it built track on the far
// side of the sign and then planted a SECOND sign further out, leaving the board reading
// limits · Whistle Post · limits · straight · limits
// with a Limits card stranded mid-track. The district below the Running Track is unbounded.
if (coord.row === area.runningRow) {
if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false;
// with a Limits card stranded mid-track.
if (!withinLimits(area, coord)) return false;
// And it must not BREAK the Running Track. A curve laid here has no east-west road, so the main
// stops dead at it — the bot did exactly this, turning the west end of its own Running Track
// into a stub and cutting the Office off from the Limits.
if (!carriesThroughTrack(card)) return false;
}
if (coord.row === area.runningRow && !carriesThroughTrack(card)) return false;
const ports: Port[] = ['n', 's', 'e', 'w'];
for (const p of ports) {
+44 -1
View File
@@ -383,6 +383,16 @@ export function officeSvg(
* offered without a word. Reported as the Depot lighting up for no visible reason.
*/
legal: { row: number; col: number; label: string }[] = [],
/**
* The columns the Limits signs stand in, drawn as the district's east and west edges.
*
* Omitted by the previews and by the replay viewers' single-card renders, which have no district
* to bound. Where it is given, the boundary is drawn down the WHOLE canvas: track may not be laid
* outside these columns at any row (§2.1), and two signs sitting on one row could not say that —
* a player looking at open ground past a sign had no way to know nothing of his could ever go
* there until the square failed to light up.
*/
limits: { west: number; east: number } | null = null,
): string {
const W = 166;
const H = 96;
@@ -447,7 +457,15 @@ export function officeSvg(
return { x: W / 2, y: H };
};
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
/**
* ROOM FOR THE BOUNDARY. The Limits edge is drawn just outside the sign's own column, and the west
* sign is usually the leftmost card on the canvas — so at x = −1.5 the line lands outside a viewBox
* starting at 0 and is simply not there. The margin is added only when there is a boundary to draw.
*/
const margin = limits ? 3 : 0;
let out =
`<svg class="bs" viewBox="${-margin} 0 ${width + margin * 2} ${height}" ` +
`preserveAspectRatio="xMinYMin meet">`;
for (const cell of cells) {
const x = px(cell.col);
const y = py(cell.row);
@@ -804,6 +822,22 @@ export function officeSvg(
`<text x="${W / 2}" y="${H - 36}" text-anchor="middle">${esc(l.label)}</text></g>`;
}
/**
* THE LIMITS, AS THE EDGE OF THE BUILDABLE DISTRICT.
*
* Drawn just OUTSIDE the sign's own column, because the sign stands on the boundary rather than
* beyond it: a siding may run under the sign, and the line has to agree with the rule or it
* teaches the wrong one. It moves outward on its own as the Running Track is extended, which is
* the reward for extending made visible.
*/
if (limits) {
const edge = (x: number, side: string): string =>
`<line class="bs-limitline" x1="${x}" y1="0" x2="${x}" y2="${height}"/>` +
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 4}" ` +
`text-anchor="${side === 'w' ? 'start' : 'end'}">LIMITS</text>`;
out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e');
}
// Mark the Running Track so the spine of the district is unmistakable.
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
out += '</svg>';
@@ -914,6 +948,10 @@ export const BOARD_CSS = `
text.bs-mod{fill:#c8a04a}
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
/* THE EDGE OF THE DISTRICT. Deliberately quiet — it is a boundary, not an action — and dashed, so it
reads as a line on the table rather than as rail. Nothing is laid outside it (§2.1). */
.bs-limitline{stroke:#5f6b7a;stroke-width:1.5;stroke-dasharray:3 5;opacity:.7}
.bs-limitlab{fill:#5f6b7a;font:600 8px ui-monospace,monospace;letter-spacing:.12em}
.bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4}
.bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace}
/* The caption on a legal square that already carries a card. Same blue as the ghost squares and the
@@ -924,6 +962,11 @@ text.bs-mod{fill:#c8a04a}
.bs-ghost,g[data-cell].bs-legal{cursor:pointer}
g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5}
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
/* THE SQUARE THE ACTION UNDER THE CURSOR MEANS. White, and brighter than anything else the board
marks, because it answers a question being asked RIGHT NOW rather than describing a standing
state — and it has to win when it lands on a square that is already legal, focused or blocked. */
g[data-cell].bs-point .bs-card,g[data-ghost].bs-point rect{stroke:#f2f5f8;stroke-width:3.5;stroke-dasharray:none}
g[data-cell].bs-point .bs-card{fill:#2b3b52}
/* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable
card reads the same way an action button does. bs-from is where it is standing now. */
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
+1 -1
View File
@@ -499,7 +499,7 @@ function render() {
$('division').innerHTML = divisionSvg(DIV);
// The same office renderer the playable app uses, so replay and game draw one board.
$('grid').innerHTML = officeSvg(CELLS, f.runningRow);
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
/* Auto-hide: the district is worth its space during the phases that change it, and folded
otherwise. A summary stays, because a panel that vanishes reads as broken. */
+10
View File
@@ -356,6 +356,15 @@ export type Frame = {
cells: CellView[];
/** Which grid row is the Running Track — the spine the district hangs beneath. */
runningRow: number;
/**
* The columns the Limits signs stand in — the district's east and west edges, at EVERY row.
*
* Track may not be laid outside them (§2.1), so a player looking at open ground beyond a sign is
* looking at ground no card of his will ever go on. Two signs on one row could not say that; the
* board draws the boundary down the whole district instead. Carried on the Frame rather than read
* off the area for the usual reason: a remote client holds no `GameState`.
*/
limits: { west: number; east: number };
/**
* Moves left in this Local Operations turn, or null outside a switching turn.
*
@@ -1201,6 +1210,7 @@ export function snapshot(
handCount: (s.decks.hands.get(viewer) ?? []).length,
objective: objectiveOf(s, viewer),
runningRow: area.runningRow,
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
moves: switchingMoves(s, viewer),
blocked: impediments(s, viewer),
+49 -4
View File
@@ -87,9 +87,36 @@ export type ActionGroup = {
* from the browser, which needs the whole `GameState`. A remote client has no state, so the menu
* carries it. See `docs/architecture/multiplayer.md` §5.
*/
actions: { index: number; label: string; tip?: string }[];
/**
* `coord` is the square the action HAPPENS ON, when it happens on one.
*
* Carried as data rather than left inside the label. Half these buttons are near-identical
* sentences distinguished only by a coordinate — "(1,3)" against "(-1,3)" — and picking the wrong
* one is recoverable in solitaire, where Undo is a click, and a disaster in a multiplayer game
* where it is not. The page hovers the matching square on the board instead of asking the player
* to read the row and column off the button. Reported by Jesse.
*
* Resolved HERE for the same reason `tip` is: a remote client holds no `GameState` and cannot look
* up where a tray is standing. See `docs/architecture/multiplayer.md` §5.
*/
actions: { index: number; label: string; tip?: string; coord?: { row: number; col: number } }[];
};
/**
* The square an intent acts on, or null when it acts on none.
*
* Only squares the intent NAMES. A card played out on the Mainline (`node`) is not a district
* coordinate and must not be treated as one — that confusion is exactly why `placement` and `node`
* are separate fields on the intent (see `intents.ts`) — and an action like ending a turn has no
* square at all.
*/
export function coordOf(i: Intent): { row: number; col: number } | null {
if ('at' in i) return i.at;
if ('to' in i) return i.to;
if (i.type === 'card.play' && i.placement) return i.placement;
return null;
}
export type Game = {
state: GameState;
seed: number;
@@ -212,7 +239,13 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
* to survive every edit to a line nobody would think to check. One stray byte and every crew's
* moves silently collapsed back into a single group. The tray is data; it travels as data.
*/
type Entry = { index: number; label: string; tip?: string; trayId?: string };
type Entry = {
index: number;
label: string;
tip?: string;
trayId?: string;
coord?: { row: number; col: number };
};
const byKind = new Map<string, Entry[]>();
options.forEach((intent, index) => {
const label = describeIntent(game.state, intent);
@@ -227,14 +260,26 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
* on the crew as well makes the de-duplication per crew, which is what it always meant.
*/
if (!list.some((a) => a.label === label && a.trayId === trayId)) {
list.push({ index, label, ...(tip ? { tip } : {}), ...(trayId ? { trayId } : {}) });
const coord = coordOf(intent);
list.push({
index,
label,
...(tip ? { tip } : {}),
...(trayId ? { trayId } : {}),
...(coord ? { coord } : {}),
});
}
byKind.set(intent.type, list);
});
/** Groups take the plain shape; the crew was only ever needed to split them. */
const plain = (entries: Entry[]): ActionGroup['actions'] =>
entries.map(({ index, label, tip }) => ({ index, label, ...(tip ? { tip } : {}) }));
entries.map(({ index, label, tip, coord }) => ({
index,
label,
...(tip ? { tip } : {}),
...(coord ? { coord } : {}),
}));
const groups: ActionGroup[] = [];
const used = new Set<string>();
+57 -5
View File
@@ -301,7 +301,7 @@ function render(): void {
: 'ATTACH TO THIS CARD';
return [{ row: cell.row, col: cell.col, label }];
});
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps);
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits);
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
for (const [key, list] of spotsAt) {
@@ -650,6 +650,51 @@ function renderDistrict(f: Frame): void {
};
}
/**
* The square an action button acts on, as an attribute the board can be matched against.
*
* `null` for the actions that act on no square at all — ending a turn, drawing a card, a train card
* that goes to the timetable — so those buttons carry nothing and highlight nothing.
*/
function cellRef(coord: { row: number; col: number } | null | undefined): string {
return coord ? ` data-square="${coord.row},${coord.col}"` : '';
}
/**
* POINT AT THE SQUARE THE BUTTON MEANS.
*
* The action list is a column of sentences that differ by a coordinate — "(1,3)" against "(-1,3)" —
* and the board is right beside it saying nothing about which one is which. Hovering a button now
* lights its square up, so the check happens with the eye rather than by reading two numbers off a
* button and finding them on a map. Reported by Jesse: recoverable in solitaire, where Undo is a
* click; a disaster in multiplayer, where it is not.
*
* ON FOCUS AS WELL AS HOVER, so tabbing through the list works the same way as pointing at it — the
* keyboard route is not a lesser one.
*
* BOTH KINDS OF SQUARE. A played card is a `data-cell`; a square being placed ONTO is an empty
* `data-ghost` target, which is precisely the case where the player is choosing between coordinates.
* Matching only one of the two would leave the most mistake-prone moment unhelped.
*/
function wirePointing(node: HTMLElement, key: string): void {
const grid = $('grid');
const marks = (): Element[] =>
[
grid.querySelector(`g[data-cell="${key}"]`),
grid.querySelector(`g[data-ghost="${key}"]`),
].filter((g): g is Element => g !== null);
const on = (): void => {
for (const g of marks()) g.classList.add('bs-point');
};
const off = (): void => {
for (const g of marks()) g.classList.remove('bs-point');
};
node.onmouseenter = on;
node.onmouseleave = off;
node.onfocus = on;
node.onblur = off;
}
function renderActions(
menu: Menu,
f: Frame,
@@ -694,14 +739,19 @@ function renderActions(
* the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3",
* which had neither a name for the card it meant nor a word about what it would do.
*/
const actionButton = (a: { index: number; label: string; tip?: string }): string => {
const actionButton = (a: {
index: number;
label: string;
tip?: string;
coord?: { row: number; col: number };
}): string => {
const { label, index } = a;
const cut = label.indexOf(' — ');
const head = cut > 0 ? label.slice(0, cut) : label;
// The menu resolved the card's description server-side, so the page never needs the state.
const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? '');
return (
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
`<button class="act" data-i="${index}"${cellRef(a.coord)}${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
};
@@ -748,7 +798,7 @@ function renderActions(
f.moves
.map(
(m) =>
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}" ` +
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` +
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.row}, ${m.from.col}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
`${esc(m.label)} <span class="dim">(${m.from.row}, ${m.from.col})</span></button>`,
)
@@ -847,7 +897,7 @@ function renderActions(
? ` data-tip-html="${esc(piecePreview(sp.links, item.subject))}"`
: '';
return (
`<button class="act" data-i="${sp.index}"${fig}` +
`<button class="act" data-i="${sp.index}"${fig}${cellRef(sp.coord)}` +
` data-tip="${esc(`${item.subject} — ${sp.label}`)}">${esc(sp.label)}</button>`
);
})
@@ -882,6 +932,8 @@ function renderActions(
render();
}
: () => apply(Number(node.dataset['i']));
const square = node.dataset['square'];
if (square) wirePointing(node, square);
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ function show(i: number): void {
$('vpos').textContent = `${at} / ${steps.length - 1}`;
($('vscrub') as HTMLInputElement).value = String(at);
$('vdivision').innerHTML = divisionSvg(f.division);
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow, [], [], f.limits);
// Auto-hide: the district is worth its space during the phases that change it.
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
+179 -1
View File
@@ -930,6 +930,177 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
// ---------------------------------------------------------------------------
describe('the Limits bound the district, and the nine spots reach round a Facility', () => {
/**
* A district whose Running Track has been extended one square east, so the sign stands at col 2
* and there is a siding hanging below the main to build on:
*
* row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track, Limits at -1 and 2
* row -1: [curve ne]
*
* Returns the industry-ready square under the sign, at (-1, 2).
*/
function district(s: GameState): GridCoord {
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
geometry: geometry as TrackCard['geometry'],
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
const col = area.limitsEast.col;
area.grid.set(coordKey(at(area.runningRow, col)), plain({
kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left',
}));
area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({
kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left',
}));
area.limitsEast = at(area.runningRow, col + 1);
area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' }));
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'draw';
return at(area.runningRow - 1, col + 1);
}
const trackInHand = (s: GameState, geometry: string): string => {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'track' && card.kind.geometry === geometry) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no ${geometry} card`);
};
const industryOf = (s: GameState, facility: string): string => {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'freightFacility' && card.kind.facility === facility) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no industry card: ${facility}`);
};
const modifierOf = (s: GameState, modifier: string): string => {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'modifier' && card.kind.modifier === modifier) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no modifier card: ${modifier}`);
};
/** An industry standing on the board, so a Modifier has a host to hang off. */
const buildFacility = (s: GameState, kind: string, coord: GridCoord): void => {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
};
it('accepts a siding in the sign\'s own column and refuses one past it', () => {
/**
* REPORTED by Jesse: "sidings should not be allowed to be built outside the limits". §3 defines
* Secondary Track as "all tracks IN YOUR LIMITS that are not the Running Track", so the bound
* belongs to the district rather than to the one row the sign stands in — it used to guard the
* Running Track alone, and a siding could run east past a player's own sign, taking industries
* with it, onto track that §8.1 and §10 do not consider his territory at all.
*
* INCLUSIVE of the sign's column, which is the half that keeps the game playable: the opening
* district is signs at ±1 around the Office, so the strict reading would leave one buildable
* column and break §11.3's promise that both Secondary rows are usable from the first Stage.
*/
const s = game();
const under = district(s);
const cardId = trackInHand(s, 'straight');
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: under }),
null,
'a siding may run under the Limits sign — the sign stands ON the boundary, not beyond it',
);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
'OUTSIDE_LIMITS',
'a siding was built outside the district it belongs to',
);
});
it('refuses an industry outside the Limits — a Facility carries track', () => {
// §11.2: "Facility cards carry their own rails — placing a Facility places track." So the bound
// is the same one, and said with the same code rather than left to read as NOT_CONNECTED.
const s = game();
const under = district(s);
const cardId = industryOf(s, 'mineTipple');
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: under }), null);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
'OUTSIDE_LIMITS',
);
});
it('offers a Modifier the DIAGONAL spots around its host, not just the four orthogonal ones', () => {
/**
* REPORTED by Jesse: a Modifier could not be placed to the south-east of his industry. §9 places
* one "adjacent to a Facility, on any of the nine nearby spots" and `check` has always accepted
* all eight neighbours — it was `placementCandidates` that walked north, south, east and west
* only, so a diagonal square with no orthogonal neighbour was legal and never offered.
*/
const s = game();
const under = district(s);
buildFacility(s, 'packingSheds', under);
const cardId = modifierOf(s, 'iceHouse');
const offered = legalActions(s, 0)
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
.map((i) => coordKey((i as { placement: GridCoord }).placement));
// South-east of the host, and orthogonally adjacent to nothing at all.
const southEast = at(under.row - 1, under.col + 1);
assert.ok(
offered.includes(coordKey(southEast)),
`the south-east spot (${southEast.row}, ${southEast.col}) is legal but was never offered — offered: ${offered.join(' ')}`,
);
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
});
it('lets a Modifier hang outside the Limits, but never in the Running Track row', () => {
/**
* Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps
* all nine of its spots — refusing the outer three would make the card unplayable exactly where
* the district ends. The Running Track ROW is the exception: inside the Limits that row is
* always full, so this bites only beyond the sign, and that is the ground the main grows onto —
* a Modifier parked there would block the player's own sign from moving outward (§2.1).
*/
const s = game();
const under = district(s);
buildFacility(s, 'packingSheds', under);
const cardId = modifierOf(s, 'iceHouse');
const area = areaOf(s, 0);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
null,
'a Modifier beside a host at the limit was refused the spot outside it',
);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
'ON_RUNNING_TRACK',
'a Modifier was allowed to stand in the row the Running Track grows along',
);
});
});
// ---------------------------------------------------------------------------
describe("a Modifier grants only what its host's flow can use", () => {
const withFacility = (kind: string, out: boolean, into: boolean) => ({
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
@@ -1111,8 +1282,13 @@ describe('Industry cards go on a stub, and lock each other out', () => {
* A district with a siding hanging off the Running Track, which is the only place an industry may
* go. Returns the siding square east of the curve.
*
* row 0: [lim] [office] [turnout, leg south] … <- Running Track
* row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track
* row -1: [curve ne] [siding square]
*
* The turnout is laid ON the east Limits sign, which is how the Running Track grows — so the sign
* MOVES OUT with it (§2.1, Gap 4a), exactly as `extendLimitsIfNeeded` does when the card is played
* rather than written straight into the grid. Without that the siding square would be outside the
* district's own Limits, which is no longer a place track may go.
*/
function withSiding(s: GameState): GridCoord {
const area = areaOf(s, 0);
@@ -1127,6 +1303,8 @@ describe('Industry cards go on a stub, and lock each other out', () => {
area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({
kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left',
}));
area.limitsEast = at(area.runningRow, col + 1);
area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' }));
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'draw';
+86 -2
View File
@@ -598,6 +598,54 @@ describe('the board shows freight work happening', () => {
assert.ok(y > 48, `the caption at y=${y} prints over the rail`);
});
it('draws the Limits as the edge of the buildable district, at every row', () => {
/**
* Track may not be laid outside the Limits at ANY row now (§2.1), and two signs sitting on the
* Running Track could not say that: a player looking at open ground beyond a sign had no way to
* know nothing of his would ever go there until the square failed to light up.
*
* Just OUTSIDE the sign's own column, because the sign stands ON the boundary — a siding may run
* under it — so a line drawn inside the sign would teach the opposite of the rule.
*/
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const f = view(game);
assert.deepEqual(
f.limits,
{ west: area.limitsWest.col, east: area.limitsEast.col },
'the Frame does not carry the district edges a remote client cannot look up',
);
const svg = officeSvg(f.cells, f.runningRow, [], [], f.limits);
const xs = [...svg.matchAll(/class="bs-limitline" x1="(-?[\d.]+)"/g)].map((m) => Number(m[1]));
assert.equal(xs.length, 2, 'the district was drawn without both of its edges');
// The sign cards themselves must fall INSIDE the two lines.
const signX = [...svg.matchAll(/data-cell="(-?\d+),(-?\d+)"[^>]*transform="translate\((-?[\d.]+)/g)]
.filter((m) => Number(m[2]) === area.limitsWest.col || Number(m[2]) === area.limitsEast.col)
.map((m) => Number(m[3]));
assert.ok(signX.length > 0, 'no Limits sign was drawn to check the boundary against');
for (const x of signX) {
assert.ok(
x >= Math.min(...xs) && x <= Math.max(...xs),
`a Limits sign at x=${x} sits outside its own boundary (${xs.join(', ')})`,
);
}
assert.match(svg, /bs-limitlab/, 'the boundary is drawn but never named');
// AND ON THE CANVAS. The west sign is the leftmost card, so a line drawn outside its column sits
// at a negative x — off a viewBox that starts at 0, which is a boundary nobody can see.
const vb = /viewBox="(-?[\d.]+) (-?[\d.]+) ([\d.]+) ([\d.]+)"/.exec(svg);
assert.ok(vb, 'the board produced no viewBox');
const x0 = Number(vb![1]);
for (const x of xs) {
assert.ok(
x >= x0 && x <= x0 + Number(vb![3]),
`the boundary at x=${x} is drawn outside the canvas (${x0} to ${x0 + Number(vb![3])})`,
);
}
});
it('says on the card which way an industry runs, and how many workers it has', () => {
/**
* TWO REPORTS, ONE CARD.
@@ -1314,7 +1362,7 @@ describe('the static build', () => {
}
});
it('renders clickable highlights on the board when a card is picked', async () => {
it('renders clickable highlights on the board, and points at the square an action names', async () => {
// The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
// nothing about whether the page draws them.
@@ -1366,7 +1414,12 @@ describe('the static build', () => {
const classes = new Set<string>();
found.set(key, {
onclick: null,
classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) },
classList: {
add: (c: string) => void classes.add(c),
// Pointing at a square is a HOVER: it has to come off again when the cursor leaves.
remove: (c: string) => void classes.delete(c),
has: (c: string) => classes.has(c),
},
classes,
});
}
@@ -1493,6 +1546,37 @@ describe('the static build', () => {
const lit = (grid['highlighted'] as () => unknown[])();
const ghosts = /data-ghost="/.test(html);
assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board');
/**
* POINTING AT THE SQUARE A BUTTON MEANS — wired on the emitted bundle, not asserted off the menu.
*
* The menu carrying a coordinate proves nothing about whether the page draws it, which is the
* same reason the highlight check above loads the real bundle. Reported by Jesse: the action list
* is a column of near-identical sentences separated by "(1,3)" against "(-1,3)", and picking the
* wrong one is a click to undo in solitaire and unrecoverable in a multiplayer game.
*/
const withSquare = (
(actions['querySelectorAll'] as (s: string) => Record<string, unknown>[])
.call(actions, 'button.act')
.filter((n) => (n['dataset'] as Record<string, string>)['square'])
);
assert.ok(withSquare.length > 0, 'no action button carries the square it acts on');
const btn = withSquare[0]!;
const key = (btn['dataset'] as Record<string, string>)['square']!;
assert.match(key, /^-?\d+,-?\d+$/, `"${key}" is not a grid coordinate`);
(btn['onmouseenter'] as (() => void) | null)?.();
const pointed = (grid['highlighted'] as () => Record<string, unknown>[])().filter((g) =>
(g['classes'] as Set<string>).has('bs-point'),
);
assert.equal(pointed.length, 1, `hovering the action for (${key}) marked ${pointed.length} squares`);
(btn['onmouseleave'] as (() => void) | null)?.();
assert.equal(
(grid['highlighted'] as () => Record<string, unknown>[])().filter((g) =>
(g['classes'] as Set<string>).has('bs-point'),
).length,
0,
'the square stayed lit after the cursor left the button',
);
});
it('busts the cache on every module, so a deploy cannot half-load', () => {