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