diff --git a/src/sim/bot.ts b/src/sim/bot.ts index 69d0ee8..48c40e2 100644 --- a/src/sim/bot.ts +++ b/src/sim/bot.ts @@ -56,17 +56,37 @@ function ruleOnClearance(options: Intent[]): Intent | null { // --------------------------------------------------------------------------- +/** + * Why the developer bot made its last choice, for the replay's decision panel. + * + * WRITE-ONLY DIAGNOSTIC. Nothing reads this to make a decision, so it cannot affect play or + * determinism — it exists so the viewer can show the branch that fired instead of re-deriving the + * reasoning, which would drift from the bot exactly as a second copy of the rules would. + */ +let lastReason = ''; + +export function lastChoiceReason(): string { + return lastReason; +} + +/** Records the branch that fired and returns the intent unchanged. */ +function because(reason: string, intent: Intent): Intent { + lastReason = reason; + return intent; +} + export const developerBot: BotPolicy = { name: 'developer', choose(s, player, options) { + lastReason = 'no specific reason — first legal option'; const clearance = ruleOnClearance(options); - if (clearance) return clearance; + if (clearance) return because('the Superintendent must rule on a following train (§8.1)', clearance); // Red Flags come before anything else — protection is only worth playing at the moment the // collision is actually pending, and that moment passes. const flags = worthFlagging(s, options); - if (flags) return flags; + if (flags) return because('a train of ours is stopped on a Mainline card with another train on it — Red Flags now or not at all', flags); // --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one. // @@ -87,7 +107,8 @@ export const developerBot: BotPolicy = { // jam costs a Freight Agent action to clear AND locks the industry track until it is cleared. // A first attempt kept a last-resort `startLoad` here "rather than idle", and the measured // result was identical to having no gate at all — 415 starts, 413 unjams, 13 completions. - return work ?? options.find((i) => i.type === 'loadUnload.end') ?? options[0]!; + if (work) return because(loadReason(work), work); + return because('every worker is spent or has nothing it can finish', options.find((i) => i.type === 'loadUnload.end') ?? options[0]!); } // --- New Train: fill the consist, but with the RIGHT cars. @@ -102,9 +123,9 @@ export const developerBot: BotPolicy = { const match = options.find( (i) => i.type === 'newTrain.placeCar' && i.carType === w.type && i.loaded === w.loaded, ); - if (match) return match; + if (match) return because(`the ${w.loaded ? 'loaded' : 'empty'} ${w.type} is what a facility is short of`, match); } - return pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!; + return because('no car on offer is one our facilities need', pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!); } // --- Local Operations. @@ -140,43 +161,51 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[]) // An Office upgrade outranks even a train card: a train that arrives with nowhere to stand is a // collision, and collisions are the largest single drain on revenue. if (can('draw') && hand.some((id) => s.cards.get(id)?.kind.kind === 'office')) { - return can('draw')!; + return because('an Office upgrade is in hand — more A/D track means fewer collisions', can('draw')!); } - if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!; + if (can('draw') && hand.some((id) => isTrainCard(s, id))) { + return because('a train card is in hand and its value compounds every Day', can('draw')!); + } // A Mainline modifier is worth the option too: Realignment permanently converts a 30 card into a // 60, and Helpers/Brakeman take a Stage off every future crossing. Like a train card, the value // compounds — but only if it can be laid right now, so check for a legal target rather than for // the card sitting in hand. - if (can('draw') && options.some((i) => i.type === 'mainline.modify')) return can('draw')!; + if (can('draw') && options.some((i) => i.type === 'mainline.modify')) { + return because('a Mainline modifier can be laid — every later crossing pays less', can('draw')!); + } // 2. A train standing at the Office is a fleeting chance to spot cars — but ONLY if there is // actually a car to spot or collect. Choosing to switch merely because a train is present // wasted the whole Local Operations action shuttling back and forth: a passenger train needs // no switching at all, because §9.2 works coaches straight off the A/D track. if (can('switch') && area.adOccupancy.length > 0 && usefulSwitching(s, player)) { - return can('switch')!; + return because('a train is at the Office and there is switching worth doing', can('switch')!); } // A crew stranded away from the Office is worth a whole turn on its own. A train may only // highball from the Office square, so one sitting anywhere else is out of the game permanently — // and the condition above never fires for it, because a crew down in the district has no work // left and would never claim the option needed to walk back. - if (can('switch') && strandedFromOffice(s, player)) return can('switch')!; + if (can('switch') && strandedFromOffice(s, player)) { + return because('the crew is away from the Office and can never depart from where it stands', can('switch')!); + } // 3. Stock a green box only when the load can actually finish — an empty car of the right type // is already spotted. Stocking without one just fills the box. - if (can('freightAgent') && canStockProductively(s, player)) return can('freightAgent')!; + if (can('freightAgent') && canStockProductively(s, player)) { + return because('a green box can be stocked with a load that can actually finish', can('freightAgent')!); + } // 4. Rescue a jammed facility, or clear a full red box blocking further unloading. if (can('freightAgent') && (hasStuckLoad(s, player) || needsClearing(s, player))) { - return can('freightAgent')!; + return because('a facility is jammed or its red box is full, blocking the pipeline', can('freightAgent')!); } // 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay. - if (can('draw')) return can('draw')!; - return can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!; + if (can('draw')) return because('nothing urgent — develop the district instead', can('draw')!); + return because('no option has a clear purpose this turn', can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!); } /** A face-up card worth spending the draw on rather than gambling on the deck. */ @@ -317,6 +346,24 @@ function worthFlagging(s: GameState, options: Intent[]): Intent | null { return null; } +/** A one-line account of which Load/Unload action was taken, and why it ranked first. */ +function loadReason(i: Intent): string { + switch (i.type) { + case 'porter.board': + return 'a Porter earns a point in ONE action — always the best use of a worker'; + case 'porter.detrain': + return 'detraining passengers is a point for a single Porter action'; + case 'laborer.advanceLoad': + return 'finish work already started — a load parked on WORK locks the industry track'; + case 'laborer.startLoad': + return 'a matching empty car is spotted, so this load can actually finish'; + case 'laborer.beginUnload': + return 'a loaded car is spotted and the red box has room'; + default: + return 'the best remaining use of a worker'; + } +} + /** * Is there already an empty car of the right type spotted to receive this load (§9.3)? Without one * the load can be started and walked across MEN|AT|WORK but can never come off. @@ -376,11 +423,11 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In const useful = options.find( (i) => i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot), ); - if (useful) return useful; + if (useful) return because('a face-up card is worth more than a blind draw right now', useful); const blind = options.find((i) => i.type === 'draw.fromHomeOffice'); - if (blind) return blind; + if (blind) return because('no face-up card is worth taking — gamble on the deck', blind); const any = options.find((i) => i.type === 'draw.fromDepartment'); - if (any) return any; + if (any) return because('the deck is empty, so take a face-up card', any); } // UPGRADE THE OFFICE FIRST. A Whistle Post has ONE A/D track, so a second arrival is an // automatic collision (§8.3, Gap 2a) — and measured, 25 of 26 collisions happened at Whistle @@ -391,13 +438,13 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In const upgrade = options.find( (i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind === 'office', ); - if (upgrade) return upgrade; + if (upgrade) return because('upgrade the Office — a Whistle Post has ONE A/D track and a second arrival collides', upgrade); // Train cards next — they take no placement and their value compounds every Day. const train = options.find( (i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId), ); - if (train) return train; + if (train) return because('a scheduled train runs every Day thereafter — the only card whose value compounds', train); // Mainline modifiers rank with train cards: every train that crosses afterwards pays the // lower price. Realignment first — converting Curves to Plains halves the crossing for @@ -405,9 +452,9 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In const realign = options.find( (i) => i.type === 'mainline.modify' && isMainlineKey(s, i.cardId, 'realignment'), ); - if (realign) return realign; + if (realign) return because('Realignment converts this Mainline card into a faster one for every future crossing', realign); const grade = options.find((i) => i.type === 'mainline.modify'); - if (grade) return grade; + if (grade) return because('a grade modifier takes a Stage off every crossing in that direction', grade); // Enhancements next: Small Yard makes switching solvable, Interlocking stops the Office // overflowing into a collision, and the dispatch devices win meets. All are worth more than @@ -420,12 +467,12 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In i.placement !== undefined && isEnhancementKey(s, i.cardId, 'interlocking'), ); - if (interlock) return interlock; + if (interlock) return because('Interlocking holds an arrival at the Limits instead of colliding into a full Office', interlock); const enh = options.find( (i) => i.type === 'card.play' && i.placement !== undefined && isEnhancement(s, i.cardId), ); - if (enh) return enh; + if (enh) return because('an Enhancement is permanent and changes how the district works', enh); // Lay track before spending a card on the grid. Track is the scarce enabler, not the // consolation prize: nothing else in the deck can create the Running-Track and Secondary-Track @@ -433,16 +480,16 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In // district exists. Measured with track absent, the hand held a playable Enhancement on 4,778 // turns and could legally place one on 33. const track = bestTrackLay(s, player, options); - if (track) return track; + if (track) return because('lay track — nothing else creates the straights Enhancements need or the spurs freight needs', track); // Then real development: a card actually laid into the grid. const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined); - if (placed) return placed; + if (placed) return because('develop the district with a card that goes on the board', placed); const play = options.find((i) => i.type === 'card.play'); - if (play) return play; + if (play) return because('play what is in hand', play); const end = options.find((i) => i.type === 'draw.end'); - if (end) return end; - return pickFirst(options, 'card.discard') ?? options[0]!; + if (end) return because('nothing in hand can be played anywhere legal', end); + return because('nothing playable and nothing to draw — discard to a Department slot', pickFirst(options, 'card.discard') ?? options[0]!); } case 'switch': { @@ -457,7 +504,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In i.count === 1 && facilityWantsAt(s, player, i.to, trayOf(s, player)?.consist.slice(-1)[0]), ); - if (flyingFirst) return flyingFirst; + if (flyingFirst) return because('a Flying Switch rolls the back car straight into an industry that wants it', flyingFirst); if (!usefulSwitching(s, player)) { // GO HOME. A train may only highball from the Office square itself (§8.1, Gap 2b) — from @@ -467,9 +514,9 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In // the wrong column and 43 down in the district. A parked train earns nothing, holds its A/D // track, and is unavailable for the next load. const home = moveTowardOffice(s, player, options); - if (home) return home; + if (home) return because('no switching left worth doing — head back to the Office so the train can depart', home); const stop = options.find((i) => i.type === 'switch.end'); - if (stop) return stop; + if (stop) return because('no switching left worth doing, and the crew is already at the Office', stop); } // Spotting the RIGHT car at the RIGHT facility is the whole point of switching. @@ -499,7 +546,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In const sort = options.find( (i) => i.type === 'switch.sortConsist' && i.order.join() === order.join(), ); - if (sort) return sort; + if (sort) return because('standing on a Small Yard — re-order so a car a facility wants ends up droppable', sort); } } } @@ -512,7 +559,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In // Standing on a facility that wants the back car: put it down. This is the payoff move. if (hereFacility && facilityWants(hereFacility, endCar)) { const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1); - if (drop) return drop; + if (drop) return because('standing on a facility that wants the back car — spot it', drop); } // SET OUT — and do it BEFORE travelling. @@ -538,7 +585,14 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In const isSpur = here.row !== area.runningRow && !hereFacility; if (isSpur) { const setOut = options.find((i) => i.type === 'switch.dropCars' && i.count === 1); - if (setOut) return setOut; + if (setOut) { + return because( + tray.consist.length >= MAX_CONSIST + ? 'the tray is full, so nothing can be coupled — set a car out here' + : 'the back car is dead weight hiding a car a facility wants — set it out to expose it', + setOut, + ); + } } const toSpur = options.find( (i) => @@ -546,7 +600,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In i.to.row !== area.runningRow && !area.grid.get(`${i.to.row},${i.to.col}`)?.facility, ); - if (toSpur) return toSpur; + if (toSpur) return because('looking for a plain spur to set out on — an industry track would silt with the wrong commodity', toSpur); } // Now travel — to a facility that wants SOMETHING aboard, not only the back car. Weighing @@ -562,7 +616,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In i.type === 'switch.move' && wanted.some((t) => t.row === i.to.row && t.col === i.to.col), ); - if (toward) return toward; + if (toward) return because('heading for a facility that wants a car we are carrying', toward); } // Never take an arbitrary Move. Picking "the first legal move" is what produced the original @@ -571,8 +625,8 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In // Moves oscillating between two cells. If there is no move with a purpose, the only useful // thing left is to head for the Office, because a train that is not on it can never depart. const goHome = moveTowardOffice(s, player, options); - if (goHome) return goHome; - return options.find((i) => i.type === 'switch.end') ?? options[0]!; + if (goHome) return because('nothing productive left — head for the Office rather than shuttle aimlessly', goHome); + return because('out of useful switching moves', options.find((i) => i.type === 'switch.end') ?? options[0]!); } case 'freightAgent': { @@ -581,13 +635,13 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In // rescue it. §6.3's unjam exists for exactly this. Clear it before anything else. if (hasStuckLoad(s, player)) { const unjam = options.find((i) => i.type === 'freightAgent.unjam' && i.from === 'menAtWork'); - if (unjam) return unjam; + if (unjam) return because('a jammed load blocks the pipeline AND strips the industry track of Operational Rail', unjam); } const clear = options.find((i) => i.type === 'freightAgent.clearInbound'); - if (clear) return clear; + if (clear) return because('the red Inbound box is full and blocking further unloading', clear); const stock = options.find((i) => i.type === 'freightAgent.stockOutbound'); - if (stock) return stock; - return pickFirst(options, 'freightAgent.unjam') ?? options[0]!; + if (stock) return because('stock a green box so a Laborer has work next Stage', stock); + return because('nothing productive at any facility — clear whatever is stuck', pickFirst(options, 'freightAgent.unjam') ?? options[0]!); } default: diff --git a/src/sim/replay.ts b/src/sim/replay.ts index 187e8ee..7469972 100644 --- a/src/sim/replay.ts +++ b/src/sim/replay.ts @@ -23,10 +23,11 @@ import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from import type { GameLength } from '../engine/content.ts'; import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts'; import type { GameEvent } from '../engine/events.ts'; +import type { Intent } from '../engine/intents.ts'; import { legalActions } from '../engine/legal.ts'; import { createGame } from '../engine/setup.ts'; -import type { GameConfig, GameState } from '../engine/state.ts'; -import { developerBot } from './bot.ts'; +import type { Facility, GameConfig, GameState } from '../engine/state.ts'; +import { developerBot, lastChoiceReason } from './bot.ts'; import type { Impediment } from './narrate.ts'; import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } from './narrate.ts'; @@ -58,6 +59,14 @@ export type FacilityView = { trackCap: number; laborers: string; porters: string; + /** + * Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on + * WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock + * that held freight to a 3% completion rate. + */ + canFinish: boolean; + /** A load is sitting on WORK with no spotted car to receive it. */ + jammed: boolean; }; export type TrainChip = { label: string; consist: string[] }; @@ -85,6 +94,26 @@ export type Frame = { timetable: (number | null)[]; blocked: Impediment[]; trains: { label: string; where: string }[]; + /** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */ + decision: Decision | null; + /** A Local Operations turn that changed nothing — the frames worth your attention. */ + wasted: boolean; +}; + +/** + * The choice behind a frame. + * + * The whole point is `rejected`: the replay could always show what happened, never what COULD have + * happened, so a daft move was visible but the alternatives it passed over were not — which is + * exactly what you need to say what it should have done instead. + */ +export type Decision = { + actor: number; + chose: string; + why: string; + /** Every legal option not taken, grouped by kind with a count. */ + rejected: { kind: string; count: number; detail: string }[]; + totalOptions: number; }; // --------------------------------------------------------------------------- @@ -124,14 +153,116 @@ function facilityView( trackCap: f.industryTrack.length, laborers: `${laborersLeft(f)}/${f.laborers}`, porters: `${portersLeft(f)}/${f.porters}`, + canFinish: canFinishHere(f), + jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f), }; } +/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */ +function canFinishHere(f: Facility): boolean { + const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0]; + if (!pending) return false; + return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type); +} + +/** + * Summarise the choice: what was taken, why, and what was passed over. + * + * Options are grouped by kind because a switching turn can offer 40 destinations, and a list that + * long hides the shape of the decision rather than showing it. + */ +function describeDecision( + s: GameState, + actor: number, + chosen: Intent, + options: Intent[], + why: string, +): Decision { + const groups = new Map(); + for (const o of options) { + if (o === chosen) continue; + const list = groups.get(o.type) ?? []; + list.push(o); + groups.set(o.type, list); + } + const rejected = [...groups.entries()] + .map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) })) + .sort((a, b) => b.count - a.count); + + return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length }; +} + +/** A short, concrete example of what a group of rejected options would have done. */ +function sampleDetail(s: GameState, kind: string, list: Intent[]): string { + // Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe + // identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the + // actual range of choices — the opposite of what this panel is for. + const seen = new Set(); + for (const i of list) seen.add(describeIntent(s, i)); + const unique = [...seen]; + const shown = unique.slice(0, 4); + const more = unique.length - shown.length; + return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : ''); +} + +/** One readable line for a single intent. */ +function describeIntent(s: GameState, i: Intent): string { + const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`; + switch (i.type) { + case 'localOps.choose': + return `choose ${i.option}`; + case 'card.play': + return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`; + case 'card.discard': + return `discard ${cardName(s, i.cardId)}`; + case 'track.lay': + return `lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${i.geometry} at ${at(i.placement)}`; + case 'switch.move': + return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`; + case 'switch.dropCars': + return `drop ${i.count} car(s)`; + case 'switch.sortConsist': + return `re-order consist [${i.order.join(',')}]`; + case 'freightAgent.stockOutbound': + return `stock a ${i.carType} at ${at(i.at)}`; + case 'freightAgent.unjam': + return `unjam ${i.from} at ${at(i.at)}`; + case 'freightAgent.clearInbound': + return `clear red box at ${at(i.at)}`; + case 'laborer.startLoad': + return `start a load at ${at(i.at)}`; + case 'laborer.advanceLoad': + return `advance load in box ${i.box} at ${at(i.at)}`; + case 'laborer.beginUnload': + return `begin unloading car ${i.carIndex} at ${at(i.at)}`; + case 'porter.board': + return `board passengers at ${at(i.at)}`; + case 'porter.detrain': + return `detrain passengers at ${at(i.at)}`; + case 'newTrain.placeCar': + return `add ${i.loaded ? 'loaded' : 'empty'} ${i.carType}`; + case 'mainline.modify': + return `${cardName(s, i.cardId)} on Mainline card ${i.node}`; + case 'maneuver.redFlags': + return `Red Flags on ${i.trayId}`; + case 'maneuver.flyingSwitch': + return `Flying Switch ${i.count} car(s) into ${at(i.to)}`; + case 'mainline.clearance': + return i.allow ? 'grant clearance' : 'refuse clearance'; + case 'draw.fromDepartment': + return `draw the face-up card in slot ${i.slot + 1}`; + default: + return i.type; + } +} + function snapshot( s: GameState, lines: { text: string; tone: string }[], where: { row: number; col: number } | null, whereFrom: { row: number; col: number } | null = null, + decision: Decision | null = null, + wasted = false, ): Frame { const area = areaOf(s, 0); const trayAt = new Map(); @@ -218,6 +349,8 @@ function snapshot( deck: s.decks.homeOffice.length, departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')), timetable: [...s.timetable], + decision, + wasted, blocked: impediments(s, 0), trains: [...s.trays.values()].map((t) => ({ label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, @@ -304,6 +437,8 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re // Tracks whether a phase did anything, so an empty one can say so rather than ending silently. let didSomething = false; + // The choice that produced the events about to be pushed. + let pendingDecision: Decision | null = null; const push = (events: GameEvent[]): void => { const visible = events.filter(isVisible); @@ -328,7 +463,16 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re const where = visible.map((e) => narrate(e, narrateCtx)).find((n) => n.where)?.where ?? null; const moved = visible.find((e) => e.type === 'trayMoved'); const from = moved && moved.type === 'trayMoved' ? moved.from : null; - frames.push(snapshot(s, lines, where ?? null, from)); + const d = pendingDecision; + pendingDecision = null; + // A Local Operations turn that ended with nothing but the turn ending is a wasted action — + // six Moves and a card play spent on nothing. Among 600+ frames these are invisible unless + // marked, and they are the ones worth reviewing. + const wasted = + d !== null && + (d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') && + visible.every((e) => e.type === 'phaseEnded' || e.type === 'actorChanged'); + frames.push(snapshot(s, lines, where ?? null, from, d, wasted)); }; frames.push( @@ -345,8 +489,11 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re if (actor === null) break; const options = legalActions(s, actor); if (options.length === 0) break; - const applied = applyIntent(s, actor, developerBot.choose(s, actor, options)); + const chosen = developerBot.choose(s, actor, options); + const decision = describeDecision(s, actor, chosen, options, lastChoiceReason()); + const applied = applyIntent(s, actor, chosen); if (!applied.ok) break; + pendingDecision = decision; push(applied.events); } @@ -432,6 +579,17 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0 .mini .box{padding:0 4px;font-size:9.5px} .cell.office{border-color:var(--clock)} .cell.modifier{border-style:dashed;opacity:.85} + +.chose{font-size:15px;margin-bottom:4px} +.why{font-style:italic;opacity:.85;margin-bottom:2px} +.rejected{list-style:none;margin:0;padding:0;max-height:210px;overflow:auto} +.rejected li{padding:3px 0;border-top:1px solid rgba(128,128,128,.25);font-size:12px} +.wasted{background:#b03030;color:#fff;font-weight:bold;padding:3px 6px;margin-bottom:5px;border-radius:3px} +.fstat{margin-top:4px;font-size:11px;padding:2px 5px;border-radius:3px;display:inline-block} +.fstat.good{background:rgba(40,140,60,.28)} +.fstat.bad{background:rgba(190,50,50,.38);font-weight:bold} +.fstat.idle{opacity:.6} +.wastedmark{color:#e06060;font-weight:bold}
@@ -455,6 +613,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0

What just happened

+

Decision — what it chose, and what it passed over

Cards

your hand:
face-up Department slots:
@@ -468,7 +627,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
Keyboard: / step · space play/pause · Home/End first/last · S next Stage · R next revenue change · - C next collision.
+ C next collision · W next wasted turn · J next jam.
Running Track is the lighter row. T4 is a train on the Division; 🚂 T4 is the crew in your yard — it carries the whole train with it as it moves, and a dashed outline @@ -485,6 +644,8 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0 + +