adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.
This commit is contained in:
+52
-22
@@ -30,6 +30,8 @@ import {
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
officeProfile,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
mainlineProfile,
|
||||
} from './content.ts';
|
||||
import type { Direction } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
@@ -186,7 +188,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
id: trayId,
|
||||
trainNumber: due,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction,
|
||||
position: { at: 'divisionPoint', side },
|
||||
@@ -219,7 +221,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
id: trayId,
|
||||
trainNumber: number,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction,
|
||||
position: { at: 'divisionPoint', side },
|
||||
@@ -250,7 +252,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
id: trayId,
|
||||
trainNumber: number,
|
||||
trainIsExtra: true,
|
||||
engineFront: true,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction: 'east',
|
||||
position: { at: 'divisionPoint', side: 'west' },
|
||||
@@ -488,6 +490,53 @@ function moveTrain(
|
||||
// Q1/Q2 — crossing takes a whole number of Stages set by the card's speed and the train's
|
||||
// Fast/Slow class. Count it down rather than stepping through printed cells.
|
||||
if (transit.stagesRemaining > 1) {
|
||||
/**
|
||||
* Q13 — REAR-END COLLISIONS, answered: a train collides when it CATCHES UP.
|
||||
*
|
||||
* §10 makes a Mainline collision the Superintendent's fault and removes both trains, and ABS
|
||||
* Signals exists to stop trains rear-ending each other — but §8.3's trigger list never named
|
||||
* one and none was implemented, so granting clearance was free: both trains survived, no
|
||||
* penalty, and ABS Signals protected against nothing.
|
||||
*
|
||||
* Colliding on CATCHING UP is the version that rewards judging the gap. A following train
|
||||
* that closes on the one ahead runs into it; a following train that never closes is fine, so
|
||||
* clearance becomes a bet on relative speed rather than a formality. §2.1 divides the card
|
||||
* into two regions, and sharing one is what "caught up" means.
|
||||
*
|
||||
* ABS Signals does what it says instead: the follower stops SHORT of the collision and holds.
|
||||
*/
|
||||
const regionOf = (t: { stagesTotal: number; stagesRemaining: number }): number => {
|
||||
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
|
||||
const elapsed = t.stagesTotal - t.stagesRemaining;
|
||||
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
|
||||
};
|
||||
// NOT on a card that prints "trains may pass". Double Track and Uncontrolled Siding hold two
|
||||
// trains because they HAVE two roads, so a train catching another there goes past it — that
|
||||
// is what the card is for. Without this the mechanic fired 0.41 times a game while the bot
|
||||
// never once granted clearance, which is the tell: those were all passing cards.
|
||||
const mayPass = mainlineProfile(node.card).trainsMayPass;
|
||||
const next = regionOf({ stagesTotal: transit.stagesTotal, stagesRemaining: transit.stagesRemaining - 1 });
|
||||
const ahead = mayPass
|
||||
? undefined
|
||||
: node.transits.find(
|
||||
(t) => t.tray !== id && t.direction === transit.direction && regionOf(t) === next,
|
||||
);
|
||||
|
||||
if (ahead) {
|
||||
if (node.absSignals) {
|
||||
// "Trains on this card will not rear-end each other; they stop short of a collision."
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason: 'ABS Signals — held short of the train ahead',
|
||||
});
|
||||
return 'held';
|
||||
}
|
||||
// §10 — the Superintendent let it in behind the other, so it is the Superintendent's fault.
|
||||
collide(s, s.clock.superintendent, [id, ahead.tray], events, 'ran into the train ahead', 'the Mainline');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
transit.stagesRemaining -= 1;
|
||||
return 'moved';
|
||||
}
|
||||
@@ -756,25 +805,6 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
// Telegraph/Telephone/Radio are each usable once a Day.
|
||||
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
||||
|
||||
/**
|
||||
* THE CLASSIFICATION YARD GOES BACK INTO SERVICE OVERNIGHT.
|
||||
*
|
||||
* ASSUMPTION, flagged rather than derived: Gap 2c says engines and cabooses return to the
|
||||
* Division Yard and everything else to Classification, and the recovered rules never say how
|
||||
* Classification empties. It was write-only — seven places pushed into it and nothing ever read
|
||||
* it — so rolling stock drained one way and left the game. Measured at 30 cars a game dead by
|
||||
* the end, 37% of the 80 dealt at setup.
|
||||
*
|
||||
* Sorting cars for redistribution is what a classification yard is FOR, and a Day is its
|
||||
* natural cycle, so they return at the Day boundary. This does not bind a 5-Day game — the
|
||||
* Division Yard still held 16.6 loaded freight cars at the end and ran dry in 2 games of 100 —
|
||||
* but a campaign-length game would have starved.
|
||||
*/
|
||||
if (s.yards.classificationYard.length > 0) {
|
||||
s.yards.divisionYard.push(...s.yards.classificationYard);
|
||||
s.yards.classificationYard = [];
|
||||
}
|
||||
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
s.collisionsToday = 0;
|
||||
|
||||
@@ -1174,6 +1174,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
|
||||
);
|
||||
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
|
||||
refillDivisionYardIfEmpty(s);
|
||||
f.outboundBox.push(e.stock);
|
||||
s.turn.freightAgentUsed = true;
|
||||
break;
|
||||
@@ -1236,6 +1237,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
|
||||
);
|
||||
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
|
||||
refillDivisionYardIfEmpty(s);
|
||||
tray.consist.push(e.stock);
|
||||
break;
|
||||
}
|
||||
@@ -1674,6 +1676,30 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
|
||||
*
|
||||
* Used Rolling Stock is set out in the Classification Yard; used engines and cabooses go straight
|
||||
* back to the Division Yard. The Classification Yard empties only when the Division Yard is bare —
|
||||
* every car of every kind gone — and then all of it returns at once.
|
||||
*
|
||||
* Confirmed from the source after an earlier guess. The first implementation returned cars at the
|
||||
* DAY boundary, which is a different rule and a much more generous one: it kept the yard topped up
|
||||
* continuously, where this lets it run down to nothing and refill in one go. That difference is the
|
||||
* whole of the supply pressure the game is meant to have.
|
||||
*
|
||||
* Called wherever a car leaves the Division Yard, so the refill happens the moment it empties
|
||||
* rather than at the next convenient tick.
|
||||
*/
|
||||
export function refillDivisionYardIfEmpty(s: GameState): { type: 'yardRefilled'; count: number } | null {
|
||||
if (s.yards.divisionYard.length > 0) return null;
|
||||
if (s.yards.classificationYard.length === 0) return null;
|
||||
const count = s.yards.classificationYard.length;
|
||||
s.yards.divisionYard.push(...s.yards.classificationYard);
|
||||
s.yards.classificationYard = [];
|
||||
return { type: 'yardRefilled', count };
|
||||
}
|
||||
|
||||
/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
|
||||
function limitsCard(): TrackCard {
|
||||
return {
|
||||
|
||||
@@ -584,7 +584,12 @@ export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', co
|
||||
export const MANEUVER_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'redFlags', name: 'Red Flags', copies: 5, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' },
|
||||
{ key: 'flyingSwitch', name: 'Flying Switch', copies: 1, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' },
|
||||
{ key: 'poling', name: 'Poling', copies: 1, placement: 'any time', effect: 'TBD in the source.' },
|
||||
// POLING IS OUT OF THE DECK, at 0 copies rather than deleted.
|
||||
//
|
||||
// It is the one card whose effect the source records as "TBD", so there is nothing to implement
|
||||
// and inventing something would be worse than leaving it out. Kept in the table with its text so
|
||||
// the gap stays visible and the card can be dealt again the moment its rule is known.
|
||||
{ key: 'poling', name: 'Poling', copies: 0, placement: 'any time', effect: 'TBD in the source.' },
|
||||
];
|
||||
|
||||
/** Played AT other players. We have no player-interaction mechanic yet. */
|
||||
|
||||
@@ -196,6 +196,13 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
||||
// least deterministic and varies between games; it should become a real player choice when
|
||||
// setup gains an interactive phase. See implications.md §10 Q11.
|
||||
if (mainlineProfile(card).speed.kind === 'grade') {
|
||||
/**
|
||||
* TEMPORARY, and flagged in TODO. The card prints "(Up)" and "Player sets orientation", so
|
||||
* which way a Heavy Grade climbs is the player's decision — but it is dealt during setup, and
|
||||
* setup has no decision point at all. Rolled from the seed until it gains one, because the
|
||||
* orientation MATTERS: it decides which direction climbs, and therefore what a Brakeman or
|
||||
* Helpers card is worth.
|
||||
*/
|
||||
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
|
||||
}
|
||||
return node;
|
||||
|
||||
+13
-2
@@ -202,8 +202,19 @@ export type CrewTray = {
|
||||
/** null while a local crew is switching without a train card. */
|
||||
trainNumber: number | null;
|
||||
trainIsExtra: boolean;
|
||||
/** Which end the engine occupies (§A.3). */
|
||||
engineFront: boolean;
|
||||
/**
|
||||
* WHERE THE ENGINE SITS IN THE TRAY, as an index into `consist`.
|
||||
*
|
||||
* A Crew Tray is an engine plus its Rolling Stock, and the engine may be PULLING (index 0, ahead
|
||||
* of everything), PUSHING (index `consist.length`, behind everything) or somewhere in the middle
|
||||
* doing both at once. That last case is why this is an index and not the boolean it replaced —
|
||||
* `engineFront` was written in three places and read in none, so the engine had a position the
|
||||
* game recorded and never used.
|
||||
*
|
||||
* The engine is NOT one of the `consist` entries: §8.2 counts the consist as Rolling Stock, and
|
||||
* the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
|
||||
*/
|
||||
engineAt: number;
|
||||
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
|
||||
consist: RollingStock[];
|
||||
direction: Direction;
|
||||
|
||||
+61
-2
@@ -24,7 +24,7 @@ import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { exitsFrom, facilityVariants, hasPort, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import { connectionsFor, exitsFrom, facilityVariants, hasPort, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { coordKey } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
@@ -524,6 +524,60 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
|
||||
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
|
||||
|
||||
/**
|
||||
* RAIL THAT CAN NEVER GO ANYWHERE.
|
||||
*
|
||||
* A port facing an EMPTY square is a promise: something may be built there later. A port butting
|
||||
* an OCCUPIED square whose card has no matching port is nothing of the kind — that square is
|
||||
* taken, so the rail stops dead and always will.
|
||||
*
|
||||
* Measured, 28% of the pieces the bot laid had one, at 4.26 a game: an arc dropped off a turnout
|
||||
* with its far end jammed into a curve that could not accept it, which is exactly the placement
|
||||
* that prompted this. Penalised rather than forbidden — a piece can still be worth laying for the
|
||||
* end that DOES connect, and the last two attempts to forbid something outright starved the
|
||||
* district instead of tidying it.
|
||||
*/
|
||||
const deadEnds = (i: Extract<Intent, { type: 'track.lay' }>): number => {
|
||||
const v = variantsFor(i.geometry)[i.variant ?? 0];
|
||||
const probe = {
|
||||
geometry: {
|
||||
kind: 'track',
|
||||
geometry: i.geometry,
|
||||
...(v?.axis ? { axis: v.axis } : {}),
|
||||
...(v?.arc ? { arc: v.arc } : {}),
|
||||
...(v?.turnout ? { turnout: v.turnout } : {}),
|
||||
},
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
} as unknown as TrackCard;
|
||||
let dead = 0;
|
||||
for (const p of new Set(connectionsFor(probe).flat())) {
|
||||
const n = neighbour(i.placement, p);
|
||||
const nb = at(n.row, n.col);
|
||||
if (!nb) continue; // empty — may still be built on
|
||||
if (hasPort(nb, opposite(p))) continue; // joins
|
||||
dead++;
|
||||
}
|
||||
return dead;
|
||||
};
|
||||
|
||||
/**
|
||||
* Arcs left to build BENEATH a turnout.
|
||||
*
|
||||
* A turnout is a hole in the Running Track and is worth cutting only for what hangs under it, so
|
||||
* cutting one with nothing left to hang there is pure loss. Measured at 0% today — the bot builds
|
||||
* the arc immediately after the turnout and never runs the supply dry first — so this is a guard
|
||||
* against the supply changing rather than a fix for something happening now.
|
||||
*/
|
||||
const arcsLeft =
|
||||
(area.trackSupply.get('curved:left') ?? 0) +
|
||||
(area.trackSupply.get('curved:right') ?? 0) +
|
||||
(area.trackSupply.get('sharpCurved:left') ?? 0) +
|
||||
(area.trackSupply.get('sharpCurved:right') ?? 0);
|
||||
|
||||
// Where the district already turns down off the main.
|
||||
const turnouts = [...area.grid.entries()]
|
||||
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c))
|
||||
@@ -562,7 +616,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
// without competing further with the ways down. Scored below a first turnout, which is still
|
||||
// the most valuable single piece on the board, and below closing a run-around.
|
||||
bonus += 13;
|
||||
} else if (row === area.runningRow && i.geometry === 'turnout') {
|
||||
} else if (row === area.runningRow && i.geometry === 'turnout' && arcsLeft > 0) {
|
||||
// A first way down is the most valuable single piece on the board; a second closes the
|
||||
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
|
||||
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
|
||||
@@ -618,6 +672,11 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
if (i.geometry === 'straight') score += 2;
|
||||
if (dRow > 0) score += 3;
|
||||
|
||||
// A TIE-BREAKER, not a veto. Applied to the distance score rather than the bonus, so it can
|
||||
// never push a piece below the "builds nothing" floor and refuse it outright: given two
|
||||
// placements the heuristics rate the same, take the one whose rail can still grow.
|
||||
score -= deadEnds(i) * 0.5;
|
||||
|
||||
/**
|
||||
* DECLINE A PIECE THAT BUILDS NOTHING.
|
||||
*
|
||||
|
||||
+19
-5
@@ -113,6 +113,10 @@ export function summarize(
|
||||
const reason = e.reason;
|
||||
if (reason.startsWith('collision')) rev.collisionLoss += -e.delta;
|
||||
else if (reason === 'freightLoad') rev.freightLoad += e.delta;
|
||||
// `freightUnload` is its own reason on its own event (apply.ts). It used to be counted from
|
||||
// `unloadBegan` instead — unloads STARTED, not Revenue EARNED — and the two differ by every
|
||||
// unload that never finished.
|
||||
else if (reason === 'freightUnload') rev.freightUnload += e.delta;
|
||||
else if (reason === 'boarding') rev.passengerBoard += e.delta;
|
||||
else if (reason === 'detraining') rev.passengerDetrain += e.delta;
|
||||
}
|
||||
@@ -129,12 +133,21 @@ export function summarize(
|
||||
|
||||
for (const i of intents) inc(intentCounts, i);
|
||||
|
||||
// An unload scores through the same event as a load completion in the reducer, so count the
|
||||
// distinct operations directly.
|
||||
rev.freightUnload = eventCounts['unloadBegan'] ?? 0;
|
||||
rev.net = final.players.reduce((n, p) => n + p.revenue, 0);
|
||||
|
||||
const grossFreight = rev.freightLoad;
|
||||
/**
|
||||
* FREIGHT IS BOTH HALVES.
|
||||
*
|
||||
* A completed load and a completed unload each earn a point, on two distinct `revenueChanged`
|
||||
* reasons — and this counted only the first, so `freightShare` reported roughly 60% of the
|
||||
* freight actually earned. That is the number the "freight is only 13-18% of gross" finding was
|
||||
* read from, and it drove the Gap 12 industry-density change.
|
||||
*
|
||||
* The comment that stood here claimed "an unload scores through the same event as a load
|
||||
* completion in the reducer", which is not true: `apply.ts` emits `freightUnload` separately. It
|
||||
* was a comment asserting a fact about code a few lines away, and it was wrong.
|
||||
*/
|
||||
const grossFreight = rev.freightLoad + rev.freightUnload;
|
||||
const grossPassenger = rev.passengerBoard + rev.passengerDetrain;
|
||||
const gross = grossFreight + grossPassenger;
|
||||
|
||||
@@ -347,7 +360,7 @@ export type StrategyBucket = {
|
||||
* consistently outscore passenger specialists (or vice versa), it shows up here.
|
||||
*/
|
||||
export function strategyBuckets(all: GameStats[]): StrategyBucket[] {
|
||||
const scored = all.filter((g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0);
|
||||
const scored = all.filter((g) => g.revenue.freightLoad + g.revenue.freightUnload + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0);
|
||||
|
||||
const buckets: { label: string; test: (g: GameStats) => boolean }[] = [
|
||||
{ label: 'passenger-only (freight 0%)', test: (g) => g.freightShare === 0 },
|
||||
@@ -408,6 +421,7 @@ export function formatAggregate(all: GameStats[]): string {
|
||||
out.push(' REVENUE (mean per game)');
|
||||
out.push(` net ${num(meanOf(all, (g) => g.revenue.net))}`);
|
||||
out.push(` from freight loads ${num(meanOf(all, (g) => g.revenue.freightLoad))}`);
|
||||
out.push(` from freight unloads ${num(meanOf(all, (g) => g.revenue.freightUnload))}`);
|
||||
out.push(` from passengers on ${num(meanOf(all, (g) => g.revenue.passengerBoard))}`);
|
||||
out.push(` from passengers off ${num(meanOf(all, (g) => g.revenue.passengerDetrain))}`);
|
||||
out.push(` lost to collisions ${num(-meanOf(all, (g) => g.revenue.collisionLoss))}`);
|
||||
|
||||
+46
-1
@@ -179,6 +179,19 @@ export type Frame = {
|
||||
departments: string[];
|
||||
/** What each face-up Department card does. */
|
||||
departmentsWhat: string[];
|
||||
/**
|
||||
* The two yards, by car type.
|
||||
*
|
||||
* Rolling stock is finite and the Classification Yard only returns to service when the Division
|
||||
* Yard is BARE, so watching the Division Yard run down is now real information — and the game
|
||||
* showed neither yard at all.
|
||||
*/
|
||||
yards: {
|
||||
division: { type: string; loaded: number; empty: number }[];
|
||||
classification: { type: string; loaded: number; empty: number }[];
|
||||
divisionTotal: number;
|
||||
classificationTotal: number;
|
||||
};
|
||||
/** 12 slots; the train number due out at each Stage, or null. */
|
||||
timetable: (number | null)[];
|
||||
blocked: Impediment[];
|
||||
@@ -598,6 +611,12 @@ export function snapshot(
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
|
||||
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
|
||||
yards: {
|
||||
division: countStock(s.yards.divisionYard),
|
||||
classification: countStock(s.yards.classificationYard),
|
||||
divisionTotal: s.yards.divisionYard.length,
|
||||
classificationTotal: s.yards.classificationYard.length,
|
||||
},
|
||||
timetable: [...s.timetable],
|
||||
decision,
|
||||
wasted,
|
||||
@@ -889,12 +908,38 @@ const MODIFIER_NAMES: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
|
||||
/** Cars in a yard, grouped by type and split loaded / empty, in a stable order. */
|
||||
function countStock(
|
||||
stock: readonly { type: string; loaded: boolean }[],
|
||||
): { type: string; loaded: number; empty: number }[] {
|
||||
const order = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
|
||||
const by = new Map<string, { type: string; loaded: number; empty: number }>();
|
||||
for (const c of stock) {
|
||||
const row = by.get(c.type) ?? { type: c.type, loaded: 0, empty: 0 };
|
||||
if (c.loaded) row.loaded += 1;
|
||||
else row.empty += 1;
|
||||
by.set(c.type, row);
|
||||
}
|
||||
return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
|
||||
}
|
||||
|
||||
function trainChip(s: GameState, id: string): TrainChip {
|
||||
const t = s.trays.get(id);
|
||||
if (!t) return { label: id, consist: [] };
|
||||
/**
|
||||
* The engine is drawn IN the consist, at the position it occupies.
|
||||
*
|
||||
* A Crew Tray is an engine plus its Rolling Stock, and the engine may be pulling, pushing, or in
|
||||
* the middle doing both — which is a thing a player has to be able to see, since it decides which
|
||||
* end cars couple onto (§A.3) and which way the train can shove. It was recorded as a boolean
|
||||
* that nothing read, so every consist was drawn as an anonymous row of cars.
|
||||
*/
|
||||
const cars = t.consist.map(carLabel);
|
||||
const at = Math.max(0, Math.min(cars.length, t.engineAt));
|
||||
cars.splice(at, 0, 'ENG');
|
||||
return {
|
||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
consist: t.consist.map(carLabel),
|
||||
consist: cars,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,8 @@ function render(): void {
|
||||
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
|
||||
: '<span class="dim">none</span>';
|
||||
|
||||
renderYards(f);
|
||||
|
||||
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
|
||||
$('supply').innerHTML =
|
||||
`<div class="dim">${total} pieces left · one may be laid per turn, during the DRAW option</div>` +
|
||||
@@ -297,6 +299,38 @@ function render(): void {
|
||||
save();
|
||||
}
|
||||
|
||||
/**
|
||||
* The two yards, and how close the Division Yard is to running out.
|
||||
*
|
||||
* §2 — used Rolling Stock is set out in the Classification Yard, and it only returns when the
|
||||
* Division Yard is BARE. So the interesting number is not how much has been used but how little is
|
||||
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
||||
*/
|
||||
function renderYards(f: ReturnType<typeof view>): void {
|
||||
const row = (rows: { type: string; loaded: number; empty: number }[]): string =>
|
||||
rows.length === 0
|
||||
? '<span class="dim">empty</span>'
|
||||
: rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
|
||||
`<b>${esc(r.type)}</b> <span class="ld">${r.loaded}</span>/<span class="mt">${r.empty}</span></span>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
$('divyard').innerHTML = row(f.yards.division);
|
||||
$('clsyard').innerHTML = row(f.yards.classification);
|
||||
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
|
||||
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
|
||||
|
||||
// The one thing worth calling out: the yard about to turn over.
|
||||
const bare = f.yards.divisionTotal === 0;
|
||||
$('divyard').classList.toggle('bare', bare);
|
||||
$('yardnote').textContent = bare
|
||||
? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.`
|
||||
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
||||
}
|
||||
|
||||
function renderDistrict(f: ReturnType<typeof view>): void {
|
||||
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
||||
const sec = $('district');
|
||||
|
||||
@@ -33,6 +33,15 @@ header button:hover{border-color:#4d6fa8}
|
||||
explicitly not a target. Previously the hand was a list of bare names and the Department slots a
|
||||
second list, so nothing looked like a card at all. */
|
||||
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
|
||||
/* THE YARDS. Rolling stock is finite and the Classification Yard only comes back when the Division
|
||||
Yard is bare, so a player has to be able to watch the supply run down. */
|
||||
.yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px}
|
||||
.stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px}
|
||||
.stock b{color:#e6e9ee}
|
||||
.stock .ld{color:#8fd6a0}
|
||||
.stock .mt{color:#9fb6d8}
|
||||
.stock.none{opacity:.4}
|
||||
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
|
||||
.handcard{background:#242c36;border:1px solid var(--line);border-radius:5px;
|
||||
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
|
||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||
@@ -213,6 +222,13 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
|
||||
<h3>Department Cards (face up)</h3><div id="depts" class="cardrow ref">—</div>
|
||||
</section>
|
||||
<section><h2 class="big">Your Track Supply</h2><div id="supply"></div></section>
|
||||
<section><h2 class="big">The Yards</h2>
|
||||
<h3>Division Yard <span class="dim" id="divtot"></span></h3>
|
||||
<div id="divyard" class="yard"></div>
|
||||
<h3>Classification Yard <span class="dim" id="clstot"></span></h3>
|
||||
<div id="clsyard" class="yard"></div>
|
||||
<div class="dim" id="yardnote"></div>
|
||||
</section>
|
||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Station Master — replays</title>
|
||||
<style>
|
||||
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
a{color:#5aa9e6}
|
||||
header{background:var(--panel);border-bottom:1px solid var(--line);padding:10px 16px;
|
||||
display:flex;gap:18px;align-items:baseline;flex-wrap:wrap;position:sticky;top:0;z-index:5}
|
||||
header b{font-size:16px}
|
||||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||||
.home:hover{color:#5aa9e6}
|
||||
.build{margin-left:auto;font-size:11px;opacity:.7}
|
||||
main{padding:18px;max-width:1300px;margin:0 auto}
|
||||
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);margin:0 0 8px}
|
||||
section{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin-bottom:14px}
|
||||
.dim{color:var(--dim)}
|
||||
.sm{font-size:11px}
|
||||
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
|
||||
padding:6px 11px;margin:2px 4px 2px 0;cursor:pointer;font:inherit;font-size:13px;text-align:left}
|
||||
button:hover{background:#39424e;border-color:#4d6fa8}
|
||||
button.entry{display:block;width:100%;margin-bottom:6px}
|
||||
#perr{color:#e58080;min-height:18px;font-size:12px}
|
||||
input[type=file]{font:inherit;font-size:12px;color:var(--dim)}
|
||||
/* viewer */
|
||||
#viewer{display:none}
|
||||
.vbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
|
||||
.vgrid{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:14px;align-items:start}
|
||||
@media(max-width:1100px){.vgrid{grid-template-columns:1fr}}
|
||||
.scroll{overflow-x:auto}
|
||||
#vlog{max-height:420px;overflow:auto;font-size:12px}
|
||||
.line{padding:1px 0}
|
||||
/* Auto-hide, matching the playable page and the standalone replay. Folded leaves a summary rather
|
||||
than nothing, because a panel that disappears reads as broken instead of folded away. */
|
||||
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||
button.ghost:hover{border-color:#4d6fa8;color:#fff}
|
||||
#vdistrict.folded .scroll{display:none}
|
||||
#vdistrict.folded #vdistrictsummary{display:block;padding:2px 0}
|
||||
#vdistrictsummary{display:none}
|
||||
.line.now{background:rgba(90,169,230,.10)}
|
||||
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
|
||||
select{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:5px;font:inherit;font-size:12px}
|
||||
input[type=range]{flex:1;min-width:180px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<b><a class="home" href="./index.html">Station Master</a></b>
|
||||
<span class="dim">replays</span>
|
||||
<span class="build">build __BUILD__</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="picker">
|
||||
<section>
|
||||
<h2>Saved on this site</h2>
|
||||
<div id="hosted"><p class="dim">loading…</p></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Open a save file</h2>
|
||||
<p class="dim">A Station Master save is a small <code>.json</code> file — a seed and the list of
|
||||
moves made. That is enough to rebuild the whole game, so a replay can be emailed like a text file.
|
||||
Save one from inside a game with <b>Save replay</b>.</p>
|
||||
<input type="file" id="file" accept=".json,application/json">
|
||||
<div id="perr"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="viewer">
|
||||
<section>
|
||||
<div class="vbar">
|
||||
<button id="vclose">← all replays</button>
|
||||
<b id="vtitle">—</b>
|
||||
<span class="dim" id="vnote"></span>
|
||||
</div>
|
||||
<div class="vbar">
|
||||
<button id="vfirst">⏮ start</button>
|
||||
<button id="vback">◀ step</button>
|
||||
<button id="vplay">▶ play</button>
|
||||
<button id="vfwd">step ▶</button>
|
||||
<button id="vstage">next Stage ⏭</button>
|
||||
<button id="vlast">end ⏭</button>
|
||||
<select id="vspeed">
|
||||
<option value="3000">extra slow</option>
|
||||
<option value="1200">slow</option>
|
||||
<option value="700" selected>normal</option>
|
||||
<option value="280">fast</option>
|
||||
<option value="90">very fast</option>
|
||||
</select>
|
||||
<button id="vsound">🔇 muted</button>
|
||||
<input type="range" id="vscrub" min="0" max="0" value="0">
|
||||
<span class="dim" id="vpos">0 / 0</span>
|
||||
</div>
|
||||
<div class="vbar">
|
||||
<span id="vclock">—</span>
|
||||
<span>phase: <b id="vphase">—</b></span>
|
||||
<span>Revenue <b id="vrev">0</b></span>
|
||||
<span class="dim">← → to step, space to play</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="vgrid">
|
||||
<div>
|
||||
<section><h2>The Division</h2><div class="scroll" id="vdivision"></div></section>
|
||||
<section id="vdistrict"><h2>Office Area <button id="vdistricttoggle" class="ghost">auto-hide: on</button></h2>
|
||||
<div id="vdistrictsummary" class="dim"></div>
|
||||
<div class="scroll" id="vgrid"></div></section>
|
||||
</div>
|
||||
<section><h2>What happened</h2><div id="vlog"></div></section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./web/replays.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+51
-2
@@ -12,6 +12,8 @@
|
||||
*/
|
||||
|
||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
||||
import { cuesFor } from '../sim/narrate.ts';
|
||||
import { playCue } from './sound.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import { narrate } from '../sim/narrate.ts';
|
||||
import { cardName, trainName } from '../sim/view.ts';
|
||||
@@ -35,7 +37,12 @@ const esc = (s: string): string =>
|
||||
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||
|
||||
/** Every position the save passes through, with the lines narrated along the way. */
|
||||
type Step = { frame: Frame; lines: { text: string; tone: string }[] };
|
||||
type Step = {
|
||||
frame: Frame;
|
||||
lines: { text: string; tone: string }[];
|
||||
/** What this step should sound like — from `cuesFor`, the same function the live game uses. */
|
||||
cues: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Rebuild a game from a save, keeping a snapshot at each step.
|
||||
@@ -54,7 +61,7 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
||||
const n = narrate(e, ctx);
|
||||
return { text: n.text, tone: n.tone };
|
||||
});
|
||||
steps.push({ frame: snapshot(s, [], null), lines });
|
||||
steps.push({ frame: snapshot(s, [], null), lines, cues: cuesFor(events) });
|
||||
};
|
||||
|
||||
push(pump(s));
|
||||
@@ -80,13 +87,28 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
||||
let steps: Step[] = [];
|
||||
let at = 0;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
/**
|
||||
* Sound and auto-hide, matching the playable page and the standalone replay.
|
||||
*
|
||||
* This viewer is a third place a game gets drawn, and it had drifted: three speeds where the
|
||||
* standalone had five, and neither of these. Anything a player learns on one screen should hold on
|
||||
* the others.
|
||||
*/
|
||||
let soundOn = false;
|
||||
let districtMode: 'auto' | 'open' | 'closed' = 'auto';
|
||||
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
|
||||
|
||||
function show(i: number): void {
|
||||
const from = at;
|
||||
at = Math.max(0, Math.min(steps.length - 1, i));
|
||||
const step = steps[at];
|
||||
if (!step) return;
|
||||
const f = step.frame;
|
||||
|
||||
// Only a single step FORWARD sounds. Scrubbing across a hundred steps would fire a hundred
|
||||
// whistles, and stepping back would sound a Stage ending that is being un-done.
|
||||
if (soundOn && at === from + 1) for (const c of step.cues) playCue(c);
|
||||
|
||||
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
|
||||
$('vphase').textContent = f.phase;
|
||||
$('vrev').textContent = String(f.revenue);
|
||||
@@ -95,6 +117,21 @@ function show(i: number): void {
|
||||
$('vdivision').innerHTML = divisionSvg(f.division);
|
||||
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
|
||||
|
||||
// Auto-hide: the district is worth its space during the phases that change it.
|
||||
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
||||
const sec = $('vdistrict');
|
||||
if (open) sec.classList.remove('folded');
|
||||
else sec.classList.add('folded');
|
||||
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
|
||||
$('vdistrictsummary').textContent =
|
||||
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing`;
|
||||
$('vdistricttoggle').textContent =
|
||||
districtMode === 'auto'
|
||||
? (open ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
|
||||
: districtMode === 'open'
|
||||
? 'always showing — click for auto-hide'
|
||||
: 'always hidden — click for auto-hide';
|
||||
|
||||
// A window of history rather than only this step, so a jump lands in context.
|
||||
let log = '';
|
||||
for (let k = Math.max(0, at - 18); k <= at; k++) {
|
||||
@@ -215,6 +252,18 @@ function wire(): void {
|
||||
show(at + 1);
|
||||
}, Number(($('vspeed') as HTMLSelectElement).value));
|
||||
};
|
||||
$('vdistricttoggle').onclick = () => {
|
||||
const f = steps[at]?.frame;
|
||||
const open = districtMode === 'auto' ? (f ? FOCUS_PHASES.has(f.phaseKey) : true) : districtMode === 'open';
|
||||
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
||||
show(at);
|
||||
};
|
||||
$('vsound').onclick = () => {
|
||||
soundOn = !soundOn;
|
||||
$('vsound').textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
|
||||
// Confirms the change, and gives the browser the gesture it needs before audio may start.
|
||||
if (soundOn) playCue('stage');
|
||||
};
|
||||
$('vclose').onclick = () => {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
|
||||
Reference in New Issue
Block a user