user card Hand becomes the action surface, the board shows the train, and a reverse stops turning it around, other bug fixes too.
This commit is contained in:
+192
@@ -10,6 +10,198 @@ The target is 20 Revenue over 5 Days.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### A playtest report, worked through
|
||||||
|
|
||||||
|
**Backing up turned the train around.** `facing` is which way the ENGINE points, and it was reset to
|
||||||
|
the direction of travel on every move — so one reverse move silently spun the train about, everything
|
||||||
|
read "forward" again, and a run-around became pointless: you could change ends for free by backing up
|
||||||
|
twice. Running forward the engine leads and points the way it went; backing up it trails, still
|
||||||
|
pointing the way it came, which is the port it arrived through. Both hold around a curve.
|
||||||
|
|
||||||
|
**"drop 1 car(s)" hid an option entirely.** It never said which car, and it read identically for a
|
||||||
|
nose drop and a tail drop — so the action list's duplicate-label filter discarded one outright, and
|
||||||
|
setting out from the front of the train could not be chosen at all. The same failure as the turnout
|
||||||
|
rotation earlier, in a feature added two commits ago to fix the make-up deadlock. Now: *"set out the
|
||||||
|
caboose off the back"*.
|
||||||
|
|
||||||
|
**ABS Signals offered the Office Area.** The card says "any Mainline card", and
|
||||||
|
`checkEnhancementPlacement` reads `placement.col` as a Division NODE index for it — while the
|
||||||
|
candidate list handed it occupied grid cells. So "(0, 1)" was accepted because node 1 happened to be
|
||||||
|
a Mainline card: the label and the meaning were different things. It now offers the Mainline cards by
|
||||||
|
name, *"on the Uncontrolled Siding, out on the Mainline"*.
|
||||||
|
|
||||||
|
**Two T10 chips on one Office.** A train standing at the Office is on the Office grid card AND on an
|
||||||
|
A/D track, so it arrived in both lists and was drawn twice.
|
||||||
|
|
||||||
|
**The train was invisible on the board**, which is what made the switching game unplayable: every
|
||||||
|
decision is about car ORDER — which car comes off next, which end a cut couples onto — and the card
|
||||||
|
showed a name badge. The train is now drawn as it sits in the tray: engine in its place with an arrow
|
||||||
|
for which way it points, cars in order, loaded solid and empty hollow. That arrow is also what makes
|
||||||
|
"(reverse)" mean something.
|
||||||
|
|
||||||
|
**Modifier effects were applied and invisible.** The Ice House and Local Small Groceries both worked
|
||||||
|
— `capacity.outbound` and `laborers` went up — but the card draws `Math.max(1, greenCap)` green
|
||||||
|
boxes, so 0 → 1 looked identical, and laborers were never drawn at all. The Facilities panel now
|
||||||
|
shows laborers, outbound and inbound as numbers with what a Modifier added (`2 +1`), names the
|
||||||
|
Modifier cards beside the industry, and says in the tooltip what the card itself prints. Laborers had
|
||||||
|
been in a hover tooltip only, which is the wrong place for the number that decides every Cargo phase.
|
||||||
|
|
||||||
|
**Coupling was silent and left no trace.** A car simply vanished from the board with only a line of
|
||||||
|
history to say where it went. There are now two new sounds — a knuckle-coupler clank for coupling and
|
||||||
|
a quieter one for setting out — alongside the whistle, bell and conductor.
|
||||||
|
|
||||||
|
**Moves left were reported only in the history panel**, which is the one place a player is not
|
||||||
|
looking while switching. Now beside the buttons, and struck red at zero.
|
||||||
|
|
||||||
|
**A/D tracks were a tooltip.** "3 A/D tracks" with nothing on the card — the number that decides
|
||||||
|
whether the next arrival is an automatic collision. Drawn as pips, filled for taken; there is no room
|
||||||
|
on the card for more rails and the count is what matters.
|
||||||
|
|
||||||
|
**The phase changed under you.** Local Operations ends the moment the last Move is spent and the
|
||||||
|
automatic phases then run themselves, so the page could change between two clicks with no notice. A
|
||||||
|
banner now names the phase it moved to.
|
||||||
|
|
||||||
|
#### What was already right, from the same report
|
||||||
|
|
||||||
|
The car type and colour on the board (read as "cab in red" without being told), the Blocked panel
|
||||||
|
explaining a full industry track, the Facilities panel's accepted car types and spotted cars, and the
|
||||||
|
Division chip's `T10 (2)` with its consist on hover. All four were reported as useful, and none of
|
||||||
|
them changed.
|
||||||
|
|
||||||
|
### Softlock: a train being made up with no visible way to make it up
|
||||||
|
|
||||||
|
Reported at Stage 10 of seed 775569289 — Train 10 at the West Division Point, history saying "now
|
||||||
|
taking cars", and **nothing at all under Your Move**.
|
||||||
|
|
||||||
|
Moving train make-up onto the Division Yard chips took the "Making up …" group out of the action
|
||||||
|
list, and everything that was not a car went with it: the heading naming the train and what its card
|
||||||
|
calls for, and the "no more cars" button. The nine cars the train could take WERE clickable on the
|
||||||
|
yard chips the whole time — nothing on screen said so, and the panel a player looks at was empty.
|
||||||
|
|
||||||
|
The panel is back, and now says where to click: *"Click a car in the Division Yard below to add it —
|
||||||
|
9 kinds it may take are highlighted there."* It carries the pass button when passing is legal, which
|
||||||
|
§7 allows only when the Division Yard is bare — "must make every effort to find a suitable car".
|
||||||
|
|
||||||
|
**The engine was never at fault.** 2065 New Train decisions across 60 campaign games, and not one
|
||||||
|
offered zero options; the pass/place pair covers the phase. The panel still words the third case
|
||||||
|
honestly rather than implying a button that is not there.
|
||||||
|
|
||||||
|
**Two guards, because the obvious one would not have caught it.** A menu-level invariant — every
|
||||||
|
option the engine offers must be reachable through something the menu exposes — passes on this bug,
|
||||||
|
because `makeUp.pass` was in the menu and correct all along. What failed was the page never reading
|
||||||
|
it. So there is also a coarse check that `main.ts` references every field the menu offers: a field
|
||||||
|
nothing reads is either dead or a control that has gone missing. Verified by putting the regression
|
||||||
|
back and watching it fail.
|
||||||
|
|
||||||
|
### Four reports from playing seed 775569289
|
||||||
|
|
||||||
|
**A curve was described as a turnout.** Both read "east-west track with a 45° leg", which is a
|
||||||
|
turnout — a road straight across the card plus a leg off it. A curve has ONE road: in from the east
|
||||||
|
or west edge, along the centre line to the frog, out at 45° through the middle of a north or south
|
||||||
|
edge, and **nothing runs past it**. Which is precisely why it may not be laid in the Running Track,
|
||||||
|
so describing it as though it had a through track contradicted the rule that stops you.
|
||||||
|
|
||||||
|
**A curve in hand showed no preview.** The shapes were read off the card's legal PLACEMENTS, so a
|
||||||
|
card with nowhere legal to go had nothing to draw — and that is exactly when a player most wants to
|
||||||
|
see what the piece is. They now come from the card itself, which is where they belong: what a piece
|
||||||
|
looks like does not depend on whether there is currently a square for it.
|
||||||
|
|
||||||
|
**"Realignment on Mainline card 3"** named a raw node index. It said nothing about which stretch of
|
||||||
|
the Division it meant or what it would do, and it had no tooltip either — because the action list
|
||||||
|
attaches one only when a label happens to contain an em-dash, which this one did not. It now reads
|
||||||
|
|
||||||
|
> Realignment on the Uncontrolled Siding — the second Mainline card west to east; converts it to
|
||||||
|
> Double Track
|
||||||
|
|
||||||
|
and any action naming a card falls back to that card's own description when its label carries no
|
||||||
|
explanation of its own. That was the actual complaint: the same card explained itself perfectly in
|
||||||
|
hand and said nothing in the action list.
|
||||||
|
|
||||||
|
**It offered only one Mainline card, and that was correct.** `REALIGNMENTS` converts Plains, Curves,
|
||||||
|
Uncontrolled Siding and Trestle; the Division on that seed is a Heavy Grade and an Uncontrolled
|
||||||
|
Siding, so only the second could be converted. The engine was right and the label was hiding it —
|
||||||
|
"Mainline card 3" gave no way to tell a considered restriction from a bug. Naming the card fixes the
|
||||||
|
report without changing the rule.
|
||||||
|
|
||||||
|
### The hand is the action surface, and the yard makes up the train
|
||||||
|
|
||||||
|
The action list reached **22 buttons**, and most of it was a cross-product. A card appeared in two
|
||||||
|
panels under two different models: as a *subject* under "Play a card from my hand", which then
|
||||||
|
highlighted squares on the board, and as one flat button per Department under "Discard a card from
|
||||||
|
my hand". Four cards times three Departments was **twelve buttons repeating the same three choices
|
||||||
|
four times**, about 290px of the list. Separately, making up a train offered up to ten buttons
|
||||||
|
reading "add loaded hopper", "add empty boxcar" — while the Division Yard sat on screen already
|
||||||
|
showing exactly those cars by type and load state.
|
||||||
|
|
||||||
|
Both are now on the objects already being looked at, using the pattern board placement always had:
|
||||||
|
**pick the thing, then pick where it goes.**
|
||||||
|
|
||||||
|
- **Every card in hand carries its own verbs.** `play` highlights the squares it may go on, exactly
|
||||||
|
as before; a card needing no square (an Office upgrade, a train, a maneuver) goes down in one
|
||||||
|
click. `discard` lights up the three Department piles as targets — they already show their top
|
||||||
|
card and their depth, which is precisely what you choose between.
|
||||||
|
- **A make-up car is picked off the Division Yard chip** that shows it. The loaded and empty counts
|
||||||
|
are separate targets, because a car of a type and a load state is exactly what the choice is.
|
||||||
|
- The action list keeps what is not about a card or a car: the Local Operations choice, drawing,
|
||||||
|
switching moves, the Freight Agent, and finishing.
|
||||||
|
|
||||||
|
**Measured over a full game of seed 430: the widest action list went from 22 buttons to 5.** A test
|
||||||
|
now walks the same game and fails if it climbs back above 8.
|
||||||
|
|
||||||
|
The rotation step stays where it was and is now the only thing the placement panel shows — the card
|
||||||
|
is picked in the hand and the square on the board, so a rotation is the one question neither of those
|
||||||
|
can ask. Its hover previews, added earlier, are unchanged.
|
||||||
|
|
||||||
|
### A note on the two replay viewers
|
||||||
|
|
||||||
|
`TODO.md` now carries an item to decide between them. The standalone `node src/sim/replay.ts` writes
|
||||||
|
a self-contained HTML file that nothing links to and that `.gitignore` excludes; the site reads JSON
|
||||||
|
saves from `public/replays/`. The standalone one carries the bot's decision trace and a timetable
|
||||||
|
panel, which is debugging material rather than something a player wants. No action taken.
|
||||||
|
|
||||||
|
### A replay now looks like the game it is a replay of
|
||||||
|
|
||||||
|
**"Extra slow" was there and did nothing.** The site's replay viewer builds its interval with
|
||||||
|
whatever the speed select held when play started, and nothing re-read it — so changing pace
|
||||||
|
mid-replay had no effect at all and the pace looked stuck. The standalone replay had always
|
||||||
|
restarted its timer on change; this viewer was missed. Both offer the same five paces, extra slow
|
||||||
|
through very fast, and a test now asserts they stay in step.
|
||||||
|
|
||||||
|
**The turn chart lived on one screen out of three.** Where you are in the Day — the five phases with
|
||||||
|
the violet "you are here" — was in `main.ts` alone, so both replays reported the Day and the phase as
|
||||||
|
two plain strings. The same position looked like a different game depending on which screen you were
|
||||||
|
on. It is now `sim/turnchart.ts`, shared exactly as the board renderers are: the playable page and
|
||||||
|
the site viewer import it, and the standalone replay embeds it by `Function.toString()` because it
|
||||||
|
is a single file with an inline script and cannot import anything.
|
||||||
|
|
||||||
|
**And the side panels were three against eight.** The viewer showed the Division, the Office Area and
|
||||||
|
the log; the play page shows those plus cards in hand, the Department decks, the yards, the blockers
|
||||||
|
and the facilities. A replay could not answer *"why is nothing moving?"* — which is most of what a
|
||||||
|
replay is for. `web/panels.ts` now renders all of them for both pages, and the duplicated CSS is gone
|
||||||
|
from `play.html`.
|
||||||
|
|
||||||
|
Three tests hold it there: both screens must call the same panel renderers, all three must use the
|
||||||
|
shared turn chart and none may keep a private copy of the phase table, and the two viewers must offer
|
||||||
|
the same paces.
|
||||||
|
|
||||||
|
### You could not rotate a turnout at all, and now you can see what you are laying
|
||||||
|
|
||||||
|
**The rotation was being thrown away before it reached the menu.** `actionGroups` drops duplicate
|
||||||
|
labels, and `describeIntent` for a card play said only `play right-hand turnout at (0, 1)` — no
|
||||||
|
rotation in it. So a turnout's two orientations produced the same label and the second was silently
|
||||||
|
discarded. The "choose a rotation" step existed and worked; it was never given more than one
|
||||||
|
rotation to choose between. The label now names the orientation, and both survive.
|
||||||
|
|
||||||
|
**And the buttons are pictures now.** Hovering a rotation draws the piece as it will land on the
|
||||||
|
board, and hovering the card itself draws every shape it could be laid as — which is how a player
|
||||||
|
sees a turnout has two orientations before picking a square at all. Rendered by `officeSvg`, the
|
||||||
|
board's own renderer, on a one-card board: the preview and the board cannot disagree about what the
|
||||||
|
piece looks like, and the rails come from the engine's `connectionsFor`, so a preview cannot promise
|
||||||
|
a shape the placement will not produce.
|
||||||
|
|
||||||
|
`Placeable.spots` carries the links for this. `data-tip-html` on the tooltip renders a figure above
|
||||||
|
the caption; it is only ever set from markup this app builds.
|
||||||
|
|
||||||
### Turnouts say what they do, and a curve may not break the Running Track
|
### Turnouts say what they do, and a curve may not break the Running Track
|
||||||
|
|
||||||
**"Right-hand turnout, stem east, through west, diverges north at 45°"** is three pieces of jargon
|
**"Right-hand turnout, stem east, through west, diverges north at 45°"** is three pieces of jargon
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ Ordered within each section by how much it is currently costing us.
|
|||||||
holds 243 of which 104 are track, so every draw is diluted by 43% — precisely the pressure
|
holds 243 of which 104 are track, so every draw is diluted by 43% — precisely the pressure
|
||||||
those multipliers exist to relieve. Until then, read no balance conclusion from the revenue
|
those multipliers exist to relieve. Until then, read no balance conclusion from the revenue
|
||||||
numbers; they are a functionality signal only.
|
numbers; they are a functionality signal only.
|
||||||
|
- [ ] **Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts
|
||||||
|
--seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON
|
||||||
|
saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored,
|
||||||
|
so it is a developer tool that happens to look like a product feature. It carries two panels
|
||||||
|
the site viewer does not — the bot's decision trace ("what it chose, why, and what it passed
|
||||||
|
over") and the timetable — which is debugging material rather than something a player wants.
|
||||||
|
Decide: fold the decision trace into the JSON viewer and delete the standalone, or keep it and
|
||||||
|
accept that it is a tool. No action for now.
|
||||||
- [ ] **The bot was partly living off an illegal placement.** Barring curves from the Running Track
|
- [ ] **The bot was partly living off an illegal placement.** Barring curves from the Running Track
|
||||||
(they have no east-west road and dead-end the main) cost it districts 28.0 → 19.7 cards and
|
(they have no east-west road and dead-end the main) cost it districts 28.0 → 19.7 cards and
|
||||||
revenue ~2.0 → 0.8. It has no plan for where a curve should go once the easy square is gone.
|
revenue ~2.0 → 0.8. It has no plan for where a curve should go once the easy square is gone.
|
||||||
|
|||||||
+14
-1
@@ -719,7 +719,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
from,
|
from,
|
||||||
to: i.to,
|
to: i.to,
|
||||||
movesRemaining: s.turn.movesRemaining - 1,
|
movesRemaining: s.turn.movesRemaining - 1,
|
||||||
facing: opposite(dest.entry),
|
/**
|
||||||
|
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND.
|
||||||
|
*
|
||||||
|
* `facing` is which way the ENGINE points, and this set it to the direction of travel on
|
||||||
|
* every move — so one reverse move silently spun the train about. Everything then read
|
||||||
|
* "forward" again, and a run-around became pointless: you could change ends for free by
|
||||||
|
* backing up twice.
|
||||||
|
*
|
||||||
|
* Running forward the engine leads, so it points the way the train went: `opposite(entry)`.
|
||||||
|
* Backing up it trails, still pointing the way it came, which is the port it arrived
|
||||||
|
* through. Both hold around a curve, where the compass heading changes but the engine's
|
||||||
|
* relationship to its train does not.
|
||||||
|
*/
|
||||||
|
facing: i.reverse ? dest.entry : opposite(dest.entry),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (dest.couples.length > 0) {
|
if (dest.couples.length > 0) {
|
||||||
|
|||||||
+14
-2
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
||||||
|
import { enhancementRule } from './content.ts';
|
||||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||||
import type { Intent } from './intents.ts';
|
import type { Intent } from './intents.ts';
|
||||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||||
@@ -140,8 +141,19 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
|||||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||||
out.push({ type: 'card.play', cardId });
|
out.push({ type: 'card.play', cardId });
|
||||||
const kind = s.cards.get(cardId)?.kind;
|
const kind = s.cards.get(cardId)?.kind;
|
||||||
// Enhancements ATTACH to a card already down, so their candidates are the occupied cells.
|
/**
|
||||||
const targets = kind?.kind === 'enhancement' ? attachments : placements;
|
* Enhancements ATTACH to a card already down, so their candidates are the occupied cells — with
|
||||||
|
* one exception. ABS Signals goes on a MAINLINE card, and `checkEnhancementPlacement` reads
|
||||||
|
* `placement.col` as a Division node index for it. Offering it grid squares meant the engine
|
||||||
|
* accepted "(0, 1)" because node 1 happened to be a Mainline card: the label said one thing and
|
||||||
|
* the placement meant another, so the card appeared to be playable all over the Office Area.
|
||||||
|
*/
|
||||||
|
const onMainline = kind?.kind === 'enhancement' && enhancementRule(kind.key)?.placement === 'mainlineCard';
|
||||||
|
const targets = onMainline
|
||||||
|
? s.division.nodes.map((_, node) => ({ row: -1, col: node }))
|
||||||
|
: kind?.kind === 'enhancement'
|
||||||
|
? attachments
|
||||||
|
: placements;
|
||||||
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
|
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
|
||||||
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
|
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
|
||||||
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
|
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
|
||||||
|
|||||||
+63
-5
@@ -96,8 +96,14 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
kind: 'run',
|
kind: 'run',
|
||||||
label: rc.label,
|
label: rc.label,
|
||||||
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '',
|
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,
|
* A train standing at the Office occupies an A/D track, which is where it is — but it is
|
||||||
|
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
|
||||||
|
* drawn twice. Reported as two T10 chips on one Office.
|
||||||
|
*/
|
||||||
|
trains: isOffice
|
||||||
|
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
|
||||||
|
: rc.trains,
|
||||||
cap: isOffice ? cap : null,
|
cap: isOffice ? cap : null,
|
||||||
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
|
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
|
||||||
owner: n.owner ?? null,
|
owner: n.owner ?? null,
|
||||||
@@ -420,6 +426,20 @@ export function officeSvg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A/D TRACKS, as pips.
|
||||||
|
*
|
||||||
|
* The Office tooltip said "3 A/D tracks" and the card showed nothing — so the number that
|
||||||
|
* decides whether the next arrival is an automatic collision (§8.3) was invisible on the card it
|
||||||
|
* belongs to. There is no room to draw more rails, and the count is what matters: one pip per
|
||||||
|
* track, filled for taken.
|
||||||
|
*/
|
||||||
|
if (cell.ad) {
|
||||||
|
for (let i = 0; i < cell.ad.of; i++) {
|
||||||
|
out += `<circle class="bs-ad${i < cell.ad.used ? ' bs-occ' : ''}" cx="${W - 9 - i * 9}" cy="10" r="3"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -503,9 +523,35 @@ export function officeSvg(
|
|||||||
// card, the old y = H-46 baseline printed the label straight along the rail itself.
|
// card, the old y = H-46 baseline printed the label straight along the rail itself.
|
||||||
out += `<text class="bs-enh" x="6" y="26">${esc(cell.enhancements.join(' · '))}</text>`;
|
out += `<text class="bs-enh" x="6" y="26">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||||
}
|
}
|
||||||
if (cell.tray) {
|
if (cell.train) {
|
||||||
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
|
/**
|
||||||
`<text class="bs-tlab" x="${W / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(cell.tray.split(' ')[0] ?? 'crew')}</text></g>`;
|
* THE TRAIN, DRAWN AS IT SITS IN THE TRAY.
|
||||||
|
*
|
||||||
|
* The badge used to be a name and nothing more, which makes the switching game unplayable:
|
||||||
|
* every decision is about car ORDER — which car comes off next, which end a cut couples onto —
|
||||||
|
* and none of it was on the board. Now the engine and its cars are drawn in sequence, nose
|
||||||
|
* first, with an arrow for which way the engine points, so "set out the caboose off the back"
|
||||||
|
* is a thing you can see before you choose it.
|
||||||
|
*/
|
||||||
|
const t = cell.train;
|
||||||
|
const items: { label: string; kind: string }[] = t.cars.map((c) => ({
|
||||||
|
label: /caboose/.test(c) ? 'cab' : /hopper/.test(c) ? 'hop' : /boxcar/.test(c) ? 'box'
|
||||||
|
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
|
||||||
|
kind: /^loaded/.test(c) ? 'ld' : 'mt',
|
||||||
|
}));
|
||||||
|
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
|
||||||
|
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng' });
|
||||||
|
const cw = 17;
|
||||||
|
const tw = Math.min(W - 8, items.length * cw + 30);
|
||||||
|
const tx = W / 2 - tw / 2;
|
||||||
|
out += `<g class="bs-crew"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
|
||||||
|
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
|
||||||
|
items.forEach((it, n) => {
|
||||||
|
const bx = tx + 28 + n * cw;
|
||||||
|
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2"/>` +
|
||||||
|
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(it.label)}</text>`;
|
||||||
|
});
|
||||||
|
out += '</g>';
|
||||||
}
|
}
|
||||||
out += '</g>';
|
out += '</g>';
|
||||||
}
|
}
|
||||||
@@ -579,6 +625,18 @@ export const BOARD_CSS = `
|
|||||||
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
|
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
|
||||||
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
||||||
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
||||||
|
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
|
||||||
|
engine is the one that carries the arrow — which is what makes "reverse" mean something. */
|
||||||
|
.bs-tcar{stroke:#3a2f14;stroke-width:.8}
|
||||||
|
.bs-tcar.bs-t-ld{fill:#e8dcc0}
|
||||||
|
.bs-tcar.bs-t-mt{fill:#6b5a2e}
|
||||||
|
.bs-tcar.bs-t-eng{fill:#2b2313;stroke:#e0c060;stroke-width:1.4}
|
||||||
|
.bs-tcarlab{font:8px ui-monospace,monospace;fill:#2b2313;font-weight:700}
|
||||||
|
/* One pip per A/D track, filled when a train is holding on it. A full Office is why the next
|
||||||
|
arrival collides, so this is the count worth seeing without hovering. */
|
||||||
|
.bs-ad{fill:none;stroke:#4d6fa8;stroke-width:1.2}
|
||||||
|
.bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee}
|
||||||
|
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
|
||||||
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
||||||
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
|
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
|
||||||
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||||||
|
|||||||
@@ -547,6 +547,10 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
|
|||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (e.type === 'trainMadeUp') out.push('train');
|
if (e.type === 'trainMadeUp') out.push('train');
|
||||||
|
// Coupling and setting out are what a switching move IS, and both were silent. A car leaving the
|
||||||
|
// board with only a line of history to say where it went is the thing that most needs a noise.
|
||||||
|
if (e.type === 'carsCoupled') out.push('couple');
|
||||||
|
if (e.type === 'carsDropped' || e.type === 'flyingSwitch') out.push('drop');
|
||||||
if (e.type === 'stageBegan') {
|
if (e.type === 'stageBegan') {
|
||||||
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
||||||
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
|
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
|
||||||
|
|||||||
+15
-8
@@ -37,6 +37,7 @@ 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, officeSvg } from './board-svg.ts';
|
import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts';
|
||||||
|
import { TURNCHART_CSS, turnChartHtml } from './turnchart.ts';
|
||||||
import { playCue } from '../web/sound.ts';
|
import { playCue } from '../web/sound.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -201,7 +202,9 @@ export function compress(frames: Frame[]): Packed {
|
|||||||
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
|
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
|
||||||
// cell and the frame's `facilities`, so this always resolves.
|
// cell and the frame's `facilities`, so this always resolves.
|
||||||
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
||||||
return [ci, wi, c.enhancements, c.tray, c.cars, fi];
|
// `train` rides whole rather than being interned: it changes almost every frame, so a table
|
||||||
|
// of them would be as long as the frames are and buy nothing.
|
||||||
|
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad];
|
||||||
});
|
});
|
||||||
return { ...f, cells } as unknown as Frame;
|
return { ...f, cells } as unknown as Frame;
|
||||||
});
|
});
|
||||||
@@ -235,12 +238,14 @@ export function rehydrateCells(
|
|||||||
facs: unknown[],
|
facs: unknown[],
|
||||||
): unknown[] {
|
): unknown[] {
|
||||||
return packed.map((row) => {
|
return packed.map((row) => {
|
||||||
const p = row as [number, number, string[], string | null, string[], number];
|
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown];
|
||||||
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
||||||
return {
|
return {
|
||||||
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
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],
|
what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4],
|
||||||
facility: p[5] < 0 ? null : facs[p[5]],
|
facility: p[5] < 0 ? null : facs[p[5]],
|
||||||
|
train: p[6] ?? null,
|
||||||
|
ad: p[7] ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -253,6 +258,7 @@ export function renderHtml(rec: Recording): string {
|
|||||||
<title>Station Master — replay seed ${rec.seed}</title>
|
<title>Station Master — replay seed ${rec.seed}</title>
|
||||||
<style>
|
<style>
|
||||||
${BOARD_CSS}
|
${BOARD_CSS}
|
||||||
|
${TURNCHART_CSS}
|
||||||
|
|
||||||
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
|
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
|
||||||
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
|
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
|
||||||
@@ -344,15 +350,17 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
|||||||
<header>
|
<header>
|
||||||
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
|
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<span class="big" id="when">—</span>
|
<span class="dim">fedora <span id="super">—</span></span>
|
||||||
<span>phase <b id="phase">—</b></span>
|
|
||||||
<span class="dim">actor <span id="actor">—</span> · fedora <span id="super">—</span></span>
|
|
||||||
<span>revenue <b class="big" id="rev">0</b></span>
|
<span>revenue <b class="big" id="rev">0</b></span>
|
||||||
<span class="dim">deck <span id="deck">0</span></span>
|
<span class="dim">deck <span id="deck">0</span></span>
|
||||||
<span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
|
<span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- The live game's turn chart, from the same renderer, so where you are in the Day looks the same
|
||||||
|
whether you are playing or watching. -->
|
||||||
|
<div id="turnchart"></div>
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div>
|
<div>
|
||||||
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
|
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
|
||||||
@@ -414,6 +422,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
|||||||
from the same state, which is the one failure a track diagram exists to prevent. */
|
from the same state, which is the one failure a track diagram exists to prevent. */
|
||||||
const divisionSvg = ${divisionSvg.toString()};
|
const divisionSvg = ${divisionSvg.toString()};
|
||||||
const officeSvg = ${officeSvg.toString()};
|
const officeSvg = ${officeSvg.toString()};
|
||||||
|
const turnChartHtml = ${turnChartHtml.toString()};
|
||||||
const playCue = ${playCue.toString()};
|
const playCue = ${playCue.toString()};
|
||||||
|
|
||||||
const PACKED = ${JSON.stringify(compress(rec.frames))};
|
const PACKED = ${JSON.stringify(compress(rec.frames))};
|
||||||
@@ -458,9 +467,7 @@ function boxes(items, cap, cls) {
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const f = FRAMES[i];
|
const f = FRAMES[i];
|
||||||
$('when').textContent = 'Day ' + f.day + ' · Stage ' + f.stage + ' · ' + f.clock;
|
$('turnchart').innerHTML = turnChartHtml(f, f.actor === null ? null : 'Player ' + (f.actor + 1));
|
||||||
$('phase').textContent = f.phase;
|
|
||||||
$('actor').textContent = f.actor === null ? 'automatic' : 'P' + f.actor;
|
|
||||||
$('super').textContent = 'P' + f.superintendent;
|
$('super').textContent = 'P' + f.superintendent;
|
||||||
$('rev').textContent = f.revenue;
|
$('rev').textContent = f.revenue;
|
||||||
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
|
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* The turn chart — where in the Day we are, and which of the five phases is running.
|
||||||
|
*
|
||||||
|
* SELF-CONTAINED ON PURPOSE, exactly like the two board renderers in `board-svg.ts`. The playable
|
||||||
|
* app and the site's replay viewer import it normally; the standalone replay is a single HTML file
|
||||||
|
* with an inline script and cannot import anything, so it embeds this via `Function.toString()`.
|
||||||
|
* That keeps ONE implementation across all three screens.
|
||||||
|
*
|
||||||
|
* It lived only in `main.ts` before, so a replay showed the Day and the phase as plain text while
|
||||||
|
* the live game showed the chart — and the violet "you are here" highlight, which is the single
|
||||||
|
* most-glanced-at thing on the page, appeared on one screen out of three.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What a chart needs from a frame. A `Frame` satisfies this; so does a replay's rehydrated one. */
|
||||||
|
export type TurnChartFrame = {
|
||||||
|
day: number;
|
||||||
|
stage: number;
|
||||||
|
clock: string;
|
||||||
|
phase: string;
|
||||||
|
phaseKey: string;
|
||||||
|
actor: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five phases of a Stage, in order, each with what actually happens in it.
|
||||||
|
*
|
||||||
|
* The first three are the rules text; Cargo and Supervisor Shift are written from what the engine
|
||||||
|
* does, since the recovered sheet does not spell them out.
|
||||||
|
*/
|
||||||
|
export function turnChartHtml(f: TurnChartFrame, actorName: string | null): string {
|
||||||
|
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
|
||||||
|
{
|
||||||
|
key: 'localOps',
|
||||||
|
label: 'Local Operations',
|
||||||
|
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
|
||||||
|
// a turnout: the choice of three roads
|
||||||
|
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'newTrain',
|
||||||
|
label: 'New Train',
|
||||||
|
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
|
||||||
|
// a locomotive being made up
|
||||||
|
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mainline',
|
||||||
|
label: 'Mainline',
|
||||||
|
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
|
||||||
|
// open road
|
||||||
|
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loadUnload',
|
||||||
|
label: 'Cargo',
|
||||||
|
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
|
||||||
|
// a loaded box on a flat
|
||||||
|
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'shiftChange',
|
||||||
|
label: 'Supervisor Shift',
|
||||||
|
tip: "End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent's hat passes to the next player.",
|
||||||
|
// a pocket watch
|
||||||
|
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const esc = (t: string): string =>
|
||||||
|
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||||
|
|
||||||
|
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
|
||||||
|
const chips = PHASES.map((p, i) => {
|
||||||
|
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
|
||||||
|
return (
|
||||||
|
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
|
||||||
|
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
|
||||||
|
`<span>${esc(p.label)}</span></li>`
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// An automatic phase is waiting on nobody, and saying so is more use than a blank.
|
||||||
|
const who = actorName ?? 'nobody — the Division is running itself';
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
||||||
|
`<span class="dim">${esc(f.clock)}</span></div>` +
|
||||||
|
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
||||||
|
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` +
|
||||||
|
`<ol class="tc-phases">${chips}</ol>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Styling, shared so the three screens cannot drift.
|
||||||
|
*
|
||||||
|
* VIOLET, not the amber used for actions. The palette already spends amber on "you can click this",
|
||||||
|
* blue on "this is a legal square or a link", green on good and red on bad. The turn chart is none
|
||||||
|
* of those — it reports WHERE YOU ARE, and a player should never read it as something to press.
|
||||||
|
* Violet is the one hue not already carrying a meaning, so it cannot be confused with any of them.
|
||||||
|
*/
|
||||||
|
export const TURNCHART_CSS = `
|
||||||
|
#turnchart{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
|
||||||
|
background:#161b22;border-bottom:1px solid #2c333d;padding:7px 14px}
|
||||||
|
.tc-when{display:flex;align-items:baseline;gap:10px}
|
||||||
|
.tc-when b{font-size:15px;color:#cfe0f5}
|
||||||
|
.tc-now{font-size:12px;color:#8b94a3}
|
||||||
|
.tc-now b{color:#b98cf0;font-size:13px}
|
||||||
|
/* WHO EVERYONE IS WAITING ON. Solitaire has one player, but the seat still has a name — the log
|
||||||
|
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
|
||||||
|
glances at most often, so it gets its own chip rather than hiding in the phase text. */
|
||||||
|
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3}
|
||||||
|
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
||||||
|
border-radius:11px;padding:1px 9px;font-size:12px}
|
||||||
|
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
|
||||||
|
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px;
|
||||||
|
padding:3px 10px 3px 7px;font-size:11px;color:#8b94a3;background:#1a1f26;cursor:help}
|
||||||
|
.tc-phase svg{display:block;flex:0 0 auto}
|
||||||
|
.tc-phase .ic{stroke:#5f6b7a;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
.tc-phase .icf{fill:#5f6b7a;stroke:none}
|
||||||
|
.tc-phase.done{opacity:.55}
|
||||||
|
.tc-phase.now{border-color:#b98cf0;background:rgba(150,110,230,.15);color:#e7dcfb;font-weight:700;
|
||||||
|
box-shadow:0 0 0 2px rgba(150,110,230,.18)}
|
||||||
|
.tc-phase.now .ic{stroke:#b98cf0}
|
||||||
|
.tc-phase.now .icf{fill:#b98cf0}
|
||||||
|
`;
|
||||||
+170
-16
@@ -17,10 +17,12 @@ import {
|
|||||||
MAINLINE_MODIFIER_CARDS,
|
MAINLINE_MODIFIER_CARDS,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
MANEUVER_CARDS,
|
MANEUVER_CARDS,
|
||||||
|
REALIGNMENTS,
|
||||||
REGIONS_PER_MAINLINE_CARD,
|
REGIONS_PER_MAINLINE_CARD,
|
||||||
OFFICE_ORDER,
|
OFFICE_ORDER,
|
||||||
SPACE_USE_CARDS,
|
SPACE_USE_CARDS,
|
||||||
industryProfile,
|
industryProfile,
|
||||||
|
mainlineProfile,
|
||||||
modifierProfile,
|
modifierProfile,
|
||||||
lengthProfile,
|
lengthProfile,
|
||||||
officeProfile,
|
officeProfile,
|
||||||
@@ -32,7 +34,7 @@ import type { Hand, TrackGeometry } from '../engine/content.ts';
|
|||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||||
import type { Impediment } from './narrate.ts';
|
import type { Impediment } from './narrate.ts';
|
||||||
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
|
import { carLabel, carsLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
|
||||||
|
|
||||||
export type CellView = {
|
export type CellView = {
|
||||||
row: number;
|
row: number;
|
||||||
@@ -47,6 +49,25 @@ export type CellView = {
|
|||||||
*/
|
*/
|
||||||
enhancements: string[];
|
enhancements: string[];
|
||||||
tray: string | null;
|
tray: string | null;
|
||||||
|
/**
|
||||||
|
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
|
||||||
|
*
|
||||||
|
* The switching game is entirely about car ORDER — which car is next to come off, which end a cut
|
||||||
|
* couples onto — and the board showed a crew badge with a name and nothing else. A player could
|
||||||
|
* not plan a move at all: "drop 1 car" tells you nothing when you cannot see what is on the back.
|
||||||
|
*
|
||||||
|
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
|
||||||
|
* `facing` is the port it points at on this card.
|
||||||
|
*/
|
||||||
|
train: { label: string; cars: string[]; engineAt: number; facing: string } | null;
|
||||||
|
/**
|
||||||
|
* Office card only: A/D tracks taken and how many the tier has.
|
||||||
|
*
|
||||||
|
* The tooltip said "3 A/D tracks" and the card showed nothing, so the number that decides whether
|
||||||
|
* the next arrival collides was invisible on the card it belongs to. Drawn as pips rather than
|
||||||
|
* extra rails — there is no room on the card for more track, and the count is what matters.
|
||||||
|
*/
|
||||||
|
ad: { used: number; of: number } | null;
|
||||||
cars: string[];
|
cars: string[];
|
||||||
facility: FacilityView | null;
|
facility: FacilityView | null;
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +100,17 @@ export type FacilityView = {
|
|||||||
trackCap: number;
|
trackCap: number;
|
||||||
laborers: string;
|
laborers: string;
|
||||||
porters: string;
|
porters: string;
|
||||||
|
/**
|
||||||
|
* What the INDUSTRY CARD itself prints, before any Modifier beside it.
|
||||||
|
*
|
||||||
|
* A modifier's whole effect is a number going up, and the panel showed only the number — so an Ice
|
||||||
|
* House raising outbound capacity from 0 to 1 and laborers from 1 to 2 looked like nothing had
|
||||||
|
* happened. Reported after playing an Ice House and Local Small Groceries and seeing no change
|
||||||
|
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
|
||||||
|
*/
|
||||||
|
base: { out: number; in: number; laborers: number };
|
||||||
|
/** The Modifier cards standing beside it, by name. */
|
||||||
|
modifiers: string[];
|
||||||
/**
|
/**
|
||||||
* Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
|
* Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
|
||||||
* WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
|
* WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
|
||||||
@@ -173,6 +205,14 @@ export type Frame = {
|
|||||||
cells: CellView[];
|
cells: CellView[];
|
||||||
/** Which grid row is the Running Track — the spine the district hangs beneath. */
|
/** Which grid row is the Running Track — the spine the district hangs beneath. */
|
||||||
runningRow: number;
|
runningRow: number;
|
||||||
|
/**
|
||||||
|
* Moves left in this Local Operations turn, or null outside a switching turn.
|
||||||
|
*
|
||||||
|
* It was reported only in the history, which is the one panel a player is NOT looking at while
|
||||||
|
* switching — the count that decides whether a run-around is still possible belongs next to the
|
||||||
|
* moves themselves.
|
||||||
|
*/
|
||||||
|
movesLeft: number | null;
|
||||||
facilities: FacilityView[];
|
facilities: FacilityView[];
|
||||||
hand: string[];
|
hand: string[];
|
||||||
/** What each hand card does, in the same order — names alone are not a playable hand. */
|
/** What each hand card does, in the same order — names alone are not a playable hand. */
|
||||||
@@ -256,7 +296,7 @@ const FACILITY_NAMES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function facilityView(
|
function facilityView(
|
||||||
card: { geometry: { kind: string; facility?: string }; facility: unknown },
|
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
|
||||||
officeName: string,
|
officeName: string,
|
||||||
): FacilityView | null {
|
): FacilityView | null {
|
||||||
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
|
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
|
||||||
@@ -282,9 +322,42 @@ function facilityView(
|
|||||||
porters: `${portersLeft(f)}/${f.porters}`,
|
porters: `${portersLeft(f)}/${f.porters}`,
|
||||||
canFinish: canFinishHere(f),
|
canFinish: canFinishHere(f),
|
||||||
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
|
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
|
||||||
|
base: baseOf(card),
|
||||||
|
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the card itself prints, before any Modifier beside it.
|
||||||
|
*
|
||||||
|
* Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card
|
||||||
|
* the player is holding. A passenger facility takes its numbers from the Office tier instead.
|
||||||
|
*/
|
||||||
|
function baseOf(card: { geometry: { kind: string; facility?: string } }): { out: number; in: number; laborers: number } {
|
||||||
|
const g = card.geometry;
|
||||||
|
if (g.kind === 'facility' && g.facility) {
|
||||||
|
const p = industryProfile(g.facility as never);
|
||||||
|
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders };
|
||||||
|
}
|
||||||
|
return { out: 0, in: 0, laborers: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
|
||||||
|
function trainOnCard(s: GameState, key: string): CellView['train'] {
|
||||||
|
for (const [id, t] of s.trays) {
|
||||||
|
if (t.position.at !== 'grid') continue;
|
||||||
|
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
||||||
|
return {
|
||||||
|
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||||
|
cars: t.consist.map(carLabel),
|
||||||
|
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
|
||||||
|
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
|
||||||
|
};
|
||||||
|
void id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
|
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
|
||||||
function canFinishHere(f: Facility): boolean {
|
function canFinishHere(f: Facility): boolean {
|
||||||
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
|
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
|
||||||
@@ -344,8 +417,22 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
: i.option === 'draw'
|
: i.option === 'draw'
|
||||||
? 'DRAW — take a card and play one, and you may lay a piece of track'
|
? 'DRAW — take a card and play one, and you may lay a piece of track'
|
||||||
: 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
|
: 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
|
||||||
case 'card.play':
|
case 'card.play': {
|
||||||
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
|
/**
|
||||||
|
* NAME THE ROTATION, or the choice disappears.
|
||||||
|
*
|
||||||
|
* The action list drops duplicate labels, and this said only "play X at (0, 1)" — so a
|
||||||
|
* turnout's two orientations produced one identical label each and the second was silently
|
||||||
|
* discarded before the menu ever saw it. The rotation is the entire decision for a turnout or
|
||||||
|
* a curve, and it could not be made.
|
||||||
|
*/
|
||||||
|
const kind = s.cards.get(i.cardId)?.kind;
|
||||||
|
const turn =
|
||||||
|
i.placement && kind?.kind === 'track'
|
||||||
|
? variantLabel(kind.geometry, i.variant, kind.hand)
|
||||||
|
: '';
|
||||||
|
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`;
|
||||||
|
}
|
||||||
case 'card.discard': {
|
case 'card.discard': {
|
||||||
/**
|
/**
|
||||||
* NAME THE DEPARTMENT, and what the card would land on.
|
* NAME THE DEPARTMENT, and what the card would land on.
|
||||||
@@ -362,8 +449,23 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
}
|
}
|
||||||
case 'switch.move':
|
case 'switch.move':
|
||||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
|
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
|
||||||
case 'switch.dropCars':
|
case 'switch.dropCars': {
|
||||||
return `drop ${i.count} car(s)`;
|
/**
|
||||||
|
* NAME THE CARS AND THE END THEY COME OFF.
|
||||||
|
*
|
||||||
|
* This said "drop 1 car(s)", which is two failures at once. It never said WHICH car, so a
|
||||||
|
* player who knew the caboose was on the back still had to guess; and it read identically for
|
||||||
|
* a nose drop and a tail drop, so — the action list dropping duplicate labels — setting out
|
||||||
|
* from the front of the train was silently discarded and could not be chosen at all.
|
||||||
|
*/
|
||||||
|
const tray = s.trays.get(i.trayId);
|
||||||
|
if (!tray) return `drop ${i.count} car(s)`;
|
||||||
|
const cut = i.fromNose
|
||||||
|
? tray.consist.slice(0, i.count)
|
||||||
|
: tray.consist.slice(tray.consist.length - i.count);
|
||||||
|
const end = i.fromNose ? 'off the front' : 'off the back';
|
||||||
|
return `set out ${carsLabel(cut)} ${end}`;
|
||||||
|
}
|
||||||
case 'switch.sortConsist':
|
case 'switch.sortConsist':
|
||||||
return `re-order consist [${i.order.join(',')}]`;
|
return `re-order consist [${i.order.join(',')}]`;
|
||||||
case 'freightAgent.stockOutbound':
|
case 'freightAgent.stockOutbound':
|
||||||
@@ -386,8 +488,35 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
|
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
|
||||||
// "add loaded caboose" on a button.
|
// "add loaded caboose" on a button.
|
||||||
return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`;
|
return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`;
|
||||||
case 'mainline.modify':
|
case 'mainline.modify': {
|
||||||
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
|
/**
|
||||||
|
* NAME THE CARD, NOT ITS INDEX, AND SAY WHAT HAPPENS TO IT.
|
||||||
|
*
|
||||||
|
* This read "Realignment on Mainline card 3" — a raw node index, which tells a player nothing
|
||||||
|
* about which stretch of the Division it means, and no clue what the card would do to it.
|
||||||
|
* Worse, the action list attaches a tooltip only when a label carries an em-dash, so this one
|
||||||
|
* silently had none at all while the same card in hand did.
|
||||||
|
*/
|
||||||
|
const node = s.division.nodes[i.node];
|
||||||
|
const key = (s.cards.get(i.cardId)?.kind as { key?: string } | undefined)?.key;
|
||||||
|
const shortWhere = node?.kind === 'mainline' ? `the ${mainlineProfile(node.card).name}` : `Mainline card ${i.node}`;
|
||||||
|
const where =
|
||||||
|
node?.kind === 'mainline'
|
||||||
|
? `the ${ordinal(mainlineIndex(s, i.node))} Mainline card west to east`
|
||||||
|
: `node ${i.node}`;
|
||||||
|
let effect = '';
|
||||||
|
if (key === 'realignment' && node?.kind === 'mainline') {
|
||||||
|
const to = REALIGNMENTS.find((r) => r.from === node.card);
|
||||||
|
effect = to ? `converts it to ${mainlineProfile(to.to).name}` : 'nothing here to convert';
|
||||||
|
} else if (key === 'brakeman' || key === 'airbrakes') {
|
||||||
|
effect = 'one Stage off the descent for a train running downhill';
|
||||||
|
} else if (key === 'helpers') {
|
||||||
|
effect = 'one Stage off the climb for a train running uphill';
|
||||||
|
}
|
||||||
|
// Short head, full detail after the em-dash — `actionButton` puts the first on the button and
|
||||||
|
// the second in the tooltip, so the list stays narrow without losing the explanation.
|
||||||
|
return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`;
|
||||||
|
}
|
||||||
case 'maneuver.redFlags':
|
case 'maneuver.redFlags':
|
||||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
||||||
case 'maneuver.flyingSwitch':
|
case 'maneuver.flyingSwitch':
|
||||||
@@ -495,6 +624,11 @@ export function snapshot(
|
|||||||
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||||
enhancements: card.enhancements.map(prettyKey),
|
enhancements: card.enhancements.map(prettyKey),
|
||||||
tray: trayAt.get(key) ?? null,
|
tray: trayAt.get(key) ?? null,
|
||||||
|
train: trainOnCard(s, key),
|
||||||
|
ad:
|
||||||
|
card.geometry.kind === 'office'
|
||||||
|
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
|
||||||
|
: null,
|
||||||
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
|
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
|
||||||
facility: fv,
|
facility: fv,
|
||||||
});
|
});
|
||||||
@@ -660,6 +794,7 @@ export function snapshot(
|
|||||||
wasted,
|
wasted,
|
||||||
objective: objectiveOf(s),
|
objective: objectiveOf(s),
|
||||||
runningRow: area.runningRow,
|
runningRow: area.runningRow,
|
||||||
|
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
|
||||||
blocked: impediments(s, 0),
|
blocked: impediments(s, 0),
|
||||||
trains: [...s.trays.values()].map((t) => ({
|
trains: [...s.trays.values()].map((t) => ({
|
||||||
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||||
@@ -772,15 +907,24 @@ export function cardDescription(s: GameState, id: string): string {
|
|||||||
if (k.geometry === 'straight') {
|
if (k.geometry === 'straight') {
|
||||||
return `east-west through track · lay it anywhere the rail continues${cost}`;
|
return `east-west through track · lay it anywhere the rail continues${cost}`;
|
||||||
}
|
}
|
||||||
const arcs = variantsFor(k.geometry, k.hand)
|
|
||||||
.map((v) => v.arc ?? `${v.turnout?.stem}-${v.turnout?.diverge}`)
|
|
||||||
.join(' or ');
|
|
||||||
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
|
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
|
||||||
return (
|
const ways = variantsFor(k.geometry, k.hand)
|
||||||
`east-west track with a 45° leg through the middle of the north or south edge · ` +
|
.map((v) => (v.arc ? curvePhrase(v.arc) : v.turnout ? turnoutPhrase(v.turnout) : ''))
|
||||||
`lay it as ${arcs} · its leg is on the ${diagonal} diagonal and only meets a card on the ` +
|
.join(', or turned about, ');
|
||||||
`same one${stop}${cost}`
|
/**
|
||||||
);
|
* A CURVE IS NOT A TURNOUT, and it used to be described as one.
|
||||||
|
*
|
||||||
|
* Both said "east-west track with a 45° leg", which is a turnout: a road straight across the
|
||||||
|
* card plus a leg off it. A curve has ONE road and no choice to make — it comes in from an
|
||||||
|
* east or west edge, runs along the centre line to the frog, and leaves at 45° through the
|
||||||
|
* middle of a north or south edge. Nothing runs past it, which is exactly why it may not be
|
||||||
|
* laid in the Running Track.
|
||||||
|
*/
|
||||||
|
const what =
|
||||||
|
k.geometry === 'turnout'
|
||||||
|
? 'east-west track with a 45° leg through the middle of the north or south edge'
|
||||||
|
: 'a single road: in from the east or west edge, then out at 45° through the middle of the north or south edge — no track runs past it';
|
||||||
|
return `${what} · lay it so it ${ways} · its 45° leg is on the ${diagonal} diagonal and only meets a card on the same one${stop}${cost}`;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
// The recovered categories carry their own prose — effect plus where it may be played.
|
// The recovered categories carry their own prose — effect plus where it may be played.
|
||||||
@@ -876,6 +1020,16 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which Mainline card this is, counting only the Mainline cards from the west end. */
|
||||||
|
function mainlineIndex(s: GameState, node: number): number {
|
||||||
|
let n = 0;
|
||||||
|
for (let k = 0; k <= node; k++) if (s.division.nodes[k]?.kind === 'mainline') n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDINALS = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth'];
|
||||||
|
const ordinal = (n: number): string => ORDINALS[n] ?? `${n}th`;
|
||||||
|
|
||||||
const compass = (p: string): string => ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
|
const compass = (p: string): string => ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+178
-6
@@ -33,6 +33,7 @@ import { cuesFor, narrate } from '../sim/narrate.ts';
|
|||||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||||
// which would pull node:fs into a browser bundle.
|
// which would pull node:fs into a browser bundle.
|
||||||
import {
|
import {
|
||||||
|
cardDescription,
|
||||||
cardName,
|
cardName,
|
||||||
describeIntent,
|
describeIntent,
|
||||||
geometryLabel,
|
geometryLabel,
|
||||||
@@ -40,10 +41,10 @@ import {
|
|||||||
trainName,
|
trainName,
|
||||||
variantLabel,
|
variantLabel,
|
||||||
} from '../sim/view.ts';
|
} from '../sim/view.ts';
|
||||||
import { HAND_LIMIT, trainProfile } from '../engine/content.ts';
|
import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts';
|
||||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
import { joins, neighbour, variantsFor } from '../engine/track.ts';
|
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
||||||
import { areaOf } from '../engine/apply.ts';
|
import { areaOf } from '../engine/apply.ts';
|
||||||
import type { Frame } from '../sim/view.ts';
|
import type { Frame } from '../sim/view.ts';
|
||||||
|
|
||||||
@@ -208,15 +209,78 @@ export type Placeable = {
|
|||||||
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
|
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
|
||||||
* piece has more than one legal rotation there, which is why the label carries the rotation too.
|
* piece has more than one legal rotation there, which is why the label carries the rotation too.
|
||||||
*/
|
*/
|
||||||
spots: { label: string; index: number; coord: { row: number; col: number } }[];
|
spots: {
|
||||||
|
label: string;
|
||||||
|
index: number;
|
||||||
|
coord: { row: number; col: number };
|
||||||
|
/**
|
||||||
|
* The rails this placement would put on the card, as `connectionsFor` codes.
|
||||||
|
*
|
||||||
|
* So the UI can DRAW the piece as it would land rather than only describing it. Words alone are
|
||||||
|
* not enough for a turnout: "allows traffic from the east to travel west or turn to the south"
|
||||||
|
* is exact and still leaves a player working out which way the leg points on the board. Taken
|
||||||
|
* from the engine's own connections, so a preview cannot promise a shape the placement will not
|
||||||
|
* produce.
|
||||||
|
*/
|
||||||
|
links: string[];
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CARD IN HAND, AND WHAT MAY BE DONE WITH IT.
|
||||||
|
*
|
||||||
|
* The hand is the action surface. A card used to appear in two panels under two different models:
|
||||||
|
* as a *subject* under "Play a card from my hand", which then highlighted squares on the board, and
|
||||||
|
* as one flat button per Department under "Discard a card from my hand". One card, two mental
|
||||||
|
* models, and the discard block was a cross-product — four cards x three Departments was twelve
|
||||||
|
* buttons and about 290px of the action list, repeating the same three choices four times.
|
||||||
|
*
|
||||||
|
* Both verbs now hang off the card itself, and both use the pattern the board placement already
|
||||||
|
* had: pick the thing, then pick where it goes. A discard's "where" is a Department pile, which is
|
||||||
|
* already on screen with its top card and depth — exactly what you need to choose between them.
|
||||||
|
*/
|
||||||
|
export type HandAction = {
|
||||||
|
cardId: string;
|
||||||
|
name: string;
|
||||||
|
what: string;
|
||||||
|
/** Playable with NO placement — an Office upgrade, a train card, a maneuver. One click does it. */
|
||||||
|
playNow: number | null;
|
||||||
|
/** Playable onto the board: the `subjectKey` of the matching `placeable` item. */
|
||||||
|
placeKey: string | null;
|
||||||
|
/** How many squares it may go on, for the count on the button. */
|
||||||
|
spots: number;
|
||||||
|
/** The option index for discarding onto each Department pile, or null where that is illegal. */
|
||||||
|
discard: (number | null)[];
|
||||||
|
/**
|
||||||
|
* Every distinct shape the card could be laid as, for the hover preview.
|
||||||
|
*
|
||||||
|
* Derived from the CARD, not from its legal placements: what a piece looks like does not depend
|
||||||
|
* on whether there is currently a square for it, and a player picking through a hand needs to see
|
||||||
|
* the shape most when there is nowhere obvious to put it. Reading it off the placements meant a
|
||||||
|
* curve with no legal square showed no preview at all.
|
||||||
|
*/
|
||||||
|
shapes: string[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A car that may be added to the train being made up, keyed to its chip in the Division Yard. */
|
||||||
|
export type MakeUpAction = { carType: string; loaded: boolean; index: number };
|
||||||
|
|
||||||
export type Menu = {
|
export type Menu = {
|
||||||
options: Intent[];
|
options: Intent[];
|
||||||
/** Actions with no further choice to make. */
|
/** Actions with no further choice to make. */
|
||||||
direct: ActionGroup[];
|
direct: ActionGroup[];
|
||||||
/** Actions needing a location, grouped under their card or track piece. */
|
/** Actions needing a location, grouped under their card or track piece. */
|
||||||
placeable: { title: string; items: Placeable[] }[];
|
placeable: { title: string; items: Placeable[] }[];
|
||||||
|
/** One entry per card in hand, with the verbs available to it. */
|
||||||
|
hand: HandAction[];
|
||||||
|
/**
|
||||||
|
* Making up a train: the cars that may be added, and the option to add none.
|
||||||
|
*
|
||||||
|
* Ten buttons reading "add loaded hopper", "add empty boxcar" and so on, when the Division Yard
|
||||||
|
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
|
||||||
|
* these key each chip to the option that adds it.
|
||||||
|
*/
|
||||||
|
makeUp: { title: string; cars: MakeUpAction[]; pass: number | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||||||
@@ -241,7 +305,7 @@ export function actionMenu(game: Game): Menu {
|
|||||||
spots: [],
|
spots: [],
|
||||||
};
|
};
|
||||||
if (!entry.spots.some((sp) => sp.label === key.spot)) {
|
if (!entry.spots.some((sp) => sp.label === key.spot)) {
|
||||||
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord });
|
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord, links: key.links });
|
||||||
}
|
}
|
||||||
bucket.set(key.subjectKey, entry);
|
bucket.set(key.subjectKey, entry);
|
||||||
placeableByTitle.set(g.title, bucket);
|
placeableByTitle.set(g.title, bucket);
|
||||||
@@ -253,16 +317,100 @@ export function actionMenu(game: Game): Menu {
|
|||||||
title,
|
title,
|
||||||
items: [...m.values()],
|
items: [...m.values()],
|
||||||
}));
|
}));
|
||||||
return { options, direct, placeable };
|
|
||||||
|
/**
|
||||||
|
* THE HAND, AND WHAT EACH CARD CAN DO.
|
||||||
|
*
|
||||||
|
* Built from the same `options` everything else reads, so a verb offered here is one `check` has
|
||||||
|
* already accepted. Cards keep their order in hand rather than being regrouped: the hand is a row
|
||||||
|
* of objects the player is looking at, not a list to sort.
|
||||||
|
*/
|
||||||
|
const handIds = game.state.decks.hands.get(0) ?? [];
|
||||||
|
const hand: HandAction[] = handIds.map((cardId) => {
|
||||||
|
const place = placeable.flatMap((g) => g.items).find((it) => it.subjectKey === `card:${cardId}`);
|
||||||
|
let playNow: number | null = null;
|
||||||
|
const discard: (number | null)[] = [null, null, null];
|
||||||
|
options.forEach((i, index) => {
|
||||||
|
if (i.type === 'card.play' && i.cardId === cardId && i.placement === undefined) playNow = index;
|
||||||
|
if (i.type === 'card.discard' && i.cardId === cardId) discard[i.toSlot] = index;
|
||||||
|
});
|
||||||
|
const kind = game.state.cards.get(cardId)?.kind;
|
||||||
|
const shapes =
|
||||||
|
kind?.kind === 'track'
|
||||||
|
? variantsFor(kind.geometry, kind.hand).map((v) =>
|
||||||
|
connectionsFor({
|
||||||
|
geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) },
|
||||||
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||||
|
} as never).map(([a, b]) => `${a}${b}`),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
cardId,
|
||||||
|
name: cardName(game.state, cardId),
|
||||||
|
what: cardDescription(game.state, cardId),
|
||||||
|
playNow,
|
||||||
|
placeKey: place ? place.subjectKey : null,
|
||||||
|
spots: place ? place.spots.length : 0,
|
||||||
|
discard,
|
||||||
|
shapes: [...new Map(shapes.map((l) => [l.join('|'), l])).values()],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Making up a train. `newTrain.placeCar` carries the car; the Division Yard chip that shows that
|
||||||
|
// car is where the click belongs.
|
||||||
|
const makeUpCars: MakeUpAction[] = [];
|
||||||
|
let pass: number | null = null;
|
||||||
|
options.forEach((i, index) => {
|
||||||
|
if (i.type === 'newTrain.placeCar') makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
|
||||||
|
if (i.type === 'newTrain.passCar') pass = index;
|
||||||
|
});
|
||||||
|
const makeUp =
|
||||||
|
makeUpCars.length > 0 || pass !== null
|
||||||
|
? {
|
||||||
|
title:
|
||||||
|
consistTitle(
|
||||||
|
game,
|
||||||
|
options,
|
||||||
|
options.map((_, index) => ({ index })).filter((a) => options[a.index]?.type.startsWith('newTrain.')),
|
||||||
|
) ?? 'Making up the train',
|
||||||
|
cars: makeUpCars,
|
||||||
|
pass,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { options, direct, placeable, hand, makeUp };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split an intent into "what" and "where", or null if it needs no location. */
|
/** Split an intent into "what" and "where", or null if it needs no location. */
|
||||||
function subjectOf(
|
function subjectOf(
|
||||||
game: Game,
|
game: Game,
|
||||||
i: Intent,
|
i: Intent,
|
||||||
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
|
): {
|
||||||
|
subjectKey: string;
|
||||||
|
subject: string;
|
||||||
|
spot: string;
|
||||||
|
coord: { row: number; col: number };
|
||||||
|
links: string[];
|
||||||
|
} | null {
|
||||||
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
||||||
if (i.type === 'card.play' && i.placement) {
|
if (i.type === 'card.play' && i.placement) {
|
||||||
|
/**
|
||||||
|
* ABS Signals is placed on a MAINLINE card, and the engine carries which one in `placement.col`
|
||||||
|
* as a Division node index. Showing that as a grid coordinate read as an Office Area square —
|
||||||
|
* "(0, 1)" — which is the wrong half of the board entirely.
|
||||||
|
*/
|
||||||
|
const k = game.state.cards.get(i.cardId)?.kind;
|
||||||
|
if (k?.kind === 'enhancement' && i.placement.row === -1) {
|
||||||
|
const node = game.state.division.nodes[i.placement.col];
|
||||||
|
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `node ${i.placement.col}`;
|
||||||
|
return {
|
||||||
|
subjectKey: `card:${i.cardId}`,
|
||||||
|
subject: cardName(game.state, i.cardId),
|
||||||
|
spot: `on the ${where}, out on the Mainline`,
|
||||||
|
coord: i.placement,
|
||||||
|
links: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
|
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
|
||||||
// the decision, so they ride on the spot rather than being left for the player to work out.
|
// the decision, so they ride on the spot rather than being left for the player to work out.
|
||||||
const kind = game.state.cards.get(i.cardId)?.kind;
|
const kind = game.state.cards.get(i.cardId)?.kind;
|
||||||
@@ -276,11 +424,35 @@ function subjectOf(
|
|||||||
subject: cardName(game.state, i.cardId),
|
subject: cardName(game.state, i.cardId),
|
||||||
spot: `${at(i.placement)}${track}`,
|
spot: `${at(i.placement)}${track}`,
|
||||||
coord: i.placement,
|
coord: i.placement,
|
||||||
|
links: placementLinks(game, i.cardId, i.variant),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rails a card would lay, in `connectionsFor` codes — the same source the board draws from.
|
||||||
|
*
|
||||||
|
* Built by asking the engine what the card becomes, not by re-deriving it: a preview that worked out
|
||||||
|
* the shape for itself would eventually show a different piece from the one the placement lays, which
|
||||||
|
* is precisely the confusion it exists to remove.
|
||||||
|
*/
|
||||||
|
function placementLinks(game: Game, cardId: string, variant: number | undefined): string[] {
|
||||||
|
const kind = game.state.cards.get(cardId)?.kind;
|
||||||
|
if (!kind) return [];
|
||||||
|
if (kind.kind === 'track') {
|
||||||
|
const v = variantsFor(kind.geometry, kind.hand)[variant ?? 0];
|
||||||
|
if (!v) return [];
|
||||||
|
return connectionsFor({
|
||||||
|
geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) },
|
||||||
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||||
|
} as never).map(([a, b]) => `${a}${b}`);
|
||||||
|
}
|
||||||
|
// Everything else that goes on the grid is a plain through track, or is not track at all.
|
||||||
|
if (kind.kind === 'freightFacility' || kind.kind === 'office') return ['ew'];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
|
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
|
||||||
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
|
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
|
||||||
|
|||||||
+275
-189
@@ -6,12 +6,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
||||||
|
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
||||||
|
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, yardHtml } from './panels.ts';
|
||||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||||
import { playCue } from './sound.ts';
|
import { playCue } from './sound.ts';
|
||||||
import type { Game } from './game.ts';
|
import type { Game } from './game.ts';
|
||||||
|
import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts';
|
||||||
|
import { cardDescription } from '../sim/view.ts';
|
||||||
import {
|
import {
|
||||||
actionMenu,
|
actionMenu,
|
||||||
handPlayable,
|
|
||||||
currentActor,
|
currentActor,
|
||||||
fromSave,
|
fromSave,
|
||||||
newGame,
|
newGame,
|
||||||
@@ -25,6 +28,15 @@ const SAVE_KEY = 'station-master.save.v1';
|
|||||||
let game: Game;
|
let game: Game;
|
||||||
/** Which card or track piece is picked, waiting for a location. */
|
/** Which card or track piece is picked, waiting for a location. */
|
||||||
let selected: string | null = null;
|
let selected: string | null = null;
|
||||||
|
/**
|
||||||
|
* WHAT the picked card is being used for.
|
||||||
|
*
|
||||||
|
* The hand is the action surface, and a card has two verbs: play it onto the board, or discard it
|
||||||
|
* onto a Department. Both then ask "where?", and the answer is highlighted in place — squares on the
|
||||||
|
* board for a play, the three Department piles for a discard. Without this the two flows would need
|
||||||
|
* two selections, which is the thing being removed.
|
||||||
|
*/
|
||||||
|
let mode: 'play' | 'discard' | 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;
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +60,14 @@ let districtMode: 'auto' | 'open' | 'closed' = 'auto';
|
|||||||
* before any audio may start.
|
* before any audio may start.
|
||||||
*/
|
*/
|
||||||
let soundOn = false;
|
let soundOn = false;
|
||||||
|
/**
|
||||||
|
* The phase the page last drew, so a change of phase can be announced.
|
||||||
|
*
|
||||||
|
* Local Operations ends the moment the last Move is spent, and New Train and Mainline then run
|
||||||
|
* themselves — so a player looking at the board finds themselves in Cargo with no idea their turn
|
||||||
|
* ended or what happened in between. Reported exactly that way.
|
||||||
|
*/
|
||||||
|
let lastPhase: string | null = null;
|
||||||
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
|
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
|
||||||
|
|
||||||
const $ = (id: string): HTMLElement => {
|
const $ = (id: string): HTMLElement => {
|
||||||
@@ -59,76 +79,37 @@ const $ = (id: string): HTMLElement => {
|
|||||||
const esc = (s: string): string =>
|
const esc = (s: string): string =>
|
||||||
s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DRAWING OF THE PIECE A PLACEMENT WOULD LAY.
|
||||||
|
*
|
||||||
|
* "Allows traffic from the east to travel west or turn to the south" is exact and still leaves a
|
||||||
|
* player working out which way the leg will point once the card is down. Rendered by `officeSvg` —
|
||||||
|
* the board's own renderer, on a one-card board — so the preview and the board cannot disagree about
|
||||||
|
* what the piece looks like. The coordinate stamp and the RUNNING TRACK caption are stripped: they
|
||||||
|
* belong to a position, and this is a card on its own.
|
||||||
|
*/
|
||||||
|
function piecePreview(links: string[], label: string): string {
|
||||||
|
const cell = {
|
||||||
|
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [],
|
||||||
|
tray: null, cars: [], facility: null, links, what: '',
|
||||||
|
};
|
||||||
|
return officeSvg([cell] as never, 99).replace(
|
||||||
|
/<text class="bs-(coord|rowlab)"[^>]*>[^<]*<\/text>/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE TURN CHART — the five phases of a Stage, in order, with the current one lit.
|
* THE TURN CHART — the five phases of a Stage, in order, with the current one lit.
|
||||||
*
|
*
|
||||||
* The title bar used to say `phase: Mainline`, which names the moment and nothing around it: a
|
* Built by `turnChartHtml`, shared with both replay viewers, so all three screens report where you
|
||||||
* player could not see what had already happened this Stage or what was coming. The printed game
|
* are in the Day the same way and with the same violet highlight. It used to live here alone.
|
||||||
* has a turn chart for exactly this reason.
|
|
||||||
*
|
|
||||||
* Each phase carries a tooltip describing what actually happens in it — the first three are the
|
|
||||||
* rules text; Cargo and Supervisor Shift are written from what the engine does, since the recovered
|
|
||||||
* sheet does not spell them out.
|
|
||||||
*/
|
*/
|
||||||
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
|
|
||||||
{
|
|
||||||
key: 'localOps',
|
|
||||||
label: 'Local Operations',
|
|
||||||
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
|
|
||||||
// a turnout: the choice of three roads
|
|
||||||
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'newTrain',
|
|
||||||
label: 'New Train',
|
|
||||||
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
|
|
||||||
// a locomotive being made up
|
|
||||||
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'mainline',
|
|
||||||
label: 'Mainline',
|
|
||||||
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
|
|
||||||
// open road
|
|
||||||
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'loadUnload',
|
|
||||||
label: 'Cargo',
|
|
||||||
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
|
|
||||||
// a loaded box on a flat
|
|
||||||
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'shiftChange',
|
|
||||||
label: 'Supervisor Shift',
|
|
||||||
tip: 'End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent\'s hat passes to the next player.',
|
|
||||||
// a pocket watch
|
|
||||||
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function renderTurnChart(f: ReturnType<typeof view>): void {
|
function renderTurnChart(f: ReturnType<typeof view>): void {
|
||||||
$('tc-day').textContent = `Day ${f.day}`;
|
|
||||||
$('tc-stage').textContent = `Stage ${f.stage} of 12`;
|
|
||||||
$('tc-clock').textContent = f.clock;
|
|
||||||
$('tc-phase').textContent = f.phase;
|
|
||||||
// WHO THE TABLE IS WAITING ON. Solitaire has one seat, but the question is the same one a
|
|
||||||
// multiplayer table asks constantly, and an automatic phase is waiting on nobody.
|
|
||||||
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
|
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
|
||||||
$('tc-who').textContent = actorName ?? 'nobody — the Division is running itself';
|
$('turnchart').innerHTML = turnChartHtml(f, actorName);
|
||||||
|
|
||||||
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
|
|
||||||
$('tc-phases').innerHTML = PHASES.map((p, i) => {
|
|
||||||
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
|
|
||||||
return (
|
|
||||||
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
|
|
||||||
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
|
|
||||||
`<span>${esc(p.label)}</span></li>`
|
|
||||||
);
|
|
||||||
}).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function start(): void {
|
function start(): void {
|
||||||
@@ -152,7 +133,13 @@ function render(): void {
|
|||||||
|
|
||||||
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
|
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
|
||||||
// coordinate list into a board: you pick the thing, then click where it goes.
|
// coordinate list into a board: you pick the thing, then click where it goes.
|
||||||
const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected);
|
// Squares light up only while a card is picked FOR PLAY. `selected` names the card; the placeable
|
||||||
|
// entry that carries its squares is keyed the same way the menu keys it.
|
||||||
|
const forPlay =
|
||||||
|
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
|
||||||
|
const chosen = forPlay
|
||||||
|
? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey)
|
||||||
|
: undefined;
|
||||||
const spotsAt = new Map<string, { label: string; index: number }[]>();
|
const spotsAt = new Map<string, { label: string; index: number }[]>();
|
||||||
for (const sp of chosen?.spots ?? []) {
|
for (const sp of chosen?.spots ?? []) {
|
||||||
const key = `${sp.coord.row},${sp.coord.col}`;
|
const key = `${sp.coord.row},${sp.coord.col}`;
|
||||||
@@ -212,47 +199,52 @@ function render(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -- facilities
|
// -- facilities
|
||||||
$('facs').innerHTML =
|
$('facs').innerHTML = facilitiesHtml(f);
|
||||||
f.facilities.length === 0
|
|
||||||
? '<div class="dim">no facilities yet</div>'
|
|
||||||
: f.facilities
|
|
||||||
.map(
|
|
||||||
(x) =>
|
|
||||||
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)} · laborers ${esc(x.laborers)} · porters ${esc(x.porters)}" tabindex="0">` +
|
|
||||||
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
|
|
||||||
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
|
|
||||||
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
|
|
||||||
x.maw
|
|
||||||
.map(
|
|
||||||
(m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`,
|
|
||||||
)
|
|
||||||
.join('') +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
|
|
||||||
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
|
|
||||||
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
|
|
||||||
x.jammed
|
|
||||||
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
|
|
||||||
: x.canFinish
|
|
||||||
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
|
|
||||||
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
|
|
||||||
}">` +
|
|
||||||
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
|
|
||||||
`</div></div>`,
|
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
|
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
|
||||||
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
|
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
|
||||||
// The crosshatch says "not yet playable"; it used to be a `not yet` caption, which had to be read
|
/**
|
||||||
// rather than seen. The tooltip still explains WHY, which a hatch cannot.
|
* THE HAND, AS THE ACTION SURFACE.
|
||||||
const cardRow = (name: string, why: string, playable: boolean | null): string =>
|
*
|
||||||
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
|
* Each card carries its own verbs. This used to be a read-only row, with the same cards appearing
|
||||||
`<b>${esc(name)}</b></div>`;
|
* again in the action list — once as subjects to play and once as one button per Department to
|
||||||
|
* discard, which for a four-card hand was twelve buttons repeating three choices four times.
|
||||||
const canPlay = handPlayable(game);
|
*/
|
||||||
$('hand').innerHTML = f.hand.length
|
$('hand').innerHTML = menu.hand.length
|
||||||
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
|
? menu.hand
|
||||||
|
.map((h) => {
|
||||||
|
const canPlace = h.placeKey !== null && h.spots > 0;
|
||||||
|
const canPlay = canPlace || h.playNow !== null;
|
||||||
|
const canDiscard = h.discard.some((d) => d !== null);
|
||||||
|
const picked = selected === `hand:${h.cardId}`;
|
||||||
|
const verb = (kind: string, on: boolean, text: string, tip: string): string =>
|
||||||
|
on
|
||||||
|
? `<button class="cardact ${kind}${picked && mode === kind ? ' on' : ''}" ` +
|
||||||
|
`data-card="${esc(h.cardId)}" data-verb="${kind}" data-tip="${esc(tip)}">${text}</button>`
|
||||||
|
: '';
|
||||||
|
// A track card shows what it would look like on the board, in every orientation it has —
|
||||||
|
// which is how a player sees a turnout has two at all, and what a curve actually is.
|
||||||
|
const fig = h.shapes.length
|
||||||
|
? ` data-tip-html="${esc(h.shapes.map((l) => piecePreview(l, h.name)).join(''))}"`
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}"${fig}` +
|
||||||
|
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
|
||||||
|
`<b>${esc(h.name)}</b>` +
|
||||||
|
`<div class="cardacts">` +
|
||||||
|
verb(
|
||||||
|
'play',
|
||||||
|
canPlay,
|
||||||
|
canPlace ? `play <span class="dim">${h.spots}</span>` : 'play',
|
||||||
|
canPlace
|
||||||
|
? `Play it onto the board — ${h.spots} square${h.spots === 1 ? '' : 's'} will light up.`
|
||||||
|
: 'Play it. This card needs no square on the board.',
|
||||||
|
) +
|
||||||
|
verb('discard', canDiscard, 'discard', 'Discard it face up on top of a Department — pick which one.') +
|
||||||
|
`</div></div>`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
: '<span class="dim">empty</span>';
|
: '<span class="dim">empty</span>';
|
||||||
/**
|
/**
|
||||||
* THE THREE DEPARTMENT PILES, and how deep each one is.
|
* THE THREE DEPARTMENT PILES, and how deep each one is.
|
||||||
@@ -263,47 +255,78 @@ function render(): void {
|
|||||||
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
|
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
|
||||||
* action rather than a click on the card itself.
|
* action rather than a click on the card itself.
|
||||||
*/
|
*/
|
||||||
const pileCard = (label: string, top: string, depth: number, why: string, tipExtra = ''): string => {
|
$('depts').innerHTML = pilesHtml(f);
|
||||||
const tip = [why, tipExtra].filter(Boolean).join(' · ');
|
|
||||||
return (
|
|
||||||
`<div class="handcard"${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
|
||||||
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
|
||||||
`<b>${esc(top)}</b></div>`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
$('depts').innerHTML =
|
|
||||||
f.departments
|
|
||||||
.map((d, i) => {
|
|
||||||
const depth = f.departmentDepth[i] ?? 0;
|
|
||||||
const under = depth - 1;
|
|
||||||
return pileCard(
|
|
||||||
`Dept ${i + 1}`,
|
|
||||||
d,
|
|
||||||
depth,
|
|
||||||
f.departmentsWhat[i] ?? '',
|
|
||||||
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join('') +
|
|
||||||
// The Salvage Yard beside them, because §6.2 sweeps it back into the deck and it is the only
|
|
||||||
// visible sign that the reshuffle is coming.
|
|
||||||
pileCard(
|
|
||||||
'Salvage',
|
|
||||||
f.salvage.top,
|
|
||||||
f.salvage.depth,
|
|
||||||
'',
|
|
||||||
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
|
|
||||||
);
|
|
||||||
|
|
||||||
renderYards(f);
|
renderYards(f);
|
||||||
|
|
||||||
$('blocked').innerHTML =
|
// -- the hand's verbs. Picking one selects the card and says what it is for; the "where" is then
|
||||||
f.blocked.length === 0
|
// highlighted in place, on the board or on the Department piles.
|
||||||
? '<li class="dim">nothing blocked</li>'
|
for (const b of Array.from($('hand').querySelectorAll('button.cardact'))) {
|
||||||
: f.blocked
|
const node = b as HTMLElement;
|
||||||
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
|
node.onclick = () => {
|
||||||
.join('');
|
const cardId = node.dataset['card']!;
|
||||||
|
const verb = node.dataset['verb'] as 'play' | 'discard';
|
||||||
|
const entry = menu.hand.find((h) => h.cardId === cardId);
|
||||||
|
if (!entry) return;
|
||||||
|
// A card that needs no square goes straight down; there is nothing to ask.
|
||||||
|
if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) {
|
||||||
|
const intent = menu.options[entry.playNow];
|
||||||
|
if (intent) submit(game, intent);
|
||||||
|
selected = null;
|
||||||
|
mode = null;
|
||||||
|
pendingAt = null;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `hand:${cardId}`;
|
||||||
|
const same = selected === key && mode === verb;
|
||||||
|
selected = same ? null : key;
|
||||||
|
mode = same ? null : verb;
|
||||||
|
pendingAt = null;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- discarding: the three Department piles become the targets. They already show their top card
|
||||||
|
// and their depth, which is exactly what you choose between.
|
||||||
|
const discarding = mode === 'discard' && selected !== null
|
||||||
|
? menu.hand.find((h) => `hand:${h.cardId}` === selected)
|
||||||
|
: undefined;
|
||||||
|
if (discarding) {
|
||||||
|
for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) {
|
||||||
|
const node = el as HTMLElement;
|
||||||
|
const slot = Number(node.dataset['dept']);
|
||||||
|
const index = discarding.discard[slot];
|
||||||
|
if (index === null || index === undefined) continue;
|
||||||
|
node.classList.add('target');
|
||||||
|
node.onclick = () => {
|
||||||
|
const intent = menu.options[index];
|
||||||
|
if (intent) submit(game, intent);
|
||||||
|
selected = null;
|
||||||
|
mode = null;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- making up a train: the Division Yard chip that shows the car IS the button.
|
||||||
|
if (menu.makeUp) {
|
||||||
|
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
|
||||||
|
const node = el as HTMLElement;
|
||||||
|
const car = menu.makeUp!.cars.find(
|
||||||
|
(c) => c.carType === node.dataset['car'] && c.loaded === (node.dataset['loaded'] === '1'),
|
||||||
|
);
|
||||||
|
if (!car) continue;
|
||||||
|
node.classList.add('addable');
|
||||||
|
node.onclick = () => {
|
||||||
|
const intent = menu.options[car.index];
|
||||||
|
if (intent) submit(game, intent);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('blocked').innerHTML = blockedHtml(f);
|
||||||
|
|
||||||
// -- log
|
// -- log
|
||||||
const log = $('log');
|
const log = $('log');
|
||||||
@@ -313,8 +336,25 @@ function render(): void {
|
|||||||
.join('');
|
.join('');
|
||||||
log.scrollTop = log.scrollHeight;
|
log.scrollTop = log.scrollHeight;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAY WHEN THE PHASE TURNS OVER.
|
||||||
|
*
|
||||||
|
* The five phases run themselves once Local Operations ends, so the page can change out from under
|
||||||
|
* a player between one click and the next. The turn chart already shows WHERE you are; this says
|
||||||
|
* that it moved, which is the part you miss when you were looking at the board.
|
||||||
|
*/
|
||||||
|
if (lastPhase !== null && lastPhase !== f.phaseKey) {
|
||||||
|
const el = $('phasenote');
|
||||||
|
el.textContent = `${f.phase} — Day ${f.day}, Stage ${f.stage}`;
|
||||||
|
el.className = 'shown';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (el.textContent?.startsWith(f.phase)) el.className = '';
|
||||||
|
}, 2600);
|
||||||
|
}
|
||||||
|
lastPhase = f.phaseKey;
|
||||||
|
|
||||||
renderDistrict(f);
|
renderDistrict(f);
|
||||||
renderActions(menu);
|
renderActions(menu, f);
|
||||||
|
|
||||||
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
|
// 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.
|
// not then play a backlog of everything that happened while it was off.
|
||||||
@@ -332,19 +372,8 @@ function render(): void {
|
|||||||
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
||||||
*/
|
*/
|
||||||
function renderYards(f: ReturnType<typeof view>): void {
|
function renderYards(f: ReturnType<typeof view>): void {
|
||||||
const row = (rows: { type: string; loaded: number; empty: number }[]): string =>
|
$('divyard').innerHTML = yardHtml(f.yards.division);
|
||||||
rows.length === 0
|
$('clsyard').innerHTML = yardHtml(f.yards.classification);
|
||||||
? '<span class="dim">empty</span>'
|
|
||||||
: rows
|
|
||||||
.map(
|
|
||||||
(r) =>
|
|
||||||
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
|
|
||||||
`<b>${esc(r.type)}</b> <span class="ld">${r.loaded}</span>/<span class="mt">${r.empty}</span></span>`,
|
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
$('divyard').innerHTML = row(f.yards.division);
|
|
||||||
$('clsyard').innerHTML = row(f.yards.classification);
|
|
||||||
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
|
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
|
||||||
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
|
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
|
||||||
|
|
||||||
@@ -384,16 +413,7 @@ function renderDistrict(f: ReturnType<typeof view>): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function boxes(items: string[], cap: number): string {
|
function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof view>): void {
|
||||||
let out = '';
|
|
||||||
for (let i = 0; i < Math.max(cap, items.length); i++) {
|
|
||||||
const v = items[i];
|
|
||||||
out += `<span class="box ${v ? 'f' : 'empty'}">${v ? esc(v) : '·'}</span>`;
|
|
||||||
}
|
|
||||||
return out || '<span class="dim">—</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
|
||||||
const el = $('actions');
|
const el = $('actions');
|
||||||
|
|
||||||
if (game.state.status !== 'active') {
|
if (game.state.status !== 'active') {
|
||||||
@@ -419,27 +439,61 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
|||||||
const intent = menu.options[index];
|
const intent = menu.options[index];
|
||||||
if (intent) submit(game, intent);
|
if (intent) submit(game, intent);
|
||||||
selected = null;
|
selected = null;
|
||||||
|
mode = null;
|
||||||
pendingAt = null;
|
pendingAt = null;
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
|
|
||||||
// A long label is two things: the action, and why it is offered. Put the first on the button and
|
/**
|
||||||
// the second on the tooltip, or the list crowds out the board.
|
* A long label is two things: the action, and why it is offered. Put the first on the button and
|
||||||
|
* the second on the tooltip, or the list crowds out the board.
|
||||||
|
*
|
||||||
|
* AND IF THE LABEL CARRIES NO EXPLANATION, ask the card. Tooltips used to depend entirely on a
|
||||||
|
* label happening to contain an em-dash, so an action naming a card could have none at all while
|
||||||
|
* the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3",
|
||||||
|
* which had neither a name for the card it meant nor a word about what it would do.
|
||||||
|
*/
|
||||||
const actionButton = (label: string, index: number): string => {
|
const actionButton = (label: string, index: number): string => {
|
||||||
const cut = label.indexOf(' — ');
|
const cut = label.indexOf(' — ');
|
||||||
const head = cut > 0 ? label.slice(0, cut) : label;
|
const head = cut > 0 ? label.slice(0, cut) : label;
|
||||||
const rest = cut > 0 ? label.slice(cut + 3) : '';
|
let rest = cut > 0 ? label.slice(cut + 3) : '';
|
||||||
|
if (!rest) {
|
||||||
|
const intent = menu.options[index];
|
||||||
|
const cardId = intent && 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
|
||||||
|
if (cardId) rest = cardDescription(game.state, cardId);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
|
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MOVES LEFT, WHERE THE MOVES ARE.
|
||||||
|
*
|
||||||
|
* §6.1 gives six Moves a turn and every switching decision is really "can I still get back?" — so
|
||||||
|
* the count belongs beside the buttons. It was reported only in the history panel, which is the
|
||||||
|
* one place a player is not looking while switching.
|
||||||
|
*/
|
||||||
|
const movesNote =
|
||||||
|
f.movesLeft !== null
|
||||||
|
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
|
||||||
|
`${f.movesLeft} of ${MOVES_PER_LOCAL_OPS} Moves left</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
// A heading over the buttons, so the panel says what it is before it says what is in it. The
|
// A heading over the buttons, so the panel says what it is before it says what is in it. The
|
||||||
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
|
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
|
||||||
// no worker able to act, the only thing offered is "End my Cargo phase".
|
// no worker able to act, the only thing offered is "End my Cargo phase".
|
||||||
let html = '<h3 class="actions-hd">Actions</h3>';
|
let html = `<h3 class="actions-hd">Actions</h3>${movesNote}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHAT IS LEFT IN THE ACTION LIST.
|
||||||
|
*
|
||||||
|
* Everything about a card in hand now lives on the card, and everything about making up a train
|
||||||
|
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
|
||||||
|
* drawing, switching moves, the Freight Agent, and finishing.
|
||||||
|
*/
|
||||||
html += menu.direct
|
html += menu.direct
|
||||||
|
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
|
||||||
.map(
|
.map(
|
||||||
(g) =>
|
(g) =>
|
||||||
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
||||||
@@ -455,17 +509,46 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
|||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
// Subject first, location second. Picking a card then a square is how the choice is actually made;
|
/**
|
||||||
// one flat list of every card-square-rotation combination was unreadable.
|
* MAKING UP A TRAIN.
|
||||||
for (const group of menu.placeable) {
|
*
|
||||||
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
|
* The cars are added from the Division Yard chips, but the panel still has to exist: it names the
|
||||||
for (const item of group.items) {
|
* train and what its card calls for, and it carries the "no more cars" button, which is the ONLY
|
||||||
const open = selected === item.subjectKey;
|
* way to finish when the yard holds nothing the train may take.
|
||||||
|
*
|
||||||
|
* Moving the cars onto the yard chips without this left a train being made up with no control on
|
||||||
|
* screen at all whenever no chip was addable — a hard softlock, reported at Stage 10 of seed
|
||||||
|
* 775569289 with Train 10 waiting at the West Division Point.
|
||||||
|
*/
|
||||||
|
if (menu.makeUp) {
|
||||||
|
const addable = menu.makeUp.cars.length;
|
||||||
html +=
|
html +=
|
||||||
`<button class="subj ${open ? 'open' : ''}" data-subj="${esc(item.subjectKey)}">` +
|
`<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` +
|
||||||
`${esc(item.subject)} <span class="dim">${item.spots.length} spot${item.spots.length === 1 ? '' : 's'}</span>` +
|
`<div class="dim makeup-note">` +
|
||||||
`</button>`;
|
(addable > 0
|
||||||
if (open) {
|
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.`
|
||||||
|
: menu.makeUp.pass !== null
|
||||||
|
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
||||||
|
: 'Nothing in the Division Yard may join this train, and §7 does not allow passing while the yard holds cars.') +
|
||||||
|
`</div>` +
|
||||||
|
(menu.makeUp.pass !== null
|
||||||
|
? `<button class="act" data-i="${menu.makeUp.pass}" data-tip="§8.2 — a train may depart with FEWER cars than its card lists, but never with the wrong ones. This sends it out as it stands.">no more cars — send it out</button>`
|
||||||
|
: '') +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card is picked in the hand and the square on the board, so all that is left here is the
|
||||||
|
* ROTATION — the one question neither of those can ask, and only for the card actually picked.
|
||||||
|
*/
|
||||||
|
const picked =
|
||||||
|
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
|
||||||
|
for (const group of menu.placeable) {
|
||||||
|
const relevant = group.items.filter((it) => picked && it.subjectKey === picked.placeKey);
|
||||||
|
if (relevant.length === 0) continue;
|
||||||
|
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
|
||||||
|
for (const item of relevant) {
|
||||||
|
{
|
||||||
// Once a square is picked, show only that square's rotations — the rest is noise.
|
// Once a square is picked, show only that square's rotations — the rest is noise.
|
||||||
const shown = pendingAt
|
const shown = pendingAt
|
||||||
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
||||||
@@ -474,7 +557,19 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
|||||||
`<div class="spots"><div class="dim">` +
|
`<div class="spots"><div class="dim">` +
|
||||||
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
|
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
shown.map((sp) => `<button class="act" data-i="${sp.index}">${esc(sp.label)}</button>`).join('') +
|
shown
|
||||||
|
.map((sp) => {
|
||||||
|
// The picture rides on the button that would lay it, so hovering a rotation shows
|
||||||
|
// that rotation — which is the question "which of these two do I want?" answered.
|
||||||
|
const fig = sp.links.length
|
||||||
|
? ` data-tip-html="${esc(piecePreview(sp.links, item.subject))}"`
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
`<button class="act" data-i="${sp.index}"${fig}` +
|
||||||
|
` data-tip="${esc(`${item.subject} — ${sp.label}`)}">${esc(sp.label)}</button>`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join('') +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,15 +592,6 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
|||||||
const node = b as HTMLElement;
|
const node = b as HTMLElement;
|
||||||
node.onclick = () => apply(Number(node.dataset['i']));
|
node.onclick = () => apply(Number(node.dataset['i']));
|
||||||
}
|
}
|
||||||
for (const b of Array.from(el.querySelectorAll('button.subj'))) {
|
|
||||||
const node = b as HTMLElement;
|
|
||||||
node.onclick = () => {
|
|
||||||
const key = node.dataset['subj'] ?? null;
|
|
||||||
selected = selected === key ? null : key;
|
|
||||||
pendingAt = null;
|
|
||||||
render();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -559,7 +645,7 @@ function clearSave(): void {
|
|||||||
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
|
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
|
||||||
// background, so the cards are drawn correctly and are simply invisible.
|
// background, so the cards are drawn correctly and are simply invisible.
|
||||||
const pageStyle = document.createElement('style');
|
const pageStyle = document.createElement('style');
|
||||||
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS;
|
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
|
||||||
document.head.appendChild(pageStyle);
|
document.head.appendChild(pageStyle);
|
||||||
installTooltips();
|
installTooltips();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* The side panels a game is read from — cards, yards, blockers, facilities.
|
||||||
|
*
|
||||||
|
* SHARED between the playable page and the site's replay viewer, for the same reason the board
|
||||||
|
* renderers are: a replay is the game being WATCHED rather than played, so it should look like the
|
||||||
|
* game. The viewer had three panels against the play page's eight — no facilities, no blockers, no
|
||||||
|
* cards, no yards — which meant a replay could not answer "why is nothing moving?", the question a
|
||||||
|
* replay mostly exists to answer.
|
||||||
|
*
|
||||||
|
* Each function returns HTML rather than writing to the DOM, so the two pages can keep their own
|
||||||
|
* element ids and their own layout while drawing the same contents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Frame } from '../sim/view.ts';
|
||||||
|
|
||||||
|
const esc = (s: string): string =>
|
||||||
|
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card, drawn as a card.
|
||||||
|
*
|
||||||
|
* `playable === false` crosshatches it: "not yet playable" had to be read, where a hatch is seen.
|
||||||
|
* The tooltip still explains WHY, which a hatch cannot. A replay passes null throughout — nothing
|
||||||
|
* there is playable, because nothing there is being played.
|
||||||
|
*/
|
||||||
|
export function cardRow(name: string, why: string, playable: boolean | null): string {
|
||||||
|
return (
|
||||||
|
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
|
||||||
|
`<b>${esc(name)}</b></div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||||
|
return f.hand.length
|
||||||
|
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
|
||||||
|
: '<span class="dim">empty</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three Department piles and the Salvage Yard, each with its top card and its depth.
|
||||||
|
*
|
||||||
|
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
|
||||||
|
* is out of reach, and choosing where to discard is choosing what to put there.
|
||||||
|
*/
|
||||||
|
export function pilesHtml(f: Frame): string {
|
||||||
|
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
|
||||||
|
const tip = [why, extra].filter(Boolean).join(' · ');
|
||||||
|
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
|
||||||
|
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
|
||||||
|
// viewer draws exactly the same markup and nothing there is clickable.
|
||||||
|
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
|
||||||
|
return (
|
||||||
|
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||||
|
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
||||||
|
`<b>${esc(top)}</b></div>`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
f.departments
|
||||||
|
.map((d, i) => {
|
||||||
|
const depth = f.departmentDepth[i] ?? 0;
|
||||||
|
const under = depth - 1;
|
||||||
|
return pile(
|
||||||
|
`Dept ${i + 1}`,
|
||||||
|
d,
|
||||||
|
depth,
|
||||||
|
f.departmentsWhat[i] ?? '',
|
||||||
|
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
|
||||||
|
i,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join('') +
|
||||||
|
pile(
|
||||||
|
'Salvage',
|
||||||
|
f.salvage.top,
|
||||||
|
f.salvage.depth,
|
||||||
|
'',
|
||||||
|
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One yard, by car type. Loaded and empty are separate numbers because they do different jobs. */
|
||||||
|
export function yardHtml(rows: { type: string; loaded: number; empty: number }[]): string {
|
||||||
|
return rows.length === 0
|
||||||
|
? '<span class="dim">empty</span>'
|
||||||
|
: rows
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
|
||||||
|
`<b>${esc(r.type)}</b> ` +
|
||||||
|
// Each count is its own target: making up a train is choosing a car OF A TYPE AND A LOAD
|
||||||
|
// STATE, which is exactly what these two numbers already distinguish.
|
||||||
|
`<span class="ld" data-car="${esc(r.type)}" data-loaded="1">${r.loaded}</span>/` +
|
||||||
|
`<span class="mt" data-car="${esc(r.type)}" data-loaded="0">${r.empty}</span></span>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockedHtml(f: Frame): string {
|
||||||
|
return f.blocked.length === 0
|
||||||
|
? '<li class="dim">nothing blocked</li>'
|
||||||
|
: f.blocked
|
||||||
|
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The load pipeline at each industry: green → MEN | AT | WORK → red, and the siding. */
|
||||||
|
export function facilitiesHtml(f: Frame): string {
|
||||||
|
/**
|
||||||
|
* One number, with what the card printed and what a Modifier added.
|
||||||
|
*
|
||||||
|
* `laborers` arrives as "left/total" and the rest as plain totals, so the total is taken from the
|
||||||
|
* end either way and compared with the base.
|
||||||
|
*/
|
||||||
|
const stat = (label: string, shown: string, base: number, why: string): string => {
|
||||||
|
const total = Number(shown.split('/').pop() ?? shown);
|
||||||
|
const added = Number.isFinite(total) ? total - base : 0;
|
||||||
|
const delta = added > 0 ? `<span class="added">+${added}</span>` : '';
|
||||||
|
return (
|
||||||
|
`<span class="cap" data-tip="${esc(why)}${added > 0 ? ` The card prints ${base}; Modifiers beside it add ${added}.` : ''}">` +
|
||||||
|
`<span class="dim">${esc(label)}</span> <b>${esc(shown)}</b>${delta}</span>`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const boxes = (filled: string[], cap: number): string => {
|
||||||
|
let h = '';
|
||||||
|
for (let i = 0; i < Math.max(cap, filled.length); i++) {
|
||||||
|
h += `<span class="box ${i < filled.length ? 'f' : 'empty'}">${i < filled.length ? esc(filled[i] ?? '') : '·'}</span>`;
|
||||||
|
}
|
||||||
|
return h || '<span class="dim">—</span>';
|
||||||
|
};
|
||||||
|
return f.facilities.length === 0
|
||||||
|
? '<div class="dim">no facilities yet</div>'
|
||||||
|
: f.facilities
|
||||||
|
.map(
|
||||||
|
(x) =>
|
||||||
|
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)}" tabindex="0">` +
|
||||||
|
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
|
||||||
|
/**
|
||||||
|
* THE WORKERS AND THE CAPACITIES, ON THE PANEL RATHER THAN IN A TOOLTIP.
|
||||||
|
*
|
||||||
|
* Laborers were only ever in the hover text, so "how many actions can I take here this
|
||||||
|
* Stage?" — the question that decides every Cargo phase — had to be hunted for. And a
|
||||||
|
* Modifier's whole effect is one of these numbers going up, so showing the number alone
|
||||||
|
* made an Ice House look like it had done nothing. Each now says what the card prints
|
||||||
|
* and what the Modifiers beside it added.
|
||||||
|
*/
|
||||||
|
`<div class="caps">` +
|
||||||
|
stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') +
|
||||||
|
stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') +
|
||||||
|
stat('in', String(x.redCap), x.base.in, 'Red boxes: loads that have come off an arriving car and are waiting to be cleared.') +
|
||||||
|
(x.modifiers.length
|
||||||
|
? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</span>`
|
||||||
|
: '') +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
|
||||||
|
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
|
||||||
|
x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
|
||||||
|
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
|
||||||
|
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
|
||||||
|
x.jammed
|
||||||
|
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
|
||||||
|
: x.canFinish
|
||||||
|
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
|
||||||
|
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
|
||||||
|
}">` +
|
||||||
|
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
|
||||||
|
`</div></div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Styling for all of the above, so the two pages cannot drift apart visually. */
|
||||||
|
export const PANEL_CSS = `
|
||||||
|
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;margin:9px 0 4px}
|
||||||
|
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
|
||||||
|
.handcard{background:#242c36;border:1px solid #2c333d;border-radius:5px;
|
||||||
|
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
|
||||||
|
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||||
|
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
||||||
|
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
||||||
|
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
||||||
|
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
||||||
|
/* A pile shows two things: which card is face up on top, and how many are under it. */
|
||||||
|
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
|
||||||
|
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3}
|
||||||
|
.pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px;
|
||||||
|
padding:0 6px;color:#cfe0f5}
|
||||||
|
/* Rolling stock is finite and the Classification Yard only returns it when the Division Yard is
|
||||||
|
bare, so watching the Division Yard run down is real information. */
|
||||||
|
.yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px}
|
||||||
|
.stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px}
|
||||||
|
.stock b{color:#e6e9ee}
|
||||||
|
.stock .ld{color:#8fd6a0}
|
||||||
|
.stock .mt{color:#9fb6d8}
|
||||||
|
.stock.none{opacity:.4}
|
||||||
|
/* A pile or a yard chip that is currently a target for the thing you picked. Blue, matching the
|
||||||
|
legal-square outline on the board: the same question is being asked, so it looks the same. */
|
||||||
|
.handcard.target{border-color:#5aa9e6;background:#233246;cursor:pointer;
|
||||||
|
box-shadow:0 0 0 2px rgba(90,169,230,.20)}
|
||||||
|
.handcard.target:hover{background:#2b3d55}
|
||||||
|
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
|
||||||
|
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
|
||||||
|
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
|
||||||
|
ul.blocked{margin:0;padding-left:18px}
|
||||||
|
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
|
||||||
|
.fac{border-top:1px solid #2c333d;padding:7px 0}
|
||||||
|
.fac:first-child{border-top:0}
|
||||||
|
.fac .nm{font-size:12px;font-weight:600}
|
||||||
|
.caps{display:flex;flex-wrap:wrap;gap:9px;margin-top:3px;font-size:10px;align-items:baseline}
|
||||||
|
.cap b{color:#e6e9ee;font-variant-numeric:tabular-nums}
|
||||||
|
.cap .added{color:#8fd6a0;margin-left:2px;font-weight:700}
|
||||||
|
.caps .mods{color:#c8a04a}
|
||||||
|
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
|
||||||
|
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
|
||||||
|
.box.empty{background:#242a32;color:#5a6472}
|
||||||
|
.box.f{background:#2f6b3d}
|
||||||
|
.box.m{background:#8a6d1f}
|
||||||
|
.fstat{margin-top:3px;font-size:10px;padding:1px 6px;border-radius:3px;display:inline-block}
|
||||||
|
.fstat.good{background:rgba(40,140,60,.28)}
|
||||||
|
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
||||||
|
.fstat.idle{opacity:.6}
|
||||||
|
`;
|
||||||
+28
-77
@@ -27,37 +27,7 @@ header button:hover{border-color:#4d6fa8}
|
|||||||
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
|
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
|
||||||
.pace.good{background:rgba(40,140,60,.32)}
|
.pace.good{background:rgba(40,140,60,.32)}
|
||||||
.pace.behind{background:rgba(190,120,40,.28)}
|
.pace.behind{background:rgba(190,120,40,.28)}
|
||||||
/* CARDS YOU HOLD vs CARDS YOU READ.
|
|
||||||
Both are drawn as cards, because they are cards. What separates them is the frame: a card in
|
|
||||||
Your Move is clickable and carries the action border; a reference card is flat, unlit and
|
|
||||||
explicitly not a target. Previously the hand was a list of bare names and the Department slots a
|
|
||||||
second list, so nothing looked like a card at all. */
|
|
||||||
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
|
|
||||||
/* THE YARDS. Rolling stock is finite and the Classification Yard only comes back when the Division
|
|
||||||
Yard is bare, so a player has to be able to watch the supply run down. */
|
|
||||||
.yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px}
|
|
||||||
.stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px}
|
|
||||||
.stock b{color:#e6e9ee}
|
|
||||||
.stock .ld{color:#8fd6a0}
|
|
||||||
.stock .mt{color:#9fb6d8}
|
|
||||||
.stock.none{opacity:.4}
|
|
||||||
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
|
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
|
||||||
.handcard{background:#242c36;border:1px solid var(--line);border-radius:5px;
|
|
||||||
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
|
|
||||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
|
||||||
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
|
||||||
/* A pile shows two things: which card is face up on top, and how many are under it. The depth is a
|
|
||||||
count and not a hint — only the top card may ever be drawn, so everything below it is out of
|
|
||||||
reach, and choosing where to discard is choosing what to put there. */
|
|
||||||
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
|
|
||||||
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim)}
|
|
||||||
.pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px;
|
|
||||||
padding:0 6px;color:#cfe0f5}
|
|
||||||
/* NOT YET PLAYABLE, drawn rather than captioned. A "not yet" tag had to be read; a crosshatch is
|
|
||||||
seen. The card stays legible underneath — it is unavailable, not irrelevant. */
|
|
||||||
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
|
||||||
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
|
||||||
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
|
||||||
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
||||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||||
@@ -90,18 +60,28 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
|||||||
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
|
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
|
||||||
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
|
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
|
||||||
.cars{font-size:10px;color:var(--dim)}
|
.cars{font-size:10px;color:var(--dim)}
|
||||||
/* facilities */
|
/* THE HAND IS THE ACTION SURFACE. Each card carries its own verbs, so the twelve-button discard
|
||||||
.fac{border-top:1px solid var(--line);padding:7px 0}
|
block and the separate "play a card" subject list are both gone. The buttons are small and quiet
|
||||||
.fac:first-child{border-top:0}
|
until the card is picked — the card is the thing being read, they are what you do with it. */
|
||||||
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
|
.handcard .cardacts{display:flex;gap:4px;margin-top:5px}
|
||||||
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
|
button.cardact{margin:0;padding:1px 7px;font-size:10px;background:#2f3a48;border:1px solid #46536440;
|
||||||
.box.empty{background:#242a32;color:#5a6472}
|
border-radius:9px;color:#cfd6e0;text-align:center}
|
||||||
.box.f{background:#2f6b3d}
|
button.cardact:hover{background:#3d4c60;border-color:#5aa9e6}
|
||||||
.box.m{background:#8a6d1f}
|
button.cardact.on{background:#2b3444;border-color:#c8912f;color:#f2e6cf;font-weight:700}
|
||||||
.fstat{margin-top:3px;font-size:10px;padding:1px 6px;border-radius:3px;display:inline-block}
|
button.cardact.discard{color:#d6b48a}
|
||||||
.fstat.good{background:rgba(40,140,60,.28)}
|
.handcard.picked{border-color:#c8912f;box-shadow:0 0 0 2px rgba(200,145,47,.18)}
|
||||||
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
#phasenote{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:40;
|
||||||
.fstat.idle{opacity:.6}
|
background:#241d33;border:1px solid var(--now);color:#e7dcfb;border-radius:14px;padding:5px 16px;
|
||||||
|
font-size:13px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
|
||||||
|
box-shadow:0 6px 22px rgba(0,0,0,.5)}
|
||||||
|
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||||
|
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
|
||||||
|
.makeup-note{font-size:11px;margin:0 0 5px}
|
||||||
|
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
|
||||||
|
buttons underneath it; struck through in red once it is gone. */
|
||||||
|
.moves{display:inline-block;font-size:11px;margin:0 0 7px;padding:1px 8px;border-radius:10px;
|
||||||
|
background:rgba(200,145,47,.16);border:1px solid #8a6d1f;color:#f2e6cf}
|
||||||
|
.moves.spent{background:rgba(190,50,50,.20);border-color:#8a4040;color:#e58080}
|
||||||
/* actions */
|
/* actions */
|
||||||
#actions{max-height:none}
|
#actions{max-height:none}
|
||||||
.grp{margin-bottom:6px}
|
.grp{margin-bottom:6px}
|
||||||
@@ -154,35 +134,6 @@ h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe
|
|||||||
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
|
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
|
||||||
ul.blocked li{padding:2px 0}
|
ul.blocked li{padding:2px 0}
|
||||||
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
|
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
|
||||||
/* turn chart */
|
|
||||||
#turnchart{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
|
|
||||||
background:#161b22;border-bottom:1px solid var(--line);padding:7px 14px}
|
|
||||||
.tc-when{display:flex;align-items:baseline;gap:10px}
|
|
||||||
.tc-when b{font-size:15px;color:#cfe0f5}
|
|
||||||
.tc-now{font-size:12px;color:var(--dim)}
|
|
||||||
.tc-now b{color:var(--now);font-size:13px}
|
|
||||||
/* WHO EVERYONE IS WAITING ON. Solitaire has one player, but the seat still has a name — the log
|
|
||||||
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
|
|
||||||
glances at most often, so it gets its own chip rather than hiding in the phase text. */
|
|
||||||
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
|
|
||||||
.tc-who b{color:var(--now);background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
|
||||||
border-radius:11px;padding:1px 9px;font-size:12px}
|
|
||||||
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
|
|
||||||
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:14px;
|
|
||||||
padding:3px 10px 3px 7px;font-size:11px;color:var(--dim);background:#1a1f26;cursor:help}
|
|
||||||
.tc-phase svg{display:block;flex:0 0 auto}
|
|
||||||
.tc-phase .ic{stroke:#5f6b7a;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
|
|
||||||
.tc-phase .icf{fill:#5f6b7a;stroke:none}
|
|
||||||
.tc-phase.done{opacity:.55}
|
|
||||||
/* VIOLET, not the amber used for actions.
|
|
||||||
The palette already spends amber on "you can click this", blue on "this is a legal square or a
|
|
||||||
link", green on good and red on bad. The turn chart is none of those — it reports WHERE YOU ARE,
|
|
||||||
and a player should never read it as something to press. Violet is the one hue not already
|
|
||||||
carrying a meaning here, so it cannot be confused with any of them. */
|
|
||||||
.tc-phase.now{border-color:var(--now);background:rgba(150,110,230,.15);color:#e7dcfb;font-weight:700;
|
|
||||||
box-shadow:0 0 0 2px rgba(150,110,230,.18)}
|
|
||||||
.tc-phase.now .ic{stroke:var(--now)}
|
|
||||||
.tc-phase.now .icf{fill:var(--now)}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -202,12 +153,12 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
|
|||||||
|
|
||||||
<!-- The turn chart: where in the Day we are, and which of the five phases is running. Lifted out
|
<!-- The turn chart: where in the Day we are, and which of the five phases is running. Lifted out
|
||||||
of the title bar, where "phase: Mainline" said nothing about what came before or after it. -->
|
of the title bar, where "phase: Mainline" said nothing about what came before or after it. -->
|
||||||
<div id="turnchart">
|
<!-- Filled by `turnChartHtml`, shared with both replay viewers so all three screens report where
|
||||||
<div class="tc-when"><b id="tc-day">Day —</b><span id="tc-stage">Stage —</span><span class="dim" id="tc-clock">—</span></div>
|
you are in the Day identically. -->
|
||||||
<div class="tc-now">phase <b id="tc-phase">—</b></div>
|
<div id="turnchart"></div>
|
||||||
<div class="tc-who">waiting on <b id="tc-who">—</b></div>
|
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
|
||||||
<ol class="tc-phases" id="tc-phases"></ol>
|
what you miss when the automatic phases run between two clicks. -->
|
||||||
</div>
|
<div id="phasenote"></div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+23
-4
@@ -99,23 +99,42 @@ input[type=range]{flex:1;min-width:180px}
|
|||||||
<input type="range" id="vscrub" min="0" max="0" value="0">
|
<input type="range" id="vscrub" min="0" max="0" value="0">
|
||||||
<span class="dim" id="vpos">0 / 0</span>
|
<span class="dim" id="vpos">0 / 0</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- The same turn chart the live game shows, from the same renderer — where you are in the
|
||||||
|
Day should look identical whether you are playing or watching. -->
|
||||||
|
<div id="turnchart"></div>
|
||||||
<div class="vbar">
|
<div class="vbar">
|
||||||
<span id="vclock">—</span>
|
|
||||||
<span>phase: <b id="vphase">—</b></span>
|
|
||||||
<span>Revenue <b id="vrev">0</b></span>
|
<span>Revenue <b id="vrev">0</b></span>
|
||||||
<span class="dim">← → to step, space to play</span>
|
<span class="dim">← → to step, space to play</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- SAME PANELS, SAME ORDER as the playable page. A replay is the game being watched rather
|
||||||
|
than played, so the only things it drops are the ones that ask you to act. It used to show
|
||||||
|
three panels against the play page's eight: no facilities, no blockers, no cards, no yards,
|
||||||
|
so a replay could not answer "why is nothing moving?" — the question a replay exists for. -->
|
||||||
<div class="vgrid">
|
<div class="vgrid">
|
||||||
<div>
|
<div>
|
||||||
<section><h2>The Division</h2><div class="scroll" id="vdivision"></div></section>
|
<section><h2>The Division — west to east</h2><div class="scroll" id="vdivision"></div></section>
|
||||||
<section id="vdistrict"><h2>Office Area <button id="vdistricttoggle" class="ghost">auto-hide: on</button></h2>
|
<section id="vdistrict"><h2>Office Area <button id="vdistricttoggle" class="ghost">auto-hide: on</button></h2>
|
||||||
<div id="vdistrictsummary" class="dim"></div>
|
<div id="vdistrictsummary" class="dim"></div>
|
||||||
<div class="scroll" id="vgrid"></div></section>
|
<div class="scroll" id="vgrid"></div></section>
|
||||||
</div>
|
|
||||||
<section><h2>What happened</h2><div id="vlog"></div></section>
|
<section><h2>What happened</h2><div id="vlog"></div></section>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<section><h2>Cards Available</h2>
|
||||||
|
<h3>Cards in Hand</h3><div id="vhand" class="cardrow">—</div>
|
||||||
|
<h3>Department Decks (top card face up)</h3><div id="vdepts" class="cardrow ref">—</div>
|
||||||
|
</section>
|
||||||
|
<section><h2>The Yards</h2>
|
||||||
|
<h3>Division Yard <span class="dim" id="vdivtot"></span></h3>
|
||||||
|
<div id="vdivyard" class="yard"></div>
|
||||||
|
<h3>Classification Yard <span class="dim" id="vclstot"></span></h3>
|
||||||
|
<div id="vclsyard" class="yard"></div>
|
||||||
|
</section>
|
||||||
|
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="vblocked"></ul></section>
|
||||||
|
<section><h2>Facilities</h2><div id="vfacs"></div></section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+33
-3
@@ -12,6 +12,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
||||||
|
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
||||||
|
import { PANEL_CSS, blockedHtml, facilitiesHtml, handHtml, pilesHtml, yardHtml } from './panels.ts';
|
||||||
import { cuesFor } from '../sim/narrate.ts';
|
import { cuesFor } from '../sim/narrate.ts';
|
||||||
import { playCue } from './sound.ts';
|
import { playCue } from './sound.ts';
|
||||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||||
@@ -109,8 +111,13 @@ function show(i: number): void {
|
|||||||
// whistles, and stepping back would sound a Stage ending that is being un-done.
|
// whistles, and stepping back would sound a Stage ending that is being un-done.
|
||||||
if (soundOn && at === from + 1) for (const c of step.cues) playCue(c);
|
if (soundOn && at === from + 1) for (const c of step.cues) playCue(c);
|
||||||
|
|
||||||
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
|
// The live game's turn chart, from the shared renderer: same five phases, same violet "you are
|
||||||
$('vphase').textContent = f.phase;
|
// here". A replay used to report the Day and phase as two plain strings, so the one thing a player
|
||||||
|
// glances at most often looked nothing like it does in play.
|
||||||
|
// A replay carries the seat names in the frame's own lines rather than a player table, so the
|
||||||
|
// solitaire seat is named directly; a multi-player replay reports the index it has.
|
||||||
|
const actorName = f.actor === null ? null : `Player ${f.actor + 1}`;
|
||||||
|
$('turnchart').innerHTML = turnChartHtml(f, actorName);
|
||||||
$('vrev').textContent = String(f.revenue);
|
$('vrev').textContent = String(f.revenue);
|
||||||
$('vpos').textContent = `${at} / ${steps.length - 1}`;
|
$('vpos').textContent = `${at} / ${steps.length - 1}`;
|
||||||
($('vscrub') as HTMLInputElement).value = String(at);
|
($('vscrub') as HTMLInputElement).value = String(at);
|
||||||
@@ -139,6 +146,17 @@ function show(i: number): void {
|
|||||||
log += `<div class="line t-${l.tone}${k === at ? ' now' : ''}">${esc(l.text)}</div>`;
|
log += `<div class="line t-${l.tone}${k === at ? ' now' : ''}">${esc(l.text)}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The same side panels the playable page shows, from the same renderers. A replay is the game
|
||||||
|
// being watched, so it drops only what asks you to act.
|
||||||
|
$('vhand').innerHTML = handHtml(f);
|
||||||
|
$('vdepts').innerHTML = pilesHtml(f);
|
||||||
|
$('vdivyard').innerHTML = yardHtml(f.yards.division);
|
||||||
|
$('vclsyard').innerHTML = yardHtml(f.yards.classification);
|
||||||
|
$('vdivtot').textContent = `${f.yards.divisionTotal} cars`;
|
||||||
|
$('vclstot').textContent = `${f.yards.classificationTotal} cars`;
|
||||||
|
$('vblocked').innerHTML = blockedHtml(f);
|
||||||
|
$('vfacs').innerHTML = facilitiesHtml(f);
|
||||||
|
|
||||||
$('vlog').innerHTML = log;
|
$('vlog').innerHTML = log;
|
||||||
const logEl = $('vlog');
|
const logEl = $('vlog');
|
||||||
logEl.scrollTop = logEl.scrollHeight;
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
@@ -252,6 +270,18 @@ function wire(): void {
|
|||||||
show(at + 1);
|
show(at + 1);
|
||||||
}, Number(($('vspeed') as HTMLSelectElement).value));
|
}, Number(($('vspeed') as HTMLSelectElement).value));
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* A SPEED CHANGE HAS TO TAKE EFFECT WHILE IT IS PLAYING.
|
||||||
|
*
|
||||||
|
* The interval is created with whatever the select held at the moment play started, and nothing
|
||||||
|
* re-read it — so choosing "extra slow" mid-replay did nothing at all and the pace looked stuck.
|
||||||
|
* The standalone replay has always restarted its timer on change; this viewer was missed.
|
||||||
|
*/
|
||||||
|
($('vspeed') as HTMLSelectElement).onchange = () => {
|
||||||
|
if (!timer) return;
|
||||||
|
$('vplay').click();
|
||||||
|
$('vplay').click();
|
||||||
|
};
|
||||||
$('vdistricttoggle').onclick = () => {
|
$('vdistricttoggle').onclick = () => {
|
||||||
const f = steps[at]?.frame;
|
const f = steps[at]?.frame;
|
||||||
const open = districtMode === 'auto' ? (f ? FOCUS_PHASES.has(f.phaseKey) : true) : districtMode === 'open';
|
const open = districtMode === 'auto' ? (f ? FOCUS_PHASES.has(f.phaseKey) : true) : districtMode === 'open';
|
||||||
@@ -283,7 +313,7 @@ function wire(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.textContent = BOARD_CSS + TOOLTIP_CSS;
|
style.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
installTooltips();
|
installTooltips();
|
||||||
wire();
|
wire();
|
||||||
|
|||||||
@@ -93,6 +93,29 @@ export function playCue(cue: string): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cue === 'couple') {
|
||||||
|
/**
|
||||||
|
* KNUCKLE COUPLERS MEETING: a low metallic clank and the slack running out behind it.
|
||||||
|
*
|
||||||
|
* Coupling a cut was reported as invisible — the car simply left the board and only history said
|
||||||
|
* where it went. It is the single most consequential thing a switching move does, so it gets the
|
||||||
|
* one sound a player will learn to listen for.
|
||||||
|
*/
|
||||||
|
for (const fg of [[86, 0.22], [131, 0.13], [197, 0.07], [61, 0.18]]) {
|
||||||
|
tone(t, fg[0]!, 0.26, fg[1]!, 'square', fg[0]! * 0.82);
|
||||||
|
}
|
||||||
|
tone(t + 0.1, 74, 0.34, 0.1, 'triangle', 58);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cue === 'drop') {
|
||||||
|
// Setting out is the same act in reverse and quieter: one clank, no slack behind it.
|
||||||
|
for (const fg of [[104, 0.14], [156, 0.08]]) {
|
||||||
|
tone(t, fg[0]!, 0.18, fg[1]!, 'square', fg[0]! * 0.85);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cue === 'day') {
|
if (cue === 'day') {
|
||||||
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
|
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
|
||||||
for (const at of [t, t + 0.22]) {
|
for (const at of [t, t + 0.22]) {
|
||||||
|
|||||||
@@ -24,7 +24,25 @@ function ensure(): HTMLElement {
|
|||||||
|
|
||||||
function place(el: HTMLElement, text: string): void {
|
function place(el: HTMLElement, text: string): void {
|
||||||
const t = ensure();
|
const t = ensure();
|
||||||
|
/**
|
||||||
|
* A PICTURE WHERE ONE HELPS, otherwise text.
|
||||||
|
*
|
||||||
|
* `data-tip-html` carries pre-built markup — a drawing of the track piece a placement would lay,
|
||||||
|
* rendered by the board's own renderer so it cannot show a shape the placement will not produce.
|
||||||
|
* Only ever set from markup this app built; nothing here interpolates anything a player typed.
|
||||||
|
*/
|
||||||
|
const html = el.getAttribute('data-tip-html');
|
||||||
|
if (html) {
|
||||||
|
t.innerHTML = `<div class="tip-fig">${html}</div>`;
|
||||||
|
if (text) {
|
||||||
|
const p = document.createElement('div');
|
||||||
|
p.className = 'tip-txt';
|
||||||
|
p.textContent = text;
|
||||||
|
t.appendChild(p);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
t.textContent = text;
|
t.textContent = text;
|
||||||
|
}
|
||||||
t.style.display = 'block';
|
t.style.display = 'block';
|
||||||
|
|
||||||
// Measure, then keep it on screen: flip above when it would fall off the bottom, and pull back
|
// Measure, then keep it on screen: flip above when it would fall off the bottom, and pull back
|
||||||
@@ -87,4 +105,9 @@ export const TOOLTIP_CSS = `
|
|||||||
box-shadow:0 6px 22px rgba(0,0,0,.55);pointer-events:none;white-space:pre-wrap}
|
box-shadow:0 6px 22px rgba(0,0,0,.55);pointer-events:none;white-space:pre-wrap}
|
||||||
[data-tip]{cursor:help}
|
[data-tip]{cursor:help}
|
||||||
button[data-tip],a[data-tip]{cursor:pointer}
|
button[data-tip],a[data-tip]{cursor:pointer}
|
||||||
|
/* A drawn piece inside a tooltip. The figure sits above its caption, and the caption keeps the
|
||||||
|
tooltip's own text treatment — the drawing is the answer, the words are the confirmation. */
|
||||||
|
#tip .tip-fig{display:flex;gap:6px;justify-content:center;margin:1px 0 6px}
|
||||||
|
#tip .tip-fig svg{width:132px;height:auto;display:block}
|
||||||
|
#tip .tip-txt{white-space:pre-wrap}
|
||||||
`;
|
`;
|
||||||
|
|||||||
+16
-5
@@ -254,8 +254,12 @@ describe('replay HTML', () => {
|
|||||||
() => run(stub, () => 0, () => undefined),
|
() => run(stub, () => 0, () => undefined),
|
||||||
'the page threw while rendering its first frame',
|
'the page threw while rendering its first frame',
|
||||||
);
|
);
|
||||||
// render() ran, so the clock element should have been filled in.
|
// render() ran, so the turn chart should have been filled in — with the same Day/Stage/phase
|
||||||
assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
|
// chart the live game draws, from the shared renderer.
|
||||||
|
const chart = String(els.get('turnchart')?.innerHTML ?? '');
|
||||||
|
assert.match(chart, /Day \d+/, 'the turn chart was not rendered');
|
||||||
|
assert.match(chart, /Stage \d+ of 12/);
|
||||||
|
assert.match(chart, /class="tc-phase now"/, 'no phase is marked as the current one');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records what the bot chose, why, and what it passed over', () => {
|
it('records what the bot chose, why, and what it passed over', () => {
|
||||||
@@ -394,16 +398,23 @@ describe('the replay behaves like the game it is replaying', () => {
|
|||||||
assert.ok(kinds.has('stage'), 'no Stage ever sounded');
|
assert.ok(kinds.has('stage'), 'no Stage ever sounded');
|
||||||
assert.ok(kinds.has('day'), 'no Day ever sounded');
|
assert.ok(kinds.has('day'), 'no Day ever sounded');
|
||||||
assert.ok(kinds.has('train'), 'no train was ever announced');
|
assert.ok(kinds.has('train'), 'no train was ever announced');
|
||||||
|
// Coupling and setting out are what a switching move IS, and both were silent — a car left the
|
||||||
|
// board and only history said where it went.
|
||||||
|
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
|
||||||
|
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
|
||||||
|
|
||||||
// One cue per Stage boundary, the bell replacing the whistle at a Day — the same accounting the
|
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
|
||||||
// live game is held to.
|
// accounting the live game is held to. The switching cues are counted separately: they follow
|
||||||
|
// what a crew does, not what the clock does.
|
||||||
let boundaries = 0;
|
let boundaries = 0;
|
||||||
for (let k = 1; k < rec.frames.length; k++) {
|
for (let k = 1; k < rec.frames.length; k++) {
|
||||||
const a = rec.frames[k - 1]!;
|
const a = rec.frames[k - 1]!;
|
||||||
const b = rec.frames[k]!;
|
const b = rec.frames[k]!;
|
||||||
if (a.day !== b.day || a.stage !== b.stage) boundaries++;
|
if (a.day !== b.day || a.stage !== b.stage) boundaries++;
|
||||||
}
|
}
|
||||||
const clock = withCues.flatMap((f) => f.cues ?? []).filter((c) => c !== 'train').length;
|
const clock = withCues
|
||||||
|
.flatMap((f) => f.cues ?? [])
|
||||||
|
.filter((c) => c === 'stage' || c === 'day').length;
|
||||||
assert.equal(clock, boundaries, 'Stage boundaries and Stage-or-Day cues disagree');
|
assert.equal(clock, boundaries, 'Stage boundaries and Stage-or-Day cues disagree');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -901,3 +901,46 @@ describe('a turnout may be laid to face another, making a crossover', () => {
|
|||||||
assert.ok(!has(dests, -1, 0), 'and may not stop on the turnout itself');
|
assert.ok(!has(dests, -1, 0), 'and may not stop on the turnout itself');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('backing up does not turn the train around', () => {
|
||||||
|
it('keeps the engine pointing the same way through a reverse move', () => {
|
||||||
|
// REPORTED as forward/reverse being scrambled. `facing` is which way the ENGINE points, and it
|
||||||
|
// was reset to the direction of travel on EVERY move — so one reverse move silently spun the
|
||||||
|
// train about, everything read "forward" again, and a run-around became pointless: you could
|
||||||
|
// change ends for free by backing up twice.
|
||||||
|
const s = gameWith(
|
||||||
|
areaFrom(
|
||||||
|
{
|
||||||
|
[coordKey(at(0, 0))]: straight(),
|
||||||
|
[coordKey(at(0, 1))]: straight(),
|
||||||
|
[coordKey(at(0, 2))]: straight(),
|
||||||
|
},
|
||||||
|
at(0, 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const id = 'crew';
|
||||||
|
s.trays.set(id, {
|
||||||
|
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [], direction: 'east', facing: 'e',
|
||||||
|
position: { at: 'grid', owner: 0, coord: at(0, 1) }, movesUsed: 0,
|
||||||
|
} as never);
|
||||||
|
s.clock.phase = 'localOps';
|
||||||
|
s.clock.currentActor = 0;
|
||||||
|
s.turn.option = 'switch';
|
||||||
|
s.turn.movesRemaining = 6;
|
||||||
|
|
||||||
|
// Running forward, east: the engine leads, so it still points east.
|
||||||
|
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 2), reverse: false }).ok);
|
||||||
|
assert.equal(s.trays.get(id)!.facing, 'e', 'a forward move must leave the engine leading');
|
||||||
|
|
||||||
|
// Backing up, west: the engine trails. It is still pointing east.
|
||||||
|
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
|
||||||
|
assert.equal(
|
||||||
|
s.trays.get(id)!.facing,
|
||||||
|
'e',
|
||||||
|
'backing up turned the train around — a run-around would then be pointless',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+378
-4
@@ -240,6 +240,31 @@ describe('the action menu presents choices the way they are made', () => {
|
|||||||
assert.equal(f.salvage.top, cardName(game.state, spare), 'the top of the Salvage Yard is not named');
|
assert.equal(f.salvage.top, cardName(game.state, spare), 'the top of the Salvage Yard is not named');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers BOTH rotations of a turnout, each with the shape it would lay', () => {
|
||||||
|
// REGRESSION, and the reason rotating a turnout felt impossible. The action list drops duplicate
|
||||||
|
// labels, and `describeIntent` for a card play said only "play X at (0, 1)" — so a turnout's two
|
||||||
|
// orientations produced the same label and the second was discarded before the menu ever saw it.
|
||||||
|
// The rotation is the entire decision for a turnout, and it could not be made.
|
||||||
|
const game = newGame(555);
|
||||||
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||||
|
dealTrack(game, 'turnout', 'right');
|
||||||
|
|
||||||
|
const spots = actionMenu(game)
|
||||||
|
.placeable.flatMap((g) => g.items)
|
||||||
|
.flatMap((it) => it.spots)
|
||||||
|
.filter((sp) => sp.coord.row === 0 && sp.coord.col === 1);
|
||||||
|
|
||||||
|
assert.equal(spots.length, 2, 'both rotations must be offered on the same square');
|
||||||
|
assert.ok(spots.some((sp) => /turn to the south/.test(sp.label)), 'the south-diverging rotation is missing');
|
||||||
|
assert.ok(spots.some((sp) => /turn to the north/.test(sp.label)), 'the north-diverging rotation is missing');
|
||||||
|
|
||||||
|
// And each carries the rails it would actually lay, so the UI can draw it rather than only
|
||||||
|
// describe it. Taken from the engine's connections, so a preview cannot promise a shape the
|
||||||
|
// placement will not produce.
|
||||||
|
const shapes = spots.map((sp) => [...sp.links].sort().join('|')).sort();
|
||||||
|
assert.deepEqual(shapes, ['es|ew', 'we|wn'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
it('says how deep a Department pile is, so a discard can be aimed', () => {
|
it('says how deep a Department pile is, so a discard can be aimed', () => {
|
||||||
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
|
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
|
||||||
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
|
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
|
||||||
@@ -1047,8 +1072,10 @@ describe('the static build', () => {
|
|||||||
|
|
||||||
const actions = els.get('actions')!;
|
const actions = els.get('actions')!;
|
||||||
const grid = els.get('grid')!;
|
const grid = els.get('grid')!;
|
||||||
|
const hand = els.get('hand')!;
|
||||||
|
|
||||||
// Take the draw option, then pick the first placeable subject.
|
// Take the draw option, then pick a card's PLAY verb — the hand is the action surface now, so
|
||||||
|
// the subject is chosen on the card rather than in a separate list.
|
||||||
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
|
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
|
||||||
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
|
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
|
||||||
const nodes = fn.call(el, sel);
|
const nodes = fn.call(el, sel);
|
||||||
@@ -1059,7 +1086,18 @@ describe('the static build', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
|
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
|
||||||
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
|
// Specifically the PLAY verb: a hand usually holds cards that can only be discarded, and
|
||||||
|
// discarding highlights the Department piles rather than the board.
|
||||||
|
const clickVerb = (el: Record<string, unknown>, verb: string): boolean => {
|
||||||
|
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
|
||||||
|
const target = fn
|
||||||
|
.call(el, 'button.cardact')
|
||||||
|
.find((n) => (n['dataset'] as Record<string, string>)['verb'] === verb);
|
||||||
|
if (!target) return false;
|
||||||
|
(target['onclick'] as (() => void) | null)?.();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
assert.ok(clickVerb(hand, 'play'), 'no card in hand offers a play verb');
|
||||||
|
|
||||||
// Without the board stylesheet every shape is drawn black on a near-black background: the page
|
// Without the board stylesheet every shape is drawn black on a near-black background: the page
|
||||||
// looks empty even though the markup is perfect.
|
// looks empty even though the markup is perfect.
|
||||||
@@ -1137,6 +1175,342 @@ describe('the static build', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws the same turn chart on all three screens', () => {
|
||||||
|
// A game gets drawn in three places — the playable page, the site's replay viewer and the
|
||||||
|
// standalone replay file — and where you are in the Day is the thing a player glances at most
|
||||||
|
// often. It lived only in the playable page: a replay reported the Day and phase as two plain
|
||||||
|
// strings with none of the violet "you are here" the live game uses, so the same position looked
|
||||||
|
// like a different game depending on which screen you were on.
|
||||||
|
//
|
||||||
|
// ONE renderer, asserted here by source rather than by eye, because three copies of a chart is
|
||||||
|
// exactly the kind of thing that drifts quietly.
|
||||||
|
for (const f of ['src/web/main.ts', 'src/web/replays.ts', 'src/sim/replay.ts']) {
|
||||||
|
const src = readFileSync(join(root, f), 'utf8');
|
||||||
|
assert.match(src, /turnChartHtml/, `${f} does not use the shared turn chart`);
|
||||||
|
assert.match(src, /TURNCHART_CSS/, `${f} does not ship the shared turn-chart styling`);
|
||||||
|
}
|
||||||
|
// And no screen may keep a private copy of the phase table.
|
||||||
|
for (const f of ['src/web/main.ts', 'src/web/replays.ts', 'src/sim/replay.ts']) {
|
||||||
|
const src = readFileSync(join(root, f), 'utf8');
|
||||||
|
assert.doesNotMatch(src, /key: 'shiftChange'/, `${f} has its own copy of the phase table`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts every verb on the card it belongs to', () => {
|
||||||
|
// THE HAND IS THE ACTION SURFACE. A card used to appear twice under two different models: as a
|
||||||
|
// subject to play, and as one flat button per Department to discard. For a four-card hand that
|
||||||
|
// discard block alone was twelve buttons repeating three choices four times.
|
||||||
|
const game = newGame(555);
|
||||||
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||||
|
|
||||||
|
const menu = actionMenu(game);
|
||||||
|
const held = game.state.decks.hands.get(0) ?? [];
|
||||||
|
assert.equal(menu.hand.length, held.length, 'every card in hand needs an entry');
|
||||||
|
for (const h of menu.hand) {
|
||||||
|
assert.ok(h.name.length > 0, 'a hand entry with no name');
|
||||||
|
// A verb is only offered when the engine would accept it.
|
||||||
|
for (let slot = 0; slot < 3; slot++) {
|
||||||
|
const idx = h.discard[slot];
|
||||||
|
if (idx === null || idx === undefined) continue;
|
||||||
|
const i = menu.options[idx]!;
|
||||||
|
assert.equal(i.type, 'card.discard');
|
||||||
|
assert.equal((i as { cardId: string }).cardId, h.cardId);
|
||||||
|
assert.equal((i as { toSlot: number }).toSlot, slot, 'a discard is keyed to the wrong Department');
|
||||||
|
}
|
||||||
|
if (h.playNow !== null) {
|
||||||
|
const i = menu.options[h.playNow]!;
|
||||||
|
assert.equal(i.type, 'card.play');
|
||||||
|
assert.equal((i as { placement?: unknown }).placement, undefined, 'playNow must need no square');
|
||||||
|
}
|
||||||
|
if (h.placeKey !== null) {
|
||||||
|
const item = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === h.placeKey);
|
||||||
|
assert.ok(item, 'placeKey points at no placeable entry');
|
||||||
|
assert.equal(item!.spots.length, h.spots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previews a track card in hand even when it has nowhere legal to go', () => {
|
||||||
|
// REPORTED on a left-hand curve, which showed no preview at all. The shape was read off the
|
||||||
|
// card's legal PLACEMENTS, so a card with no legal square had nothing to draw — and that is
|
||||||
|
// exactly when a player most wants to see what the piece is.
|
||||||
|
const game = newGame(775569289);
|
||||||
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||||
|
const { cardId } = dealTrack(game, 'curved', 'left');
|
||||||
|
const entry = actionMenu(game).hand.find((h) => h.cardId === cardId);
|
||||||
|
assert.ok(entry, 'the curve is not in the hand menu');
|
||||||
|
assert.deepEqual(entry!.shapes, [['sw'], ['ne']], 'both orientations must be previewable');
|
||||||
|
assert.equal(entry!.spots, 0, 'this is the case worth guarding: nowhere legal to lay it');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes a curve as a curve, not as a turnout', () => {
|
||||||
|
// REPORTED: the curve read "east-west track with a 45° leg", which is a turnout — a road across
|
||||||
|
// the card plus a leg off it. A curve has ONE road and nothing runs past it, which is precisely
|
||||||
|
// why it may not be laid in the Running Track.
|
||||||
|
const game = newGame(775569289);
|
||||||
|
const { cardId } = dealTrack(game, 'curved', 'left');
|
||||||
|
const what = cardDescription(game.state, cardId);
|
||||||
|
assert.doesNotMatch(what, /east-west track with/, 'a curve is still described as a turnout');
|
||||||
|
assert.match(what, /single road/);
|
||||||
|
assert.match(what, /no track runs past it/);
|
||||||
|
// And a turnout must still say it HAS a road across it.
|
||||||
|
const t = dealTrack(game, 'turnout', 'left');
|
||||||
|
assert.match(cardDescription(game.state, t.cardId), /east-west track with/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the Mainline card a modifier would change, and what it becomes', () => {
|
||||||
|
// REPORTED: "Realignment on Mainline card 3" — a raw node index, with no clue which stretch of
|
||||||
|
// the Division it meant or what it would do, and no tooltip either, because the action list only
|
||||||
|
// attaches one when the label happens to contain an em-dash.
|
||||||
|
const game = newGame(775569289);
|
||||||
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||||
|
for (const [id, c] of game.state.cards) {
|
||||||
|
if (c.kind.kind === 'mainlineModifier' && c.kind.key === 'realignment') {
|
||||||
|
game.state.decks.hands.set(0, [id]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const menu = actionMenu(game);
|
||||||
|
const labels = menu.direct
|
||||||
|
.filter((g) => g.title === 'Mainline modifiers')
|
||||||
|
.flatMap((g) => g.actions)
|
||||||
|
.map((a) => a.label);
|
||||||
|
assert.ok(labels.length > 0, 'Realignment was not offered at all');
|
||||||
|
for (const l of labels) {
|
||||||
|
assert.doesNotMatch(l, /Mainline card \d/, `still naming a raw node index: ${l}`);
|
||||||
|
assert.match(l, / — /, `no tooltip half, so the button gets none: ${l}`);
|
||||||
|
assert.match(l, /converts it to|nothing here to convert/, `does not say what it does: ${l}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never strands the player with a legal move and no way to make it', () => {
|
||||||
|
// REGRESSION, and a hard softlock. Moving train make-up onto the Division Yard chips took the
|
||||||
|
// "Making up …" group out of the action list, and the PASS option went with it — so a train
|
||||||
|
// being made up when the yard held nothing it could take had no control on screen at all.
|
||||||
|
// Reported at Stage 10 of seed 775569289, Train 10 waiting at the West Division Point.
|
||||||
|
//
|
||||||
|
// The invariant is the general one: every option the engine offers must be reachable through
|
||||||
|
// SOMETHING the page renders. Menu-level, so it holds for every phase rather than the handful a
|
||||||
|
// click-through happens to visit.
|
||||||
|
const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => {
|
||||||
|
const out = new Set<number>();
|
||||||
|
for (const g of menu.direct) for (const a of g.actions) out.add(a.index);
|
||||||
|
for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index);
|
||||||
|
for (const h of menu.hand) {
|
||||||
|
if (h.playNow !== null) out.add(h.playNow);
|
||||||
|
for (const d of h.discard) if (d !== null && d !== undefined) out.add(d);
|
||||||
|
}
|
||||||
|
if (menu.makeUp) {
|
||||||
|
for (const c of menu.makeUp.cars) out.add(c.index);
|
||||||
|
if (menu.makeUp.pass !== null) out.add(menu.makeUp.pass);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const seed of [775569289, 555, 430]) {
|
||||||
|
const game = newGame(seed);
|
||||||
|
for (let i = 0; i < 900 && currentActor(game) !== null; i++) {
|
||||||
|
const menu = actionMenu(game);
|
||||||
|
if (menu.options.length === 0) break;
|
||||||
|
const got = reachable(menu);
|
||||||
|
assert.ok(
|
||||||
|
got.size > 0,
|
||||||
|
`seed ${seed}: ${menu.options.length} legal options and not one of them is on screen ` +
|
||||||
|
`(phase ${game.state.clock.phase}, Day ${game.state.clock.day} Stage ${game.state.clock.stage})`,
|
||||||
|
);
|
||||||
|
const { options } = actionGroups(game);
|
||||||
|
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders every part of the menu it is given', () => {
|
||||||
|
// THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along —
|
||||||
|
// `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu
|
||||||
|
// shape passed while the page stranded the player.
|
||||||
|
//
|
||||||
|
// Coarse on purpose: it asks only that every field the menu offers is referenced by the page. A
|
||||||
|
// field nothing reads is either dead or a control that has gone missing, and both are worth a
|
||||||
|
// failing test.
|
||||||
|
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
|
||||||
|
for (const field of ['makeUp.title', 'makeUp.cars', 'makeUp.pass', 'h.playNow', 'h.discard', 'h.shapes']) {
|
||||||
|
assert.match(src, new RegExp(field.replace('.', '\\.')), `main.ts never reads menu ${field}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the action list to what is not a card in hand', () => {
|
||||||
|
// Measured across a full game of seed 430: the widest action list was 22 buttons, of which up to
|
||||||
|
// 12 were the discard cross-product and up to 10 were "add loaded hopper"-style make-up buttons.
|
||||||
|
// Both now live on the objects already on screen — the card, and the Division Yard chip.
|
||||||
|
const game = newGame(430);
|
||||||
|
let worst = 0;
|
||||||
|
for (let i = 0; i < 400 && currentActor(game) !== null; i++) {
|
||||||
|
const menu = actionMenu(game);
|
||||||
|
const shown = menu.direct
|
||||||
|
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
|
||||||
|
.reduce((n, g) => n + g.actions.length, 0);
|
||||||
|
worst = Math.max(worst, shown);
|
||||||
|
const { options } = actionGroups(game);
|
||||||
|
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||||
|
}
|
||||||
|
assert.ok(worst <= 8, `the action list still reaches ${worst} buttons`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keys each make-up car to the yard chip that shows it', () => {
|
||||||
|
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
|
||||||
|
// exactly those cars by type and load state. The yard is the surface.
|
||||||
|
const game = newGame(430);
|
||||||
|
for (let i = 0; i < 400 && currentActor(game) !== null; i++) {
|
||||||
|
const menu = actionMenu(game);
|
||||||
|
if (menu.makeUp && menu.makeUp.cars.length > 0) {
|
||||||
|
for (const car of menu.makeUp.cars) {
|
||||||
|
const intent = menu.options[car.index]!;
|
||||||
|
assert.equal(intent.type, 'newTrain.placeCar');
|
||||||
|
assert.equal((intent as { carType: string }).carType, car.carType);
|
||||||
|
assert.equal((intent as { loaded: boolean }).loaded, car.loaded);
|
||||||
|
}
|
||||||
|
assert.match(menu.makeUp.title, /Making up/, 'the make-up panel does not name the train');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { options } = actionGroups(game);
|
||||||
|
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||||
|
}
|
||||||
|
assert.fail('no train was ever made up, so this proves nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers ABS Signals the Mainline, not the Office Area', () => {
|
||||||
|
// REPORTED: the card says "any Mainline card" and every option offered was a square in the
|
||||||
|
// Office Area. `checkEnhancementPlacement` reads `placement.col` as a Division NODE index for a
|
||||||
|
// mainline-card enhancement, while the candidate list handed it occupied grid cells — so "(0,1)"
|
||||||
|
// was accepted because node 1 happened to be a Mainline card. The label and the meaning were
|
||||||
|
// different things.
|
||||||
|
const game = newGame(775569289);
|
||||||
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||||
|
for (const [id, c] of game.state.cards) {
|
||||||
|
if (c.kind.kind === 'enhancement' && c.kind.key === 'absSignals') {
|
||||||
|
game.state.decks.hands.set(0, [id]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((it) => it.spots);
|
||||||
|
assert.ok(spots.length > 0, 'ABS Signals was not offered anywhere');
|
||||||
|
for (const sp of spots) {
|
||||||
|
assert.match(sp.label, /out on the Mainline/, `not a Mainline card: ${sp.label}`);
|
||||||
|
const node = game.state.division.nodes[sp.coord.col];
|
||||||
|
assert.equal(node?.kind, 'mainline', `spot points at a ${node?.kind}, not a Mainline card`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a train standing on a card, in order, with which way it points', () => {
|
||||||
|
// REPORTED: with a train in the station there was no way to see which cars it held or in what
|
||||||
|
// order, so the switching game could not be planned at all — "drop 1 car" says nothing when you
|
||||||
|
// cannot see what is on the back.
|
||||||
|
const game = newGame(555);
|
||||||
|
const area = game.state.officeAreas.get(0)!;
|
||||||
|
const id = 'crew';
|
||||||
|
game.state.trays.set(id, {
|
||||||
|
id, trainNumber: 10, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [
|
||||||
|
{ type: 'tank', loaded: true },
|
||||||
|
{ type: 'boxcar', loaded: true },
|
||||||
|
{ type: 'caboose', loaded: false },
|
||||||
|
],
|
||||||
|
direction: 'east', facing: 'e',
|
||||||
|
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
|
||||||
|
assert.ok(cell.train, 'the card carries no train');
|
||||||
|
assert.deepEqual(cell.train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
|
||||||
|
assert.equal(cell.train!.engineAt, 0, 'the engine has no place in the train');
|
||||||
|
assert.equal(cell.train!.facing, 'e', 'which way it points is not carried');
|
||||||
|
|
||||||
|
const svg = officeSvg([cell], area.runningRow);
|
||||||
|
assert.match(svg, /bs-t-eng/, 'the engine is not drawn');
|
||||||
|
assert.match(svg, /bs-t-ld/, 'a loaded car is not drawn');
|
||||||
|
assert.match(svg, /cab</, 'the caboose is not named on the board');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows A/D tracks on the Office card, taken and total', () => {
|
||||||
|
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
|
||||||
|
// decides whether the next arrival is an automatic collision (§8.3).
|
||||||
|
const game = newGame(555);
|
||||||
|
const area = game.state.officeAreas.get(0)!;
|
||||||
|
const cell = view(game).cells.find((c) => c.kind === 'office')!;
|
||||||
|
assert.ok(cell.ad, 'the Office card carries no A/D count');
|
||||||
|
assert.equal(cell.ad!.of, 1, 'a Whistle Post has one A/D track');
|
||||||
|
assert.equal(cell.ad!.used, 0);
|
||||||
|
assert.match(officeSvg([cell], area.runningRow), /bs-ad/, 'no A/D pips are drawn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the cars a drop would set out, and which end they come off', () => {
|
||||||
|
// REPORTED: "drop 1 car(s)" — which car? And because a nose drop and a tail drop read
|
||||||
|
// identically, the action list's duplicate-label filter discarded one of them outright, so
|
||||||
|
// setting out from the front of the train could not be chosen at all.
|
||||||
|
const game = newGame(555);
|
||||||
|
const area = game.state.officeAreas.get(0)!;
|
||||||
|
const id = 'crew';
|
||||||
|
game.state.trays.set(id, {
|
||||||
|
id, trainNumber: null, trainIsExtra: false, engineAt: 1,
|
||||||
|
consist: [{ type: 'boxcar', loaded: true }, { type: 'caboose', loaded: false }],
|
||||||
|
direction: 'east', facing: 'e',
|
||||||
|
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0,
|
||||||
|
} as never);
|
||||||
|
const front = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true });
|
||||||
|
const back = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1 });
|
||||||
|
assert.match(front, /loaded boxcar/, `the front cut is not named: ${front}`);
|
||||||
|
assert.match(front, /off the front/);
|
||||||
|
assert.match(back, /caboose/, `the back cut is not named: ${back}`);
|
||||||
|
assert.match(back, /off the back/);
|
||||||
|
assert.notEqual(front, back, 'the two ends still read identically, so one is dropped from the list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the side panels from one place, on both screens', () => {
|
||||||
|
// A replay is the game being WATCHED rather than played, so it should look like the game. The
|
||||||
|
// site's viewer had three panels against the play page's eight — no facilities, no blockers, no
|
||||||
|
// cards, no yards — which meant it could not answer "why is nothing moving?", the question a
|
||||||
|
// replay mostly exists to answer.
|
||||||
|
for (const f of ['src/web/main.ts', 'src/web/replays.ts']) {
|
||||||
|
const src = readFileSync(join(root, f), 'utf8');
|
||||||
|
for (const fn of ['pilesHtml', 'yardHtml', 'blockedHtml', 'facilitiesHtml']) {
|
||||||
|
assert.match(src, new RegExp(fn), `${f} does not use the shared ${fn}`);
|
||||||
|
}
|
||||||
|
assert.match(src, /PANEL_CSS/, `${f} does not ship the shared panel styling`);
|
||||||
|
}
|
||||||
|
// The HAND is the one panel that differs, and deliberately: on the play page every card carries
|
||||||
|
// its own play and discard verbs, while a replay's hand is a read-only row of what was held.
|
||||||
|
assert.match(readFileSync(join(root, 'src/web/replays.ts'), 'utf8'), /handHtml/);
|
||||||
|
assert.match(readFileSync(join(root, 'src/web/main.ts'), 'utf8'), /button class="cardact/);
|
||||||
|
// And the viewer's page must actually have somewhere to put them.
|
||||||
|
const html = readFileSync(join(dist, 'replays.html'), 'utf8');
|
||||||
|
for (const id of ['vhand', 'vdepts', 'vdivyard', 'vclsyard', 'vblocked', 'vfacs']) {
|
||||||
|
assert.match(html, new RegExp(`id="${id}"`), `the replay viewer has no #${id} panel`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a replay change pace while it is playing', () => {
|
||||||
|
// The interval is created with whatever the speed select held when play started. Nothing re-read
|
||||||
|
// it in the site's viewer, so choosing "extra slow" mid-replay did nothing at all and the pace
|
||||||
|
// looked stuck — the standalone replay had always restarted its timer on change.
|
||||||
|
for (const [file, id] of [['src/web/replays.ts', 'vspeed'], ['src/sim/replay.ts', 'speed']]) {
|
||||||
|
const src = readFileSync(join(root, file!), 'utf8');
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
new RegExp(`\\$\\('${id}'\\)[^;]*\\.onchange`),
|
||||||
|
`${file} never reacts to a speed change`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the same five paces in both replay viewers', () => {
|
||||||
|
const standalone = readFileSync(join(root, 'src/sim/replay.ts'), 'utf8');
|
||||||
|
const viewer = readFileSync(join(root, 'src/web/replays.html'), 'utf8');
|
||||||
|
const paces = (src: string): string[] =>
|
||||||
|
[...src.matchAll(/<option value="\d+"[^>]*>([^<]+)<\/option>/g)].map((m) => m[1]!);
|
||||||
|
assert.deepEqual(paces(standalone), ['extra slow', 'slow', 'normal', 'fast', 'very fast']);
|
||||||
|
assert.deepEqual(paces(viewer), paces(standalone), 'the two replay viewers offer different paces');
|
||||||
|
});
|
||||||
|
|
||||||
it('gives every phase the engine can be in a pill on the turn chart', () => {
|
it('gives every phase the engine can be in a pill on the turn chart', () => {
|
||||||
// The turn chart exists to say where in the Stage you are, so a phase with no pill is a hole
|
// The turn chart exists to say where in the Stage you are, so a phase with no pill is a hole
|
||||||
// exactly when the player most needs it. `Phase` is a closed union in the engine; this fails if
|
// exactly when the player most needs it. `Phase` is a closed union in the engine; this fails if
|
||||||
@@ -1147,12 +1521,12 @@ describe('the static build', () => {
|
|||||||
const phases = [...decl![1]!.matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]!);
|
const phases = [...decl![1]!.matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]!);
|
||||||
assert.ok(phases.length >= 5, `only found ${phases.length} phases`);
|
assert.ok(phases.length >= 5, `only found ${phases.length} phases`);
|
||||||
|
|
||||||
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
|
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
||||||
for (const p of phases) {
|
for (const p of phases) {
|
||||||
assert.match(src, new RegExp(`key: '${p}'`), `no turn-chart pill for the ${p} phase`);
|
assert.match(src, new RegExp(`key: '${p}'`), `no turn-chart pill for the ${p} phase`);
|
||||||
}
|
}
|
||||||
// And every pill must carry a tooltip: an icon alone does not explain a phase.
|
// And every pill must carry a tooltip: an icon alone does not explain a phase.
|
||||||
const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: '/g)];
|
const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: ["']/g)];
|
||||||
assert.equal(tips.length, phases.length, 'a turn-chart pill has no tooltip');
|
assert.equal(tips.length, phases.length, 'a turn-chart pill has no tooltip');
|
||||||
|
|
||||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||||
|
|||||||
Reference in New Issue
Block a user