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 ')}`;
}
/**