Track becomes a deck card, Departments become decks, and trains must be made up to leave, industry restrictions enforced, update display and add a new game button

This commit is contained in:
Jesse
2026-08-05 08:28:22 -04:00
parent 2d348e1ecf
commit 9c76b1a5e2
28 changed files with 1927 additions and 394 deletions
+58 -15
View File
@@ -41,8 +41,10 @@ import {
variantLabel,
} from '../sim/view.ts';
import { HAND_LIMIT, trainProfile } from '../engine/content.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { joins, neighbour, variantsFor } from '../engine/track.ts';
import { areaOf } from '../engine/apply.ts';
import type { Frame } from '../sim/view.ts';
export const SOLO_CONFIG: GameConfig = {
@@ -103,7 +105,6 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
{ prefix: 'card.play', title: 'Play a card from my hand' },
{ prefix: 'card.discard', title: 'Discard a card from my hand' },
{ prefix: 'draw.end', title: 'Finish' },
{ prefix: 'track.lay', title: 'Lay track from your supply' },
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
// American spelling throughout, to match MANEUVER_CARDS and the source deck.
{ prefix: 'maneuver.', title: 'Maneuvers' },
@@ -262,19 +263,18 @@ function subjectOf(
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
if (i.type === 'card.play' && i.placement) {
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
// the decision, so they ride on the spot rather than being left for the player to work out.
const kind = game.state.cards.get(i.cardId)?.kind;
const track =
kind?.kind === 'track'
? rotationNote(kind.geometry, i.variant, kind.hand) +
joinsNote(game, kind.geometry, kind.hand, i.variant, i.placement)
: rotationNote(null, i.variant);
return {
subjectKey: `card:${i.cardId}`,
subject: cardName(game.state, i.cardId),
spot: `${at(i.placement)}${rotationNote(null, i.variant)}`,
coord: i.placement,
};
}
if (i.type === 'track.lay') {
const hand = i.hand === 'none' ? '' : `${i.hand}-hand `;
return {
subjectKey: `track:${i.geometry}:${i.hand}`,
subject: `${hand}${geometryLabel(i.geometry)}`,
spot: `${at(i.placement)}${rotationNote(i.geometry, i.variant)}`,
spot: `${at(i.placement)}${track}`,
coord: i.placement,
};
}
@@ -285,10 +285,53 @@ function subjectOf(
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
* decision. Only shown when there is more than one way to lay the piece.
*
* HAND IS NOT OPTIONAL HERE. It decides which diagonal the 45° leg lies on, so `variantsFor` without
* it answers for the left-hand card whatever you are actually holding: every right-hand turnout was
* offered as "stem west, through east, diverges south" — the mirror of the card it would lay — and
* every right-hand curve named the wrong edge. The placement was right and the description was
* backwards, which is worse than no description at all.
*/
function rotationNote(geometry: TrackGeometry | null, variant: number | undefined): string {
function rotationNote(
geometry: TrackGeometry | null,
variant: number | undefined,
hand: Hand = 'none',
): string {
if (geometry === null) return variant === undefined || variant === 0 ? '' : ` — option ${variant + 1}`;
return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : '';
return variantsFor(geometry, hand).length > 1 ? variantLabel(geometry, variant, hand) : '';
}
/**
* WHAT THIS PLACEMENT WOULD ACTUALLY CONNECT TO.
*
* Two cards meeting at an edge is not a rail — on a north or south edge their 45° legs must also lie
* on the same diagonal — so "is this square legal" and "does this piece join the one I am aiming at"
* are different questions, and only the first was on screen. A player building a crossover down onto
* a siding had to pick a hand and a rotation and find out afterwards.
*/
function joinsNote(
game: Game,
geometry: TrackGeometry,
hand: Hand,
variant: number | undefined,
placement: { row: number; col: number },
): string {
const v = variantsFor(geometry, hand)[variant ?? 0];
if (!v) return '';
const area = areaOf(game.state, 0);
const probe = {
geometry: { kind: 'track', geometry, ...v, ...(hand !== 'none' ? { hand } : {}) },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never;
const where: Record<Port, string> = { n: 'above', s: 'below', e: 'to the east', w: 'to the west' };
const met: string[] = [];
for (const p of ['n', 's', 'w', 'e'] as Port[]) {
const n = neighbour(placement, p);
const nb = area.grid.get(`${n.row},${n.col}`);
if (nb && joins(probe, p, nb)) met.push(where[p]);
}
return met.length === 0 ? '' : ` · joins the track ${met.join(' and ')}`;
}
/**
+64 -16
View File
@@ -254,25 +254,50 @@ function render(): void {
$('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>';
/**
* THE THREE DEPARTMENT PILES, and how deep each one is.
*
* They are decks, not single face-up cards: a discard goes on TOP of the one its owner chooses, so
* a pile is a card being offered with a history of cards buried under it. Only the top may be
* taken, which makes the depth real information — a deep pile is where cards have been put beyond
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself.
*/
const pileCard = (label: string, top: string, depth: number, why: string, tipExtra = ''): string => {
const tip = [why, tipExtra].filter(Boolean).join(' · ');
return (
`<div class="handcard"${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
$('depts').innerHTML =
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pileCard(
`Dept ${i + 1}`,
d,
depth,
f.departmentsWhat[i] ?? '',
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
);
})
.join('') +
// The Salvage Yard beside them, because §6.2 sweeps it back into the deck and it is the only
// visible sign that the reshuffle is coming.
pileCard(
'Salvage',
f.salvage.top,
f.salvage.depth,
'',
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
);
renderYards(f);
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>'
@@ -541,6 +566,29 @@ installTooltips();
const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave;
/**
* Forget the saved game and deal a fresh one.
*
* The only way out used to be finishing the game — `start()` restores from localStorage on every
* load, so a game you no longer wanted followed you across reloads, and the "new game" button
* appeared solely on the game-over screen. Confirmed because the save is the game: there is no undo,
* and the replay download is right beside it.
*
* `location.search = ''` rather than a direct re-render, so a `?seed=` in the URL goes too — leaving
* it would deal the same game again and look like the button had done nothing.
*/
const newBtn = document.getElementById('newgame');
if (newBtn) {
newBtn.onclick = () => {
const day = game.state.clock.day;
const started = game.state.status === 'active' && (day > 1 || game.state.clock.stage > 1);
if (started && !confirm(`Forget this game (seed ${game.seed}, Day ${day}) and deal a new one?`)) return;
clearSave();
if (location.search === '') location.reload();
else location.search = '';
};
}
const soundBtn = document.getElementById('sound');
if (soundBtn) {
const paint = (): void => {
+9 -2
View File
@@ -46,6 +46,13 @@ header button:hover{border-color:#4d6fa8}
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
/* A pile shows two things: which card is face up on top, and how many are under it. The depth is a
count and not a hint — only the top card may ever be drawn, so everything below it is out of
reach, and choosing where to discard is choosing what to put there. */
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim)}
.pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px;
padding:0 6px;color:#cfe0f5}
/* NOT YET PLAYABLE, drawn rather than captioned. A "not yet" tag had to be read; a crosshatch is
seen. The card stays legible underneath — it is unavailable, not irrelevant. */
.handcard.unplayable{color:#7d8794;border-color:#39424e}
@@ -188,6 +195,7 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Forget the game saved in this browser and deal a fresh one. There is no undo — download the replay first if you want to keep it.">New game</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
@@ -219,9 +227,8 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<section><h2 class="big">Your Move</h2><div id="actions"></div></section>
<section><h2 class="big">Cards Available</h2>
<h3>Cards in My Hand</h3><div id="hand" class="cardrow">—</div>
<h3>Department Cards (face up)</h3><div id="depts" class="cardrow ref">—</div>
<h3>Department Decks &amp; Salvage Yard — top card face up, with depth</h3><div id="depts" class="cardrow ref">—</div>
</section>
<section><h2 class="big">Your Track Supply</h2><div id="supply"></div></section>
<section><h2 class="big">The Yards</h2>
<h3>Division Yard <span class="dim" id="divtot"></span></h3>
<div id="divyard" class="yard"></div>