various fixes. added version#s and added a playable browser build of the solitaire game (including deploy to filebrowser script)

This commit is contained in:
Jesse
2026-07-31 22:03:13 -04:00
parent 160190da3f
commit 2eca9de09f
16 changed files with 2399 additions and 401 deletions
+306
View File
@@ -0,0 +1,306 @@
/**
* Station Master — solitaire, playable in a browser.
*
* The whole game runs client-side. There is no server and no network call at any point: the engine
* is pure, imports nothing outside itself, and never touches `Math.random`, `Date` or `crypto`, so
* a static host is all this needs. (Proven, not assumed — a test runs full games with every Node
* global replaced by a throwing stub.)
*
* WHAT THIS MODULE IS. Everything here is presentation and input. It builds no rules of its own:
*
* - what you may do -> `legalActions(state, actor)`
* - what it means -> `describeIntent()`, shared with the replay
* - what the board looks like -> `snapshot()`, shared with the replay
* - what just happened -> `narrate()`, shared with the replay
*
* That is the same discipline the engine holds itself to. A second opinion about which moves are
* legal would eventually disagree with `check`, and the failure mode is a UI that offers an illegal
* move or refuses a legal one.
*
* SAVING. The event log is the game (`state = fold(events)`), and the RNG is seeded, so a save is
* the seed plus the list of intents submitted. Replaying them reconstructs the position exactly,
* which is far smaller and far more robust than serialising the state graph.
*/
import { pump } from '../engine/advance.ts';
import { applyIntent } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
import { narrate } from '../sim/narrate.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
import { cardName, describeIntent, geometryLabel, snapshot, variantLabel } from '../sim/view.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts';
import type { Frame } from '../sim/view.ts';
export const SOLO_CONFIG: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/** A group of legal actions of one kind, ready to put on screen. */
export type ActionGroup = {
kind: string;
title: string;
actions: { index: number; label: string }[];
};
export type Game = {
state: GameState;
seed: number;
/** Every intent submitted, in order — the save file. */
history: Intent[];
/** Narrated lines, newest last. */
log: { text: string; tone: string }[];
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
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' },
{ prefix: 'track.lay', title: 'Lay track from your supply' },
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
{ prefix: 'maneuver.', title: 'Manoeuvres' },
{ prefix: 'freightAgent.', title: 'Freight Agent' },
{ prefix: 'newTrain.', title: 'Making up the train' },
{ prefix: 'porter.', title: 'Porters' },
{ prefix: 'laborer.', title: 'Laborers' },
{ prefix: 'loadUnload.', title: 'Finish' },
{ prefix: 'redFlag.', title: 'Red flag' },
];
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: [] };
drain(game);
return game;
}
/**
* Run the engine forward until it needs a decision.
*
* Most of a Stage is automatic — the Mainline Phase moves trains, the clock turns over — so the
* player is only ever asked when `advance` genuinely stops.
*/
export function drain(game: Game): void {
record(game, pump(game.state));
}
/** Whose turn it is, or null if the game is over or waiting on nothing. */
export function currentActor(game: Game): PlayerIndex | null {
if (game.state.status !== 'active') return null;
return game.state.clock.pendingDecision !== null
? game.state.clock.superintendent
: game.state.clock.currentActor;
}
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
export function actionGroups(game: Game): { options: Intent[]; groups: ActionGroup[] } {
const actor = currentActor(game);
if (actor === null) return { options: [], groups: [] };
const options = legalActions(game.state, actor);
const byKind = new Map<string, { index: number; label: string }[]>();
options.forEach((intent, index) => {
const label = describeIntent(game.state, intent);
const list = byKind.get(intent.type) ?? [];
// Orientation variants and duplicate copies describe identically; showing one is enough, and a
// list of forty identical rows hides the real choice rather than presenting it.
if (!list.some((a) => a.label === label)) list.push({ index, label });
byKind.set(intent.type, list);
});
const groups: ActionGroup[] = [];
const used = new Set<string>();
for (const { prefix, title } of GROUP_ORDER) {
const kinds = [...byKind.keys()].filter((k) => k.startsWith(prefix) && !used.has(k));
const actions = kinds.flatMap((k) => {
used.add(k);
return byKind.get(k) ?? [];
});
if (actions.length > 0) groups.push({ kind: prefix, title, actions });
}
// Anything the table above does not name still has to be offered — silently dropping a legal
// action would make the game unplayable in a way that is very hard to notice.
const leftovers = [...byKind.entries()].filter(([k]) => !used.has(k));
for (const [kind, actions] of leftovers) groups.push({ kind, title: kind, actions });
return { options, groups };
}
/**
* A thing you might do, and — if it goes on the board — where it could go.
*
* Placeable actions are presented as SUBJECT then LOCATION rather than as one flat list of every
* (card x square x rotation) combination. A single turn offered 29 track buttons and 7 card buttons
* with no way to tell which square each referred to; picking the card first and the square second is
* how the choice is actually made at the table.
*/
export type Placeable = {
/** Groups every option that plays the same card or lays the same piece. */
subjectKey: string;
subject: string;
/**
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
* piece has more than one legal rotation there, which is why the label carries the rotation too.
*/
spots: { label: string; index: number; coord: { row: number; col: number } }[];
};
export type Menu = {
options: Intent[];
/** Actions with no further choice to make. */
direct: ActionGroup[];
/** Actions needing a location, grouped under their card or track piece. */
placeable: { title: string; items: Placeable[] }[];
};
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
export function actionMenu(game: Game): Menu {
const { options, groups } = actionGroups(game);
const direct: ActionGroup[] = [];
const placeableByTitle = new Map<string, Map<string, Placeable>>();
for (const g of groups) {
const plain: ActionGroup['actions'] = [];
for (const a of g.actions) {
const intent = options[a.index]!;
const key = subjectOf(game, intent);
if (key === null) {
plain.push(a);
continue;
}
const bucket = placeableByTitle.get(g.title) ?? new Map<string, Placeable>();
const entry = bucket.get(key.subjectKey) ?? {
subjectKey: key.subjectKey,
subject: key.subject,
spots: [],
};
if (!entry.spots.some((sp) => sp.label === key.spot)) {
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord });
}
bucket.set(key.subjectKey, entry);
placeableByTitle.set(g.title, bucket);
}
if (plain.length > 0) direct.push({ ...g, actions: plain });
}
const placeable = [...placeableByTitle.entries()].map(([title, m]) => ({
title,
items: [...m.values()],
}));
return { options, direct, placeable };
}
/** Split an intent into "what" and "where", or null if it needs no location. */
function subjectOf(
game: Game,
i: Intent,
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
if (i.type === 'card.play' && i.placement) {
return {
subjectKey: `card:${i.cardId}`,
subject: cardName(game.state, i.cardId),
spot: `${at(i.placement)}${rotationNote(null, i.variant)}`,
coord: i.placement,
};
}
if (i.type === 'track.lay') {
const hand = i.hand === 'none' ? '' : `${i.hand}-hand `;
return {
subjectKey: `track:${i.geometry}:${i.hand}`,
subject: `${hand}${geometryLabel(i.geometry)}`,
spot: `${at(i.placement)}${rotationNote(i.geometry, i.variant)}`,
coord: i.placement,
};
}
return null;
}
/**
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
* decision. Only shown when there is more than one way to lay the piece.
*/
function rotationNote(geometry: TrackGeometry | null, variant: number | undefined): string {
if (geometry === null) return variant === undefined || variant === 0 ? '' : ` — option ${variant + 1}`;
return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : '';
}
/** Submit an action. Returns false and changes nothing if the engine rejects it. */
export function submit(game: Game, intent: Intent): boolean {
const actor = currentActor(game);
if (actor === null) return false;
const result = applyIntent(game.state, actor, intent);
if (!result.ok) {
game.log.push({ text: `That is not allowed: ${result.code}`, tone: 'bad' });
return false;
}
game.history.push(intent);
record(game, result.events);
drain(game);
return true;
}
/** The board as the replay draws it, so the live game and the replay agree. */
export function view(game: Game): Frame {
return snapshot(game.state, [], null);
}
function record(game: Game, events: GameEvent[]): void {
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;
const n = narrate(e, { cardName: (id) => cardName(game.state, id) });
game.log.push({ text: n.text, tone: 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);
}
// ---------------------------------------------------------------------------
// Saving — seed plus intents, replayed
// ---------------------------------------------------------------------------
export type Save = { seed: number; history: Intent[] };
export function toSave(game: Game): Save {
return { seed: game.seed, history: game.history };
}
/**
* Rebuild a game from a save.
*
* Replays the intents through the real engine rather than restoring a serialised state, so a save
* can never describe a position the rules could not have produced. An intent that no longer applies
* stops the replay rather than being forced — better a short game than a corrupt one.
*/
export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
const game = newGame(save.seed, config);
for (const intent of save.history) {
const actor = currentActor(game);
if (actor === null) break;
const result = applyIntent(game.state, actor, intent);
if (!result.ok) break;
game.history.push(intent);
record(game, result.events);
drain(game);
}
return game;
}
+128
View File
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — solitaire</title>
<style>
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250}
*{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}
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);
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
header b{font-size:16px}
.build{margin-left:auto;font-size:11px;opacity:.7}
.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 .what{font-size:11px;line-height:1.35}
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;
padding:10px 12px;margin-bottom:12px}
/* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
.node.office{border-color:#4d6fa8;background:rgba(60,100,170,.13)}
.node .nm{font-weight:600;font-size:12px}
.chip{display:inline-block;background:#2f6b3d;border-radius:4px;padding:1px 6px;margin:3px 3px 0 0;font-size:11px}
.consist{font-size:10px;color:var(--dim)}
.grade{font-size:10px;background:rgba(200,90,60,.35);border-radius:3px;padding:1px 4px;font-weight:400}
.mlmods{font-size:10px;background:rgba(200,150,60,.28);border-radius:3px;padding:1px 4px;margin-top:2px}
/* board */
#grid{display:grid;gap:5px;overflow-x:auto}
.cell{border:1px solid var(--line);border-radius:5px;padding:5px 6px;min-height:64px;background:#161b21}
.cell.run{background:var(--run)}
.cell.office{border-color:#4d6fa8}
.cell.modifier{background:#2b2333;border-style:dashed}
.cell.legal{outline:2px solid #5aa9e6;outline-offset:-2px;cursor:pointer;
box-shadow:0 0 0 3px rgba(90,169,230,.18) inset}
.cell.legal:hover{background:#233246}
.cell.ghost{border-style:dashed;background:rgba(90,169,230,.07);
display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
.cell.ghost .nm{font-weight:400;color:#5aa9e6;font-size:11px}
.cell .nm{font-size:12px;font-weight:600}
.coord{font-size:10px}
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
.cars{font-size:10px;color:var(--dim)}
/* facilities */
.fac{border-top:1px solid var(--line);padding:7px 0}
.fac:first-child{border-top:0}
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
.box.empty{background:#242a32;color:#5a6472}
.box.f{background:#2f6b3d}
.box.m{background:#8a6d1f}
.fstat{margin-top:4px;font-size:11px;padding:2px 5px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
.fstat.idle{opacity:.6}
/* actions */
#actions{max-height:none}
.grp{margin-bottom:6px}
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}
button.act{display:inline-block}
.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)}
/* cards, log, blocked */
.card.gone{opacity:.35;text-decoration:line-through}
.subj{display:block;width:100%;margin:2px 0}
.subj.open{border-color:#4d6fa8;background:#39424e}
.spots{margin:0 0 6px 12px;padding-left:8px;border-left:2px solid #4d6fa8}
.card{display:inline-block;background:#242c36;border:1px solid var(--line);border-radius:4px;
padding:2px 6px;margin:2px 2px 0 0;font-size:11px}
#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)}
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}
</style>
</head>
<body>
<header>
<b>Station Master</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>
<span class="dim">saved in this browser · add ?seed=1234 to the address for a set deal</span>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></div></section>
<section><h2>Your Office Area</h2><div id="grid"></div></section>
<section><h2>History</h2><div id="log"></div></section>
</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>
<section><h2>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>
</main>
<script type="module" src="./web/main.js"></script>
</body>
</html>
+357
View File
@@ -0,0 +1,357 @@
/**
* Browser entry point — wires the DOM to `game.ts`.
*
* Presentation only. Every question of what is legal, what it means, or what the board looks like
* is answered by the engine or by the shared view helpers.
*/
import type { Game } from './game.ts';
import {
actionMenu,
currentActor,
fromSave,
newGame,
submit,
toSave,
view,
} from './game.ts';
const SAVE_KEY = 'station-master.save.v1';
let game: Game;
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/** A square picked on the board, waiting for a rotation. */
let pendingAt: string | null = null;
const $ = (id: string): HTMLElement => {
const el = document.getElementById(id);
if (!el) throw new Error(`missing element: ${id}`);
return el as HTMLElement;
};
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
// ---------------------------------------------------------------------------
function start(): void {
const params = new URLSearchParams(location.search);
const requested = params.get('seed');
const saved = load();
if (saved && requested === null) {
game = fromSave(saved);
} else {
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
game = newGame(seed);
}
render();
}
function render(): void {
const f = view(game);
const menu = actionMenu(game);
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected);
const spotsAt = new Map<string, { label: string; index: number }[]>();
for (const sp of chosen?.spots ?? []) {
const key = `${sp.coord.row},${sp.coord.col}`;
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
}
$('clock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
$('phase').textContent = f.phase;
$('revenue').textContent = String(f.revenue);
const obj = $('objective');
obj.textContent = f.objective.note;
obj.className = f.objective.onPace ? 'pace good' : 'pace behind';
$('seed').textContent = String(game.seed);
// -- division
$('division').innerHTML = f.division
.map(
(n) =>
`<div class="node ${n.kind === 'office' ? 'office' : ''}">` +
`<div class="nm">${esc(n.label)}` +
(n.gradeUp
? ` <span class="grade">${n.gradeUp === 'east' ? 'climbs E ▲' : '▲ W climbs'}</span>`
: '') +
`</div>` +
(n.modifiers.length ? `<div class="mlmods">${n.modifiers.map(esc).join(' · ')}</div>` : '') +
`<div class="regions">` +
n.trains
.map(
(r) =>
`<div class="region">` +
r
.map(
(t) =>
`<span class="chip">${esc(t.label)}</span>` +
`<div class="consist">${t.consist.length ? esc(t.consist.join(', ')) : 'empty'}</div>`,
)
.join('') +
`</div>`,
)
.join('') +
`</div></div>`,
)
.join('');
// -- grid
// Include the legal squares in the bounds. A placement is usually just OUTSIDE the current
// extent — that is what extending the district means — so a grid sized to the existing cards
// would highlight nothing at exactly the moment highlighting matters.
const spotCoords = [...spotsAt.keys()].map((k) => k.split(',').map(Number));
const rows = [...f.cells.map((c) => c.row), ...spotCoords.map((c) => c[0]!)];
const cols = [...f.cells.map((c) => c.col), ...spotCoords.map((c) => c[1]!)];
const r0 = Math.min(...rows);
const r1 = Math.max(...rows);
const c0 = Math.min(...cols);
const c1 = Math.max(...cols);
const grid = $('grid');
grid.style.gridTemplateColumns = `repeat(${c1 - c0 + 1}, minmax(104px, 1fr))`;
let cells = '';
for (let r = r1; r >= r0; r--) {
for (let c = c0; c <= c1; c++) {
const cell = f.cells.find((x) => x.row === r && x.col === c);
const spots = spotsAt.get(`${r},${c}`);
if (!cell) {
// An empty square is still a legal destination — render it as a target rather than a gap,
// or the most common placement of all would have nothing to click.
cells += spots
? `<div class="cell ghost legal" data-at="${r},${c}">` +
`<div class="nm">place here</div><div class="dim coord">(${r},${c})</div>` +
(spots.length > 1 ? `<div class="dim">${spots.length} rotations</div>` : '') +
`</div>`
: '<div></div>';
continue;
}
cells +=
`<div class="cell ${cell.running ? 'run ' : ''}${cell.kind === 'office' ? 'office ' : ''}` +
`${cell.kind === 'modifier' ? 'modifier ' : ''}${spots ? 'legal' : ''}"` +
`${spots ? ` data-at="${r},${c}"` : ''}>` +
`<div class="nm">${esc(cell.label)}</div>` +
`<div class="dim coord">(${r},${c})</div>` +
(cell.enhancements.length
? `<div class="enh">${cell.enhancements.map(esc).join(' · ')}</div>`
: '') +
(cell.tray ? `<div class="crew">🚂 ${esc(cell.tray)}</div>` : '') +
(cell.cars.length ? `<div class="cars">${esc(cell.cars.join(', '))}</div>` : '') +
`</div>`;
}
}
grid.innerHTML = cells;
// Clicking a highlighted square plays there. With more than one rotation available it narrows to
// that square instead, so the rotation is a second click rather than a guess.
for (const el of Array.from(grid.querySelectorAll('[data-at]'))) {
const node = el as HTMLElement;
node.onclick = () => {
const spots = spotsAt.get(node.dataset['at'] ?? '') ?? [];
if (spots.length === 1) {
const intent = menu.options[spots[0]!.index];
if (intent) submit(game, intent);
selected = null;
pendingAt = null;
} else if (spots.length > 1) {
pendingAt = node.dataset['at'] ?? null;
}
render();
};
}
// -- facilities
$('facs').innerHTML =
f.facilities.length === 0
? '<div class="dim">no facilities yet</div>'
: f.facilities
.map(
(x) =>
`<div class="fac"><div class="nm">${esc(x.name)}</div>` +
`<div class="dim">${esc(x.commodity)} · ${esc(x.flow)} · laborers ${esc(x.laborers)}</div>` +
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
x.maw
.map(
(m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`,
)
.join('') +
`</div>` +
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}">` +
(x.jammed
? 'JAMMED — a load is on WORK with no car spotted; the siding is locked'
: x.canFinish
? 'ready — a matching empty car is spotted'
: 'no car spotted — a load started here would jam') +
`</div></div>`,
)
.join('');
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
const cardRow = (name: string, why: string): string =>
`<div class="handcard"><b>${esc(name)}</b>` +
(why ? `<div class="dim what">${esc(why)}</div>` : '') +
`</div>`;
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '')).join('')
: '<span class="dim">empty</span>';
$('depts').innerHTML = f.departments.length
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '')).join('')
: '<span class="dim">none</span>';
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
$('supply').innerHTML =
`<div class="dim">${total} pieces left · one may be laid per turn, during the DRAW option</div>` +
f.trackSupply
.map(
(t) =>
`<span class="card ${t.left === 0 ? 'gone' : ''}">${esc(t.piece)} <b>${t.left}</b></span>`,
)
.join(' ');
$('blocked').innerHTML =
f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
.join('');
// -- log
const log = $('log');
log.innerHTML = game.log
.slice(-60)
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
.join('');
log.scrollTop = log.scrollHeight;
renderActions(menu);
save();
}
function boxes(items: string[], cap: number): string {
let out = '';
for (let i = 0; i < Math.max(cap, items.length); i++) {
const v = items[i];
out += `<span class="box ${v ? 'f' : 'empty'}">${v ? esc(v) : '·'}</span>`;
}
return out || '<span class="dim">—</span>';
}
function renderActions(menu: ReturnType<typeof actionMenu>): void {
const el = $('actions');
if (game.state.status !== 'active') {
const o = game.state.outcome;
el.innerHTML =
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
`final Revenue ${game.state.players[0]?.revenue ?? 0} against a target of ${view(game).objective.target}</div>` +
`<button id="again">new game</button>`;
$('again').onclick = () => {
clearSave();
location.search = '';
};
return;
}
if (menu.direct.length === 0 && menu.placeable.length === 0) {
el.innerHTML = '<div class="dim">nothing to decide — the engine is running the Division</div>';
return;
}
const apply = (index: number): void => {
const intent = menu.options[index];
if (intent) submit(game, intent);
selected = null;
pendingAt = null;
render();
};
let html = menu.direct
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
g.actions.map((a) => `<button class="act" data-i="${a.index}">${esc(a.label)}</button>`).join('') +
`</div>`,
)
.join('');
// Subject first, location second. Picking a card then a square is how the choice is actually made;
// one flat list of every card-square-rotation combination was unreadable.
for (const group of menu.placeable) {
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
for (const item of group.items) {
const open = selected === item.subjectKey;
html +=
`<button class="subj ${open ? 'open' : ''}" data-subj="${esc(item.subjectKey)}">` +
`${esc(item.subject)} <span class="dim">${item.spots.length} spot${item.spots.length === 1 ? '' : 's'}</span>` +
`</button>`;
if (open) {
// Once a square is picked, show only that square's rotations — the rest is noise.
const shown = pendingAt
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
: item.spots;
html +=
`<div class="spots"><div class="dim">` +
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
`</div>` +
shown.map((sp) => `<button class="act" data-i="${sp.index}">${esc(sp.label)}</button>`).join('') +
`</div>`;
}
}
html += `</div>`;
}
el.innerHTML = html;
for (const b of Array.from(el.querySelectorAll('button.act'))) {
const node = b as HTMLElement;
node.onclick = () => apply(Number(node.dataset['i']));
}
for (const b of Array.from(el.querySelectorAll('button.subj'))) {
const node = b as HTMLElement;
node.onclick = () => {
const key = node.dataset['subj'] ?? null;
selected = selected === key ? null : key;
pendingAt = null;
render();
};
}
}
// ---------------------------------------------------------------------------
// Saving. localStorage only — nothing leaves the browser.
// ---------------------------------------------------------------------------
function save(): void {
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game)));
} catch {
// A full or disabled localStorage must not take the game down with it.
}
}
function load(): ReturnType<typeof toSave> | null {
try {
const raw = localStorage.getItem(SAVE_KEY);
return raw ? (JSON.parse(raw) as ReturnType<typeof toSave>) : null;
} catch {
return null;
}
}
function clearSave(): void {
try {
localStorage.removeItem(SAVE_KEY);
} catch {
/* nothing to do */
}
}
start();