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
+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();