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
+57 -389
View File
@@ -28,391 +28,19 @@ import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
import { developerBot, lastChoiceReason } from './bot.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } from './narrate.ts';
import { carLabel, idleNote, isVisible, narrate } from './narrate.ts';
// The view-model lives in its own module so the browser build can import it without dragging in
// this file's Node dependencies. Re-exported because tests and the web app import it from here.
export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
export { cardName, describeIntent, snapshot } from './view.ts';
import type { Frame } from './view.ts';
import type { Decision } from './view.ts';
import { cardName, describeDecision, snapshot } from './view.ts';
// ---------------------------------------------------------------------------
// Frame shape — only what the viewer draws
// ---------------------------------------------------------------------------
export type CellView = {
row: number;
col: number;
kind: string;
label: string;
running: boolean;
tray: string | null;
cars: string[];
facility: FacilityView | null;
};
export type FacilityView = {
name: string;
commodity: string;
flow: string;
green: string[];
greenCap: number;
maw: (string | null)[];
red: string[];
redCap: number;
track: string[];
trackCap: number;
laborers: string;
porters: string;
/**
* Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
* WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
* that held freight to a 3% completion rate.
*/
canFinish: boolean;
/** A load is sitting on WORK with no spotted car to receive it. */
jammed: boolean;
};
export type TrainChip = { label: string; consist: string[] };
export type DivisionView = { kind: string; label: string; trains: TrainChip[][] };
export type Frame = {
day: number;
stage: number;
clock: string;
phase: string;
actor: number | null;
superintendent: number;
revenue: number;
lines: { text: string; tone: string }[];
where: { row: number; col: number } | null;
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
whereFrom: { row: number; col: number } | null;
division: DivisionView[];
cells: CellView[];
facilities: FacilityView[];
hand: string[];
deck: number;
departments: string[];
/** 12 slots; the train number due out at each Stage, or null. */
timetable: (number | null)[];
blocked: Impediment[];
trains: { label: string; where: string }[];
/** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */
decision: Decision | null;
/** A Local Operations turn that changed nothing — the frames worth your attention. */
wasted: boolean;
};
/**
* The choice behind a frame.
*
* The whole point is `rejected`: the replay could always show what happened, never what COULD have
* happened, so a daft move was visible but the alternatives it passed over were not — which is
* exactly what you need to say what it should have done instead.
*/
export type Decision = {
actor: number;
chose: string;
why: string;
/** Every legal option not taken, grouped by kind with a count. */
rejected: { kind: string; count: number; detail: string }[];
totalOptions: number;
};
// ---------------------------------------------------------------------------
// Snapshotting
// ---------------------------------------------------------------------------
const FACILITY_NAMES: Record<string, string> = {
mineTipple: 'Mine Tipple',
produceShed: 'Produce Shed',
grocersWarehouse: "Grocer's Warehouse",
oilRefinery: 'Oil Refinery',
powerPlant: 'Power Plant',
};
function facilityView(
card: { geometry: { kind: string; facility?: string }; facility: unknown },
officeName: string,
): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
// Passenger facilities were excluded entirely, so the Office's green and red slots never
// appeared — which is why stocking a coach into the green box looked like nothing happening.
if (!f) return null;
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
return {
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? key),
commodity: facilityCarType(f) ?? '?',
flow: f.kind === 'passenger'
? 'passengers on and off'
: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
green: f.outboundBox.map(carLabel),
greenCap: f.capacity.outbound,
maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
red: f.inboundBox.map(carLabel),
redCap: f.capacity.inbound,
track: f.industryTrack.cars.map(carLabel),
trackCap: f.industryTrack.length,
laborers: `${laborersLeft(f)}/${f.laborers}`,
porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f),
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
};
}
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
function canFinishHere(f: Facility): boolean {
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
if (!pending) return false;
return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type);
}
/**
* Summarise the choice: what was taken, why, and what was passed over.
*
* Options are grouped by kind because a switching turn can offer 40 destinations, and a list that
* long hides the shape of the decision rather than showing it.
*/
function describeDecision(
s: GameState,
actor: number,
chosen: Intent,
options: Intent[],
why: string,
): Decision {
const groups = new Map<string, Intent[]>();
for (const o of options) {
if (o === chosen) continue;
const list = groups.get(o.type) ?? [];
list.push(o);
groups.set(o.type, list);
}
const rejected = [...groups.entries()]
.map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) }))
.sort((a, b) => b.count - a.count);
return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length };
}
/** A short, concrete example of what a group of rejected options would have done. */
function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
// Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe
// identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the
// actual range of choices — the opposite of what this panel is for.
const seen = new Set<string>();
for (const i of list) seen.add(describeIntent(s, i));
const unique = [...seen];
const shown = unique.slice(0, 4);
const more = unique.length - shown.length;
return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
}
/** One readable line for a single intent. */
function describeIntent(s: GameState, i: Intent): string {
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
switch (i.type) {
case 'localOps.choose':
return `choose ${i.option}`;
case 'card.play':
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
case 'card.discard':
return `discard ${cardName(s, i.cardId)}`;
case 'track.lay':
return `lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${i.geometry} at ${at(i.placement)}`;
case 'switch.move':
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
case 'switch.dropCars':
return `drop ${i.count} car(s)`;
case 'switch.sortConsist':
return `re-order consist [${i.order.join(',')}]`;
case 'freightAgent.stockOutbound':
return `stock a ${i.carType} at ${at(i.at)}`;
case 'freightAgent.unjam':
return `unjam ${i.from} at ${at(i.at)}`;
case 'freightAgent.clearInbound':
return `clear red box at ${at(i.at)}`;
case 'laborer.startLoad':
return `start a load at ${at(i.at)}`;
case 'laborer.advanceLoad':
return `advance load in box ${i.box} at ${at(i.at)}`;
case 'laborer.beginUnload':
return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
case 'porter.board':
return `board passengers at ${at(i.at)}`;
case 'porter.detrain':
return `detrain passengers at ${at(i.at)}`;
case 'newTrain.placeCar':
return `add ${i.loaded ? 'loaded' : 'empty'} ${i.carType}`;
case 'mainline.modify':
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
case 'maneuver.redFlags':
return `Red Flags on ${i.trayId}`;
case 'maneuver.flyingSwitch':
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
case 'mainline.clearance':
return i.allow ? 'grant clearance' : 'refuse clearance';
case 'draw.fromDepartment':
return `draw the face-up card in slot ${i.slot + 1}`;
default:
return i.type;
}
}
function snapshot(
s: GameState,
lines: { text: string; tone: string }[],
where: { row: number; col: number } | null,
whereFrom: { row: number; col: number } | null = null,
decision: Decision | null = null,
wasted = false,
): Frame {
const area = areaOf(s, 0);
const trayAt = new Map<string, string>();
for (const [id, tray] of s.trays) {
if (tray.position.at === 'grid') {
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
}
}
const cells: CellView[] = [];
const facilities: FacilityView[] = [];
for (const [key, card] of area.grid) {
const [row, col] = key.split(',').map(Number);
const g = card.geometry;
const kind = g.kind;
let label: string;
if (g.kind === 'office') label = officeProfile(area.tier).name;
else if (g.kind === 'limits') label = 'Limits';
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier;
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = g.geometry === 'sharpCurved' ? 'sharp curve' : g.geometry;
const fv = facilityView(card as never, officeProfile(area.tier).name);
if (fv) facilities.push(fv);
cells.push({
row: row!,
col: col!,
kind,
label,
running: row === area.runningRow,
tray: trayAt.get(key) ?? null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
facility: fv,
});
}
const division: DivisionView[] = s.division.nodes.map((n) => {
if (n.kind === 'divisionPoint') {
return {
kind: 'dp',
label: n.side === 'west' ? 'West DP' : 'East DP',
trains: [n.holding.map((id) => trainChip(s, id))],
};
}
if (n.kind === 'mainline') {
// Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it
// with how long each still has to run.
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
return {
kind: 'ml',
label: name,
trains: [n.transits.map((t) => {
const chip = trainChip(s, t.tray);
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
})],
};
}
return {
kind: 'office',
label: officeProfile(areaOf(s, n.owner).tier).name,
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
};
});
return {
day: s.clock.day,
stage: s.clock.stage,
clock: clockTime(s.clock.stage),
phase: phaseLabel(s.clock.phase),
actor: s.clock.currentActor,
superintendent: s.clock.superintendent,
revenue: s.players[0]?.revenue ?? 0,
lines,
where,
whereFrom,
division,
cells,
facilities,
hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
timetable: [...s.timetable],
decision,
wasted,
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
where:
t.position.at === 'divisionPoint'
? `${t.position.side} Division Point`
: t.position.at === 'mainline'
? `Mainline card ${t.position.index}`
: `Office Area (${t.position.coord.row},${t.position.coord.col})`,
})),
};
}
/** A card id turned into something a person can read. */
export function cardName(s: GameState, id: string): string {
const k = s.cards.get(id)?.kind;
if (!k) return 'a card';
switch (k.kind) {
case 'timetabledTrain':
return `Train ${k.number}`;
case 'extraTrain':
return `Extra X${k.number}`;
case 'office':
return `${k.tier.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())} upgrade`;
case 'freightFacility':
return FACILITY_NAMES[k.facility] ?? k.facility;
case 'modifier':
return MODIFIER_NAMES[k.modifier] ?? k.modifier;
case 'track':
return k.geometry === 'sharpCurved' ? 'Sharp curve' : `${k.geometry} track`;
case 'spaceUse':
case 'enhancement':
case 'mainlineModifier':
case 'maneuver':
case 'action':
return prettyKey(k.key);
}
}
/** camelCase key → readable name, for the card categories that carry only a key. */
function prettyKey(key: string): string {
return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
}
const MODIFIER_NAMES: Record<string, string> = {
teamTrack: 'Team Track',
loadingDock: 'Loading Dock',
storageShed: 'Storage Shed',
extraPlatform: 'Extra Platform',
sectionGang: 'Section Gang',
};
/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
function trainChip(s: GameState, id: string): TrainChip {
const t = s.trays.get(id);
if (!t) return { label: id, consist: [] };
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
consist: t.consist.map(carLabel),
};
}
// ---------------------------------------------------------------------------
// Recording
// ---------------------------------------------------------------------------
@@ -510,6 +138,27 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
// HTML
// ---------------------------------------------------------------------------
/**
* Drop repeated board state from the serialised frames.
*
* Only the wire format changes: `record()` still returns complete frames, so tests and any other
* consumer are unaffected. The page resolves a null by walking back to the last frame that carried
* the field.
*/
function compress(frames: Frame[]): unknown[] {
const keys = ['cells', 'facilities', 'division'] as const;
let prev: Record<string, string> = {};
return frames.map((f, i) => {
const out: Record<string, unknown> = { ...f };
for (const k of keys) {
const json = JSON.stringify(f[k]);
if (i > 0 && prev[k] === json) out[k] = null;
prev[k] = json;
}
return out;
});
}
export function renderHtml(rec: Recording): string {
const target = lengthProfile(rec.length);
return `<!doctype html>
@@ -590,6 +239,10 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
.fstat.bad{background:rgba(190,50,50,.38);font-weight:bold}
.fstat.idle{opacity:.6}
.wastedmark{color:#e06060;font-weight:bold}
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
.mlmods{font-size:10px;background:rgba(200,150,60,.28);border-radius:3px;padding:1px 4px;margin-top:2px}
.grade{font-size:10px;background:rgba(200,90,60,.35);border-radius:3px;padding:1px 4px;font-weight:normal}
</style></head><body>
<header>
@@ -657,7 +310,14 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
</footer>
<script>
const FRAMES = ${JSON.stringify(rec.frames)};
const FRAMES = ${JSON.stringify(compress(rec.frames))};
/* The board changes rarely, so cells/facilities/division are emitted as null when identical to the
previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of
a 5.2 MB page. The scrubber jumps anywhere, hence the walk rather than a running pointer. */
function carry(i, key) {
for (let k = i; k >= 0; k--) if (FRAMES[k][key] !== null) return FRAMES[k][key];
return [];
}
let i = 0, timer = null;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
@@ -704,8 +364,13 @@ function render() {
$('fno').textContent = i;
$('scrub').value = i;
$('division').innerHTML = f.division.map((n) =>
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) + '</div>' +
const CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
$('division').innerHTML = DIV.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>' +
@@ -713,14 +378,14 @@ function render() {
).join('') + '</div>').join('') +
'</div></div>').join('');
const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col);
const rows = CELLS.map((c) => c.row), cols = CELLS.map((c) => c.col);
const r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols);
const g = $('grid');
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
let cellsHtml = '';
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 cell = CELLS.find((x) => x.row === r && x.col === c);
if (!cell) { cellsHtml += '<div></div>'; continue; }
const hl = f.where && f.where.row === r && f.where.col === c;
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
@@ -728,6 +393,9 @@ function render() {
(cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
'<div class="nm">' + esc(cell.label) + '</div>' +
'<div class="dim" style="font-size:10px">(' + 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.facility ? miniFacility(cell.facility) : '') +
(cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
@@ -736,9 +404,9 @@ function render() {
}
g.innerHTML = cellsHtml;
$('facs').innerHTML = f.facilities.length === 0
$('facs').innerHTML = FACS.length === 0
? '<span class="dim">no freight facilities built yet</span>'
: f.facilities.map((x) =>
: FACS.map((x) =>
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
@@ -790,7 +458,7 @@ function render() {
}
function go(n) { i = Math.max(0, Math.min(FRAMES.length - 1, n)); render(); }
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1])) return go(k); go(FRAMES.length - 1); }
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1], k)) return go(k); go(FRAMES.length - 1); }
$('ftot').textContent = FRAMES.length - 1;
$('scrub').max = FRAMES.length - 1;
@@ -802,7 +470,7 @@ $('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
$('waste').onclick = () => findNext((f) => f.wasted);
$('jam').onclick = () => findNext((f) => f.facilities.some((x) => x.jammed));
$('jam').onclick = () => findNext((f, p, idx) => carry(idx, 'facilities').some((x) => x.jammed));
$('play').onclick = () => {
if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
$('play').textContent = '⏸ pause';