More bug fixes. Show the timetable, and stop the arrival message contradicting itself. Fixed division point in labels. And highlighted discard as targets.

This commit is contained in:
Jesse
2026-08-05 23:08:03 -04:00
parent 543584a2ca
commit f4c0f49604
13 changed files with 381 additions and 50 deletions
+12 -1
View File
@@ -91,6 +91,14 @@ export type Game = {
* would be guesswork from a rendered frame.
*/
cues: string[];
/**
* The timetable slot the last batch of events filled, or null.
*
* Playing a train card rolls 1D12 for a Stage and the answer landed nowhere the player could see.
* Carrying the slot lets the timetable flash the one that just changed — the roll becomes
* something you watch land rather than something you are told about afterwards.
*/
scheduled: number | null;
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -125,7 +133,7 @@ export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [] };
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
@@ -603,6 +611,9 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
}
game.cues.push(...cuesFor(events));
// Which timetable slot the die just filled, so the panel can flash it. Last one wins: a batch can
// schedule more than one train, and the most recent is the one the eye should be sent to.
for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot;
// 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);
}
+38 -4
View File
@@ -7,7 +7,7 @@
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, yardHtml } from './panels.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts';
import type { Game } from './game.ts';
@@ -293,6 +293,9 @@ function render(): void {
? menu.hand.find((h) => `hand:${h.cardId}` === selected)
: undefined;
if (discarding) {
// Dim the piles that are not targets, so the three that are read as a choice being offered
// rather than as four cards that happen to be there.
$('depts').classList.add('aiming');
for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) {
const node = el as HTMLElement;
const slot = Number(node.dataset['dept']);
@@ -309,6 +312,10 @@ function render(): void {
}
}
else {
$('depts').classList.remove('aiming');
}
// -- making up a train: the Division Yard chip that shows the car IS the button.
if (menu.makeUp) {
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
@@ -326,6 +333,16 @@ function render(): void {
}
}
/**
* THE TIMETABLE, with the slot the die just filled flashed.
*
* `scheduled` is cleared as it is consumed, so the flash marks the moment rather than the state —
* it is gone by the next render, which is what makes it read as "that just happened".
*/
const justSet = game.scheduled;
game.scheduled = null;
$('timetable').innerHTML = timetableHtml(f, justSet);
$('blocked').innerHTML = blockedHtml(f);
// -- log
@@ -354,7 +371,7 @@ function render(): void {
lastPhase = f.phaseKey;
renderDistrict(f);
renderActions(menu, f);
renderActions(menu, f, justSet);
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
// not then play a backlog of everything that happened while it was off.
@@ -413,7 +430,11 @@ function renderDistrict(f: ReturnType<typeof view>): void {
};
}
function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof view>): void {
function renderActions(
menu: ReturnType<typeof actionMenu>,
f: ReturnType<typeof view>,
justSet: number | null,
): void {
const el = $('actions');
if (game.state.status !== 'active') {
@@ -474,6 +495,19 @@ function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof
* the count belongs beside the buttons. It was reported only in the history panel, which is the
* one place a player is not looking while switching.
*/
/**
* WHAT THE DIE DID, said where the player is looking.
*
* Playing a train card rolls 1D12 for its departure Stage, and the card simply left the hand — the
* answer arrived only in the history panel, one line among many. The timetable now shows it and
* flashes the slot; this says it in words at the same moment.
*/
const scheduledNote =
justSet !== null && f.timetable[justSet] != null
? `<div class="scheduled" data-tip="§7 — the Stage is rolled on 1D12 when the card is played; if that Stage is taken the train works down the column to the next free one. From now on it runs at this time every Day.">` +
`Train ${f.timetable[justSet]} is scheduled to depart at Stage ${justSet + 1} — see the Timetable</div>`
: '';
const movesNote =
f.movesLeft !== null
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
@@ -483,7 +517,7 @@ function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof
// 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>${movesNote}`;
let html = `<h3 class="actions-hd">Actions</h3>${scheduledNote}${movesNote}`;
/**
* WHAT IS LEFT IN THE ACTION LIST.
+86 -11
View File
@@ -97,6 +97,43 @@ export function yardHtml(rows: { type: string; loaded: number; empty: number }[]
.join('');
}
/**
* THE TIMETABLE: twelve Stages of a Day, and which train is due out at each.
*
* Playing a train card rolls 1D12 for a slot, and the roll landed nowhere a player could see — the
* card was gone and the answer to "so when does it run?" was one line of history. The Frame has
* carried the twelve slots all along and only the standalone replay ever drew them.
*
* `justSet` is the slot filled by the roll being reported this frame, so it can be flashed. It marks
* the moment rather than the state: the highlight is gone by the next render, which is what makes it
* read as "that just happened" rather than "this is special".
*/
export function timetableHtml(f: Frame, justSet: number | null): string {
const slots = f.timetable
.map((t, k) => {
const stage = k + 1;
const cls = [
t !== null ? 'due' : '',
stage === f.stage ? 'now' : '',
stage < f.stage ? 'past' : '',
k === justSet ? 'fresh' : '',
]
.filter(Boolean)
.join(' ');
const tip =
t !== null
? `Train ${t} departs at Stage ${stage}. A train's NUMBER is its seniority and direction — odd runs west, even runs east — and the Stage it leaves is set by the 1D12 roll made when its card was played, so Train 8 at Stage 6 is normal.`
: `Stage ${stage} — no train due out.`;
return (
`<div class="tt-slot ${cls}" data-tip="${esc(tip)}" tabindex="0">` +
`<span class="tt-s">${stage}</span>` +
`<span class="tt-t">${t !== null ? `T${t}` : '·'}</span></div>`
);
})
.join('');
return `<div class="tt">${slots}</div>`;
}
export function blockedHtml(f: Frame): string {
return f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
@@ -147,18 +184,28 @@ export function facilitiesHtml(f: Frame): string {
*/
`<div class="caps">` +
stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') +
stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') +
stat('in', String(x.redCap), x.base.in, 'Red boxes: loads that have come off an arriving car and are waiting to be cleared.') +
(x.allowsOut ? stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') : '') +
(x.allowsIn ? stat('in', String(x.redCap), x.base.in, 'Red boxes: loads cleared off an arriving car.') : '') +
(x.modifiers.length
? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</span>`
: '') +
`</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>` +
/**
* IN THE ORDER THE LOAD TRAVELS. §9.3 loads Green → MEN → AT → WORK → the spotted car,
* and unloads the other way: car → WORK → AT → MEN → red. Labelling the rows by colour
* alone left a player to work out which direction their industry ran; naming the step
* says it outright, and an industry that only receives has no green row to puzzle over.
*/
(x.allowsOut
? `<div class="boxes"><span class="dim" data-tip="Loads waiting to be worked out onto a car. They travel green → MEN → AT → WORK, then onto a spotted empty car.">waiting to load</span>${boxes(x.green, x.greenCap)}</div>`
: '') +
`<div class="boxes"><span class="dim" data-tip="One physical sign, worked in whichever direction this industry runs. Only one load may sit on each of the three boxes.">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>` +
(x.allowsIn
? `<div class="boxes"><span class="dim" data-tip="Loads that have come off an arriving car and been cleared. They travel car → WORK → AT → MEN, then into a red box — the opposite direction to loading.">cleared inbound</span>${boxes(x.red, x.redCap)}</div>`
: '') +
`<div class="boxes"><span class="dim" data-tip="Cars spotted on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one.">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
x.jammed
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
@@ -196,14 +243,42 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
.stock .ld{color:#8fd6a0}
.stock .mt{color:#9fb6d8}
.stock.none{opacity:.4}
/* A pile or a yard chip that is currently a target for the thing you picked. Blue, matching the
legal-square outline on the board: the same question is being asked, so it looks the same. */
.handcard.target{border-color:#5aa9e6;background:#233246;cursor:pointer;
box-shadow:0 0 0 2px rgba(90,169,230,.20)}
.handcard.target:hover{background:#2b3d55}
/* A PILE THAT IS WAITING TO BE PICKED, drawn like the board's ghost squares because it is the same
question: you have chosen a card, now choose where it goes. It was a 2px outline on a small tile
in a panel the player might not be looking at, and was reported as "a very tiny highlight". Now it
grows, brightens, says DISCARD HERE outright, and pulses once so the eye finds it. */
.handcard.target{border:2px dashed #5aa9e6;background:#233246;cursor:pointer;color:#e6e9ee;
box-shadow:0 0 0 4px rgba(90,169,230,.22);transform:translateY(-2px);
animation:pick 1.1s ease-out 2}
.handcard.target:hover{background:#31485f;border-style:solid;box-shadow:0 0 0 6px rgba(90,169,230,.30)}
.handcard.target::before{content:"DISCARD HERE";display:block;font-size:9px;letter-spacing:.09em;
color:#8fc4ee;font-weight:700;margin-bottom:2px}
@keyframes pick{0%{box-shadow:0 0 0 4px rgba(90,169,230,.22)}
50%{box-shadow:0 0 0 9px rgba(90,169,230,.10)}
100%{box-shadow:0 0 0 4px rgba(90,169,230,.22)}}
@media(prefers-reduced-motion:reduce){.handcard.target{animation:none}}
/* And the piles that are NOT targets step back while a discard is being aimed, so the three that
are stand out from the Salvage Yard beside them. */
.cardrow.aiming .handcard:not(.target){opacity:.4}
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
/* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are
dimmed, and a slot the die has just filled flashes once. */
.tt{display:flex;gap:3px;flex-wrap:wrap}
.tt-slot{flex:1 1 0;min-width:26px;text-align:center;border:1px solid #2c333d;border-radius:4px;
padding:2px 0;background:#1a1f26;font-size:10px;cursor:help;line-height:1.25}
.tt-slot .tt-s{display:block;color:#5f6b7a;font-size:9px}
.tt-slot .tt-t{display:block;color:#5f6b7a;font-variant-numeric:tabular-nums}
.tt-slot.due{background:#222c38;border-color:#4d6fa8}
.tt-slot.due .tt-t{color:#cfe0f5;font-weight:700}
.tt-slot.past{opacity:.45}
.tt-slot.now{border-color:#b98cf0;box-shadow:0 0 0 2px rgba(150,110,230,.20)}
.tt-slot.now .tt-s{color:#b98cf0;font-weight:700}
.tt-slot.fresh{animation:ttflash 1.5s ease-out 1}
@keyframes ttflash{0%{background:#2f6b47;border-color:#8fd6a0;transform:scale(1.18)}
100%{background:#222c38;border-color:#4d6fa8;transform:scale(1)}}
@media(prefers-reduced-motion:reduce){.tt-slot.fresh{animation:none;outline:2px solid #8fd6a0}}
ul.blocked{margin:0;padding-left:18px}
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
.fac{border-top:1px solid #2c333d;padding:7px 0}
+7
View File
@@ -76,6 +76,10 @@ button.cardact.discard{color:#d6b48a}
box-shadow:0 6px 22px rgba(0,0,0,.5)}
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
/* The die's answer, said where the player is looking. Green like a revenue gain — it is a good
thing that just happened — and it disappears with the next render, as a moment should. */
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
.makeup-note{font-size:11px;margin:0 0 5px}
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
buttons underneath it; struck through in red once it is gone. */
@@ -187,6 +191,9 @@ ul.blocked li{padding:2px 0}
<div id="clsyard" class="yard"></div>
<div class="dim" id="yardnote"></div>
</section>
<section><h2>Timetable — who is due out, and when</h2><div id="timetable"></div>
<div class="dim" style="font-size:11px;margin-top:5px">A train's number is its seniority and direction — odd runs west, even runs east. The Stage it departs is set by a 1D12 roll when its card is played, and it then runs at that time every Day.</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>
+8
View File
@@ -116,6 +116,14 @@ export function playCue(cue: string): void {
return;
}
if (cue === 'schedule') {
// A die landing on the despatcher's desk, then the slot taking it: a short clatter and a chime.
for (const at of [t, t + 0.05, t + 0.09]) tone(at, 200 + Math.random() * 90, 0.05, 0.07, 'square');
tone(t + 0.16, 880, 0.3, 0.09, 'sine');
tone(t + 0.16, 1320, 0.28, 0.05, 'sine');
return;
}
if (cue === 'day') {
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
for (const at of [t, t + 0.22]) {