Say who is playing, what you can press, and where you are in the Stage

Mostly presentation, plus one rules fix found on the way.
This commit is contained in:
Jesse
2026-08-04 08:37:42 -04:00
parent 982952fd6c
commit ec792dfc15
12 changed files with 392 additions and 62 deletions
+15
View File
@@ -93,6 +93,21 @@ target is settled and freight carries its intended share.
## Not yet built
- [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom,
Teams or Jitsi call and post into its chat, it could carry the whole table's shared state
without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone
is holding the game up — "Now waiting on player Alice to complete the Cargo phase."
- Further out, audio into the same call: a crash when a collision happens, a bell as the Stage
clock turns over.
- Further out still, a nudge on a timer — if a player has not moved within some interval, the
game says so, by beep or by spoken line: "Still waiting on Alice to complete the Cargo
phase." That turns the turn chart's "waiting on" chip into something a distracted table
actually notices.
- [ ] **Multiplayer train make-up is a round, not one player's job.** When a new train is built,
players take turns adding cars to the consist; in solitaire one player does all of it. The
engine currently has no per-player turn within the New Train phase, so this is unbuilt rather
than wrong.
- [ ] **Action cards (10) and Space-use cards (12).** Genuinely multiplayer-only — they are played AT
an opponent. Rejected with `NOT_IMPLEMENTED`.
- [ ] **Multiplayer proper.** The engine runs 2–5 player games and the bot plays them, but there is no
+12 -2
View File
@@ -19,6 +19,7 @@
* FB_URL default https://phoenix.local:58157
* FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
* FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host)
* SITE_URL default https://65.78.82.12:54697/ — the public address Start9 Pages serves at
* --dry-run list what would be sent, contact nothing
*/
@@ -28,6 +29,15 @@ import { dirname, join, posix, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
/**
* Where Start9 Pages actually publishes the site — the address a player types, as opposed to the
* File Browser folder the files are uploaded INTO. The two are unrelated and the deploy output used
* to print only the second, which is the one nobody wants.
*
* Provisional: this is the playtesting host and it will change. Override with SITE_URL.
*/
const SITE_URL = process.env['SITE_URL'] ?? 'https://65.78.82.12:54697/';
const dist = join(root, 'dist');
const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replace(/\/+$/, '');
@@ -150,6 +160,6 @@ if (DRY) {
console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
}
console.log(`\ndeployed to ${URL_BASE}${DEST}`);
console.log('Start9 Pages serves these files as the site; File Browser is only the store.');
console.log(`\nDeployed to FileBrowser at: ${URL_BASE}${DEST}`);
console.log(`Start9 Pages serves this website at: ${SITE_URL}`);
}
+24 -6
View File
@@ -604,9 +604,9 @@ function checkPlay(
case 'modifier': {
if (!placement) return 'NO_PLACEMENT';
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
// §9 — a Modifier is not track. It must sit adjacent to a Facility (one of the nine nearby
// spots) or it does nothing at all, so placing it anywhere else is not a legal play.
return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
// the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play.
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
}
case 'enhancement': {
if (!placement) return 'NO_PLACEMENT';
@@ -1423,15 +1423,33 @@ function buildFreightFacility(kind: FreightKind): Facility {
* Facility found instead. Modelling the per-Stage choice needs an extra decision point in the
* Load/Unload phase and is not worth it until the mechanic has been played.
*/
function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord | null {
/**
* The neighbouring Facility a Modifier would attach to — and, when a Modifier is named, only one it
* is actually ALLOWED to attach to.
*
* Every Modifier prints its host: a Waiting Area, a Restaurant and a Hotel go beside a Passenger
* Facility, Forklifts beside a Freight House or Packing Sheds, and so on. That was not checked. Any
* square touching ANY facility was offered — so a Waiting Area was legal beside a Mine Tipple and
* `applyModifier` then handed its extra Porter to whichever facility the scan reached first, which
* could be a different one again. The player saw three legal spots for a card that has one.
*/
function adjacentFacilityCoord(
area: OfficeArea,
coord: GridCoord,
modifier?: ModifierKind,
): GridCoord | null {
// Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six.
if (coord.row > area.runningRow) return null;
const hosts = modifier ? modifierProfile(modifier).hosts : null;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const c = { row: coord.row + dr, col: coord.col + dc };
if (c.row > area.runningRow) continue;
if (area.grid.get(coordKey(c))?.facility) return c;
const f = area.grid.get(coordKey(c))?.facility;
if (!f) continue;
if (hosts && !hosts.includes(f.subtype)) continue;
return c;
}
}
return null;
@@ -1583,7 +1601,7 @@ export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
const target = adjacentFacilityCoord(area, coord);
const target = adjacentFacilityCoord(area, coord, modifier);
if (!target) return;
const f = area.grid.get(coordKey(target))?.facility;
if (!f) return;
+6 -4
View File
@@ -59,9 +59,9 @@ export function phaseLabel(phase: string): string {
case 'mainline':
return 'Mainline';
case 'loadUnload':
return 'Load / Unload';
return 'Cargo';
case 'shiftChange':
return 'Shift Change';
return 'Supervisor Shift';
default:
return phase;
}
@@ -74,7 +74,7 @@ export function phaseLabel(phase: string): string {
export type Narration = {
text: string;
/** Colours the line in the viewer. */
tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet';
tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet' | 'phase';
/** Board cell to highlight, if the event happened somewhere. */
where?: GridCoord;
};
@@ -104,7 +104,9 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
case 'stageBegan':
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
case 'phaseBegan':
return { tone: 'quiet', text: `${phaseLabel(e.phase)}` };
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
// the log read as one undifferentiated column and you could not see where a phase began.
return { tone: 'phase', text: `▸ ${phaseLabel(e.phase)} phase` };
case 'actorChanged':
return {
tone: 'quiet',
+6 -3
View File
@@ -111,6 +111,8 @@ export type Frame = {
stage: number;
clock: string;
phase: string;
/** The raw phase, so a caller can mark WHICH of the five is current without parsing the label. */
phaseKey: string;
actor: number | null;
superintendent: number;
revenue: number;
@@ -332,11 +334,11 @@ export function describeIntent(s: GameState, i: Intent): string {
case 'draw.fromHomeOffice':
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
case 'draw.end':
return 'done drawing — end my turn';
return 'End my turn';
case 'switch.end':
return 'done switching — end my turn';
return 'End my switching turn';
case 'loadUnload.end':
return 'done working — end the Load/Unload phase';
return 'End my Cargo phase';
case 'newTrain.passCar':
return 'add no more cars to this train';
case 'newTrain.secondSection':
@@ -459,6 +461,7 @@ export function snapshot(
stage: s.clock.stage,
clock: clockTime(s.clock.stage),
phase: phaseLabel(s.clock.phase),
phaseKey: s.clock.phase,
actor: s.clock.currentActor,
superintendent: s.clock.superintendent,
revenue: s.players[0]?.revenue ?? 0,
+44 -7
View File
@@ -70,6 +70,15 @@ export type Game = {
history: Intent[];
/** Narrated lines, newest last. */
log: { text: string; tone: string }[];
/**
* Set when a card has been drawn this turn and not yet played or discarded.
*
* §6.2 makes you reduce your hand before the turn ends; this is that requirement made visible,
* as a disabled button rather than a rejection after the click. UI state deliberately — putting
* it in the engine would change what the bot may do, and with it every balance figure measured
* against the current rules.
*/
mustPlayCard: boolean;
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -77,8 +86,14 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
{ prefix: 'switch.', title: 'Switching' },
{ prefix: 'draw.', title: 'Draw' },
{ prefix: 'card.', title: 'Cards' },
// Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the
// three are different acts. Taking a face-up Department card is not the same decision as
// gambling on the Home Office deck, and neither is ending the turn.
{ prefix: 'draw.fromHomeOffice', title: 'Draw a card from the Home Office deck' },
{ prefix: 'draw.fromDepartment', title: 'Take a Department card (face up)' },
{ prefix: 'card.play', title: 'Play a card from my hand' },
{ prefix: 'card.discard', title: 'Discard a card from my hand' },
{ prefix: 'draw.end', title: 'Finish' },
{ prefix: 'track.lay', title: 'Lay track from your supply' },
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
// American spelling throughout, to match MANEUVER_CARDS and the source deck.
@@ -91,9 +106,19 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
{ prefix: 'redFlag.', title: 'Red flag' },
];
/**
* The solitaire seat still has a NAME, because the history reads "Player Solitaire chose…" and a
* log that says "You" cannot be read back by anyone else — a save is meant to be sent around.
*/
export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: ['You'] });
const game: Game = { state, seed, history: [], log: [] };
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
game.log.push({ text: `Solitaire · one player · seed ${seed}`, tone: 'quiet' });
drain(game);
return game;
}
@@ -261,7 +286,14 @@ export function submit(game: Game, intent: Intent): boolean {
return false;
}
game.history.push(intent);
record(game, result.events);
if (intent.type === 'draw.fromHomeOffice' || intent.type === 'draw.fromDepartment') {
game.mustPlayCard = true;
} else if (intent.type === 'card.play' || intent.type === 'card.discard' || intent.type === 'localOps.choose') {
// Choosing an option starts a fresh turn — and Freight Agent and Switching never draw at all,
// so nothing can be left outstanding from them.
game.mustPlayCard = false;
}
record(game, result.events, actor);
drain(game);
return true;
}
@@ -290,7 +322,8 @@ export function view(game: Game): Frame {
return snapshot(game.state, [], null);
}
function record(game: Game, events: GameEvent[]): void {
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
for (const e of events) {
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
if (e.type === 'actorChanged') continue;
@@ -298,7 +331,11 @@ function record(game: Game, events: GameEvent[]): void {
cardName: (id) => cardName(game.state, id),
trainName: (id) => trainName(game.state, id),
});
game.log.push({ text: n.text, tone: n.tone });
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
// one seat. Only events the player caused are attributed; the Division running itself is not.
const mine = who !== null && 'player' in e;
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
game.log.push({ text, tone: mine ? 'act' : n.tone });
}
// Keep the log bounded; the full history lives in `history` and can be replayed.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
+12 -9
View File
@@ -33,7 +33,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
<body>
<main>
<h1>Station Master</h1>
<p class="tag">Timetable-and-train-order railroading, 1840&ndash;1950.</p>
<p class="tag">TimeTable and Train Order (TT&amp;TO) railroading, 1840&ndash;1950.</p>
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
@@ -42,24 +42,27 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
</svg>
<p class="blurb">
You run a division: a Running Track between your Limits, a district of industries hanging beneath
it, and trains that arrive whether or not you are ready for them. Spot the right cars, work the
loads, and get every train away again &mdash; a train with nowhere to stand is a collision, and a
As Station Master in your line-side office, you manage the town's railroad connection. You are
responsible for all track between your Limits, a district of industries hanging beneath it, and
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
and get every train away again &mdash; a train with nowhere to stand is a collision, and a
collision costs more than the freight was worth.
</p>
<div class="doors">
<a class="door" href="./play.html">
<h2>Play solitaire</h2>
<p>Five Days, twelve Stages each, and a target of 20 Revenue. Your game saves in this browser,
so you can close the tab and come back to it.</p>
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
Your game data is saved in your browser &mdash; if you close the tab and reopen this site
without clearing your cache, your game is preserved and you can continue automatically.
During the game you can also explicitly save your progress for later replay.</p>
<span class="go">Start a game &rarr;</span>
</a>
<a class="door" href="./replays.html">
<h2>Replays</h2>
<p>Watch a finished game back, Stage by Stage. Open one saved on this site, or a save file
somebody has sent you.</p>
<p>Replay a saved game, step by step or advancing automatically through phases, Stages and
Days. Open one saved on the site, or a saved file that someone has sent you.</p>
<span class="go">Browse replays &rarr;</span>
</a>
</div>
@@ -68,7 +71,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
<footer>
<span>build <span id="build">__BUILD__</span></span>
<span>runs entirely in your browser &mdash; nothing is sent anywhere</span>
<span>runs entirely in your browser &mdash; no server code required</span>
</footer>
</main>
+94 -7
View File
@@ -38,6 +38,76 @@ const esc = (s: string): string =>
// ---------------------------------------------------------------------------
/**
* 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
* player could not see what had already happened this Stage or what was coming. The printed game
* 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 {
$('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);
$('tc-who').textContent = actorName ?? 'nobody — the Division is running itself';
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 {
const params = new URLSearchParams(location.search);
const requested = params.get('seed');
@@ -66,8 +136,7 @@ function render(): void {
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
}
$('clock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
$('phase').textContent = f.phase;
renderTurnChart(f);
$('revenue').textContent = String(f.revenue);
const obj = $('objective');
obj.textContent = f.objective.note;
@@ -150,16 +219,19 @@ function render(): void {
// 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.
// 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.
const cardRow = (name: string, why: string, playable: boolean | null): string =>
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b>` +
(playable === false ? ' <span class="tag">not yet</span>' : '') +
`</div>`;
`<b>${esc(name)}</b></div>`;
const canPlay = handPlayable(game);
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
: '<span class="dim">empty</span>';
// Reference, not choices: the Department cards are three FACE-UP CARDS, not three decks — there
// is nothing underneath them. Drawn like the hand so they read as cards, dashed and unlit so they
// do not read as clickable.
$('depts').innerHTML = f.departments.length
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
: '<span class="dim">none</span>';
@@ -243,11 +315,26 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
);
};
let html = menu.direct
// 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
// no worker able to act, the only thing offered is "End my Cargo phase".
let html = '<h3 class="actions-hd">Actions</h3>';
html += menu.direct
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
g.actions.map((a) => actionButton(a.label, a.index)).join('') +
g.actions
.map((a) => {
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
// the INTENT, not the label: matching button text would break the moment the wording
// changed, and would have caught `switch.end` too.
const blocked = menu.options[a.index]?.type === 'draw.end' && game.mustPlayCard;
return blocked
? `<button class="act blocked" disabled data-tip="You have drawn a card. Play it or discard it to a Department slot before ending your turn — §6.2 holds you to three cards.">${esc(a.label)} — play or discard first</button>`
: actionButton(a.label, a.index);
})
.join('') +
`</div>`,
)
.join('');
+82 -14
View File
@@ -5,13 +5,15 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — play</title>
<style>
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250}
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250;--now:#b98cf0}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
h1{font-size:17px;margin:0}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);
margin:0 0 6px;font-weight:600}
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);
@@ -25,12 +27,21 @@ header button:hover{border-color:#4d6fa8}
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
.pace.good{background:rgba(40,140,60,.32)}
.pace.behind{background:rgba(190,120,40,.28)}
.handcard{border-top:1px solid var(--line);padding:4px 0}
.handcard:first-child{border-top:0}
.handcard{padding:3px 0}
.handcard:focus{outline:1px solid #4d6fa8;border-radius:3px}
.handcard.unplayable{opacity:.55}
.tag{font-size:10px;background:rgba(190,120,40,.3);border-radius:3px;padding:0 5px}
/* 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}
.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}
/* 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}
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
@@ -78,10 +89,24 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
/* actions */
#actions{max-height:none}
.grp{margin-bottom:6px}
/* THESE ARE THE THINGS YOU CAN DO. An action carried the same grey border as every other panel
on the page, so the one region that is clickable did not look it. Amber border and a lit face,
used nowhere else, so "this is a move" is answered before the label is read. */
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
button:hover{background:#39424e;border-color:#4d6fa8}
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
color:#f2e6cf;font-weight:600}
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
#actions button:focus-visible{outline:2px solid #f0b64a;outline-offset:2px}
button.act{display:inline-block}
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
so a disabled button must not wear it. */
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
color:#7d8794;box-shadow:none;cursor:not-allowed;font-weight:400}
#actions button.blocked:hover{background:#232830;border-color:#4a5361;box-shadow:none}
h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe0f5;
margin:0 0 7px;font-weight:700}
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
.over.win{background:rgba(40,140,60,.35)}
.over.loss{background:rgba(160,60,60,.3)}
@@ -95,17 +120,51 @@ button.act{display:inline-block}
#log{max-height:230px;overflow:auto;font-size:12px}
.line{padding:1px 0}
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
/* A phase marker is a HEADING inside the log, and takes the same violet the turn chart uses for
"where you are" — the two are saying the same thing in different places. `act` is what the
player did, so it stays the brightest thing in the column. */
.t-phase{color:var(--now);font-weight:700;margin-top:4px;letter-spacing:.02em}
.t-act{color:#e6e9ee}
.t-start{color:#cfe0f5;font-weight:700;font-size:13px;border-bottom:1px solid var(--line);
padding-bottom:3px;margin-bottom:3px}
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
ul.blocked li{padding:2px 0}
.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>
</head>
<body>
<header>
<b><a href="./index.html" class="home">Station Master</a></b>
<span id="clock">—</span>
<span>phase: <b id="phase">—</b></span>
<span>Revenue <b id="revenue">0</b></span>
<span id="objective" class="pace">—</span>
<span class="dim">seed <span id="seed">—</span></span>
@@ -115,6 +174,15 @@ ul.blocked li{padding:2px 0}
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
<!-- 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. -->
<div id="turnchart">
<div class="tc-when"><b id="tc-day">Day —</b><span id="tc-stage">Stage —</span><span class="dim" id="tc-clock">—</span></div>
<div class="tc-now">phase <b id="tc-phase">—</b></div>
<div class="tc-who">waiting on <b id="tc-who">—</b></div>
<ol class="tc-phases" id="tc-phases"></ol>
</div>
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></div></section>
@@ -123,12 +191,12 @@ ul.blocked li{padding:2px 0}
</div>
<div>
<section><h2>Your move</h2><div id="actions"></div></section>
<section><h2>Cards</h2>
<div><span class="dim">hand:</span> <span id="hand">—</span></div>
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
<section><h2 class="big">Your Move</h2><div id="actions"></div></section>
<section><h2 class="big">Cards Available</h2>
<h3>Cards in My Hand</h3><div id="hand" class="cardrow">—</div>
<h3>Department Cards (face up)</h3><div id="depts" class="cardrow ref">—</div>
</section>
<section><h2>Your track supply</h2><div id="supply"></div></section>
<section><h2 class="big">Your Track Supply</h2><div id="supply"></div></section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Facilities</h2><div id="facs"></div></section>
</div>
+51
View File
@@ -635,3 +635,54 @@ describe('an industry handles every commodity its profile names (regression)', (
assert.deepEqual(twoCommodity, ['powerPlant', 'grocersWarehouse']);
});
});
describe('a Modifier only goes beside a host that can use it (regression)', () => {
it('offers a Waiting Area no square that is not beside a Passenger Facility', () => {
// REGRESSION. Every Modifier prints its host — a Waiting Area, Restaurant and Hotel go beside a
// Passenger Facility; Forklifts beside a Freight House or Packing Sheds — and the placement
// check ignored all of it, asking only "does this square touch ANY facility?". A Waiting Area
// was therefore legal beside a Mine Tipple, and `applyModifier` handed its extra Porter to
// whichever facility the scan happened to reach first. The player was shown three legal spots
// for a card that has one.
const s = game();
const area = areaOf(s, 0);
// A freight industry away from the Office, and nothing else nearby.
area.grid.set(coordKey({ row: -1, col: 4 }), {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ew' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const waiting = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
);
assert.ok(waiting, 'no Waiting Area card in the deck');
const [cardId] = waiting!;
s.decks.hands.set(0, [cardId]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const spots = legalActions(s, 0).filter(
(i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined,
);
assert.ok(spots.length > 0, 'a Waiting Area has nowhere legal at all — the Office is a host');
// Every offered square must touch the Office and none may touch only the Mine Tipple.
for (const i of spots) {
const p = (i as { placement: { row: number; col: number } }).placement;
const touchesOffice =
Math.abs(p.row - area.officeCoord.row) <= 1 && Math.abs(p.col - area.officeCoord.col) <= 1;
assert.ok(
touchesOffice,
`a Waiting Area was offered (${p.row}, ${p.col}), which is not beside a Passenger Facility`,
);
}
});
});
+5 -2
View File
@@ -78,7 +78,7 @@ describe('narration', () => {
const n = narrate(e);
assert.ok(n.text.length > 3, `${e.type} produced no useful text`);
assert.ok(!/^\[/.test(n.text), `${e.type} fell through to a fallback`);
assert.ok(['plain', 'good', 'bad', 'clock', 'quiet'].includes(n.tone), `${e.type} bad tone`);
assert.ok(['plain', 'good', 'bad', 'clock', 'quiet', 'phase'].includes(n.tone), `${e.type} bad tone`);
}
});
@@ -98,8 +98,11 @@ describe('narration', () => {
});
it('names phases in words rather than identifiers', () => {
assert.equal(phaseLabel('loadUnload'), 'Load / Unload');
assert.equal(phaseLabel('loadUnload'), 'Cargo');
assert.equal(phaseLabel('newTrain'), 'New Train');
assert.equal(phaseLabel('shiftChange'), 'Supervisor Shift');
assert.equal(phaseLabel('localOps'), 'Local Operations');
assert.equal(phaseLabel('mainline'), 'Mainline');
});
it('hides only the events with nothing to show', () => {
+40 -7
View File
@@ -148,13 +148,21 @@ describe('the action menu presents choices the way they are made', () => {
// "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const draw = actionMenu(game).direct.find((g) => g.title === 'Draw');
assert.ok(draw, 'no draw group');
for (const a of draw!.actions) {
// The draw actions are split by SOURCE now — gambling on the deck, taking a named face-up
// card, and ending the turn are three different decisions — so gather every group that offers
// one rather than looking for a single group called "Draw".
const menu = actionMenu(game);
const draws = menu.direct.filter((g) =>
g.actions.some((a) => menu.options[a.index]?.type?.startsWith('draw.')),
);
assert.ok(draws.length > 0, 'no draw actions are offered at all');
for (const g of draws) {
for (const a of g.actions) {
assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
}
}
assert.ok(
draw!.actions.some((a) => /face-up slot/.test(a.label)),
draws.flatMap((g) => g.actions).some((a) => /face-up slot/.test(a.label)),
'face-up slots are not named',
);
});
@@ -743,8 +751,10 @@ describe('the static build', () => {
// STRICT: only ids that really exist in the served page. The stub used to conjure an element
// for any id asked for, so a `$('target')` left behind after removing #target from the HTML
// would pass here and throw on load in a browser — the page would simply never start.
// `[\w-]` and not `[a-zA-Z]`: a hyphenated id is valid HTML, and the narrower pattern skipped
// those silently — which would have let a genuinely MISSING `#tc-day` pass this test too.
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
(m) => m[1]!,
),
);
@@ -867,9 +877,9 @@ describe('the static build', () => {
// Cheap and total: compare every $('id') in the source against the ids in the served HTML.
// Getting this wrong does not degrade the page, it stops the game starting at all.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z]+)'\)/g)].map((m) => m[1]!));
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
const html = readFileSync(join(dist, 'play.html'), 'utf8');
const present = new Set([...html.matchAll(/id="([a-zA-Z]+)"/g)].map((m) => m[1]!));
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
// `again` is created by the game-over screen before it is looked up.
present.add('again');
for (const id of asked) {
@@ -877,6 +887,29 @@ describe('the static build', () => {
}
});
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
// exactly when the player most needs it. `Phase` is a closed union in the engine; this fails if
// one is added and the chart is not.
const engine = readFileSync(join(root, 'src/engine/state.ts'), 'utf8');
const decl = /export type Phase =([^;]+);/.exec(engine);
assert.ok(decl, 'the Phase union moved — this test cannot see it any more');
const phases = [...decl![1]!.matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]!);
assert.ok(phases.length >= 5, `only found ${phases.length} phases`);
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
for (const p of phases) {
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.
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');
const html = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(html, /id="turnchart"/, 'the page has no turn chart');
assert.doesNotMatch(html, /phase: <b id="phase">/, 'the old title-bar phase text is still there');
});
it('serves all three pages, each stamped and cache-busted', () => {
// The splash is the front door now; the game and the replay directory are separate pages.
for (const [name, entry] of [