v0.4.4 — New Game dialog for the opening hand and the three revenue rates, Yard renamed Interchange, and two movement bugs behind a mirrored consist

This commit is contained in:
Jesse
2026-08-14 22:39:50 -04:00
parent d1314066bd
commit 6655e20ea8
29 changed files with 5450 additions and 3902 deletions
+12 -7
View File
@@ -77,7 +77,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
/** Nose first, no engine — drawn as blocks, loaded solid and empty hollow. */
cars?: string[];
engineAt?: number;
facing?: string;
/** East or west, always — a Division runs east and west and so does its rolling stock. */
facing?: 'e' | 'w';
region?: number;
direction?: string;
stagesLeft?: number;
@@ -291,7 +292,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
*/
c.trains.forEach((t, k) => {
const cars = t.cars ?? [];
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
@@ -704,7 +705,7 @@ export function officeSvg(
kind: /^loaded/.test(c) || /caboose/.test(c) ? 'ld' : 'mt',
what: c,
}));
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng', what: 'the engine' });
/**
* WEST ON THE LEFT, AND THE NOSE POINTING THE WAY THE ENGINE FACES.
@@ -715,15 +716,19 @@ export function officeSvg(
* drawn engine-first at the WEST end, which reads as an engine shoving four cars ahead of it.
*
* The board is a map, so the drawing has to obey the map: reverse the seating order for an
* east-facing train and its nose lands at the east end, where it is. A crew on a north-south
* spur has no left or right to be right about, so it keeps nose-left and its \u25b2/\u25bc says the rest.
* east-facing train and its nose lands at the east end, where it is.
*
* A crew on a north-south spur is drawn east-west like every other train, because `facing` is
* now always east or west (`railFacingOf`). It used to keep its compass port and draw \u25b2 or \u25bc
* with the consist pinned nose-left, which meant the one thing the picture is for \u2014 which end
* the engine is on \u2014 flipped its convention the moment a crew turned a corner. Playtested and
* reported as more confusing than a strip drawn the same way every time.
*/
const laid = t.facing === 'e' ? [...items].reverse() : items;
const cw = 17;
const tw = Math.min(W - 8, laid.length * cw + 30);
const tx = W / 2 - tw / 2;
const facingWord =
t.facing === 'e' ? 'east' : t.facing === 'w' ? 'west' : t.facing === 'n' ? 'north' : 'south';
const facingWord = t.facing === 'e' ? 'east' : 'west';
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
out += `<g class="bs-crew" data-tip="${esc(
`${t.label} — engine pointing ${facingWord}, carrying ${consistWords}` + (t.what ? `\n\n${t.what}` : ''),
+30 -7
View File
@@ -21,7 +21,7 @@
* node src/sim/save-replay.ts 400 --top 3 trainCapSlack=0
*/
import { writeFileSync } from 'node:fs';
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -131,13 +131,33 @@ if (isMain) {
` best ${revenues.slice(0, 5).join(', ')} · median ${revenues[Math.floor(revenues.length / 2)]}`,
);
let written = 0;
for (const p of best) {
const verified = best.filter((p) => {
const check = verifyReplays(p);
if (!check.ok) {
console.warn(` REFUSED seed ${p.seed} — ${check.why}`);
continue;
if (!check.ok) console.warn(` REFUSED seed ${p.seed} — ${check.why}`);
return check.ok;
});
/**
* THE PUBLISHED SET IS REPLACED, NOT ADDED TO.
*
* Every file here is named for its seed, so re-recording used to leave the previous set sitting
* beside the new one — and the previous set is precisely the one whose rules have just moved. The
* point of re-recording is that those files are dead; keeping them means the site serves dead
* replays and `harness.test.ts` fails on them forever, which is how the last two went unnoticed.
*
* Only ever run after at least one replacement verifies, so a run that produces nothing publishable
* leaves what is already published alone.
*/
if (verified.length > 0) {
for (const old of readdirSync(dest).filter((f) => f.endsWith('.json') && f !== 'manifest.json')) {
if (verified.some((p) => `seed-${p.seed}.json` === old)) continue;
unlinkSync(join(dest, old));
console.log(` retired ${old} — recorded under rules that have since moved`);
}
}
let written = 0;
for (const p of verified) {
const file = join(dest, `seed-${p.seed}.json`);
writeFileSync(
file,
@@ -148,13 +168,16 @@ if (isMain) {
// there is room — a title carrying seven tweak names is a title nobody reads.
title: `${p.revenue} Revenue · seed ${p.seed}`,
note: `${p.note} · played by ${policy.name}`,
// The house rules it was DEALT under, without which the seed does not name this game and
// the file replays as something else — see `Save.rules` in `web/game.ts`.
...(p.save.rules ? { rules: p.save.rules } : {}),
history: p.save.history,
},
null,
1,
),
);
console.log(` wrote ${file} (${p.note}, ${check.why})`);
console.log(` wrote ${file} (${p.note})`);
written += 1;
}
console.log(`${written} replay(s) saved — run \`npm run build:web\` to publish them`);
+22 -10
View File
@@ -38,11 +38,12 @@ import {
lengthProfile,
officeProfile,
trainProfile,
houseRules,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import { playerAtSeat, seatOf, turnOf } from '../engine/state.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import { playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
@@ -76,13 +77,14 @@ export type CellView = {
* not plan a move at all: "drop 1 car" tells you nothing when you cannot see what is on the back.
*
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
* `facing` is the port it points at on this card.
* `facing` is which way the engine points — EAST OR WEST, never north or south, whatever the
* track under it runs. See `railFacingOf` in state.ts for why, and why the type says so.
*/
train: {
label: string;
cars: string[];
engineAt: number;
facing: string;
facing: 'e' | 'w';
/**
* WHAT THIS PARTICULAR TRAIN'S CARD SAYS.
*
@@ -188,13 +190,14 @@ export type TrainChip = {
* THE SAME TRAIN THE OFFICE CARD DRAWS, so the Division map can draw it the same way.
*
* `cars` is nose first and carries no engine; `engineAt` is where the engine sits among them and
* `facing` is the port it points at. The Division chip used to be a name and a number — and the
* number was Stages left to cross, which reads as redundant beside the position already drawn on
* the card. A train is worth drawing: what it is carrying, loaded or empty, and which end leads.
* `facing` is which way the engine points, east or west. The Division chip used to be a name and a
* number — and the number was Stages left to cross, which reads as redundant beside the position
* already drawn on the card. A train is worth drawing: what it is carrying, loaded or empty, and
* which end leads.
*/
cars: string[];
engineAt: number;
facing: string;
facing: 'e' | 'w';
/**
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
@@ -287,6 +290,14 @@ export type Frame = {
*/
/** Which of §6's three exclusive options the VIEWER has taken this Stage, if any. */
option: 'switch' | 'draw' | 'freightAgent' | null;
/**
* The settings this game was dealt under — the opening hand, and what the three economies pay.
*
* On the Frame rather than read off the config, for the same reason as everything else here: a
* remote client holds no `GameState`, and "what does a load pay in this game?" is a question it
* must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults.
*/
houseRules: HouseRules;
status: GameState['status'];
outcome: GameState['outcome'];
/**
@@ -513,7 +524,7 @@ function trainOnCard(s: GameState, key: string): CellView['train'] {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
cars: t.consist.map(carLabel),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
facing: railFacingOf(t),
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
};
void id;
@@ -1044,6 +1055,7 @@ export function snapshot(
decision,
wasted,
option: turnOf(s, viewer).option,
houseRules: houseRules(s.config),
status: s.status,
outcome: s.outcome,
players: s.players.map((p) => ({
@@ -1579,7 +1591,7 @@ function trainChip(s: GameState, id: string): TrainChip {
consist: seated,
cars,
engineAt: at,
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
facing: railFacingOf(t),
};
}