v0.1.0 — undo, the crew's reach on the board, workers on the card, and four engine bugs
Fifteen items from two playtest sessions. Three that read as drawing faults were engine bugs: cars could be added to a train that was not being made up (50 offers in 8 games), the make-up panel merged two trains and could couple a car to the wrong one, and an Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel inside a coordinate's own value range — a Mainline placement travelling as row -1, which is an ordinary district row. Trains are now drawn the way they stand: west on the left, nose toward the way the engine faces, on both the district card and the Division chip. Undo steps back through the game by replaying the save without its last intent. The switching walk keeps its rejections, so the board can say why a square is not offered. Laborers and Porters are on the card, and the rule that a district only grows outwards is finally written down. Versions start here: third digit for fixes, second for a feature set, 1.0 for a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
This commit is contained in:
+102
-38
@@ -45,7 +45,7 @@ import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts'
|
||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
||||
import { areaOf } from '../engine/apply.ts';
|
||||
import { areaOf, trainNeedingCars } from '../engine/apply.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
|
||||
export const SOLO_CONFIG: GameConfig = {
|
||||
@@ -189,7 +189,10 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
// "Pee-Dee" — a per-diem train whose consist is one caboose and nothing else — offers a
|
||||
// single button to add a caboose and no reason why, which reads as a broken game rather than
|
||||
// as the card doing exactly what it prints.
|
||||
const headed = prefix === 'newTrain.' ? consistTitle(game, options, actions) ?? title : title;
|
||||
// The tray the phase is waiting on, so the heading names the train the yard chips will load
|
||||
// rather than whichever tray happened to come first out of the map.
|
||||
const filling = prefix === 'newTrain.' ? trainNeedingCars(game.state) : null;
|
||||
const headed = filling !== null ? (consistTitle(game, filling) ?? title) : title;
|
||||
groups.push({ kind: prefix, title: headed, actions });
|
||||
}
|
||||
}
|
||||
@@ -220,7 +223,17 @@ export type Placeable = {
|
||||
spots: {
|
||||
label: string;
|
||||
index: number;
|
||||
coord: { row: number; col: number };
|
||||
/**
|
||||
* The square on the board this spot would fill, or **null** when the placement is not on the
|
||||
* board at all — ABS Signals goes out on the Mainline, and `node` names which card.
|
||||
*
|
||||
* Null rather than a stand-in coordinate. A Mainline placement used to travel as
|
||||
* `{ row: -1, col: node }`, and row −1 is an ordinary district row, so the board lit up a
|
||||
* district card for a placement that was never going there.
|
||||
*/
|
||||
coord: { row: number; col: number } | null;
|
||||
/** Division node index, for a placement out on the Mainline. */
|
||||
node?: number;
|
||||
/**
|
||||
* The rails this placement would put on the card, as `connectionsFor` codes.
|
||||
*
|
||||
@@ -288,7 +301,7 @@ export type Menu = {
|
||||
* 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;
|
||||
makeUp: { trayId: string; 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. */
|
||||
@@ -313,7 +326,13 @@ export function actionMenu(game: Game): Menu {
|
||||
spots: [],
|
||||
};
|
||||
if (!entry.spots.some((sp) => sp.label === key.spot)) {
|
||||
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord, links: key.links });
|
||||
entry.spots.push({
|
||||
label: key.spot,
|
||||
index: a.index,
|
||||
coord: key.coord,
|
||||
links: key.links,
|
||||
...(key.node === undefined ? {} : { node: key.node }),
|
||||
});
|
||||
}
|
||||
bucket.set(key.subjectKey, entry);
|
||||
placeableByTitle.set(g.title, bucket);
|
||||
@@ -364,23 +383,42 @@ export function actionMenu(game: Game): Menu {
|
||||
};
|
||||
});
|
||||
|
||||
// Making up a train. `newTrain.placeCar` carries the car; the Division Yard chip that shows that
|
||||
// car is where the click belongs.
|
||||
/**
|
||||
* MAKING UP A TRAIN — ONE TRAIN.
|
||||
*
|
||||
* `newTrain.placeCar` carries the car, and the Division Yard chip showing that car is where the
|
||||
* click belongs. But two trains can be built in the same Stage — a timetabled train and a Second
|
||||
* Section, or an Extra — and this collected every option from every tray into one panel titled
|
||||
* with whichever tray came first. Reproduced at seed 99, Day 3 Stage 12: eighteen car chips under
|
||||
* "Making up Train 8", covering two different trains.
|
||||
*
|
||||
* Worse than a wrong caption: the yard chip binds to the FIRST matching option, so clicking a
|
||||
* hopper could couple it to the other train entirely.
|
||||
*
|
||||
* So the panel is scoped to ONE tray — the one the engine is actually waiting on
|
||||
* (`trainNeedingCars`, the same predicate the New Train Phase stops for). The second train comes
|
||||
* up as soon as the first is done, which is how the phase runs anyway.
|
||||
*/
|
||||
const filling =
|
||||
trainNeedingCars(game.state) ??
|
||||
options.find((i): i is Extract<Intent, { type: 'newTrain.placeCar' | 'newTrain.passCar' }> =>
|
||||
i.type === 'newTrain.placeCar' || i.type === 'newTrain.passCar',
|
||||
)?.trayId ??
|
||||
null;
|
||||
|
||||
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;
|
||||
if (i.type === 'newTrain.placeCar' && i.trayId === filling) {
|
||||
makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
|
||||
}
|
||||
if (i.type === 'newTrain.passCar' && i.trayId === filling) pass = index;
|
||||
});
|
||||
const makeUp =
|
||||
makeUpCars.length > 0 || pass !== null
|
||||
filling !== null && (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',
|
||||
trayId: filling,
|
||||
title: consistTitle(game, filling) ?? 'Making up the train',
|
||||
cars: makeUpCars,
|
||||
pass,
|
||||
}
|
||||
@@ -397,28 +435,31 @@ function subjectOf(
|
||||
subjectKey: string;
|
||||
subject: string;
|
||||
spot: string;
|
||||
coord: { row: number; col: number };
|
||||
coord: { row: number; col: number } | null;
|
||||
node?: number;
|
||||
links: string[];
|
||||
} | null {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
||||
/**
|
||||
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
|
||||
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
|
||||
* row −1 is an ordinary district row: an enhancement laid on a real card one row below the
|
||||
* Running Track was described as being "out on the Mainline", and ABS Signals itself lit up
|
||||
* whichever district card sat at that column.
|
||||
*/
|
||||
if (i.type === 'card.play' && i.node !== undefined) {
|
||||
const node = game.state.division.nodes[i.node];
|
||||
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `Mainline card ${i.node}`;
|
||||
return {
|
||||
subjectKey: `card:${i.cardId}`,
|
||||
subject: cardName(game.state, i.cardId),
|
||||
spot: `on the ${where}, out on the Mainline`,
|
||||
coord: null,
|
||||
node: i.node,
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
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
|
||||
// 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;
|
||||
@@ -521,10 +562,8 @@ function joinsNote(
|
||||
* the consist is the reason a button is missing. Naming it turns an unexplained restriction into a
|
||||
* card the player can read.
|
||||
*/
|
||||
function consistTitle(game: Game, options: Intent[], actions: { index: number }[]): string | null {
|
||||
const first = actions.map((a) => options[a.index]).find((i) => i && 'trayId' in i);
|
||||
if (!first || !('trayId' in first)) return null;
|
||||
const tray = game.state.trays.get(first.trayId);
|
||||
function consistTitle(game: Game, trayId: string): string | null {
|
||||
const tray = game.state.trays.get(trayId);
|
||||
if (!tray) return null;
|
||||
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!p) return null;
|
||||
@@ -628,6 +667,31 @@ export function toSave(game: Game): Save {
|
||||
return { seed: game.seed, history: game.history };
|
||||
}
|
||||
|
||||
/**
|
||||
* TAKE THE LAST ACTION BACK.
|
||||
*
|
||||
* The save IS the game — a seed and the intents submitted — so undo is "replay everything except the
|
||||
* last one". That is why this is a few lines rather than a feature: there is no undo stack to keep,
|
||||
* no inverse of each action to write, and no way for it to produce a position the rules could not
|
||||
* have reached, because the position is reached by the rules.
|
||||
*
|
||||
* WHAT IT COSTS. Replaying is O(history), which at a few hundred intents is imperceptible, and the
|
||||
* whole log is rebuilt with it — so the history panel matches the board afterwards rather than still
|
||||
* describing the move that was taken back.
|
||||
*
|
||||
* WHAT IT ALLOWS, deliberately: the RNG advances with the replay, so playing the same train card
|
||||
* again rolls the same Stage — you cannot undo your way to a better die. You CAN see the roll and
|
||||
* then spend the turn differently, which is an ordinary solitaire take-back and is the reason this
|
||||
* is solitaire-only. `TODO.md` carries the open question of whether a Stage boundary should become a
|
||||
* commit point.
|
||||
*
|
||||
* Returns null when there is nothing to undo, so the caller can leave the button disabled.
|
||||
*/
|
||||
export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null {
|
||||
if (game.history.length === 0) return null;
|
||||
return fromSave({ seed: game.seed, history: game.history.slice(0, -1) }, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a game from a save.
|
||||
*
|
||||
|
||||
+95
-4
@@ -20,6 +20,7 @@ import {
|
||||
newGame,
|
||||
submit,
|
||||
toSave,
|
||||
undo,
|
||||
view,
|
||||
} from './game.ts';
|
||||
|
||||
@@ -140,8 +141,11 @@ function render(): void {
|
||||
const chosen = forPlay
|
||||
? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey)
|
||||
: undefined;
|
||||
// A spot with no coordinate is not on this board — ABS Signals goes out on the Mainline — so it
|
||||
// is chosen from the action list and lights nothing in the district.
|
||||
const spotsAt = new Map<string, { label: string; index: number }[]>();
|
||||
for (const sp of chosen?.spots ?? []) {
|
||||
if (sp.coord === null) continue;
|
||||
const key = `${sp.coord.row},${sp.coord.col}`;
|
||||
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
|
||||
}
|
||||
@@ -168,7 +172,27 @@ function render(): void {
|
||||
const [gr, gc] = k.split(',').map(Number);
|
||||
return { row: gr!, col: gc! };
|
||||
});
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords);
|
||||
/**
|
||||
* WHAT PLAYING ON AN OCCUPIED SQUARE WOULD DO.
|
||||
*
|
||||
* Three different acts light up the same blue: building on empty ground, EXTENDING the Running
|
||||
* Track at a Limits sign (the sign moves outward with the card — the only way a district grows
|
||||
* along the main), and ATTACHING an Enhancement to a card already down. The first says "place
|
||||
* here" on a ghost card; the other two said nothing at all, which is why the Depot lighting up
|
||||
* read as a bug rather than as an offer.
|
||||
*/
|
||||
const legalCaps = [...spotsAt.keys()].flatMap((k) => {
|
||||
const cell = f.cells.find((c) => `${c.row},${c.col}` === k);
|
||||
if (!cell) return [];
|
||||
const label =
|
||||
cell.kind === 'limits'
|
||||
? 'EXTEND THE RUNNING TRACK HERE'
|
||||
: cell.kind === 'office'
|
||||
? 'ATTACH TO YOUR OFFICE'
|
||||
: 'ATTACH TO THIS CARD';
|
||||
return [{ row: cell.row, col: cell.col, label }];
|
||||
});
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps);
|
||||
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
@@ -186,6 +210,37 @@ function render(): void {
|
||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SWITCHING MOVE, ON THE BOARD.
|
||||
*
|
||||
* Every switching decision is about geography — which card the crew can reach, what it will couple
|
||||
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
|
||||
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
|
||||
* months; the play page simply never used them.
|
||||
*
|
||||
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
|
||||
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
|
||||
*/
|
||||
if (f.moves && !forPlay) {
|
||||
const here = grid.querySelector(`g[data-cell="${f.moves.from.row},${f.moves.from.col}"]`);
|
||||
if (here) here.classList.add('bs-from');
|
||||
for (const c of f.moves.to) {
|
||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||
if (g) g.classList.add('bs-focus');
|
||||
}
|
||||
for (const b of f.moves.blocked) {
|
||||
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
|
||||
if (!g) continue;
|
||||
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
|
||||
// your way — you run through it — so it must not be drawn like an industry that is locked.
|
||||
const passable = b.kind === 'noStopping';
|
||||
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
|
||||
const own = g.getAttribute('data-tip') ?? '';
|
||||
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
|
||||
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
||||
}
|
||||
}
|
||||
|
||||
function pick(key: string, list: { label: string; index: number }[]): void {
|
||||
if (list.length === 1) {
|
||||
const intent = menu.options[list[0]!.index];
|
||||
@@ -372,6 +427,7 @@ function render(): void {
|
||||
|
||||
renderDistrict(f);
|
||||
renderActions(menu, f, justSet);
|
||||
renderUndo();
|
||||
|
||||
// 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.
|
||||
@@ -381,6 +437,40 @@ function render(): void {
|
||||
save();
|
||||
}
|
||||
|
||||
/**
|
||||
* UNDO, as far back as you like.
|
||||
*
|
||||
* The game is a seed and a list of intents, so stepping back is replaying without the last one —
|
||||
* see `undo()` in game.ts. The button says how many moves are behind you, because "can I still get
|
||||
* back?" is the question it exists to answer; it goes quiet when there is nothing to take back.
|
||||
*
|
||||
* The whole page is re-rendered from the rebuilt game, including the history panel, so nothing on
|
||||
* screen is left describing a move that no longer happened. Any card picked up mid-choice is
|
||||
* dropped: the position it was going to be played into may not exist any more.
|
||||
*/
|
||||
function renderUndo(): void {
|
||||
const btn = document.getElementById('undo') as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
const n = game.history.length;
|
||||
btn.disabled = n === 0;
|
||||
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
|
||||
btn.onclick = () => {
|
||||
const back = undo(game);
|
||||
if (!back) return;
|
||||
game = back;
|
||||
selected = null;
|
||||
mode = null;
|
||||
pendingAt = null;
|
||||
// The rebuilt game replays its own cues from the beginning; none of them are news.
|
||||
game.cues.length = 0;
|
||||
game.scheduled = null;
|
||||
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
|
||||
// different phase is not that event, so the banner is suppressed rather than fired backwards.
|
||||
lastPhase = null;
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The two yards, and how close the Division Yard is to running out.
|
||||
*
|
||||
@@ -585,7 +675,7 @@ function renderActions(
|
||||
{
|
||||
// Once a square is picked, show only that square's rotations — the rest is noise.
|
||||
const shown = pendingAt
|
||||
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
||||
? item.spots.filter((sp) => sp.coord !== null && `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
||||
: item.spots;
|
||||
html +=
|
||||
`<div class="spots"><div class="dim">` +
|
||||
@@ -691,8 +781,9 @@ if (saveBtn) saveBtn.onclick = downloadSave;
|
||||
*
|
||||
* The only way out used to be finishing the game — `start()` restores from localStorage on every
|
||||
* load, so a game you no longer wanted followed you across reloads, and the "new game" button
|
||||
* appeared solely on the game-over screen. Confirmed because the save is the game: there is no undo,
|
||||
* and the replay download is right beside it.
|
||||
* appeared solely on the game-over screen. Confirmed because it throws the whole game away — Undo
|
||||
* steps back one action at a time, but nothing brings back a game that has been dealt over, and the
|
||||
* replay download is right beside it.
|
||||
*
|
||||
* `location.search = ''` rather than a direct re-render, so a `?seed=` in the URL goes too — leaving
|
||||
* it would deal the same game again and look like the button had done nothing.
|
||||
|
||||
+29
-1
@@ -183,7 +183,23 @@ export function facilitiesHtml(f: Frame): string {
|
||||
* 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.') +
|
||||
/**
|
||||
* PORTERS TOO, NOT JUST LABORERS.
|
||||
*
|
||||
* `porters` has been on the view-model all along and no renderer ever printed it — so a
|
||||
* Waiting Area, Restaurant or Hotel beside the Office, whose whole effect is +1 porter
|
||||
* and +1 passenger slot, showed exactly half of what it did. Reported: "it gave me an
|
||||
* extra porter that I see no indication of anywhere."
|
||||
*
|
||||
* A facility has one kind of worker or the other: Laborers work freight, Porters work
|
||||
* passengers. Showing the row that is always 0/0 would be noise, so each shows its own.
|
||||
*/
|
||||
(Number(x.laborers.split('/').pop()) > 0 || x.base.laborers > 0
|
||||
? stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square. Left of the slash is how many are still free this Stage.')
|
||||
: '') +
|
||||
(Number(x.porters.split('/').pop()) > 0 || x.base.porters > 0
|
||||
? stat('porters', x.porters, x.base.porters, 'Passenger work, one action each per Stage: a Porter boards or detrains passengers, and earns the Revenue in a single action. Left of the slash is how many are still free this Stage.')
|
||||
: '') +
|
||||
(x.allowsOut ? stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') : '') +
|
||||
(x.allowsIn ? stat('in', String(x.redCap), x.base.in, 'Red boxes: loads cleared off an arriving car.') : '') +
|
||||
(x.modifiers.length
|
||||
@@ -279,6 +295,18 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
@keyframes ttflash{0%{background:#2f6b47;border-color:#8fd6a0;transform:scale(1.18)}
|
||||
100%{background:#222c38;border-color:#4d6fa8;transform:scale(1)}}
|
||||
@media(prefers-reduced-motion:reduce){.tt-slot.fresh{animation:none;outline:2px solid #8fd6a0}}
|
||||
/* THE KEY TO THE FOUR STATES ABOVE. A slot can be lit blue, ringed violet, dimmed, or flashing
|
||||
green, and nothing said which was which — reported as "what do the colours mean?" The green one
|
||||
in particular is a MOMENT, not a state: it marks the slot the die just filled and is gone by the
|
||||
next render, so without a key it reads as a category of train. */
|
||||
.tt-key{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px;font-size:10px;color:#8b94a3}
|
||||
.tt-key span{display:inline-flex;align-items:center;gap:4px}
|
||||
.tt-key span::before{content:"";width:9px;height:9px;border-radius:2px;border:1px solid #2c333d;
|
||||
background:#1a1f26}
|
||||
.tt-key .k-due::before{background:#222c38;border-color:#4d6fa8}
|
||||
.tt-key .k-now::before{border-color:#b98cf0;box-shadow:0 0 0 1px rgba(150,110,230,.35)}
|
||||
.tt-key .k-past::before{opacity:.45}
|
||||
.tt-key .k-fresh::before{background:#2f6b47;border-color:#8fd6a0}
|
||||
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}
|
||||
|
||||
+38
-2
@@ -16,11 +16,20 @@ h2.big{font-size:15px;text-transform:none;letter-spacing:.01em;color:#cfe0f5;
|
||||
margin:0 0 9px;padding-bottom:5px;border-bottom:1px solid #2c333d}
|
||||
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
|
||||
.dim{color:var(--dim)}
|
||||
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
|
||||
/* THE TOP TWO ROWS TRAVEL TOGETHER.
|
||||
The title bar was sticky and the turn chart under it was not, so scrolling down took away the one
|
||||
thing you most need while reading the board — which Day and Stage it is, which phase is running,
|
||||
and who it is waiting on. They are one block now, and it sticks as one. */
|
||||
.topbar{position:sticky;top:0;z-index:5}
|
||||
header{background:var(--panel);border-bottom:1px solid var(--line);
|
||||
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
|
||||
header b{font-size:16px}
|
||||
header button{background:#2a3038;color:inherit;border:1px solid #2c333d;border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px}
|
||||
header button:hover{border-color:#4d6fa8}
|
||||
/* Nothing to take back yet. It keeps its place — a button that disappears reads as a bug — but it
|
||||
stops inviting the press. */
|
||||
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
|
||||
header button:disabled:hover{border-color:#2c333d}
|
||||
.build{margin-left:auto;font-size:11px;opacity:.7}
|
||||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||||
.home:hover{color:#5aa9e6}
|
||||
@@ -106,6 +115,12 @@ button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size
|
||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
#district.folded #grid{display:none}
|
||||
#district.folded .districtrule{display:none}
|
||||
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
||||
and never again, so it must not shout. */
|
||||
.districtrule{font-size:11px;line-height:1.4;color:#8b94a3;margin:0 0 7px;padding:5px 8px;
|
||||
border-left:2px solid #39424e;background:#171c23;border-radius:0 4px 4px 0}
|
||||
.districtrule b{color:#cfd6e0}
|
||||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||
#districtsummary{display:none}
|
||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||
@@ -142,6 +157,7 @@ ul.blocked li{padding:2px 0}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<header>
|
||||
<b><a href="./index.html" class="home">Station Master</a></b>
|
||||
<span>Revenue <b id="revenue">0</b></span>
|
||||
@@ -149,8 +165,9 @@ ul.blocked li{padding:2px 0}
|
||||
<span class="dim">seed <span id="seed">—</span></span>
|
||||
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
|
||||
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Forget the game saved in this browser and deal a fresh one. There is no undo — download the replay first if you want to keep it.">New game</button>
|
||||
<button id="newgame" title="Forget the game saved in this browser and deal a fresh one. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
|
||||
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||
</header>
|
||||
@@ -160,6 +177,7 @@ ul.blocked li{padding:2px 0}
|
||||
<!-- Filled by `turnChartHtml`, shared with both replay viewers so all three screens report where
|
||||
you are in the Day identically. -->
|
||||
<div id="turnchart"></div>
|
||||
</div><!-- /topbar -->
|
||||
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
|
||||
what you miss when the automatic phases run between two clicks. -->
|
||||
<div id="phasenote"></div>
|
||||
@@ -173,6 +191,15 @@ ul.blocked li{padding:2px 0}
|
||||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
||||
</h2>
|
||||
<div id="districtsummary" class="dim"></div>
|
||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||
It is enforced everywhere and was stated nowhere: the Limits signs are the only growth
|
||||
point on the Running Track and they move outward as you build, and no card anywhere can
|
||||
be inserted between two cards already down. -->
|
||||
<div class="districtrule">A district only ever grows <b>outwards</b>. On the Running Track the
|
||||
Limits signs are the growth point — lay a card on a sign and the sign moves one further out;
|
||||
nothing may be built beyond it. Everywhere else a card needs an empty square that its rails
|
||||
reach. <b>No card is ever inserted between two cards already down</b>, so nothing you have
|
||||
built is pushed along by something laid later.</div>
|
||||
<div id="grid"></div>
|
||||
</section>
|
||||
<section><h2>History</h2><div id="log"></div></section>
|
||||
@@ -192,6 +219,15 @@ ul.blocked li{padding:2px 0}
|
||||
<div class="dim" id="yardnote"></div>
|
||||
</section>
|
||||
<section><h2>Timetable — who is due out, and when</h2><div id="timetable"></div>
|
||||
<!-- WHAT THE COLOURS MEAN. Asked directly in playtesting — "what do green and purple mean?"
|
||||
Violet is "where you are" everywhere on this page, and the green is a flash rather than a
|
||||
state, which is exactly why it needed saying. -->
|
||||
<div class="tt-key">
|
||||
<span class="k-due">Train due</span>
|
||||
<span class="k-now">this Stage</span>
|
||||
<span class="k-past">already gone</span>
|
||||
<span class="k-fresh">just rolled</span>
|
||||
</div>
|
||||
<div class="dim" style="font-size:11px;margin-top:5px">A train's number is its seniority and direction — odd runs west, even runs east. The Stage it departs is set by a 1D12 roll when its card is played, and it then runs at that time every Day.</div>
|
||||
</section>
|
||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||
|
||||
Reference in New Issue
Block a user