From 6ead39c5309e072d1c5a12d5878d38d661d67ef9 Mon Sep 17 00:00:00 2001 From: Jesse Date: Sun, 9 Aug 2026 19:44:18 -0400 Subject: [PATCH] fixed train# specific rules. cleaned up display topline and train display on division map --- TODO.md | 33 +++++++++++++++++++++++ src/engine/advance.ts | 42 +++++++++++++++++++++++++++++ src/engine/apply.ts | 8 ++++++ src/engine/events.ts | 2 ++ src/engine/intents.ts | 8 ++++++ src/engine/legal.ts | 3 +++ src/engine/state.ts | 7 +++++ src/sim/board-svg.ts | 63 ++++++++++++++----------------------------- src/sim/bot.ts | 13 ++++++--- src/sim/narrate.ts | 47 ++++++++++++++++++++++++++++++++ src/sim/view.ts | 62 +++++++++++++++++++++++++++++++++++++++++- src/web/main.ts | 31 ++++++++++++++++++--- src/web/play.html | 12 ++++++--- test/advance.test.ts | 42 ++++++++++++++++++++++++++++- 14 files changed, 316 insertions(+), 57 deletions(-) diff --git a/TODO.md b/TODO.md index f7d5285..6e45438 100644 --- a/TODO.md +++ b/TODO.md @@ -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` diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 883b0dd..d3fff39 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -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') { diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 8db38b7..3093dab 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -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': { diff --git a/src/engine/events.ts b/src/engine/events.ts index 9900cd8..a4745c6 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -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 diff --git a/src/engine/intents.ts b/src/engine/intents.ts index 5dc40ce..fe69b8a 100644 --- a/src/engine/intents.ts +++ b/src/engine/intents.ts @@ -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 } diff --git a/src/engine/legal.ts b/src/engine/legal.ts index 176a699..904c187 100644 --- a/src/engine/legal.ts +++ b/src/engine/legal.ts @@ -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 }); diff --git a/src/engine/state.ts b/src/engine/state.ts index e2c7b28..e4e2e2d 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -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; }; // --------------------------------------------------------------------------- diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 019c065..326543a 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -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 += `` + + out += `` + `` + - `${esc(t.label)}`; - laid.forEach((it, n) => { - const bx = tx - w / 2 + nameW + n * CARW; - out += ``; - if (CARW >= 11) { - out += `${esc(it.label)}`; - } - }); + `${esc(label)}`; out += ''; }); out += ''; @@ -716,8 +693,8 @@ 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 += ``; + `${t.label} — engine pointing ${facingWord}, carrying ${consistWords}` + (t.what ? `\n\n${t.what}` : ''), + )}">`; out += `${esc(t.label)}`; laid.forEach((it, n) => { const bx = tx + 28 + n * cw; diff --git a/src/sim/bot.ts b/src/sim/bot.ts index 5591c6a..7d4fae7 100644 --- a/src/sim/bot.ts +++ b/src/sim/bot.ts @@ -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), ); diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 7acaa4f..35e6a5a 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -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) { diff --git a/src/sim/view.ts b/src/sim/view.ts index be19ac3..6e273c1 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -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); diff --git a/src/web/main.ts b/src/web/main.ts index 863b65c..b9d3414 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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(); - if (location.search === '') location.reload(); - else location.search = ''; + 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)}`; }; } diff --git a/src/web/play.html b/src/web/play.html index 119c7a6..9cae1a2 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -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}
Station Master Revenue 0 + — seed — - saved in this browser · add ?seed=1234 for a set deal - + replays __BUILD__
diff --git a/test/advance.test.ts b/test/advance.test.ts index 7eb1416..b65f28d 100644 --- a/test/advance.test.ts +++ b/test/advance.test.ts @@ -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'); + }); +});