fixed train# specific rules. cleaned up display topline and train display on division map

This commit is contained in:
Jesse
2026-08-09 19:44:18 -04:00
parent 4a800a053a
commit 6ead39c530
14 changed files with 316 additions and 57 deletions
+33
View File
@@ -175,9 +175,42 @@ Ordered within each section by how much it is currently costing us.
Blocked on a decision, not on work.
- [ ] **PROPOSED RULE — deal each player six track cards at the start.** Jesse's proposal, and it
aims squarely at the measured problem above: a run-around needs five specific pieces and the
bot holds a turnout and a matching curve together on 0.2% of turns. Six pieces in the opening
hand is roughly the private supply the prototype had, in a form that does not reintroduce an
unbounded one — and it would make the opening district a decision rather than a wait. Questions
before it goes in: are the six drawn from the track cards in the deck (thinning it for
everyone) or from outside it; do they sit in the hand, against the three-card limit, or in a
separate track hand that does not compete; and does the bot's opening change enough to need
re-measuring (it will — this is the one change that could move the run-around numbers).
- [ ] **PROPOSED RULE — one Revenue point for every train that exits your section.** Jesse's
proposal. Worth noting what it would do to the economy as measured: arrivals run at 5.4 a game
and completed runs at about the same, so this is roughly **+5 Revenue a game** against a
current mean of 2.6 — it would more than triple the score and, unlike freight or passengers, it
pays for traffic the player does not have to work. That may be exactly the intent (it rewards
keeping the line clear, which is the Superintendent's job) but it changes what the game is
about, and it interacts with the victory-target question below: 20 over 5 Days becomes
reachable almost on traffic alone. Cheap to measure once decided.
- [x] ~~**Q13 — rear-end collisions on a Mainline card.**~~ Answered: collide on catching up.
Implemented, and not on cards that print "trains may pass". Invisible to a bot that always
denies clearance; a bot that always allows drops from 7.34 revenue to **-5.13**.
- [ ] **NINE OF THE TWELVE SPECIAL-TRAIN RULES ARE DECLARED AND READ BY NOTHING.** Found by grepping
each flag on `TrainRules` for a reader outside `content.ts`. Only `expedite`, `emptiesOnly` and
`freightTypes` are enforced; `stopEarnsPoint` now is too, after a playtest reported the Circus
Train standing still for a Stage and earning nothing. Still unbuilt:
`noSwitching` · `terminalsOnly` · `coachStaysOnStationTrack` · `oneFreightPerLocation` ·
`noPassengerWork` · `dropOnly` · `pickUpEmptiesOnly` · `stopThenExpedite` · `copiesNextScheduled`
These are what make a special train special — a Circus Train that may not switch, a per-diem
train that may only pick up empties, a Second Section that copies the train ahead. Until they
are enforced, `trainRules()` marks them **NOT YET ENFORCED BY THE ENGINE** in the train's
tooltip rather than listing them as if they applied, because telling a player a rule is in
force when it is not is worse than saying nothing. Each is small on its own; the question is
whether they are worth building before the rebalance, since several of them restrict switching
and would move the freight numbers.
- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect
as "TBD in the source". A test asserts it stays TBD so nobody invents one.
- [ ] **Heavy Grade orientation at setup.** The card says "Player sets orientation", but `createGame`
+42
View File
@@ -307,7 +307,49 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
for (const [id, tray] of order) {
if (s.movedThisPhase.has(id)) continue;
const where = tray.position;
const moved = moveTrain(s, id, tray, events);
/**
* X18 CIRCUS TRAIN — "one turn stopped on any track (circus set-up) earns 1 point".
*
* The flag was declared on the profile and read NOWHERE, so the one card in the deck that pays
* for standing still paid nothing: reported from a playtest where TX18 sat on a siding for a
* full Stage and no point arrived. Claimed once — an Extra runs once and is gone.
*
* "Stopped" is measured against the Mainline Phase: the train attempted to move and stayed where
* it was. A train that is still in the district when the phase runs has not moved either, which
* is the circus setting up on a siding rather than crossing the Division.
*/
if (!tray.stopPointClaimed && trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopEarnsPoint) {
const stillThere =
tray.position.at === where.at &&
(tray.position.at !== 'mainline' || where.at !== 'mainline' || tray.position.index === where.index) &&
(tray.position.at !== 'grid' ||
where.at !== 'grid' ||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
if (moved !== 'expedited' && stillThere) {
tray.stopPointClaimed = true;
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
const label =
tray.position.at === 'grid'
? `(${tray.position.coord.row},${tray.position.coord.col})`
: tray.position.at === 'mainline'
? `Mainline card ${tray.position.index}`
: `the ${tray.position.side} Division Point`;
events.push({ type: 'trainStoodStill', trainNumber: tray.trainNumber ?? 0, where: label });
const p = s.players[owner];
if (p) {
p.revenue += 1;
events.push({
type: 'revenueChanged',
player: owner,
delta: 1,
total: p.revenue,
reason: 'circus set-up — a Stage spent standing still',
});
}
}
}
if (moved === 'needsClearance') return { events, needsInput: true };
// An expedited train may act twice in one Stage: it arrives and departs (Q3).
if (moved === 'expedited') {
+8
View File
@@ -480,6 +480,13 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
return f.inboundBox[i.index] ? null : 'BOX_EMPTY';
}
case 'freightAgent.end':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
// §6.3 offers three things the Freight Agent may do and requires none of them. Ending with the
// action unspent is a wasted Stage, which is the player's to waste — the alternative was
// forcing an unjam that destroys a load.
return s.turn.option === 'freightAgent' ? null : 'OPTION_NOT_CHOSEN';
case 'freightAgent.unjam': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
@@ -864,6 +871,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'switch.end':
case 'draw.end':
case 'freightAgent.end':
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
case 'draw.fromHomeOffice': {
+2
View File
@@ -88,6 +88,8 @@ export type GameEvent =
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
| { type: 'trainHeld'; trainNumber: number; reason: string }
/** A train whose card pays for standing still (X18 Circus) collected on it. */
| { type: 'trainStoodStill'; trainNumber: number; where: string }
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
/**
* `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train
+8
View File
@@ -55,6 +55,14 @@ export type Intent =
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
| { type: 'freightAgent.clearInbound'; at: GridCoord; index: number }
| { type: 'freightAgent.unjam'; at: GridCoord; from: 'outbound' | 'inbound' | 'menAtWork'; index: number }
/**
* Finish the turn having chosen the Freight Agent and done nothing.
*
* §6.3 lists three things the Freight Agent MAY do; it does not say one must be done. Without this
* the option was a trap — having chosen it, the only legal moves were to stock, to clear, or to
* UNJAM, and unjamming a healthy facility throws away a load that cost a whole action to stock.
*/
| { type: 'freightAgent.end' }
// -- New Train Phase (§7)
| { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean }
| { type: 'newTrain.passCar'; trayId: TrayId }
+3
View File
@@ -177,6 +177,9 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
out.push({ type: 'draw.end' });
// -- freight agent (§6.3)
// Ending without acting is always on offer: the section lists what the Freight Agent MAY do and
// requires none of it.
out.push({ type: 'freightAgent.end' });
for (const coord of facilityCoords(s, player)) {
for (const carType of CAR_TYPES) {
out.push({ type: 'freightAgent.stockOutbound', at: coord, carType });
+7
View File
@@ -256,6 +256,13 @@ export type CrewTray = {
facing?: 'n' | 's' | 'e' | 'w';
position: NodeRef;
movesUsed: number;
/**
* X18 Circus Train — "one turn stopped on any track (circus set-up) earns 1 point", claimed once.
*
* Recorded on the tray rather than the player because it is the TRAIN that sets up, and an Extra
* runs once and is gone; there is no second visit to claim it on.
*/
stopPointClaimed?: boolean;
};
// ---------------------------------------------------------------------------
+19 -42
View File
@@ -277,64 +277,41 @@ export function divisionSvg(nodes: DivisionView[]): string {
}
/**
* A TRAIN, DRAWN AS A TRAIN — the same vocabulary the Office Area card uses.
* ON THE DIVISION MAP, A TRAIN IS A CHIP — name, which way it points, how many cars.
*
* The chip was a name and a figure, and the figure was Stages left to cross this card: read
* once as the car count, and once the unit was spelled out, read as redundant beside the
* position the card already draws. What it never said is the thing worth knowing at a glance —
* what the train is carrying, whether those cars are loaded, and which end the engine is on.
* It was drawn as a full consist here, matching the Office Area card, and reported as too large
* and hard to read: a Mainline cell is 152px and a Running Track cell 78px, against a district
* card's 166px, and a four-car train plus its engine has to squeeze into a fifth of that. The
* Office card is where the consist is worth drawing, because that is where the switching
* decisions are made and where there is room to read it.
*
* WEST ON THE LEFT here too. `cars` is nose first, so an east-facing train is drawn reversed and
* its engine lands at the east end, exactly as on the district card.
* So this keeps the two things the Division map is actually for — where a train is and which way
* it is going — and leaves the cars to the tooltip and to the district.
*/
c.trains.forEach((t, k) => {
const kindOf = (car: string): string =>
/caboose/.test(car) ? 'cab' : /hopper/.test(car) ? 'hop' : /boxcar/.test(car) ? 'box'
: /reefer/.test(car) ? 'rfr' : /tank/.test(car) ? 'tnk' : /coach/.test(car) ? 'cch' : '?';
const cars = t.cars ?? [];
const arrow = t.facing === 'w' ? '◀' : t.facing === 'e' ? '▶' : t.facing === 'n' ? '▲' : '▼';
const items: { label: string; kind: string }[] = cars.map((car) => ({
label: kindOf(car),
kind: /^loaded/.test(car) || /caboose/.test(car) ? 'ld' : 'mt',
}));
items.splice(Math.max(0, Math.min(items.length, t.engineAt ?? 0)), 0, { label: arrow, kind: 'eng' });
const laid = t.facing === 'e' ? [...items].reverse() : items;
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
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}`;
const nameW = t.label.length * 6.5 + 6;
const w = Math.min(c.w - 8, nameW + laid.length * 13 + 6);
/**
* NARROW THE CARS RATHER THAN DROP THEM.
*
* A Running Track cell is 78 wide and a four-car train plus its engine wants more than that.
* Squeezing is honest; leaving cars out is not — a chip showing three of five cars is a chip
* that lies about the train. Below 11px the three-letter type will not fit, so the blocks
* carry the load state alone and the tooltip carries the words.
*/
const CARW = Math.max(5, Math.min(13, (w - nameW - 6) / Math.max(1, laid.length)));
const w = Math.min(c.w - 8, label.length * 6.6 + 12);
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
const inRegion = c.regions > 1 && typeof t.region === 'number';
const slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2;
const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
const tx = slotX + spread;
const dir = t.direction === 'west' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : '';
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
const stages =
typeof t.stagesLeft === 'number'
? ` · ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` +
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` +
' (Stages, not regions: a card is two regions of fixed distance, and how many Stages a' +
' train takes over them depends on the card speed and the train)'
: '';
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(cars.join(', ') || 'no cars')}, nose first${
inRegion ? ` · region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''
}${esc(stages)}">` +
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
cars.length ? ` (${loaded} loaded)` : ''
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''}${esc(stages)}">` +
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
`<text class="bs-tlab" x="${tx - w / 2 + 4}" y="${c.y + 35}">${esc(t.label)}</text>`;
laid.forEach((it, n) => {
const bx = tx - w / 2 + nameW + n * CARW;
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${c.y + 25}" width="${Math.max(3, CARW - 2)}" height="13" rx="2"/>`;
if (CARW >= 11) {
out += `<text class="bs-tcarlab" x="${bx + (CARW - 2) / 2}" y="${c.y + 35}" text-anchor="middle">${esc(it.label)}</text>`;
}
});
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(label)}</text>`;
out += '</g>';
});
out += '</g>';
@@ -716,7 +693,7 @@ export function officeSvg(
t.facing === 'e' ? 'east' : t.facing === 'w' ? 'west' : t.facing === 'n' ? 'north' : 'south';
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
out += `<g class="bs-crew" data-tip="${esc(
`${t.label} \u2014 engine pointing ${facingWord}, carrying ${consistWords} (nose first). Drawn as it stands: west is on the left.`,
`${t.label} — engine pointing ${facingWord}, carrying ${consistWords}` + (t.what ? `\n\n${t.what}` : ''),
)}"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
laid.forEach((it, n) => {
+9 -4
View File
@@ -1472,10 +1472,15 @@ function followThrough(
* §6.3's unjam is for a load stranded on MEN|AT|WORK that cannot advance. A green box holding
* a load waiting for a car is not jammed, it is waiting.
*/
// The option has no "end" action, so something must be done. Order by what it costs to lose:
// a load stranded on MEN|AT|WORK is already blocking the pipeline, a red box holds a load
// whose Revenue is banked, and the green box is the only one where discarding destroys work
// that has not been paid for yet. It is last deliberately.
/**
* NOTHING WORTH DOING — SO DO NOTHING.
*
* The option used to have no "end" action, so this fell through to unjamming whichever box
* was least costly to wreck. §6.3 requires no action at all, and it now says so: a wasted
* Stage is strictly better than destroying a load that cost a whole action to stock.
*/
const idle = options.find((i) => i.type === 'freightAgent.end');
if (idle) return because('nothing productive at any facility — leave the Freight Agent idle rather than wreck a working box', idle);
const byCost = (['menAtWork', 'inbound', 'outbound'] as const).map((from) =>
options.find((i) => i.type === 'freightAgent.unjam' && i.from === from),
);
+47
View File
@@ -231,6 +231,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'good',
text: `${e.isExtra ? `EXTRA X${e.trainNumber}` : `TRAIN ${e.trainNumber}`} MADE UP at the ${e.at}, running ${e.direction} — crew assigned, now taking cars`,
};
case 'trainStoodStill':
return {
tone: 'good',
text:
`Train ${e.trainNumber} stood still for a whole Stage at ${e.where} and earned a point — ` +
'its card pays for the stop, not for the run (circus set-up)',
};
case 'trainHeld':
return {
tone: 'bad',
@@ -525,6 +532,46 @@ export function impediments(s: GameState, player = 0): Impediment[] {
}
}
/**
* WHY THE PASSENGERS ARE NOT GETTING OFF.
*
* Reported: "I can't figure out how to have a train with three passenger coaches unload all three
* at my depot." You cannot — a Depot has ONE Porter and ONE red slot, so it works one coach a
* Stage and then needs a Freight Agent action to clear the box before the next. A Station does two
* and a Terminal three. That is the Office ladder doing its job, and nothing on screen said so.
*
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which is a
* requirement the engine only started enforcing recently and which no panel mentions at all.
*/
const office = area.grid.get(coordKey(area.officeCoord));
const pf = office?.facility;
if (pf && pf.kind === 'passenger') {
const waiting = area.adOccupancy
.map((id) => s.trays.get(id))
.filter((t) => t?.consist.some((c) => c.type === 'coach' && c.loaded));
if (waiting.length > 0) {
const reasons: string[] = [];
if (portersLeft(pf) < 1) {
reasons.push(
`all ${pf.porters} Porter${pf.porters === 1 ? '' : 's'} used this Stage — one works one coach, ` +
'and the Office tier is the Porter count (Depot 1, Station 2, Terminal 3)',
);
}
if (pf.inboundBox.length >= pf.capacity.inbound) {
reasons.push(
`the red Unloading box is full (${pf.capacity.inbound} slot${pf.capacity.inbound === 1 ? '' : 's'}) — ` +
'a Freight Agent action clears it, which costs a whole Local Operations turn',
);
}
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) {
reasons.push('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
}
for (const why of reasons) {
out.push({ where: 'passengers waiting to de-train', why, severity: 'waiting' });
}
}
}
// Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
const due = s.timetable[s.clock.stage - 1];
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
+61 -1
View File
@@ -66,7 +66,20 @@ export type CellView = {
* `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.
*/
train: { label: string; cars: string[]; engineAt: number; facing: string } | null;
train: {
label: string;
cars: string[];
engineAt: number;
facing: string;
/**
* WHAT THIS PARTICULAR TRAIN'S CARD SAYS.
*
* Reported from a playtest: the Circus Train arrived and there was no way to find out what made
* it a Circus Train. A special train is special only if the player can read the rule while it is
* standing in front of them — the card is face down in a box somewhere by then.
*/
what: string;
} | null;
/**
* Office card only: A/D tracks taken and how many the tier has.
*
@@ -424,6 +437,7 @@ function trainOnCard(s: GameState, key: string): CellView['train'] {
cars: t.consist.map(carLabel),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
};
void id;
}
@@ -657,6 +671,8 @@ export function describeIntent(s: GameState, i: Intent): string {
return 'End Local Operations';
case 'switch.end':
return 'End Local Operations';
case 'freightAgent.end':
return 'End Local Operations — leave the Freight Agent idle';
case 'loadUnload.end':
return 'End my Cargo phase';
case 'newTrain.passCar':
@@ -1049,6 +1065,50 @@ export function cardDescription(s: GameState, id: string): string {
}
}
/**
* EVERYTHING THIS TRAIN'S CARD PRINTS, in one line.
*
* The name, its class, what its consist should be, and — the part that prompted this — whatever
* special rule the card carries. Nine of the twelve rule flags are declared on the profiles and read
* by nothing in the engine, so those are marked as not yet implemented rather than quietly listed:
* telling a player a rule applies when it does not is worse than saying nothing.
*/
export function trainRules(t: {
trainNumber: number | null;
trainIsExtra: boolean;
}): string {
const p = trainProfile(t.trainNumber ?? 0, t.trainIsExtra);
if (!p) return '';
const parts: string[] = [`${p.isExtra ? 'Extra X' : 'Train '}${p.number} “${p.name}” · ${p.speed}`];
const consist: string[] = [];
if (p.consist.freight > 0) {
consist.push(`${p.consist.freight} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
}
if (p.consist.coach > 0) consist.push(`${p.consist.coach} coach${p.consist.coach > 1 ? 'es' : ''}`);
if (p.consist.caboose > 0) consist.push(`${p.consist.caboose} caboose`);
parts.push(`its card calls for ${consist.join(' + ') || 'no cars'}`);
if (p.rules.note) parts.push(p.rules.note);
// Only `expedite` and the consist restrictions are actually enforced today (§7, §8.2), plus the
// Circus Train's point for standing still. The rest are printed on the profile and read nowhere.
const unbuilt: string[] = [];
if (p.rules.noSwitching) unbuilt.push('no switching');
if (p.rules.terminalsOnly) unbuilt.push('terminals only');
if (p.rules.coachStaysOnStationTrack) unbuilt.push('the coach stays on the station track');
if (p.rules.oneFreightPerLocation) unbuilt.push('one freight car per location');
if (p.rules.noPassengerWork) unbuilt.push('no passenger work');
if (p.rules.dropOnly) unbuilt.push('may drop but not pick up');
if (p.rules.pickUpEmptiesOnly) unbuilt.push('may pick up empties only');
if (p.rules.stopThenExpedite) unbuilt.push('stops, then runs expedited');
if (unbuilt.length > 0) parts.push(`NOT YET ENFORCED BY THE ENGINE: ${unbuilt.join('; ')}`);
if (p.rules.expedite) parts.push('EXPEDITED — it departs in the same Stage it arrives (§7)');
if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once');
return parts.join(' · ');
}
/** The train riding a Crew Tray, for anything that has to talk about it. */
export function trainName(s: GameState, trayId: string): string {
const tray = s.trays.get(trayId);
+25 -2
View File
@@ -152,9 +152,17 @@ function render(): void {
renderTurnChart(f);
$('revenue').textContent = String(f.revenue);
/**
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
*
* It used to read "3 of 20 · 2 Days left · behind the pace (expected 8)". The score, the target and
* the Days left are what a player steers by; the pace verdict and the engine's guess at what the
* score ought to be were an opinion taking up the one line that must not wrap — and the expected
* figure came from a target that is itself an open question.
*/
const obj = $('objective');
obj.textContent = f.objective.note;
obj.className = f.objective.onPace ? 'pace good' : 'pace behind';
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
obj.className = 'pace';
$('seed').textContent = String(game.seed);
// -- division
@@ -794,9 +802,24 @@ if (newBtn) {
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;
/**
* ASK FOR THE SEED, rather than documenting a URL parameter in the title bar.
*
* The same deal can be replayed, shared or compared by seed, which is worth offering — it was
* offered as the note "add ?seed=1234 for a set deal", which spent width on the one line that
* must not wrap to explain a thing the button could simply ask. Blank means random.
*/
const asked = prompt('Seed for the new game — leave blank for a random one:', '');
if (asked === null) return; // cancelled
clearSave();
const wanted = asked.trim();
if (wanted === '') {
// No `?seed=`, so `start()` rolls one. Reload rather than re-render, to clear any seed in the URL.
if (location.search === '') location.reload();
else location.search = '';
return;
}
location.search = `?seed=${encodeURIComponent(wanted)}`;
};
}
+8 -4
View File
@@ -21,8 +21,11 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim)
thing you most need while reading the board — which Day and Stage it is, which phase is running,
and who it is waiting on. They are one block now, and it sticks as one. */
.topbar{position:sticky;top:0;z-index:5}
/* ONE LINE, AND IT HAS TO STAY ONE LINE. The build stamp was pushed to the far right by
`margin-left:auto` and wrapped the whole row onto a second line the moment the content grew.
Tighter gaps, a smaller stamp, and nothing on here that is not worth the width. */
header{background:var(--panel);border-bottom:1px solid var(--line);
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
padding:8px 12px;display:flex;gap:13px;align-items:baseline;flex-wrap:wrap;font-size:13px}
header b{font-size:16px}
header button{background:#2a3038;color:inherit;border:1px solid #2c333d;border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px}
header button:hover{border-color:#4d6fa8}
@@ -30,7 +33,7 @@ header button:hover{border-color:#4d6fa8}
stops inviting the press. */
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
header button:disabled:hover{border-color:#2c333d}
.build{margin-left:auto;font-size:11px;opacity:.7}
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
.home:hover{color:#5aa9e6}
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
@@ -161,13 +164,14 @@ ul.blocked li{padding:2px 0}
<header>
<b><a href="./index.html" class="home">Station Master</a></b>
<span>Revenue <b id="revenue">0</b></span>
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
<span id="objective" class="pace">—</span>
<span class="dim">seed <span id="seed">—</span></span>
<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="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</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. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
<button id="newgame" title="Deal a fresh game. You will be asked for a seed — leave it blank for a random one. Undo steps back one action at a time; this throws the whole game away, so 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>
+41 -1
View File
@@ -8,7 +8,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent } from '../src/engine/apply.ts';
import { applyIntent, areaOf } from '../src/engine/apply.ts';
import { STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
@@ -566,3 +566,43 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
assert.equal(count, TOTAL_ROLLING_STOCK, 'rolling stock leaked or was duplicated');
});
});
describe('X18 Circus Train — a point for standing still', () => {
it('pays once for a Stage spent stopped, and never again', () => {
/**
* REPORTED: "Circus train TX18 was stopped on a siding for a full Stage and I did not get my
* Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read
* NOWHERE, along with eight other special-train rules. The one card in the deck that pays for
* standing still paid nothing.
*/
const s = game();
s.clock.phase = 'mainline';
s.trays.set('circus', {
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
consist: [], direction: 'east',
position: { at: 'grid', owner: 0, coord: { row: -1, col: 0 } },
movesUsed: 0,
} as never);
// A card under it, so the crew is somewhere real rather than off the grid.
areaOf(s, 0).grid.set('-1,0', {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never);
const before = s.players[0]!.revenue;
const first = pump(s);
assert.ok(
first.some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18),
'the Circus Train stood still for a Stage and earned nothing',
);
assert.equal(s.players[0]!.revenue, before + 1, 'the point was not paid');
// "One turn stopped" — once. A train that goes on standing there does not keep earning.
const paidAgain = () => {
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
return pump(s).some((e) => e.type === 'trainStoodStill');
};
assert.ok(!paidAgain(), 'the Circus Train collected a second time for the same set-up');
});
});