Show the whole Division mainline. district now has option for auto hiding on phases where not needed. scaffold for multi-player geometry showing division. regions on mainline cards implemented. bug fix on laying rail cards. optimized replay (carried links forward). Add sounds, first pass
This commit is contained in:
+137
@@ -10,6 +10,143 @@ The target is 20 Revenue over 5 Days.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### A legal move you could not click
|
||||||
|
|
||||||
|
Reported from playtesting: laying a straight offered three places in the list and highlighted one on
|
||||||
|
the board.
|
||||||
|
|
||||||
|
Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG afterwards. It computed
|
||||||
|
its origin over cells PLUS the offered spots, while `officeSvg` sized itself over cells alone — so
|
||||||
|
the two disagreed the moment a legal square lay outside the played cards, which is every square that
|
||||||
|
would EXTEND the district. Reproduced on seed 555: three placements offered, the empty one drawn at
|
||||||
|
y=99 on a canvas 103 tall, i.e. off it entirely.
|
||||||
|
|
||||||
|
`officeSvg` now takes the ghosts and sizes itself over both, so there is one origin and one canvas,
|
||||||
|
and `ghostSvg` is deleted rather than fixed — a second coordinate system was the bug, not a detail
|
||||||
|
of it. It was also imported by the replay and never called there.
|
||||||
|
|
||||||
|
A test walks six seeds and asserts every square the menu offers has a target drawn inside the
|
||||||
|
canvas: 129 squares across 47 placeable subjects. It fails on the old code with the exact
|
||||||
|
coordinates above.
|
||||||
|
|
||||||
|
### The railroad, heard
|
||||||
|
|
||||||
|
A whistle at the end of each Stage, the grade-crossing bell at the end of each Day, and the
|
||||||
|
conductor when a train is built. **Off by default** — everything here is synthesised rather than
|
||||||
|
recorded, so it is a placeholder for real audio and a playtester who did not ask for noise should
|
||||||
|
not get any. One click in the title bar turns it on, and that click doubles as the gesture browsers
|
||||||
|
require before audio may start.
|
||||||
|
|
||||||
|
**Synthesised, not sampled**, and that is a constraint rather than a preference: the site is a static
|
||||||
|
folder that fetches nothing — there is a test asserting no page reaches an external host — so audio
|
||||||
|
would have to be committed to the repo, and a plausible whistle is not something to invent. A real
|
||||||
|
steam whistle is a CHORD of several chambers slightly out of tune with each other plus the breath of
|
||||||
|
the steam, which three detuned partials and a band of filtered noise get most of the way to. The
|
||||||
|
bell is inharmonic partials struck twice, which is what separates a bell from a beep.
|
||||||
|
|
||||||
|
**"All aboard" is speech, and speech cannot be faked with oscillators.** `speechSynthesis` is built
|
||||||
|
into the browser, needs no asset and works offline, so it says the words; where the platform has no
|
||||||
|
voice installed a two-note conductor's call takes its place rather than nothing happening.
|
||||||
|
|
||||||
|
The model names WHAT happened — a Stage ended, a Day turned, a train was made up — and the page
|
||||||
|
decides what that sounds like. A Day boundary rings the bell only: both would collide, and the bell
|
||||||
|
is the bigger event. The first Stage of the game announces nothing, because a Stage beginning is the
|
||||||
|
previous one ending and there is no previous one.
|
||||||
|
|
||||||
|
Counted against the clock rather than trusted: over a full game, 60 Stage boundaries produced 55
|
||||||
|
whistles and 5 bells, and 5 Days produced 5 bells.
|
||||||
|
|
||||||
|
### Regions, drawn from what the crossing already cost
|
||||||
|
|
||||||
|
§2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. The
|
||||||
|
engine had replaced that with `crossingStages()` — `implications.md` records it plainly: "The
|
||||||
|
`Region` model is gone" — because ten card types with real speeds cannot be expressed by one region
|
||||||
|
a Stage. Both `REGIONS_PER_MAINLINE_CARD = 2` and `entryPoints` survived as dead constants.
|
||||||
|
|
||||||
|
The map draws regions again without reinstating the mechanic, because the printed cards say how:
|
||||||
|
they carry **Start positions**, so a train with Brakemen *enters further along the card*. That is
|
||||||
|
the same fact as "takes a Stage off the crossing", in different coordinates. So position falls out
|
||||||
|
of what the engine already knows:
|
||||||
|
|
||||||
|
entry = REGIONS - stagesTotal position = clamp(entry + elapsed)
|
||||||
|
|
||||||
|
| crossing | drawn as | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 Stage (a 60 card) | enters at region 2, gone next Stage | the printed Start position |
|
||||||
|
| 2 Stages (a 30 card) | region 1 → region 2 | **§8.2 exactly** |
|
||||||
|
| 3 Stages (slow train) | region 1 → region 1 → region 2 | fixed distance, slow train |
|
||||||
|
|
||||||
|
`entry` is deliberately allowed to go negative and only the final position is clamped: that keeps a
|
||||||
|
slow train's extra Stage at the START, where being slow shows. Clamping the entry instead parked it
|
||||||
|
at the exit, reading as a train that raced across and then waited — which is what the first version
|
||||||
|
did, and what the test now forbids.
|
||||||
|
|
||||||
|
`Transit` gains `stagesTotal`, set on entry. State, not rules: nothing reads it to decide anything,
|
||||||
|
so the bot and every balance figure are untouched, and a save is a seed plus intents so there is
|
||||||
|
nothing to migrate. It cannot be recomputed later — a modifier played onto the card mid-crossing
|
||||||
|
would change the answer and make the train jump backwards.
|
||||||
|
|
||||||
|
Division Points draw one region, the queue. Running Track cards inside a district draw none: a crew
|
||||||
|
moves there by Moves, not Stages, so it occupies a card outright.
|
||||||
|
|
||||||
|
### The Division map shows the whole route
|
||||||
|
|
||||||
|
The map drew one box per node, which collapsed each player's entire district into a single "Office"
|
||||||
|
tile — so the track a train actually runs along was invisible on the only view that shows where
|
||||||
|
trains are.
|
||||||
|
|
||||||
|
An Office now expands into its **Running Track, Limits to Limits**. That is the right cut rather than
|
||||||
|
a compromise: the through route IS the Running Track, and everything hanging beneath it is secondary
|
||||||
|
track a crossing train never touches. Sidings, industries and the load pipeline stay in the district
|
||||||
|
view, which is where they can be read.
|
||||||
|
|
||||||
|
West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
|
||||||
|
|
||||||
|
**Trains are shown wherever they are.** On a Running Track card, on a Mainline card, queued at a
|
||||||
|
Division Point. A crew working BELOW the Running Track has no position on the through route, so it
|
||||||
|
is reported against the district — "2 switching below" — rather than drawn somewhere it is not.
|
||||||
|
Projecting a siding onto the through line would be a lie the map cannot support.
|
||||||
|
|
||||||
|
**Seating.** Players sit around a table, so the route is laid out the way they do: one row alone,
|
||||||
|
two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop —
|
||||||
|
trains enter at one Division Point and leave at the other — so the shape is deliberately left open,
|
||||||
|
with buffer stops at both ends and the gap labelled "trains enter" and "trains leave". Closing it
|
||||||
|
into a ring would promise a connection the rules do not have.
|
||||||
|
|
||||||
|
Layout is geometry with no visual feedback loop, so it is tested rather than eyeballed: for 1, 2, 3
|
||||||
|
and 4 players no two cells may overlap and none may fall outside the canvas.
|
||||||
|
|
||||||
|
### The district folds itself away
|
||||||
|
|
||||||
|
Auto-focus. The district is worth its vertical space during the phases that change it — Local
|
||||||
|
Operations and Cargo — and not during New Train, Mainline or Supervisor Shift, where the Division
|
||||||
|
map is what matters. Folded, it leaves a summary line rather than vanishing, because a panel that
|
||||||
|
disappears entirely reads as broken.
|
||||||
|
|
||||||
|
A manual toggle overrides it and stays put. This is DISPLAY state and never game state: two players
|
||||||
|
at the same table may reasonably want it set differently.
|
||||||
|
|
||||||
|
### The replay was storing the same things over and over
|
||||||
|
|
||||||
|
Halved, near enough: **3415 KB → 1877 KB** on a 735-frame game.
|
||||||
|
|
||||||
|
The TODO said to carry `links` forward. Measured first, that would have bought 5% of the `cells`
|
||||||
|
payload — `cells` is 62% of the file, and inside it the cost is elsewhere:
|
||||||
|
|
||||||
|
what 32% of cells long prose, identical on every turnout in the district
|
||||||
|
facility 24% of cells the SAME object already serialised in the frame's `facilities`
|
||||||
|
identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
|
||||||
|
|
||||||
|
So all three are interned. A card's identity and its description are written once for the whole
|
||||||
|
recording and referenced by integer, and a cell points at its facility instead of carrying a second
|
||||||
|
copy of it. What stays per-frame is what genuinely changes: the cars standing there, the crew, and
|
||||||
|
any Enhancement laid on the card.
|
||||||
|
|
||||||
|
`rehydrateCells` is exported and emitted into the page by `toString()`, the same trick the two board
|
||||||
|
renderers use — a second copy inside the page's inline script could drift from the packing and the
|
||||||
|
symptom would be a board drawing the wrong cards rather than an error. The round-trip test runs that
|
||||||
|
exact function over every frame, resolving nulls the way the page does.
|
||||||
|
|
||||||
### Rolling stock was leaving the game
|
### Rolling stock was leaving the game
|
||||||
|
|
||||||
Four things were tried against bot revenue. **One of them was worth more than everything else in
|
Four things were tried against bot revenue. **One of them was worth more than everything else in
|
||||||
|
|||||||
@@ -93,6 +93,39 @@ target is settled and freight carries its intended share.
|
|||||||
|
|
||||||
## Not yet built
|
## Not yet built
|
||||||
|
|
||||||
|
- [ ] **Real audio, as committed assets.** Everything the game plays is synthesised from oscillators
|
||||||
|
(`src/web/sound.ts`), which was the honest choice for a site that fetches nothing — but it is a
|
||||||
|
placeholder, not the finished sound. Sound therefore defaults to OFF.
|
||||||
|
- **"All aboard" most of all.** It currently goes through the browser's `speechSynthesis`, so
|
||||||
|
it is whatever system voice the player happens to have — a robot, not a conductor. A real
|
||||||
|
clip is the single biggest improvement available here.
|
||||||
|
- **Find and add the rest as assets**: steam whistle, grade-crossing bell, couplers clashing,
|
||||||
|
a train pulling away. Needs licences that permit redistribution (CC0 or similar), files small
|
||||||
|
enough to commit, and a check that the "fetches nothing external" test still passes — assets
|
||||||
|
must be served from the site's own folder, never hot-linked.
|
||||||
|
- Keep the synthesised versions as the fallback for anything not sourced, so a missing file is
|
||||||
|
a quieter game rather than a broken one.
|
||||||
|
|
||||||
|
- [ ] **Regions as the primary model (the other half of §8.2).** The Division map now DRAWS regions,
|
||||||
|
deriving position from what the crossing already cost. The engine still models a crossing as a
|
||||||
|
countdown of Stages, so two things printed on the cards remain unimplemented:
|
||||||
|
- `entryPoints` is declared on every Mainline profile and read nowhere. The Heavy Grade card
|
||||||
|
has five named Start positions, and playing Brakeman is supposed to move your entry point
|
||||||
|
along the card. The engine gets the same ANSWER by taking a Stage off the crossing, which is
|
||||||
|
why the derived drawing looks right — but the mechanism is not the printed one, so a card
|
||||||
|
whose starts do not correspond to its speed would be drawn wrong.
|
||||||
|
- `implications.md` §6 calls this "the single largest mechanical gap" and asks for typed cards
|
||||||
|
with speeds and named entries, with crossing time DERIVED from the region walk.
|
||||||
|
Doing it properly changes movement, so it invalidates every balance figure — revenue 8.7, the
|
||||||
|
freight numbers, all of it — and needs a full paired re-measure over 400 seeds. Needs the
|
||||||
|
source Start-position art for the ten card types before it can begin.
|
||||||
|
|
||||||
|
- [ ] **Player settings, saved.** The district's auto-focus is the first of these: it is DISPLAY
|
||||||
|
state, so in a multiplayer game two players may reasonably want it set differently and it must
|
||||||
|
never become part of game state. It currently resets on reload. Worth a settings object in
|
||||||
|
localStorage — auto-focus mode to start with, and whatever else earns a preference — kept
|
||||||
|
strictly separate from the save, which is the seed plus the intents and has to stay portable.
|
||||||
|
|
||||||
- [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom,
|
- [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom,
|
||||||
Teams or Jitsi call and post into its chat, it could carry the whole table's shared state
|
Teams or Jitsi call and post into its chat, it could carry the whole table's shared state
|
||||||
without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone
|
without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone
|
||||||
@@ -117,8 +150,11 @@ target is settled and freight carries its intended share.
|
|||||||
|
|
||||||
## Smaller things
|
## Smaller things
|
||||||
|
|
||||||
- [ ] **Carry `links` forward in replay frames.** Static per card and never changes once laid, but
|
- [x] **Carry `links` forward in replay frames.** Done, and the premise was wrong in an instructive
|
||||||
re-sent whenever anything on the board changes. The replay is 3.4 MB, mostly board state.
|
way: measured, `links` was 5% of the `cells` payload. What actually cost was the `what` prose
|
||||||
|
(32%), the facility object stored a second time inside its own cell (24%) and the rest of the
|
||||||
|
static identity (26%). All three are interned now — 3415 KB → 1877 KB, and a round-trip test
|
||||||
|
runs the page's own unpacking function.
|
||||||
- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
|
- [ ] **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
|
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.
|
frame — but the number itself deserves a reason.
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ function enterMainline(
|
|||||||
tray.direction,
|
tray.direction,
|
||||||
node.gradeUp ?? 'east',
|
node.gradeUp ?? 'east',
|
||||||
);
|
);
|
||||||
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
|
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
|
||||||
tray.position = { at: 'mainline', index };
|
tray.position = { at: 'mainline', index };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -228,7 +228,24 @@ export type CrewTray = {
|
|||||||
* A train part-way across a Mainline card. Crossing time is measured in STAGES (Q1/Q2), so the
|
* A train part-way across a Mainline card. Crossing time is measured in STAGES (Q1/Q2), so the
|
||||||
* regions the placeholder used are gone — the cells printed on the cards are decoration.
|
* regions the placeholder used are gone — the cells printed on the cards are decoration.
|
||||||
*/
|
*/
|
||||||
export type Transit = { tray: TrayId; stagesRemaining: number; direction: Direction };
|
/**
|
||||||
|
* A train crossing a Mainline card.
|
||||||
|
*
|
||||||
|
* `stagesTotal` is what the crossing cost when the train ENTERED, and it exists so the Division map
|
||||||
|
* can say where on the card a train is. §2.1 divides a Mainline card into two regions and §8.2 has
|
||||||
|
* a train move one region per Stage; the engine models crossing as a countdown of Stages instead,
|
||||||
|
* so position has to be derived — and it cannot be recomputed later, because a modifier played onto
|
||||||
|
* the card mid-crossing would change the answer and make the train appear to jump backwards.
|
||||||
|
*
|
||||||
|
* Read by nothing that decides anything: no legality check, no movement, no bot. It records what
|
||||||
|
* already happened so the picture can be drawn.
|
||||||
|
*/
|
||||||
|
export type Transit = {
|
||||||
|
tray: TrayId;
|
||||||
|
stagesRemaining: number;
|
||||||
|
stagesTotal: number;
|
||||||
|
direction: Direction;
|
||||||
|
};
|
||||||
|
|
||||||
export type DivisionNode =
|
export type DivisionNode =
|
||||||
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
|
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
|
||||||
|
|||||||
+283
-82
@@ -29,82 +29,274 @@ export type BoardTrain = { label: string; consist: string[] };
|
|||||||
* separated by thin seams, capacity legible because the lines can be counted.
|
* separated by thin seams, capacity legible because the lines can be counted.
|
||||||
*/
|
*/
|
||||||
export function divisionSvg(nodes: DivisionView[]): string {
|
export function divisionSvg(nodes: DivisionView[]): string {
|
||||||
const COL = 190;
|
/**
|
||||||
const TOP = 34;
|
* THE WHOLE DIVISION, west to east, as one continuous route.
|
||||||
const LANE = 30;
|
*
|
||||||
const width = Math.max(1, nodes.length) * COL;
|
* It used to draw one box per node, which collapsed every player's district into a single
|
||||||
const maxCap = nodes.reduce((n, d) => Math.max(n, d.capacity ?? 3), 1);
|
* "Office" tile — so the thing a train actually runs along, the Running Track between the Limits,
|
||||||
const height = TOP + maxCap * LANE + 54;
|
* was invisible on the only map that shows where trains are. Now an Office expands into its
|
||||||
|
* Running Track, Limits to Limits, and the route reads end to end:
|
||||||
|
*
|
||||||
|
* West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
|
||||||
|
*
|
||||||
|
* SEATING. Players sit around a table, so the route is laid out the way they do: one row alone,
|
||||||
|
* two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop
|
||||||
|
* — trains enter at one Division Point and leave at the other — so the shape is deliberately left
|
||||||
|
* open, with the two ends drawn as buffer stops facing each other across a marked gap. Closing it
|
||||||
|
* into a ring would promise a connection the rules do not have.
|
||||||
|
*
|
||||||
|
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
||||||
|
* anything outside its own body.
|
||||||
|
*/
|
||||||
|
const CW = { dp: 118, ml: 152, run: 78 };
|
||||||
|
const CH = 58;
|
||||||
|
const GAP = 6;
|
||||||
|
const PAD = 26;
|
||||||
|
const SIDE_GAP = 34;
|
||||||
|
|
||||||
const esc = (t: string): string =>
|
const esc = (t: string): string =>
|
||||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||||
|
|
||||||
|
// -- flatten the route into drawable cells, remembering which belong to whose district --------
|
||||||
|
type Cell = {
|
||||||
|
kind: 'dp' | 'ml' | 'run';
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
trains: { label: string; consist: string[]; region?: number; direction?: string }[];
|
||||||
|
cap: number | null;
|
||||||
|
tip: string;
|
||||||
|
owner: number | null;
|
||||||
|
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||||||
|
regions: number;
|
||||||
|
w: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
const cells: Cell[] = [];
|
||||||
|
const sides: number[][] = [];
|
||||||
|
let side: number[] = [];
|
||||||
|
|
||||||
|
const push = (c: Omit<Cell, 'x' | 'y'>): void => {
|
||||||
|
side.push(cells.length);
|
||||||
|
cells.push({ ...c, x: 0, y: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.kind === 'office') {
|
||||||
|
const cap = n.capacity;
|
||||||
|
const ad = n.trains.flat();
|
||||||
|
for (const rc of n.running ?? []) {
|
||||||
|
const isOffice = rc.kind === 'office';
|
||||||
|
push({
|
||||||
|
kind: 'run',
|
||||||
|
label: rc.label,
|
||||||
|
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '',
|
||||||
|
// A train standing at the Office occupies an A/D track, which is where it is.
|
||||||
|
trains: isOffice ? [...rc.trains, ...ad] : rc.trains,
|
||||||
|
cap: isOffice ? cap : null,
|
||||||
|
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
|
||||||
|
owner: n.owner ?? null,
|
||||||
|
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
|
||||||
|
// occupies a card outright rather than a part of one.
|
||||||
|
regions: 0,
|
||||||
|
w: CW.run,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A crew below the Running Track has no position ON it, so it is reported against the
|
||||||
|
// district rather than drawn somewhere it is not.
|
||||||
|
const below = n.switching ?? [];
|
||||||
|
if (below.length > 0) {
|
||||||
|
const last = cells[cells.length - 1];
|
||||||
|
if (last) last.sub = `${below.length} switching below`;
|
||||||
|
}
|
||||||
|
sides.push(side);
|
||||||
|
side = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dp = n.kind === 'dp';
|
||||||
|
push({
|
||||||
|
kind: dp ? 'dp' : 'ml',
|
||||||
|
label: n.label,
|
||||||
|
sub: n.capacity === null ? 'no limit — trains queue' : `${Math.max(0, n.capacity - n.trains.flat().length)} of ${n.capacity} free`,
|
||||||
|
trains: n.trains.flat(),
|
||||||
|
cap: n.capacity,
|
||||||
|
tip: dp
|
||||||
|
? 'A Division Point — trains enter and leave the Division here, and queue without limit'
|
||||||
|
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`,
|
||||||
|
owner: null,
|
||||||
|
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||||||
|
regions: dp ? 1 : (n.regions ?? 0),
|
||||||
|
w: dp ? CW.dp : CW.ml,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (side.length > 0) sides.push(side);
|
||||||
|
|
||||||
|
// Each player's side carries their district and the Mainline card leading into it; whatever is
|
||||||
|
// left over (the last Mainline and the East DP) joins the final side.
|
||||||
|
const seats = Math.max(1, Math.min(4, nodes.filter((n) => n.kind === 'office').length));
|
||||||
|
const lanes: number[][] = [];
|
||||||
|
for (let i = 0; i < seats; i++) lanes.push([]);
|
||||||
|
sides.forEach((grp, i) => {
|
||||||
|
const target = Math.min(i, seats - 1);
|
||||||
|
for (const idx of grp) lanes[target]!.push(idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- lay the sides out around the table -------------------------------------------------------
|
||||||
|
// top → right → bottom (reversed) → left (reversed), which gives a row, two facing rows, a
|
||||||
|
// horseshoe open to the west, and a square broken at the same place.
|
||||||
|
const dir: ('top' | 'right' | 'bottom' | 'left')[] =
|
||||||
|
seats === 1 ? ['top'] : seats === 2 ? ['top', 'bottom'] : seats === 3 ? ['top', 'right', 'bottom'] : ['top', 'right', 'bottom', 'left'];
|
||||||
|
|
||||||
|
const runLen = (idxs: number[]): number =>
|
||||||
|
idxs.reduce((n, i) => n + cells[i]!.w + GAP, -GAP);
|
||||||
|
const widest = Math.max(...lanes.map((l) => runLen(l)), 200);
|
||||||
|
const tall = lanes.length > 1 ? Math.max(...lanes.map((l) => l.length), 1) * (CH + GAP) : CH;
|
||||||
|
|
||||||
|
const vertCount = dir.filter((d) => d === 'right' || d === 'left').length;
|
||||||
|
const boardW = PAD * 2 + widest + (vertCount > 0 ? CW.run + SIDE_GAP : 0);
|
||||||
|
const boardH = PAD * 2 + (dir.includes('bottom') ? CH * 2 + SIDE_GAP + (vertCount ? tall : 0) : CH) + 30;
|
||||||
|
|
||||||
|
lanes.forEach((idxs, i) => {
|
||||||
|
const d = dir[i]!;
|
||||||
|
if (d === 'top' || d === 'bottom') {
|
||||||
|
const y = d === 'top' ? PAD : boardH - PAD - CH - 22;
|
||||||
|
const order = d === 'bottom' ? [...idxs].reverse() : idxs;
|
||||||
|
let x = PAD;
|
||||||
|
for (const idx of order) {
|
||||||
|
const c = cells[idx]!;
|
||||||
|
c.x = x;
|
||||||
|
c.y = y;
|
||||||
|
x += c.w + GAP;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const x = d === 'right' ? boardW - PAD - CW.run : PAD;
|
||||||
|
const order = d === 'left' ? [...idxs].reverse() : idxs;
|
||||||
|
let y = PAD + CH + SIDE_GAP;
|
||||||
|
for (const idx of order) {
|
||||||
|
const c = cells[idx]!;
|
||||||
|
c.x = x;
|
||||||
|
c.y = y;
|
||||||
|
c.w = CW.run;
|
||||||
|
y += CH + GAP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- draw -------------------------------------------------------------------------------------
|
||||||
const rail = (x1: number, y: number, x2: number): string => {
|
const rail = (x1: number, y: number, x2: number): string => {
|
||||||
let out =
|
let o =
|
||||||
`<line class="bs-rail" x1="${x1}" y1="${y - 2.5}" x2="${x2}" y2="${y - 2.5}"/>` +
|
`<line class="bs-rail" x1="${x1}" y1="${y - 2.5}" x2="${x2}" y2="${y - 2.5}"/>` +
|
||||||
`<line class="bs-rail" x1="${x1}" y1="${y + 2.5}" x2="${x2}" y2="${y + 2.5}"/>`;
|
`<line class="bs-rail" x1="${x1}" y1="${y + 2.5}" x2="${x2}" y2="${y + 2.5}"/>`;
|
||||||
const n = Math.max(2, Math.floor(Math.abs(x2 - x1) / 9));
|
const n = Math.max(2, Math.floor(Math.abs(x2 - x1) / 9));
|
||||||
for (let i = 0; i <= n; i++) {
|
for (let i = 0; i <= n; i++) {
|
||||||
const tx = x1 + ((x2 - x1) * i) / n;
|
const tx = x1 + ((x2 - x1) * i) / n;
|
||||||
out += `<line class="bs-tie" x1="${tx}" y1="${y - 4.5}" x2="${tx}" y2="${y + 4.5}"/>`;
|
o += `<line class="bs-tie" x1="${tx}" y1="${y - 4.5}" x2="${tx}" y2="${y + 4.5}"/>`;
|
||||||
}
|
}
|
||||||
return out;
|
return o;
|
||||||
};
|
};
|
||||||
|
|
||||||
const chip = (x: number, y: number, t: BoardTrain): string => {
|
// The same rail turned through ninety degrees, for the sides of the table.
|
||||||
const w = 30 + Math.min(t.consist.length, 4) * 9;
|
const railV = (x: number, y1: number, y2: number): string => {
|
||||||
return (
|
let o =
|
||||||
`<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}">` +
|
`<line class="bs-rail" x1="${x - 2.5}" y1="${y1}" x2="${x - 2.5}" y2="${y2}"/>` +
|
||||||
`<rect x="${x - w / 2}" y="${y - 10}" width="${w}" height="20" rx="3"/>` +
|
`<line class="bs-rail" x1="${x + 2.5}" y1="${y1}" x2="${x + 2.5}" y2="${y2}"/>`;
|
||||||
`<text class="bs-tlab" x="${x}" y="${y + 4}" text-anchor="middle">${esc(t.label)}</text></g>`
|
const n = Math.max(2, Math.floor(Math.abs(y2 - y1) / 9));
|
||||||
);
|
for (let i = 0; i <= n; i++) {
|
||||||
|
const ty = y1 + ((y2 - y1) * i) / n;
|
||||||
|
o += `<line class="bs-tie" x1="${x - 4.5}" y1="${ty}" x2="${x + 4.5}" y2="${ty}"/>`;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
};
|
};
|
||||||
|
|
||||||
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
|
let out = `<svg class="bs bs-div" viewBox="0 0 ${Math.ceil(boardW)} ${Math.ceil(boardH)}" preserveAspectRatio="xMinYMin meet">`;
|
||||||
nodes.forEach((node, i) => {
|
|
||||||
const x0 = i * COL;
|
|
||||||
const x1 = x0 + COL;
|
|
||||||
const cap = node.capacity;
|
|
||||||
const lanes = cap ?? 3;
|
|
||||||
const kind = node.kind === 'dp' ? 'dp' : node.kind === 'office' ? 'office' : 'ml';
|
|
||||||
|
|
||||||
out += `<rect class="bs-sec bs-${kind}" x="${x0 + 2}" y="${TOP - 16}" width="${COL - 4}" height="${lanes * LANE + 30}" rx="5"/>`;
|
// The joins between consecutive cells, drawn as rail so a connection is rail meeting rail. A join
|
||||||
out += `<text class="bs-name" x="${x0 + 10}" y="${TOP - 3}">${esc(node.label)}</text>`;
|
// that crosses from one player's side to the next is drawn heavier and labelled: that boundary is
|
||||||
if (node.gradeUp) {
|
// the Mainline card two districts share, and it is the thing a table needs to see.
|
||||||
out += `<text class="bs-grade" x="${x1 - 10}" y="${TOP - 3}" text-anchor="end">climbs ${node.gradeUp === 'east' ? 'E ▲' : '▲ W'}</text>`;
|
for (let i = 0; i + 1 < cells.length; i++) {
|
||||||
|
const a = cells[i]!;
|
||||||
|
const b = cells[i + 1]!;
|
||||||
|
const sameRow = Math.abs(a.y - b.y) < 1;
|
||||||
|
const sameCol = Math.abs(a.x - b.x) < 1;
|
||||||
|
if (sameRow && b.x > a.x) out += rail(a.x + a.w, a.y + CH / 2, b.x);
|
||||||
|
else if (sameRow && b.x < a.x) out += rail(b.x + b.w, a.y + CH / 2, a.x);
|
||||||
|
else if (sameCol) {
|
||||||
|
// Stacked down one side of the table: still one straight run of track, not a turn.
|
||||||
|
const top = Math.min(a.y + CH, b.y + CH);
|
||||||
|
const bot = Math.max(a.y, b.y);
|
||||||
|
out += railV(a.x + a.w / 2, top, bot);
|
||||||
|
} else {
|
||||||
|
// A turn between sides: an elbow, so the route is visibly continuous around the table.
|
||||||
|
const ax = a.x + a.w / 2;
|
||||||
|
const bx = b.x + b.w / 2;
|
||||||
|
const ay = a.y + CH;
|
||||||
|
const by = b.y;
|
||||||
|
out += `<path class="bs-turn" d="M${ax} ${ay} L${ax} ${(ay + by) / 2} L${bx} ${(ay + by) / 2} L${bx} ${by}"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.forEach((c) => {
|
||||||
|
const full = c.cap !== null && c.trains.length >= c.cap;
|
||||||
|
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
|
||||||
|
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
|
||||||
|
out += `<text class="bs-name" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label)}</text>`;
|
||||||
|
out += rail(c.x + 6, c.y + 32, c.x + c.w - 6);
|
||||||
|
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
|
||||||
|
|
||||||
|
// REGIONS. §2.1 divides a Mainline card into two, and §8.2 moves a train one region per Stage.
|
||||||
|
// The bars are the card's DISTANCE and never vary; what varies is how fast a train covers them,
|
||||||
|
// so a 60 card is crossed in one Stage and a slow train on a 30 takes three.
|
||||||
|
const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0;
|
||||||
|
for (let r = 0; r < c.regions; r++) {
|
||||||
|
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One rail per track this section can hold. Unlimited sections are drawn as three faded lines
|
c.trains.forEach((t, k) => {
|
||||||
// and labelled, rather than pretending to a number they do not have.
|
const w = 30 + Math.min(t.consist.length, 4) * 8;
|
||||||
for (let k = 0; k < lanes; k++) {
|
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
|
||||||
out += `<g class="${cap === null ? 'bs-open' : ''}">${rail(x0 + 12, TOP + 14 + k * LANE, x1 - 12)}</g>`;
|
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
||||||
}
|
const slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2;
|
||||||
|
const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
|
||||||
const here = node.trains.flat();
|
const tx = slotX + spread;
|
||||||
here.forEach((t, k) => {
|
const dir = t.direction === 'west' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : '';
|
||||||
const lane = Math.min(k, lanes - 1);
|
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}${
|
||||||
const across = here.length > lanes ? (k - lane) * 34 : 0;
|
inRegion ? ` · region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''
|
||||||
out += chip(x0 + COL / 2 + across, TOP + 14 + lane * LANE, t);
|
}">` +
|
||||||
|
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
|
||||||
|
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(t.label)}</text></g>`;
|
||||||
});
|
});
|
||||||
|
out += '</g>';
|
||||||
const free = cap === null ? '∞' : `${Math.max(0, cap - here.length)} of ${cap} free`;
|
|
||||||
const warn = cap !== null && here.length >= cap ? ' bs-full' : '';
|
|
||||||
out += `<text class="bs-cap${warn}" x="${x0 + 10}" y="${TOP + lanes * LANE + 8}">${cap === null ? 'no limit — trains queue' : free}</text>`;
|
|
||||||
if (node.modifiers.length > 0) {
|
|
||||||
out += `<text class="bs-mod" x="${x0 + 10}" y="${TOP + lanes * LANE + 22}">${esc(node.modifiers.join(' · '))}</text>`;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// THE ENDS. The route stops at both Division Points; drawing buffer stops and naming the gap is
|
||||||
|
// what stops a seated layout being read as a loop.
|
||||||
|
const first = cells[0];
|
||||||
|
const last = cells[cells.length - 1];
|
||||||
|
if (first) {
|
||||||
|
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
|
||||||
|
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
|
||||||
|
out += `<text class="bs-end" x="${first.x - 12}" y="${first.y + CH + 12}" text-anchor="end">trains enter</text>`;
|
||||||
|
}
|
||||||
|
if (last && last !== first) {
|
||||||
|
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
|
||||||
|
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
|
||||||
|
out += `<text class="bs-end" x="${last.x + last.w + 12}" y="${last.y + CH + 12}">trains leave</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
out += '</svg>';
|
out += '</svg>';
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Office Area as a map: one card per grid square, rails drawn edge to edge.
|
* The Office Area as a map: one card per grid square, rails drawn edge to edge.
|
||||||
*
|
*
|
||||||
* The through rail sits at a constant height on every card — the alignment the printed cards use —
|
* The through rail sits at a constant height on every card — the alignment the printed cards use —
|
||||||
* so abutting cards produce one unbroken line and a gap is visibly a gap.
|
* so abutting cards produce one unbroken line and a gap is visibly a gap.
|
||||||
*/
|
*/
|
||||||
export function officeSvg(cells: CellView[], runningRow: number): string {
|
export function officeSvg(
|
||||||
|
cells: CellView[],
|
||||||
|
runningRow: number,
|
||||||
|
ghosts: { row: number; col: number }[] = [],
|
||||||
|
): string {
|
||||||
const W = 132;
|
const W = 132;
|
||||||
const H = 96;
|
const H = 96;
|
||||||
const PAD = 3;
|
const PAD = 3;
|
||||||
@@ -113,9 +305,22 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
|
|||||||
const esc = (t: string): string =>
|
const esc = (t: string): string =>
|
||||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||||
|
|
||||||
if (cells.length === 0) return '<svg class="bs" viewBox="0 0 10 10"></svg>';
|
/**
|
||||||
const rows = cells.map((c) => c.row);
|
* ONE COORDINATE SYSTEM, AND ONE CANVAS.
|
||||||
const cols = cells.map((c) => c.col);
|
*
|
||||||
|
* Empty squares a card may be placed on used to be drawn by a separate `ghostSvg` and spliced in
|
||||||
|
* afterwards. It computed its own origin over cells PLUS spots while this function sized itself
|
||||||
|
* over cells alone, so the two disagreed the moment a legal square lay outside the played cards —
|
||||||
|
* and every legal square that extends the district does. Measured on seed 555: three placements
|
||||||
|
* offered, the one empty square drawn at y=99 on a canvas 103 tall, i.e. off it entirely. The
|
||||||
|
* list said three, the board showed two, and the third could not be clicked.
|
||||||
|
*
|
||||||
|
* Sizing over cells AND ghosts together is what makes that class of bug impossible.
|
||||||
|
*/
|
||||||
|
const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...ghosts];
|
||||||
|
if (all.length === 0) return '<svg class="bs" viewBox="0 0 10 10"></svg>';
|
||||||
|
const rows = all.map((c) => c.row);
|
||||||
|
const cols = all.map((c) => c.col);
|
||||||
const r0 = Math.min(...rows);
|
const r0 = Math.min(...rows);
|
||||||
const r1 = Math.max(...rows);
|
const r1 = Math.max(...rows);
|
||||||
const c0 = Math.min(...cols);
|
const c0 = Math.min(...cols);
|
||||||
@@ -254,44 +459,21 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
|
|||||||
out += '</g>';
|
out += '</g>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A legal EMPTY square has no card to outline, so it gets a target drawn in the same frame.
|
||||||
|
for (const g of ghosts) {
|
||||||
|
out += `<g data-ghost="${g.row},${g.col}" transform="translate(${px(g.col)},${py(g.row)})" class="bs-ghost">` +
|
||||||
|
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
|
||||||
|
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
|
||||||
|
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.row},${g.col}</text>` +
|
||||||
|
`</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Mark the Running Track so the spine of the district is unmistakable.
|
// 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 += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
|
||||||
out += '</svg>';
|
out += '</svg>';
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Targets for legal EMPTY squares. They have no card to outline, so the board draws a dashed
|
|
||||||
* placeholder — the commonest placement of all is onto a blank square, and without this the
|
|
||||||
* highlight would have nothing to attach to.
|
|
||||||
*/
|
|
||||||
export function ghostSvg(
|
|
||||||
spots: { row: number; col: number }[],
|
|
||||||
cells: CellView[],
|
|
||||||
runningRow: number,
|
|
||||||
): string {
|
|
||||||
const W = 132;
|
|
||||||
const H = 96;
|
|
||||||
const PAD = 3;
|
|
||||||
const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...spots];
|
|
||||||
if (all.length === 0) return '';
|
|
||||||
const r1 = Math.max(...all.map((c) => c.row));
|
|
||||||
const c0 = Math.min(...all.map((c) => c.col));
|
|
||||||
let out = '';
|
|
||||||
for (const s of spots) {
|
|
||||||
const x = (s.col - c0) * (W + PAD);
|
|
||||||
const y = (r1 - s.row) * (H + PAD);
|
|
||||||
out +=
|
|
||||||
`<g data-ghost="${s.row},${s.col}" transform="translate(${x},${y})" class="bs-ghost">` +
|
|
||||||
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
|
|
||||||
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
|
|
||||||
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${s.row},${s.col}</text>` +
|
|
||||||
`</g>`;
|
|
||||||
}
|
|
||||||
void runningRow;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Styling for both renderers. Shared so the replay and the app cannot drift apart visually. */
|
/** Styling for both renderers. Shared so the replay and the app cannot drift apart visually. */
|
||||||
export const BOARD_CSS = `
|
export const BOARD_CSS = `
|
||||||
.bs{width:100%;height:auto;background:#0e1116;border-radius:6px;padding:6px;overflow:visible}
|
.bs{width:100%;height:auto;background:#0e1116;border-radius:6px;padding:6px;overflow:visible}
|
||||||
@@ -308,6 +490,25 @@ export const BOARD_CSS = `
|
|||||||
.bs-sec{fill:#161b21;stroke:#39424e}
|
.bs-sec{fill:#161b21;stroke:#39424e}
|
||||||
.bs-sec.bs-dp{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3}
|
.bs-sec.bs-dp{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3}
|
||||||
.bs-sec.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
|
.bs-sec.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
|
||||||
|
/* THE DIVISION, drawn as the route rather than as a row of boxes.
|
||||||
|
Three kinds of cell, and they must be told apart at a glance: a Division Point is where trains
|
||||||
|
come from and go to, a Mainline card is shared ground between two districts, and a Running Track
|
||||||
|
card belongs to one player's Office Area. */
|
||||||
|
.bs-dcell rect{fill:#161b21;stroke:#39424e;stroke-width:1}
|
||||||
|
.bs-ddp rect{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3}
|
||||||
|
.bs-dml rect{fill:#1d222b;stroke:#6b7686;stroke-width:1.6}
|
||||||
|
.bs-drun rect{fill:#1b2534;stroke:#4d6fa8}
|
||||||
|
.bs-dcell.bs-full rect{stroke:#e0a060;stroke-width:2}
|
||||||
|
/* The elbow where the route turns from one player's side of the table to the next. Drawn heavier
|
||||||
|
than a plain join because that turn is the boundary two districts share. */
|
||||||
|
.bs-turn{fill:none;stroke:#7f8b9c;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
/* Buffer stops at both ends. The Division is a line, not a loop: trains enter at one Division Point
|
||||||
|
and leave at the other, and a seated layout must not be read as a ring. */
|
||||||
|
.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round}
|
||||||
|
.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em}
|
||||||
|
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
|
||||||
|
train is measured against, not something to look at instead of the train. */
|
||||||
|
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||||
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
||||||
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
|
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
|
||||||
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
||||||
|
|||||||
+87
-9
@@ -36,7 +36,7 @@ export { cardName, describeIntent, snapshot } from './view.ts';
|
|||||||
import type { Frame } from './view.ts';
|
import type { Frame } from './view.ts';
|
||||||
import type { Decision } from './view.ts';
|
import type { Decision } from './view.ts';
|
||||||
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
|
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
|
||||||
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from './board-svg.ts';
|
import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Frame shape — only what the viewer draws
|
// Frame shape — only what the viewer draws
|
||||||
@@ -147,19 +147,94 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
|||||||
*
|
*
|
||||||
* Only the wire format changes: `record()` still returns complete frames, so tests and any other
|
* Only the wire format changes: `record()` still returns complete frames, so tests and any other
|
||||||
* consumer are unaffected. The page resolves a null by walking back to the last frame that carried
|
* consumer are unaffected. The page resolves a null by walking back to the last frame that carried
|
||||||
* the field.
|
* the field, and rebuilds each cell from the dictionaries below.
|
||||||
|
*
|
||||||
|
* MEASURED, on a 735-frame game, before any of this: `cells` was 62% of 7.1 MB of JSON, and within
|
||||||
|
* it the cost was not where the TODO said. Carrying `links` forward — the item this started as —
|
||||||
|
* would have saved 5% of cells. What actually costs:
|
||||||
|
*
|
||||||
|
* what 32% of cells long prose, and the SAME prose on every turnout in the district
|
||||||
|
* facility 24% of cells the identical object is already in the frame's `facilities`
|
||||||
|
* identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
|
||||||
|
*
|
||||||
|
* So each of the three is interned instead. A card's identity and its description are written once
|
||||||
|
* for the whole recording and referenced by integer, and a cell points at its facility rather than
|
||||||
|
* carrying a second copy of it. What stays per-frame is only what genuinely changes: the cars
|
||||||
|
* standing there, the crew, and any Enhancement laid on it.
|
||||||
*/
|
*/
|
||||||
function compress(frames: Frame[]): unknown[] {
|
type Packed = {
|
||||||
|
/** Static per square, written once: row, col, kind, label, running, links. */
|
||||||
|
cards: unknown[];
|
||||||
|
/** Distinct `what` descriptions, written once. */
|
||||||
|
whats: string[];
|
||||||
|
frames: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function compress(frames: Frame[]): Packed {
|
||||||
|
const cards: unknown[] = [];
|
||||||
|
const cardAt = new Map<string, number>();
|
||||||
|
const whats: string[] = [];
|
||||||
|
const whatAt = new Map<string, number>();
|
||||||
|
|
||||||
|
const packed = frames.map((f) => {
|
||||||
|
const cells = f.cells.map((c) => {
|
||||||
|
const idKey = JSON.stringify([c.row, c.col, c.kind, c.label, c.running, c.links]);
|
||||||
|
let ci = cardAt.get(idKey);
|
||||||
|
if (ci === undefined) {
|
||||||
|
ci = cards.length;
|
||||||
|
cards.push([c.row, c.col, c.kind, c.label, c.running, c.links]);
|
||||||
|
cardAt.set(idKey, ci);
|
||||||
|
}
|
||||||
|
let wi = whatAt.get(c.what);
|
||||||
|
if (wi === undefined) {
|
||||||
|
wi = whats.length;
|
||||||
|
whats.push(c.what);
|
||||||
|
whatAt.set(c.what, wi);
|
||||||
|
}
|
||||||
|
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
|
||||||
|
// cell and the frame's `facilities`, so this always resolves.
|
||||||
|
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
||||||
|
return [ci, wi, c.enhancements, c.tray, c.cars, fi];
|
||||||
|
});
|
||||||
|
return { ...f, cells } as unknown as Frame;
|
||||||
|
});
|
||||||
|
|
||||||
const keys = ['cells', 'facilities', 'division'] as const;
|
const keys = ['cells', 'facilities', 'division'] as const;
|
||||||
let prev: Record<string, string> = {};
|
let prev: Record<string, string> = {};
|
||||||
return frames.map((f, i) => {
|
const out = packed.map((f, i) => {
|
||||||
const out: Record<string, unknown> = { ...f };
|
const o: Record<string, unknown> = { ...f };
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
const json = JSON.stringify(f[k]);
|
const json = JSON.stringify(f[k]);
|
||||||
if (i > 0 && prev[k] === json) out[k] = null;
|
if (i > 0 && prev[k] === json) o[k] = null;
|
||||||
prev[k] = json;
|
prev[k] = json;
|
||||||
}
|
}
|
||||||
return out;
|
return o;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { cards, whats, frames: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild whole CellViews from the compact wire rows.
|
||||||
|
*
|
||||||
|
* Exported and then emitted into the page by `toString()`, exactly as the two board renderers are:
|
||||||
|
* a second copy of this in the page's inline script could drift from the packing above, and the
|
||||||
|
* failure would be a board that draws the wrong cards rather than an error.
|
||||||
|
*/
|
||||||
|
export function rehydrateCells(
|
||||||
|
packed: unknown[],
|
||||||
|
cards: unknown[],
|
||||||
|
whats: string[],
|
||||||
|
facs: unknown[],
|
||||||
|
): unknown[] {
|
||||||
|
return packed.map((row) => {
|
||||||
|
const p = row as [number, number, string[], string | null, string[], number];
|
||||||
|
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
||||||
|
return {
|
||||||
|
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
||||||
|
what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4],
|
||||||
|
facility: p[5] < 0 ? null : facs[p[5]],
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +397,8 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
|||||||
const divisionSvg = ${divisionSvg.toString()};
|
const divisionSvg = ${divisionSvg.toString()};
|
||||||
const officeSvg = ${officeSvg.toString()};
|
const officeSvg = ${officeSvg.toString()};
|
||||||
|
|
||||||
const FRAMES = ${JSON.stringify(compress(rec.frames))};
|
const PACKED = ${JSON.stringify(compress(rec.frames))};
|
||||||
|
const FRAMES = PACKED.frames, CARDS = PACKED.cards, WHATS = PACKED.whats;
|
||||||
/* The board changes rarely, so cells/facilities/division are emitted as null when identical to the
|
/* The board changes rarely, so cells/facilities/division are emitted as null when identical to the
|
||||||
previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of
|
previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of
|
||||||
a 5.2 MB page. The scrubber jumps anywhere, hence the walk rather than a running pointer. */
|
a 5.2 MB page. The scrubber jumps anywhere, hence the walk rather than a running pointer. */
|
||||||
@@ -330,6 +406,8 @@ function carry(i, key) {
|
|||||||
for (let k = i; k >= 0; k--) if (FRAMES[k][key] !== null) return FRAMES[k][key];
|
for (let k = i; k >= 0; k--) if (FRAMES[k][key] !== null) return FRAMES[k][key];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const rehydrateCells = ${rehydrateCells.toString()};
|
||||||
|
function cellsAt(i) { return rehydrateCells(carry(i, 'cells'), CARDS, WHATS, carry(i, 'facilities')); }
|
||||||
let i = 0, timer = null;
|
let i = 0, timer = null;
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
||||||
@@ -376,7 +454,7 @@ function render() {
|
|||||||
$('fno').textContent = i;
|
$('fno').textContent = i;
|
||||||
$('scrub').value = i;
|
$('scrub').value = i;
|
||||||
|
|
||||||
const CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
||||||
|
|
||||||
$('division').innerHTML = divisionSvg(DIV);
|
$('division').innerHTML = divisionSvg(DIV);
|
||||||
// The same office renderer the playable app uses, so replay and game draw one board.
|
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||||
|
|||||||
+122
-5
@@ -17,6 +17,7 @@ import {
|
|||||||
MAINLINE_MODIFIER_CARDS,
|
MAINLINE_MODIFIER_CARDS,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
MANEUVER_CARDS,
|
MANEUVER_CARDS,
|
||||||
|
REGIONS_PER_MAINLINE_CARD,
|
||||||
OFFICE_ORDER,
|
OFFICE_ORDER,
|
||||||
SPACE_USE_CARDS,
|
SPACE_USE_CARDS,
|
||||||
industryProfile,
|
industryProfile,
|
||||||
@@ -86,7 +87,35 @@ export type FacilityView = {
|
|||||||
jammed: boolean;
|
jammed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TrainChip = { label: string; consist: string[] };
|
export type TrainChip = {
|
||||||
|
label: string;
|
||||||
|
consist: string[];
|
||||||
|
/**
|
||||||
|
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
|
||||||
|
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
|
||||||
|
* Moves rather than by Stages, so it occupies a card outright.
|
||||||
|
*/
|
||||||
|
region?: number;
|
||||||
|
direction?: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* One card of a player's Running Track, as the Division sees it.
|
||||||
|
*
|
||||||
|
* The through route between the Limits IS the Running Track — everything hanging beneath it is
|
||||||
|
* secondary, and a train crossing the Division never touches it. So the Division map expands an
|
||||||
|
* Office into this row and leaves sidings, industries and the load pipeline to the district view.
|
||||||
|
*/
|
||||||
|
export type RunningCardView = {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
kind: string;
|
||||||
|
label: string;
|
||||||
|
/** Port pairs, from the engine's `connectionsFor`, so the drawn rail cannot invent a join. */
|
||||||
|
links: string[];
|
||||||
|
/** Trains standing ON this card of the Running Track. */
|
||||||
|
trains: TrainChip[];
|
||||||
|
};
|
||||||
|
|
||||||
export type DivisionView = {
|
export type DivisionView = {
|
||||||
kind: string;
|
kind: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -104,6 +133,19 @@ export type DivisionView = {
|
|||||||
* on that grade has no visible meaning.
|
* on that grade has no visible meaning.
|
||||||
*/
|
*/
|
||||||
gradeUp: string | null;
|
gradeUp: string | null;
|
||||||
|
/** Mainline cards only: how many regions the card is divided into (§2.1 — two). */
|
||||||
|
regions?: number;
|
||||||
|
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
|
||||||
|
running?: RunningCardView[];
|
||||||
|
/** Office nodes only: whose district this is. */
|
||||||
|
owner?: number;
|
||||||
|
/**
|
||||||
|
* Office nodes only: crews working BELOW the Running Track.
|
||||||
|
*
|
||||||
|
* A crew down a siding has no position on the through route — projecting one would be a lie — so
|
||||||
|
* it is reported against the district as a whole and drawn exactly where it is in the zoomed view.
|
||||||
|
*/
|
||||||
|
switching?: TrainChip[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Frame = {
|
export type Frame = {
|
||||||
@@ -431,12 +473,43 @@ export function snapshot(
|
|||||||
// with how long each still has to run.
|
// with how long each still has to run.
|
||||||
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
|
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
|
||||||
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
|
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
|
||||||
|
/**
|
||||||
|
* WHERE ON THE CARD, from what the crossing already cost.
|
||||||
|
*
|
||||||
|
* §2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage.
|
||||||
|
* The engine crosses in `crossingStages` Stages instead, which varies by card speed, train
|
||||||
|
* speed, passengers and modifiers — so the printed model is recovered by treating the entry
|
||||||
|
* point as the thing that varies, exactly as the cards do:
|
||||||
|
*
|
||||||
|
* entry = REGIONS - stagesTotal position = entry + elapsed
|
||||||
|
*
|
||||||
|
* A 60 card is one Stage, so the train enters at the second region and is gone — which is
|
||||||
|
* what "Start positions further along the card" means on the printed art. A 30 card is two
|
||||||
|
* Stages, giving one region per Stage, which is §8.2 exactly. A slow train needing three
|
||||||
|
* Stages cannot fit three steps into two regions, so it holds in the first for a Stage: the
|
||||||
|
* card's distance is fixed and the train is simply slow across it.
|
||||||
|
*/
|
||||||
|
const place = (t: { stagesRemaining: number; stagesTotal: number }): number => {
|
||||||
|
// `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into
|
||||||
|
// two regions, so it notionally starts before the card and spends the extra Stage getting
|
||||||
|
// to the first region. Clamping only the final position keeps that Stage at the START,
|
||||||
|
// where being slow shows; clamping `entry` first would have parked it at the exit instead.
|
||||||
|
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
|
||||||
|
const elapsed = t.stagesTotal - t.stagesRemaining;
|
||||||
|
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
kind: 'ml',
|
kind: 'ml',
|
||||||
label: name,
|
label: name,
|
||||||
|
regions: REGIONS_PER_MAINLINE_CARD,
|
||||||
trains: [n.transits.map((t) => {
|
trains: [n.transits.map((t) => {
|
||||||
const chip = trainChip(s, t.tray);
|
const chip = trainChip(s, t.tray);
|
||||||
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
|
return {
|
||||||
|
...chip,
|
||||||
|
label: `${chip.label} (${t.stagesRemaining})`,
|
||||||
|
region: place(t),
|
||||||
|
direction: t.direction,
|
||||||
|
};
|
||||||
})],
|
})],
|
||||||
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
|
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
|
||||||
modifiers: [
|
modifiers: [
|
||||||
@@ -446,13 +519,57 @@ export function snapshot(
|
|||||||
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
|
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const oa = areaOf(s, n.owner);
|
||||||
|
|
||||||
|
// Where every crew in this district actually is: on a Running Track card, or below it.
|
||||||
|
const onRunning = new Map<string, TrainChip[]>();
|
||||||
|
const below: TrainChip[] = [];
|
||||||
|
for (const [id, tray] of s.trays) {
|
||||||
|
const pos = tray.position;
|
||||||
|
if (pos.at !== 'grid' || pos.owner !== n.owner) continue;
|
||||||
|
const c = trainChip(s, id);
|
||||||
|
if (pos.coord.row === oa.runningRow) {
|
||||||
|
const k = `${pos.coord.row},${pos.coord.col}`;
|
||||||
|
onRunning.set(k, [...(onRunning.get(k) ?? []), c]);
|
||||||
|
} else {
|
||||||
|
below.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limits to Limits, west to east. §2.1 — the Running Track runs BETWEEN the Limits, so the
|
||||||
|
// signs are the ends of the through route rather than obstacles on it.
|
||||||
|
const running: RunningCardView[] = [];
|
||||||
|
for (let col = oa.limitsWest.col; col <= oa.limitsEast.col; col++) {
|
||||||
|
const card = oa.grid.get(`${oa.runningRow},${col}`);
|
||||||
|
if (!card) continue;
|
||||||
|
const g = card.geometry;
|
||||||
|
let label: string;
|
||||||
|
if (g.kind === 'office') label = officeProfile(oa.tier).name;
|
||||||
|
else if (g.kind === 'limits') label = 'Limits';
|
||||||
|
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility);
|
||||||
|
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier);
|
||||||
|
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
||||||
|
else label = geometryLabel(g.geometry);
|
||||||
|
running.push({
|
||||||
|
row: oa.runningRow,
|
||||||
|
col,
|
||||||
|
kind: g.kind,
|
||||||
|
label,
|
||||||
|
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||||
|
trains: onRunning.get(`${oa.runningRow},${col}`) ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'office',
|
kind: 'office',
|
||||||
label: officeProfile(areaOf(s, n.owner).tier).name,
|
label: officeProfile(oa.tier).name,
|
||||||
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
|
trains: [oa.adOccupancy.map((id) => trainChip(s, id))],
|
||||||
capacity: officeProfile(areaOf(s, n.owner).tier).adTracks,
|
capacity: officeProfile(oa.tier).adTracks,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
gradeUp: null,
|
gradeUp: null,
|
||||||
|
owner: n.owner,
|
||||||
|
running,
|
||||||
|
switching: below,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -79,6 +79,14 @@ export type Game = {
|
|||||||
* against the current rules.
|
* against the current rules.
|
||||||
*/
|
*/
|
||||||
mustPlayCard: boolean;
|
mustPlayCard: boolean;
|
||||||
|
/**
|
||||||
|
* Sounds the last batch of events earned, for the page to play and clear.
|
||||||
|
*
|
||||||
|
* The model names WHAT happened — a Stage ended, a Day turned, a train was built — and the view
|
||||||
|
* decides what that sounds like. Detection lives here because this is where events are seen; it
|
||||||
|
* would be guesswork from a rendered frame.
|
||||||
|
*/
|
||||||
|
cues: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||||
@@ -114,7 +122,7 @@ export const SOLO_PLAYER = 'Solitaire';
|
|||||||
|
|
||||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||||||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false };
|
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [] };
|
||||||
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||||
// first, then let the clock take over.
|
// first, then let the clock take over.
|
||||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||||
@@ -336,6 +344,15 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
|||||||
const mine = who !== null && 'player' in e;
|
const mine = who !== null && 'player' in e;
|
||||||
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
|
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
|
||||||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||||||
|
|
||||||
|
if (e.type === 'trainMadeUp') game.cues.push('train');
|
||||||
|
if (e.type === 'stageBegan') {
|
||||||
|
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
||||||
|
// and has nothing behind it. A Day boundary gets the bell only: sounding both there would
|
||||||
|
// collide, and the bell is the bigger event.
|
||||||
|
if (e.day > 1 && e.stage === 1) game.cues.push('day');
|
||||||
|
else if (e.day > 1 || e.stage > 1) game.cues.push('stage');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||||
|
|||||||
+82
-14
@@ -5,8 +5,9 @@
|
|||||||
* is answered by the engine or by the shared view helpers.
|
* is answered by the engine or by the shared view helpers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from '../sim/board-svg.ts';
|
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
||||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||||
|
import { play } from './sound.ts';
|
||||||
import type { Game } from './game.ts';
|
import type { Game } from './game.ts';
|
||||||
import {
|
import {
|
||||||
actionMenu,
|
actionMenu,
|
||||||
@@ -26,6 +27,28 @@ let game: Game;
|
|||||||
let selected: string | null = null;
|
let selected: string | null = null;
|
||||||
/** A square picked on the board, waiting for a rotation. */
|
/** A square picked on the board, waiting for a rotation. */
|
||||||
let pendingAt: string | null = null;
|
let pendingAt: string | null = null;
|
||||||
|
/**
|
||||||
|
* AUTO-FOCUS on the district.
|
||||||
|
*
|
||||||
|
* It is only worth the vertical space during the phases that change it — Local Operations, where
|
||||||
|
* cards and track are placed and the crew switches, and Cargo, where loads move. During New Train,
|
||||||
|
* Mainline and Supervisor Shift nothing in the district moves and the Division map is what matters.
|
||||||
|
*
|
||||||
|
* 'auto' follows the phase; 'open' and 'closed' are the player overriding it and stay put until
|
||||||
|
* they change it again. Display only — in a multiplayer game two players may reasonably want it
|
||||||
|
* set differently, so this must never become part of game state.
|
||||||
|
*/
|
||||||
|
let districtMode: 'auto' | 'open' | 'closed' = 'auto';
|
||||||
|
/**
|
||||||
|
* Sound, OFF until asked for.
|
||||||
|
*
|
||||||
|
* Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio
|
||||||
|
* rather than the finished thing — and a playtester who did not ask for noise should not get any.
|
||||||
|
* One click in the title bar turns it on, and that click is also the gesture browsers require
|
||||||
|
* before any audio may start.
|
||||||
|
*/
|
||||||
|
let soundOn = false;
|
||||||
|
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
|
||||||
|
|
||||||
const $ = (id: string): HTMLElement => {
|
const $ = (id: string): HTMLElement => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -149,7 +172,16 @@ function render(): void {
|
|||||||
// -- board. Both renderers are shared with the replay so the two can never draw different
|
// -- board. Both renderers are shared with the replay so the two can never draw different
|
||||||
// pictures of the same position.
|
// pictures of the same position.
|
||||||
const grid = $('grid');
|
const grid = $('grid');
|
||||||
grid.innerHTML = officeSvg(f.cells, f.runningRow);
|
// The empty squares a legal placement would extend the district onto. Drawn by `officeSvg` in the
|
||||||
|
// same pass so they share its origin and its canvas: a target outside the canvas is a legal move
|
||||||
|
// the player cannot click.
|
||||||
|
const ghostCoords = [...spotsAt.keys()]
|
||||||
|
.filter((k) => !f.cells.some((c) => `${c.row},${c.col}` === k))
|
||||||
|
.map((k) => {
|
||||||
|
const [gr, gc] = k.split(',').map(Number);
|
||||||
|
return { row: gr!, col: gc! };
|
||||||
|
});
|
||||||
|
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords);
|
||||||
|
|
||||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||||
for (const [key, list] of spotsAt) {
|
for (const [key, list] of spotsAt) {
|
||||||
@@ -160,18 +192,11 @@ function render(): void {
|
|||||||
(g as unknown as HTMLElement).onclick = () => pick(key, list);
|
(g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A legal EMPTY square has no card to outline, so draw a target for it.
|
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
|
||||||
const ghosts = [...spotsAt.entries()].filter(([k]) => !f.cells.some((c) => `${c.row},${c.col}` === k));
|
for (const [key, list] of spotsAt) {
|
||||||
if (ghosts.length > 0) {
|
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
|
||||||
const coords = ghosts.map(([k]) => {
|
const g = grid.querySelector(`g[data-ghost="${key}"]`);
|
||||||
const [gr, gc] = k.split(',').map(Number);
|
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||||
return { row: gr!, col: gc! };
|
|
||||||
});
|
|
||||||
grid.innerHTML = grid.innerHTML.replace('</svg>', ghostSvg(coords, f.cells, f.runningRow) + '</svg>');
|
|
||||||
for (const [key, list] of ghosts) {
|
|
||||||
const g = grid.querySelector(`g[data-ghost="${key}"]`);
|
|
||||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pick(key: string, list: { label: string; index: number }[]): void {
|
function pick(key: string, list: { label: string; index: number }[]): void {
|
||||||
@@ -261,10 +286,38 @@ function render(): void {
|
|||||||
.join('');
|
.join('');
|
||||||
log.scrollTop = log.scrollHeight;
|
log.scrollTop = log.scrollHeight;
|
||||||
|
|
||||||
|
renderDistrict(f);
|
||||||
renderActions(menu);
|
renderActions(menu);
|
||||||
|
|
||||||
|
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
|
||||||
|
// not then play a backlog of everything that happened while it was off.
|
||||||
|
const cues = game.cues.splice(0, game.cues.length);
|
||||||
|
if (soundOn) for (const c of cues) play(c);
|
||||||
|
|
||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDistrict(f: ReturnType<typeof view>): void {
|
||||||
|
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
||||||
|
const sec = $('district');
|
||||||
|
if (open) sec.classList.remove('folded');
|
||||||
|
else sec.classList.add('folded');
|
||||||
|
|
||||||
|
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
|
||||||
|
const crew = f.cells.filter((c) => c.tray).length;
|
||||||
|
$('districtsummary').textContent =
|
||||||
|
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
||||||
|
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
||||||
|
|
||||||
|
const btn = $('districttoggle');
|
||||||
|
btn.textContent = districtMode === 'auto' ? (open ? 'auto · open' : 'auto · folded') : districtMode;
|
||||||
|
btn.onclick = () => {
|
||||||
|
// auto -> the opposite of what it is doing now -> back to auto.
|
||||||
|
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function boxes(items: string[], cap: number): string {
|
function boxes(items: string[], cap: number): string {
|
||||||
let out = '';
|
let out = '';
|
||||||
for (let i = 0; i < Math.max(cap, items.length); i++) {
|
for (let i = 0; i < Math.max(cap, items.length); i++) {
|
||||||
@@ -439,4 +492,19 @@ installTooltips();
|
|||||||
const saveBtn = document.getElementById('savefile');
|
const saveBtn = document.getElementById('savefile');
|
||||||
if (saveBtn) saveBtn.onclick = downloadSave;
|
if (saveBtn) saveBtn.onclick = downloadSave;
|
||||||
|
|
||||||
|
const soundBtn = document.getElementById('sound');
|
||||||
|
if (soundBtn) {
|
||||||
|
const paint = (): void => {
|
||||||
|
soundBtn.textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
|
||||||
|
};
|
||||||
|
soundBtn.onclick = () => {
|
||||||
|
soundOn = !soundOn;
|
||||||
|
paint();
|
||||||
|
// Confirm the change audibly — the one press where a sound is unambiguously wanted, and it
|
||||||
|
// doubles as the user gesture the browser needs before any audio may start.
|
||||||
|
if (soundOn) play('stage');
|
||||||
|
};
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
|
||||||
start();
|
start();
|
||||||
|
|||||||
+17
-1
@@ -100,6 +100,14 @@ button:hover{background:#39424e;border-color:#4d6fa8}
|
|||||||
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
||||||
#actions button:focus-visible{outline:2px solid #f0b64a;outline-offset:2px}
|
#actions button:focus-visible{outline:2px solid #f0b64a;outline-offset:2px}
|
||||||
button.act{display:inline-block}
|
button.act{display:inline-block}
|
||||||
|
/* The district collapses when nothing in it is changing. A summary line stays, because a panel
|
||||||
|
that vanishes entirely reads as broken rather than as folded away. */
|
||||||
|
button.ghost{background:transparent;border:1px solid var(--line);color:var(--dim);font-size:10px;
|
||||||
|
padding:1px 7px;margin-left:8px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||||
|
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||||
|
#district.folded #grid{display:none}
|
||||||
|
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||||
|
#districtsummary{display:none}
|
||||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||||
so a disabled button must not wear it. */
|
so a disabled button must not wear it. */
|
||||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||||
@@ -169,6 +177,7 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
|
|||||||
<span id="objective" class="pace">—</span>
|
<span id="objective" class="pace">—</span>
|
||||||
<span class="dim">seed <span id="seed">—</span></span>
|
<span class="dim">seed <span id="seed">—</span></span>
|
||||||
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
|
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
|
||||||
|
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||||
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||||
@@ -186,7 +195,14 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
|
|||||||
<main>
|
<main>
|
||||||
<div>
|
<div>
|
||||||
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
||||||
<section><h2>Your Office Area <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span></h2><div id="grid"></div></section>
|
<section id="district">
|
||||||
|
<h2>Your Office Area
|
||||||
|
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||||
|
<button id="districttoggle" class="ghost" title="Auto-focus opens the district during the phases that change it">auto</button>
|
||||||
|
</h2>
|
||||||
|
<div id="districtsummary" class="dim"></div>
|
||||||
|
<div id="grid"></div>
|
||||||
|
</section>
|
||||||
<section><h2>History</h2><div id="log"></div></section>
|
<section><h2>History</h2><div id="log"></div></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* The railroad, heard.
|
||||||
|
*
|
||||||
|
* SYNTHESISED, NOT SAMPLED, and that is a constraint rather than a preference. The site is a static
|
||||||
|
* folder that fetches nothing — there is a test asserting no page reaches an external host — so an
|
||||||
|
* audio file would have to be committed to the repo, and a plausible-sounding whistle is not
|
||||||
|
* something to invent. The Web Audio API can build these from oscillators at no download cost, and
|
||||||
|
* what it produces is honestly synthetic rather than a fake recording.
|
||||||
|
*
|
||||||
|
* A steam whistle is the one that repays the effort: a real one is a CHORD of several chambers
|
||||||
|
* slightly out of tune with each other, plus the breath of the steam itself. Three detuned partials
|
||||||
|
* and a band of noise is most of the way there.
|
||||||
|
*
|
||||||
|
* "All aboard" is speech, and speech cannot be faked with oscillators. `speechSynthesis` is built
|
||||||
|
* into the browser, needs no asset and works offline, so it says the words — and where the platform
|
||||||
|
* has no voice installed, a two-note call takes its place rather than nothing happening.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let ctx: AudioContext | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browsers refuse to start audio until the player has interacted with the page, so the context is
|
||||||
|
* created on first use — by which time they have clicked something — and resumed if it was
|
||||||
|
* suspended in the meantime.
|
||||||
|
*/
|
||||||
|
function audio(): AudioContext | null {
|
||||||
|
try {
|
||||||
|
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!Ctor) return null;
|
||||||
|
ctx ??= new Ctor();
|
||||||
|
if (ctx.state === 'suspended') void ctx.resume();
|
||||||
|
return ctx;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tone with an attack/decay envelope, so nothing starts or stops with a click. */
|
||||||
|
function tone(
|
||||||
|
c: AudioContext,
|
||||||
|
at: number,
|
||||||
|
freq: number,
|
||||||
|
dur: number,
|
||||||
|
gain: number,
|
||||||
|
type: OscillatorType = 'sine',
|
||||||
|
bendTo?: number,
|
||||||
|
): void {
|
||||||
|
const osc = c.createOscillator();
|
||||||
|
const amp = c.createGain();
|
||||||
|
osc.type = type;
|
||||||
|
osc.frequency.setValueAtTime(freq, at);
|
||||||
|
if (bendTo !== undefined) osc.frequency.linearRampToValueAtTime(bendTo, at + dur);
|
||||||
|
amp.gain.setValueAtTime(0, at);
|
||||||
|
amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.04, dur / 4));
|
||||||
|
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||||
|
osc.connect(amp).connect(c.destination);
|
||||||
|
osc.start(at);
|
||||||
|
osc.stop(at + dur + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The escaping steam under a whistle — filtered noise, without which it is just a chord. */
|
||||||
|
function breath(c: AudioContext, at: number, dur: number, gain: number): void {
|
||||||
|
const frames = Math.floor(c.sampleRate * dur);
|
||||||
|
const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate);
|
||||||
|
const data = buf.getChannelData(0);
|
||||||
|
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
|
||||||
|
const src = c.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
const band = c.createBiquadFilter();
|
||||||
|
band.type = 'bandpass';
|
||||||
|
band.frequency.value = 1400;
|
||||||
|
band.Q.value = 0.8;
|
||||||
|
const amp = c.createGain();
|
||||||
|
amp.gain.setValueAtTime(0, at);
|
||||||
|
amp.gain.linearRampToValueAtTime(gain, at + 0.05);
|
||||||
|
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||||
|
src.connect(band).connect(amp).connect(c.destination);
|
||||||
|
src.start(at);
|
||||||
|
src.stop(at + dur);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End of a Stage. A three-chime whistle, one long call. */
|
||||||
|
export function whistle(): void {
|
||||||
|
const c = audio();
|
||||||
|
if (!c) return;
|
||||||
|
const t = c.currentTime + 0.02;
|
||||||
|
const d = 0.9;
|
||||||
|
// A minor-third-ish cluster, deliberately not in tune with itself — that beating IS the sound.
|
||||||
|
for (const [f, g] of [[392, 0.16], [466, 0.13], [587, 0.10], [784, 0.05]] as const) {
|
||||||
|
tone(c, t, f, d, g, 'sawtooth', f * 0.985);
|
||||||
|
}
|
||||||
|
breath(c, t, d, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End of a Day. The grade-crossing bell: two strikes, metallic and inharmonic. */
|
||||||
|
export function clang(): void {
|
||||||
|
const c = audio();
|
||||||
|
if (!c) return;
|
||||||
|
const t = c.currentTime + 0.02;
|
||||||
|
for (const at of [t, t + 0.22]) {
|
||||||
|
// Inharmonic partials are what separate a bell from a beep.
|
||||||
|
for (const [f, g] of [[1046, 0.13], [1570, 0.09], [2350, 0.06], [3140, 0.03]] as const) {
|
||||||
|
tone(c, at, f, 0.4, g, 'square');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A train is added to the board. */
|
||||||
|
export function allAboard(): void {
|
||||||
|
try {
|
||||||
|
const s = window.speechSynthesis;
|
||||||
|
if (s && typeof SpeechSynthesisUtterance === 'function') {
|
||||||
|
const u = new SpeechSynthesisUtterance('All aboard!');
|
||||||
|
u.rate = 0.95;
|
||||||
|
u.pitch = 0.85;
|
||||||
|
u.volume = 0.9;
|
||||||
|
s.speak(u);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to the call below
|
||||||
|
}
|
||||||
|
// No voice on this platform: a two-note conductor's call rather than silence.
|
||||||
|
const c = audio();
|
||||||
|
if (!c) return;
|
||||||
|
const t = c.currentTime + 0.02;
|
||||||
|
tone(c, t, 523, 0.22, 0.14, 'triangle');
|
||||||
|
tone(c, t + 0.2, 784, 0.34, 0.14, 'triangle');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cue names the model emits; the view decides what they sound like. */
|
||||||
|
export function play(cue: string): void {
|
||||||
|
if (cue === 'stage') whistle();
|
||||||
|
else if (cue === 'day') clang();
|
||||||
|
else if (cue === 'train') allAboard();
|
||||||
|
}
|
||||||
@@ -299,7 +299,7 @@ describe('collisions are automatic (Gap 2)', () => {
|
|||||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||||
ml.card = 'plains';
|
ml.card = 'plains';
|
||||||
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||||
}
|
}
|
||||||
|
|
||||||
s.clock.phase = 'mainline';
|
s.clock.phase = 'mainline';
|
||||||
@@ -322,7 +322,7 @@ describe('collisions are automatic (Gap 2)', () => {
|
|||||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||||
mlx.card = 'plains';
|
mlx.card = 'plains';
|
||||||
mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
|
mlx.transits.push({ tray: 'x', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||||
}
|
}
|
||||||
s.trays.set('x', {
|
s.trays.set('x', {
|
||||||
id: 'x',
|
id: 'x',
|
||||||
@@ -371,7 +371,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||||
ml.card = 'plains';
|
ml.card = 'plains';
|
||||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// A second train at the Western Division Point wanting to follow it.
|
// A second train at the Western Division Point wanting to follow it.
|
||||||
@@ -414,7 +414,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||||
ml.card = 'plains';
|
ml.card = 'plains';
|
||||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
|
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||||
}
|
}
|
||||||
|
|
||||||
s.trays.set('waiting', {
|
s.trays.set('waiting', {
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
|||||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||||
ml.card = 'plains';
|
ml.card = 'plains';
|
||||||
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||||
}
|
}
|
||||||
s.clock.phase = 'mainline';
|
s.clock.phase = 'mainline';
|
||||||
s.movedThisPhase = new Set();
|
s.movedThisPhase = new Set();
|
||||||
@@ -261,7 +261,7 @@ describe('ABS Signals amend the collision rule', () => {
|
|||||||
// pass, so the following train would never be held and there would be nothing for ABS to do.
|
// pass, so the following train would never be held and there would be nothing for ABS to do.
|
||||||
ml.card = 'plains';
|
ml.card = 'plains';
|
||||||
ml.absSignals = true;
|
ml.absSignals = true;
|
||||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||||
}
|
}
|
||||||
s.trays.set('ahead', {
|
s.trays.set('ahead', {
|
||||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||||
@@ -301,7 +301,7 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
|||||||
// otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
// otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||||
// `trainsMayPass`, which legitimately removes the §8.1 bar this test is about.
|
// `trainsMayPass`, which legitimately removes the §8.1 bar this test is about.
|
||||||
ml.card = 'plains';
|
ml.card = 'plains';
|
||||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
|
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||||
}
|
}
|
||||||
s.trays.set('mine', {
|
s.trays.set('mine', {
|
||||||
id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||||
import { coordKey } from '../src/engine/state.ts';
|
import { coordKey } from '../src/engine/state.ts';
|
||||||
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
@@ -146,7 +147,7 @@ describe('placing a Mainline modifier', () => {
|
|||||||
it('will not modify a card with a train on it', () => {
|
it('will not modify a card with a train on it', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
const node = pinned(s, 1, 'heavyGrade');
|
const node = pinned(s, 1, 'heavyGrade');
|
||||||
node.transits.push({ tray: 'someone', stagesRemaining: 1, direction: 'east' });
|
node.transits.push({ tray: 'someone', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||||
drawTurn(s);
|
drawTurn(s);
|
||||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD');
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD');
|
||||||
@@ -207,7 +208,7 @@ describe('Red Flags protect a stopped train', () => {
|
|||||||
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
|
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
|
||||||
function rearEnder(s: GameState) {
|
function rearEnder(s: GameState) {
|
||||||
const node = pinned(s, 1, 'plains');
|
const node = pinned(s, 1, 'plains');
|
||||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||||
s.trays.set('ahead', {
|
s.trays.set('ahead', {
|
||||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||||
@@ -534,3 +535,63 @@ describe("a train is made up to its card's consist (§8.2)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
||||||
|
/** Put one train mid-crossing and ask the view where the map should draw it. */
|
||||||
|
const regionFor = (stagesTotal: number, stagesRemaining: number): { region: number; regions: number } => {
|
||||||
|
const s = createGame({
|
||||||
|
id: 'reg',
|
||||||
|
seed: 4,
|
||||||
|
config: {
|
||||||
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||||
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['Solitaire'],
|
||||||
|
});
|
||||||
|
const node = s.division.nodes.find((n) => n.kind === 'mainline');
|
||||||
|
assert.ok(node && node.kind === 'mainline');
|
||||||
|
const tray = [...s.trays.keys()][0]!;
|
||||||
|
node.transits.push({ tray, stagesRemaining, stagesTotal, direction: 'east' });
|
||||||
|
const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml');
|
||||||
|
assert.ok(ml, 'no Mainline node in the view');
|
||||||
|
const t = ml!.trains.flat()[0]!;
|
||||||
|
return { region: t.region as number, regions: ml!.regions as number };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('divides every Mainline card into two regions', () => {
|
||||||
|
// §2.1, and open-questions.md settled on two deliberately: at three, a Day of 12 Stages leaves
|
||||||
|
// trains still on the road when the next Day begins, locking Crew Trays permanently.
|
||||||
|
assert.equal(regionFor(2, 2).regions, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves one region per Stage for an ordinary train on a 30 card', () => {
|
||||||
|
// §8.2 exactly. This is the case the printed rule was written for.
|
||||||
|
assert.equal(regionFor(2, 2).region, 0);
|
||||||
|
assert.equal(regionFor(2, 1).region, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a fast train further along the card rather than moving it twice', () => {
|
||||||
|
// The printed cards carry Start positions — a 60 card is crossed in one Stage, which the map
|
||||||
|
// shows as entering at the second region. That is the same fact as "Brakemen moves your entry
|
||||||
|
// point further along", which is how the modifier cards are drawn.
|
||||||
|
assert.equal(regionFor(1, 1).region, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a slow train at the START, not at the exit', () => {
|
||||||
|
// Three Stages will not fit three steps into two regions. The card's distance is fixed, so the
|
||||||
|
// extra Stage is spent getting going — clamping the entry instead would park it at the exit and
|
||||||
|
// read as a train that raced across and then waited.
|
||||||
|
assert.equal(regionFor(3, 3).region, 0);
|
||||||
|
assert.equal(regionFor(3, 2).region, 0);
|
||||||
|
assert.equal(regionFor(3, 1).region, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never leaves the card it is on', () => {
|
||||||
|
for (let total = 1; total <= 4; total++) {
|
||||||
|
for (let left = total; left >= 1; left--) {
|
||||||
|
const r = regionFor(total, left).region;
|
||||||
|
assert.ok(r >= 0 && r <= 1, `total ${total}, ${left} left put the train in region ${r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+53
-3
@@ -14,7 +14,7 @@ import { createGame } from '../src/engine/setup.ts';
|
|||||||
import type { GameConfig } from '../src/engine/state.ts';
|
import type { GameConfig } from '../src/engine/state.ts';
|
||||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||||
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
||||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
||||||
import { summarize } from '../src/sim/stats.ts';
|
import { summarize } from '../src/sim/stats.ts';
|
||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
@@ -189,8 +189,16 @@ describe('replay HTML', () => {
|
|||||||
assert.deepEqual(externals, [], `external references: ${externals.join(', ')}`);
|
assert.deepEqual(externals, [], `external references: ${externals.join(', ')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('embeds the frame data', () => {
|
it('embeds the frame data and the dictionaries it is packed against', () => {
|
||||||
assert.match(html, /const FRAMES = \[/);
|
// The frames are packed now: a card's identity and its description are written once into
|
||||||
|
// CARDS/WHATS and referenced by integer. A page with FRAMES but no dictionaries would draw an
|
||||||
|
// empty board rather than fail, so all three are checked.
|
||||||
|
assert.match(html, /const PACKED = \{/);
|
||||||
|
assert.match(html, /FRAMES = PACKED\.frames/);
|
||||||
|
assert.match(html, /CARDS = PACKED\.cards/);
|
||||||
|
assert.match(html, /WHATS = PACKED\.whats/);
|
||||||
|
// And the unpacking function must be emitted, not just referenced.
|
||||||
|
assert.match(html, /const rehydrateCells = function/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers the controls that make it usable', () => {
|
it('offers the controls that make it usable', () => {
|
||||||
@@ -300,3 +308,45 @@ describe('replay HTML', () => {
|
|||||||
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
|
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the replay wire format is lossless (regression)', () => {
|
||||||
|
it('rebuilds every cell exactly, through the same function the page runs', () => {
|
||||||
|
// The frames are packed before they reach the page: a card's identity and its description are
|
||||||
|
// written once and referenced by integer, and a cell points at its facility instead of carrying
|
||||||
|
// a second copy of it. That is 45% of the file — and it is only safe if unpacking is exact.
|
||||||
|
//
|
||||||
|
// `rehydrateCells` is the function the page itself runs, emitted by toString(), so this checks
|
||||||
|
// the real thing rather than a second implementation of it.
|
||||||
|
const rec = record(880009, 'standard', 4000);
|
||||||
|
const packed = compress(rec.frames);
|
||||||
|
|
||||||
|
let checked = 0;
|
||||||
|
let carried: unknown[] = [];
|
||||||
|
let carriedFacs: unknown[] = [];
|
||||||
|
for (let i = 0; i < rec.frames.length; i++) {
|
||||||
|
const wire = packed.frames[i] as Record<string, unknown>;
|
||||||
|
// null means "unchanged since the last frame that carried it", exactly as the page resolves it
|
||||||
|
if (wire['cells'] !== null) carried = wire['cells'] as unknown[];
|
||||||
|
if (wire['facilities'] !== null) carriedFacs = wire['facilities'] as unknown[];
|
||||||
|
|
||||||
|
const back = rehydrateCells(carried, packed.cards, packed.whats, carriedFacs);
|
||||||
|
assert.deepEqual(back, rec.frames[i]!.cells, `frame ${i} did not survive the round trip`);
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
assert.ok(checked > 100, `only ${checked} frames checked`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes each distinct card and description once, not once per frame', () => {
|
||||||
|
const rec = record(880009, 'standard', 4000);
|
||||||
|
const packed = compress(rec.frames);
|
||||||
|
const cellsSeen = rec.frames.reduce((n, f) => n + f.cells.length, 0);
|
||||||
|
assert.ok(
|
||||||
|
packed.cards.length < cellsSeen / 20,
|
||||||
|
`${packed.cards.length} card entries for ${cellsSeen} cell-frames — identity is not interned`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
packed.whats.length < cellsSeen / 20,
|
||||||
|
`${packed.whats.length} descriptions for ${cellsSeen} cell-frames — prose is not interned`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+187
-1
@@ -12,7 +12,9 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
import { cardDescription, describeIntent } from '../src/sim/view.ts';
|
import { cardDescription, describeIntent } from '../src/sim/view.ts';
|
||||||
import { officeSvg } from '../src/sim/board-svg.ts';
|
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||||
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
|
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||||
import {
|
import {
|
||||||
actionGroups,
|
actionGroups,
|
||||||
actionMenu,
|
actionMenu,
|
||||||
@@ -696,8 +698,19 @@ describe('the static build', () => {
|
|||||||
// page that works perfectly.
|
// page that works perfectly.
|
||||||
let cache = new Map<string, Record<string, unknown>[]>();
|
let cache = new Map<string, Record<string, unknown>[]>();
|
||||||
const found = new Map<string, Record<string, unknown>>();
|
const found = new Map<string, Record<string, unknown>>();
|
||||||
|
// Every element carries a classList. The page folds the district panel by toggling a class on
|
||||||
|
// it, and a stub without one throws on load — which is a page that never starts, not a
|
||||||
|
// cosmetic gap.
|
||||||
|
const ownClasses = new Set<string>();
|
||||||
const node: Record<string, unknown> = {
|
const node: Record<string, unknown> = {
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||||
|
classList: {
|
||||||
|
add: (c: string) => void ownClasses.add(c),
|
||||||
|
remove: (c: string) => void ownClasses.delete(c),
|
||||||
|
contains: (c: string) => ownClasses.has(c),
|
||||||
|
toggle: (c: string) => (ownClasses.has(c) ? ownClasses.delete(c) : ownClasses.add(c)),
|
||||||
|
},
|
||||||
|
classes: ownClasses,
|
||||||
// The board is SVG now, and highlighting works by finding a card group and adding a class.
|
// The board is SVG now, and highlighting works by finding a card group and adding a class.
|
||||||
// The stub has to model that much or it cannot see whether highlighting happened at all.
|
// The stub has to model that much or it cannot see whether highlighting happened at all.
|
||||||
querySelector(sel: string) {
|
querySelector(sel: string) {
|
||||||
@@ -936,3 +949,176 @@ describe('the static build', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the Division map shows the whole route', () => {
|
||||||
|
const divisionFor = (players: number): string => {
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: `div-${players}`,
|
||||||
|
seed: 7,
|
||||||
|
config: {
|
||||||
|
mode: players === 1 ? 'solitaire' : 'competitive',
|
||||||
|
victory: 'highestAfterDays',
|
||||||
|
length: 'standard',
|
||||||
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
|
||||||
|
});
|
||||||
|
return divisionSvg(snapshot(s, [], null).division);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('expands each Office into its Running Track, Limits to Limits', () => {
|
||||||
|
// The map used to collapse a whole district into one "Office" box, so the track a train
|
||||||
|
// actually runs along was invisible on the only view that shows where trains are.
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'div-run',
|
||||||
|
seed: 7,
|
||||||
|
config: {
|
||||||
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||||
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['Solitaire'],
|
||||||
|
});
|
||||||
|
const f = snapshot(s, [], null);
|
||||||
|
const office = f.division.find((n) => n.kind === 'office');
|
||||||
|
assert.ok(office, 'no Office node in the Division');
|
||||||
|
assert.ok(office!.running && office!.running.length >= 3, 'the Office carries no Running Track');
|
||||||
|
// The through route runs BETWEEN the Limits, so both ends must be Limits cards (§2.1).
|
||||||
|
assert.equal(office!.running![0]!.kind, 'limits', 'the district does not start at a Limits sign');
|
||||||
|
assert.equal(office!.running![office!.running!.length - 1]!.kind, 'limits', 'the district does not end at a Limits sign');
|
||||||
|
// And the columns must run west to east without a hole.
|
||||||
|
const cols = office!.running!.map((c) => c.col);
|
||||||
|
for (let i = 1; i < cols.length; i++) {
|
||||||
|
assert.ok(cols[i]! > cols[i - 1]!, 'the Running Track is not ordered west to east');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seats 1 to 4 players without overlapping or spilling off the canvas', () => {
|
||||||
|
// A row, two facing rows, a horseshoe and a square. The layout is geometry with no visual
|
||||||
|
// feedback loop, so this is the only thing standing between a change and an unreadable board.
|
||||||
|
for (const players of [1, 2, 3, 4]) {
|
||||||
|
const svg = divisionFor(players);
|
||||||
|
const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg);
|
||||||
|
assert.ok(vb, `${players}p produced no viewBox`);
|
||||||
|
const W = Number(vb![1]);
|
||||||
|
const H = Number(vb![2]);
|
||||||
|
const rects = [...svg.matchAll(/class="bs-dcell[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
|
||||||
|
.map((m) => ({ x: +m[1]!, y: +m[2]!, w: +m[3]!, h: +m[4]! }));
|
||||||
|
assert.ok(rects.length >= 5, `${players}p drew only ${rects.length} cells`);
|
||||||
|
for (let i = 0; i < rects.length; i++) {
|
||||||
|
const a = rects[i]!;
|
||||||
|
assert.ok(
|
||||||
|
a.x >= 0 && a.y >= 0 && a.x + a.w <= W && a.y + a.h <= H,
|
||||||
|
`${players}p: a cell falls outside the canvas`,
|
||||||
|
);
|
||||||
|
for (let j = i + 1; j < rects.length; j++) {
|
||||||
|
const b = rects[j]!;
|
||||||
|
const hit = a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||||
|
assert.ok(!hit, `${players}p: two cells overlap`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the Division as a line with two ends, never as a loop', () => {
|
||||||
|
// Seating players around a table invites exactly one misreading: that the route joins up. It
|
||||||
|
// does not — trains enter at one Division Point and leave at the other.
|
||||||
|
for (const players of [1, 2, 3, 4]) {
|
||||||
|
const svg = divisionFor(players);
|
||||||
|
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`);
|
||||||
|
assert.match(svg, /trains enter/, `${players}p does not label where trains enter`);
|
||||||
|
assert.match(svg, /trains leave/, `${players}p does not label where trains leave`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('every square the menu offers can actually be clicked (regression)', () => {
|
||||||
|
it('draws a target for it, inside the canvas', () => {
|
||||||
|
// REGRESSION. Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG after
|
||||||
|
// the fact. It sized its origin over cells PLUS spots while `officeSvg` sized itself over cells
|
||||||
|
// alone, so the two disagreed the moment a legal square lay outside the played cards — which is
|
||||||
|
// every square that would EXTEND the district, i.e. most of them.
|
||||||
|
//
|
||||||
|
// Seed 555 offered three placements for a straight; the empty square was drawn at y=99 on a
|
||||||
|
// canvas 103 tall. The list said three, the board showed two, and the third could not be
|
||||||
|
// clicked at all. Ghosts are drawn by `officeSvg` in the same pass now, so there is one origin
|
||||||
|
// and one canvas.
|
||||||
|
let checked = 0;
|
||||||
|
for (const seed of [555, 42, 909, 7, 88, 1234]) {
|
||||||
|
const game = newGame(seed);
|
||||||
|
const draw = actionMenu(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw');
|
||||||
|
if (draw) submit(game, draw);
|
||||||
|
const menu = actionMenu(game);
|
||||||
|
const f = view(game);
|
||||||
|
|
||||||
|
for (const item of menu.placeable.flatMap((g) => g.items)) {
|
||||||
|
const uniq = [...new Map(item.spots.map((sp) => [`${sp.coord.row},${sp.coord.col}`, sp.coord])).values()];
|
||||||
|
const ghosts = uniq.filter((c) => !f.cells.some((x) => x.row === c.row && x.col === c.col));
|
||||||
|
const svg = officeSvg(f.cells, f.runningRow, ghosts);
|
||||||
|
const vb = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);
|
||||||
|
assert.ok(vb, 'the board produced no viewBox');
|
||||||
|
const W = Number(vb![1]);
|
||||||
|
const H = Number(vb![2]);
|
||||||
|
|
||||||
|
for (const c of uniq) {
|
||||||
|
checked++;
|
||||||
|
const key = `${c.row},${c.col}`;
|
||||||
|
const isCard = f.cells.some((x) => x.row === c.row && x.col === c.col);
|
||||||
|
const target = isCard ? `data-cell="${key}"` : `data-ghost="${key}"`;
|
||||||
|
assert.ok(svg.includes(target), `${item.subject}: (${key}) is offered but has no target on the board`);
|
||||||
|
|
||||||
|
// `data-tip` sits between the id and the transform on a real card, so match loosely.
|
||||||
|
const m = new RegExp(
|
||||||
|
'data-(?:cell|ghost)="' + key + '"[^>]*transform="translate\\((-?[\\d.]+),(-?[\\d.]+)\\)"',
|
||||||
|
).exec(svg);
|
||||||
|
assert.ok(m, `${item.subject}: (${key}) has no position`);
|
||||||
|
const x = Number(m![1]);
|
||||||
|
const y = Number(m![2]);
|
||||||
|
assert.ok(
|
||||||
|
x >= 0 && y >= 0 && x + 132 <= W + 0.01 && y + 96 <= H + 0.01,
|
||||||
|
`${item.subject}: (${key}) is drawn off-canvas at (${x}, ${y}) on ${W}x${H} — a legal move that cannot be clicked`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(checked > 50, `only ${checked} squares checked`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the sounds fire on the events they name', () => {
|
||||||
|
it('one cue per Stage boundary, the bell replacing the whistle at a Day', () => {
|
||||||
|
// 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);
|
||||||
|
const cues: Record<string, number> = {};
|
||||||
|
let stageBoundaries = 0;
|
||||||
|
let dayBoundaries = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < 4000; i++) {
|
||||||
|
if (currentActor(game) === null) break;
|
||||||
|
const { options, groups } = actionGroups(game);
|
||||||
|
if (groups.length === 0 || options.length === 0) break;
|
||||||
|
const before = { d: game.state.clock.day, s: game.state.clock.stage };
|
||||||
|
submit(game, options[groups[0]!.actions[0]!.index]!);
|
||||||
|
for (const c of game.cues) cues[c] = (cues[c] ?? 0) + 1;
|
||||||
|
game.cues.length = 0;
|
||||||
|
if (game.state.clock.day !== before.d) dayBoundaries++;
|
||||||
|
if (game.state.clock.stage !== before.s || game.state.clock.day !== before.d) stageBoundaries++;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(stageBoundaries > 20, `only ${stageBoundaries} Stages elapsed — the game did not run`);
|
||||||
|
assert.equal(cues['day'] ?? 0, dayBoundaries, 'the Day bell does not match the Days played');
|
||||||
|
assert.equal(
|
||||||
|
(cues['stage'] ?? 0) + (cues['day'] ?? 0),
|
||||||
|
stageBoundaries,
|
||||||
|
'Stage boundaries and Stage-or-Day cues disagree — some Stage ended silently, or one sounded twice',
|
||||||
|
);
|
||||||
|
assert.ok((cues['train'] ?? 0) > 0, 'no train was ever announced');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing at all before the first Stage has ended', () => {
|
||||||
|
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening.
|
||||||
|
const game = newGame(555);
|
||||||
|
assert.deepEqual(game.cues, [], 'the game announced a Stage ending before one had');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user