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:
+58
-15
@@ -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
@@ -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
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user