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:
+357
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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();
|
||||
Reference in New Issue
Block a user