v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+98
-7
@@ -39,7 +39,7 @@ import type { GameEvent } from './events.ts';
|
||||
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';
|
||||
import { coordKey, freshTurn, subdivisions, totalRevenue } from './state.ts';
|
||||
|
||||
export type AdvanceResult = {
|
||||
events: GameEvent[];
|
||||
@@ -457,8 +457,19 @@ function spendDispatchBonus(
|
||||
}
|
||||
|
||||
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
|
||||
/**
|
||||
* Does this train leave in the same Stage it arrives (§7)?
|
||||
*
|
||||
* Two ways to earn it. `expedite` is printed and permanent. `stopThenExpedite` is the X17 Campaign
|
||||
* Train — "one turn at station (speeches) then expedite": its FIRST arrival is an ordinary stop while
|
||||
* the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first
|
||||
* stop, so the train is slow once and fast thereafter.
|
||||
*/
|
||||
function isExpedited(tray: CrewTray): boolean {
|
||||
return trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.expedite === true;
|
||||
const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules;
|
||||
if (!rules) return false;
|
||||
if (rules.expedite) return true;
|
||||
return rules.stopThenExpedite === true && tray.speechMade === true;
|
||||
}
|
||||
|
||||
function moveTrain(
|
||||
@@ -540,11 +551,22 @@ function moveTrain(
|
||||
from: 'the Office',
|
||||
to: 'the Mainline',
|
||||
});
|
||||
awardDeparture(s, owner, tray, events);
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
/**
|
||||
* CURRENTLY UNREACHABLE, and kept correct rather than deleted.
|
||||
*
|
||||
* `buildDivision` always lays `DP · Mainline · Office · Mainline · … · DP`, so an Office is never
|
||||
* adjacent to a Division Point and this branch cannot be entered at any player count. It is left
|
||||
* in — with the departure paid, so it would behave — because the layout is a setup decision that
|
||||
* could reasonably change, and a branch that silently failed to score would be hard to spot.
|
||||
* `setup.test.ts` asserts the flanking invariant that makes this dead.
|
||||
*/
|
||||
if (node.kind === 'divisionPoint') {
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
awardDeparture(s, owner, tray, events);
|
||||
retireTrain(s, id, tray, events);
|
||||
return 'moved';
|
||||
}
|
||||
@@ -668,11 +690,32 @@ function evaluateClearance(
|
||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === node.card);
|
||||
if (profile?.trainsMayPass) return 'clear';
|
||||
|
||||
for (const t of node.transits) {
|
||||
const other = t.tray;
|
||||
if (!other || other === id) continue;
|
||||
/**
|
||||
* §8.1 asks about the next SUBDIVISION, not the next card.
|
||||
*
|
||||
* "If there is a train in the next Subdivision moving towards the considered train, the considered
|
||||
* train will not depart" — and the same for a following train. This used to inspect only
|
||||
* `node.transits`, the one card being entered, so a train ran headlong into a Subdivision an
|
||||
* opposing train was two cards deep in and was stopped only on the Stage they met. `subdivisions()`
|
||||
* had existed for this the whole time and was called by nothing outside a test.
|
||||
*
|
||||
* It bites hardest early: every Office starts as a Whistle Post, so the entire railroad is ONE
|
||||
* Subdivision until someone upgrades, which is exactly why §8 describes early traffic as
|
||||
* constrained. Each Office upgrade to a Control Point splits one in two and buys capacity.
|
||||
*/
|
||||
const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex];
|
||||
const occupants: { tray: TrayId; onCard: number }[] = [];
|
||||
for (const i of subdivision) {
|
||||
const n = s.division.nodes[i];
|
||||
if (!n || n.kind !== 'mainline') continue;
|
||||
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
|
||||
}
|
||||
|
||||
for (const { tray: other, onCard } of occupants) {
|
||||
if (other === id) continue;
|
||||
const otherTray = s.trays.get(other);
|
||||
if (!otherTray) continue;
|
||||
const onNode = s.division.nodes[onCard];
|
||||
|
||||
if (otherTray.direction !== tray.direction) {
|
||||
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
|
||||
@@ -688,12 +731,16 @@ function evaluateClearance(
|
||||
// Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
|
||||
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
||||
// where ABS Signals protects everything on the card.
|
||||
if ((node.redFlagged ?? []).includes(other)) return 'blocked';
|
||||
//
|
||||
// Both of these now read the card the OTHER train is standing on rather than the card being
|
||||
// entered. They were the same card while this only looked one card ahead; across a Subdivision
|
||||
// they are not, and the protection belongs where the train it protects actually is.
|
||||
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
|
||||
|
||||
// ABS Signals — "trains on this card will not rear-end each other; they stop short of a
|
||||
// collision". With signals in place a following train simply holds, and the Superintendent has
|
||||
// no judgment call to make. This is the amendment to Gap 2's unconditional collisions.
|
||||
if (node.absSignals) return 'blocked';
|
||||
if (onNode?.kind === 'mainline' && onNode.absSignals) return 'blocked';
|
||||
|
||||
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
||||
@@ -786,6 +833,14 @@ function arriveAtOffice(
|
||||
// Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to
|
||||
// movedThisPhase and gets a second chance to move before the phase ends.
|
||||
if (isExpedited(tray)) return 'expedited';
|
||||
/**
|
||||
* X17 Campaign Train — the speeches happen at the first Office it reaches. Recorded AFTER the
|
||||
* expedite test above, so this arrival is the ordinary stop the card pays for and every later one
|
||||
* runs expedited.
|
||||
*/
|
||||
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopThenExpedite) {
|
||||
tray.speechMade = true;
|
||||
}
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
@@ -839,6 +894,42 @@ function collide(
|
||||
}
|
||||
|
||||
/** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */
|
||||
/**
|
||||
* ONE REVENUE FOR EVERY TRAIN THAT GETS OUT OF YOUR SECTION.
|
||||
*
|
||||
* Jesse's rule. Unlike freight and passengers it pays for traffic the player does not have to work —
|
||||
* which is the point: keeping the line clear is the Superintendent's job, and until now nothing paid
|
||||
* for doing it well. It rewards splitting a Subdivision with a Control Point, holding a following
|
||||
* train rather than gambling on it, and building the A/D capacity to turn arrivals around.
|
||||
*
|
||||
* Paid on DEPARTURE from the Office, once per train. Not paid again when the train later runs off
|
||||
* the end of the Division and retires: it left YOUR section when it cleared your Office, and the far
|
||||
* Division Point belongs to whoever is seated there.
|
||||
*
|
||||
* A crew with no train number is a local switching move and not a departure, so it earns nothing.
|
||||
*
|
||||
* PROVISIONAL, and worth watching: measured, this is roughly +5 Revenue a game against a mean of
|
||||
* 2.7, so it more than doubles the score on its own. Flagged in `TODO.md`.
|
||||
*/
|
||||
function awardDeparture(
|
||||
s: GameState,
|
||||
owner: PlayerIndex,
|
||||
tray: CrewTray,
|
||||
events: GameEvent[],
|
||||
): void {
|
||||
if (tray.trainNumber === null) return;
|
||||
const p = s.players[owner];
|
||||
if (!p) return;
|
||||
p.revenue += 1;
|
||||
events.push({
|
||||
type: 'revenueChanged',
|
||||
player: owner,
|
||||
delta: 1,
|
||||
total: p.revenue,
|
||||
reason: 'a train cleared your section',
|
||||
});
|
||||
}
|
||||
|
||||
function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): void {
|
||||
for (const car of tray.consist) {
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
||||
|
||||
+365
-32
@@ -30,7 +30,7 @@ import {
|
||||
officeProfile,
|
||||
trainProfile,
|
||||
} from './content.ts';
|
||||
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
|
||||
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import type { Intent, RejectionCode } from './intents.ts';
|
||||
import type {
|
||||
@@ -39,11 +39,14 @@ import type {
|
||||
Facility,
|
||||
GameState,
|
||||
GridCoord,
|
||||
Load,
|
||||
OfficeArea,
|
||||
PlayerIndex,
|
||||
RollingStock,
|
||||
TrackArc,
|
||||
TrackCard,
|
||||
TrayId,
|
||||
TurnoutOrientation,
|
||||
} from './state.ts';
|
||||
import { createRng } from './rng.ts';
|
||||
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
|
||||
@@ -138,7 +141,7 @@ export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolea
|
||||
if (!f) continue;
|
||||
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) return true;
|
||||
if (f.inboundBox.length > 0) return true;
|
||||
if (f.menAtWork.some((l) => l !== null)) return true;
|
||||
if (f.menAtWork?.some((l) => l !== null)) return true;
|
||||
if (f.outboundBox.length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -196,6 +199,9 @@ export function portersLeft(f: Facility): number {
|
||||
* `loadAdvanced` never fired in 200 games because the outbound pipeline had no entry point.
|
||||
*/
|
||||
export function canAdvanceLoad(f: Facility, box: number): boolean {
|
||||
// A Passenger Facility has no pipeline at all (§9.2 is porters, not MEN | AT | WORK), so freight
|
||||
// work is refused here on the shape of the facility rather than on it happening to have 0 Laborers.
|
||||
if (!f.menAtWork) return false;
|
||||
if (box < 0 || box >= f.menAtWork.length) return false;
|
||||
const load = f.menAtWork[box];
|
||||
if (!load) return false;
|
||||
@@ -215,7 +221,71 @@ export function canAdvanceLoad(f: Facility, box: number): boolean {
|
||||
}
|
||||
|
||||
/** §9.3 — the first Laborer step of an outbound load: Green Loading Slot onto MEN. */
|
||||
/**
|
||||
* A turnout's diverging leg, as an arc.
|
||||
*
|
||||
* The stem is always an east or west edge and the leg always leaves north or south, so the arc is
|
||||
* just the two named together — `{stem:'w', diverge:'s'}` is `sw`. Naming the leg this way is what
|
||||
* lets the upgrade rule below be stated in GEOMETRY rather than in hands, so it is unaffected by
|
||||
* which printed row we call left.
|
||||
*/
|
||||
function divergingArc(t: TurnoutOrientation): TrackArc {
|
||||
return `${t.diverge}${t.stem}` as TrackArc;
|
||||
}
|
||||
|
||||
/**
|
||||
* May this turnout be laid ON TOP of the card already on this square?
|
||||
*
|
||||
* Reported from playtesting: a district can only ever hang off a turnout, so a player who has laid
|
||||
* a straight along the main and then wants to branch there had no move at all — the piece had to
|
||||
* have been a turnout when it went down. A turnout may therefore UPGRADE:
|
||||
*
|
||||
* - a **straight**, at any of its orientations, because a turnout is a straight plus a leg; or
|
||||
* - a **curve of the same arc** as the turnout's own diverging leg, which is the same road with a
|
||||
* through track added beside it.
|
||||
*
|
||||
* Both are strict port SUPERSETS of what they replace — `{e,w}` for a straight, one arc for a curve
|
||||
* — so an upgrade can never sever a join a neighbour already relies on, and needs no connection test
|
||||
* of its own. The new leg is allowed to reach nothing at all; opening a direction is the point.
|
||||
*
|
||||
* Two things block it, and both are about the card being in use rather than about its shape: you
|
||||
* cannot swap the track out from under a standing car, and an Interlocking or Telegraph built on the
|
||||
* card would have to be lifted with it. The replaced card leaves play — board cards are never
|
||||
* salvaged (see the `cardPlayed` reducer), so a lifted one is simply gone, as it would be at a table.
|
||||
*/
|
||||
function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCode | null {
|
||||
const t = proto.geometry.kind === 'track' ? proto.geometry.turnout : undefined;
|
||||
if (!t) return 'NOT_UPGRADEABLE_TRACK';
|
||||
|
||||
const g = existing.geometry;
|
||||
if (g.kind !== 'track') return 'NOT_UPGRADEABLE_TRACK';
|
||||
if (g.geometry === 'curved' || g.geometry === 'sharpCurved') {
|
||||
// The ARC, not merely the diagonal: a `sw` curve and an `ne` one share a slope but leave by
|
||||
// opposite edges, so replacing one with the other would move the leg off its neighbour.
|
||||
if (g.arc !== divergingArc(t)) return 'NOT_UPGRADEABLE_TRACK';
|
||||
} else if (g.geometry !== 'straight') {
|
||||
return 'NOT_UPGRADEABLE_TRACK';
|
||||
}
|
||||
|
||||
if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED';
|
||||
if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MEN | AT | WORK pipeline of a Freight Facility.
|
||||
*
|
||||
* `execute` and `reduce` run only after `check` has passed, and every freight-work check refuses a
|
||||
* Passenger Facility — so reaching here with one is a broken invariant, not a case to handle. Throwing
|
||||
* says that, where a `!` would quietly write into nothing and leave the fault to surface later.
|
||||
*/
|
||||
function workTrack(f: Facility): [Load | null, Load | null, Load | null] {
|
||||
if (!f.menAtWork) throw new Error('freight work attempted on a Passenger Facility');
|
||||
return f.menAtWork;
|
||||
}
|
||||
|
||||
export function canStartLoad(f: Facility): boolean {
|
||||
if (!f.menAtWork) return false;
|
||||
if (laborersLeft(f) < 1) return false;
|
||||
if (f.outboundBox.length === 0) return false;
|
||||
return f.menAtWork[0] === null;
|
||||
@@ -226,7 +296,13 @@ export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): bool
|
||||
const f = facilityAt(s, player, at);
|
||||
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
||||
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false;
|
||||
return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && !c.loaded);
|
||||
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
|
||||
// train these passengers can board however many empty coaches it is carrying.
|
||||
return trainAtOfficeWith(
|
||||
s, player,
|
||||
(c) => c.type === 'coach' && !c.loaded,
|
||||
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,16 +322,127 @@ export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): bo
|
||||
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
||||
if (f.inboundBox.length >= f.capacity.inbound) return false;
|
||||
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
|
||||
return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && c.loaded);
|
||||
return trainAtOfficeWith(
|
||||
s, player,
|
||||
(c) => c.type === 'coach' && c.loaded,
|
||||
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
||||
);
|
||||
}
|
||||
|
||||
function trainAtOfficeWith(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
pred: (c: RollingStock) => boolean,
|
||||
trayOk: (t: CrewTray) => boolean = () => true,
|
||||
): boolean {
|
||||
const area = areaOf(s, player);
|
||||
return area.adOccupancy.some((id) => s.trays.get(id)?.consist.some(pred) ?? false);
|
||||
return area.adOccupancy.some((id) => {
|
||||
const t = s.trays.get(id);
|
||||
return !!t && trayOk(t) && t.consist.some(pred);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §7 — the operating rules printed on a train's own card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What this tray's train card prints, or nothing at all.
|
||||
*
|
||||
* A local crew has no train number and therefore no printed rules — it is the player's own switcher
|
||||
* and may do anything the general rules allow. Every restriction below is keyed off the CARD, so a
|
||||
* crew is unaffected by all of them.
|
||||
*/
|
||||
function rulesOf(tray: CrewTray): TrainRules {
|
||||
if (tray.trainNumber === null) return {};
|
||||
return trainProfile(tray.trainNumber, tray.trainIsExtra)?.rules ?? {};
|
||||
}
|
||||
|
||||
/** Freight cars this train has already exchanged on this square this turn (trains 3/4). */
|
||||
function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
|
||||
return `${trayId}@${coordKey(at)}`;
|
||||
}
|
||||
|
||||
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
||||
|
||||
/**
|
||||
* May this train work these freight cars on this square?
|
||||
*
|
||||
* Trains 3/4 Express print "may drop or pick up one freight car at EVERY location", so the budget is
|
||||
* per square rather than per turn — it may work a car here, move on, and work another there. Both
|
||||
* setting out and picking up spend from the same one, because the card says "drop OR pick up".
|
||||
*/
|
||||
function freightBudgetLeft(
|
||||
s: GameState,
|
||||
tray: CrewTray,
|
||||
at: GridCoord,
|
||||
wanted: number,
|
||||
): boolean {
|
||||
if (!rulesOf(tray).oneFreightPerLocation) return true;
|
||||
const already = s.turn.freightWorked[freightWorkedKey(tray.id, at)] ?? 0;
|
||||
return already + wanted <= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a train may do switching work at all (§7).
|
||||
*
|
||||
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
|
||||
* Military trains. They run the Division; they do not shunt. This covers moving, setting out and
|
||||
* sorting alike, because all three are switching.
|
||||
*/
|
||||
function switchingRefusal(tray: CrewTray): RejectionCode | null {
|
||||
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge freight cars against this train's per-location budget (trains 3/4).
|
||||
*
|
||||
* Called from BOTH the coupling and the setting-out reducers, because the card says "drop OR pick up
|
||||
* one" — the two share a budget rather than getting one each. Only recorded for trains the rule
|
||||
* applies to, so the map stays empty for everything else.
|
||||
*/
|
||||
function spendFreightBudget(
|
||||
s: GameState,
|
||||
tray: CrewTray,
|
||||
at: GridCoord,
|
||||
stock: readonly RollingStock[],
|
||||
): void {
|
||||
if (!rulesOf(tray).oneFreightPerLocation) return;
|
||||
const n = stock.filter(isFreight).length;
|
||||
if (n === 0) return;
|
||||
const key = freightWorkedKey(tray.id, at);
|
||||
s.turn.freightWorked[key] = (s.turn.freightWorked[key] ?? 0) + n;
|
||||
}
|
||||
|
||||
/** Trains that may not be worked by Porters at all (§7): the Military train and the Director's car. */
|
||||
function refusesPassengers(tray: CrewTray): boolean {
|
||||
return rulesOf(tray).noPassengerWork === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains 1/2 Crack Limited — "stop at Terminals only". It runs into every Office and takes an A/D
|
||||
* track like anything else, but Porters only work it where it is booked to stop, so passengers can
|
||||
* neither board nor alight anywhere but a Terminal.
|
||||
*/
|
||||
function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): boolean {
|
||||
if (!rulesOf(tray).terminalsOnly) return false;
|
||||
const area = s.officeAreas.get(player);
|
||||
return !area || area.tier !== 'terminal';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY passenger work was refused — the printed rule if one is to blame, otherwise the general one.
|
||||
*
|
||||
* `canBoard`/`canDetrain` answer a single yes/no over every train at the Office, so when they say no
|
||||
* this works out whether a card is the reason. Without it a Military train standing at the platform
|
||||
* reported "no train at the Office", which is both wrong and unhelpful.
|
||||
*/
|
||||
function passengerRefusal(s: GameState, player: PlayerIndex): RejectionCode {
|
||||
const area = areaOf(s, player);
|
||||
const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t);
|
||||
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
|
||||
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
|
||||
return 'NO_TRAIN_AT_OFFICE';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -291,12 +478,27 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
const from = trayCoord(s, i.trayId);
|
||||
if (!from) return 'ILLEGAL_MOVE';
|
||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||
return dests.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)
|
||||
? null
|
||||
: 'ILLEGAL_MOVE';
|
||||
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
||||
if (!dest) return 'ILLEGAL_MOVE';
|
||||
|
||||
/**
|
||||
* COUPLING IS MANDATORY (§A.4), so a train forbidden to pick something up may not make the
|
||||
* MOVE that would pick it up. There is no "move but leave them"; the restriction has to bite
|
||||
* on the move or it cannot bite at all.
|
||||
*/
|
||||
const rules = rulesOf(tray);
|
||||
if (dest.couples.length > 0) {
|
||||
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.pickUpEmptiesOnly && dest.couples.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
||||
const freight = dest.couples.filter(isFreight).length;
|
||||
if (freight > 0 && !freightBudgetLeft(s, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'switch.dropCars': {
|
||||
@@ -304,6 +506,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'CANNOT_DROP_HERE';
|
||||
@@ -319,6 +523,30 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const ahead = tray.engineAt;
|
||||
const behind = tray.consist.length - tray.engineAt;
|
||||
if (i.fromNose ? i.count > ahead : i.count > behind) return 'CONSIST_EMPTY';
|
||||
|
||||
// The cut that would come off, so the printed rules can be asked about its contents.
|
||||
const cut = i.fromNose
|
||||
? tray.consist.slice(0, i.count)
|
||||
: tray.consist.slice(tray.consist.length - i.count);
|
||||
const dropRules = rulesOf(tray);
|
||||
/**
|
||||
* Trains 7/8 Local — "coach must remain on station track if switching", i.e. the coach is
|
||||
* never set out during switching at all.
|
||||
*
|
||||
* The intended reading was "set out only at the Office", but §A.4 makes that unimplementable:
|
||||
* `canDropCarsAt` refuses the Office square outright — "the Office track is Operational Rail,
|
||||
* but Rolling Stock may not be left there" — so "only at the Office" and "nowhere" are the same
|
||||
* rule. What is left is the effect that matters: the Local may shunt its freight car around the
|
||||
* district, and may not abandon its coach at an industry or on a siding while it does.
|
||||
* Flagged in `TODO.md` in case the station track is meant to become a real place to leave one.
|
||||
*/
|
||||
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach')) {
|
||||
return 'COACH_MUST_STAY';
|
||||
}
|
||||
const droppedFreight = cut.filter(isFreight).length;
|
||||
if (droppedFreight > 0 && !freightBudgetLeft(s, tray, here, droppedFreight)) {
|
||||
return 'FREIGHT_WORKED_HERE';
|
||||
}
|
||||
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
|
||||
}
|
||||
|
||||
@@ -328,6 +556,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'ILLEGAL_MOVE';
|
||||
// Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard".
|
||||
@@ -493,7 +723,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
|
||||
const f = facilityAt(s, player, i.at);
|
||||
if (!f) return 'NO_SUCH_FACILITY';
|
||||
if (i.from === 'menAtWork') return f.menAtWork[i.index] ? null : 'BOX_EMPTY';
|
||||
if (i.from === 'menAtWork') return f.menAtWork?.[i.index] ? null : 'BOX_EMPTY';
|
||||
const box = i.from === 'outbound' ? f.outboundBox : f.inboundBox;
|
||||
return box[i.index] ? null : 'BOX_EMPTY';
|
||||
}
|
||||
@@ -544,13 +774,13 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
|
||||
if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
|
||||
if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
|
||||
return canBoard(s, player, i.at) ? null : 'NO_TRAIN_AT_OFFICE';
|
||||
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player);
|
||||
|
||||
case 'porter.detrain':
|
||||
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
|
||||
if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
|
||||
if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
|
||||
return canDetrain(s, player, i.at) ? null : 'NO_TRAIN_AT_OFFICE';
|
||||
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player);
|
||||
|
||||
case 'laborer.startLoad': {
|
||||
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
|
||||
@@ -584,7 +814,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
* taking it, so every unload minted one. Unloading is meant to consume supply.
|
||||
*/
|
||||
if (!s.yards.divisionYard.some((c) => c.type === car.type && !c.loaded)) return 'NO_SUITABLE_CAR';
|
||||
// The load is placed on WORK, the last box, so that box must be free.
|
||||
// The load is placed on WORK, the last box, so that box must be free — and a Passenger
|
||||
// Facility has no such box, so there is nothing to unload into.
|
||||
if (!f.menAtWork) return 'NO_SUCH_FACILITY';
|
||||
return f.menAtWork[f.menAtWork.length - 1] === null ? null : 'BOX_FULL';
|
||||
}
|
||||
|
||||
@@ -629,6 +861,21 @@ function checkPlay(
|
||||
{
|
||||
const proto = protoCard(card.kind, variant);
|
||||
if (!proto) return 'NO_PLACEMENT';
|
||||
|
||||
/**
|
||||
* AN OCCUPIED SQUARE IS AN UPGRADE, not a placement.
|
||||
*
|
||||
* A turnout may be laid on top of a card already down — see `checkTurnoutUpgrade`. The one
|
||||
* occupied square that is NOT an upgrade is a Limits sign on the Running Track: that is the
|
||||
* growth point, and `canPlaceAt` moves it outward rather than building over it.
|
||||
*/
|
||||
const existing = area.grid.get(coordKey(placement));
|
||||
const isMovableSign =
|
||||
existing?.geometry.kind === 'limits' &&
|
||||
placement.row === area.runningRow &&
|
||||
existing.standing.length === 0;
|
||||
if (existing && !isMovableSign) return checkTurnoutUpgrade(existing, proto);
|
||||
|
||||
// Said separately from NOT_CONNECTED because it is a different mistake: the card would join
|
||||
// perfectly well, and would still leave the Running Track stopping dead at it.
|
||||
if (placement.row === area.runningRow && !carriesThroughTrack(proto)) {
|
||||
@@ -662,6 +909,8 @@ function checkPlay(
|
||||
case 'modifier': {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
||||
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
|
||||
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||
// the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play.
|
||||
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
|
||||
@@ -1010,7 +1259,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const f = facilityAt(s, player, i.at)!;
|
||||
const stock: RollingStock =
|
||||
i.from === 'menAtWork'
|
||||
? { type: f.menAtWork[i.index]!.type, loaded: true }
|
||||
? { type: workTrack(f)[i.index]!.type, loaded: true }
|
||||
: (i.from === 'outbound' ? f.outboundBox : f.inboundBox)[i.index]!;
|
||||
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }];
|
||||
}
|
||||
@@ -1059,10 +1308,10 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
|
||||
case 'laborer.advanceLoad': {
|
||||
const f = facilityAt(s, player, i.at)!;
|
||||
const load = f.menAtWork[i.box]!;
|
||||
const load = workTrack(f)[i.box]!;
|
||||
const next = load.dir === 'out' ? i.box + 1 : i.box - 1;
|
||||
|
||||
if (next >= f.menAtWork.length) {
|
||||
if (next >= workTrack(f).length) {
|
||||
// Outbound complete: the load goes onto the spotted car (§9.3).
|
||||
return [
|
||||
{ type: 'loadCompleted', player, at: i.at, carType: load.type },
|
||||
@@ -1158,6 +1407,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
card.standing = [];
|
||||
if (card.facility) card.facility.industryTrack.cars = [];
|
||||
}
|
||||
spendFreightBudget(s, tray, e.at, e.stock);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1187,6 +1437,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const card = area.grid.get(coordKey(e.at));
|
||||
// On a Facility card the industry track is where cars stand (§9.3).
|
||||
if (card) carsOn(card).push(...e.stock);
|
||||
spendFreightBudget(s, tray, e.at, e.stock);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1353,8 +1604,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'facilityUnjammed': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
if (e.from === 'menAtWork') {
|
||||
const idx = f.menAtWork.findIndex((l) => l !== null);
|
||||
if (idx >= 0) f.menAtWork[idx] = null;
|
||||
const idx = workTrack(f).findIndex((l) => l !== null);
|
||||
if (idx >= 0) workTrack(f)[idx] = null;
|
||||
} else {
|
||||
const box = e.from === 'outbound' ? f.outboundBox : f.inboundBox;
|
||||
const idx = box.findIndex((c) => c.type === e.stock.type);
|
||||
@@ -1445,22 +1696,22 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const idx = f.outboundBox.findIndex((c) => c.type === e.carType);
|
||||
if (idx >= 0) f.outboundBox.splice(idx, 1);
|
||||
f.menAtWork[0] = { type: e.carType, dir: 'out' };
|
||||
workTrack(f)[0] = { type: e.carType, dir: 'out' };
|
||||
f.usedThisStage.laborers += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'loadAdvanced': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
f.menAtWork[e.toBox] = f.menAtWork[e.fromBox]!;
|
||||
f.menAtWork[e.fromBox] = null;
|
||||
workTrack(f)[e.toBox] = workTrack(f)[e.fromBox]!;
|
||||
workTrack(f)[e.fromBox] = null;
|
||||
f.usedThisStage.laborers += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unloadCompleted': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
f.menAtWork[0] = null;
|
||||
workTrack(f)[0] = null;
|
||||
f.inboundBox.push({ type: e.carType, loaded: true });
|
||||
f.usedThisStage.laborers += 1;
|
||||
break;
|
||||
@@ -1468,7 +1719,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
|
||||
case 'loadCompleted': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
f.menAtWork[f.menAtWork.length - 1] = null;
|
||||
workTrack(f)[workTrack(f).length - 1] = null;
|
||||
const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
|
||||
if (ci >= 0) {
|
||||
s.yards.classificationYard.push(f.industryTrack.cars[ci]!);
|
||||
@@ -1489,7 +1740,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
refillDivisionYardIfEmpty(s);
|
||||
f.industryTrack.cars[ci] = empty;
|
||||
}
|
||||
f.menAtWork[f.menAtWork.length - 1] = { type: e.carType, dir: 'in' };
|
||||
workTrack(f)[workTrack(f).length - 1] = { type: e.carType, dir: 'in' };
|
||||
f.usedThisStage.laborers += 1;
|
||||
break;
|
||||
}
|
||||
@@ -1697,14 +1948,29 @@ export function checkEnhancementPlacement(
|
||||
}
|
||||
|
||||
const onRunning = placement.row === area.runningRow;
|
||||
const isStraight =
|
||||
card.geometry.kind === 'track' && card.geometry.geometry === 'straight';
|
||||
/**
|
||||
* A STRAIGHT-PLACED ENHANCEMENT REPLACES THE STRAIGHT, so it cannot be stacked.
|
||||
*
|
||||
* The printed placement is "any Running Track Straight": the card goes down IN PLACE OF the
|
||||
* straight, and what stands there afterwards is an Interlocking, not a straight carrying one. A
|
||||
* second such card has no straight left to replace. This was unchecked — an enhancement only adds
|
||||
* a string to `enhancements[]` and leaves the geometry alone, so a single straight could take
|
||||
* Interlocking and Telegraph and a Water Column all at once.
|
||||
*
|
||||
* The `onCard` chain is untouched and is NOT an exception to this: Telephone prints "on Telegraph"
|
||||
* and Radio "on Telephone", so those target a named card rather than a straight, which is exactly
|
||||
* why they still stack. `requiresOnSameCard` above is what enforces it.
|
||||
*/
|
||||
const isBareStraight =
|
||||
card.geometry.kind === 'track' &&
|
||||
card.geometry.geometry === 'straight' &&
|
||||
card.enhancements.length === 0;
|
||||
|
||||
switch (rule.placement) {
|
||||
case 'runningTrackStraight':
|
||||
return onRunning && isStraight ? null : 'NOT_CONNECTED';
|
||||
return onRunning && isBareStraight ? null : 'NOT_CONNECTED';
|
||||
case 'secondaryTrackStraight':
|
||||
return !onRunning && isStraight ? null : 'NOT_CONNECTED';
|
||||
return !onRunning && isBareStraight ? null : 'NOT_CONNECTED';
|
||||
case 'onCard':
|
||||
return null;
|
||||
default:
|
||||
@@ -1778,18 +2044,85 @@ export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a Modifier of this kind already standing in the district?
|
||||
*
|
||||
* Reported from playtesting: two Ice Houses could be built in one Office Area. Industries have been
|
||||
* barred from doubling up since Q4 (`isLockedOut` above), but a Modifier is a different card kind
|
||||
* and had no such check — every square adjacent to an eligible host was legal, however many copies
|
||||
* you held. One of a kind per Office Area, the same rule the industries follow.
|
||||
*
|
||||
* Enhancements are deliberately NOT covered. An Interlocking is a plant at one junction, so a second
|
||||
* on another straight is a different installation, and the Telegraph → Telephone → Radio chain is
|
||||
* already gated per card by `checkEnhancementPlacement`.
|
||||
*
|
||||
* Consequence worth expecting: modifiers printed in more than one copy (Truck Dock 2, Ice House 2,
|
||||
* Waiting Area 3) go partly dead in solitaire, where there is only one Office Area. That is correct
|
||||
* — the spare copies exist for other players' districts.
|
||||
*/
|
||||
function hasModifierInArea(area: OfficeArea, kind: ModifierKind): boolean {
|
||||
for (const card of area.grid.values()) {
|
||||
if (card.geometry.kind === 'modifier' && card.geometry.modifier === kind) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a Modifier's printed capacity its host can actually use.
|
||||
*
|
||||
* AN INDUSTRY'S PRINTED FLOW IS ABSOLUTE. A Grocer's Warehouse is `flow: 'inbound'`, so it has no
|
||||
* green boxes and `freightAgent.stockOutbound` refuses it — yet an Ice House beside it prints "+1
|
||||
* outbound" and the capacity was being raised anyway, on a direction that can never be drawn or
|
||||
* stocked. Reported from playtesting as "the Ice House added the laborer but not the outbound slot":
|
||||
* the laborer landed because Laborers have no direction, and the slot did not because there was
|
||||
* nowhere for it to go. No modifier turns a receiver into a shipper, so the grant is dropped.
|
||||
*
|
||||
* It is not one card's quirk. The same trap catches **Truck Dock** and **Forklifts**, which also
|
||||
* print `addOut` and also list `grocersWarehouse` among their hosts, and **Waiting Area**,
|
||||
* **Restaurant** and **Hotel**, whose `hosts: ['office']` includes a Whistle Post — not a Passenger
|
||||
* Facility, so `allows.outbound` is false there too.
|
||||
*
|
||||
* The industry track grows by what was actually applied, not by what was printed: a slot that does
|
||||
* not exist must not lengthen the siding that would have served it.
|
||||
*/
|
||||
function usableGrant(f: Facility, m: ModifierProfile): { out: number; in: number } {
|
||||
return {
|
||||
out: f.allows.outbound ? m.addOut : 0,
|
||||
in: f.allows.inbound ? m.addIn : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */
|
||||
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
|
||||
const target = adjacentFacilityCoord(area, coord, modifier);
|
||||
if (!target) return;
|
||||
const f = area.grid.get(coordKey(target))?.facility;
|
||||
if (!f) return;
|
||||
const host = area.grid.get(coordKey(target));
|
||||
const f = host?.facility;
|
||||
if (!host || !f) return;
|
||||
const m = modifierProfile(modifier);
|
||||
f.capacity.outbound += m.addOut;
|
||||
f.capacity.inbound += m.addIn;
|
||||
/**
|
||||
* Record WHICH facility this Modifier served.
|
||||
*
|
||||
* `TrackCard.modifiers` was initialised everywhere and appended nowhere, so the panel row listing
|
||||
* "Modifier cards standing beside this industry" was permanently empty — and, more to the point,
|
||||
* nothing downstream could tell which card had granted what. It is needed now to explain a grant
|
||||
* that the host's flow discards (see `usableGrant`).
|
||||
*/
|
||||
host.modifiers.push(modifier);
|
||||
const use = usableGrant(f, m);
|
||||
f.capacity.outbound += use.out;
|
||||
f.capacity.inbound += use.in;
|
||||
f.laborers += m.addLoaders;
|
||||
f.porters += m.addPorters;
|
||||
if (m.addOut > 0 || m.addIn > 0) f.industryTrack.length += m.addOut + m.addIn;
|
||||
/**
|
||||
* The siding grows only where there IS one. A Passenger Facility has no industry track — passengers
|
||||
* board off the platform — so a Waiting Area, whose "+1" is a passenger slot rather than a car,
|
||||
* must not give the Office somewhere to spot a car. It did, and the board then drew the Depot a
|
||||
* siding square: the same phantom siding the renderer was just fixed for, arriving by another door.
|
||||
*/
|
||||
if (f.kind === 'freight' && (use.out > 0 || use.in > 0)) {
|
||||
f.industryTrack.length += use.out + use.in;
|
||||
}
|
||||
}
|
||||
|
||||
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
|
||||
|
||||
+101
-16
@@ -72,15 +72,34 @@ export type TrackProfile = {
|
||||
* hand-of-three is the real constraint on how fast a railroad grows.
|
||||
*
|
||||
* Handedness is PRINTED, not chosen on placement: it is the diagonal the 45° leg lies on.
|
||||
*
|
||||
* DO NOT REORDER THESE ROWS to put left before right. `setup.ts` builds the deck by walking this
|
||||
* array, so a row's POSITION decides which physical card a given seed deals — and the replays in
|
||||
* `public/replays/` are saved as a seed plus a list of intents, replayed through this deck. When
|
||||
* playtesting found the hands inverted (see `track.ts`, Orientation), the fix flipped each pair's
|
||||
* `hand` label in place and left the order alone, so slot 32 still holds an `nw_se` curve and every
|
||||
* published replay still plays. Reordering to look tidy would silently re-deal every saved game.
|
||||
*/
|
||||
export const TRACK_CARDS: readonly TrackProfile[] = [
|
||||
{ geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 32, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', copiesInDeck: 4, isOperationalRail: true, moveCost: 2 },
|
||||
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', copiesInDeck: 4, isOperationalRail: true, moveCost: 2 },
|
||||
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
|
||||
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
|
||||
/**
|
||||
* SHARP CURVES ARE DEALT ZERO COPIES — Jesse's call, and the same treatment as Poling.
|
||||
*
|
||||
* The only thing that made them different from an ordinary curve was `moveCost: 2`, and nothing
|
||||
* ever charged it: every switching move costs exactly 1, hard-coded. So the 8 cards in the deck
|
||||
* were geometric duplicates of the curves, taking 8 draws from a deck the rebalance already thinks
|
||||
* is too diluted. They come out rather than having the Move cost built, because a per-card movement
|
||||
* cost is a change to the Move model and the rebalance can wait.
|
||||
*
|
||||
* The rows stay in the catalogue at zero, exactly as Poling does, so the design is still visible
|
||||
* and the geometry still works if they are ever dealt again.
|
||||
*/
|
||||
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', copiesInDeck: 0, isOperationalRail: true, moveCost: 2 },
|
||||
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', copiesInDeck: 0, isOperationalRail: true, moveCost: 2 },
|
||||
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
|
||||
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
|
||||
];
|
||||
|
||||
/** 104 — the sheet's "Total track". */
|
||||
@@ -321,8 +340,14 @@ export type TrainRules = {
|
||||
/** X17 Campaign, X18 Circus: a scheduled stop that does something. */
|
||||
stopEarnsPoint?: boolean;
|
||||
stopThenExpedite?: boolean;
|
||||
/** The Second Section card copies the next scheduled train. */
|
||||
copiesNextScheduled?: boolean;
|
||||
/**
|
||||
* `copiesNextScheduled` was here and is DELETED. No train card ever carried it: a Second Section
|
||||
* is a Maneuver card played on a train that is due out, and it has its own intent
|
||||
* (`newTrain.secondSection`, `SECOND_SECTION` below) which has been implemented all along. The
|
||||
* flag was a second, unreachable way to describe a mechanic that already worked — it appeared in
|
||||
* the "nine rules read by nothing" count while being the one entry that needed removing rather
|
||||
* than building.
|
||||
*/
|
||||
note?: string;
|
||||
};
|
||||
|
||||
@@ -567,21 +592,72 @@ export type EnhancementRule = {
|
||||
requiresInDistrict?: EnhancementKey;
|
||||
/** Bonus added to an opposing train's number when resolving a meet, once a Day. */
|
||||
dispatchBonus?: number;
|
||||
/**
|
||||
* WHETHER THE PRINTED EFFECT ACTUALLY DOES ANYTHING, so the card can say so.
|
||||
*
|
||||
* - `live` — resolves in a solitaire game.
|
||||
* - `dormantSolo` — implemented and read at the point of attack, but the attack is an
|
||||
* opponent-directed card that a solitaire deck does not contain (Q6).
|
||||
* - `unbuilt` — nothing reads it at all. The effect is recorded here and not yet written.
|
||||
*
|
||||
* Seven of the ten are live. Each row below cites the file that reads it, because the first
|
||||
* attempt at this table got FIVE of the ten wrong: it was filled in by grepping for four helper
|
||||
* function names and reading "no match" as "no implementation", when Interlocking, Yard Office,
|
||||
* Small Yard and ABS Signals are all read directly by key — and all four are covered by tests in
|
||||
* `enhancements.test.ts` that were passing the whole time. The result was a tooltip telling players
|
||||
* that four working cards did nothing, which is worse than the bare label it replaced.
|
||||
*
|
||||
* KEEP THIS HONEST, AND CHECK THE CITATION. Implementing one of these means changing its value in
|
||||
* the same commit; otherwise the card goes on apologising for something it now does. It is data
|
||||
* rather than something derived because "is this key read anywhere" is not a question the type
|
||||
* system can answer — but a claim here without a file reference beside it is a claim nobody checked.
|
||||
*/
|
||||
effect: 'live' | 'dormantSolo' | 'unbuilt';
|
||||
};
|
||||
|
||||
export const ENHANCEMENT_RULES: readonly EnhancementRule[] = [
|
||||
{ key: 'interlocking', placement: 'runningTrackStraight' },
|
||||
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking' },
|
||||
{ key: 'yardOffice', placement: 'secondaryTrackStraight' },
|
||||
{ key: 'smallYard', placement: 'secondaryTrackStraight' },
|
||||
{ key: 'waterColumn', placement: 'runningTrackStraight' },
|
||||
{ key: 'overpass', placement: 'onCard' },
|
||||
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4 },
|
||||
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8 },
|
||||
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12 },
|
||||
{ key: 'absSignals', placement: 'mainlineCard' },
|
||||
// Holds an arrival at the Limits instead of colliding when the Office is full — advance.ts:770.
|
||||
{ key: 'interlocking', placement: 'runningTrackStraight', effect: 'live' },
|
||||
// Wired at apply.ts:1738, but it answers Derail, an Action card the solitaire deck omits (Q6).
|
||||
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking', effect: 'dormantSolo' },
|
||||
// Diverts a coachless arrival away from the Train Order Office — advance.ts:750.
|
||||
{ key: 'yardOffice', placement: 'secondaryTrackStraight', effect: 'live' },
|
||||
// Lets a consist be re-ordered for one Move — apply.ts:405.
|
||||
{ key: 'smallYard', placement: 'secondaryTrackStraight', effect: 'live' },
|
||||
// Wired at apply.ts:1743, but it removes a Watertower, a Space-use card the solo deck omits.
|
||||
{ key: 'waterColumn', placement: 'runningTrackStraight', effect: 'dormantSolo' },
|
||||
// The only one with NO code path at all: nothing anywhere reads `overpass`.
|
||||
{ key: 'overpass', placement: 'onCard', effect: 'unbuilt' },
|
||||
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4, effect: 'live' },
|
||||
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8, effect: 'live' },
|
||||
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12, effect: 'live' },
|
||||
// Stored on the Mainline node rather than in `enhancements[]` — apply.ts:1461, read at
|
||||
// advance.ts:599 (no rear-ending) and advance.ts:721 (the follower holds instead of being ruled on).
|
||||
{ key: 'absSignals', placement: 'mainlineCard', effect: 'live' },
|
||||
];
|
||||
|
||||
/**
|
||||
* What an Enhancement's card does, and whether it does it yet — one line, ready for a tooltip.
|
||||
*
|
||||
* Reported from playtesting: an Interlocking on the board is a bare label with no hover text at all.
|
||||
* Saying only the printed effect would be worse than silence for the four that are `unbuilt` — a
|
||||
* player who builds one to hold a train at the Limit watches it not happen with no way to tell a
|
||||
* misread card from a bug. Same discipline as `checkPlay`'s NOT_IMPLEMENTED: never let a card look
|
||||
* like it is doing something it is not.
|
||||
*/
|
||||
export function enhancementText(key: string): string | null {
|
||||
const card = ENHANCEMENT_CARDS.find((c) => c.key === key);
|
||||
if (!card) return null;
|
||||
const rule = enhancementRule(key);
|
||||
const note =
|
||||
rule?.effect === 'unbuilt'
|
||||
? ' — NOT YET IMPLEMENTED: this card has no effect in play.'
|
||||
: rule?.effect === 'dormantSolo'
|
||||
? ' — it answers an opponent-directed card, which a solitaire deck does not contain, so it never fires in this game.'
|
||||
: '';
|
||||
return `${card.name}: ${card.effect}${note}`;
|
||||
}
|
||||
|
||||
export function enhancementRule(key: string): EnhancementRule | null {
|
||||
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
|
||||
}
|
||||
@@ -677,6 +753,15 @@ export const REGIONS_PER_MAINLINE_CARD = 2; // provisional, pending §10 Q2
|
||||
export const STAGES_PER_DAY = 12;
|
||||
export const STAGES_PER_SHIFT = 3;
|
||||
export const HAND_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* The opening deal: 3 track cards and 3 others, from two separately shuffled piles (`setup.ts`).
|
||||
*
|
||||
* Six against a limit of three on purpose — the first turn is spent choosing which district you can
|
||||
* afford to build. PROVISIONAL, and flagged in `TODO.md` for review after play.
|
||||
*/
|
||||
export const OPENING_TRACK = 3;
|
||||
export const OPENING_OTHER = 3;
|
||||
export const MAX_CONSIST = 4;
|
||||
export const MOVES_PER_LOCAL_OPS = 6;
|
||||
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||
|
||||
@@ -136,6 +136,26 @@ export type RejectionCode =
|
||||
| 'ON_RUNNING_TRACK'
|
||||
/** A card with no east-west road would dead-end the Running Track it was laid in. */
|
||||
| 'BREAKS_RUNNING_TRACK'
|
||||
/**
|
||||
* A turnout may be laid on top of a straight, or of a curve on the same arc as its diverging leg
|
||||
* — but only those, and only while the square is idle. See `checkTurnoutUpgrade`.
|
||||
*/
|
||||
| 'NOT_UPGRADEABLE_TRACK'
|
||||
/** You cannot swap the track out from under a standing car. */
|
||||
| 'UPGRADE_OCCUPIED'
|
||||
/** An Interlocking or Telegraph is built on that card; the upgrade would have to lift it. */
|
||||
| 'UPGRADE_ENHANCED'
|
||||
/**
|
||||
* §7 — the operating rules printed on a train's own card. Each names the restriction it broke, so
|
||||
* the UI can say which card is refusing rather than "illegal move".
|
||||
*/
|
||||
| 'NO_SWITCHING'
|
||||
| 'NOT_A_TERMINAL'
|
||||
| 'COACH_MUST_STAY'
|
||||
| 'FREIGHT_WORKED_HERE'
|
||||
| 'NO_PASSENGER_WORK'
|
||||
| 'PICKUP_NOT_ALLOWED'
|
||||
| 'EMPTIES_ONLY'
|
||||
| 'NOT_IMPLEMENTED'
|
||||
| 'WRONG_INTENT'
|
||||
| 'NOT_A_GRADE'
|
||||
|
||||
+28
-2
@@ -156,7 +156,22 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
out.push({ type: 'card.play', cardId, node });
|
||||
}
|
||||
}
|
||||
const targets = onMainline ? [] : kind?.kind === 'enhancement' ? attachments : placements;
|
||||
/**
|
||||
* A TURNOUT MAY ALSO UPGRADE A CARD ALREADY DOWN, so it is offered the occupied cells as well as
|
||||
* the empty ones — `check` decides which of them it can actually be laid over. Without this the
|
||||
* upgrade rule exists in the engine and is never once presented, which is precisely how the 18
|
||||
* Enhancement cards came to be permanently dead.
|
||||
*
|
||||
* Deduplicated because the two lists overlap: `placements` already contains the Limits signs.
|
||||
*/
|
||||
const isTurnout = kind?.kind === 'track' && kind.geometry === 'turnout';
|
||||
const targets = onMainline
|
||||
? []
|
||||
: kind?.kind === 'enhancement'
|
||||
? attachments
|
||||
: isTurnout
|
||||
? dedupe([...placements, ...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;
|
||||
@@ -226,7 +241,8 @@ function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
|
||||
if (f) {
|
||||
out.push({ type: 'laborer.startLoad', at: coord });
|
||||
for (let box = 0; box < f.menAtWork.length; box++) {
|
||||
// A Passenger Facility has no MEN | AT | WORK boxes to advance a load along.
|
||||
for (let box = 0; box < (f.menAtWork?.length ?? 0); box++) {
|
||||
out.push({ type: 'laborer.advanceLoad', at: coord, box });
|
||||
}
|
||||
for (let ci = 0; ci < f.industryTrack.cars.length; ci++) {
|
||||
@@ -248,6 +264,16 @@ function facilityCoords(s: GameState, player: PlayerIndex): GridCoord[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function dedupe(coords: GridCoord[]): GridCoord[] {
|
||||
const seen = new Set<string>();
|
||||
return coords.filter((c) => {
|
||||
const k = `${c.row},${c.col}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Empty cells adjacent to occupied ones — `check` decides which actually connect. */
|
||||
function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
|
||||
const area = areaOf(s, player);
|
||||
|
||||
+47
-7
@@ -16,6 +16,8 @@ import {
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
MODIFIER_PROFILES,
|
||||
OFFICE_PROFILES,
|
||||
OPENING_OTHER,
|
||||
OPENING_TRACK,
|
||||
MAINLINE_PROFILES,
|
||||
mainlineProfile,
|
||||
TRACK_CARDS,
|
||||
@@ -173,7 +175,10 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
||||
outboundBox: [],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: p.passengerOut, inbound: p.passengerIn },
|
||||
menAtWork: [null, null, null],
|
||||
// No freight pipeline: a Depot, Station and Terminal work passengers only, and the MEN | AT |
|
||||
// WORK sign is printed "For Freight Facilities" (rules-v0.2.md:452). This was a three-slot array
|
||||
// of nulls, which read as "three empty boxes" to every renderer that looked.
|
||||
menAtWork: null,
|
||||
industryTrack: { length: 0, cars: [] },
|
||||
laborers: 0,
|
||||
porters: p.porters,
|
||||
@@ -244,21 +249,56 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
const superintendent = argmax(superRolls);
|
||||
void divisionRolls; // seating is fixed by array order; the roll is recorded for the event log
|
||||
|
||||
// §4.6-4.7 - shuffle, deal three each, then turn three face-up as the Department slots.
|
||||
/**
|
||||
* §4.6-4.7 — THE OPENING DEAL, dealt from two piles rather than one.
|
||||
*
|
||||
* Track is shuffled SEPARATELY from everything else and each player is dealt **3 track cards and 3
|
||||
* other cards**; the track left over is then shuffled back in and the game runs off one deck as
|
||||
* before. The player opens holding six against a limit of three, so the first turn is spent
|
||||
* choosing what to keep — draw as usual, then play or discard down to three (§6.2, enforced on
|
||||
* `draw.end`, which needs no special case for this).
|
||||
*
|
||||
* WHY. A run-around needs five specific pieces in a usable order, and the bot held a turnout and a
|
||||
* matching-hand curve together on **0.2% of turns** — about once every eight games. Measured five
|
||||
* ways, that is a SUPPLY problem and not a bot weakness: 91 run-arounds per 100 games when track
|
||||
* was a private 26-piece supply the player chose from, 29 per 100 once it was drawn, 4 per 60 with
|
||||
* track in the deck. Dealing the opening district as track restores something close to the
|
||||
* prototype's private supply without reintroducing an unbounded one, and makes the opening a
|
||||
* decision rather than a wait.
|
||||
*
|
||||
* PROVISIONAL — flagged for review once it has been played. See `TODO.md`.
|
||||
*/
|
||||
const deck = buildDeck(config.mode);
|
||||
const cards = new Map<CardId, Card>();
|
||||
for (const c of deck) cards.set(c.id, c);
|
||||
|
||||
const shuffled = rng.shuffle(deck.map((c) => c.id));
|
||||
const isTrack = (id: CardId): boolean => cards.get(id)?.kind.kind === 'track';
|
||||
const ids = deck.map((c) => c.id);
|
||||
// Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed.
|
||||
const trackPile = rng.shuffle(ids.filter(isTrack));
|
||||
const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id)));
|
||||
|
||||
const hands = new Map<PlayerIndex, CardId[]>();
|
||||
let cursor = 0;
|
||||
let trackCursor = 0;
|
||||
let otherCursor = 0;
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const p = (superintendent + i) % playerCount;
|
||||
hands.set(p, shuffled.slice(cursor, cursor + 3));
|
||||
cursor += 3;
|
||||
hands.set(p, [
|
||||
...trackPile.slice(trackCursor, trackCursor + OPENING_TRACK),
|
||||
...otherPile.slice(otherCursor, otherCursor + OPENING_OTHER),
|
||||
]);
|
||||
trackCursor += OPENING_TRACK;
|
||||
otherCursor += OPENING_OTHER;
|
||||
}
|
||||
|
||||
// The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal
|
||||
// device only, so track competes for the draw exactly as before from the first draw onward.
|
||||
const shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]);
|
||||
|
||||
// §4.6-4.7 — three cards turned face up beside the deck. Each is the bottom of a Department pile
|
||||
// that grows as players discard onto it.
|
||||
// that grows as players discard onto it. Taken after the recombination, so a Department slot can
|
||||
// show track like any other card.
|
||||
let cursor = 0;
|
||||
const departments: CardId[][] = [[], [], []];
|
||||
for (const pile of departments) {
|
||||
const card = shuffled[cursor++];
|
||||
|
||||
+32
-3
@@ -150,8 +150,17 @@ export type Facility = {
|
||||
outboundBox: RollingStock[];
|
||||
inboundBox: RollingStock[];
|
||||
capacity: { outbound: number; inbound: number };
|
||||
/** Freight only. One load per box; three boxes, so it is a pipeline (§9.1). */
|
||||
menAtWork: [Load | null, Load | null, Load | null];
|
||||
/**
|
||||
* FREIGHT ONLY — `null` on a Passenger Facility, which is what makes that unrepresentable rather
|
||||
* than merely unused.
|
||||
*
|
||||
* One load per box; three boxes, so it is a pipeline (§9.1). §9.2's passenger work is porters
|
||||
* boarding and detraining with no pipeline at all, and the sign is printed "For Freight
|
||||
* Facilities" (rules-v0.2.md:452). Every Office got a three-slot array anyway, and because it was
|
||||
* an array the renderers happily drew three boxes on a Depot that has no Laborer to work them —
|
||||
* reported from playtesting. Typing it away means a fourth renderer cannot reintroduce the bug.
|
||||
*/
|
||||
menAtWork: [Load | null, Load | null, Load | null] | null;
|
||||
/** Where cars are spotted for loading and unloading. */
|
||||
industryTrack: { length: number; cars: RollingStock[] };
|
||||
laborers: number;
|
||||
@@ -203,7 +212,8 @@ export function isOperationalRail(card: TrackCard): boolean {
|
||||
* `isOperationalRail` for exactly that reason.
|
||||
*/
|
||||
export function isLockedByWork(card: TrackCard): boolean {
|
||||
return !!card.facility && card.facility.menAtWork.some((slot) => slot !== null);
|
||||
// A Passenger Facility has no pipeline, so it is never locked by freight work.
|
||||
return !!card.facility?.menAtWork?.some((slot) => slot !== null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -263,6 +273,15 @@ export type CrewTray = {
|
||||
* runs once and is gone; there is no second visit to claim it on.
|
||||
*/
|
||||
stopPointClaimed?: boolean;
|
||||
/**
|
||||
* X17 Campaign Train — "one turn at station (speeches) then expedite".
|
||||
*
|
||||
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
|
||||
* then on the train runs expedited, departing every Office in the Stage it arrives. Recorded on the
|
||||
* tray for the same reason as `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs
|
||||
* once, so there is no later visit to hang it on.
|
||||
*/
|
||||
speechMade?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -450,6 +469,15 @@ export type TurnState = {
|
||||
movesRemaining: number;
|
||||
drawnThisTurn: boolean;
|
||||
freightAgentUsed: boolean;
|
||||
/**
|
||||
* Trains 3/4 Express — "may drop or pick up ONE freight car at every location".
|
||||
*
|
||||
* Keyed `trayId@row,col`, counting freight cars that train has exchanged on that square this turn.
|
||||
* Per LOCATION rather than per turn, so the Express can work its way along a district a car at a
|
||||
* time — which is what makes it an Express rather than a train that may move one car a Stage.
|
||||
* Cleared with the rest of the turn.
|
||||
*/
|
||||
freightWorked: Record<string, number>;
|
||||
/** Set when the actor finishes; the phase driver then moves to the next player. */
|
||||
done: boolean;
|
||||
};
|
||||
@@ -460,6 +488,7 @@ export function freshTurn(moves: number): TurnState {
|
||||
movesRemaining: moves,
|
||||
drawnThisTurn: false,
|
||||
freightAgentUsed: false,
|
||||
freightWorked: {},
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
+34
-14
@@ -123,7 +123,7 @@ export function connectionsFor(card: TrackCard): readonly PortPair[] {
|
||||
*/
|
||||
case 'curved':
|
||||
case 'sharpCurved': {
|
||||
const arc = card.geometry.arc ?? (card.geometry.hand === 'right' ? 'se' : 'sw');
|
||||
const arc = card.geometry.arc ?? (card.geometry.hand === 'right' ? 'sw' : 'se');
|
||||
return [[arc[0] as Port, arc[1] as Port]];
|
||||
}
|
||||
}
|
||||
@@ -131,8 +131,12 @@ export function connectionsFor(card: TrackCard): readonly PortPair[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* A turnout with no stated orientation takes this one — the left-hand card at 0°, i.e. the
|
||||
* A turnout with no stated orientation takes this one — the right-hand card at 0°, i.e. the
|
||||
* `ne_sw` slope. Only hand-less fixtures reach it; `variantsFor` always states an orientation.
|
||||
*
|
||||
* The ORIENTATION here is unchanged by the handedness fix; only the word for it moved, because
|
||||
* `ne_sw` is now the right hand. Fixtures that lay a bare turnout get the same physical card as
|
||||
* before, which is what keeps existing tests and saves describing the same board.
|
||||
*/
|
||||
export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'w', through: 'e', diverge: 's' };
|
||||
|
||||
@@ -197,9 +201,24 @@ export function joins(a: TrackCard, p: Port, b: TrackCard): boolean {
|
||||
* and south, but never between diagonals.
|
||||
*
|
||||
* So handedness is not decoration and not merely a supply label: it is the slope, and a siding
|
||||
* needs one card of each hand (a left turnout to drop off the main, a right curve to climb back).
|
||||
* Which printed row we call "left" is the one arbitrary bit, and it lives entirely in the two
|
||||
* tables below.
|
||||
* needs one card of each hand (a right turnout to drop off the main, a left curve to climb back).
|
||||
* Which printed row we call "left" is fixed by the prototype and lives entirely in the two tables
|
||||
* below.
|
||||
*
|
||||
* WHICH ROW IS WHICH. A turnout is named for the side the diverging route leaves toward, seen by a
|
||||
* train entering at the points. Take `{stem:'e', through:'w', diverge:'s'}`: the train enters at the
|
||||
* east edge heading west, and facing west its left hand points south — so a leg going south is a
|
||||
* LEFT-hand turnout. Turn the card 180° and it enters heading east with the leg going north, which
|
||||
* is left again; that invariance under rotation is exactly why the hand can be a property of the
|
||||
* card. Both orientations lie on `nw_se`, so LEFT IS `nw_se`.
|
||||
*
|
||||
* A curve has no points of its own, so it takes its hand from the turnout whose leg it continues —
|
||||
* which is what makes "a run-around needs one card of each hand" true. Both tables therefore put
|
||||
* left on the SAME diagonal: a left turnout's leg lands on `nw_se` and only a left curve can carry
|
||||
* it onward.
|
||||
*
|
||||
* Both tables were inverted until playtesting caught it, so every label named the mirror card. The
|
||||
* geometry was never wrong — the board draws from `connectionsFor` — only the words.
|
||||
*
|
||||
* §A.1's constraint is preserved: a turnout's two legs still never join each other.
|
||||
*/
|
||||
@@ -209,26 +228,27 @@ export type TrackVariant = {
|
||||
bypass?: Port;
|
||||
};
|
||||
|
||||
/** Left-hand cards lie on `ne_sw`, right-hand on `nw_se`; 0° sends the leg south, 180° north. */
|
||||
/** Left-hand curves lie on `nw_se`, right-hand on `ne_sw`; 0° sends the leg south, 180° north. */
|
||||
const CURVE_VARIANTS: Record<Hand, readonly TrackArc[]> = {
|
||||
left: ['sw', 'ne'],
|
||||
right: ['se', 'nw'],
|
||||
none: ['sw', 'ne'],
|
||||
left: ['se', 'nw'],
|
||||
right: ['sw', 'ne'],
|
||||
none: ['se', 'nw'],
|
||||
};
|
||||
|
||||
/** Left-hand turnouts lie on `nw_se`, right-hand on `ne_sw` — the same diagonals as the curves. */
|
||||
const TURNOUT_VARIANTS: Record<Hand, readonly TurnoutOrientation[]> = {
|
||||
left: [
|
||||
{ stem: 'w', through: 'e', diverge: 's' },
|
||||
{ stem: 'e', through: 'w', diverge: 'n' },
|
||||
],
|
||||
right: [
|
||||
{ stem: 'e', through: 'w', diverge: 's' },
|
||||
{ stem: 'w', through: 'e', diverge: 'n' },
|
||||
],
|
||||
none: [
|
||||
right: [
|
||||
{ stem: 'w', through: 'e', diverge: 's' },
|
||||
{ stem: 'e', through: 'w', diverge: 'n' },
|
||||
],
|
||||
none: [
|
||||
{ stem: 'e', through: 'w', diverge: 's' },
|
||||
{ stem: 'w', through: 'e', diverge: 'n' },
|
||||
],
|
||||
};
|
||||
|
||||
export function variantsFor(geometry: TrackGeometry, hand: Hand = 'none'): TrackVariant[] {
|
||||
|
||||
+46
-14
@@ -525,6 +525,13 @@ export function officeSvg(
|
||||
|
||||
let bx = 7;
|
||||
const by = H - 42;
|
||||
/**
|
||||
* The MEN | AT | WORK sign is a FREIGHT fitting, so a Passenger Facility has none — `maw` comes
|
||||
* back empty for one. The arrows below point into and out of that sign, so they go with it:
|
||||
* without the sign they were chevrons aimed at nothing. A Depot keeps its green and red boxes,
|
||||
* because passengers really do wait in one and arrive into the other.
|
||||
*/
|
||||
const hasSign = f.maw.length > 0;
|
||||
// Green feeds INTO the sign, red comes OUT of it, so both sit on this side and their arrows
|
||||
// point opposite ways. An industry that only receives has no green boxes at all.
|
||||
if (f.allowsOut) {
|
||||
@@ -532,16 +539,20 @@ export function officeSvg(
|
||||
out += box(bx, by, i < f.green.length, 'bs-green');
|
||||
bx += cellW + 2;
|
||||
}
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
||||
bx += 9;
|
||||
if (hasSign) {
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
||||
bx += 9;
|
||||
}
|
||||
}
|
||||
if (f.allowsIn) {
|
||||
for (let i = 0; i < Math.max(1, f.redCap); i++) {
|
||||
out += box(bx, by, i < f.red.length, 'bs-red');
|
||||
bx += cellW + 2;
|
||||
}
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">‹</text>`;
|
||||
bx += 9;
|
||||
if (hasSign) {
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">‹</text>`;
|
||||
bx += 9;
|
||||
}
|
||||
}
|
||||
// MEN | AT | WORK — one physical sign, worked left to right loading and right to left
|
||||
// unloading. The spotted car sits past WORK, on the siding drawn below.
|
||||
@@ -549,7 +560,9 @@ export function officeSvg(
|
||||
out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
|
||||
bx += cellW + 2;
|
||||
}
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
|
||||
if (hasSign) {
|
||||
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.
|
||||
@@ -559,14 +572,21 @@ export function officeSvg(
|
||||
* 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';
|
||||
// A Passenger Facility is not an industry and must not borrow an industry's word for it: a
|
||||
// Depot read "SHIPS + RECEIVES", which says freight. Passengers board and alight instead.
|
||||
const passenger = f.kind === 'passenger';
|
||||
const flow = passenger
|
||||
? 'BOARDS + ALIGHTS'
|
||||
: f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
|
||||
const flowCls = passenger || (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.',
|
||||
passenger
|
||||
? 'Passengers only — no freight is handled here. They wait in a green box, are boarded onto a coach by Porters, and arrive into a red one. There is no MEN | AT | WORK sign because that is a Freight Facility fitting.'
|
||||
: 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>`;
|
||||
|
||||
/**
|
||||
@@ -596,7 +616,15 @@ export function officeSvg(
|
||||
}
|
||||
|
||||
// Standing room, drawn as the printed squares: filled means occupied.
|
||||
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
|
||||
//
|
||||
// `Math.max(1, …)` applies only to an industry, whose track is at least one car long. A Passenger
|
||||
// Facility has NO industry track — passengers board straight off the platform — so a Depot was
|
||||
// being drawn a siding slot it cannot spot a car on.
|
||||
const spots = cell.facility
|
||||
? cell.facility.kind === 'passenger'
|
||||
? cell.facility.trackCap
|
||||
: 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
|
||||
@@ -652,7 +680,11 @@ export function officeSvg(
|
||||
// ABOVE the rail. An `onCard` enhancement can land on a facility, so it has to clear both the
|
||||
// load pipeline below and the through track — and with the rail now down the middle of the
|
||||
// card, the old y = H-46 baseline printed the label straight along the rail itself.
|
||||
out += `<text class="bs-enh" x="6" y="26">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
// Hover text per enhancement, from the card catalogue — an Interlocking used to be a bare word
|
||||
// on the card with nothing to say what it did, or that it does not do it yet.
|
||||
out += `<text class="bs-enh" x="6" y="26" data-tip="${esc(
|
||||
(cell.enhancementsWhat ?? []).join(' · '),
|
||||
)}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
}
|
||||
if (cell.train) {
|
||||
/**
|
||||
|
||||
+4
-3
@@ -502,7 +502,7 @@ function canStockProductively(s: GameState, player: PlayerIndex): boolean {
|
||||
function hasStuckLoad(s: GameState, player: PlayerIndex): boolean {
|
||||
for (const card of areaOf(s, player).grid.values()) {
|
||||
const f = card.facility;
|
||||
if (!f || laborersLeft(f) < 1) continue;
|
||||
if (!f?.menAtWork || laborersLeft(f) < 1) continue;
|
||||
for (let box = 0; box < f.menAtWork.length; box++) {
|
||||
if (f.menAtWork[box] && !canAdvanceLoad(f, box)) return true;
|
||||
}
|
||||
@@ -834,12 +834,13 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
};
|
||||
|
||||
/**
|
||||
* A curve of the RIGHT HAND to hang under this turnout, somewhere the player can still reach.
|
||||
* A curve of the MATCHING hand to hang under this turnout, somewhere the player can still reach.
|
||||
*
|
||||
* A turnout is a hole in the Running Track and is worth cutting only for what hangs off it, so
|
||||
* cutting one with nothing to hang there is pure loss. Handedness is the slope, so a left-hand
|
||||
* turnout's leg can only be continued by a left-hand curve and a hand full of right-hand ones is
|
||||
* no help to it.
|
||||
* no help to it. That is a match on hands, not on a particular hand, so it was unaffected when
|
||||
* the two were found to be inverted and swapped.
|
||||
*
|
||||
* Track is drawn from the deck now rather than taken from a private supply, so "have I got one?"
|
||||
* means the HAND — with 20 of each hand among 104 track cards in a 243-card deck, the next one is
|
||||
|
||||
+2
-2
@@ -460,8 +460,8 @@ export function impediments(s: GameState, player = 0): Impediment[] {
|
||||
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
|
||||
// it also strips the industry track of Operational Rail status (§9.3), so no car can be
|
||||
// brought in to rescue it.
|
||||
for (let box = 0; box < f.menAtWork.length; box++) {
|
||||
const load = f.menAtWork[box];
|
||||
for (let box = 0; box < (f.menAtWork?.length ?? 0); box++) {
|
||||
const load = f.menAtWork![box];
|
||||
if (!load || canAdvanceLoad(f, box)) continue;
|
||||
const reason =
|
||||
laborersLeft(f) < 1
|
||||
|
||||
+18
-6
@@ -204,7 +204,7 @@ export function compress(frames: Frame[]): Packed {
|
||||
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
||||
// `train` rides whole rather than being interned: it changes almost every frame, so a table
|
||||
// of them would be as long as the frames are and buy nothing.
|
||||
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad];
|
||||
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad, c.enhancementsWhat];
|
||||
});
|
||||
return { ...f, cells } as unknown as Frame;
|
||||
});
|
||||
@@ -238,7 +238,7 @@ export function rehydrateCells(
|
||||
facs: unknown[],
|
||||
): unknown[] {
|
||||
return packed.map((row) => {
|
||||
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown];
|
||||
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown, string[]];
|
||||
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
||||
return {
|
||||
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
||||
@@ -246,6 +246,9 @@ export function rehydrateCells(
|
||||
facility: p[5] < 0 ? null : facs[p[5]],
|
||||
train: p[6] ?? null,
|
||||
ad: p[7] ?? null,
|
||||
// Carried rather than recomputed: this function is emitted into the page by toString() and so
|
||||
// cannot reach the card catalogue that produced the text.
|
||||
enhancementsWhat: p[8] ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -293,7 +296,7 @@ margin-top:5px;font-size:11px;line-height:1.35}
|
||||
.fac{border:1px solid var(--line);border-radius:5px;padding:8px;margin-bottom:8px;background:#20252c}
|
||||
.boxes{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;align-items:center}
|
||||
.box{border-radius:3px;padding:3px 7px;font-size:11px;border:1px solid var(--line)}
|
||||
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}
|
||||
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}.box.s{background:#3a4450}
|
||||
.box.empty{background:transparent;color:var(--dim)}
|
||||
.log{max-height:230px;overflow:auto}
|
||||
/* Auto-hide, the same as the playable page. Folded leaves a summary rather than nothing, because a
|
||||
@@ -519,10 +522,19 @@ function render() {
|
||||
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
|
||||
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
|
||||
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
|
||||
'<div class="boxes"><span class="dim">MEN|AT|WORK</span>' +
|
||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>' +
|
||||
// No sign and no siding on a Passenger Facility: maw is empty for one, but the LABEL was
|
||||
// printed before the loop, so a Depot showed the caption over nothing. The mini card above
|
||||
// gates on trackCap and was already right; this copy was not.
|
||||
// (No backticks in here — this whole block is inside a template literal.)
|
||||
(x.maw.length > 0
|
||||
? '<div class="boxes"><span class="dim">MEN|AT|WORK</span>' +
|
||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>'
|
||||
: '') +
|
||||
'<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
|
||||
'<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 'g') + '</div>' +
|
||||
// 's', not 'g': the siding is a place, not a direction, and green is reserved for outbound.
|
||||
(x.trackCap > 0 || x.track.length > 0
|
||||
? '<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 's') + '</div>'
|
||||
: '') +
|
||||
'<div class="fstat ' + (x.jammed ? 'bad' : (x.canFinish ? 'good' : 'idle')) + '">' +
|
||||
(x.jammed
|
||||
? 'JAMMED — a load is on WORK with no car spotted to receive it; the industry track is locked'
|
||||
|
||||
+141
-24
@@ -24,10 +24,13 @@ import {
|
||||
MAINLINE_MODIFIER_CARDS,
|
||||
MAINLINE_PROFILES,
|
||||
MANEUVER_CARDS,
|
||||
MODIFIER_PROFILES,
|
||||
REALIGNMENTS,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
OFFICE_ORDER,
|
||||
SPACE_USE_CARDS,
|
||||
enhancementRule,
|
||||
enhancementText,
|
||||
industryProfile,
|
||||
mainlineProfile,
|
||||
modifierProfile,
|
||||
@@ -55,6 +58,13 @@ export type CellView = {
|
||||
* the district works left no trace you could see.
|
||||
*/
|
||||
enhancements: string[];
|
||||
/**
|
||||
* What each of those enhancements does, in the same order — and whether it does it yet.
|
||||
*
|
||||
* Carried on the view model because `board-svg.ts` imports nothing (the replay embeds it via
|
||||
* `toString()`), so it cannot reach the card catalogue itself.
|
||||
*/
|
||||
enhancementsWhat: string[];
|
||||
tray: string | null;
|
||||
/**
|
||||
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
|
||||
@@ -108,6 +118,14 @@ export type CellView = {
|
||||
};
|
||||
|
||||
export type FacilityView = {
|
||||
/**
|
||||
* Freight or passengers — the one thing the renderers could not previously ask.
|
||||
*
|
||||
* Without it they guessed from side effects (`trackCap > 0`, `laborers` starting "0/0"), and one
|
||||
* of the three simply did not guess at all, so a Depot drew three MEN | AT | WORK boxes it has no
|
||||
* Laborer to work. Reported from playtesting.
|
||||
*/
|
||||
kind: 'freight' | 'passenger';
|
||||
name: string;
|
||||
commodity: string;
|
||||
flow: string;
|
||||
@@ -129,6 +147,15 @@ export type FacilityView = {
|
||||
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
|
||||
*/
|
||||
base: { out: number; in: number; laborers: number; porters: number };
|
||||
/**
|
||||
* Printed grants this host's flow throws away, phrased for a tooltip.
|
||||
*
|
||||
* An industry's flow is absolute, so an Ice House beside a Grocer's Warehouse gives its Laborer and
|
||||
* nothing else — the "+1 out" has no direction to go in. Left unsaid that reads as a bug: reported
|
||||
* from playtesting as "the Ice House added the laborer but not the outbound slot". Naming it turns
|
||||
* a number that failed to move into a rule the player can see.
|
||||
*/
|
||||
suppressed: string[];
|
||||
/**
|
||||
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
|
||||
*
|
||||
@@ -373,6 +400,7 @@ function facilityView(
|
||||
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
|
||||
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
|
||||
return {
|
||||
kind: f.kind,
|
||||
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? prettyKey(key)),
|
||||
commodity: facilityCarType(f) ?? '?',
|
||||
flow: f.kind === 'passenger'
|
||||
@@ -380,7 +408,9 @@ function facilityView(
|
||||
: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
|
||||
green: f.outboundBox.map(carLabel),
|
||||
greenCap: f.capacity.outbound,
|
||||
maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
|
||||
// Empty on a Passenger Facility, so a renderer that loops it draws nothing without needing a
|
||||
// guard of its own — which is the whole point of the field being nullable in the engine.
|
||||
maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
|
||||
red: f.inboundBox.map(carLabel),
|
||||
redCap: f.capacity.inbound,
|
||||
track: f.industryTrack.cars.map(carLabel),
|
||||
@@ -388,14 +418,37 @@ function facilityView(
|
||||
laborers: `${laborersLeft(f)}/${f.laborers}`,
|
||||
porters: `${portersLeft(f)}/${f.porters}`,
|
||||
canFinish: canFinishHere(f),
|
||||
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
|
||||
jammed: !!f.menAtWork?.some((l) => l !== null) && !canFinishHere(f),
|
||||
allowsOut: f.allows.outbound,
|
||||
allowsIn: f.allows.inbound,
|
||||
base: baseOf(card, officeName),
|
||||
suppressed: suppressedGrants(card.modifiers ?? [], f),
|
||||
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The half of each Modifier beside this facility that its printed flow discards.
|
||||
*
|
||||
* Mirrors `usableGrant` in the engine — the engine decides, this only reports. A Grocer's Warehouse
|
||||
* is `flow: 'inbound'`, so an Ice House's "+1 out" lands nowhere and the panel would otherwise show
|
||||
* a Modifier that visibly did half of what its card says.
|
||||
*/
|
||||
function suppressedGrants(modifiers: string[], f: Facility): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of modifiers) {
|
||||
const m = MODIFIER_PROFILES.find((p) => p.kind === key);
|
||||
if (!m) continue;
|
||||
if (m.addOut > 0 && !f.allows.outbound) {
|
||||
out.push(`${m.name}: +${m.addOut} outbound has no effect here — this facility only receives`);
|
||||
}
|
||||
if (m.addIn > 0 && !f.allows.inbound) {
|
||||
out.push(`${m.name}: +${m.addIn} inbound has no effect here — this facility only ships`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the card itself prints, before any Modifier beside it.
|
||||
*
|
||||
@@ -446,7 +499,7 @@ function trainOnCard(s: GameState, key: string): CellView['train'] {
|
||||
|
||||
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
|
||||
function canFinishHere(f: Facility): boolean {
|
||||
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
|
||||
const pending = f.menAtWork?.find((l) => l !== null) ?? f.outboundBox[0];
|
||||
if (!pending) return false;
|
||||
return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type);
|
||||
}
|
||||
@@ -517,7 +570,21 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
i.placement && kind?.kind === 'track'
|
||||
? variantLabel(kind.geometry, i.variant, kind.hand)
|
||||
: '';
|
||||
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`;
|
||||
/**
|
||||
* "Play a turnout at (0,1)" and "upgrade the straight at (0,1) to a turnout" are different
|
||||
* moves — the second lifts a card already down — and calling both "play" hid the fact that the
|
||||
* square was not empty. The Limits sign is excluded: laying track there is ordinary growth.
|
||||
*/
|
||||
const actor = s.clock.currentActor;
|
||||
const over =
|
||||
i.placement && actor !== null
|
||||
? s.officeAreas.get(actor)?.grid.get(`${i.placement.row},${i.placement.col}`)
|
||||
: undefined;
|
||||
const upgrade = over?.geometry.kind === 'track';
|
||||
return (
|
||||
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
|
||||
`${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`
|
||||
);
|
||||
}
|
||||
case 'card.discard': {
|
||||
/**
|
||||
@@ -745,6 +812,7 @@ export function snapshot(
|
||||
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
|
||||
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||
enhancements: card.enhancements.map(prettyKey),
|
||||
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
||||
tray: trayAt.get(key) ?? null,
|
||||
train: trainOnCard(s, key),
|
||||
ad:
|
||||
@@ -895,8 +963,17 @@ export function snapshot(
|
||||
division,
|
||||
cells,
|
||||
facilities,
|
||||
hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
|
||||
handWhat: (s.decks.hands.get(0) ?? []).map((id) => cardDescription(s, id)),
|
||||
/**
|
||||
* NEWEST FIRST, matching the play page (`actionMenu`).
|
||||
*
|
||||
* The engine pushes a drawn card onto the END of the hand, which put the card just turned over
|
||||
* at the far end of a wrapping row. Reversed here rather than in the engine so the bot's hand
|
||||
* iteration — and every revenue measurement taken with it — is left alone.
|
||||
*
|
||||
* Both lines must reverse together or the descriptions come apart from the names.
|
||||
*/
|
||||
hand: [...(s.decks.hands.get(0) ?? [])].reverse().map((id) => cardName(s, id)),
|
||||
handWhat: [...(s.decks.hands.get(0) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((pile) => {
|
||||
const top = pile[pile.length - 1];
|
||||
@@ -1028,7 +1105,24 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
if (m.addIn) adds.push(`+${m.addIn} in`);
|
||||
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
|
||||
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
|
||||
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}`;
|
||||
/**
|
||||
* WARN BEFORE IT IS PLAYED, not only after.
|
||||
*
|
||||
* An industry's printed flow is absolute, so a Modifier granting capacity in the other
|
||||
* direction gives that host nothing — an Ice House lists both Packing Sheds and a Grocer's
|
||||
* Warehouse, and only the Packing Sheds can use its "+1 out". Which host you choose is the
|
||||
* whole decision, so it has to be answerable while the card is still in hand. Computed from
|
||||
* the two catalogues, so no facility need be on the board yet.
|
||||
*/
|
||||
const caveats = m.hosts
|
||||
.filter((h) => h !== 'office')
|
||||
.map((h) => ({ host: h, flow: industryProfile(h as never).flow }))
|
||||
.filter(({ flow }) => (m.addOut > 0 && flow === 'inbound') || (m.addIn > 0 && flow === 'outbound'))
|
||||
.map(({ host, flow }) => `${facilityLabel(host)} only ${flow === 'inbound' ? 'receives' : 'ships'}`);
|
||||
const warn = caveats.length
|
||||
? ` · ${caveats.join(' and ')}, so the ${m.addOut > 0 ? 'outbound' : 'inbound'} slot does nothing there`
|
||||
: '';
|
||||
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}${warn}`;
|
||||
}
|
||||
case 'track': {
|
||||
// Track is the largest category in the deck, so a player holds it constantly — and what it
|
||||
@@ -1038,7 +1132,7 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
if (k.geometry === 'straight') {
|
||||
return `east-west through track · lay it anywhere the rail continues${cost}`;
|
||||
}
|
||||
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
|
||||
const diagonal = k.hand === 'right' ? 'north–east / south–west' : 'north–west / south–east';
|
||||
const ways = variantsFor(k.geometry, k.hand)
|
||||
.map((v) => (v.arc ? curvePhrase(v.arc) : v.turnout ? turnoutPhrase(v.turnout) : ''))
|
||||
.join(', or turned about, ');
|
||||
@@ -1060,7 +1154,20 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
default: {
|
||||
// The recovered categories carry their own prose — effect plus where it may be played.
|
||||
const card = SIMPLE_CARDS.find((c) => c.key === (k as { key: string }).key);
|
||||
return card ? `${card.effect} · played on ${card.placement}` : '';
|
||||
if (!card) return '';
|
||||
/**
|
||||
* An Enhancement also says whether its effect is wired up. Four of the ten are read by nothing
|
||||
* at all, and a card that describes a power it does not have is worse than one that says
|
||||
* nothing — the player cannot tell a misread from a bug.
|
||||
*/
|
||||
const rule = k.kind === 'enhancement' ? enhancementRule(card.key) : null;
|
||||
const note =
|
||||
rule?.effect === 'unbuilt'
|
||||
? ' · NOT YET IMPLEMENTED — no effect in play'
|
||||
: rule?.effect === 'dormantSolo'
|
||||
? ' · never fires in solitaire — it answers an opponent card the solo deck omits'
|
||||
: '';
|
||||
return `${card.effect} · played on ${card.placement}${note}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1091,18 +1198,28 @@ export function trainRules(t: {
|
||||
|
||||
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('; ')}`);
|
||||
/**
|
||||
* §7's operating rules, ALL of which the engine now enforces.
|
||||
*
|
||||
* These used to be listed under "NOT YET ENFORCED BY THE ENGINE", which was honest at the time and
|
||||
* is not any more — every one below is checked in `apply.ts` or `advance.ts`. Saying what a rule
|
||||
* DOES rather than that it exists, because the restriction is the whole character of the card: a
|
||||
* Military train that cannot be worked by Porters plays nothing like a Local.
|
||||
*/
|
||||
if (p.rules.noSwitching) parts.push('NO SWITCHING — it runs the Division and does not shunt');
|
||||
if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else');
|
||||
if (p.rules.coachStaysOnStationTrack) {
|
||||
parts.push('THE COACH STAYS AT THE STATION — a cut carrying it may only be set out at the Office');
|
||||
}
|
||||
if (p.rules.oneFreightPerLocation) {
|
||||
parts.push('ONE FREIGHT CAR PER LOCATION — dropped or picked up, one each square per turn');
|
||||
}
|
||||
if (p.rules.noPassengerWork) parts.push('NO PASSENGER WORK — Porters may not board or detrain it');
|
||||
if (p.rules.dropOnly) parts.push('MAY DROP BUT NOT PICK UP — it cannot couple anything');
|
||||
if (p.rules.pickUpEmptiesOnly) parts.push('EMPTIES ONLY — it may not couple a loaded car');
|
||||
if (p.rules.stopThenExpedite) {
|
||||
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -1179,11 +1296,11 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
if (!f) return 'a facility';
|
||||
if (f.kind === 'passenger') return 'passengers board and detrain here';
|
||||
// Say where the work has actually got to — the squares on the card show it, this names it.
|
||||
const inWork = f.menAtWork.findIndex((l) => l !== null);
|
||||
const inWork = (f.menAtWork ?? []).findIndex((l) => l !== null);
|
||||
const progress =
|
||||
inWork >= 0
|
||||
? ` · a load is on ${['MEN', 'AT', 'WORK'][inWork]}, ${
|
||||
inWork === f.menAtWork.length - 1
|
||||
inWork === (f.menAtWork?.length ?? 0) - 1
|
||||
? 'one more Laborer action and it goes onto a spotted car'
|
||||
: 'each Laborer action moves it one square right'
|
||||
}`
|
||||
@@ -1205,7 +1322,7 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
return 'through track, east–west';
|
||||
case 'curved':
|
||||
case 'sharpCurved': {
|
||||
const arc = g.arc ?? (g.hand === 'right' ? 'se' : 'sw');
|
||||
const arc = g.arc ?? (g.hand === 'right' ? 'sw' : 'se');
|
||||
const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
|
||||
return `curve — ${curvePhrase(arc)}${cost}${slopePhrase(arc[0] as Port, arc[1] as Port)}`;
|
||||
}
|
||||
|
||||
+22
-5
@@ -28,7 +28,7 @@ import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||
// which would pull node:fs into a browser bundle.
|
||||
@@ -99,6 +99,15 @@ export type Game = {
|
||||
* something you watch land rather than something you are told about afterwards.
|
||||
*/
|
||||
scheduled: number | null;
|
||||
/**
|
||||
* The card most recently drawn into hand, or null.
|
||||
*
|
||||
* A drawn card arrives among two others that look exactly like it, and nothing said which was new.
|
||||
* Unlike `scheduled`, this is NOT cleared on the next render: it marks WHICH CARD IS NEW rather
|
||||
* than that a draw just happened, so it stands until another draw replaces it. Nothing needs to
|
||||
* clear it when the card is played — no element carries the class once the card leaves the hand.
|
||||
*/
|
||||
justDrawn: CardId | null;
|
||||
};
|
||||
|
||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||
@@ -133,7 +142,7 @@ export const SOLO_PLAYER = 'Solitaire';
|
||||
|
||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null };
|
||||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null };
|
||||
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||
// first, then let the clock take over.
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
@@ -349,10 +358,16 @@ export function actionMenu(game: Game): Menu {
|
||||
* THE HAND, AND WHAT EACH CARD CAN DO.
|
||||
*
|
||||
* Built from the same `options` everything else reads, so a verb offered here is one `check` has
|
||||
* already accepted. Cards keep their order in hand rather than being regrouped: the hand is a row
|
||||
* of objects the player is looking at, not a list to sort.
|
||||
* already accepted. Cards are not regrouped or sorted: the hand is a row of objects the player is
|
||||
* looking at, not a list to sort.
|
||||
*
|
||||
* NEWEST FIRST. The engine pushes a drawn card onto the END of the hand, and with the row wrapping
|
||||
* that put the card you just turned over wherever the eye is least likely to be — reported from
|
||||
* playtesting. Reversing HERE rather than in the engine is deliberate: the bot iterates its hand to
|
||||
* generate options, so changing the stored order would reshuffle its tie-breaks and invalidate
|
||||
* every revenue measurement in TODO.md. `snapshot()` reverses identically for the replay viewers.
|
||||
*/
|
||||
const handIds = game.state.decks.hands.get(0) ?? [];
|
||||
const handIds = [...(game.state.decks.hands.get(0) ?? [])].reverse();
|
||||
const hand: HandAction[] = handIds.map((cardId) => {
|
||||
const place = placeable.flatMap((g) => g.items).find((it) => it.subjectKey === `card:${cardId}`);
|
||||
let playNow: number | null = null;
|
||||
@@ -653,6 +668,8 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
// Which timetable slot the die just filled, so the panel can flash it. Last one wins: a batch can
|
||||
// schedule more than one train, and the most recent is the one the eye should be sent to.
|
||||
for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot;
|
||||
// Which card just came into hand, so the row can badge it. Last one wins for the same reason.
|
||||
for (const e of events) if (e.type === 'cardDrawn') game.justDrawn = e.cardId;
|
||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||
}
|
||||
|
||||
+12
-2
@@ -120,6 +120,9 @@ function start(): void {
|
||||
const saved = load();
|
||||
if (saved && requested === null) {
|
||||
game = fromSave(saved);
|
||||
// Restoring replays the whole history, which re-records every draw along the way. Nothing on
|
||||
// this screen is news to the player who left it there, so the "new card" badge starts clear.
|
||||
game.justDrawn = null;
|
||||
} else {
|
||||
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||
@@ -290,8 +293,12 @@ function render(): void {
|
||||
const fig = h.shapes.length
|
||||
? ` data-tip-html="${esc(h.shapes.map((l) => piecePreview(l, h.name)).join(''))}"`
|
||||
: '';
|
||||
// The card just drawn, badged so it can be told from the two beside it. It stands until
|
||||
// another draw replaces it, rather than flashing once — the question a player asks looking
|
||||
// at the row is "which of these is new", not "did something happen".
|
||||
const fresh = h.cardId === game.justDrawn;
|
||||
return (
|
||||
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}"${fig}` +
|
||||
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}${fresh ? ' fresh' : ''}"${fig}` +
|
||||
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
|
||||
`<b>${esc(h.name)}</b>` +
|
||||
`<div class="cardacts">` +
|
||||
@@ -469,9 +476,12 @@ function renderUndo(): void {
|
||||
selected = null;
|
||||
mode = null;
|
||||
pendingAt = null;
|
||||
// The rebuilt game replays its own cues from the beginning; none of them are news.
|
||||
// The rebuilt game replays its own cues from the beginning; none of them are news. `justDrawn`
|
||||
// goes with them: replaying the history re-records every draw, so it would badge whichever card
|
||||
// the replay happened to end on rather than one the player just turned over.
|
||||
game.cues.length = 0;
|
||||
game.scheduled = null;
|
||||
game.justDrawn = 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;
|
||||
|
||||
+63
-9
@@ -159,10 +159,17 @@ export function facilitiesHtml(f: Frame): string {
|
||||
`<span class="dim">${esc(label)}</span> <b>${esc(shown)}</b>${delta}</span>`
|
||||
);
|
||||
};
|
||||
const boxes = (filled: string[], cap: number): string => {
|
||||
/**
|
||||
* GREEN IS OUTBOUND AND RED IS INBOUND — the caller says which, because this helper cannot know.
|
||||
*
|
||||
* It used to paint every filled box with one class, so the inbound row rendered GREEN while the
|
||||
* board SVG on the same page drew it red. The two disagreed on screen at the same time, which is
|
||||
* the one thing a colour code must never do. Reported from playtesting.
|
||||
*/
|
||||
const boxes = (filled: string[], cap: number, cls: 'g' | 'r' | 's'): string => {
|
||||
let h = '';
|
||||
for (let i = 0; i < Math.max(cap, filled.length); i++) {
|
||||
h += `<span class="box ${i < filled.length ? 'f' : 'empty'}">${i < filled.length ? esc(filled[i] ?? '') : '·'}</span>`;
|
||||
h += `<span class="box ${i < filled.length ? cls : 'empty'}">${i < filled.length ? esc(filled[i] ?? '') : '·'}</span>`;
|
||||
}
|
||||
return h || '<span class="dim">—</span>';
|
||||
};
|
||||
@@ -205,6 +212,18 @@ export function facilitiesHtml(f: Frame): string {
|
||||
(x.modifiers.length
|
||||
? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</span>`
|
||||
: '') +
|
||||
/**
|
||||
* A GRANT THIS FACILITY CANNOT USE, SAID OUT LOUD.
|
||||
*
|
||||
* An industry's printed flow is absolute, so an Ice House beside a Grocer's Warehouse
|
||||
* gives its Laborer and nothing else. Showing only the numbers made that read as a bug —
|
||||
* reported from playtesting as "it added the laborer but not the outbound slot". The
|
||||
* number genuinely does not move; what was missing was the reason.
|
||||
*/
|
||||
(x.suppressed.length
|
||||
? `<span class="dead" data-tip="${esc(x.suppressed.join(' · '))}">` +
|
||||
`${esc(String(x.suppressed.length))} printed bonus${x.suppressed.length === 1 ? '' : 'es'} unused</span>`
|
||||
: '') +
|
||||
`</div>` +
|
||||
/**
|
||||
* IN THE ORDER THE LOAD TRAVELS. §9.3 loads Green → MEN → AT → WORK → the spotted car,
|
||||
@@ -213,15 +232,35 @@ export function facilitiesHtml(f: Frame): string {
|
||||
* says it outright, and an industry that only receives has no green row to puzzle over.
|
||||
*/
|
||||
(x.allowsOut
|
||||
? `<div class="boxes"><span class="dim" data-tip="Loads waiting to be worked out onto a car. They travel green → MEN → AT → WORK, then onto a spotted empty car.">waiting to load</span>${boxes(x.green, x.greenCap)}</div>`
|
||||
? `<div class="boxes"><span class="dim" data-tip="${
|
||||
x.kind === 'passenger'
|
||||
? 'Passengers waiting to board. A Porter puts them onto a coach in a single action.'
|
||||
: 'Loads waiting to be worked out onto a car. They travel green → MEN → AT → WORK, then onto a spotted empty car.'
|
||||
}">${x.kind === 'passenger' ? 'waiting to board' : 'waiting to load'}</span>${boxes(x.green, x.greenCap, 'g')}</div>`
|
||||
: '') +
|
||||
/**
|
||||
* A PASSENGER FACILITY HAS NO SIGN AND NO SIDING, so neither row is drawn for one.
|
||||
*
|
||||
* `maw` comes back empty for a Depot, Station or Terminal — the pipeline is a Freight
|
||||
* Facility fitting (§9.2 is Porters, with no MEN | AT | WORK step). The row LABEL printed
|
||||
* ahead of the loop regardless, so a Depot showed the caption and three empty boxes it
|
||||
* has no Laborer to work. Same for the siding: an Office has no industry track at all.
|
||||
*/
|
||||
(x.maw.length > 0
|
||||
? `<div class="boxes"><span class="dim" data-tip="One physical sign, worked in whichever direction this industry runs. Only one load may sit on each of the three boxes.">MEN AT WORK</span>` +
|
||||
x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
|
||||
`</div>`
|
||||
: '') +
|
||||
`<div class="boxes"><span class="dim" data-tip="One physical sign, worked in whichever direction this industry runs. Only one load may sit on each of the three boxes.">MEN AT WORK</span>` +
|
||||
x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
|
||||
`</div>` +
|
||||
(x.allowsIn
|
||||
? `<div class="boxes"><span class="dim" data-tip="Loads that have come off an arriving car and been cleared. They travel car → WORK → AT → MEN, then into a red box — the opposite direction to loading.">cleared inbound</span>${boxes(x.red, x.redCap)}</div>`
|
||||
? `<div class="boxes"><span class="dim" data-tip="${
|
||||
x.kind === 'passenger'
|
||||
? 'Passengers who have arrived and been detrained by a Porter.'
|
||||
: 'Loads that have come off an arriving car and been cleared. They travel car → WORK → AT → MEN, then into a red box — the opposite direction to loading.'
|
||||
}">${x.kind === 'passenger' ? 'arrived' : 'cleared inbound'}</span>${boxes(x.red, x.redCap, 'r')}</div>`
|
||||
: '') +
|
||||
(x.trackCap > 0 || x.track.length > 0
|
||||
? `<div class="boxes"><span class="dim" data-tip="Cars spotted on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one.">siding</span>${boxes(x.track, x.trackCap, 's')}</div>`
|
||||
: '') +
|
||||
`<div class="boxes"><span class="dim" data-tip="Cars spotted on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one.">siding</span>${boxes(x.track, x.trackCap)}</div>` +
|
||||
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
|
||||
x.jammed
|
||||
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
|
||||
@@ -246,6 +285,14 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
||||
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
||||
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
||||
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
|
||||
look alike otherwise, with nothing to distinguish the one you turned over. Green is the page's
|
||||
"a good thing just happened" colour, as on the timetable. A static badge rather than a flash: the
|
||||
hand is rebuilt by innerHTML on every render, which would restart a keyframe each time, and the
|
||||
question being answered is "which of these is new" rather than "did something happen". */
|
||||
.handcard.fresh{border-color:#8fd6a0;box-shadow:0 0 0 2px rgba(143,214,160,.18)}
|
||||
.handcard.fresh::before{content:"NEW";display:block;font-size:9px;letter-spacing:.09em;
|
||||
color:#8fd6a0;font-weight:700;margin-bottom:2px}
|
||||
/* A pile shows two things: which card is face up on top, and how many are under it. */
|
||||
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
|
||||
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3}
|
||||
@@ -316,10 +363,17 @@ ul.blocked{margin:0;padding-left:18px}
|
||||
.cap b{color:#e6e9ee;font-variant-numeric:tabular-nums}
|
||||
.cap .added{color:#8fd6a0;margin-left:2px;font-weight:700}
|
||||
.caps .mods{color:#c8a04a}
|
||||
/* Amber-grey: a fact about the card, not a fault the player caused. */
|
||||
.caps .dead{color:#8b94a3;border-bottom:1px dotted #6c7480}
|
||||
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
|
||||
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
|
||||
.box.empty{background:#242a32;color:#5a6472}
|
||||
.box.f{background:#2f6b3d}
|
||||
/* Green OUT, red IN, matching the board SVG's palette exactly — the panel and the map are the same
|
||||
card seen twice, so they may not disagree about which colour means which way. Grey for the siding,
|
||||
which is a place rather than a direction. */
|
||||
.box.g{background:#2f6b3d}
|
||||
.box.r{background:#8a4a4a}
|
||||
.box.s{background:#3a4450}
|
||||
.box.m{background:#8a6d1f}
|
||||
.fstat{margin-top:3px;font-size:10px;padding:1px 6px;border-radius:3px;display:inline-block}
|
||||
.fstat.good{background:rgba(40,140,60,.28)}
|
||||
|
||||
Reference in New Issue
Block a user