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:
@@ -93,6 +93,21 @@ target is settled and freight carries its intended share.
|
|||||||
|
|
||||||
## Not yet built
|
## 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
|
- [ ] **Action cards (10) and Space-use cards (12).** Genuinely multiplayer-only — they are played AT
|
||||||
an opponent. Rejected with `NOT_IMPLEMENTED`.
|
an opponent. Rejected with `NOT_IMPLEMENTED`.
|
||||||
- [ ] **Multiplayer proper.** The engine runs 2–5 player games and the bot plays them, but there is no
|
- [ ] **Multiplayer proper.** The engine runs 2–5 player games and the bot plays them, but there is no
|
||||||
|
|||||||
+12
-2
@@ -19,6 +19,7 @@
|
|||||||
* FB_URL default https://phoenix.local:58157
|
* FB_URL default https://phoenix.local:58157
|
||||||
* FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
|
* 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)
|
* 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
|
* --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';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = join(dirname(fileURLToPath(import.meta.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 dist = join(root, 'dist');
|
||||||
|
|
||||||
const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replace(/\/+$/, '');
|
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(` [${String(done).padStart(2)}/${files.length}] ${f}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\ndeployed to ${URL_BASE}${DEST}`);
|
console.log(`\nDeployed to FileBrowser at: ${URL_BASE}${DEST}`);
|
||||||
console.log('Start9 Pages serves these files as the site; File Browser is only the store.');
|
console.log(`Start9 Pages serves this website at: ${SITE_URL}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-6
@@ -604,9 +604,9 @@ function checkPlay(
|
|||||||
case 'modifier': {
|
case 'modifier': {
|
||||||
if (!placement) return 'NO_PLACEMENT';
|
if (!placement) return 'NO_PLACEMENT';
|
||||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
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
|
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||||
// spots) or it does nothing at all, so placing it anywhere else is not a legal play.
|
// the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play.
|
||||||
return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
|
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
|
||||||
}
|
}
|
||||||
case 'enhancement': {
|
case 'enhancement': {
|
||||||
if (!placement) return 'NO_PLACEMENT';
|
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
|
* 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.
|
* 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.
|
// Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six.
|
||||||
if (coord.row > area.runningRow) return null;
|
if (coord.row > area.runningRow) return null;
|
||||||
|
const hosts = modifier ? modifierProfile(modifier).hosts : null;
|
||||||
for (let dr = -1; dr <= 1; dr++) {
|
for (let dr = -1; dr <= 1; dr++) {
|
||||||
for (let dc = -1; dc <= 1; dc++) {
|
for (let dc = -1; dc <= 1; dc++) {
|
||||||
if (dr === 0 && dc === 0) continue;
|
if (dr === 0 && dc === 0) continue;
|
||||||
const c = { row: coord.row + dr, col: coord.col + dc };
|
const c = { row: coord.row + dr, col: coord.col + dc };
|
||||||
if (c.row > area.runningRow) continue;
|
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;
|
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). */
|
/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */
|
||||||
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
|
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
|
||||||
const target = adjacentFacilityCoord(area, coord);
|
const target = adjacentFacilityCoord(area, coord, modifier);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const f = area.grid.get(coordKey(target))?.facility;
|
const f = area.grid.get(coordKey(target))?.facility;
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
|
|||||||
+6
-4
@@ -59,9 +59,9 @@ export function phaseLabel(phase: string): string {
|
|||||||
case 'mainline':
|
case 'mainline':
|
||||||
return 'Mainline';
|
return 'Mainline';
|
||||||
case 'loadUnload':
|
case 'loadUnload':
|
||||||
return 'Load / Unload';
|
return 'Cargo';
|
||||||
case 'shiftChange':
|
case 'shiftChange':
|
||||||
return 'Shift Change';
|
return 'Supervisor Shift';
|
||||||
default:
|
default:
|
||||||
return phase;
|
return phase;
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ export function phaseLabel(phase: string): string {
|
|||||||
export type Narration = {
|
export type Narration = {
|
||||||
text: string;
|
text: string;
|
||||||
/** Colours the line in the viewer. */
|
/** 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. */
|
/** Board cell to highlight, if the event happened somewhere. */
|
||||||
where?: GridCoord;
|
where?: GridCoord;
|
||||||
};
|
};
|
||||||
@@ -104,7 +104,9 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
case 'stageBegan':
|
case 'stageBegan':
|
||||||
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
|
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
|
||||||
case 'phaseBegan':
|
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':
|
case 'actorChanged':
|
||||||
return {
|
return {
|
||||||
tone: 'quiet',
|
tone: 'quiet',
|
||||||
|
|||||||
+6
-3
@@ -111,6 +111,8 @@ export type Frame = {
|
|||||||
stage: number;
|
stage: number;
|
||||||
clock: string;
|
clock: string;
|
||||||
phase: 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;
|
actor: number | null;
|
||||||
superintendent: number;
|
superintendent: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
@@ -332,11 +334,11 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
case 'draw.fromHomeOffice':
|
case 'draw.fromHomeOffice':
|
||||||
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
|
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
|
||||||
case 'draw.end':
|
case 'draw.end':
|
||||||
return 'done drawing — end my turn';
|
return 'End my turn';
|
||||||
case 'switch.end':
|
case 'switch.end':
|
||||||
return 'done switching — end my turn';
|
return 'End my switching turn';
|
||||||
case 'loadUnload.end':
|
case 'loadUnload.end':
|
||||||
return 'done working — end the Load/Unload phase';
|
return 'End my Cargo phase';
|
||||||
case 'newTrain.passCar':
|
case 'newTrain.passCar':
|
||||||
return 'add no more cars to this train';
|
return 'add no more cars to this train';
|
||||||
case 'newTrain.secondSection':
|
case 'newTrain.secondSection':
|
||||||
@@ -459,6 +461,7 @@ export function snapshot(
|
|||||||
stage: s.clock.stage,
|
stage: s.clock.stage,
|
||||||
clock: clockTime(s.clock.stage),
|
clock: clockTime(s.clock.stage),
|
||||||
phase: phaseLabel(s.clock.phase),
|
phase: phaseLabel(s.clock.phase),
|
||||||
|
phaseKey: s.clock.phase,
|
||||||
actor: s.clock.currentActor,
|
actor: s.clock.currentActor,
|
||||||
superintendent: s.clock.superintendent,
|
superintendent: s.clock.superintendent,
|
||||||
revenue: s.players[0]?.revenue ?? 0,
|
revenue: s.players[0]?.revenue ?? 0,
|
||||||
|
|||||||
+44
-7
@@ -70,6 +70,15 @@ export type Game = {
|
|||||||
history: Intent[];
|
history: Intent[];
|
||||||
/** Narrated lines, newest last. */
|
/** Narrated lines, newest last. */
|
||||||
log: { text: string; tone: string }[];
|
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. */
|
/** 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: 'mainline.clearance', title: 'Superintendent — rule on this train' },
|
||||||
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
|
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
|
||||||
{ prefix: 'switch.', title: 'Switching' },
|
{ prefix: 'switch.', title: 'Switching' },
|
||||||
{ prefix: 'draw.', title: 'Draw' },
|
// Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the
|
||||||
{ prefix: 'card.', title: 'Cards' },
|
// 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: 'track.lay', title: 'Lay track from your supply' },
|
||||||
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
|
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
|
||||||
// American spelling throughout, to match MANEUVER_CARDS and the source deck.
|
// 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' },
|
{ 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 {
|
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: ['You'] });
|
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||||||
const game: Game = { state, seed, history: [], log: [] };
|
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);
|
drain(game);
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
@@ -261,7 +286,14 @@ export function submit(game: Game, intent: Intent): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
game.history.push(intent);
|
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);
|
drain(game);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -290,7 +322,8 @@ export function view(game: Game): Frame {
|
|||||||
return snapshot(game.state, [], null);
|
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) {
|
for (const e of events) {
|
||||||
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
|
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
|
||||||
if (e.type === 'actorChanged') continue;
|
if (e.type === 'actorChanged') continue;
|
||||||
@@ -298,7 +331,11 @@ function record(game: Game, events: GameEvent[]): void {
|
|||||||
cardName: (id) => cardName(game.state, id),
|
cardName: (id) => cardName(game.state, id),
|
||||||
trainName: (id) => trainName(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.
|
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||||
|
|||||||
+12
-9
@@ -33,7 +33,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
|||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Station Master</h1>
|
<h1>Station Master</h1>
|
||||||
<p class="tag">Timetable-and-train-order railroading, 1840–1950.</p>
|
<p class="tag">TimeTable and Train Order (TT&TO) railroading, 1840–1950.</p>
|
||||||
|
|
||||||
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
||||||
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
<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>
|
</svg>
|
||||||
|
|
||||||
<p class="blurb">
|
<p class="blurb">
|
||||||
You run a division: a Running Track between your Limits, a district of industries hanging beneath
|
As Station Master in your line-side office, you manage the town's railroad connection. You are
|
||||||
it, and trains that arrive whether or not you are ready for them. Spot the right cars, work the
|
responsible for all track between your Limits, a district of industries hanging beneath it, and
|
||||||
loads, and get every train away again — a train with nowhere to stand is a collision, and a
|
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
|
||||||
|
and get every train away again — a train with nowhere to stand is a collision, and a
|
||||||
collision costs more than the freight was worth.
|
collision costs more than the freight was worth.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="doors">
|
<div class="doors">
|
||||||
<a class="door" href="./play.html">
|
<a class="door" href="./play.html">
|
||||||
<h2>Play solitaire</h2>
|
<h2>Play solitaire</h2>
|
||||||
<p>Five Days, twelve Stages each, and a target of 20 Revenue. Your game saves in this browser,
|
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
||||||
so you can close the tab and come back to it.</p>
|
Your game data is saved in your browser — 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 →</span>
|
<span class="go">Start a game →</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="door" href="./replays.html">
|
<a class="door" href="./replays.html">
|
||||||
<h2>Replays</h2>
|
<h2>Replays</h2>
|
||||||
<p>Watch a finished game back, Stage by Stage. Open one saved on this site, or a save file
|
<p>Replay a saved game, step by step or advancing automatically through phases, Stages and
|
||||||
somebody has sent you.</p>
|
Days. Open one saved on the site, or a saved file that someone has sent you.</p>
|
||||||
<span class="go">Browse replays →</span>
|
<span class="go">Browse replays →</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +71,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>build <span id="build">__BUILD__</span></span>
|
<span>build <span id="build">__BUILD__</span></span>
|
||||||
<span>runs entirely in your browser — nothing is sent anywhere</span>
|
<span>runs entirely in your browser — no server code required</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+94
-7
@@ -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 {
|
function start(): void {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const requested = params.get('seed');
|
const requested = params.get('seed');
|
||||||
@@ -66,8 +136,7 @@ function render(): void {
|
|||||||
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
|
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$('clock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
|
renderTurnChart(f);
|
||||||
$('phase').textContent = f.phase;
|
|
||||||
$('revenue').textContent = String(f.revenue);
|
$('revenue').textContent = String(f.revenue);
|
||||||
const obj = $('objective');
|
const obj = $('objective');
|
||||||
obj.textContent = f.objective.note;
|
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 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.
|
||||||
const cardRow = (name: string, why: string, playable: boolean | null): string =>
|
const cardRow = (name: string, why: string, playable: boolean | null): string =>
|
||||||
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
|
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
|
||||||
`<b>${esc(name)}</b>` +
|
`<b>${esc(name)}</b></div>`;
|
||||||
(playable === false ? ' <span class="tag">not yet</span>' : '') +
|
|
||||||
`</div>`;
|
|
||||||
|
|
||||||
const canPlay = handPlayable(game);
|
const canPlay = handPlayable(game);
|
||||||
$('hand').innerHTML = f.hand.length
|
$('hand').innerHTML = f.hand.length
|
||||||
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
|
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
|
||||||
: '<span class="dim">empty</span>';
|
: '<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
|
$('depts').innerHTML = f.departments.length
|
||||||
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
|
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
|
||||||
: '<span class="dim">none</span>';
|
: '<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(
|
.map(
|
||||||
(g) =>
|
(g) =>
|
||||||
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
`<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>`,
|
`</div>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|||||||
+82
-14
@@ -5,13 +5,15 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Station Master — play</title>
|
<title>Station Master — play</title>
|
||||||
<style>
|
<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}
|
*{box-sizing:border-box}
|
||||||
body{margin:0;background:var(--bg);color:var(--fg);
|
body{margin:0;background:var(--bg);color:var(--fg);
|
||||||
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||||
h1{font-size:17px;margin:0}
|
h1{font-size:17px;margin:0}
|
||||||
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);
|
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);
|
||||||
margin:0 0 6px;font-weight:600}
|
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}
|
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
|
||||||
.dim{color:var(--dim)}
|
.dim{color:var(--dim)}
|
||||||
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
|
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{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)}
|
||||||
.handcard{border-top:1px solid var(--line);padding:4px 0}
|
/* CARDS YOU HOLD vs CARDS YOU READ.
|
||||||
.handcard:first-child{border-top:0}
|
Both are drawn as cards, because they are cards. What separates them is the frame: a card in
|
||||||
.handcard{padding:3px 0}
|
Your Move is clickable and carries the action border; a reference card is flat, unlit and
|
||||||
.handcard:focus{outline:1px solid #4d6fa8;border-radius:3px}
|
explicitly not a target. Previously the hand was a list of bare names and the Department slots a
|
||||||
.handcard.unplayable{opacity:.55}
|
second list, so nothing looked like a card at all. */
|
||||||
.tag{font-size:10px;background:rgba(190,120,40,.3);border-radius:3px;padding:0 5px}
|
.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}
|
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;
|
||||||
@@ -78,10 +89,24 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
|||||||
/* actions */
|
/* actions */
|
||||||
#actions{max-height:none}
|
#actions{max-height:none}
|
||||||
.grp{margin-bottom:6px}
|
.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;
|
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}
|
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}
|
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}
|
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{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
|
||||||
.over.win{background:rgba(40,140,60,.35)}
|
.over.win{background:rgba(40,140,60,.35)}
|
||||||
.over.loss{background:rgba(160,60,60,.3)}
|
.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}
|
#log{max-height:230px;overflow:auto;font-size:12px}
|
||||||
.line{padding:1px 0}
|
.line{padding:1px 0}
|
||||||
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
|
.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{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>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<b><a href="./index.html" class="home">Station Master</a></b>
|
<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>Revenue <b id="revenue">0</b></span>
|
||||||
<span id="objective" class="pace">—</span>
|
<span id="objective" class="pace">—</span>
|
||||||
<span class="dim">seed <span id="seed">—</span></span>
|
<span class="dim">seed <span id="seed">—</span></span>
|
||||||
@@ -115,6 +174,15 @@ ul.blocked li{padding:2px 0}
|
|||||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||||
</header>
|
</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>
|
<main>
|
||||||
<div>
|
<div>
|
||||||
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
||||||
@@ -123,12 +191,12 @@ ul.blocked li{padding:2px 0}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<section><h2>Your move</h2><div id="actions"></div></section>
|
<section><h2 class="big">Your Move</h2><div id="actions"></div></section>
|
||||||
<section><h2>Cards</h2>
|
<section><h2 class="big">Cards Available</h2>
|
||||||
<div><span class="dim">hand:</span> <span id="hand">—</span></div>
|
<h3>Cards in My Hand</h3><div id="hand" class="cardrow">—</div>
|
||||||
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
|
<h3>Department Cards (face up)</h3><div id="depts" class="cardrow ref">—</div>
|
||||||
</section>
|
</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>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -635,3 +635,54 @@ describe('an industry handles every commodity its profile names (regression)', (
|
|||||||
assert.deepEqual(twoCommodity, ['powerPlant', 'grocersWarehouse']);
|
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
@@ -78,7 +78,7 @@ describe('narration', () => {
|
|||||||
const n = narrate(e);
|
const n = narrate(e);
|
||||||
assert.ok(n.text.length > 3, `${e.type} produced no useful text`);
|
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(!/^\[/.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', () => {
|
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('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', () => {
|
it('hides only the events with nothing to show', () => {
|
||||||
|
|||||||
+40
-7
@@ -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.
|
// "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
|
||||||
const game = newGame(555);
|
const game = newGame(555);
|
||||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||||
const draw = actionMenu(game).direct.find((g) => g.title === 'Draw');
|
// The draw actions are split by SOURCE now — gambling on the deck, taking a named face-up
|
||||||
assert.ok(draw, 'no draw group');
|
// card, and ending the turn are three different decisions — so gather every group that offers
|
||||||
for (const a of draw!.actions) {
|
// 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\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
assert.ok(
|
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',
|
'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
|
// 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
|
// 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.
|
// 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(
|
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]!,
|
(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.
|
// 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.
|
// 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 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 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.
|
// `again` is created by the game-over screen before it is looked up.
|
||||||
present.add('again');
|
present.add('again');
|
||||||
for (const id of asked) {
|
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', () => {
|
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.
|
// The splash is the front door now; the game and the replay directory are separate pages.
|
||||||
for (const [name, entry] of [
|
for (const [name, entry] of [
|
||||||
|
|||||||
Reference in New Issue
Block a user