A second-digit bump for a playtest read back against the save file. Nine questions were asked of one three-Day game; three were bugs, three were the rules working and undocumented, three were decisions. Every save on the test server was replayed against this build BEFORE release, which is how the cost of each rule was known before it was chosen rather than discovered after. EVERY DISTRICT OPENS ON A DEPOT. A Whistle Post has one A/D track and is not a Passenger Facility, so the opening of every game was spent unable to work a passenger and one arrival away from a collision. Two A/D tracks and passengers from Stage 1 now; "Players start with Whistle Posts, not Depots" is the harder game, set when the game is created. The deck follows the choice — starting on Depots the four Depot upgrade cards are left out, because an upgrade must be to the next tier and a Depot card at a table of Depots is a dead draw. How much easier it is showed up as a test failure rather than an argument: the cue-coverage pool needed widening from 24 seeded games to 60 before it held one collision. NO SAVE WAS STRANDED BY IT, which took care. This is the one house rule that changes how a game is DEALT rather than how it plays, so replaying a save under the wrong opening is a different railroad from intent one — silently, with no error. `withSavedOpening` fills it on the replay paths ONLY. Putting it in the resolver instead made a fresh Cutthroat game deal Whistle Posts and read as Custom, which is how the distinction was found. THREE BUGS, ALL REPORTED FROM ONE GAME AND ALL CONFIRMED ON ITS SAVE. An Office held TWO TRAINS ON ONE A/D TRACK. The capacity test passed with nothing standing, the train the Interlocking had been holding at the Limits was moved into the free slot, and the arriving train was pushed in after it without anyone asking again whether there was room — so the collision §8.3 calls for never happened. The held train keeps priority; the newcomer now takes the consequence it would have met had the held train arrived first. THE HISTORY FROZE, permanently, and the log cap was not really the cause. Each seat's "what have I sent you" bookmark was an INDEX into an array the game trims, so once a seat's bookmark reached the limit the slice returned nothing for the rest of the game — at a different moment per seat, because each holds its own. That game's log ended at exactly the cap. Lines carry a sequence number now, which survives trimming; proven by pushing twice the cap through a simulated seat. §8.1 ASKED THE WRONG QUESTION TWICE. "Trains may pass" returned `clear` before the Subdivision was looked at, so a train entering a Double Track was released however busy the rest of it was — that, not anything about Control Points, is what let Train 8 out with no ruling. And a train standing at an Office was invisible to the scan, so one about to re-enter the very Subdivision being entered counted for nothing. Capacity is the test, not presence: a Depot with a track free is not in the way; a Whistle Post with its one track taken is. THINGS THAT HAPPENED SILENTLY NOW SAY SO — a train held against a facing one, a train released from the Limits (a side effect of somebody else's arrival, so it simply appeared at the Office), and the train an Interlocking is holding, whose explanatory tooltip has existed since #99 with NO renderer ever reading the flag. WHERE A MOVE IS REFUSED, AND WHY. `exploreMoves` decides where the rails go and the pick-up restrictions are enforced afterwards in `check`, so a square the rails reached and the card forbade was reachable, un-offered, and absent from the block list with nothing said. Those squares are blocked with the rule that blocks them now, and the reasons are got by ASKING `check` rather than re-deriving: a second implementation of the rules is exactly the failure the block list exists to avoid. A train may also always recover its own caboose — X13 prints "may drop but not pick up anything", and a train needs its caboose to be made up, so one that parted with it could never legally leave again. RULES DECIDED IN SEPTEMBER AND APPLIED HERE. A Modifier must sit square against its host, no diagonals. A passenger Modifier may not be played at a Whistle Post. Both were built, measured, held back for a fortnight so a playtest could finish, and applied now. A Second Section costs its card: `SECOND_SECTION` was declared in content.ts and never dealt, so the action was free and the bot ordered 26 accidental ones in a measured round. The card is dealt and spent — gating on a card the deck never holds would have deleted the mechanic rather than fixed it. THE DOCUMENTATION IS A SET OF PAGES, not five text files served as text/plain — a card reference is mostly tables, and as plain text a table is rows of pipes. Markdown is still the one copy; the build renders it, and publishes the .md beside each page. No Markdown library: this project has no runtime dependencies and one would be a poor first. The pages add what Markdown cannot carry without drifting — a nav across the set, a contents list built from the headings actually rendered, an anchor on every heading, a 70-character measure, and tables that are tables. They print as ink on paper. The references caught up with the rules, checked rather than assumed: two statements had gone from stale to misleading (the Quickstart told a new player to "get a Depot down as soon as one appears"), and four rules nobody could look up are written down — the Office tier table, §8.1 in practice, what the Circus Train pays for, and that a Realignment can be a card with no legal target. Adding one card to the deck reshuffles every seeded deal, which broke five fixtures. Each was a seed meaning "a game like this" — TODO #84, exactly — so seeds moved and pools widened rather than assertions weakening, and the clearance fixture pins its terrain the way `enhancements.test.ts` already does. The three published replays were re-recorded. Closes TODO #40, #42a, #108, #109 and #110. 1046 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
363 lines
15 KiB
TypeScript
363 lines
15 KiB
TypeScript
/**
|
|
* THE RULES BLOCK, driven identically on both screens.
|
|
*
|
|
* The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same twelve questions.
|
|
* They used to ask them in two hand-written copies and had already drifted — the dialog had "where
|
|
* an Extra may start" and no optional rules, the lobby the reverse — so this module owns reading,
|
|
* writing, comparing and annotating the block, and each screen supplies only the id prefix its
|
|
* markup uses (`lb-` or `ng-`).
|
|
*
|
|
* THE MARKUP ITSELF STAYS STATIC IN `play.html`, deliberately. Generating it here would be less
|
|
* duplication, but `test/web.test.ts` drives the emitted bundle against a hand-built DOM stub with
|
|
* no HTML parser in it (there is no jsdom in this project, and adding one to test a form is a poor
|
|
* trade), and the ids in that stub come from scanning the real `play.html`. A test in the same suite
|
|
* asserts both screens carry every key below, which is the drift guard the shared markup would have
|
|
* been.
|
|
*/
|
|
|
|
import { REVENUE_MAX, REVENUE_MIN } from '../engine/content.ts';
|
|
import type { ExtraStartRule, StartingHand } from '../engine/content.ts';
|
|
import { closestPreset, differencesFrom, presetOf, presetSettings, settingsOf } from './presets.ts';
|
|
import type { PresetName, Settings } from './presets.ts';
|
|
import type { GameConfig } from '../engine/state.ts';
|
|
|
|
/**
|
|
* How each rule is asked on screen.
|
|
*
|
|
* `gated` is the pair introduced 2026-08-23: a checkbox that says whether the condition applies at
|
|
* all, plus the number it applies at. The engine's convention is that `0` switches these off, which
|
|
* is exact but unreadable — a Cutthroat game showed two zeroes and left the player to know the
|
|
* convention. Unchecked still WRITES 0, so nothing under this changed.
|
|
*/
|
|
type Field =
|
|
| { key: keyof Settings; kind: 'radio'; id: string }
|
|
| { key: keyof Settings; kind: 'number'; id: string }
|
|
| { key: keyof Settings; kind: 'checkbox'; id: string }
|
|
| { key: keyof Settings; kind: 'gated'; id: string };
|
|
|
|
export const FIELDS: readonly Field[] = [
|
|
{ key: 'startingHand', kind: 'radio', id: 'hand' },
|
|
{ key: 'extraStart', kind: 'radio', id: 'extra' },
|
|
{ key: 'passengerPerCoach', kind: 'number', id: 'passenger' },
|
|
{ key: 'freightPerLoad', kind: 'number', id: 'freight' },
|
|
{ key: 'trainPerTransit', kind: 'number', id: 'transit' },
|
|
{ key: 'minCombinedRevenue', kind: 'gated', id: 'minrev' },
|
|
{ key: 'maxCollisionsPerDay', kind: 'gated', id: 'colday' },
|
|
{ key: 'maxCollisionsTotal', kind: 'gated', id: 'coltotal' },
|
|
{ key: 'reducedVisibility', kind: 'checkbox', id: 'visibility' },
|
|
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
|
|
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
|
|
{ key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' },
|
|
{ key: 'startWhistlePosts', kind: 'checkbox', id: 'whistlestart' },
|
|
];
|
|
|
|
/**
|
|
* What `play.html` must contain for a screen to be able to ask all twelve questions — the exact
|
|
* attribute text, so `test/web.test.ts` can assert it against the built page.
|
|
*
|
|
* THIS IS THE DRIFT GUARD. The two blocks are generated from one template today; this is what says
|
|
* so tomorrow, when someone edits one of them by hand.
|
|
*/
|
|
export function fieldSelectors(prefix: string): string[] {
|
|
const out: string[] = [];
|
|
for (const f of FIELDS) {
|
|
// A radio group is addressed by NAME — there is no one element carrying the field's id.
|
|
out.push(f.kind === 'radio' ? `name="${prefix}${f.id}"` : `id="${prefix}${f.id}"`);
|
|
if (f.kind === 'gated') out.push(`id="${prefix}${f.id}-on"`);
|
|
out.push(`id="${prefix}${f.id}-row"`, `id="${prefix}${f.id}-hint"`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The label each field goes under in the read-only view below. */
|
|
const FIELD_LABELS: Record<keyof Settings, string> = {
|
|
startingHand: 'Starting hand',
|
|
extraStart: 'An Extra may start at',
|
|
discardTimetabled: 'A Timetabled train may be discarded',
|
|
startWhistlePosts: 'Players start with Whistle Posts, not Depots',
|
|
passengerPerCoach: 'Passenger per coach',
|
|
freightPerLoad: 'Freight per load',
|
|
trainPerTransit: 'Train per transit',
|
|
minCombinedRevenue: 'Combined Revenue floor',
|
|
maxCollisionsPerDay: 'Collisions in one Day',
|
|
maxCollisionsTotal: 'Collisions in the game',
|
|
reducedVisibility: 'Reduced Visibility',
|
|
employeeRotation: 'Employee Rotation',
|
|
emergencyToolbox: 'Emergency Toolbox',
|
|
};
|
|
|
|
const esc = (s: string): string =>
|
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
/**
|
|
* THE WHOLE RULE SET, READ-ONLY — what a player weighing a join reads before taking a seat, and what
|
|
* the seating screen keeps showing afterwards.
|
|
*
|
|
* Generated rather than a fourth copy of the form: nobody may change these, so form controls would
|
|
* be a lie, and a `<dl>` says "this is what you are joining" in a third of the space. Fields that
|
|
* differ from the nearest named type are amber here for the same reason they are amber in the form —
|
|
* a non-standard game should never be a surprise.
|
|
*/
|
|
export function rulesListHtml(config: GameConfig, players: number, days: number): string {
|
|
const settings = settingsOf(config);
|
|
const type = presetOf(config, players, days);
|
|
const near = closestPreset(config, players, days);
|
|
const differing = type === 'custom' ? near.differing : [];
|
|
|
|
const rows = (keys: (keyof Settings)[]): string =>
|
|
keys
|
|
.map((k) => {
|
|
const changed = differing.includes(k) ? ' class="changed"' : '';
|
|
return `<dt>${esc(FIELD_LABELS[k])}</dt><dd${changed}>${esc(describe(k, settings[k]))}</dd>`;
|
|
})
|
|
.join('');
|
|
|
|
const head =
|
|
`<dl><dt>Players</dt><dd>${players}</dd><dt>Days</dt><dd>${days}</dd>` +
|
|
`<dt>Opponent-directed cards</dt><dd>not implemented yet</dd></dl>`;
|
|
|
|
return (
|
|
head +
|
|
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart', 'startWhistlePosts'])}</dl>` +
|
|
`<h4>Train cards</h4><dl>${rows(['discardTimetabled'])}</dl>` +
|
|
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
|
|
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
|
|
`<h4>Optional rules</h4><dl>${rows(['reducedVisibility', 'employeeRotation', 'emergencyToolbox'])}</dl>`
|
|
);
|
|
}
|
|
|
|
/** What a value looks like beside its label — "Co-op default: 75", "Co-op default: six random cards". */
|
|
function describe(key: keyof Settings, value: Settings[keyof Settings]): string {
|
|
if (typeof value === 'boolean') return value ? 'on' : 'off';
|
|
if (key === 'startingHand') {
|
|
const words: Record<StartingHand, string> = {
|
|
threeRandom: 'three random',
|
|
sixRandom: 'six random',
|
|
threeTrackThreeOther: 'three track and three other',
|
|
};
|
|
return words[value as StartingHand];
|
|
}
|
|
if (key === 'extraStart') {
|
|
const words: Record<ExtraStartRule, string> = {
|
|
divisionPointsOnly: 'Division Points only',
|
|
ownOffice: 'your own Control Point',
|
|
anyOffice: 'any Control Point',
|
|
};
|
|
return words[value as ExtraStartRule];
|
|
}
|
|
// A victory condition at 0 is not "0" on screen, it is a condition that does not apply.
|
|
if (value === 0 && (key === 'minCombinedRevenue' || key === 'maxCollisionsPerDay' || key === 'maxCollisionsTotal')) {
|
|
return 'off';
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
export type SettingsForm = {
|
|
read(): Settings;
|
|
/** `suggestions` fills the placeholder of a switched-off condition, so ticking it back on has a
|
|
* number to offer rather than an empty box. */
|
|
write(values: Settings, suggestions: Settings): void;
|
|
/**
|
|
* Paint the block against a named type: every field that differs goes amber and every field gets
|
|
* its "<Type> default: x" hint. Returns what differs, for the summary line the screen draws.
|
|
*/
|
|
mark(name: PresetName, players: number, days: number): (keyof Settings)[];
|
|
/** Custom with no named baseline to compare against — clears the paint rather than lying. */
|
|
clearMarks(): void;
|
|
/** Called whenever the player changes any rule; the screen answers by selecting Custom. */
|
|
onEdit(fn: (key: keyof Settings) => void): void;
|
|
/** Read-only for the join preview and the seating screen: shown in full, changeable by nobody. */
|
|
setEditable(on: boolean): void;
|
|
/** Employee Rotation is meaningless at one player — disabled with a note rather than hidden, so
|
|
* the two screens still read the same. */
|
|
setEmployeeRotationAvailable(on: boolean): void;
|
|
};
|
|
|
|
const $ = <T extends HTMLElement = HTMLElement>(id: string): T | null =>
|
|
document.getElementById(id) as T | null;
|
|
|
|
export function settingsForm(prefix: string): SettingsForm {
|
|
const el = <T extends HTMLElement = HTMLElement>(id: string): T | null => $<T>(`${prefix}${id}`);
|
|
const radios = (name: string): HTMLInputElement[] =>
|
|
Array.from(document.querySelectorAll<HTMLInputElement>(`input[name="${prefix}${name}"]`));
|
|
const checkedRadio = (name: string): string | null =>
|
|
radios(name).find((r) => r.checked)?.value ?? null;
|
|
|
|
let editHandler: ((key: keyof Settings) => void) | null = null;
|
|
let editable = true;
|
|
|
|
function num(id: string, fallback: number): number {
|
|
const raw = el<HTMLInputElement>(id)?.value ?? '';
|
|
return raw.trim() === '' || !Number.isFinite(Number(raw)) ? fallback : Math.round(Number(raw));
|
|
}
|
|
|
|
function readGated(id: string): number {
|
|
// Unchecked IS zero — the engine's "off". The number in the box is kept so re-ticking restores it.
|
|
if (el<HTMLInputElement>(`${id}-on`)?.checked !== true) return 0;
|
|
return Math.max(0, num(id, 0));
|
|
}
|
|
|
|
function read(): Settings {
|
|
return {
|
|
startingHand: (checkedRadio('hand') ?? 'sixRandom') as StartingHand,
|
|
extraStart: (checkedRadio('extra') ?? 'anyOffice') as ExtraStartRule,
|
|
passengerPerCoach: clampRevenue(num('passenger', 1)),
|
|
freightPerLoad: clampRevenue(num('freight', 1)),
|
|
trainPerTransit: clampRevenue(num('transit', 0)),
|
|
minCombinedRevenue: readGated('minrev'),
|
|
maxCollisionsPerDay: readGated('colday'),
|
|
maxCollisionsTotal: readGated('coltotal'),
|
|
reducedVisibility: el<HTMLInputElement>('visibility')?.checked === true,
|
|
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
|
|
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
|
|
discardTimetabled: el<HTMLInputElement>('tossloco')?.checked === true,
|
|
startWhistlePosts: el<HTMLInputElement>('whistlestart')?.checked === true,
|
|
};
|
|
}
|
|
|
|
function write(values: Settings, suggestions: Settings): void {
|
|
for (const r of radios('hand')) r.checked = r.value === values.startingHand;
|
|
for (const r of radios('extra')) r.checked = r.value === values.extraStart;
|
|
setNumber('passenger', values.passengerPerCoach);
|
|
setNumber('freight', values.freightPerLoad);
|
|
setNumber('transit', values.trainPerTransit);
|
|
writeGated('minrev', values.minCombinedRevenue, suggestions.minCombinedRevenue);
|
|
writeGated('colday', values.maxCollisionsPerDay, suggestions.maxCollisionsPerDay);
|
|
writeGated('coltotal', values.maxCollisionsTotal, suggestions.maxCollisionsTotal);
|
|
setChecked('visibility', values.reducedVisibility);
|
|
setChecked('rotation', values.employeeRotation);
|
|
setChecked('toolbox', values.emergencyToolbox);
|
|
setChecked('tossloco', values.discardTimetabled);
|
|
setChecked('whistlestart', values.startWhistlePosts);
|
|
}
|
|
|
|
function setNumber(id: string, value: number): void {
|
|
const input = el<HTMLInputElement>(id);
|
|
if (input) input.value = String(value);
|
|
}
|
|
|
|
function setChecked(id: string, on: boolean): void {
|
|
const input = el<HTMLInputElement>(id);
|
|
if (input) input.checked = on;
|
|
}
|
|
|
|
function writeGated(id: string, value: number, suggestion: number): void {
|
|
const box = el<HTMLInputElement>(`${id}-on`);
|
|
const input = el<HTMLInputElement>(id);
|
|
if (box) box.checked = value > 0;
|
|
if (input) {
|
|
// A switched-off condition shows an empty box with the suggestion as its placeholder, rather
|
|
// than a `0` the player has to decode.
|
|
input.value = value > 0 ? String(value) : '';
|
|
input.placeholder = String(suggestion);
|
|
input.disabled = !editable || value === 0;
|
|
}
|
|
}
|
|
|
|
function mark(name: PresetName, players: number, days: number): (keyof Settings)[] {
|
|
const want = presetSettings(name, players, days);
|
|
const differing = differencesFrom(name, read(), players, days);
|
|
const label = presetLabelOf(name);
|
|
for (const f of FIELDS) {
|
|
const row = el(`${f.id}-row`);
|
|
const hint = el(`${f.id}-hint`);
|
|
const off = differing.includes(f.key);
|
|
row?.classList.toggle('changed', off);
|
|
if (hint) hint.textContent = `${label} default: ${describe(f.key, want[f.key])}`;
|
|
}
|
|
return differing;
|
|
}
|
|
|
|
function clearMarks(): void {
|
|
for (const f of FIELDS) {
|
|
el(`${f.id}-row`)?.classList.remove('changed');
|
|
const hint = el(`${f.id}-hint`);
|
|
if (hint) hint.textContent = '';
|
|
}
|
|
}
|
|
|
|
function setEditable(on: boolean): void {
|
|
editable = on;
|
|
for (const f of FIELDS) {
|
|
if (f.kind === 'radio') {
|
|
for (const r of radios(f.id)) r.disabled = !on;
|
|
continue;
|
|
}
|
|
const input = el<HTMLInputElement>(f.id);
|
|
if (input) {
|
|
// A gated number stays disabled when its own condition is off, whatever the block's state.
|
|
const gatedOff = f.kind === 'gated' && el<HTMLInputElement>(`${f.id}-on`)?.checked !== true;
|
|
input.disabled = !on || gatedOff;
|
|
}
|
|
if (f.kind === 'gated') {
|
|
const box = el<HTMLInputElement>(`${f.id}-on`);
|
|
if (box) box.disabled = !on;
|
|
}
|
|
}
|
|
}
|
|
|
|
function setEmployeeRotationAvailable(on: boolean): void {
|
|
const input = el<HTMLInputElement>('rotation');
|
|
if (input) {
|
|
input.disabled = !on || !editable;
|
|
if (!on) input.checked = false;
|
|
}
|
|
el('rotation-row')?.classList.toggle('unavailable', !on);
|
|
}
|
|
|
|
/** One change handler for every control — the screens all answer it the same way (select Custom). */
|
|
for (const f of FIELDS) {
|
|
const fire = (): void => editHandler?.(f.key);
|
|
if (f.kind === 'radio') {
|
|
for (const r of radios(f.id)) r.onchange = fire;
|
|
continue;
|
|
}
|
|
const input = el<HTMLInputElement>(f.id);
|
|
if (input) {
|
|
input.oninput = fire;
|
|
input.onchange = fire;
|
|
}
|
|
if (f.kind === 'gated') {
|
|
const box = el<HTMLInputElement>(`${f.id}-on`);
|
|
if (box) {
|
|
box.onchange = () => {
|
|
const number = el<HTMLInputElement>(f.id);
|
|
if (number) {
|
|
number.disabled = !box.checked || !editable;
|
|
// Ticking a condition back on with an empty box takes the suggestion showing in it,
|
|
// so "on" never means "on, at zero".
|
|
if (box.checked && number.value.trim() === '') number.value = number.placeholder;
|
|
}
|
|
fire();
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
read,
|
|
write,
|
|
mark,
|
|
clearMarks,
|
|
onEdit: (fn) => void (editHandler = fn),
|
|
setEditable,
|
|
setEmployeeRotationAvailable,
|
|
};
|
|
}
|
|
|
|
function clampRevenue(n: number): number {
|
|
return Math.max(REVENUE_MIN, Math.min(REVENUE_MAX, n));
|
|
}
|
|
|
|
/** Kept local rather than imported from `presets.ts`'s `preset()`, which would drag the whole table
|
|
* in for one word — and this is the only place a hint needs it. */
|
|
function presetLabelOf(name: PresetName): string {
|
|
const labels: Record<PresetName, string> = {
|
|
solitaire: 'Solitaire',
|
|
coop: 'Co-op',
|
|
competitive: 'Competitive',
|
|
cutthroat: 'Cutthroat',
|
|
};
|
|
return labels[name];
|
|
}
|