v0.1.0 — undo, the crew's reach on the board, workers on the card, and four engine bugs
Fifteen items from two playtest sessions. Three that read as drawing faults were engine bugs: cars could be added to a train that was not being made up (50 offers in 8 games), the make-up panel merged two trains and could couple a car to the wrong one, and an Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel inside a coordinate's own value range — a Mainline placement travelling as row -1, which is an ordinary district row. Trains are now drawn the way they stand: west on the left, nose toward the way the engine faces, on both the district card and the Division chip. Undo steps back through the game by replaying the save without its last intent. The switching walk keeps its rejections, so the board can say why a square is not offered. Laborers and Porters are on the card, and the rule that a district only grows outwards is finally written down. Versions start here: third digit for fixes, second for a feature set, 1.0 for a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
This commit is contained in:
+3
-21
@@ -20,7 +20,6 @@ import {
|
||||
COLLISION_PENALTY,
|
||||
MAINLINE_PROFILES,
|
||||
enhancementRule,
|
||||
consistSize,
|
||||
crossingStages,
|
||||
trainProfile,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
@@ -35,7 +34,9 @@ import {
|
||||
} from './content.ts';
|
||||
import type { Direction } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import { acceptsCar, areaOf } from './apply.ts';
|
||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||
// the legality test cannot disagree about which train is being assembled.
|
||||
import { areaOf, trainNeedingCars } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurn, totalRevenue } from './state.ts';
|
||||
@@ -286,25 +287,6 @@ function trainRunning(s: GameState, number: number, isExtra: boolean): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A made-up train still short of its consist spec, with a suitable car available. */
|
||||
function trainNeedingCars(s: GameState): TrayId | null {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.trainNumber === null) continue;
|
||||
if (tray.position.at !== 'divisionPoint') continue;
|
||||
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||
if (!profile) continue;
|
||||
const want = consistSize(profile.consist);
|
||||
if (tray.consist.length >= want) continue;
|
||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||
// the yard is potentially suitable unless the card narrows it.
|
||||
// Ask the SAME predicate `check` uses. A separate copy of this test stalled the game: the phase
|
||||
// believed a car could be added while check rejected every option, so the Stage never ended.
|
||||
const suitable = s.yards.divisionYard.some((c) => acceptsCar(tray, c.type));
|
||||
if (suitable) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mainline Phase (§8) — automatic, except the Superintendent's clearance ruling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+133
-22
@@ -20,6 +20,7 @@ import {
|
||||
LABORER_ACTIONS_PER_LOAD,
|
||||
MAX_CONSIST,
|
||||
REALIGNMENTS,
|
||||
consistSize,
|
||||
enhancementRule,
|
||||
industryProfile,
|
||||
mainlineModifierRule,
|
||||
@@ -46,11 +47,12 @@ import type {
|
||||
} from './state.ts';
|
||||
import { createRng } from './rng.ts';
|
||||
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
|
||||
import type { Occupancy, Port } from './track.ts';
|
||||
import type { MoveBlock, Occupancy, Port } from './track.ts';
|
||||
import {
|
||||
canDropCarsAt,
|
||||
canPlaceAt,
|
||||
carriesThroughTrack,
|
||||
exploreMoves,
|
||||
facilityVariants,
|
||||
opposite,
|
||||
reachableDestinations,
|
||||
@@ -351,7 +353,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
const hand = s.decks.hands.get(player) ?? [];
|
||||
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
||||
return checkPlay(s, player, i.cardId, i.placement, i.variant);
|
||||
return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node);
|
||||
}
|
||||
|
||||
case 'card.discard': {
|
||||
@@ -482,6 +484,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
// §7 — cars are added to the train being ASSEMBLED, at a Division Point. Any other tray is a
|
||||
// train that is running, and loading one from the yard is teleporting cars onto it.
|
||||
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
|
||||
if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL';
|
||||
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
|
||||
return 'NO_SUITABLE_CAR';
|
||||
@@ -509,6 +514,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
// Same scope as placeCar: only the train being assembled has a make-up round to finish.
|
||||
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
|
||||
// §7 — "must make every effort to find a suitable car". A pass is only legal when none exists.
|
||||
return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null;
|
||||
}
|
||||
@@ -572,10 +579,14 @@ function checkPlay(
|
||||
cardId: string,
|
||||
placement: GridCoord | undefined,
|
||||
variant: number | undefined,
|
||||
node?: number,
|
||||
): RejectionCode | null {
|
||||
const card = s.cards.get(cardId);
|
||||
if (!card) return 'NO_SUCH_CARD';
|
||||
const area = areaOf(s, player);
|
||||
// A Division node and an Office Area square are different boards. Naming both is not a placement
|
||||
// with extra detail, it is two contradictory answers to "where?".
|
||||
if (node !== undefined && placement) return 'NO_PLACEMENT';
|
||||
|
||||
switch (card.kind.kind) {
|
||||
case 'office': {
|
||||
@@ -629,6 +640,12 @@ function checkPlay(
|
||||
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
case 'enhancement': {
|
||||
// ABS Signals goes out on the Mainline, so it takes a Division node and no square.
|
||||
if (enhancementRule(card.kind.key)?.placement === 'mainlineCard') {
|
||||
if (node === undefined) return 'NO_PLACEMENT';
|
||||
const target = s.division.nodes[node];
|
||||
return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
return checkEnhancementPlacement(s, area, card.kind.key, placement);
|
||||
}
|
||||
@@ -696,6 +713,46 @@ function destinationsFor(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE THIS CREW MAY GO, AND WHY IT MAY NOT GO ELSEWHERE.
|
||||
*
|
||||
* Both directions at once, because a player is not thinking in terms of "forward" and "reverse" when
|
||||
* looking at a card two squares away — a square reachable only by backing up is still reachable, and
|
||||
* a reason that applies in one direction should not be reported when the other direction works.
|
||||
*
|
||||
* Straight out of the movement walk (`exploreMoves`), so the reasons cannot drift from the rules
|
||||
* that produced them.
|
||||
*/
|
||||
export function movesFor(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
trayId: TrayId,
|
||||
): { to: GridCoord[]; blocked: MoveBlock[] } {
|
||||
const tray = s.trays.get(trayId);
|
||||
if (!tray || tray.position.at !== 'grid') return { to: [], blocked: [] };
|
||||
const from = tray.position.coord;
|
||||
const ctx = {
|
||||
area: areaOf(s, player),
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
consistSize: tray.consist.length,
|
||||
self: trayId,
|
||||
};
|
||||
const facing = facingPort(s, trayId);
|
||||
const forward = exploreMoves(ctx, from, facing);
|
||||
const back = exploreMoves(ctx, from, opposite(facing));
|
||||
|
||||
const to = new Map<string, GridCoord>();
|
||||
for (const d of [...forward.destinations, ...back.destinations]) to.set(coordKey(d.coord), d.coord);
|
||||
|
||||
const blocked = new Map<string, MoveBlock>();
|
||||
for (const b of [...forward.blocked, ...back.blocked]) {
|
||||
if (to.has(coordKey(b.coord))) continue; // reachable the other way round; not a blocker
|
||||
if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b);
|
||||
}
|
||||
|
||||
return { to: [...to.values()], blocked: [...blocked.values()] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// execute — reads state, emits events, never mutates
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -850,13 +907,12 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
to: card.kind.tier,
|
||||
});
|
||||
}
|
||||
if (card.kind.kind === 'enhancement' && i.placement) {
|
||||
events.push({
|
||||
type: 'enhancementPlaced',
|
||||
player,
|
||||
key: card.kind.key,
|
||||
at: i.placement,
|
||||
});
|
||||
if (card.kind.kind === 'enhancement' && (i.placement || i.node !== undefined)) {
|
||||
events.push(
|
||||
i.node !== undefined
|
||||
? { type: 'enhancementPlaced', player, key: card.kind.key, node: i.node }
|
||||
: { type: 'enhancementPlaced', player, key: card.kind.key, at: i.placement! },
|
||||
);
|
||||
}
|
||||
if (card.kind.kind === 'extraTrain') {
|
||||
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
|
||||
@@ -1205,13 +1261,32 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'officeUpgraded': {
|
||||
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
||||
const area = areaOf(s, e.player);
|
||||
const from = officeProfile(e.from);
|
||||
const to = officeProfile(e.to);
|
||||
area.tier = e.to;
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
if (officeCard?.facility) {
|
||||
const p = officeProfile(e.to);
|
||||
officeCard.facility.porters = p.porters;
|
||||
officeCard.facility.capacity = { outbound: p.passengerOut, inbound: p.passengerIn };
|
||||
officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility };
|
||||
/**
|
||||
* THE TIER IS A DELTA, NOT AN OVERWRITE.
|
||||
*
|
||||
* This wrote the new tier's printed numbers straight over the facility, which silently
|
||||
* deleted everything a Modifier had added: a Waiting Area, Restaurant or Hotel beside the
|
||||
* Office is +1 passenger out and +1 porter, and upgrading Depot → Station threw both away
|
||||
* with no message, after the card had been spent. Reported from a playtest where a
|
||||
* Restaurant's porter never appeared — it had appeared and then been erased.
|
||||
*
|
||||
* Applying the DIFFERENCE between the two tiers raises the Office by exactly what the
|
||||
* upgrade is worth and leaves anything standing beside it untouched.
|
||||
*/
|
||||
const f = officeCard.facility;
|
||||
f.porters += to.porters - from.porters;
|
||||
f.capacity = {
|
||||
outbound: f.capacity.outbound + (to.passengerOut - from.passengerOut),
|
||||
inbound: f.capacity.inbound + (to.passengerIn - from.passengerIn),
|
||||
};
|
||||
// Becoming a Passenger Facility at all is a state change, not a delta (Gap 8): a Whistle
|
||||
// Post has no passenger boxes to add to.
|
||||
f.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1263,12 +1338,11 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
}
|
||||
|
||||
case 'enhancementPlaced': {
|
||||
const rule = enhancementRule(e.key);
|
||||
if (rule?.placement === 'mainlineCard') {
|
||||
const node = s.division.nodes[e.at.col];
|
||||
if (e.node !== undefined) {
|
||||
const node = s.division.nodes[e.node];
|
||||
// ABS Signals: trains on this card stop short rather than rear-ending each other.
|
||||
if (node?.kind === 'mainline') node.absSignals = true;
|
||||
} else {
|
||||
} else if (e.at) {
|
||||
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
|
||||
if (card) card.enhancements.push(e.key);
|
||||
}
|
||||
@@ -1564,11 +1638,9 @@ export function checkEnhancementPlacement(
|
||||
const rule = enhancementRule(key);
|
||||
if (!rule) return 'NOT_IMPLEMENTED';
|
||||
|
||||
// ABS Signals goes on a Mainline card; `placement.col` names which one.
|
||||
if (rule.placement === 'mainlineCard') {
|
||||
const node = s.division.nodes[placement.col];
|
||||
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
// A Mainline-card enhancement never reaches here: it has no grid square, and `checkPlay` answers
|
||||
// it against `division.nodes` directly. This function is only ever asked about the Office Area.
|
||||
if (rule.placement === 'mainlineCard') return 'WRONG_INTENT';
|
||||
|
||||
const card = area.grid.get(coordKey(placement));
|
||||
if (!card) return 'NOT_CONNECTED';
|
||||
@@ -1741,6 +1813,45 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* §7 — IS THIS TRAY THE ONE BEING MADE UP?
|
||||
*
|
||||
* A train is made up where it is built, standing at a Division Point, and only until its consist
|
||||
* matches its card. Everything else with a Crew Tray — a train working your district, a train
|
||||
* halfway across the Division — is running, not being assembled.
|
||||
*
|
||||
* SHARED with the New Train Phase, which uses it to decide whether to stop and ask. It has to be:
|
||||
* `check` accepted any tray with room in its consist, so during a New Train Phase the Division Yard
|
||||
* would hand cars to a train standing on your own siding or out on the Mainline — cars appearing on
|
||||
* a train nobody was making up. Measured before the fix: 50 such offers across 8 solitaire games,
|
||||
* including Train 9 mid-crossing with three cars already aboard.
|
||||
*/
|
||||
export function isBeingMadeUp(tray: CrewTray): boolean {
|
||||
if (tray.trainNumber === null) return false;
|
||||
if (tray.position.at !== 'divisionPoint') return false;
|
||||
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||
if (!profile) return false;
|
||||
return tray.consist.length < consistSize(profile.consist);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tray the New Train Phase is waiting on, or null.
|
||||
*
|
||||
* `isBeingMadeUp` plus "and there is something in the yard it will take" — the phase must not stop
|
||||
* to ask for a car that cannot be supplied.
|
||||
*/
|
||||
export function trainNeedingCars(s: GameState): TrayId | null {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (!isBeingMadeUp(tray)) continue;
|
||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
|
||||
// uses: a separate copy of this test stalled the game, because the phase believed a car could
|
||||
// be added while `check` rejected every option, so the Stage never ended.
|
||||
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
|
||||
*
|
||||
|
||||
@@ -78,7 +78,12 @@ export type GameEvent =
|
||||
* which made the replay say "New Train" and nothing else. A train being made up, departing,
|
||||
* arriving or finishing its run are four distinct facts and deserve four event types.
|
||||
*/
|
||||
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at: GridCoord }
|
||||
/**
|
||||
* `at` is a square in the Office Area; `node` is a Division node, for an Enhancement that goes on
|
||||
* a Mainline card. Exactly one is set — see `card.play` in intents.ts for why they are not one
|
||||
* field wearing a sentinel.
|
||||
*/
|
||||
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at?: GridCoord; node?: number }
|
||||
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
||||
|
||||
+12
-1
@@ -38,8 +38,17 @@ export type Intent =
|
||||
* `variant` indexes `variantsFor(geometry, hand)` — 0° or 180°, the only two ways a printed card
|
||||
* can be laid. Track is played through here like every other card; there is no separate lay
|
||||
* intent, because there is no separate supply to lay from.
|
||||
*
|
||||
* `placement` is a square in the player's Office Area; `node` indexes `division.nodes` and is for
|
||||
* the one card that goes out on the Mainline instead (ABS Signals). They are alternatives, and
|
||||
* they are SEPARATE FIELDS on purpose. A Mainline placement used to travel as the fake coordinate
|
||||
* `{ row: -1, col: node }`, and row −1 is an ordinary district row — the first one below the
|
||||
* Running Track, where most districts start. So a real placement on row −1 was labelled "out on
|
||||
* the Mainline, node −2", and an ABS Signals placement highlighted whichever district card
|
||||
* happened to sit at that column. A sentinel inside a coordinate's own value range is not a
|
||||
* sentinel.
|
||||
*/
|
||||
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number; node?: number }
|
||||
| { type: 'card.discard'; cardId: CardId; toSlot: number }
|
||||
| { type: 'draw.end' }
|
||||
// -- freight agent (§6.3)
|
||||
@@ -108,6 +117,8 @@ export type RejectionCode =
|
||||
| 'BOX_EMPTY'
|
||||
| 'NO_SUITABLE_CAR'
|
||||
| 'SUITABLE_CAR_EXISTS'
|
||||
/** §7 — cars go onto the train being assembled at a Division Point, not onto one already running. */
|
||||
| 'NOT_BEING_MADE_UP'
|
||||
| 'WRONG_CAR_TYPE'
|
||||
| 'NOT_SUPERINTENDENT'
|
||||
| 'NO_PENDING_DECISION'
|
||||
|
||||
+12
-9
@@ -143,17 +143,20 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const kind = s.cards.get(cardId)?.kind;
|
||||
/**
|
||||
* Enhancements ATTACH to a card already down, so their candidates are the occupied cells — with
|
||||
* one exception. ABS Signals goes on a MAINLINE card, and `checkEnhancementPlacement` reads
|
||||
* `placement.col` as a Division node index for it. Offering it grid squares meant the engine
|
||||
* accepted "(0, 1)" because node 1 happened to be a Mainline card: the label said one thing and
|
||||
* the placement meant another, so the card appeared to be playable all over the Office Area.
|
||||
* one exception. ABS Signals goes on a MAINLINE card, which is not a grid square at all, so it
|
||||
* is offered as a Division NODE and never as a coordinate.
|
||||
*
|
||||
* It used to be offered as `{ row: -1, col: node }`. Row −1 is a real district row — the first
|
||||
* one below the Running Track, where most districts start — so the card appeared to be playable
|
||||
* all over the Office Area, and ordinary placements on that row were labelled as Mainline ones.
|
||||
*/
|
||||
const onMainline = kind?.kind === 'enhancement' && enhancementRule(kind.key)?.placement === 'mainlineCard';
|
||||
const targets = onMainline
|
||||
? s.division.nodes.map((_, node) => ({ row: -1, col: node }))
|
||||
: kind?.kind === 'enhancement'
|
||||
? attachments
|
||||
: placements;
|
||||
if (onMainline) {
|
||||
for (let node = 0; node < s.division.nodes.length; node++) {
|
||||
out.push({ type: 'card.play', cardId, node });
|
||||
}
|
||||
}
|
||||
const targets = onMainline ? [] : kind?.kind === 'enhancement' ? attachments : placements;
|
||||
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
|
||||
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
|
||||
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
|
||||
|
||||
+87
-10
@@ -323,12 +323,57 @@ export function reachableDestinations(
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
): MoveDestination[] {
|
||||
const { area, occupancy } = ctx;
|
||||
const startCard = cardAt(area, start);
|
||||
if (!startCard) return [];
|
||||
if (!hasPort(startCard, initialExit)) return [];
|
||||
return exploreMoves(ctx, start, initialExit).destinations;
|
||||
}
|
||||
|
||||
/**
|
||||
* A card the walk reached and refused, with the rule that refused it, in a player's words.
|
||||
*
|
||||
* `kind` separates the OBSTRUCTIONS — another train, a locked industry, a consist that would
|
||||
* overfill, rails that do not meet — from `noStopping`, which is not an obstruction at all: a train
|
||||
* runs through a turnout freely and simply may not finish a Move on one. They are drawn differently
|
||||
* and only the obstructions are worth listing in the "why nothing is moving" panel, where every
|
||||
* turnout in the district would otherwise appear.
|
||||
*/
|
||||
export type MoveBlockKind = 'noJoin' | 'occupied' | 'locked' | 'tooManyCars' | 'noStopping';
|
||||
export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
|
||||
|
||||
/**
|
||||
* THE SAME WALK, KEEPING ITS REJECTIONS.
|
||||
*
|
||||
* `reachableDestinations` answers "where may I go", and the UI could show that much — but a player
|
||||
* looking at a siding two cards away and no button for it is asking the opposite question, and the
|
||||
* answer was nowhere on screen. Reported as "trains seem to be blocked from moving onto industry in
|
||||
* certain conditions; make it clear what those conditions are".
|
||||
*
|
||||
* Both answers come out of ONE traversal on purpose. A second function that worked out why a square
|
||||
* was missing would be a second implementation of the movement rules, and the failure mode is a
|
||||
* reason that does not match the refusal — worse than no reason at all.
|
||||
*
|
||||
* A block is recorded for every card the walk actually touched and turned down, plus every card
|
||||
* beyond a reachable one whose rails do not meet. Squares the walk never came near are not listed:
|
||||
* "there is no track between here and there" is not news.
|
||||
*/
|
||||
export function exploreMoves(
|
||||
ctx: MoveContext,
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
|
||||
const { area, occupancy } = ctx;
|
||||
const results: MoveDestination[] = [];
|
||||
const blocked: MoveBlock[] = [];
|
||||
const noted = new Set<string>();
|
||||
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
|
||||
const k = coordKey(coord);
|
||||
if (noted.has(k)) return;
|
||||
noted.add(k);
|
||||
blocked.push({ coord, kind, why });
|
||||
};
|
||||
|
||||
const startCard = cardAt(area, start);
|
||||
if (!startCard) return { destinations: results, blocked };
|
||||
if (!hasPort(startCard, initialExit)) return { destinations: results, blocked };
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
|
||||
@@ -337,7 +382,10 @@ export function reachableDestinations(
|
||||
// hop is checked at the push site below, where both sides of the edge are in hand.
|
||||
const first = neighbour(start, initialExit);
|
||||
const firstCard = cardAt(area, first);
|
||||
if (!firstCard || !joins(startCard, initialExit, firstCard)) return [];
|
||||
if (!firstCard || !joins(startCard, initialExit, firstCard)) {
|
||||
if (firstCard) block(first, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
|
||||
return { destinations: results, blocked };
|
||||
}
|
||||
const queue: Frontier[] = [
|
||||
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
|
||||
];
|
||||
@@ -352,7 +400,16 @@ export function reachableDestinations(
|
||||
const occupant = occupancy.trayAt(node.coord);
|
||||
if (occupant !== null && occupant !== ctx.self) {
|
||||
const isOffice = sameCoord(node.coord, area.officeCoord);
|
||||
if (!isOffice || occupancy.freeAdTracks() <= 0) continue;
|
||||
if (!isOffice || occupancy.freeAdTracks() <= 0) {
|
||||
block(
|
||||
node.coord,
|
||||
'occupied',
|
||||
isOffice
|
||||
? 'the Office is full — every A/D track is taken, so there is no room to enter or pass through (§A.4)'
|
||||
: 'another train is standing here — two trains may not share a card, or move through each other (§A.4)',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPort(card, node.entry)) continue;
|
||||
@@ -361,12 +418,22 @@ export function reachableDestinations(
|
||||
// stops a train FINISHING here; a turnout is not Operational Rail either and trains run through
|
||||
// one all day. Without this a crew rolled straight over a locked industry, and coupled the cars
|
||||
// spotted on it on the way past — the two things the safety lockout exists to prevent.
|
||||
if (isLockedByWork(card)) continue;
|
||||
if (isLockedByWork(card)) {
|
||||
block(node.coord, 'locked', 'MEN AT WORK — a load is on the sign, so this industry track is locked for safety: no train may enter it or cross it, and no car may be picked up or set out (§9.3)');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
|
||||
// overfill the tray is illegal, not a move that picks up fewer cars.
|
||||
const couples = [...node.couples, ...carsOn(card)];
|
||||
if (ctx.consistSize + couples.length > MAX_CONSIST) continue;
|
||||
if (ctx.consistSize + couples.length > MAX_CONSIST) {
|
||||
block(
|
||||
node.coord,
|
||||
'tooManyCars',
|
||||
`too many cars — running here couples ${couples.length} standing car(s) onto a train already holding ${ctx.consistSize}, and ${MAX_CONSIST} is the limit. Coupling is mandatory: you may not run past a car and leave it (§A.4)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${coordKey(node.coord)}|${node.entry}`;
|
||||
if (seen.has(key)) continue;
|
||||
@@ -374,6 +441,10 @@ export function reachableDestinations(
|
||||
|
||||
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
|
||||
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
|
||||
} else if (!sameCoord(node.coord, start)) {
|
||||
// Reached, crossable, but not somewhere a train may STOP — a turnout carries no wheel icon
|
||||
// (§A.1). Trains run through one all day; they just cannot finish a Move on it.
|
||||
block(node.coord, 'noStopping', 'a train may run through here but not stop — this is not Operational Rail (§A.1), so it cannot be the end of a Move');
|
||||
}
|
||||
|
||||
for (const exit of exitsFrom(card, node.entry)) {
|
||||
@@ -381,13 +452,19 @@ export function reachableDestinations(
|
||||
// hand. Enqueueing on `hasPort` alone routed trains across a 45° leg that bent back on itself.
|
||||
const to = neighbour(node.coord, exit);
|
||||
const next = cardAt(area, to);
|
||||
if (!next || !joins(card, exit, next)) continue;
|
||||
if (!next || !joins(card, exit, next)) {
|
||||
if (next) block(to, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
|
||||
continue;
|
||||
}
|
||||
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
|
||||
// from a bad angle first and a good one later.
|
||||
const reached = new Set(results.map((r) => coordKey(r.coord)));
|
||||
return { destinations: results, blocked: blocked.filter((b) => !reached.has(coordKey(b.coord))) };
|
||||
}
|
||||
|
||||
/** Both directions at once — what the UI highlights when a tray is selected. */
|
||||
|
||||
+242
-37
@@ -52,11 +52,15 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
const CH = 58;
|
||||
const GAP = 6;
|
||||
/**
|
||||
* Room for the buffer stops AND their labels. "trains enter" hangs off the west end and "trains
|
||||
* leave" off the east, so a padding sized for the stop alone clipped both to their middle three
|
||||
* letters — the west end read "TER" and the east "tra".
|
||||
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
|
||||
*
|
||||
* They used to hang off the ends — "west end · in and out" anchored outside the first cell — which
|
||||
* needed about 126px of padding to fit and had 92, so both ends were clipped mid-word. Widening
|
||||
* the padding to fit them would have spent that space on two captions instead of on the map.
|
||||
* They now sit UNDER their own Division Point, where they are neither clipped nor in the way, and
|
||||
* this is back to what a buffer stop actually needs.
|
||||
*/
|
||||
const PAD = 92;
|
||||
const PAD = 22;
|
||||
const SIDE_GAP = 34;
|
||||
|
||||
const esc = (t: string): string =>
|
||||
@@ -67,7 +71,17 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
kind: 'dp' | 'ml' | 'run';
|
||||
label: string;
|
||||
sub: string;
|
||||
trains: { label: string; consist: string[]; region?: number; direction?: string }[];
|
||||
trains: {
|
||||
label: string;
|
||||
consist: string[];
|
||||
/** Nose first, no engine — drawn as blocks, loaded solid and empty hollow. */
|
||||
cars?: string[];
|
||||
engineAt?: number;
|
||||
facing?: string;
|
||||
region?: number;
|
||||
direction?: string;
|
||||
stagesLeft?: number;
|
||||
}[];
|
||||
cap: number | null;
|
||||
tip: string;
|
||||
owner: number | null;
|
||||
@@ -262,19 +276,66 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A TRAIN, DRAWN AS A TRAIN — the same vocabulary the Office Area card uses.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
c.trains.forEach((t, k) => {
|
||||
const w = 30 + Math.min(t.consist.length, 4) * 8;
|
||||
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 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)));
|
||||
// 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 ▶' : '';
|
||||
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}${
|
||||
const stages =
|
||||
typeof t.stagesLeft === 'number'
|
||||
? ` · ${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)}">` +
|
||||
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
|
||||
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(t.label)}</text></g>`;
|
||||
`<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>`;
|
||||
}
|
||||
});
|
||||
out += '</g>';
|
||||
});
|
||||
out += '</g>';
|
||||
});
|
||||
@@ -283,19 +344,27 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
// what stops a seated layout being read as a loop.
|
||||
const first = cells[0];
|
||||
const last = cells[cells.length - 1];
|
||||
/**
|
||||
* UNDER THE DIVISION POINT, NOT BESIDE IT.
|
||||
*
|
||||
* Both labels used to hang off the outside of the end cells, anchored away from the board — which
|
||||
* needed padding wider than the caption to survive, did not get it, and so printed clipped at both
|
||||
* ends while spending the space anyway. Centred under their own cell they are neither cut off nor
|
||||
* competing with the map for width.
|
||||
*
|
||||
* BOTH ENDS DO BOTH. Odd trains run west and even trains run east (content.ts `pair`), so each
|
||||
* Division Point is a way on AND a way off. "trains enter" here and "trains leave" there said the
|
||||
* Division ran one way, which it does not — the buffer stops only mean it is a LINE, not a loop.
|
||||
*/
|
||||
if (first) {
|
||||
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
|
||||
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
|
||||
// BOTH ENDS DO BOTH. Odd trains run west and even trains run east (content.ts `pair`), so each
|
||||
// Division Point is a way on AND a way off. "trains enter" here and "trains leave" there said
|
||||
// the Division ran one way, which it does not — the point of the buffer stops is only that it is
|
||||
// a LINE and not a loop.
|
||||
out += `<text class="bs-end" x="${first.x - 12}" y="${first.y + CH + 12}" text-anchor="end">west end · in and out</text>`;
|
||||
out += `<text class="bs-end" x="${first.x + first.w / 2}" y="${first.y + CH + 13}" text-anchor="middle">west end · in and out</text>`;
|
||||
}
|
||||
if (last && last !== first) {
|
||||
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
|
||||
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
|
||||
out += `<text class="bs-end" x="${last.x + last.w + 12}" y="${last.y + CH + 12}">east end · in and out</text>`;
|
||||
out += `<text class="bs-end" x="${last.x + last.w / 2}" y="${last.y + CH + 13}" text-anchor="middle">east end · in and out</text>`;
|
||||
}
|
||||
|
||||
out += '</svg>';
|
||||
@@ -317,6 +386,15 @@ export function officeSvg(
|
||||
cells: CellView[],
|
||||
runningRow: number,
|
||||
ghosts: { row: number; col: number }[] = [],
|
||||
/**
|
||||
* Legal squares that already HAVE a card on them, each with what playing there would do.
|
||||
*
|
||||
* An empty legal square gets a whole ghost card saying "place here"; an occupied one used to get a
|
||||
* blue outline and nothing else, so the two most important placements in the game — extending the
|
||||
* Running Track at a Limits sign, and attaching an Enhancement to a card already down — were
|
||||
* offered without a word. Reported as the Depot lighting up for no visible reason.
|
||||
*/
|
||||
legal: { row: number; col: number; label: string }[] = [],
|
||||
): string {
|
||||
const W = 166;
|
||||
const H = 96;
|
||||
@@ -495,12 +573,58 @@ export function officeSvg(
|
||||
bx += cellW + 2;
|
||||
}
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
|
||||
|
||||
/**
|
||||
* WHICH WAY THIS INDUSTRY RUNS, IN WORDS.
|
||||
*
|
||||
* A Refinery ships out and a Grocer's Warehouse receives, and the drawing told them apart by
|
||||
* the STROKE COLOUR of one 13x12px box and the direction of a 10px chevron. Asked directly in
|
||||
* playtesting whether the two were supposed to look the same: they were not, and effectively
|
||||
* they did. A word costs almost nothing and cannot be misread.
|
||||
*/
|
||||
const flow = f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
|
||||
const flowCls = f.allowsOut && f.allowsIn ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
|
||||
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
|
||||
f.allowsOut && f.allowsIn
|
||||
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
|
||||
: f.allowsOut
|
||||
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
|
||||
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
|
||||
)}">${esc(flow)}</text>`;
|
||||
|
||||
/**
|
||||
* THE WORKERS, ON THE CARD.
|
||||
*
|
||||
* "How do I see the number of laborers in an industry card?" — you could not. The number that
|
||||
* decides every Cargo phase lived only in a side panel, so a Modifier's +1 Laborer landed
|
||||
* where the player was not looking and an Oil Depot appeared to do nothing at all. Porters
|
||||
* were worse: nothing on the page drew them anywhere.
|
||||
*
|
||||
* FREE OVER TOTAL, because free is what can be spent right now. A facility has one kind of
|
||||
* worker or the other — Laborers work freight, Porters work passengers — so the card carries
|
||||
* whichever one it actually has.
|
||||
*/
|
||||
const isPorter = f.laborers.startsWith('0/0');
|
||||
const crew = isPorter ? f.porters : f.laborers;
|
||||
if (!crew.startsWith('0/0')) {
|
||||
const free = Number(crew.split('/')[0]);
|
||||
out += `<g data-tip="${esc(
|
||||
isPorter
|
||||
? `${crew} Porters free this Stage. A Porter boards or detrains passengers — one action each, and the Revenue arrives in that single action.`
|
||||
: `${crew} Laborers free this Stage. One Laborer moves one load one square along MEN | AT | WORK, so this is the whole of what this industry can do before the Stage turns over.`,
|
||||
)}">` +
|
||||
`<rect class="bs-crewpill${free > 0 ? ' bs-ready' : ''}" x="${W - 44}" y="${by - 1}" width="38" height="14" rx="7"/>` +
|
||||
`<text class="bs-crewlab" x="${W - 25}" y="${by + 9}" text-anchor="middle">${esc(isPorter ? 'P' : 'L')} ${esc(crew)}</text></g>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Standing room, drawn as the printed squares: filled means occupied.
|
||||
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
|
||||
for (let i = 0; i < Math.min(spots, 4); i++) {
|
||||
const filled = i < cell.cars.length;
|
||||
const carTip = filled
|
||||
? `${cell.cars[i]} standing here`
|
||||
: 'an empty space on this track — a car may be spotted here';
|
||||
/**
|
||||
* WHICH CAR IS STANDING HERE, not just that one is.
|
||||
*
|
||||
@@ -526,11 +650,24 @@ export function officeSvg(
|
||||
: /coach/.test(car)
|
||||
? 'cch'
|
||||
: '';
|
||||
const loaded = /^loaded/.test(car);
|
||||
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ''}` : '';
|
||||
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
|
||||
// A caboose is never "loaded" in the freight sense (carLabel says so), so it is drawn solid:
|
||||
// it is a car that is doing its job, not an empty waiting for one.
|
||||
const loaded = /^loaded/.test(car) || /caboose/.test(car);
|
||||
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ' bs-mt'}` : '';
|
||||
/**
|
||||
* LOADED OR EMPTY, TOLD THE SAME WAY THE TRAY TELLS IT.
|
||||
*
|
||||
* REPORTED: "I left a car in a siding and cannot remember whether it was loaded. In the train
|
||||
* tray it is obvious; on the office cards it is hard to tell." It was: an occupied slot took a
|
||||
* generic blue fill first and only a LOADED car overrode it with its type colour, so empty
|
||||
* read as blue-ish and loaded as brown-ish at 26x15px, and neither said which it was.
|
||||
*
|
||||
* Now the card matches the tray — solid face for loaded, hollow for empty — and every slot
|
||||
* carries the car's own words as a tooltip, so "loaded hopper" is one hover away.
|
||||
*/
|
||||
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2" data-tip="${esc(carTip)}"/>`;
|
||||
if (filled) {
|
||||
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc(type)}</text>`;
|
||||
out += `<text class="bs-carlab${loaded ? ' bs-lab-ld' : ''}" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle" data-tip="${esc(carTip)}">${esc(type)}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,22 +688,41 @@ export function officeSvg(
|
||||
* is a thing you can see before you choose it.
|
||||
*/
|
||||
const t = cell.train;
|
||||
const items: { label: string; kind: string }[] = t.cars.map((c) => ({
|
||||
const items: { label: string; kind: string; what: string }[] = t.cars.map((c) => ({
|
||||
label: /caboose/.test(c) ? 'cab' : /hopper/.test(c) ? 'hop' : /boxcar/.test(c) ? 'box'
|
||||
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
|
||||
kind: /^loaded/.test(c) ? 'ld' : 'mt',
|
||||
kind: /^loaded/.test(c) || /caboose/.test(c) ? 'ld' : 'mt',
|
||||
what: c,
|
||||
}));
|
||||
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
|
||||
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng' });
|
||||
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng', what: 'the engine' });
|
||||
/**
|
||||
* WEST ON THE LEFT, AND THE NOSE POINTING THE WAY THE ENGINE FACES.
|
||||
*
|
||||
* `consist` is ordered NOSE FIRST (state.ts), and this drew index 0 leftmost whatever the
|
||||
* train was doing \u2014 so a westbound train came out right and an eastbound one came out
|
||||
* MIRRORED. Reported from seed 270861860: Train 10 running east arrived at the Whistle Post
|
||||
* drawn engine-first at the WEST end, which reads as an engine shoving four cars ahead of it.
|
||||
*
|
||||
* The board is a map, so the drawing has to obey the map: reverse the seating order for an
|
||||
* east-facing train and its nose lands at the east end, where it is. A crew on a north-south
|
||||
* spur has no left or right to be right about, so it keeps nose-left and its \u25b2/\u25bc says the rest.
|
||||
*/
|
||||
const laid = t.facing === 'e' ? [...items].reverse() : items;
|
||||
const cw = 17;
|
||||
const tw = Math.min(W - 8, items.length * cw + 30);
|
||||
const tw = Math.min(W - 8, laid.length * cw + 30);
|
||||
const tx = W / 2 - tw / 2;
|
||||
out += `<g class="bs-crew"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
|
||||
const facingWord =
|
||||
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.`,
|
||||
)}"><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>`;
|
||||
items.forEach((it, n) => {
|
||||
laid.forEach((it, n) => {
|
||||
const bx = tx + 28 + n * cw;
|
||||
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2"/>` +
|
||||
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(it.label)}</text>`;
|
||||
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2" data-tip="${esc(it.what)}"/>` +
|
||||
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle" data-tip="${esc(it.what)}">${esc(it.label)}</text>`;
|
||||
});
|
||||
out += '</g>';
|
||||
}
|
||||
@@ -582,6 +738,23 @@ export function officeSvg(
|
||||
`</g>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A LEGAL SQUARE THAT ALREADY HAS A CARD ON IT, captioned like the empty ones.
|
||||
*
|
||||
* The two are the same question — you have picked a card, now pick where it goes — and they were
|
||||
* answered in two different registers: a full ghost card reading "place here" for an empty square,
|
||||
* and a 2px blue outline for an occupied one. So the Limits sign (which is how a district grows at
|
||||
* all) and the Depot (which is where an Enhancement attaches) both lit up saying nothing.
|
||||
*
|
||||
* Drawn as a band across the card rather than over its middle: there is a rail, a train and a load
|
||||
* pipeline under here, and the caption must not sit on top of them.
|
||||
*/
|
||||
for (const l of legal) {
|
||||
out += `<g class="bs-legalcap" transform="translate(${px(l.col)},${py(l.row)})">` +
|
||||
`<rect x="6" y="${H - 46}" width="${W - 12}" height="14" rx="7"/>` +
|
||||
`<text x="${W / 2}" y="${H - 36}" text-anchor="middle">${esc(l.label)}</text></g>`;
|
||||
}
|
||||
|
||||
// Mark the Running Track so the spine of the district is unmistakable.
|
||||
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
|
||||
out += '</svg>';
|
||||
@@ -624,22 +797,25 @@ export const BOARD_CSS = `
|
||||
train is measured against, not something to look at instead of the train. */
|
||||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
||||
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
|
||||
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL. An industry takes one commodity and nothing else, so the
|
||||
type has to be readable at a glance; and a loaded car and an empty one of the same type are
|
||||
opposite halves of the same job. Empty rides as an outline, loaded as a solid face. */
|
||||
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
|
||||
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they
|
||||
are the same fact about the same car. An occupied slot used to take a generic blue fill and only
|
||||
a loaded car overrode it, so a 26x15px square said "a car" and left "which car, carrying what?"
|
||||
to be worked out. Empty is now hollow with a dark face, loaded a solid one, exactly as in the
|
||||
tray; the type colour is the stroke either way. */
|
||||
.bs-slot.bs-car-box{stroke:#c08a4a}
|
||||
.bs-slot.bs-car-hop{stroke:#8d97a5}
|
||||
.bs-slot.bs-car-tnk{stroke:#7a6ab0}
|
||||
.bs-slot.bs-car-rfr{stroke:#6fb5a0}
|
||||
.bs-slot.bs-car-cch{stroke:#5aa9e6}
|
||||
.bs-slot.bs-car-cab{stroke:#c05a5a}
|
||||
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.42)}
|
||||
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.42)}
|
||||
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.42)}
|
||||
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.42)}
|
||||
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.42)}
|
||||
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
|
||||
.bs-slot.bs-occ.bs-mt{fill:#181d24}
|
||||
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.85)}
|
||||
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.85)}
|
||||
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.85)}
|
||||
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.85)}
|
||||
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)}
|
||||
.bs-slot.bs-car-cab.bs-loaded{fill:rgba(192,90,90,.85)}
|
||||
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
||||
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
||||
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
|
||||
@@ -655,7 +831,10 @@ export const BOARD_CSS = `
|
||||
.bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee}
|
||||
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
|
||||
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
||||
/* The three letters sit ON the slot, so they invert with it: dark on a loaded car's solid face,
|
||||
light on an empty one's hollow. */
|
||||
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
|
||||
.bs-carlab.bs-lab-ld{fill:#12161b;font-weight:700}
|
||||
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||||
.bs-wb.bs-green{stroke:#3f7a52}
|
||||
.bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)}
|
||||
@@ -664,6 +843,17 @@ export const BOARD_CSS = `
|
||||
.bs-wb.bs-red{stroke:#8a5a5a}
|
||||
.bs-wb.bs-red.bs-occ{fill:rgba(170,70,70,.6)}
|
||||
.bs-wl{fill:#8d97a5;font:7px ui-monospace,monospace}
|
||||
/* WHICH WAY THE INDUSTRY RUNS. Green for ships-out, red for receives, in the same hues as the boxes
|
||||
they describe — the word and the colour saying the same thing twice. */
|
||||
.bs-flow{font:700 8px ui-monospace,monospace;letter-spacing:.06em;cursor:help}
|
||||
.bs-flow-out{fill:#5f9b74}
|
||||
.bs-flow-in{fill:#b07272}
|
||||
.bs-flow-both{fill:#8b94a3}
|
||||
/* THE WORKERS. Free over total, on the card, because a Laborer that is already spent this Stage is
|
||||
not a Laborer you can use. Lit while any remain, dim once they are all gone. */
|
||||
.bs-crewpill{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||||
.bs-crewpill.bs-ready{fill:rgba(190,150,40,.22);stroke:#8a6d1f}
|
||||
.bs-crewlab{fill:#cfd6e0;font:600 9px ui-monospace,monospace}
|
||||
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
|
||||
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
||||
@@ -677,9 +867,24 @@ text.bs-mod{fill:#c8a04a}
|
||||
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
||||
.bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4}
|
||||
.bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace}
|
||||
/* The caption on a legal square that already carries a card. Same blue as the ghost squares and the
|
||||
same words in kind, because it is the same question: you have picked a card, now pick where. */
|
||||
.bs-legalcap rect{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-width:1}
|
||||
.bs-legalcap text{fill:#cfe0f5;font:700 8px ui-monospace,monospace;letter-spacing:.07em}
|
||||
.bs-legalcap{pointer-events:none}
|
||||
.bs-ghost,g[data-cell].bs-legal{cursor:pointer}
|
||||
g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5}
|
||||
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
|
||||
/* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable
|
||||
card reads the same way an action button does. bs-from is where it is standing now. */
|
||||
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
|
||||
g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
|
||||
/* AND WHERE IT CANNOT. Muted red with a barred face — a card that is refusing traffic, not one that
|
||||
is merely uninteresting. The reason rides on the card's own tooltip. */
|
||||
g[data-cell].bs-blocked .bs-card{stroke:#8a5a5a;stroke-width:2;stroke-dasharray:2 3}
|
||||
/* NOT THE SAME THING. A turnout carries no wheel icon (§A.1): a train runs through it freely and
|
||||
simply cannot finish a Move on it. Drawn as a faint amber dash — related to the reachable amber,
|
||||
because it IS reachable, just not a place to stop. */
|
||||
g[data-cell].bs-nostop .bs-card{stroke:#6a5f3a;stroke-width:1.6;stroke-dasharray:2 4}
|
||||
g[data-cell].bs-blocked,g[data-cell].bs-nostop{cursor:help}
|
||||
`;
|
||||
|
||||
+41
-7
@@ -17,7 +17,7 @@
|
||||
|
||||
import { adTrackCount, coordKey } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
|
||||
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
|
||||
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -212,12 +212,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
tone: 'good',
|
||||
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
|
||||
};
|
||||
case 'enhancementPlaced':
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `ENHANCEMENT built: ${e.key.replace(/([A-Z])/g, ' $1')} at ${at(e.at)}`,
|
||||
};
|
||||
case 'enhancementPlaced': {
|
||||
const name = e.key.replace(/([A-Z])/g, ' $1');
|
||||
// Out on the Mainline is not a square in anyone's district, so it is named rather than
|
||||
// given a coordinate the Office Area does not have.
|
||||
if (e.at === undefined) {
|
||||
return { tone: 'good', text: `ENHANCEMENT built: ${name} on Mainline card ${e.node}` };
|
||||
}
|
||||
return { tone: 'good', where: e.at, text: `ENHANCEMENT built: ${name} at ${at(e.at)}` };
|
||||
}
|
||||
case 'secondSectionOrdered':
|
||||
return {
|
||||
tone: 'bad',
|
||||
@@ -491,6 +494,37 @@ export function impediments(s: GameState, player = 0): Impediment[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY THE CREW CANNOT GET THERE.
|
||||
*
|
||||
* The panel is called "why nothing is moving" and covered everything except movement: jammed
|
||||
* facilities, held trains, a full Office. A crew standing one card short of an industry it cannot
|
||||
* enter had nothing here at all — reported as trains being blocked "in certain conditions", with
|
||||
* no way to find out which.
|
||||
*
|
||||
* Only while switching, and only the cards actually in the crew's way: `movesFor` reports the
|
||||
* squares the movement walk reached and refused, not every square on the board.
|
||||
*/
|
||||
if (s.clock.phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining > 0) {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
const { blocked } = movesFor(s, player, id);
|
||||
for (const b of blocked) {
|
||||
// A turnout is not an obstruction — a train runs through one all day and simply may not
|
||||
// STOP on it. Listing every one would bury the four reasons that are genuinely in the way;
|
||||
// the board says it on the card instead.
|
||||
if (b.kind === 'noStopping') continue;
|
||||
out.push({
|
||||
where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.row},${b.coord.col})`,
|
||||
why: b.why,
|
||||
// Not "stuck": these are the shape of the district and of the other trains in it, which is
|
||||
// the puzzle rather than a fault. Amber, not red.
|
||||
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) {
|
||||
|
||||
+156
-19
@@ -10,7 +10,14 @@
|
||||
* drift into two different pictures of the same board.
|
||||
*/
|
||||
|
||||
import { areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
|
||||
import {
|
||||
areaOf,
|
||||
destinationsFor,
|
||||
facilityCarType,
|
||||
laborersLeft,
|
||||
movesFor,
|
||||
portersLeft,
|
||||
} from '../engine/apply.ts';
|
||||
import {
|
||||
ACTION_CARDS,
|
||||
ENHANCEMENT_CARDS,
|
||||
@@ -29,7 +36,7 @@ import {
|
||||
trainProfile,
|
||||
} from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Facility, GameState, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||
@@ -108,7 +115,7 @@ export type FacilityView = {
|
||||
* happened. Reported after playing an Ice House and Local Small Groceries and seeing no change
|
||||
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
|
||||
*/
|
||||
base: { out: number; in: number; laborers: number };
|
||||
base: { out: number; in: number; laborers: number; porters: number };
|
||||
/**
|
||||
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
|
||||
*
|
||||
@@ -133,7 +140,19 @@ export type FacilityView = {
|
||||
|
||||
export type TrainChip = {
|
||||
label: string;
|
||||
/** Nose first, with `ENG` seated where the engine actually is. The words, for a tooltip. */
|
||||
consist: string[];
|
||||
/**
|
||||
* THE SAME TRAIN THE OFFICE CARD DRAWS, so the Division map can draw it the same way.
|
||||
*
|
||||
* `cars` is nose first and carries no engine; `engineAt` is where the engine sits among them and
|
||||
* `facing` is the port it points at. The Division chip used to be a name and a number — and the
|
||||
* number was Stages left to cross, which reads as redundant beside the position already drawn on
|
||||
* the card. A train is worth drawing: what it is carrying, loaded or empty, and which end leads.
|
||||
*/
|
||||
cars: string[];
|
||||
engineAt: number;
|
||||
facing: string;
|
||||
/**
|
||||
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
|
||||
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
|
||||
@@ -141,6 +160,15 @@ export type TrainChip = {
|
||||
*/
|
||||
region?: number;
|
||||
direction?: string;
|
||||
/**
|
||||
* Stages still to run before it is off this Mainline card — NOT the same as regions left.
|
||||
*
|
||||
* A card is two regions of fixed distance; the Stages are how long this train takes over them
|
||||
* (`crossingStages`): a 60 card is one Stage, a 30 card two, a Slow train adds one. So they
|
||||
* coincide only in the middle case. It rode on the chip as "· 2⧗" and was read as a car count;
|
||||
* it belongs in the tooltip, where there is room to say which it is.
|
||||
*/
|
||||
stagesLeft?: number;
|
||||
};
|
||||
/**
|
||||
* One card of a player's Running Track, as the Division sees it.
|
||||
@@ -223,6 +251,22 @@ export type Frame = {
|
||||
* moves themselves.
|
||||
*/
|
||||
movesLeft: number | null;
|
||||
/**
|
||||
* WHERE THE CREW CAN GO, AND WHY NOT ELSEWHERE — while it is switching, and null otherwise.
|
||||
*
|
||||
* The switching game was played off a list of coordinates: "move to (0, -2)" as a button, with
|
||||
* nothing on the board and no account of the squares that were missing from the list. Reported as
|
||||
* trains being blocked from entering an industry "in certain conditions", with no way to see what
|
||||
* the conditions were.
|
||||
*
|
||||
* `blocked` comes out of the movement walk itself (`movesFor`), so a reason on screen is the rule
|
||||
* that actually refused the square rather than a second guess at it.
|
||||
*/
|
||||
moves: {
|
||||
from: { row: number; col: number };
|
||||
to: { row: number; col: number }[];
|
||||
blocked: { coord: { row: number; col: number }; kind: string; why: string }[];
|
||||
} | null;
|
||||
facilities: FacilityView[];
|
||||
hand: string[];
|
||||
/** What each hand card does, in the same order — names alone are not a playable hand. */
|
||||
@@ -334,7 +378,7 @@ function facilityView(
|
||||
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
|
||||
allowsOut: f.allows.outbound,
|
||||
allowsIn: f.allows.inbound,
|
||||
base: baseOf(card),
|
||||
base: baseOf(card, officeName),
|
||||
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
|
||||
};
|
||||
}
|
||||
@@ -345,13 +389,29 @@ function facilityView(
|
||||
* Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card
|
||||
* the player is holding. A passenger facility takes its numbers from the Office tier instead.
|
||||
*/
|
||||
function baseOf(card: { geometry: { kind: string; facility?: string } }): { out: number; in: number; laborers: number } {
|
||||
function baseOf(
|
||||
card: { geometry: { kind: string; facility?: string } },
|
||||
officeName: string,
|
||||
): { out: number; in: number; laborers: number; porters: number } {
|
||||
const g = card.geometry;
|
||||
if (g.kind === 'facility' && g.facility) {
|
||||
const p = industryProfile(g.facility as never);
|
||||
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders };
|
||||
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders, porters: 0 };
|
||||
}
|
||||
return { out: 0, in: 0, laborers: 0 };
|
||||
/**
|
||||
* A PASSENGER FACILITY TAKES ITS NUMBERS FROM THE OFFICE TIER.
|
||||
*
|
||||
* The comment above this function has said so for a long time and the code returned zeros, so the
|
||||
* panel worked out its "+N from a Modifier" against a base of nothing: a plain Depot with no
|
||||
* Modifier anywhere near it displayed `out 1 +1`, crediting a card that had never been played.
|
||||
* The tier is the printed number here, exactly as the industry card is for an industry.
|
||||
*/
|
||||
if (g.kind === 'office') {
|
||||
const tier = OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost';
|
||||
const p = officeProfile(tier);
|
||||
return { out: p.passengerOut, in: p.passengerIn, laborers: 0, porters: p.porters };
|
||||
}
|
||||
return { out: 0, in: 0, laborers: 0, porters: 0 };
|
||||
}
|
||||
|
||||
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
|
||||
@@ -459,8 +519,30 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
const onto = top ? `, burying ${cardName(s, top)}` : ' (empty)';
|
||||
return `discard ${cardName(s, i.cardId)} onto Department ${i.toSlot + 1}${onto}`;
|
||||
}
|
||||
case 'switch.move':
|
||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
|
||||
case 'switch.move': {
|
||||
/**
|
||||
* SAY WHAT THE MOVE WILL PICK UP.
|
||||
*
|
||||
* Coupling is mandatory (§A.4): run over a card with cars standing on it and they join the
|
||||
* train, whether or not you wanted them. The button said "move to (0, -2)" and the only
|
||||
* account of the coupling was a line in the history panel — which is how a playtester ended up
|
||||
* reporting that "cars magically appeared on my train".
|
||||
*
|
||||
* Taken from the engine's own destination list, so the count on the button is the count that
|
||||
* will actually couple.
|
||||
*/
|
||||
const tray = s.trays.get(i.trayId);
|
||||
const here = tray?.position.at === 'grid' ? tray.position.coord : null;
|
||||
let picks = '';
|
||||
if (here) {
|
||||
const dest = destinationsFor(s, tray!.position.at === 'grid' ? tray!.position.owner : 0, i.trayId, here, i.reverse)
|
||||
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
||||
if (dest && dest.couples.length > 0) {
|
||||
picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`;
|
||||
}
|
||||
}
|
||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}${picks}`;
|
||||
}
|
||||
case 'switch.dropCars': {
|
||||
/**
|
||||
* NAME THE CARS AND THE END THEY COME OFF.
|
||||
@@ -708,13 +790,14 @@ export function snapshot(
|
||||
return {
|
||||
...chip,
|
||||
/**
|
||||
* SAY WHAT THE NUMBER IS.
|
||||
* THE NUMBER COMES OFF THE CHIP.
|
||||
*
|
||||
* This read "TX14 (2)", a bare figure next to a train's name — and it was read as the
|
||||
* car count twice, by the same player, because that is the obvious guess. It is Stages
|
||||
* left to cross this Mainline card. Naming the unit costs three characters.
|
||||
* It read "TX14 (2)" and was taken for the car count — twice, by the same player — so
|
||||
* it was named "· 2⧗", Stages left to cross. Named, it was then correctly read as
|
||||
* redundant: the card already draws WHERE the train is, and how many Stages it still
|
||||
* needs is a detail for the tooltip. The chip draws the train instead.
|
||||
*/
|
||||
label: `${chip.label} · ${t.stagesRemaining}⧗`,
|
||||
stagesLeft: t.stagesRemaining,
|
||||
region: place(t),
|
||||
direction: t.direction,
|
||||
};
|
||||
@@ -826,6 +909,7 @@ export function snapshot(
|
||||
objective: objectiveOf(s),
|
||||
runningRow: area.runningRow,
|
||||
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
|
||||
moves: switchingMoves(s, 0),
|
||||
blocked: impediments(s, 0),
|
||||
trains: [...s.trays.values()].map((t) => ({
|
||||
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
@@ -978,15 +1062,44 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
const g = card.geometry;
|
||||
switch (g.kind) {
|
||||
case 'limits':
|
||||
return 'the edge of your control area — lay track HERE to extend the Running Track';
|
||||
/**
|
||||
* THE WHOLE RULE, not half of it.
|
||||
*
|
||||
* This said "lay track HERE to extend the Running Track", which is true and leaves out the
|
||||
* part a player has to know: the sign is the ONLY growth point on the main, it moves outward
|
||||
* with the card, and nothing anywhere on the board can be inserted between two cards already
|
||||
* down. Asked for directly after a playtest — "somewhere it should be clear that track can
|
||||
* only be added to expand outwards".
|
||||
*/
|
||||
return (
|
||||
'the edge of your control area. Lay track HERE and the sign moves one card further out — ' +
|
||||
'this is the only way the Running Track grows, and nothing may be built beyond the sign. ' +
|
||||
'A district only ever expands: no card can be inserted between two cards already down.'
|
||||
);
|
||||
case 'office': {
|
||||
const p = officeProfile(
|
||||
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
|
||||
);
|
||||
/**
|
||||
* READ THE OFFICE AS IT STANDS, not as its card was printed.
|
||||
*
|
||||
* This took the porter count and the passenger slots from the TIER PROFILE, so a Waiting Area,
|
||||
* Restaurant or Hotel standing beside the Office — each +1 porter and +1 passenger out —
|
||||
* changed the Office and left this line saying what a bare Depot has. Reported as an extra
|
||||
* porter with "no indication of it anywhere": the Modifier had worked and nothing said so.
|
||||
*
|
||||
* The facility record is the Office; the profile is only what it started as.
|
||||
*/
|
||||
const f = card.facility;
|
||||
const porters = f ? f.porters : p.porters;
|
||||
const out = f ? f.capacity.outbound : p.passengerOut;
|
||||
const inb = f ? f.capacity.inbound : p.passengerIn;
|
||||
const added = porters - p.porters + (out - p.passengerOut) + (inb - p.passengerIn);
|
||||
return (
|
||||
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
|
||||
(p.isPassengerFacility
|
||||
? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
|
||||
? `${porters} porter${porters === 1 ? '' : 's'}, passengers ${out} out / ${inb} in` +
|
||||
(added > 0 ? ` (the card prints ${p.porters}/${p.passengerOut}/${p.passengerIn}; the Modifiers beside it add the rest)` : '')
|
||||
: 'not a Passenger Facility — no porters, no passenger boxes')
|
||||
);
|
||||
}
|
||||
@@ -1193,9 +1306,29 @@ function countStock(
|
||||
return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
|
||||
}
|
||||
|
||||
/**
|
||||
* The switching crew's reach, for the board to draw.
|
||||
*
|
||||
* Only while a switching turn is actually running and only while Moves remain: a highlight that
|
||||
* survives into the Cargo phase is an invitation to click something that is no longer offered.
|
||||
*
|
||||
* One crew. Solitaire has one, and with more the answer would depend on which is selected — a
|
||||
* question the page does not yet ask.
|
||||
*/
|
||||
function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
|
||||
if (s.clock.phase !== 'localOps' || s.turn.option !== 'switch') return null;
|
||||
if (s.turn.movesRemaining < 1) return null;
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
const { to, blocked } = movesFor(s, player, id);
|
||||
return { from: tray.position.coord, to, blocked };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function trainChip(s: GameState, id: string): TrainChip {
|
||||
const t = s.trays.get(id);
|
||||
if (!t) return { label: id, consist: [] };
|
||||
if (!t) return { label: id, consist: [], cars: [], engineAt: 0, facing: 'e' };
|
||||
/**
|
||||
* The engine is drawn IN the consist, at the position it occupies.
|
||||
*
|
||||
@@ -1206,10 +1339,14 @@ function trainChip(s: GameState, id: string): TrainChip {
|
||||
*/
|
||||
const cars = t.consist.map(carLabel);
|
||||
const at = Math.max(0, Math.min(cars.length, t.engineAt));
|
||||
cars.splice(at, 0, 'ENG');
|
||||
const seated = [...cars];
|
||||
seated.splice(at, 0, 'ENG');
|
||||
return {
|
||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
consist: cars,
|
||||
consist: seated,
|
||||
cars,
|
||||
engineAt: at,
|
||||
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+102
-38
@@ -45,7 +45,7 @@ import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts'
|
||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
||||
import { areaOf } from '../engine/apply.ts';
|
||||
import { areaOf, trainNeedingCars } from '../engine/apply.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
|
||||
export const SOLO_CONFIG: GameConfig = {
|
||||
@@ -189,7 +189,10 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
// "Pee-Dee" — a per-diem train whose consist is one caboose and nothing else — offers a
|
||||
// single button to add a caboose and no reason why, which reads as a broken game rather than
|
||||
// as the card doing exactly what it prints.
|
||||
const headed = prefix === 'newTrain.' ? consistTitle(game, options, actions) ?? title : title;
|
||||
// The tray the phase is waiting on, so the heading names the train the yard chips will load
|
||||
// rather than whichever tray happened to come first out of the map.
|
||||
const filling = prefix === 'newTrain.' ? trainNeedingCars(game.state) : null;
|
||||
const headed = filling !== null ? (consistTitle(game, filling) ?? title) : title;
|
||||
groups.push({ kind: prefix, title: headed, actions });
|
||||
}
|
||||
}
|
||||
@@ -220,7 +223,17 @@ export type Placeable = {
|
||||
spots: {
|
||||
label: string;
|
||||
index: number;
|
||||
coord: { row: number; col: number };
|
||||
/**
|
||||
* The square on the board this spot would fill, or **null** when the placement is not on the
|
||||
* board at all — ABS Signals goes out on the Mainline, and `node` names which card.
|
||||
*
|
||||
* Null rather than a stand-in coordinate. A Mainline placement used to travel as
|
||||
* `{ row: -1, col: node }`, and row −1 is an ordinary district row, so the board lit up a
|
||||
* district card for a placement that was never going there.
|
||||
*/
|
||||
coord: { row: number; col: number } | null;
|
||||
/** Division node index, for a placement out on the Mainline. */
|
||||
node?: number;
|
||||
/**
|
||||
* The rails this placement would put on the card, as `connectionsFor` codes.
|
||||
*
|
||||
@@ -288,7 +301,7 @@ export type Menu = {
|
||||
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
|
||||
* these key each chip to the option that adds it.
|
||||
*/
|
||||
makeUp: { title: string; cars: MakeUpAction[]; pass: number | null } | null;
|
||||
makeUp: { trayId: string; title: string; cars: MakeUpAction[]; pass: number | null } | null;
|
||||
};
|
||||
|
||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||||
@@ -313,7 +326,13 @@ export function actionMenu(game: Game): Menu {
|
||||
spots: [],
|
||||
};
|
||||
if (!entry.spots.some((sp) => sp.label === key.spot)) {
|
||||
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord, links: key.links });
|
||||
entry.spots.push({
|
||||
label: key.spot,
|
||||
index: a.index,
|
||||
coord: key.coord,
|
||||
links: key.links,
|
||||
...(key.node === undefined ? {} : { node: key.node }),
|
||||
});
|
||||
}
|
||||
bucket.set(key.subjectKey, entry);
|
||||
placeableByTitle.set(g.title, bucket);
|
||||
@@ -364,23 +383,42 @@ export function actionMenu(game: Game): Menu {
|
||||
};
|
||||
});
|
||||
|
||||
// Making up a train. `newTrain.placeCar` carries the car; the Division Yard chip that shows that
|
||||
// car is where the click belongs.
|
||||
/**
|
||||
* MAKING UP A TRAIN — ONE TRAIN.
|
||||
*
|
||||
* `newTrain.placeCar` carries the car, and the Division Yard chip showing that car is where the
|
||||
* click belongs. But two trains can be built in the same Stage — a timetabled train and a Second
|
||||
* Section, or an Extra — and this collected every option from every tray into one panel titled
|
||||
* with whichever tray came first. Reproduced at seed 99, Day 3 Stage 12: eighteen car chips under
|
||||
* "Making up Train 8", covering two different trains.
|
||||
*
|
||||
* Worse than a wrong caption: the yard chip binds to the FIRST matching option, so clicking a
|
||||
* hopper could couple it to the other train entirely.
|
||||
*
|
||||
* So the panel is scoped to ONE tray — the one the engine is actually waiting on
|
||||
* (`trainNeedingCars`, the same predicate the New Train Phase stops for). The second train comes
|
||||
* up as soon as the first is done, which is how the phase runs anyway.
|
||||
*/
|
||||
const filling =
|
||||
trainNeedingCars(game.state) ??
|
||||
options.find((i): i is Extract<Intent, { type: 'newTrain.placeCar' | 'newTrain.passCar' }> =>
|
||||
i.type === 'newTrain.placeCar' || i.type === 'newTrain.passCar',
|
||||
)?.trayId ??
|
||||
null;
|
||||
|
||||
const makeUpCars: MakeUpAction[] = [];
|
||||
let pass: number | null = null;
|
||||
options.forEach((i, index) => {
|
||||
if (i.type === 'newTrain.placeCar') makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
|
||||
if (i.type === 'newTrain.passCar') pass = index;
|
||||
if (i.type === 'newTrain.placeCar' && i.trayId === filling) {
|
||||
makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
|
||||
}
|
||||
if (i.type === 'newTrain.passCar' && i.trayId === filling) pass = index;
|
||||
});
|
||||
const makeUp =
|
||||
makeUpCars.length > 0 || pass !== null
|
||||
filling !== null && (makeUpCars.length > 0 || pass !== null)
|
||||
? {
|
||||
title:
|
||||
consistTitle(
|
||||
game,
|
||||
options,
|
||||
options.map((_, index) => ({ index })).filter((a) => options[a.index]?.type.startsWith('newTrain.')),
|
||||
) ?? 'Making up the train',
|
||||
trayId: filling,
|
||||
title: consistTitle(game, filling) ?? 'Making up the train',
|
||||
cars: makeUpCars,
|
||||
pass,
|
||||
}
|
||||
@@ -397,28 +435,31 @@ function subjectOf(
|
||||
subjectKey: string;
|
||||
subject: string;
|
||||
spot: string;
|
||||
coord: { row: number; col: number };
|
||||
coord: { row: number; col: number } | null;
|
||||
node?: number;
|
||||
links: string[];
|
||||
} | null {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
||||
/**
|
||||
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
|
||||
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
|
||||
* row −1 is an ordinary district row: an enhancement laid on a real card one row below the
|
||||
* Running Track was described as being "out on the Mainline", and ABS Signals itself lit up
|
||||
* whichever district card sat at that column.
|
||||
*/
|
||||
if (i.type === 'card.play' && i.node !== undefined) {
|
||||
const node = game.state.division.nodes[i.node];
|
||||
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `Mainline card ${i.node}`;
|
||||
return {
|
||||
subjectKey: `card:${i.cardId}`,
|
||||
subject: cardName(game.state, i.cardId),
|
||||
spot: `on the ${where}, out on the Mainline`,
|
||||
coord: null,
|
||||
node: i.node,
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
if (i.type === 'card.play' && i.placement) {
|
||||
/**
|
||||
* ABS Signals is placed on a MAINLINE card, and the engine carries which one in `placement.col`
|
||||
* as a Division node index. Showing that as a grid coordinate read as an Office Area square —
|
||||
* "(0, 1)" — which is the wrong half of the board entirely.
|
||||
*/
|
||||
const k = game.state.cards.get(i.cardId)?.kind;
|
||||
if (k?.kind === 'enhancement' && i.placement.row === -1) {
|
||||
const node = game.state.division.nodes[i.placement.col];
|
||||
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `node ${i.placement.col}`;
|
||||
return {
|
||||
subjectKey: `card:${i.cardId}`,
|
||||
subject: cardName(game.state, i.cardId),
|
||||
spot: `on the ${where}, out on the Mainline`,
|
||||
coord: i.placement,
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
|
||||
// the decision, so they ride on the spot rather than being left for the player to work out.
|
||||
const kind = game.state.cards.get(i.cardId)?.kind;
|
||||
@@ -521,10 +562,8 @@ function joinsNote(
|
||||
* the consist is the reason a button is missing. Naming it turns an unexplained restriction into a
|
||||
* card the player can read.
|
||||
*/
|
||||
function consistTitle(game: Game, options: Intent[], actions: { index: number }[]): string | null {
|
||||
const first = actions.map((a) => options[a.index]).find((i) => i && 'trayId' in i);
|
||||
if (!first || !('trayId' in first)) return null;
|
||||
const tray = game.state.trays.get(first.trayId);
|
||||
function consistTitle(game: Game, trayId: string): string | null {
|
||||
const tray = game.state.trays.get(trayId);
|
||||
if (!tray) return null;
|
||||
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!p) return null;
|
||||
@@ -628,6 +667,31 @@ export function toSave(game: Game): Save {
|
||||
return { seed: game.seed, history: game.history };
|
||||
}
|
||||
|
||||
/**
|
||||
* TAKE THE LAST ACTION BACK.
|
||||
*
|
||||
* The save IS the game — a seed and the intents submitted — so undo is "replay everything except the
|
||||
* last one". That is why this is a few lines rather than a feature: there is no undo stack to keep,
|
||||
* no inverse of each action to write, and no way for it to produce a position the rules could not
|
||||
* have reached, because the position is reached by the rules.
|
||||
*
|
||||
* WHAT IT COSTS. Replaying is O(history), which at a few hundred intents is imperceptible, and the
|
||||
* whole log is rebuilt with it — so the history panel matches the board afterwards rather than still
|
||||
* describing the move that was taken back.
|
||||
*
|
||||
* WHAT IT ALLOWS, deliberately: the RNG advances with the replay, so playing the same train card
|
||||
* again rolls the same Stage — you cannot undo your way to a better die. You CAN see the roll and
|
||||
* then spend the turn differently, which is an ordinary solitaire take-back and is the reason this
|
||||
* is solitaire-only. `TODO.md` carries the open question of whether a Stage boundary should become a
|
||||
* commit point.
|
||||
*
|
||||
* Returns null when there is nothing to undo, so the caller can leave the button disabled.
|
||||
*/
|
||||
export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null {
|
||||
if (game.history.length === 0) return null;
|
||||
return fromSave({ seed: game.seed, history: game.history.slice(0, -1) }, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a game from a save.
|
||||
*
|
||||
|
||||
+95
-4
@@ -20,6 +20,7 @@ import {
|
||||
newGame,
|
||||
submit,
|
||||
toSave,
|
||||
undo,
|
||||
view,
|
||||
} from './game.ts';
|
||||
|
||||
@@ -140,8 +141,11 @@ function render(): void {
|
||||
const chosen = forPlay
|
||||
? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey)
|
||||
: undefined;
|
||||
// A spot with no coordinate is not on this board — ABS Signals goes out on the Mainline — so it
|
||||
// is chosen from the action list and lights nothing in the district.
|
||||
const spotsAt = new Map<string, { label: string; index: number }[]>();
|
||||
for (const sp of chosen?.spots ?? []) {
|
||||
if (sp.coord === null) continue;
|
||||
const key = `${sp.coord.row},${sp.coord.col}`;
|
||||
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
|
||||
}
|
||||
@@ -168,7 +172,27 @@ function render(): void {
|
||||
const [gr, gc] = k.split(',').map(Number);
|
||||
return { row: gr!, col: gc! };
|
||||
});
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords);
|
||||
/**
|
||||
* WHAT PLAYING ON AN OCCUPIED SQUARE WOULD DO.
|
||||
*
|
||||
* Three different acts light up the same blue: building on empty ground, EXTENDING the Running
|
||||
* Track at a Limits sign (the sign moves outward with the card — the only way a district grows
|
||||
* along the main), and ATTACHING an Enhancement to a card already down. The first says "place
|
||||
* here" on a ghost card; the other two said nothing at all, which is why the Depot lighting up
|
||||
* read as a bug rather than as an offer.
|
||||
*/
|
||||
const legalCaps = [...spotsAt.keys()].flatMap((k) => {
|
||||
const cell = f.cells.find((c) => `${c.row},${c.col}` === k);
|
||||
if (!cell) return [];
|
||||
const label =
|
||||
cell.kind === 'limits'
|
||||
? 'EXTEND THE RUNNING TRACK HERE'
|
||||
: cell.kind === 'office'
|
||||
? 'ATTACH TO YOUR OFFICE'
|
||||
: 'ATTACH TO THIS CARD';
|
||||
return [{ row: cell.row, col: cell.col, label }];
|
||||
});
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps);
|
||||
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
@@ -186,6 +210,37 @@ function render(): void {
|
||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SWITCHING MOVE, ON THE BOARD.
|
||||
*
|
||||
* Every switching decision is about geography — which card the crew can reach, what it will couple
|
||||
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
|
||||
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
|
||||
* months; the play page simply never used them.
|
||||
*
|
||||
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
|
||||
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
|
||||
*/
|
||||
if (f.moves && !forPlay) {
|
||||
const here = grid.querySelector(`g[data-cell="${f.moves.from.row},${f.moves.from.col}"]`);
|
||||
if (here) here.classList.add('bs-from');
|
||||
for (const c of f.moves.to) {
|
||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||
if (g) g.classList.add('bs-focus');
|
||||
}
|
||||
for (const b of f.moves.blocked) {
|
||||
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
|
||||
if (!g) continue;
|
||||
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
|
||||
// your way — you run through it — so it must not be drawn like an industry that is locked.
|
||||
const passable = b.kind === 'noStopping';
|
||||
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
|
||||
const own = g.getAttribute('data-tip') ?? '';
|
||||
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
|
||||
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
||||
}
|
||||
}
|
||||
|
||||
function pick(key: string, list: { label: string; index: number }[]): void {
|
||||
if (list.length === 1) {
|
||||
const intent = menu.options[list[0]!.index];
|
||||
@@ -372,6 +427,7 @@ function render(): void {
|
||||
|
||||
renderDistrict(f);
|
||||
renderActions(menu, f, justSet);
|
||||
renderUndo();
|
||||
|
||||
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
|
||||
// not then play a backlog of everything that happened while it was off.
|
||||
@@ -381,6 +437,40 @@ function render(): void {
|
||||
save();
|
||||
}
|
||||
|
||||
/**
|
||||
* UNDO, as far back as you like.
|
||||
*
|
||||
* The game is a seed and a list of intents, so stepping back is replaying without the last one —
|
||||
* see `undo()` in game.ts. The button says how many moves are behind you, because "can I still get
|
||||
* back?" is the question it exists to answer; it goes quiet when there is nothing to take back.
|
||||
*
|
||||
* The whole page is re-rendered from the rebuilt game, including the history panel, so nothing on
|
||||
* screen is left describing a move that no longer happened. Any card picked up mid-choice is
|
||||
* dropped: the position it was going to be played into may not exist any more.
|
||||
*/
|
||||
function renderUndo(): void {
|
||||
const btn = document.getElementById('undo') as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
const n = game.history.length;
|
||||
btn.disabled = n === 0;
|
||||
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
|
||||
btn.onclick = () => {
|
||||
const back = undo(game);
|
||||
if (!back) return;
|
||||
game = back;
|
||||
selected = null;
|
||||
mode = null;
|
||||
pendingAt = null;
|
||||
// The rebuilt game replays its own cues from the beginning; none of them are news.
|
||||
game.cues.length = 0;
|
||||
game.scheduled = null;
|
||||
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
|
||||
// different phase is not that event, so the banner is suppressed rather than fired backwards.
|
||||
lastPhase = null;
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The two yards, and how close the Division Yard is to running out.
|
||||
*
|
||||
@@ -585,7 +675,7 @@ function renderActions(
|
||||
{
|
||||
// Once a square is picked, show only that square's rotations — the rest is noise.
|
||||
const shown = pendingAt
|
||||
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
||||
? item.spots.filter((sp) => sp.coord !== null && `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
||||
: item.spots;
|
||||
html +=
|
||||
`<div class="spots"><div class="dim">` +
|
||||
@@ -691,8 +781,9 @@ if (saveBtn) saveBtn.onclick = downloadSave;
|
||||
*
|
||||
* The only way out used to be finishing the game — `start()` restores from localStorage on every
|
||||
* load, so a game you no longer wanted followed you across reloads, and the "new game" button
|
||||
* appeared solely on the game-over screen. Confirmed because the save is the game: there is no undo,
|
||||
* and the replay download is right beside it.
|
||||
* appeared solely on the game-over screen. Confirmed because it throws the whole game away — Undo
|
||||
* steps back one action at a time, but nothing brings back a game that has been dealt over, and the
|
||||
* replay download is right beside it.
|
||||
*
|
||||
* `location.search = ''` rather than a direct re-render, so a `?seed=` in the URL goes too — leaving
|
||||
* it would deal the same game again and look like the button had done nothing.
|
||||
|
||||
+29
-1
@@ -183,7 +183,23 @@ export function facilitiesHtml(f: Frame): string {
|
||||
* and what the Modifiers beside it added.
|
||||
*/
|
||||
`<div class="caps">` +
|
||||
stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') +
|
||||
/**
|
||||
* PORTERS TOO, NOT JUST LABORERS.
|
||||
*
|
||||
* `porters` has been on the view-model all along and no renderer ever printed it — so a
|
||||
* Waiting Area, Restaurant or Hotel beside the Office, whose whole effect is +1 porter
|
||||
* and +1 passenger slot, showed exactly half of what it did. Reported: "it gave me an
|
||||
* extra porter that I see no indication of anywhere."
|
||||
*
|
||||
* A facility has one kind of worker or the other: Laborers work freight, Porters work
|
||||
* passengers. Showing the row that is always 0/0 would be noise, so each shows its own.
|
||||
*/
|
||||
(Number(x.laborers.split('/').pop()) > 0 || x.base.laborers > 0
|
||||
? stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square. Left of the slash is how many are still free this Stage.')
|
||||
: '') +
|
||||
(Number(x.porters.split('/').pop()) > 0 || x.base.porters > 0
|
||||
? stat('porters', x.porters, x.base.porters, 'Passenger work, one action each per Stage: a Porter boards or detrains passengers, and earns the Revenue in a single action. Left of the slash is how many are still free this Stage.')
|
||||
: '') +
|
||||
(x.allowsOut ? stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') : '') +
|
||||
(x.allowsIn ? stat('in', String(x.redCap), x.base.in, 'Red boxes: loads cleared off an arriving car.') : '') +
|
||||
(x.modifiers.length
|
||||
@@ -279,6 +295,18 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
@keyframes ttflash{0%{background:#2f6b47;border-color:#8fd6a0;transform:scale(1.18)}
|
||||
100%{background:#222c38;border-color:#4d6fa8;transform:scale(1)}}
|
||||
@media(prefers-reduced-motion:reduce){.tt-slot.fresh{animation:none;outline:2px solid #8fd6a0}}
|
||||
/* THE KEY TO THE FOUR STATES ABOVE. A slot can be lit blue, ringed violet, dimmed, or flashing
|
||||
green, and nothing said which was which — reported as "what do the colours mean?" The green one
|
||||
in particular is a MOMENT, not a state: it marks the slot the die just filled and is gone by the
|
||||
next render, so without a key it reads as a category of train. */
|
||||
.tt-key{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px;font-size:10px;color:#8b94a3}
|
||||
.tt-key span{display:inline-flex;align-items:center;gap:4px}
|
||||
.tt-key span::before{content:"";width:9px;height:9px;border-radius:2px;border:1px solid #2c333d;
|
||||
background:#1a1f26}
|
||||
.tt-key .k-due::before{background:#222c38;border-color:#4d6fa8}
|
||||
.tt-key .k-now::before{border-color:#b98cf0;box-shadow:0 0 0 1px rgba(150,110,230,.35)}
|
||||
.tt-key .k-past::before{opacity:.45}
|
||||
.tt-key .k-fresh::before{background:#2f6b47;border-color:#8fd6a0}
|
||||
ul.blocked{margin:0;padding-left:18px}
|
||||
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
|
||||
.fac{border-top:1px solid #2c333d;padding:7px 0}
|
||||
|
||||
+38
-2
@@ -16,11 +16,20 @@ h2.big{font-size:15px;text-transform:none;letter-spacing:.01em;color:#cfe0f5;
|
||||
margin:0 0 9px;padding-bottom:5px;border-bottom:1px solid #2c333d}
|
||||
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
|
||||
.dim{color:var(--dim)}
|
||||
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
|
||||
/* THE TOP TWO ROWS TRAVEL TOGETHER.
|
||||
The title bar was sticky and the turn chart under it was not, so scrolling down took away the one
|
||||
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}
|
||||
header{background:var(--panel);border-bottom:1px solid var(--line);
|
||||
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
|
||||
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}
|
||||
/* Nothing to take back yet. It keeps its place — a button that disappears reads as a bug — but it
|
||||
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}
|
||||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||||
.home:hover{color:#5aa9e6}
|
||||
@@ -106,6 +115,12 @@ button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size
|
||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
#district.folded #grid{display:none}
|
||||
#district.folded .districtrule{display:none}
|
||||
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
||||
and never again, so it must not shout. */
|
||||
.districtrule{font-size:11px;line-height:1.4;color:#8b94a3;margin:0 0 7px;padding:5px 8px;
|
||||
border-left:2px solid #39424e;background:#171c23;border-radius:0 4px 4px 0}
|
||||
.districtrule b{color:#cfd6e0}
|
||||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||
#districtsummary{display:none}
|
||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||
@@ -142,6 +157,7 @@ ul.blocked li{padding:2px 0}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<header>
|
||||
<b><a href="./index.html" class="home">Station Master</a></b>
|
||||
<span>Revenue <b id="revenue">0</b></span>
|
||||
@@ -149,8 +165,9 @@ ul.blocked li{padding:2px 0}
|
||||
<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. There is no undo — download the replay first if you want to keep it.">New game</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>
|
||||
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||
</header>
|
||||
@@ -160,6 +177,7 @@ ul.blocked li{padding:2px 0}
|
||||
<!-- Filled by `turnChartHtml`, shared with both replay viewers so all three screens report where
|
||||
you are in the Day identically. -->
|
||||
<div id="turnchart"></div>
|
||||
</div><!-- /topbar -->
|
||||
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
|
||||
what you miss when the automatic phases run between two clicks. -->
|
||||
<div id="phasenote"></div>
|
||||
@@ -173,6 +191,15 @@ ul.blocked li{padding:2px 0}
|
||||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
||||
</h2>
|
||||
<div id="districtsummary" class="dim"></div>
|
||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||
It is enforced everywhere and was stated nowhere: the Limits signs are the only growth
|
||||
point on the Running Track and they move outward as you build, and no card anywhere can
|
||||
be inserted between two cards already down. -->
|
||||
<div class="districtrule">A district only ever grows <b>outwards</b>. On the Running Track the
|
||||
Limits signs are the growth point — lay a card on a sign and the sign moves one further out;
|
||||
nothing may be built beyond it. Everywhere else a card needs an empty square that its rails
|
||||
reach. <b>No card is ever inserted between two cards already down</b>, so nothing you have
|
||||
built is pushed along by something laid later.</div>
|
||||
<div id="grid"></div>
|
||||
</section>
|
||||
<section><h2>History</h2><div id="log"></div></section>
|
||||
@@ -192,6 +219,15 @@ ul.blocked li{padding:2px 0}
|
||||
<div class="dim" id="yardnote"></div>
|
||||
</section>
|
||||
<section><h2>Timetable — who is due out, and when</h2><div id="timetable"></div>
|
||||
<!-- WHAT THE COLOURS MEAN. Asked directly in playtesting — "what do green and purple mean?"
|
||||
Violet is "where you are" everywhere on this page, and the green is a flash rather than a
|
||||
state, which is exactly why it needed saying. -->
|
||||
<div class="tt-key">
|
||||
<span class="k-due">Train due</span>
|
||||
<span class="k-now">this Stage</span>
|
||||
<span class="k-past">already gone</span>
|
||||
<span class="k-fresh">just rolled</span>
|
||||
</div>
|
||||
<div class="dim" style="font-size:11px;margin-top:5px">A train's number is its seniority and direction — odd runs west, even runs east. The Stage it departs is set by a 1D12 roll when its card is played, and it then runs at that time every Day.</div>
|
||||
</section>
|
||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||
|
||||
Reference in New Issue
Block a user