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:
Jesse
2026-08-04 08:37:42 -04:00
parent 982952fd6c
commit ec792dfc15
12 changed files with 392 additions and 62 deletions
+94 -7
View File
@@ -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 {
const params = new URLSearchParams(location.search);
const requested = params.get('seed');
@@ -66,8 +136,7 @@ function render(): void {
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
}
$('clock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
$('phase').textContent = f.phase;
renderTurnChart(f);
$('revenue').textContent = String(f.revenue);
const obj = $('objective');
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 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 =>
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b>` +
(playable === false ? ' <span class="tag">not yet</span>' : '') +
`</div>`;
`<b>${esc(name)}</b></div>`;
const canPlay = handPlayable(game);
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
: '<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
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
: '<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(
(g) =>
`<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>`,
)
.join('');