Compare commits
+1035
File diff suppressed because it is too large
Load Diff
@@ -132,6 +132,32 @@ is the thing this machinery exists to prevent.
|
||||
roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game
|
||||
replays the intents.
|
||||
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
||||
- **The Mainline Phase can stop and ask, and there are three questions it asks.** §8.1's clearance
|
||||
ruling goes to the Superintendent; the Yard Office offer and the Red Flag prompt go to the owner of
|
||||
the district a train is arriving at. `pendingDecision` is a discriminated union and `decisionActor`
|
||||
is the single place that maps a question to whoever must answer it — a new question adds a case
|
||||
there and nowhere else. **Ask before the move is committed:** returning `needsClearance` unwinds
|
||||
the whole phase and the driver re-enters from the top, so anything already mutated is applied
|
||||
twice or left half-done.
|
||||
- **A game ends by PAUSING, and the first ending is the real one.** Running out of Days, or closing
|
||||
short of the combined Revenue floor, puts the game in `awaitingExtension` rather than `finished`:
|
||||
the table is asked whether to play one more Day, unanimously, and asked again at the end of every
|
||||
Day it grants. `state.official` is written at the first ending and never rewritten, so the winner
|
||||
is always the one decided at `config.days` however long play carries on — `config.days` itself
|
||||
never moves, and `state.extraDays` counts the borrowed ones. A §3.4 collision breach is the
|
||||
exception and finishes outright, during an extended Day exactly as during the scheduled game.
|
||||
Because a save is a replay, the vote is an intent (`game.extend`), and it is the one intent that
|
||||
**carries its own player**: every seat may vote in any order, so a replay cannot derive who did.
|
||||
- **Statistics are folded, not recorded.** `state.tally` counts what the event stream says happened —
|
||||
trains through the Division and how many of them did any switching, loads made up and broken — and
|
||||
is hooked
|
||||
at the two boundaries every event crosses exactly once, `applyIntent` and `advance`. It is not
|
||||
hooked in `reduce`, which never sees the phase driver's events at all. Nothing in the rules reads
|
||||
it, so adding a counter is always safe; it rides the `Frame`, so a multiplayer client gets the same
|
||||
numbers as solitaire from one implementation. **What it cannot count is anything the events do not
|
||||
say.** `trainStoodStill` fires once per game for the X18 Circus alone, so "the longest an engine sat
|
||||
on a siding" has no signal behind it — see `TODO.md` #36 rather than assuming an event means what
|
||||
its name suggests.
|
||||
- **A game is one of four TYPES, and a type is a set of defaults rather than a ruleset.** Co-op,
|
||||
Competitive, Cutthroat and Solitaire (`src/web/presets.ts`) each name an opening hand, an Extra
|
||||
rule, three revenue rates and the victory conditions; picking one fills the form, and changing any
|
||||
|
||||
Binary file not shown.
+83
-18
@@ -40,8 +40,9 @@ All nine answers are implemented, **170 tests passing**:
|
||||
|
||||
| Answer | Implemented as |
|
||||
| --- | --- |
|
||||
| Q1 crossing time | `crossingStages()` — a 60 card takes 1 Stage, a 30 takes 2. Mainline nodes now carry a **terrain type** dealt at setup, and trains count down Stages instead of stepping through regions. The `Region` model is gone. |
|
||||
| Q2 Fast/Slow | Slow adds one Stage to every card. Hilly reads the consist (any coach = passenger). |
|
||||
| ~~Q1 crossing time~~ | **SUPERSEDED 2026-08-26 — see Q1a below.** Was: a 60 card takes 1 Stage, a 30 takes 2. |
|
||||
| ~~Q2 Fast/Slow~~ | **SUPERSEDED 2026-08-26 — see Q1a below.** Was: Slow adds one Stage to every card; Hilly reads the consist. |
|
||||
| Q1a crossing time | `crossingStages()` — a card costs one Stage per **region printed on it**, and where a train STARTS is what varies. Plains 1, Double Track 1, Trestle 1, Curves 2, Tunnel 2, Heavy Grade 3. The printed mph are scenery. Fast/Slow is read on Hilly and nowhere else. |
|
||||
| Q3 Expedite | An expedited train departs the Stage it arrives — it gets a second `moveTrain` in the same Mainline Phase, still subject to §8.1 clearance. |
|
||||
| Q4 Lockouts | `isLockedOut()` rejects the placement with `FACILITY_LOCKED`. |
|
||||
| Q5 Run-around | Nothing to do — reachability is geometric, so a built bypass already works. |
|
||||
@@ -76,6 +77,39 @@ Crossing time never falls below one Stage — a train cannot cross in no time.
|
||||
there is nothing to build. A test asserts it remains TBD, to stop anyone "fixing" it by inventing
|
||||
an effect; a silent no-op would be worse than a rejection.
|
||||
|
||||
**Q1a, answered by RAR 2026-08-26 (Gitea#3), and it replaces Q1 and Q2 together.**
|
||||
|
||||
> "Ignore speed signs. They are just graphics. Regions shown on cards indicate how many stages it
|
||||
> takes to cross. Plains is 1. Double track is 1, tunnel is 2, curves is 2, heavy grade is 3 unless
|
||||
> you have help… Some cards say fast / slow. This is an indication that if on the train card, the
|
||||
> train is listed as fast or slow, that's starting position / how many stages it takes to traverse
|
||||
> the card. Fast / Slow does not apply to every card — just those that say fast / slow on them.
|
||||
> Currently this is only hilly."
|
||||
|
||||
What this changes, against what was recorded before:
|
||||
|
||||
- **The printed 60/30 mean nothing.** Q1 read them as crossing time; they are ambiance.
|
||||
- **Fast/Slow is not a global penalty.** Q2 added a Stage to every card for a Slow train, which is
|
||||
what made a Slow train take two Stages to clear Double Track — the report that opened the issue.
|
||||
It now applies on Hilly alone, where a fast train starts in the second region.
|
||||
- **Hilly no longer reads the consist.** RAR: "I notice that you are basing stages in mainline cards
|
||||
off coach/non-coach. Actually, all trains are rated as FAST and SLOW."
|
||||
- **Heavy Grade is three regions, not two**, and the modifiers move the START rather than cutting the
|
||||
clock: Helpers start an uphill train a region on, Brakeman a downhill one, Airbrakes another again.
|
||||
- **The Uncontrolled Siding and the Interchange print a back region** that is not part of the road. A
|
||||
train running through starts past it; a train arriving to find the siding occupied takes it and
|
||||
runs a region behind, which is what keeps the two apart, and an Extra beginning its run at an
|
||||
Interchange starts there too.
|
||||
- **ABS holds a train off the card** rather than letting it collide, on any Mainline card.
|
||||
|
||||
Measured consequence, replacing the one recorded under Q2: on a **3-player Division the Mainline
|
||||
cards themselves now cost a fast train ~5.6 Stages and a slow train ~6.0**, against ~5.4 and ~9.4
|
||||
before. Fast traffic is unchanged; **slow traffic is about a third quicker**, and the Fast/Slow gap
|
||||
across a whole Division collapses from roughly four Stages to less than one. The Q2 note that "every
|
||||
Slow train is still on the road when the next Day begins, holding its Crew Tray" no longer holds, so
|
||||
the `players + 3` tray count is due a re-examination — RAR's own closing worry: "been worried about
|
||||
the time it takes to cross the division. More thunking on this is needed."
|
||||
|
||||
**Q11, answered from the source.** The Heavy Grade card prints **"(Up)"** and **"Player sets
|
||||
orientation"**, so which way it climbs is a property of the placed card, not a fixed compass
|
||||
direction. `DivisionNode.gradeUp` records the direction a train travels when **climbing**; a train
|
||||
@@ -888,34 +922,65 @@ any setting** — being a place an Extra can start is part of what upgrading buy
|
||||
|
||||
---
|
||||
|
||||
## §6.2 — a train card is never discarded
|
||||
## §6.2 — which train cards may be discarded
|
||||
|
||||
**Jesse's ruling, v0.4.9e playtest** (Gitea#6): "Players are not allowed to discard Train cards. They
|
||||
may keep the card in their hand for multiple stages and even multiple days, but they may not discard
|
||||
it. If a player has three train cards in their hand, and they draw a fourth, then they must play one
|
||||
of those cards."
|
||||
**SUPERSEDED ONCE. Read both rulings; the second narrows the first.**
|
||||
|
||||
**Extras count.** An Extra is a train, even though it runs once and ends in the Salvage Yard where a
|
||||
Timetabled card joins the timetable for the rest of the game.
|
||||
**Gitea#6, v0.4.9e playtest:** "Players are not allowed to discard Train cards. They may keep the
|
||||
card in their hand for multiple stages and even multiple days, but they may not discard it. If a
|
||||
player has three train cards in their hand, and they draw a fourth, then they must play one of those
|
||||
cards." Extras counted: an Extra is a train.
|
||||
|
||||
**Gitea#9, 2026-08-24 — the ruling in force:** "Timetabled trains are at the choice of the player:
|
||||
they can either play or discard. If someone else wants to pick it up, they are more than able to.
|
||||
The reason: I don't want, if you decide to play a game longer than five days, to decide that maybe
|
||||
there are too many trains, the stations are jammed, and the railroad doesn't need any more. You can
|
||||
toss it. Someone else might disagree and pick it up."
|
||||
|
||||
So the rule is now:
|
||||
|
||||
- a **Timetabled** train may be discarded;
|
||||
- an **Extra** may not. It never joins the timetable, so it can never be what jams it, and the only
|
||||
rule it would dodge by being thrown away is the hand limit;
|
||||
- **on `main` the Timetabled half is a New Game setting** (`discardTimetabled`, on by default),
|
||||
because Jesse's reasoning is explicitly about LONG games and a five-Day game may well want
|
||||
Gitea#6's pressure. The 0.4.9 playtest line has no scaffolding for a setting and takes the plain
|
||||
rule. Both lines behave identically at their defaults.
|
||||
|
||||
**"Someone else might disagree and pick it up" needed no machinery.** A discard already goes face-up
|
||||
onto a Department pile, and a Department pile is exactly what a rival draws from. The second half of
|
||||
the ruling was already built; only the first half was a change.
|
||||
|
||||
§6.2 as transcribed says only "the player must reduce his hand to no more than three cards" with no
|
||||
exception for any card type, so this is a ruling rather than a gap — the prototype rules do not
|
||||
address it either way.
|
||||
exception for any card type, so both of these are rulings rather than gaps — the prototype rules do
|
||||
not address it either way.
|
||||
|
||||
### It needs no forcing mechanism, and that is the point
|
||||
|
||||
The interesting property of this rule is that the forced play falls out of two rules that already
|
||||
The interesting property of the rule is that the forced play falls out of two rules that already
|
||||
exist rather than needing a third:
|
||||
|
||||
1. a train card cannot be discarded, so it is not among the ways to shed a card; and
|
||||
1. an undiscardable card is not among the ways to shed a card; and
|
||||
2. `draw.end` already refuses while the hand is over the limit (§6.2).
|
||||
|
||||
A player holding four trains therefore has exactly one legal way to conclude the turn — play one —
|
||||
without anything in the engine ever computing "you must play a train". The corner cannot lock a
|
||||
player in, because **playing a train card is unconditionally legal**: `card.play`'s train case
|
||||
A player holding four undiscardable trains therefore has exactly one legal way to conclude the turn —
|
||||
play one — without anything in the engine ever computing "you must play a train". The corner cannot
|
||||
lock a player in, because **playing a train card is unconditionally legal**: `card.play`'s train case
|
||||
refuses only a board placement, and a train card played when the timetable is full still leaves the
|
||||
hand (it simply schedules nothing). Confirmed by playing it: a hand of four trains offers zero
|
||||
discards, no `draw.end`, and four plays.
|
||||
hand (it simply schedules nothing). Confirmed by playing it: such a hand offers zero discards, no
|
||||
`draw.end`, and four plays.
|
||||
|
||||
**Gitea#9 does not retire that corner, it narrows the way in.** With the setting on, the only hand
|
||||
that reaches it is four Extras; with the setting off it is any four trains, exactly as before.
|
||||
|
||||
### One place decides, and the card says which rule refused
|
||||
|
||||
`keepReason` (`src/engine/apply.ts`) returns the sentence a player should read, or `null` if the card
|
||||
may be discarded. `check`, the hand panel and the blocked "End Local Operations" button all ask it,
|
||||
so none of them can drift from the rule. It returns a SENTENCE rather than a boolean because there
|
||||
are now two distinct reasons — "an Extra is never discarded" and "not in this game" — and a panel
|
||||
that hard-codes one of them tells half the players the wrong thing. It reaches the page as the
|
||||
Frame's `handKeepWhy`.
|
||||
|
||||
The bot needed no rule of its own either. `legal.ts` enumerates candidates and filters them through
|
||||
`check`, so the option stops being offered; and the developer bot already reaches for `card.play`
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -60,7 +60,21 @@ execFileSync(
|
||||
*/
|
||||
function buildStamp(): string {
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
|
||||
let git = 'nogit';
|
||||
/**
|
||||
* THE FALLBACK HAS TO BE UNIQUE PER BUILD, because this string is also the cache-bust key.
|
||||
*
|
||||
* It used to be the literal `nogit`, which is exactly what the `.s9pk` build produces — the
|
||||
* Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there every time.
|
||||
* Every packaged release therefore published `?v=nogit`, byte-identical to the release before it,
|
||||
* and a returning player's browser had no reason to refetch a single module. v0.7.5's setup screen
|
||||
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
|
||||
* that asked for them (Jesse, twice, 2026-08-29 — "setup did not work").
|
||||
*
|
||||
* The version plus the build's own timestamp is always distinct, needs nothing from the
|
||||
* environment, and stays honest: two builds of the same commit ARE two deploys, and a cache key
|
||||
* that says so costs one refetch, while one that lies costs a release nobody receives.
|
||||
*/
|
||||
let git = `${pkg.version}-${Date.now().toString(36)}`;
|
||||
try {
|
||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
||||
.toString()
|
||||
|
||||
+601
-78
@@ -23,23 +23,25 @@ import {
|
||||
enhancementRule,
|
||||
crossingStages,
|
||||
trainProfile,
|
||||
startRegion,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
MOVES_PER_LOCAL_OPS_NIGHT,
|
||||
STAGES_PER_DAY,
|
||||
STAGES_PER_SHIFT,
|
||||
houseRules,
|
||||
officeProfile,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
mainlineProfile,
|
||||
} from './content.ts';
|
||||
import type { Direction } from './content.ts';
|
||||
import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||
// the legality test cannot disagree about which train is being assembled.
|
||||
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
|
||||
import { areaAtSeat, areaOf, occupancyFor, trainNeedingCars } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
import { reachableDestinations } from './track.ts';
|
||||
import { tallyEvent } from './tally.ts';
|
||||
|
||||
export type AdvanceResult = {
|
||||
events: GameEvent[];
|
||||
@@ -49,6 +51,31 @@ export type AdvanceResult = {
|
||||
|
||||
const NIGHT_STAGES = new Set([1, 2, 3, 11, 12]);
|
||||
|
||||
/**
|
||||
* IS EVERY CAR ON THIS TRAIN LOADED? (Gitea#13)
|
||||
*
|
||||
* "You only get credit for a circus or campaign train (one point per stop) if you have it fully
|
||||
* loaded. Not much of a circus if all the cars are empty."
|
||||
*
|
||||
* A COACH COUNTS AS LOADED WHEN IT IS OCCUPIED, which is what makes this the right test for the
|
||||
* Campaign Train: X17 carries one coach and no freight, so "fully loaded" is precisely "the
|
||||
* candidate is aboard" (Jesse's ruling, 2026-08-29). The engine already models an occupied coach
|
||||
* as `loaded`, so no second notion is introduced here.
|
||||
*
|
||||
* A CABOOSE IS EXEMPT, and it costs nothing to say so: every caboose in `ROLLING_STOCK_SUPPLY` is
|
||||
* minted `loaded: true` — there is no empty one — so including it would change no outcome today.
|
||||
* It is excluded anyway because a caboose is crew space rather than payload, and a supply table
|
||||
* that grew an empty caboose should not silently start voiding circus points.
|
||||
*
|
||||
* AN EMPTY TRAIN IS NOT FULLY LOADED. A Circus that departed short and carries nothing at all earns
|
||||
* nothing: `every` on an empty list is vacuously true, which would pay the emptiest train of the
|
||||
* lot, so the length is tested first.
|
||||
*/
|
||||
function fullyLoaded(tray: CrewTray): boolean {
|
||||
const payload = tray.consist.filter((c) => c.type !== 'caboose');
|
||||
return payload.length > 0 && payload.every((c) => c.loaded);
|
||||
}
|
||||
|
||||
function movesForStage(s: GameState): number {
|
||||
return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage)
|
||||
? MOVES_PER_LOCAL_OPS_NIGHT
|
||||
@@ -71,10 +98,58 @@ const step = (d: Direction): number => (d === 'east' ? 1 : -1);
|
||||
// advance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The phase driver, plus the two things that have to happen around EVERY batch of events it
|
||||
* produces. `advanceInner` below is the driver itself, unchanged.
|
||||
*
|
||||
* ORDER IS THE WHOLE POINT of this wrapper, and it is the one subtle thing in Gitea#16.
|
||||
* `checkVictory` runs deep inside the driver, so if the official result froze a copy of the Tally
|
||||
* from in there it would freeze it BEFORE this batch's events had been counted — and the batch that
|
||||
* ends a game is exactly the one carrying the last Day's work. So the Tally is folded first and the
|
||||
* result frozen second, both out here where the whole batch is in hand.
|
||||
*
|
||||
* Safe because both endings `return` the moment they fire: no scoring event is emitted after a game
|
||||
* has ended within a single batch, so "everything in this batch" and "everything up to the ending"
|
||||
* are the same set of events. `test/tally.test.ts` pins that.
|
||||
*/
|
||||
export function advance(s: GameState): AdvanceResult {
|
||||
const r = advanceInner(s);
|
||||
for (const e of r.events) tallyEvent(s, e);
|
||||
freezeOfficial(s);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE OFFICIAL RESULT, written once (Gitea#11).
|
||||
*
|
||||
* "The winner is based upon the original game length" — so the first ending is the real one and
|
||||
* every later evaluation is informational. Idempotent by construction: it does nothing once
|
||||
* `official` is set, which is what stops an extended Day, or a §3.4 breach during one, from
|
||||
* rewriting a recorded win.
|
||||
*/
|
||||
function freezeOfficial(s: GameState): void {
|
||||
if (s.official !== null || s.outcome === null) return;
|
||||
s.official = {
|
||||
day: s.config.days,
|
||||
outcome: { ...s.outcome },
|
||||
revenues: s.players.map((p) => p.revenue),
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
tally: cloneTally(s.tally),
|
||||
};
|
||||
}
|
||||
|
||||
function advanceInner(s: GameState): AdvanceResult {
|
||||
const events: GameEvent[] = [];
|
||||
if (s.status === 'finished') return { events, needsInput: false };
|
||||
|
||||
/**
|
||||
* §3.3 (Gitea#11) — the timetable has run out and the table is being asked whether to play one
|
||||
* more Day. Nothing runs itself while that question is open, so this is `needsInput` rather than
|
||||
* an ending: `pump` stops here, the server keeps the game in memory, and the only intent the
|
||||
* rules will take is `game.extend`.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') return { events, needsInput: true };
|
||||
|
||||
// The Superintendent's clearance ruling interrupts the Mainline Phase (§8.1).
|
||||
if (s.clock.pendingDecision !== null) return { events, needsInput: true };
|
||||
|
||||
@@ -371,33 +446,45 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
const where = tray.position;
|
||||
const moved = moveTrain(s, id, tray, events);
|
||||
/**
|
||||
* X18 CIRCUS TRAIN — "one turn stopped on any track (circus set-up) earns 1 point".
|
||||
* X18 CIRCUS / X17 CAMPAIGN — a Stage spent set up in somebody's Office Area earns a point.
|
||||
*
|
||||
* The flag was declared on the profile and read NOWHERE, so the one card in the deck that pays
|
||||
* The flag was declared on the profile and read NOWHERE, so the one card in the deck that paid
|
||||
* for standing still paid nothing: reported from a playtest where TX18 sat on a siding for a
|
||||
* full Stage and no point arrived. Claimed once — an Extra runs once and is gone.
|
||||
* full Stage and no point arrived.
|
||||
*
|
||||
* ONCE PER OFFICE AREA (Gitea#13, Jesse 2026-08-29): "once per stop in an office area. In a
|
||||
* multiplayer game, each player could score if the circus stops in their area." So a Circus
|
||||
* touring three districts is paid three times and one parked in the same district all game is
|
||||
* paid once, which `stopPointSeats` records per seat.
|
||||
*
|
||||
* ONLY IN AN OFFICE AREA. It used to pay for standing on the Mainline or at a Division Point
|
||||
* too, and misattributed both: `playerAtSeat` needs a seat, and off the grid there is none, so
|
||||
* the fallback handed the point to PLAYER 0 wherever the train happened to be. Scoping the rule
|
||||
* to Office Areas is what Jesse's ruling says and it removes that bug rather than patching it.
|
||||
*
|
||||
* FULLY LOADED, or nothing. "Not much of a circus if all the cars are empty" — see
|
||||
* `fullyLoaded` below for what that means for a train whose only car is a coach.
|
||||
*
|
||||
* "Stopped" is measured against the Mainline Phase: the train attempted to move and stayed where
|
||||
* it was. A train that is still in the district when the phase runs has not moved either, which
|
||||
* is the circus setting up on a siding rather than crossing the Division.
|
||||
*/
|
||||
if (!tray.stopPointClaimed && trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopEarnsPoint) {
|
||||
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopEarnsPoint) {
|
||||
const stillThere =
|
||||
tray.position.at === where.at &&
|
||||
(tray.position.at !== 'mainline' || where.at !== 'mainline' || tray.position.index === where.index) &&
|
||||
(tray.position.at !== 'grid' ||
|
||||
where.at !== 'grid' ||
|
||||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
|
||||
if (stillThere) {
|
||||
tray.stopPointClaimed = true;
|
||||
// Bound as one value so the grid case narrows: `tray.position` is a union, and testing a
|
||||
// `seat` extracted from it does not tell the compiler which member it came from.
|
||||
const at = tray.position.at === 'grid' ? tray.position : null;
|
||||
const alreadyPaidHere = at !== null && (tray.stopPointSeats ?? []).includes(at.seat);
|
||||
if (stillThere && at !== null && !alreadyPaidHere && fullyLoaded(tray)) {
|
||||
tray.stopPointSeats = [...(tray.stopPointSeats ?? []), at.seat];
|
||||
// The point goes to whoever is SITTING in the district it stopped in.
|
||||
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
|
||||
const label =
|
||||
tray.position.at === 'grid'
|
||||
? `(${tray.position.coord.col},${tray.position.coord.row})`
|
||||
: tray.position.at === 'mainline'
|
||||
? `Mainline card ${tray.position.index}`
|
||||
: `the ${tray.position.side} Division Point`;
|
||||
const owner = playerAtSeat(s, at.seat);
|
||||
const label = `(${at.coord.col},${at.coord.row})`;
|
||||
events.push({ type: 'trainStoodStill', trainNumber: tray.trainNumber ?? 0, where: label });
|
||||
const p = s.players[owner];
|
||||
if (p) {
|
||||
@@ -407,7 +494,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
player: owner,
|
||||
delta: 1,
|
||||
total: p.revenue,
|
||||
reason: 'circus set-up — a Stage spent standing still',
|
||||
reason: 'set up in the district — a Stage spent standing still, fully loaded',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -451,6 +538,104 @@ function badlyMadeUp(tray: CrewTray): string | null {
|
||||
return caboose === rear ? null : 'not made up — the caboose must be at the rear of the train';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH REGION OF A MAINLINE CARD A TRAIN IS STANDING IN (Gitea#3).
|
||||
*
|
||||
* A card is `regions` boxes wide and a train advances one per Stage, so what it has LEFT to run says
|
||||
* where it is: enter with `regions` still to go and you are at the beginning; enter with one to go
|
||||
* and you are in the last box.
|
||||
*
|
||||
* This used to be derived from a single global `REGIONS_PER_MAINLINE_CARD = 2`, with an entry term
|
||||
* that put a one-Stage train in region 1 of a two-region card — a fast train did not traverse a fast
|
||||
* card, it appeared at the far half of it. Cards carry their own region count now, so the position
|
||||
* is simply the count minus what is left.
|
||||
*/
|
||||
export function regionOfTransit(card: MainlineKind, stagesRemaining: number): number {
|
||||
const regions = mainlineProfile(card).regions;
|
||||
return Math.min(regions - 1, Math.max(0, regions - stagesRemaining));
|
||||
}
|
||||
|
||||
/** The entry a train would make onto this card, before occupancy is taken into account. */
|
||||
function entryFor(
|
||||
node: Extract<DivisionNode, { kind: 'mainline' }>,
|
||||
tray: CrewTray,
|
||||
startsAtBack = false,
|
||||
): MainlineEntry {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
return {
|
||||
trainSpeed: profile?.speed ?? 'slow',
|
||||
direction: tray.direction,
|
||||
gradeUp: node.gradeUp ?? 'east',
|
||||
modifiers: node.modifiers ?? [],
|
||||
...(startsAtBack ? { startsAtBack: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* THE UNCONTROLLED SIDING RULE (Gitea#3): "if a train already exists when you arrive, you go in the
|
||||
* second stage back — you are in the siding and are one behind the other train. This prevents a
|
||||
* collision, since you are not in same exact location."
|
||||
*
|
||||
* So arriving at an occupied siding is not a collision and not a hold; it is a different, slower
|
||||
* entry. Anywhere else this returns false and the ordinary start applies.
|
||||
*/
|
||||
function takesTheSiding(node: Extract<DivisionNode, { kind: 'mainline' }>): boolean {
|
||||
return node.card === 'uncontrolledSiding' && node.transits.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* IS MOVING ONTO THIS CARD A COLLISION? (Gitea#3)
|
||||
*
|
||||
* A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train
|
||||
* granted clearance arrives in the same region as the train ahead the moment it enters. There was no
|
||||
* test for that at all: the catch-up check lives inside `stagesRemaining > 1`, which a one-Stage
|
||||
* crossing never reaches, so entering behind another train on a Plains was silently free.
|
||||
*
|
||||
* ABS is the card that answers it, in RAR's words: "This is played on a mainline card to prevent
|
||||
* collisions. If a collision would normally occur, the train moving onto the card is instead held
|
||||
* back." Held, not waved through — it tries again next Stage.
|
||||
*
|
||||
* The Uncontrolled Siding never conflicts on entry, because `takesTheSiding` has already moved this
|
||||
* train a region back; that is the whole point of the card.
|
||||
*/
|
||||
function entryConflict(
|
||||
s: GameState,
|
||||
node: Extract<DivisionNode, { kind: 'mainline' }>,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
events: GameEvent[],
|
||||
startsAtBack = false,
|
||||
): 'collided' | 'held' | null {
|
||||
if (mainlineProfile(node.card).trainsMayPass) return null;
|
||||
const start = startRegion(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node)));
|
||||
const ahead = node.transits.find(
|
||||
(t) =>
|
||||
t.tray !== id &&
|
||||
t.direction === tray.direction &&
|
||||
regionOfTransit(node.card, t.stagesRemaining) === start,
|
||||
);
|
||||
if (!ahead) return null;
|
||||
|
||||
/**
|
||||
* A BACKSTOP, not the main path. `evaluateClearance` already refuses to clear a train onto a card
|
||||
* carrying ABS, so in the ordinary run of things nothing reaches here with signals up. It stays
|
||||
* because the two rules answer to different questions — clearance looks at the whole Subdivision,
|
||||
* this looks at one region — and a card that promises no rear-enders should not depend on the
|
||||
* wider check happening to fire first.
|
||||
*/
|
||||
if (node.absSignals) {
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason: 'ABS Signals — held short of the train ahead',
|
||||
});
|
||||
return 'held';
|
||||
}
|
||||
// §10 — the Superintendent cleared it into an occupied region, so it is the Superintendent's fault.
|
||||
collide(s, s.clock.superintendent, [id, ahead.tray], events, 'ran into the train ahead', 'the Mainline');
|
||||
return 'collided';
|
||||
}
|
||||
|
||||
/** Puts a train onto a Mainline card with its crossing time already computed. */
|
||||
function enterMainline(
|
||||
s: GameState,
|
||||
@@ -458,17 +643,9 @@ function enterMainline(
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
index: number,
|
||||
startsAtBack = false,
|
||||
): void {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
const carriesPassengers = tray.consist.some((c) => c.type === 'coach');
|
||||
const stages = crossingStages(
|
||||
node.card,
|
||||
profile?.speed ?? 'slow',
|
||||
carriesPassengers,
|
||||
node.modifiers ?? [],
|
||||
tray.direction,
|
||||
node.gradeUp ?? 'east',
|
||||
);
|
||||
const stages = crossingStages(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node)));
|
||||
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
|
||||
tray.position = { at: 'mainline', index };
|
||||
// It is running now, so it is no longer being assembled (state.ts). A train at a Division Point
|
||||
@@ -577,6 +754,10 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
if (clearance === 'blocked') return 'held';
|
||||
if (clearance === 'ask') return 'needsClearance';
|
||||
|
||||
const conflict = entryConflict(s, node, id, tray, events);
|
||||
if (conflict === 'held') return 'held';
|
||||
if (conflict === 'collided') return 'moved';
|
||||
|
||||
enterMainline(s, node, id, tray, target);
|
||||
const dp = s.division.nodes[dpIndex];
|
||||
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
|
||||
@@ -636,6 +817,11 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
if (clearance === 'blocked') return 'held';
|
||||
if (clearance === 'ask') return 'needsClearance';
|
||||
|
||||
const conflict = entryConflict(s, node, id, tray, events);
|
||||
if (conflict === 'held') return 'held';
|
||||
// The wreck's A/D track is released by `collide` itself, which is why it has to be.
|
||||
if (conflict === 'collided') return 'moved';
|
||||
|
||||
enterMainline(s, node, id, tray, target);
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
events.push({
|
||||
@@ -696,8 +882,19 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
}
|
||||
if (clearance === 'ask') return 'needsClearance';
|
||||
|
||||
/**
|
||||
* AN EXTRA PULLING OUT OF THE INTERCHANGE STARTS IN THE BACK REGION (Gitea#3) — "interchange
|
||||
* has new extras show up in second region (like uncontrolled siding)", and earlier, "Plains is
|
||||
* 1 stage for ALL trains. So are interlockings, with a second stage for incoming extras to hold
|
||||
* at." A train running THROUGH the Interchange starts past that region and crosses in one
|
||||
* Stage; one that began its run here has the holding region to clear first.
|
||||
*/
|
||||
const conflict = entryConflict(s, node, id, tray, events, true);
|
||||
if (conflict === 'held') return 'held';
|
||||
if (conflict === 'collided') return 'moved';
|
||||
|
||||
node.holding = node.holding.filter((t) => t !== id);
|
||||
enterMainline(s, node, id, tray, index);
|
||||
enterMainline(s, node, id, tray, index, true);
|
||||
events.push({
|
||||
type: 'trainHighballed',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
@@ -729,21 +926,22 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
*
|
||||
* ABS Signals does what it says instead: the follower stops SHORT of the collision and holds.
|
||||
*/
|
||||
const regionOf = (t: { stagesTotal: number; stagesRemaining: number }): number => {
|
||||
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
|
||||
const elapsed = t.stagesTotal - t.stagesRemaining;
|
||||
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
|
||||
};
|
||||
// NOT on a card that prints "trains may pass". Double Track and Uncontrolled Siding hold two
|
||||
// trains because they HAVE two roads, so a train catching another there goes past it — that
|
||||
// is what the card is for. Without this the mechanic fired 0.41 times a game while the bot
|
||||
// never once granted clearance, which is the tell: those were all passing cards.
|
||||
// NOT on a card that prints "trains may pass" — Double Track holds two trains because it HAS
|
||||
// two roads, so a train catching another there goes past it. That is what the card is for.
|
||||
//
|
||||
// The Uncontrolled Siding used to be in that set and no longer is: it keeps two trains apart
|
||||
// by putting the second one in the siding a region back (`takesTheSiding`), not by letting
|
||||
// them share a place. Marking it "may pass" skipped this test entirely and made the siding do
|
||||
// nothing at all.
|
||||
const mayPass = mainlineProfile(node.card).trainsMayPass;
|
||||
const next = regionOf({ stagesTotal: transit.stagesTotal, stagesRemaining: transit.stagesRemaining - 1 });
|
||||
const next = regionOfTransit(node.card, transit.stagesRemaining - 1);
|
||||
const ahead = mayPass
|
||||
? undefined
|
||||
: node.transits.find(
|
||||
(t) => t.tray !== id && t.direction === transit.direction && regionOf(t) === next,
|
||||
(t) =>
|
||||
t.tray !== id &&
|
||||
t.direction === transit.direction &&
|
||||
regionOfTransit(node.card, t.stagesRemaining) === next,
|
||||
);
|
||||
|
||||
if (ahead) {
|
||||
@@ -768,9 +966,30 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
// Off the end of the card: into the adjoining Limit, then straight to the Office (§8.2).
|
||||
const target = index + dir;
|
||||
const dest = s.division.nodes[target];
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — the Yard Office offer is put BEFORE the train leaves the Mainline card, for
|
||||
* the same reason §8.1's clearance is: `needsClearance` unwinds the whole phase and the driver
|
||||
* re-enters here from the top, so anything already mutated is mutated twice or, worse, left
|
||||
* half-applied. Asking after the `transits` filter below cost the train its place on the card
|
||||
* and it was never seen again — the question was asked and the answer had nowhere to land.
|
||||
*/
|
||||
if (dest?.kind === 'office') {
|
||||
/**
|
||||
* §Q, RED FLAGS (Gitea#19) — asked and answered before the train leaves the card, for exactly
|
||||
* the reason the Yard Office offer is (see below): `needsClearance` unwinds the phase.
|
||||
*
|
||||
* Order matters. A flag stops the train OUTSIDE the Limits, so it never reaches the point
|
||||
* where the Yard Office would be offered — flagging is about keeping a train out altogether.
|
||||
*/
|
||||
const flagged = redFlagStop(s, id, tray, dest, events);
|
||||
if (flagged === 'ask') return 'needsClearance';
|
||||
if (flagged === 'held') return 'held';
|
||||
|
||||
if (yardOfficeQuestion(s, id, tray, dest.seat, events) === 'ask') return 'needsClearance';
|
||||
}
|
||||
|
||||
node.transits = node.transits.filter((t) => t.tray !== id);
|
||||
// Red Flags protect a train while it is stopped here; once it rolls, the flags come in.
|
||||
if (node.redFlagged) node.redFlagged = node.redFlagged.filter((t) => t !== id);
|
||||
|
||||
if (!dest) return 'held';
|
||||
|
||||
@@ -806,10 +1025,10 @@ function evaluateClearance(
|
||||
): 'clear' | 'blocked' | 'ask' {
|
||||
// A ruling already given for this train is consumed here — this is what stops the driver from
|
||||
// re-asking the same question every time it re-evaluates the train.
|
||||
const ruling = s.clock.clearanceRuling;
|
||||
if (ruling && ruling.train === id) {
|
||||
s.clock.clearanceRuling = null;
|
||||
return ruling.allow ? 'clear' : 'blocked';
|
||||
const answer = s.clock.decisionAnswer;
|
||||
if (answer && answer.kind === 'clearance' && answer.train === id) {
|
||||
s.clock.decisionAnswer = null;
|
||||
return answer.allow ? 'clear' : 'blocked';
|
||||
}
|
||||
|
||||
const node = s.division.nodes[targetIndex];
|
||||
@@ -877,24 +1096,239 @@ function evaluateClearance(
|
||||
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
||||
// where ABS Signals protects everything on the card.
|
||||
//
|
||||
// Both of these 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';
|
||||
// Red Flags used to protect a stopped train here as well. Gitea#19 replaced that rule outright
|
||||
// (Jesse, 2026-08-29): a flag is now planted on a district's Limits and holds trains coming from
|
||||
// one direction, so it never applies out on the Mainline. ABS Signals is what protects a train
|
||||
// standing on a Mainline card now, and it always did the job better.
|
||||
|
||||
// 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 (onNode?.kind === 'mainline' && onNode.absSignals) return 'blocked';
|
||||
/**
|
||||
* ABS Signals — "this is played on a mainline card to prevent collisions. If a collision would
|
||||
* normally occur, the train moving onto the card is instead held back" (RAR, Gitea#3).
|
||||
*
|
||||
* With signals in place a following train simply holds and the Superintendent has no judgment
|
||||
* call to make, which is the amendment to Gap 2's unconditional collisions. It is caught HERE
|
||||
* rather than at the entry itself, so the train never gets as far as the card.
|
||||
*
|
||||
* IT USED TO HOLD SILENTLY. A blocked clearance emits nothing on the Office and Division Point
|
||||
* paths, so the one card whose entire purpose is to stop a wreck did its job invisibly: the
|
||||
* train simply did not move, Stage after Stage, with nothing on screen saying why. The card is
|
||||
* unplayable to reason about without this line.
|
||||
*/
|
||||
if (onNode?.kind === 'mainline' && onNode.absSignals) {
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason: 'ABS Signals — held short of the train ahead',
|
||||
});
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
||||
s.clock.pendingDecision = { kind: 'clearance', train: id, occupiedBy: other };
|
||||
events.push({ type: 'clearanceRequested', trainId: id, occupiedBy: other });
|
||||
return 'ask';
|
||||
}
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
/**
|
||||
* CAN THIS TRAIN REACH THE YARD OFFICE, AND IS THE LEAD CLEAR? (Gitea#5)
|
||||
*
|
||||
* Three answers, because Jesse's ruling (2026-08-29) splits two failures his issue describes
|
||||
* separately: "if the Yard Office is not accessible in one move, you should not get the option"
|
||||
* and "cars on the tracks you use to get in result in a crash".
|
||||
*
|
||||
* - `clear` — a route exists and nothing is standing on it. Offer it; taking it is safe.
|
||||
* - `fouled` — a route exists and there are cars on it. Offer it; taking it collides.
|
||||
* - `none` — no route in one move. Do not offer it, and say why in the history.
|
||||
*
|
||||
* WALKED WITH THE ENGINE'S OWN MOVE RULES rather than a bespoke adjacency test. `exploreMoves`
|
||||
* already means exactly what the card's "in one move" means — any distance without changing
|
||||
* direction, finishing on Operational Rail (§2.4, §A.1) — so using it is what makes the code and
|
||||
* the card agree, which was the whole complaint.
|
||||
*
|
||||
* THE FOULING SIGNAL IS `couples`. The walk does not treat standing cars as obstructions: it
|
||||
* COUPLES them, because that is what a switching move does (§A.4). An arriving train is not
|
||||
* switching, so anything it would have coupled is instead something it is about to hit — the same
|
||||
* reading §8.3 already applies to the Running Track.
|
||||
*
|
||||
* The walk starts at the Office square, where a standard arrival puts the train, and leaves by the
|
||||
* way the train is already facing. Reversing is a separate Move (§A.5), so a Yard Office that can
|
||||
* only be reached by backing up is correctly "not in one move".
|
||||
*/
|
||||
/**
|
||||
* The flag comes down as it stops the train — one card, one train (Gitea#19).
|
||||
*
|
||||
* MUTATES RATHER THAN EMITTING A REDUCED EVENT, because this is the phase driver: `advance.ts`
|
||||
* changes state directly and then describes what it did, and roughly a third of the event types are
|
||||
* never reduced at all (`README.md`, and `tally.ts` on the same asymmetry). A `redFlagSpent`
|
||||
* reducer case looked right and never fired — the flag stayed up and held every train that came.
|
||||
*/
|
||||
function spendFlag(
|
||||
office: Extract<DivisionNode, { kind: 'office' }>,
|
||||
tray: CrewTray,
|
||||
side: Direction,
|
||||
events: GameEvent[],
|
||||
): 'held' {
|
||||
delete office.redFlag;
|
||||
events.push({ type: 'redFlagSpent', seat: office.seat, side, trainNumber: tray.trainNumber ?? 0 });
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason: 'Red Flags — held short of the Limits',
|
||||
});
|
||||
return 'held';
|
||||
}
|
||||
|
||||
/**
|
||||
* §Q, RED FLAGS (Gitea#19) — does a flag stop this train, and should its owner be offered one?
|
||||
*
|
||||
* Two jobs, because they are the same moment seen twice: a flag already planted stops the train
|
||||
* outright, and a train about to run into trouble is the cue to offer a flag to somebody holding
|
||||
* the card. "You can play the card normally or out of phase, but only if you need it."
|
||||
*
|
||||
* - `held` — a flag was up on the side this train is coming from. It loses this Mainline
|
||||
* Phase and the flag comes down with it: one card, one train (Jesse, 2026-08-29).
|
||||
* - `ask` — entering would collide and the district's owner holds a Red Flags card.
|
||||
* - `proceed` — neither.
|
||||
*
|
||||
* WHICH SIDE. A train running WEST arrives from the east, so `FLAG EAST` is what holds it — which
|
||||
* is the example the issue gives, and the reason the flag names a side rather than a heading.
|
||||
*/
|
||||
function redFlagStop(
|
||||
s: GameState,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
dest: Extract<DivisionNode, { kind: 'office' }>,
|
||||
events: GameEvent[],
|
||||
): 'held' | 'ask' | 'proceed' {
|
||||
const from: Direction = tray.direction === 'east' ? 'west' : 'east';
|
||||
|
||||
// The answer to a prompt already put. Consumed here so the driver cannot ask twice.
|
||||
const answer = s.clock.decisionAnswer;
|
||||
if (answer && answer.kind === 'redFlag' && answer.train === id) {
|
||||
s.clock.decisionAnswer = null;
|
||||
if (!answer.flag) return 'proceed';
|
||||
// The card was spent planting the flag; it stops this train and comes down again at once.
|
||||
return spendFlag(dest, tray, from, events);
|
||||
}
|
||||
|
||||
if (dest.redFlag === from) return spendFlag(dest, tray, from, events);
|
||||
|
||||
/**
|
||||
* "In actual cases of danger… if there is a train or cars on the track and there will be a
|
||||
* collision, then you break in with a dialog." The two ways an arrival collides are §8.3's own:
|
||||
* no free A/D track, and cars fouling the Running Track. Asked only of a player who can actually
|
||||
* answer — offering a flag to somebody holding no card is a prompt with one button.
|
||||
*/
|
||||
const owner = playerAtSeat(s, dest.seat);
|
||||
const holdsFlag = (s.decks.hands.get(owner) ?? []).some((cid) => {
|
||||
const c = s.cards.get(cid);
|
||||
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
||||
});
|
||||
if (!holdsFlag) return 'proceed';
|
||||
if (!arrivalWouldCollide(s, id, tray, dest.seat)) return 'proceed';
|
||||
|
||||
s.clock.pendingDecision = { kind: 'redFlag', train: id, seat: dest.seat, from };
|
||||
return 'ask';
|
||||
}
|
||||
|
||||
/**
|
||||
* Would this arrival collide? §8.3's two triggers, asked before the train commits.
|
||||
*
|
||||
* Deliberately a READ of the same conditions `arriveAtOffice` enforces rather than a second rule:
|
||||
* if these two ever diverge, the prompt offers a flag against a collision that will not happen, or
|
||||
* stays silent before one that will.
|
||||
*/
|
||||
function arrivalWouldCollide(s: GameState, id: TrayId, tray: CrewTray, seat: SeatIndex): boolean {
|
||||
const area = areaAtSeat(s, seat);
|
||||
const hasInterlocking = [...area.grid.values()].some((c) => c.enhancements.includes('interlocking'));
|
||||
const full = area.adOccupancy.length >= officeProfile(area.tier).adTracks;
|
||||
// Interlocking turns a full Office into a hold rather than a collision, so it is not danger.
|
||||
if (full && !hasInterlocking) return true;
|
||||
|
||||
// A coach may legally stand at the Office while its engine switches (§A.4's carve-out), so it is
|
||||
// not a hazard to the next arrival. Anything else on the Running Track is.
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
return officeCard !== undefined && officeCard.standing.some((c) => c.type !== 'coach');
|
||||
}
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — should the district's owner be asked about the Yard Office, and is there
|
||||
* anything to ask about?
|
||||
*
|
||||
* Returns `ask` only when the offer is real: a coachless train, a Yard Office card in the district,
|
||||
* and a route to it in one move. Everything else is `proceed`, which means the ordinary arrival.
|
||||
*
|
||||
* ALSO THE PLACE THE HISTORY LEARNS WHY NOT. Jesse, 2026-08-29: "make sure this is logged in
|
||||
* history — why can't move so user knows why they can't get to yard." A qualifying train that is
|
||||
* simply never offered the choice looks exactly like the feature being broken, which is how the
|
||||
* missing reachability check went unnoticed for so long.
|
||||
*/
|
||||
function yardOfficeQuestion(
|
||||
s: GameState,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
seat: SeatIndex,
|
||||
events: GameEvent[],
|
||||
): 'ask' | 'proceed' {
|
||||
// Already answered: `arriveAtOffice` consumes it. Asking again would loop the phase for ever.
|
||||
const answer = s.clock.decisionAnswer;
|
||||
if (answer && answer.kind === 'yardOffice' && answer.train === id) return 'proceed';
|
||||
|
||||
if (tray.consist.some((c) => c.type === 'coach')) return 'proceed';
|
||||
const area = areaAtSeat(s, seat);
|
||||
if (![...area.grid.values()].some((c) => c.enhancements.includes('yardOffice'))) return 'proceed';
|
||||
|
||||
const route = yardOfficeRoute(s, seat, id, tray);
|
||||
if (route.kind === 'none') {
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Office',
|
||||
reason: `the Yard Office could not be offered — ${route.why}`,
|
||||
});
|
||||
return 'proceed';
|
||||
}
|
||||
|
||||
s.clock.pendingDecision = { kind: 'yardOffice', train: id, seat };
|
||||
return 'ask';
|
||||
}
|
||||
|
||||
type YardOfficeRoute =
|
||||
| { kind: 'clear' | 'fouled'; coord: GridCoord }
|
||||
| { kind: 'none'; why: string };
|
||||
|
||||
function yardOfficeRoute(s: GameState, seat: SeatIndex, id: TrayId, tray: CrewTray): YardOfficeRoute {
|
||||
const area = areaAtSeat(s, seat);
|
||||
const target = [...area.grid.entries()].find(([, card]) => card.enhancements.includes('yardOffice'));
|
||||
if (!target) return { kind: 'none', why: 'there is no Yard Office in this district' };
|
||||
const [key] = target;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
const coord = { row: row!, col: col! };
|
||||
|
||||
const player = playerAtSeat(s, seat);
|
||||
const facing = railFacingOf(tray);
|
||||
const found = reachableDestinations(
|
||||
{
|
||||
area,
|
||||
occupancy: occupancyFor(s, player, id),
|
||||
consistSize: tray.consist.length,
|
||||
self: id,
|
||||
},
|
||||
area.officeCoord,
|
||||
facing,
|
||||
).find((d) => d.coord.row === coord.row && d.coord.col === coord.col);
|
||||
|
||||
if (!found) {
|
||||
return {
|
||||
kind: 'none',
|
||||
why: 'it cannot be reached from the Office in one move, running the way this train is facing',
|
||||
};
|
||||
}
|
||||
return { kind: found.couples.length > 0 ? 'fouled' : 'clear', coord };
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.3 — arriving at an Office. Collisions here are AUTOMATIC (Gap 2a): if the trigger holds,
|
||||
* the collision happens, with no die roll and no judgment.
|
||||
@@ -911,22 +1345,51 @@ function arriveAtOffice(
|
||||
const hasEnhancement = (key: string): boolean =>
|
||||
[...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
|
||||
// Yard Office — "an inbound train with NO COACHES that can make a single move to the yard office
|
||||
// track may arrive there, not at the Train Order Office". It sidesteps the A/D track entirely.
|
||||
const noCoaches = !tray.consist.some((c) => c.type === 'coach');
|
||||
if (noCoaches && hasEnhancement('yardOffice')) {
|
||||
for (const [key, card] of area.grid) {
|
||||
if (!card.enhancements.includes('yardOffice')) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
tray.position = { at: 'grid', seat, coord: { row: row!, col: col! } };
|
||||
/**
|
||||
* §11, THE YARD OFFICE (Gitea#5) — offered, not imposed.
|
||||
*
|
||||
* "Trains that are only freight (cabooses ok, no coaches allowed) that arrive in a player's area
|
||||
* who has the yard office card get an extra ability. On the turn (mainline phase) that the train
|
||||
* arrives the game will offer that player the option to have that train go directly to the yard
|
||||
* office card instead of the standard office. They can of course still choose to have the train
|
||||
* go to the standard office."
|
||||
*
|
||||
* WHAT THIS USED TO DO, and why all three of the rule's conditions were missing: a qualifying
|
||||
* train was TELEPORTED onto the Yard Office card. The player was never asked, no route was ever
|
||||
* computed — so the card's own printed text, "that can reach the yard office in one move", was
|
||||
* unenforced — and because nothing was walked, nothing was ever met on the way in.
|
||||
*
|
||||
* The answer comes back through `pendingDecision`, so this returns `needsClearance` and is
|
||||
* re-entered once the player has answered. `yardOfficeOffer` below is where the route is walked.
|
||||
*/
|
||||
/**
|
||||
* The answer to the offer `yardOfficeQuestion` put before the train left the Mainline card.
|
||||
* Absent — because the train has no Yard Office, or no route to it, or carries coaches — this
|
||||
* falls straight through to the ordinary arrival below.
|
||||
*/
|
||||
const answer = s.clock.decisionAnswer;
|
||||
if (answer && answer.kind === 'yardOffice' && answer.train === id) {
|
||||
s.clock.decisionAnswer = null;
|
||||
const route = answer.take ? yardOfficeRoute(s, seat, id, tray) : { kind: 'none' as const };
|
||||
if (route.kind !== 'none') {
|
||||
tray.position = { at: 'grid', seat, coord: route.coord };
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Yard Office',
|
||||
reason: 'no coaches, so it need not occupy the Train Order Office',
|
||||
});
|
||||
/**
|
||||
* Cars on the lead are a COLLISION, not a coupling — the same §8.3 rule that governs the
|
||||
* Running Track, and the third of the three things this implementation was missing. An
|
||||
* arriving train is at speed and is not expecting them (§A.4).
|
||||
*/
|
||||
if (route.kind === 'fouled') {
|
||||
collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the lead into the Yard Office', 'the Yard Office');
|
||||
}
|
||||
return 'moved';
|
||||
}
|
||||
// Declined: fall through to the standard Office, with its own capacity and collision rules.
|
||||
}
|
||||
|
||||
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
|
||||
@@ -1039,7 +1502,18 @@ function collide(
|
||||
if (n.kind !== 'mainline') continue;
|
||||
n.transits = n.transits.filter((t) => t.tray !== id);
|
||||
if (n.holding) n.holding = n.holding.filter((t) => t !== id);
|
||||
if (n.redFlagged) n.redFlagged = n.redFlagged.filter((t) => t !== id);
|
||||
}
|
||||
/**
|
||||
* AND OFF THE A/D TRACK, for exactly the same reason as the transit above.
|
||||
*
|
||||
* It never mattered while every collision happened to a train already out on the road. Gitea#3
|
||||
* adds one that can happen as a train LEAVES — a following train entering an occupied region —
|
||||
* and that train is still standing at the Office when it dies. Without this its A/D track stays
|
||||
* marked forever: the Office reads as permanently full, and every later arrival collides against
|
||||
* a train that no longer exists.
|
||||
*/
|
||||
for (const [, area] of s.officeAreas) {
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1162,17 +1636,39 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
||||
}
|
||||
|
||||
// §3.4 — every mode but competitive-and-coop-only: a Day's collisions against `maxCollisionsPerDay`
|
||||
// and the game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not
|
||||
// scaled by player count — Jesse's call, 2026-08-20: more players is more independent chances to
|
||||
// collide, not a bigger shared budget.
|
||||
if (s.config.mode === 'competitive' || s.config.mode === 'coop') {
|
||||
/**
|
||||
* §3.4 — EVERY MODE, SOLITAIRE INCLUDED: a Day's collisions against `maxCollisionsPerDay` and the
|
||||
* game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not scaled
|
||||
* by player count — Jesse's call, 2026-08-20: more players is more independent chances to collide,
|
||||
* not a bigger shared budget.
|
||||
*
|
||||
* SOLITAIRE WAS EXCLUDED UNTIL 2026-08-30 and nothing said so. The gate here read `mode ===
|
||||
* 'competitive' || mode === 'coop'`, while `SOLO_CONFIG` carried both limits and the New Game
|
||||
* dialog offered them as live settings — so a solitaire player could set a collision limit, read
|
||||
* "the game ends in a loss" beside it, and crash as often as they liked. Found reviewing that
|
||||
* screen's wording (Jesse, 2026-08-30); his ruling is that the settings should do what they say,
|
||||
* so the gate is gone rather than the controls.
|
||||
*
|
||||
* A SOLITAIRE GAME CAN THEREFORE NOW END EARLY, which no measurement in `TODO.md` was taken
|
||||
* under. At the shipped defaults (3 a Day, 5 total) it is a rare ending rather than a common one —
|
||||
* the bot averages 0.06 collisions a game — but any figure quoted from a full-length run predates
|
||||
* it.
|
||||
*/
|
||||
{
|
||||
const perDayBreach =
|
||||
s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay;
|
||||
const totalBreach =
|
||||
s.config.maxCollisionsTotal > 0 && s.collisionsTotal >= s.config.maxCollisionsTotal;
|
||||
if (perDayBreach || totalBreach) {
|
||||
s.status = 'finished';
|
||||
/**
|
||||
* NOT EXTENDABLE, AND IT DOES NOT REWRITE A RECORDED RESULT (Gitea#11).
|
||||
*
|
||||
* A breach during an EXTENDED Day ends play at once, exactly as it would during the regular
|
||||
* game — but by then the official result already exists, and a railroad declared unsafe on
|
||||
* Day 9 does not retract who won on Day 5. `freezeOfficial` is what keeps that true: it
|
||||
* writes only when `official` is still null, so assigning `outcome` here is safe.
|
||||
*/
|
||||
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
|
||||
return { events, needsInput: false };
|
||||
}
|
||||
@@ -1216,28 +1712,55 @@ function rotateSeats(s: GameState, events: GameEvent[]): void {
|
||||
|
||||
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
|
||||
const daysElapsed = s.clock.day - 1;
|
||||
if (daysElapsed < s.config.days) return false;
|
||||
/**
|
||||
* `extraDays` is Gitea#11. `config.days` is never touched by an extension — it is what the
|
||||
* OFFICIAL result is decided at — so the Day the timetable currently runs to is the sum of the
|
||||
* two. On the first ending they are equal, which is why `freezeOfficial` can record `config.days`
|
||||
* as the official Day without asking anything further.
|
||||
*/
|
||||
if (daysElapsed < s.config.days + s.extraDays) return false;
|
||||
|
||||
s.status = 'finished';
|
||||
s.outcome = decideOutcome(s);
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY — an ending the table may play past PAUSES rather than finishing.
|
||||
*
|
||||
* `freezeOfficial` (the `advance` wrapper) records the first of these as the official result, so
|
||||
* by the time a second one is reached the winner is already settled and everything here is
|
||||
* informational. The votes are cleared each time because the question is asked again at the end
|
||||
* of every extended Day: agreeing once does not agree to the rest of the game.
|
||||
*/
|
||||
if (isExtendable(s.outcome.reason)) {
|
||||
s.status = 'awaitingExtension';
|
||||
s.extensionVotes = s.players.map(() => null);
|
||||
} else {
|
||||
s.status = 'finished';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHO WON, on the evidence as it stands right now.
|
||||
*
|
||||
* Split out of `checkVictory` for Gitea#11: it is asked once per ending, and an extended game has
|
||||
* more than one. Unchanged in substance — the revenue floor, then co-op's shared achievement, then
|
||||
* the highest Revenue — it simply no longer writes to the state it is reasoning about.
|
||||
*/
|
||||
function decideOutcome(s: GameState): Outcome {
|
||||
const combined = totalRevenue(s);
|
||||
if (s.config.minCombinedRevenue > 0 && combined < s.config.minCombinedRevenue) {
|
||||
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||
return true;
|
||||
return { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||
}
|
||||
|
||||
if (s.config.mode === 'coop') {
|
||||
s.outcome = { result: 'win', winner: null, reason: 'daysElapsed' };
|
||||
return true;
|
||||
return { result: 'win', winner: null, reason: 'daysElapsed' };
|
||||
}
|
||||
|
||||
const best = Math.max(...s.players.map((p) => p.revenue));
|
||||
s.outcome = {
|
||||
return {
|
||||
result: 'win',
|
||||
winner: s.players.findIndex((p) => p.revenue === best),
|
||||
reason: 'daysElapsed',
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+296
-49
@@ -52,11 +52,14 @@ import type {
|
||||
TrayId,
|
||||
TurnoutOrientation,
|
||||
} from './state.ts';
|
||||
import { tallyEvent } from './tally.ts';
|
||||
import { createRng } from './rng.ts';
|
||||
import {
|
||||
carsOn,
|
||||
coordKey,
|
||||
cutTowards,
|
||||
decisionActor,
|
||||
officeNodeFor,
|
||||
isOperationalRail,
|
||||
playerAtSeat,
|
||||
pooled,
|
||||
@@ -77,6 +80,7 @@ import {
|
||||
facilityVariants,
|
||||
opposite,
|
||||
reachableDestinations,
|
||||
rowEndAt,
|
||||
variantsFor,
|
||||
withinLimits,
|
||||
} from './track.ts';
|
||||
@@ -122,7 +126,12 @@ function trayCoord(s: GameState, trayId: TrayId): GridCoord | null {
|
||||
}
|
||||
|
||||
/** A tray sitting on the Office card occupies an A/D track (§2.1). */
|
||||
function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy {
|
||||
/**
|
||||
* Exported for `advance.ts`'s Yard Office walk (Gitea#5), which has to ask the SAME occupancy
|
||||
* question a switching move asks — a second copy would be free to drift into a different answer
|
||||
* about which cards are free.
|
||||
*/
|
||||
export function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy {
|
||||
const area = areaOf(s, player);
|
||||
return {
|
||||
trayAt: (c) => {
|
||||
@@ -331,6 +340,15 @@ function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCo
|
||||
|
||||
if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED';
|
||||
if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED';
|
||||
/**
|
||||
* NOTHING IS ASKED ABOUT THE NEIGHBOURS, deliberately (Gitea#15).
|
||||
*
|
||||
* A turnout adds a 45° leg the card underneath did not have, and that leg may well point into an
|
||||
* occupied square with nothing to meet it. That is legal: RAR ruled (2026-08-26) that a rail may
|
||||
* stop dead against its neighbour, and an upgrade is no different from laying the piece there in
|
||||
* the first place. What must hold either way is that no train can cross the gap, which is
|
||||
* `exploreMoves`' business and is tested in `track.test.ts`.
|
||||
*/
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -417,14 +435,42 @@ export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord, tra
|
||||
}
|
||||
|
||||
/**
|
||||
* A Timetabled or Extra train card (§6.2, Gitea#6) — the one place that decides what "a train card"
|
||||
* means, so the rule, the UI's reason text and any test all ask the same question.
|
||||
* A Timetabled or Extra train card (§6.2) — the one place that decides what "a train card" means.
|
||||
*/
|
||||
export function isTrainCard(s: GameState, cardId: CardId): boolean {
|
||||
const kind = s.cards.get(cardId)?.kind.kind;
|
||||
return kind === 'timetabledTrain' || kind === 'extraTrain';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY THIS CARD CANNOT BE THROWN AWAY, or `null` if it can (§6.2, Gitea#9 superseding Gitea#6).
|
||||
*
|
||||
* The one place that answers the question, so `check`, the hand panel and the blocked "End Local
|
||||
* Operations" button all give the same reason rather than three hand-written approximations of it.
|
||||
* Gitea#6 made every train card unconditionally undiscardable; Gitea#9 narrows that:
|
||||
*
|
||||
* - a TIMETABLED train is discardable unless the `discardTimetabled` house rule is off. Jesse's
|
||||
* reasoning is about a long game whose timetable has filled up — "the stations are jammed and
|
||||
* the railroad doesn't need any more. You can toss it";
|
||||
* - an EXTRA is never discardable. It never joins the timetable, so it cannot jam it, and the
|
||||
* rule it would otherwise dodge is the hand limit.
|
||||
*
|
||||
* Returns the sentence rather than a code because it is written for a player, and the two cases
|
||||
* fail for genuinely different reasons — "not in this game" and "not ever".
|
||||
*/
|
||||
export function keepReason(s: GameState, cardId: CardId): string | null {
|
||||
const kind = s.cards.get(cardId)?.kind.kind;
|
||||
if (kind === 'extraTrain') {
|
||||
return 'An Extra is never discarded. It runs once and ends in the Salvage Yard, so it can only ' +
|
||||
'be played — hold it for as many Stages and Days as you like.';
|
||||
}
|
||||
if (kind === 'timetabledTrain' && !houseRules(s.config).discardTimetabled) {
|
||||
return 'A train card is never discarded in this game. The only way it leaves your hand is onto ' +
|
||||
'the timetable — hold it for as many Stages and Days as you like.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE AN EXTRA STARTS AND WHICH WAY IT RUNS — the one answer `check`, `execute` and the reducer
|
||||
* all use, so a placement can never be checked against one square and made on another.
|
||||
@@ -563,6 +609,21 @@ function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
|
||||
|
||||
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
||||
|
||||
/**
|
||||
* IS THIS CAR CARRYING A LOAD? A CABOOSE NEVER IS, whatever its `loaded` flag says.
|
||||
*
|
||||
* `ROLLING_STOCK_SUPPLY` mints all six cabooses as `{ loaded: 6, empty: 0 }` because §2.2's
|
||||
* "a coloured car is loaded, a white car is empty" is doing double duty there as a PIECE COUNT,
|
||||
* and a caboose has no white version — there is no such thing as an empty one to make up a train
|
||||
* from. Every other reading of `.loaded` in this file is already scoped to a coach or to a named
|
||||
* car type, so the flag's second meaning only ever escaped here.
|
||||
*
|
||||
* Reported as Gitea#8: X22 Pee-Dee, whose whole card is "may only pick up MTs", could not couple a
|
||||
* caboose at all — including the one it was made up with. Drop it and it was stranded, which made
|
||||
* the train unplayable rather than merely restricted.
|
||||
*/
|
||||
const carriesLoad = (c: RollingStock): boolean => c.loaded && c.type !== 'caboose';
|
||||
|
||||
/**
|
||||
* May this train work these freight cars on this square?
|
||||
*
|
||||
@@ -662,7 +723,7 @@ function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): b
|
||||
* 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(
|
||||
export function passengerRefusal(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
at: GridCoord,
|
||||
@@ -716,16 +777,64 @@ function passengerRefusal(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCode | null {
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — asked ABOVE the status guard, because the whole point of the
|
||||
* vote is that it is the one thing the rules will take from a game that has stopped.
|
||||
*
|
||||
* Out of turn like `mainline.clearance` below, and unlike it open to every seat at once: it is a
|
||||
* table decision rather than a ruling, so there is no actor to be.
|
||||
*/
|
||||
if (i.type === 'game.extend') {
|
||||
if (s.status !== 'awaitingExtension') return 'NOT_AWAITING_EXTENSION';
|
||||
// The intent NAMES its voter so that a save can be replayed (`intents.ts`), which makes it a
|
||||
// claim until it is checked against the seat the caller authenticated. One seat may not vote
|
||||
// for another.
|
||||
if (i.player !== player) return 'NOT_YOUR_TURN';
|
||||
if (s.extensionVotes[player] !== null) return 'ALREADY_VOTED';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (s.status !== 'active') return 'WRONG_PHASE';
|
||||
|
||||
// The clearance ruling is the one intent that arrives out of turn order: it interrupts the
|
||||
// automatic Mainline Phase and goes to the Superintendent (§8.1, fourth condition).
|
||||
if (i.type === 'mainline.clearance') {
|
||||
if (s.clock.pendingDecision === null) return 'NO_PENDING_DECISION';
|
||||
if (s.clock.pendingDecision?.kind !== 'clearance') return 'NO_PENDING_DECISION';
|
||||
if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §Q (Gitea#19) — the Red Flag prompt, the third interruption of the Mainline Phase.
|
||||
*
|
||||
* Only ever raised for a player who holds the card, so `flag: true` can always be paid for; the
|
||||
* card is checked again here because `check` is the authority and a hand can change between the
|
||||
* prompt being raised and answered.
|
||||
*/
|
||||
if (i.type === 'mainline.redFlag') {
|
||||
if (s.clock.pendingDecision?.kind !== 'redFlag') return 'NO_RED_FLAG_PROMPT';
|
||||
if (decisionActor(s) !== player) return 'NOT_YOUR_TURN';
|
||||
if (!i.flag) return null;
|
||||
const held = (s.decks.hands.get(player) ?? []).find((id) => {
|
||||
const c = s.cards.get(id);
|
||||
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
||||
});
|
||||
if (!held) return 'NO_SUCH_CARD';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — the Yard Office offer, the second interruption of the Mainline Phase.
|
||||
*
|
||||
* Goes to the district's owner rather than the Superintendent, which is the whole reason
|
||||
* `pendingDecision` became a union. `decisionActor` is the single place that mapping lives.
|
||||
*/
|
||||
if (i.type === 'mainline.yardOffice') {
|
||||
if (s.clock.pendingDecision?.kind !== 'yardOffice') return 'NO_YARD_OFFICE_OFFER';
|
||||
if (decisionActor(s) !== player) return 'NOT_YOUR_TURN';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isActor(s, player)) return 'NOT_YOUR_TURN';
|
||||
|
||||
switch (i.type) {
|
||||
@@ -777,7 +886,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
*/
|
||||
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
||||
if (rules.pickUpEmptiesOnly && fresh.some(carriesLoad)) return 'EMPTIES_ONLY';
|
||||
const freight = fresh.filter(isFreight).length;
|
||||
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
||||
}
|
||||
@@ -881,30 +990,29 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.2, AS RULED BY JESSE (Gitea#6): A TRAIN CARD MAY NOT BE DISCARDED. EVER.
|
||||
* §6.2 — WHICH TRAIN CARDS MAY BE THROWN AWAY (Gitea#9, superseding Gitea#6).
|
||||
*
|
||||
* It may be held for as many Stages and Days as the player likes — the hand limit is the only
|
||||
* pressure on it — but it never goes onto a Department pile. The consequence is the point of the
|
||||
* rule and needs no machinery of its own: a player holding four train cards has nothing
|
||||
* discardable, and `draw.end` already refuses while the hand is over the limit, so the only way
|
||||
* to conclude the turn is to PLAY one. Playing a train card is unconditionally legal (see
|
||||
* `card.play`'s `timetabledTrain` case, which refuses only a board placement), so that corner
|
||||
* can never lock a player in.
|
||||
* `keepReason` holds the rule; this asks it. A Timetabled train is discardable unless the
|
||||
* `discardTimetabled` house rule is off, and an Extra never is.
|
||||
*
|
||||
* Extras count. They are trains — Jesse's ruling in the same breath — even though an Extra runs
|
||||
* once and ends in the Salvage Yard while a Timetabled card joins the timetable for the rest of
|
||||
* the game.
|
||||
* WHERE THE DISCARD GOES IS THE OTHER HALF OF THE RULING. "If someone else wants to pick it up,
|
||||
* they are more than able to" — a discard goes face-up on a Department pile, which is exactly
|
||||
* where a rival can draw it from, so the second half needed no machinery at all.
|
||||
*
|
||||
* `legal.ts` enumerates candidates and filters them through here, so the discard option simply
|
||||
* stops being offered for these cards; the bot needs no separate rule and already reaches for
|
||||
* `card.play` before it reaches for a discard.
|
||||
* The corner Gitea#6 created still exists when the setting is off, and is still deliberate: a
|
||||
* player holding four undiscardable trains has one way forward, which is to PLAY one. `draw.end`
|
||||
* refuses while the hand is over the limit, and playing a train card is unconditionally legal
|
||||
* (`card.play`'s `timetabledTrain` case refuses only a board placement), so it can never lock.
|
||||
*
|
||||
* `legal.ts` enumerates candidates and filters them through here, so an undiscardable card
|
||||
* simply stops being offered; the bot needs no separate rule.
|
||||
*/
|
||||
case 'card.discard': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
const hand = s.decks.hands.get(player) ?? [];
|
||||
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
||||
if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY';
|
||||
if (isTrainCard(s, i.cardId)) return 'TRAINS_ARE_NEVER_DISCARDED';
|
||||
if (keepReason(s, i.cardId) !== null) return 'TRAINS_ARE_NEVER_DISCARDED';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -920,7 +1028,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
||||
const on = node.modifiers ?? [];
|
||||
if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN';
|
||||
if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
|
||||
if (rule.gradeOnly && node.card !== 'heavyGrade') return 'NOT_A_GRADE';
|
||||
if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
|
||||
// "Not while a train is on it" — realigning under a moving train is exactly the situation the
|
||||
// restriction exists to prevent. A train standing in the Interchange's yard counts: it is on
|
||||
@@ -936,14 +1044,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
// "A STOPPED train is prevented from being hit" — it protects a train that is standing on a
|
||||
// Mainline card, which is the only place a rear-ender can happen.
|
||||
if (tray.position.at !== 'mainline') return 'NO_PLACEMENT';
|
||||
const node = s.division.nodes[tray.position.index];
|
||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
||||
if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN';
|
||||
// §Q (Gitea#19) — a flag goes on your OWN Limits. There is no target train to name and no
|
||||
// placement to find: the district is yours, and the only question is which side.
|
||||
if (officeNodeFor(s, seatOf(s, player))?.redFlag === i.side) return 'ALREADY_FLAGGED';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1089,7 +1192,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
// This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
|
||||
// Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
|
||||
// no caboose. Fewer is allowed; more, or of the wrong category, is not.
|
||||
return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR';
|
||||
return acceptsCar(tray, i.carType, i.loaded, s.yards.divisionYard) ? null : 'NO_SUITABLE_CAR';
|
||||
}
|
||||
|
||||
case 'newTrain.secondSection': {
|
||||
@@ -1428,7 +1531,7 @@ export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, rev
|
||||
const facing = facingPort(s, trayId);
|
||||
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
|
||||
const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard();
|
||||
return cutTowards(card, carsOn(card), exit);
|
||||
return cutTowards(card, carsOn(card), rowEndAt(card, exit));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1480,6 +1583,33 @@ export function movesFor(
|
||||
|
||||
function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
switch (i.type) {
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the vote, and what it settles.
|
||||
*
|
||||
* Decided HERE rather than in `reduce` because the answer depends on the votes as they stand
|
||||
* BEFORE this one lands, and `execute` is the half that still sees that. Three outcomes:
|
||||
*
|
||||
* - a refusal ends it immediately. Unanimity means one "no" is decisive, so nobody is made to
|
||||
* wait on a player who has already said no (Jesse's call, 2026-08-28);
|
||||
* - the last outstanding "yes" grants the Day — ONE Day, and the question is put again at the
|
||||
* end of it;
|
||||
* - anything else is just a vote recorded, and the table waits.
|
||||
*/
|
||||
case 'game.extend': {
|
||||
const vote: GameEvent = { type: 'extensionVoted', player, agree: i.agree };
|
||||
if (!i.agree) return [vote, { type: 'playConcluded', declinedBy: player }];
|
||||
const after = s.extensionVotes.map((v, p) => (p === player ? true : v));
|
||||
return after.every((v) => v === true)
|
||||
? [vote, { type: 'dayExtended', day: s.config.days + s.extraDays + 1 }]
|
||||
: [vote];
|
||||
}
|
||||
|
||||
case 'mainline.yardOffice': {
|
||||
const pending = s.clock.pendingDecision;
|
||||
const trainId = pending?.kind === 'yardOffice' ? pending.train : '';
|
||||
return [{ type: 'yardOfficeRuled', player, trainId, take: i.take }];
|
||||
}
|
||||
|
||||
case 'localOps.choose':
|
||||
return [{ type: 'localOpsOptionChosen', player, option: i.option }];
|
||||
|
||||
@@ -1546,14 +1676,16 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
*/
|
||||
const grid = areaOf(s, player).grid;
|
||||
const startCard = grid.get(coordKey(from)) ?? emptyCard();
|
||||
const startCut = cutTowards(startCard, carsOn(startCard), exitPort);
|
||||
const startCut = cutTowards(startCard, carsOn(startCard), rowEndAt(startCard, exitPort));
|
||||
const lifted = [
|
||||
...(startCut.length > 0 ? [from] : []),
|
||||
...dest.path.map((step) => step.coord),
|
||||
i.to,
|
||||
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
||||
const sides = standingSides(startCard, carsOn(startCard));
|
||||
const stayed = exitPort === 'e' ? sides.west : exitPort === 'w' ? sides.east : [];
|
||||
// The OTHER end's cut, which stays behind — so it is the other end of the row, not the
|
||||
// other port. A 45° leg is an end of the row too (`rowEndAt`, Gitea#17).
|
||||
const stayed = rowEndAt(startCard, exitPort) === 'e' ? sides.west : sides.east;
|
||||
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
|
||||
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
|
||||
// up, they couple behind. Which end they land on is the whole point of a run-around: it
|
||||
@@ -1711,10 +1843,25 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
];
|
||||
}
|
||||
|
||||
case 'maneuver.redFlags': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
const index = tray.position.at === 'mainline' ? tray.position.index : -1;
|
||||
return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }];
|
||||
case 'maneuver.redFlags':
|
||||
return [{ type: 'redFlagsSet', player, cardId: i.cardId, seat: seatOf(s, player), side: i.side }];
|
||||
|
||||
case 'mainline.redFlag': {
|
||||
const pending = s.clock.pendingDecision;
|
||||
const trainId = pending?.kind === 'redFlag' ? pending.train : '';
|
||||
const seat = pending?.kind === 'redFlag' ? pending.seat : 0;
|
||||
const side = pending?.kind === 'redFlag' ? pending.from : 'east';
|
||||
if (!i.flag) return [{ type: 'redFlagRuled', player, trainId, flag: false }];
|
||||
const cardId =
|
||||
i.cardId ??
|
||||
(s.decks.hands.get(player) ?? []).find((id) => {
|
||||
const c = s.cards.get(id);
|
||||
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
||||
})!;
|
||||
return [
|
||||
{ type: 'redFlagsSet', player, cardId, seat, side },
|
||||
{ type: 'redFlagRuled', player, trainId, flag: true },
|
||||
];
|
||||
}
|
||||
|
||||
case 'maneuver.flyingSwitch': {
|
||||
@@ -1888,6 +2035,37 @@ function findTimetableSlot(s: GameState, from: number): number | null {
|
||||
|
||||
export function reduce(s: GameState, e: GameEvent): void {
|
||||
switch (e.type) {
|
||||
// -- §3.3, extended play (Gitea#11)
|
||||
case 'extensionVoted':
|
||||
s.extensionVotes[e.player] = e.agree;
|
||||
break;
|
||||
|
||||
/**
|
||||
* One more Day, and the votes are wiped: agreeing once does not agree to the rest of the game.
|
||||
*
|
||||
* `config.days` is deliberately untouched. It is what the OFFICIAL result was decided at
|
||||
* (`state.ts`'s `FinalReport`), so leaving it alone is what makes "the winner is decided at the
|
||||
* original game length" a fact about the code rather than a comment on it. `outcome` is left
|
||||
* alone too — it is the last evaluation, and it is what the results screen shows while the extra
|
||||
* Day is played.
|
||||
*/
|
||||
case 'dayExtended':
|
||||
s.extraDays += 1;
|
||||
s.extensionVotes = s.players.map(() => null);
|
||||
s.status = 'active';
|
||||
break;
|
||||
|
||||
case 'playConcluded':
|
||||
s.status = 'finished';
|
||||
break;
|
||||
|
||||
// §11 (Gitea#5) — the same shape as `clearanceGiven`: clear the question, record the answer for
|
||||
// the arriving train to consume, or the driver asks again for ever.
|
||||
case 'yardOfficeRuled':
|
||||
s.clock.pendingDecision = null;
|
||||
s.clock.decisionAnswer = { kind: 'yardOffice', train: e.trainId, take: e.take };
|
||||
break;
|
||||
|
||||
case 'localOpsOptionChosen':
|
||||
turnOf(s, e.player).option = e.option;
|
||||
break;
|
||||
@@ -2112,14 +2290,23 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
}
|
||||
|
||||
case 'redFlagsSet': {
|
||||
const node = s.division.nodes[e.node];
|
||||
if (node?.kind === 'mainline') {
|
||||
node.redFlagged = [...(node.redFlagged ?? []), e.trayId];
|
||||
}
|
||||
const node = officeNodeFor(s, e.seat);
|
||||
if (node) node.redFlag = e.side;
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* §Q (Gitea#19) — `redFlagSpent` is NOT reduced, deliberately. It is emitted only by the phase
|
||||
* driver, which mutates state itself and then describes it (`advance.ts`'s `spendFlag`), so a
|
||||
* case here would be dead code that reads as the live one.
|
||||
*/
|
||||
|
||||
case 'redFlagRuled':
|
||||
s.clock.pendingDecision = null;
|
||||
s.clock.decisionAnswer = { kind: 'redFlag', train: e.trainId, flag: e.flag };
|
||||
break;
|
||||
|
||||
case 'flyingSwitch': {
|
||||
const tray = s.trays.get(e.trayId);
|
||||
const area = areaOf(s, e.player);
|
||||
@@ -2476,7 +2663,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'clearanceGiven':
|
||||
s.clock.pendingDecision = null;
|
||||
// Recorded for the asking train to consume; otherwise the driver asks again forever.
|
||||
s.clock.clearanceRuling = { train: e.trainId, allow: e.allow };
|
||||
s.clock.decisionAnswer = { kind: 'clearance', train: e.trainId, allow: e.allow };
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -2879,7 +3066,20 @@ function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||
* this. A second copy stalled the game outright: the phase believed a car could be added while
|
||||
* `check` rejected every option, so the Stage never completed.
|
||||
*/
|
||||
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
export function acceptsCar(
|
||||
tray: CrewTray,
|
||||
carType: CarType,
|
||||
/**
|
||||
* Whether the car being offered is loaded, and what the Division Yard still holds.
|
||||
*
|
||||
* Both optional so that a caller asking the SHAPE question — "does this card take a car of this
|
||||
* category at all?" — need not answer the loading question. `trainNeedingCars` asks the shape
|
||||
* question of every car in the yard; `check` asks the full one about a specific car a player has
|
||||
* named. Omitting them skips the loading rules rather than guessing at them.
|
||||
*/
|
||||
loaded?: boolean,
|
||||
yard?: readonly RollingStock[],
|
||||
): boolean {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!profile) return true;
|
||||
|
||||
@@ -2902,6 +3102,40 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
const types = profile.consist.freightTypes;
|
||||
if (adding === 'freight' && types && !types.includes(carType)) return false;
|
||||
|
||||
if (loaded === undefined) return true;
|
||||
|
||||
/**
|
||||
* X13 APPLESEED — "may drop MTs but not pick up anything", and its consist prints EMPTIES ONLY.
|
||||
*
|
||||
* `emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)" by both
|
||||
* `web/game.ts` and `sim/view.ts`, and enforced by nothing: the Appleseed could be made up with
|
||||
* loaded cars while its own card said it could not. Found while building Gitea#13, which is the
|
||||
* same rule pointing the other way, and fixed with it rather than left as the odd one out.
|
||||
*
|
||||
* A caboose is exempt. Every caboose in `ROLLING_STOCK_SUPPLY` is `loaded: true` — there is no
|
||||
* such thing as an empty one — so applying this to the caboose would bar the Appleseed from the
|
||||
* caboose its own consist calls for.
|
||||
*/
|
||||
if (profile.consist.emptiesOnly && loaded && adding !== 'caboose') return false;
|
||||
|
||||
/**
|
||||
* MUST RUN LOADED (Gitea#13) — a preference order, not a flat requirement.
|
||||
*
|
||||
* "If not loaded, then empty, and if none available, run without." So an EMPTY is refused only
|
||||
* while the yard can still supply a loaded car this train would accept; once it cannot, the empty
|
||||
* becomes legal and the train may also simply depart short. Asked of the yard rather than
|
||||
* remembered on the tray, because the yard is what the rule is about and it changes under the
|
||||
* train as other consists are built.
|
||||
*
|
||||
* The caboose is exempt for the same reason as above.
|
||||
*/
|
||||
if (profile.rules.mustRunLoaded && !loaded && adding !== 'caboose' && yard) {
|
||||
const loadedAvailable = yard.some(
|
||||
(c) => c.loaded && cat(c.type) === adding && acceptsCar(tray, c.type),
|
||||
);
|
||||
if (loadedAvailable) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2942,11 +3176,21 @@ export function isBeingMadeUp(tray: CrewTray): boolean {
|
||||
export function trainNeedingCars(s: GameState): TrayId | null {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (!isBeingMadeUp(tray)) continue;
|
||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
|
||||
// uses: a separate copy of this test stalled the game, because the phase believed a car could
|
||||
// be added while `check` rejected every option, so the Stage never ended.
|
||||
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
|
||||
/**
|
||||
* Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||
* the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
|
||||
* uses: a separate copy of this test stalled the game, because the phase believed a car could
|
||||
* be added while `check` rejected every option, so the Stage never ended.
|
||||
*
|
||||
* ASKED PER CAR, WITH ITS LOADED STATE, since Gitea#13. The shape question alone is no longer
|
||||
* the same question `check` answers: an `emptiesOnly` train looking at a yard of nothing but
|
||||
* loaded cars, or a `mustRunLoaded` train offered only empties while loaded ones remain, would
|
||||
* both be told a car was available and then refused every one of them — the very stall this
|
||||
* comment was written about.
|
||||
*/
|
||||
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type, c.loaded, s.yards.divisionYard))) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2998,6 +3242,9 @@ export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): Apply
|
||||
|
||||
const events = execute(s, player, i);
|
||||
for (const e of events) reduce(s, e);
|
||||
// Gitea#16 — the intent half of the fold; `advance` does the phase driver's half. See `tally.ts`
|
||||
// for why it cannot simply live inside `reduce`.
|
||||
for (const e of events) tallyEvent(s, e);
|
||||
return { ok: true, events };
|
||||
}
|
||||
|
||||
|
||||
+306
-168
@@ -72,6 +72,26 @@ export type TrackProfile = {
|
||||
* from `docs/Deck cards2.xlsx`, a fixed document, and stay here as the audit trail for the
|
||||
* transcription — they are not claims about what the game deals today.
|
||||
*
|
||||
* THE COUNTS BELOW NOW COME FROM `docs/Deck cards5.xlsx` (Gitea#14), which HALVES every track row
|
||||
* against sheet 2: straight 32 → 16, each curve 16 → 8, each turnout 16 → 8. Track is the only
|
||||
* section of that sheet whose numbers moved — every station, industry, modifier and train row is
|
||||
* character-for-character what sheet 2 said — so this is the whole of the deck change it asks for.
|
||||
*
|
||||
* Sheet 5 also deals the sharp curves ZERO, which is where they already were: Jesse took them out
|
||||
* for the reason below, and RAR arrived at the same number independently. Nothing to do, but worth
|
||||
* recording that the two agree rather than leaving it looking like a coincidence.
|
||||
*
|
||||
* IT LANDS ON RAR'S OWN TARGETS, which is the check that matters — the top right of sheet 5 states
|
||||
* the draw rates he is designing to. Against his denominators (start cards counted for track, only
|
||||
* the non-track deck counted for trains): he wants track 48/167 = **28.7%** and trains 22/107 =
|
||||
* **20.6%**; this deck gives 48/170 = **28.2%** and 22/110 = **20.0%**.
|
||||
*
|
||||
* THAT MATCH IS PARTLY A CANCELLATION, and whoever retunes next should know it. The engine holds
|
||||
* ~25 more office and industry cards than the sheet (doubled and tripled, below) and is missing the
|
||||
* ~33 Safety, Event, Inspection and Space-use cards sheet 5 lists, which Gitea#14 defers. The two
|
||||
* errors are opposite and nearly equal today. Build the deferred categories and they stop
|
||||
* cancelling, so the ratios have to be re-measured then rather than assumed to have held.
|
||||
*
|
||||
* It matters well beyond bookkeeping. Track competes for the draw with industry, trains and
|
||||
* enhancements, so building a district is paid for in cards you did not draw instead — and the
|
||||
* hand-of-three is the real constraint on how fast a railroad grows.
|
||||
@@ -86,9 +106,9 @@ export type TrackProfile = {
|
||||
* 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: 'right', name: 'Curved track (right)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 8, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 8, isOperationalRail: true, moveCost: 1 },
|
||||
/**
|
||||
* SHARP CURVES ARE DEALT ZERO COPIES — Jesse's call, and the same treatment as Poling.
|
||||
*
|
||||
@@ -103,8 +123,8 @@ export const TRACK_CARDS: readonly TrackProfile[] = [
|
||||
*/
|
||||
{ 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 },
|
||||
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', copiesInDeck: 8, isOperationalRail: false, moveCost: 1 },
|
||||
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 8, isOperationalRail: false, moveCost: 1 },
|
||||
];
|
||||
|
||||
/** Summed from `copiesInDeck` above, never written down — it moves whenever the deck is retuned. */
|
||||
@@ -143,29 +163,39 @@ export type OfficeProfile = {
|
||||
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
|
||||
*/
|
||||
/**
|
||||
* Office cards. Every tier's `copiesInDeck` was **doubled** against the recovered design — Q12.
|
||||
* Office cards, at `docs/Deck cards5.xlsx`'s counts exactly: Depot 4, Station 2, Terminal 1.
|
||||
*
|
||||
* Players always start at a Whistle Post, which has ONE A/D track, so a second arrival is an
|
||||
* automatic collision (§8.3, Gap 2a). Measured at the original density, 25 of 100 games never drew
|
||||
* a Depot and never escaped: they averaged **−6.0** revenue against **−0.4** for games that
|
||||
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post.
|
||||
* THE Q12 DOUBLING IS GONE (Gitea#14). Every tier used to be dealt at twice the sheet, to remove a
|
||||
* 25% chance of an unwinnable opening deal: players always start at a Whistle Post, which has ONE
|
||||
* A/D track, so a second arrival is an automatic collision (§8.3, Gap 2a), and measured at the
|
||||
* sheet's density 25 of 100 games never drew a Depot and never escaped — averaging **−6.0** revenue
|
||||
* against **−0.4** for games that upgraded at least once, with 25 of 26 collisions at a Whistle
|
||||
* Post.
|
||||
*
|
||||
* That measurement was taken against a deck with 96 track cards in it, and the failure it describes
|
||||
* does not survive the halving of track. RE-MEASURED at the sheet's counts, 100 games: **43 of 100**
|
||||
* never upgrade off a Whistle Post, up from 25 — but they average **−0.2** revenue against **+1.4**
|
||||
* for games that do upgrade, where the gap used to be −6.0 against −0.4. Collisions fell from 26 per
|
||||
* 100 games to **6**, and only 3 of those are in games that never upgraded, against 25 of 26 before.
|
||||
*
|
||||
* So staying at a Whistle Post is now common and survivable rather than rare and fatal, which is the
|
||||
* opposite of the shape Q12 was answering: with fewer trains reaching an Office, a single A/D track
|
||||
* is seldom contested. The doubling was the blunt instrument its own note called it, and at the
|
||||
* sheet's deck size it costs more than it buys — see `TRACK_CARDS` for the whole comparison and
|
||||
* `INDUSTRY_PROFILES` for the other half of the same decision.
|
||||
*
|
||||
* Upgrades are strictly sequential (Gap 3b, no skipping), so Station and Terminal are rarer than
|
||||
* their raw counts imply — Terminal needs all three cards in order. Station and Terminal were
|
||||
* doubled with Depot to keep that ladder in proportion rather than making Depot a special case.
|
||||
* their raw counts imply — Terminal needs all three cards in order.
|
||||
*
|
||||
* PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal,
|
||||
* not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and
|
||||
* dilutes every other category slightly. Revisit once the victory target is settled and freight is
|
||||
* carrying its intended share; the right answer may instead be fewer Terminals, a cheaper first
|
||||
* upgrade, or more A/D capacity at Whistle Post. The counts themselves are in the rows below, which
|
||||
* is the only place they should be read from.
|
||||
* IF THE OPENING BITES AGAIN, the fix is not to re-double this. The note it replaces already listed
|
||||
* the better options: fewer Terminals, a cheaper first upgrade, or more A/D capacity at a Whistle
|
||||
* Post. Any of those answers the collision without diluting every other category to do it.
|
||||
*/
|
||||
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
|
||||
{ tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 },
|
||||
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 8 },
|
||||
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 4 },
|
||||
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 2 },
|
||||
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 4 },
|
||||
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 2 },
|
||||
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 1 },
|
||||
];
|
||||
|
||||
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
|
||||
@@ -202,15 +232,24 @@ export type IndustryProfile = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Industry density (Gap 12). The recovered sheet lists 9 industries in a 115-card deck; the
|
||||
* prototype ran 10 in 52. At the sheet's density a game saw 1.6 Freight Facilities, freight was 10%
|
||||
* of gross revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the
|
||||
* point of the game, effectively never ran.
|
||||
* Industry density, at `docs/Deck cards5.xlsx`'s counts exactly (Gitea#14).
|
||||
*
|
||||
* Each industry's `copies` is TRIPLED against the sheet, which restores roughly the prototype's
|
||||
* ratio while preserving the sheet's proportions exactly: the outbound/inbound balance and the
|
||||
* lockout structure are unchanged, because every kind scales by the same factor. The multiplier is
|
||||
* the decision; the resulting totals are in the rows below and move with every retune.
|
||||
* THE GAP-12 TRIPLING IS GONE. The recovered sheet listed 9 industries in a 115-card deck and the
|
||||
* prototype ran 10 in 52; at that density a game saw 1.6 Freight Facilities, freight was 10% of
|
||||
* gross revenue, and `carsCoupled` fired 4 times per 100 games, so the freight loop effectively
|
||||
* never ran. Tripling every kind restored roughly the prototype's ratio.
|
||||
*
|
||||
* ALL OF THAT WAS MEASURED AGAINST A DECK WITH 96 TRACK CARDS. Sheet 5 halves the track, and the
|
||||
* tripling then works backwards: the deck keeps dealing industries while the district stays too
|
||||
* small to reach them. Measured over 300 bot games on identical seeds — 96 track with the multiplier
|
||||
* / 48 track with it / 48 track without — reefer cars set out by a crew went **49 / 0 / 39** and
|
||||
* mean revenue **−0.20 / +0.22 / +0.27**. The middle column is the tripling meeting the halved
|
||||
* deck: it wipes out the reefer chain completely. The sheet's own density is the best of the three
|
||||
* on both counts.
|
||||
*
|
||||
* The sheet's proportions were always preserved by the multiplier, since every kind scaled by the
|
||||
* same factor — so removing it changes the density and nothing else. The outbound/inbound balance
|
||||
* and the lockout structure below are the sheet's, as they always were.
|
||||
*/
|
||||
/**
|
||||
* LOCKOUTS, from the sheet's "Lockouts" column verbatim:
|
||||
@@ -233,8 +272,8 @@ export type IndustryProfile = {
|
||||
* enforced for every kind in `isLockedOut`, not repeated in each row here.
|
||||
*/
|
||||
export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
|
||||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
|
||||
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 2 },
|
||||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 2 },
|
||||
/**
|
||||
* OUTBOUND ONLY. A Refinery ships oil out and receives nothing; reported from playtesting and
|
||||
* confirmed by Jesse (v0.4.9e): "only ships out tanks, does not receive anything".
|
||||
@@ -252,9 +291,9 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
* the game with no way to raise the direction it is supposed to use half its capacity on.
|
||||
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
|
||||
*/
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
|
||||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
|
||||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 1 },
|
||||
/**
|
||||
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
|
||||
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
|
||||
@@ -267,7 +306,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
* other example. The Truck Dock (+1 inbound) and Local Small Groceries (+1 Laborer) are the two
|
||||
* that do work here.
|
||||
*/
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 1 },
|
||||
];
|
||||
|
||||
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
|
||||
@@ -395,6 +434,19 @@ export type TrainRules = {
|
||||
/** X17 Campaign, X18 Circus: a scheduled stop that does something. */
|
||||
stopEarnsPoint?: boolean;
|
||||
stopThenExpedite?: boolean;
|
||||
/**
|
||||
* MUST RUN LOADED (Gitea#13) — X17 Campaign, X18 Circus, X19 Military.
|
||||
*
|
||||
* "I've redefined some of the extra trains that they have to run full boxcars (not just any crazy
|
||||
* stuff) — military trains, circus trains, etc. If not loaded, then empty, and if none available,
|
||||
* run without." So it is a PREFERENCE ORDER enforced at make-up, not a flat requirement: a loaded
|
||||
* car of an acceptable type must be taken while one is in the Division Yard; only once none is
|
||||
* left may an empty be taken; and a train may still depart short (§8.2 already allows fewer cars
|
||||
* than the card lists).
|
||||
*
|
||||
* Distinct from `ConsistSpec.emptiesOnly`, which is the opposite rule for X13 Appleseed.
|
||||
*/
|
||||
mustRunLoaded?: 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
|
||||
@@ -453,9 +505,9 @@ export const EXTRA_TRAINS: readonly TrainProfile[] = [
|
||||
{ number: 14, isExtra: true, name: 'Fruit Growers Express', speed: 'fast', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1, freightTypes: ['reefer'] }, rules: { expedite: true, note: 'Reefers only. May pick up one extra loaded reefer.' } },
|
||||
{ number: 15, isExtra: true, name: 'Yard Xfer', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1 }, rules: {} },
|
||||
{ number: 16, isExtra: true, name: 'Light Engine Move', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 0 }, rules: { noSwitching: true, note: 'No cars at all.' } },
|
||||
{ number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, note: 'One turn at station (speeches) then expedite.' } },
|
||||
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, note: 'One turn stopped on any track (circus set-up) earns 1 point.' } },
|
||||
{ number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true } },
|
||||
{ number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, stopEarnsPoint: true, mustRunLoaded: true, note: 'One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard.' } },
|
||||
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, mustRunLoaded: true, note: 'One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded.' } },
|
||||
{ number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true, mustRunLoaded: true, note: 'Troops and materiel: runs loaded where the yard can supply it.' } },
|
||||
{ number: 20, isExtra: true, name: "Director's private car", speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 0 }, rules: { noPassengerWork: true } },
|
||||
{ number: 21, isExtra: true, name: 'Freight Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1 }, rules: {} },
|
||||
{ number: 22, isExtra: true, name: 'Pee-Dee', speed: 'slow', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 1 }, rules: { pickUpEmptiesOnly: true, note: 'Per-diem train. May only pick up MTs.' } },
|
||||
@@ -504,23 +556,35 @@ export type MainlineKind =
|
||||
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange';
|
||||
|
||||
/**
|
||||
* Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade prints
|
||||
* "G" — no number at all, plus "Player sets orientation", **which the game deliberately does not do**
|
||||
* (see `gradeReduction` below, and implications.md §10 Q11).
|
||||
* THE PRINTED SPEEDS ARE GRAPHICS. RAR, 2026-08-26 (Gitea#3): "please ignore the speed signs I put
|
||||
* on the cards — those are nothing but scene-setting graphics that mimic the speed you are
|
||||
* travelling. It's just ambiance, nothing more."
|
||||
*
|
||||
* WHAT THESE NUMBERS MEAN IS NOT YET SETTLED — see implications.md §10 Q2. Transcribed as data so
|
||||
* the answer can be applied without re-reading the cards.
|
||||
* They used to decide everything: a `MainlineSpeed` of 60 meant one Stage and a 30 meant two, plus
|
||||
* one more for a Slow train. Both rules are gone. **What crosses a card is REGIONS** — the boxes
|
||||
* printed on it — one per Stage, and where a train STARTS decides how many it has left to run.
|
||||
*/
|
||||
export type MainlineSpeed =
|
||||
| { kind: 'uniform'; value: number }
|
||||
| { kind: 'byTrainType'; passenger: number; freight: number }
|
||||
| { kind: 'grade' };
|
||||
|
||||
export type MainlineProfile = {
|
||||
kind: MainlineKind;
|
||||
name: string;
|
||||
speed: MainlineSpeed;
|
||||
/** Double Track and Uncontrolled Siding: "Trains may pass". */
|
||||
/** Regions printed on the card. A train advances one per Stage, so a full run costs `regions`. */
|
||||
regions: number;
|
||||
/**
|
||||
* The region an ordinary train enters at. Zero on nearly everything — but the Uncontrolled Siding
|
||||
* and the Interchange print a back region that is a siding or a holding spur rather than part of
|
||||
* the road, so a train running straight through starts past it and crosses in one Stage.
|
||||
*/
|
||||
defaultStart: number;
|
||||
/**
|
||||
* Cards that print a FAST and a SLOW start, and the region each begins at. "Some cards say fast /
|
||||
* slow. This is an indication that if on the train card, the train is listed as fast or slow,
|
||||
* that's starting position / how many stages it takes to traverse the card. Fast / Slow does not
|
||||
* apply to every card — just those that say fast / slow on them. Currently this is only hilly."
|
||||
*
|
||||
* So the train's rating is read HERE and nowhere else. It used to add a Stage to every card.
|
||||
*/
|
||||
speedStarts?: { fast: number; slow: number };
|
||||
/** Double Track: "Trains may pass". */
|
||||
trainsMayPass: boolean;
|
||||
/** Interchange: "Sort cars in new order". */
|
||||
sortsCars: boolean;
|
||||
@@ -529,30 +593,56 @@ export type MainlineProfile = {
|
||||
};
|
||||
|
||||
export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
||||
{ kind: 'plains', name: 'Plains', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'curves', name: 'Curves', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'hilly', name: 'Hilly', speed: { kind: 'byTrainType', passenger: 60, freight: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['passenger', 'freight'] },
|
||||
{ kind: 'plains', name: 'Plains', regions: 1, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'curves', name: 'Curves', regions: 2, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
/**
|
||||
* `entryPoints` is TRANSCRIBED, NOT READ — nothing anywhere reads this field on any profile, and
|
||||
* the printed start positions are not modelled: `crossingStages` counts Stages instead. Recorded
|
||||
* here because implications.md §6 describes the card as having FIVE distinct starts (plain,
|
||||
* brakemen, airbrakes, plain, helpers) against the four listed, and that discrepancy should be
|
||||
* settled against `Mainline Cards.pdf` if the starts are ever implemented — not quietly "fixed"
|
||||
* now, when nothing depends on it either way.
|
||||
* THE ONLY CARD THAT READS A TRAIN'S FAST/SLOW RATING. A fast train starts in the second region
|
||||
* and is across in one Stage; a slow one starts at the beginning and takes two.
|
||||
*
|
||||
* It used to read the CONSIST instead — any coach aboard made the train "passenger" for this card
|
||||
* — off the printed P60/F30. RAR corrected that directly: "I notice that you are basing stages in
|
||||
* mainline cards off coach/non-coach. Actually, all trains are rated as FAST and SLOW."
|
||||
*/
|
||||
{ kind: 'heavyGrade', name: 'Heavy Grade', speed: { kind: 'grade' }, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] },
|
||||
{ kind: 'doubleTrack', name: 'Double Track', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['noPass', 'passingTrains'] },
|
||||
{ kind: 'tunnel', name: 'Tunnel', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'trestle', name: 'Trestle', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'hilly', name: 'Hilly', regions: 2, defaultStart: 0, speedStarts: { fast: 1, slow: 0 }, trainsMayPass: false, sortsCars: false, entryPoints: ['fast', 'slow'] },
|
||||
/**
|
||||
* RENAMED FROM "Yard" after play. The card is unchanged — same 60, same "sort cars in new
|
||||
* order", same entry points, same art — but "Yard" collided with the Division Yard, the
|
||||
* Classification Yard, the Salvage Yard, the Yard Office and the Small Yard, none of which are
|
||||
* this. Its own key is renamed with it, so the two never drift apart. Those OTHER yards are
|
||||
* deliberately left alone: they are different things that merely shared a word.
|
||||
* THREE REGIONS, AND THE MODIFIERS MOVE THE START RATHER THAN CUTTING THE TIME — which comes to
|
||||
* the same number of Stages and is how the card is actually printed and played. "If you play the
|
||||
* home deck card 'helpers' against the mainline card heavy grade, it remains there the rest of the
|
||||
* game and helps all trains going up hill by starting 1 region easier — so 2 to traverse, not 3.
|
||||
* Other cards help the other direction, similar idea. Airbrakes is an upgrade from brakemen (which
|
||||
* must be played first)."
|
||||
*
|
||||
* So: Helpers moves an UPHILL train up one region; Brakeman moves a DOWNHILL train up one, and
|
||||
* Airbrakes another on top of it. A fully-equipped grade is one Stage downhill and two up.
|
||||
*/
|
||||
{ kind: 'interchange', name: 'Interchange', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
|
||||
{ kind: 'heavyGrade', name: 'Heavy Grade', regions: 3, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] },
|
||||
{ kind: 'doubleTrack', name: 'Double Track', regions: 1, defaultStart: 0, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] },
|
||||
/**
|
||||
* TWO REGIONS, AND THE BACK ONE IS THE SIDING. A train with the card to itself starts past it and
|
||||
* crosses in one Stage. "Uncontrolled siding: if a train already exists when you arrive, you go in
|
||||
* the second stage back (you are in the siding and are one behind the other train). This prevents
|
||||
* a collision — since you are not in same exact location."
|
||||
*
|
||||
* `trainsMayPass` is FALSE here, and used to be true. Two trains fit, but not by passing: the
|
||||
* second one takes the siding and sits a region behind, which is what keeps them apart. Leaving it
|
||||
* true skipped the collision test altogether and made the siding do nothing at all.
|
||||
*/
|
||||
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', regions: 2, defaultStart: 1, trainsMayPass: false, sortsCars: false, entryPoints: ['through', 'siding'] },
|
||||
{ kind: 'tunnel', name: 'Tunnel', regions: 2, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'trestle', name: 'Trestle', regions: 1, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
/**
|
||||
* RENAMED FROM "Yard" after play. The card is unchanged — same "sort cars in new order", same art
|
||||
* — but "Yard" collided with the Division Yard, the Classification Yard, the Salvage Yard, the
|
||||
* Yard Office and the Small Yard, none of which are this. Its own key is renamed with it, so the
|
||||
* two never drift apart. Those OTHER yards are deliberately left alone: they are different things
|
||||
* that merely shared a word.
|
||||
*
|
||||
* TWO REGIONS, the back one a holding spur, exactly as the Uncontrolled Siding: "interchange has
|
||||
* new extras show up in second region (like uncontrolled siding)", and earlier, "Plains is 1 stage
|
||||
* for ALL trains. So are interlockings, with a second stage for incoming extras to hold at." A
|
||||
* train running through crosses in one Stage; an Extra beginning its run here starts at the back.
|
||||
*/
|
||||
{ kind: 'interchange', name: 'Interchange', regions: 2, defaultStart: 1, trainsMayPass: false, sortsCars: true, entryPoints: ['through', 'extraStart'] },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -579,44 +669,63 @@ export const MAINLINE_DECK: readonly MainlineKind[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* How many Stages a train needs to cross a Mainline card.
|
||||
* WHERE A TRAIN ENTERS A MAINLINE CARD, and therefore how long it takes to cross (Gitea#3).
|
||||
*
|
||||
* Q1 — the printed 60/30 are miles per hour expressed as crossing time: a 60 card takes one Stage,
|
||||
* a 30 card takes two. The cells drawn on the cards are decoration.
|
||||
* Q2 — a Slow train adds one Stage to every card.
|
||||
* "Regions shown on cards indicate how many stages it takes to cross. Plains is 1. Double track is
|
||||
* 1, tunnel is 2, curves is 2, heavy grade is 3 unless you have help." A train advances one region
|
||||
* per Stage, so the whole of crossing time is `regions - startRegion`.
|
||||
*
|
||||
* Hilly prints P60/F30, so it reads the consist rather than the speed class: a train carrying any
|
||||
* coach is "passenger" for this purpose.
|
||||
* FOUR THINGS MOVE THE START, and nothing else does:
|
||||
*
|
||||
* 1. the card's own `defaultStart` — the Uncontrolled Siding and the Interchange print a back
|
||||
* region that is not part of the road, so a train running through begins past it;
|
||||
* 2. `speedStarts`, on a card that prints a Fast and a Slow start. Only Hilly does;
|
||||
* 3. the permanent Heavy Grade modifiers, which move a train one region up the hill each;
|
||||
* 4. `takesSiding` / `startsAtBack`, the two occupancy cases below.
|
||||
*
|
||||
* WHAT NO LONGER MOVES IT: the printed mph, which is now scenery, and a train's Fast/Slow rating on
|
||||
* any card but Hilly. That rating used to add a Stage to EVERY card, which is what made a Slow train
|
||||
* cross Double Track in two Stages and produced the report this issue opened with.
|
||||
*/
|
||||
export function crossingStages(
|
||||
kind: MainlineKind,
|
||||
trainSpeed: TrainSpeed,
|
||||
carriesPassengers: boolean,
|
||||
modifiers: readonly string[] = [],
|
||||
direction: Direction = 'east',
|
||||
gradeUp: Direction = 'east',
|
||||
): number {
|
||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
||||
export type MainlineEntry = {
|
||||
trainSpeed: TrainSpeed;
|
||||
direction: Direction;
|
||||
gradeUp: Direction;
|
||||
modifiers: readonly string[];
|
||||
/**
|
||||
* The Uncontrolled Siding with a train already on it: this one takes the siding and sits a region
|
||||
* behind, which is what keeps them out of the same place. Also the Interchange, where an Extra
|
||||
* beginning its run starts in the holding region rather than on the road.
|
||||
*/
|
||||
startsAtBack?: boolean;
|
||||
};
|
||||
|
||||
let mph: number;
|
||||
switch (profile.speed.kind) {
|
||||
case 'uniform':
|
||||
mph = profile.speed.value;
|
||||
break;
|
||||
case 'byTrainType':
|
||||
mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight;
|
||||
break;
|
||||
case 'grade':
|
||||
// Heavy Grade has no printed number; the modifier cards are what improve it, so it is a 30
|
||||
// until one is placed.
|
||||
mph = 30;
|
||||
break;
|
||||
export function startRegion(kind: MainlineKind, entry: MainlineEntry): number {
|
||||
const profile = mainlineProfile(kind);
|
||||
if (entry.startsAtBack) return 0;
|
||||
|
||||
const base = profile.speedStarts
|
||||
? profile.speedStarts[entry.trainSpeed]
|
||||
: profile.defaultStart;
|
||||
|
||||
const climbing = entry.direction === entry.gradeUp;
|
||||
let help = 0;
|
||||
if (kind === 'heavyGrade') {
|
||||
if (climbing) {
|
||||
if (entry.modifiers.includes('helpers')) help++;
|
||||
} else {
|
||||
// Airbrakes is an upgrade on Brakeman and cannot be played without it, so this counts both.
|
||||
if (entry.modifiers.includes('brakeman')) help++;
|
||||
if (entry.modifiers.includes('airbrakes')) help++;
|
||||
}
|
||||
}
|
||||
// Never past the last region: a card always costs at least one Stage to cross.
|
||||
return Math.min(profile.regions - 1, base + help);
|
||||
}
|
||||
|
||||
const base = mph >= 60 ? 1 : 2;
|
||||
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
|
||||
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
|
||||
/** How many Stages a train needs to cross a Mainline card — the regions it has left to run. */
|
||||
export function crossingStages(kind: MainlineKind, entry: MainlineEntry): number {
|
||||
return mainlineProfile(kind).regions - startRegion(kind, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -634,35 +743,50 @@ export function crossingStages(
|
||||
export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'east'): string {
|
||||
const p = mainlineProfile(kind);
|
||||
const stages = (n: number): string => `${n} Stage${n === 1 ? '' : 's'}`;
|
||||
const run = (entry: Partial<MainlineEntry>): number =>
|
||||
crossingStages(kind, { trainSpeed: 'fast', direction: 'east', gradeUp, modifiers: [], ...entry });
|
||||
const parts: string[] = [];
|
||||
|
||||
if (p.speed.kind === 'byTrainType') {
|
||||
// Hilly. The split is by CONSIST, not by the train's speed class: anything with a coach on it
|
||||
// takes the passenger figure.
|
||||
parts.push(`${p.regions} region${p.regions === 1 ? '' : 's'} — one Stage each.`);
|
||||
|
||||
if (p.speedStarts) {
|
||||
parts.push(
|
||||
`P${p.speed.passenger} / F${p.speed.freight} — a train carrying ANY coach crosses as a ` +
|
||||
`${p.speed.passenger} (${stages(crossingStages(kind, 'fast', true))} for a fast train), and a ` +
|
||||
`freight-only train as a ${p.speed.freight} (${stages(crossingStages(kind, 'fast', false))}). ` +
|
||||
`A slow train adds one Stage either way.`,
|
||||
`This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses ` +
|
||||
`in ${stages(run({ trainSpeed: 'fast' }))}, a slow one in ${stages(run({ trainSpeed: 'slow' }))}. ` +
|
||||
`No other card cares which it is.`,
|
||||
);
|
||||
} else if (p.speed.kind === 'grade') {
|
||||
} else if (kind === 'heavyGrade') {
|
||||
parts.push(
|
||||
`A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. It crosses as a 30 — ` +
|
||||
`${stages(crossingStages(kind, 'fast', false, [], gradeUp, gradeUp))} for a fast train, and one ` +
|
||||
`more for a slow one. Brakeman and Airbrakes each take a Stage off a train running DOWNHILL; ` +
|
||||
`Helpers takes one off a train running UPHILL. Never below one Stage.`,
|
||||
`A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. ` +
|
||||
`${stages(run({ direction: gradeUp }))} to climb it and ${stages(run({ direction: gradeUp === 'east' ? 'west' : 'east' }))} to run down, ` +
|
||||
`before help. Helpers start an UPHILL train a region further on; Brakeman does the same ` +
|
||||
`DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. ` +
|
||||
`Never less than one Stage.`,
|
||||
);
|
||||
} else if (p.defaultStart > 0) {
|
||||
parts.push(
|
||||
`A train with the card to itself starts past the back region and is across in ` +
|
||||
`${stages(run({}))}.`,
|
||||
);
|
||||
} else {
|
||||
parts.push(`${stages(run({}))} for every train — the printed speed is scenery.`);
|
||||
}
|
||||
|
||||
if (kind === 'uncontrolledSiding') {
|
||||
parts.push(
|
||||
`${p.speed.value} — ${stages(crossingStages(kind, 'fast', false))} for a fast train, ` +
|
||||
`${stages(crossingStages(kind, 'slow', false))} for a slow one.`,
|
||||
'UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region ' +
|
||||
'behind it. You are not in the same place, so you do not run into it; it costs you the ' +
|
||||
'extra Stage instead.',
|
||||
);
|
||||
}
|
||||
if (kind === 'interchange') {
|
||||
parts.push('An Extra beginning its run here starts in the back region and takes the extra Stage.');
|
||||
}
|
||||
|
||||
if (p.trainsMayPass) {
|
||||
parts.push(
|
||||
'TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held ' +
|
||||
'behind a slower one. Only this and the Double Track allow it.',
|
||||
'behind a slower one.',
|
||||
);
|
||||
} else {
|
||||
parts.push('One train at a time — anything following has to wait for it to clear.');
|
||||
@@ -672,43 +796,7 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH WAY THE GRADE CLIMBS, AND WHY NO PLAYER CHOOSES IT.
|
||||
*
|
||||
* Q11, answered from the card: Heavy Grade prints "(Up)" and "Player sets orientation", so the climb
|
||||
* is a property of the PLACED CARD rather than a compass constant. `gradeUp` is the direction a train
|
||||
* is travelling when it goes UPHILL; a train heading the other way is descending.
|
||||
*
|
||||
* **The second half of that print is deliberately overridden.** No player sets it — `setup.ts` rolls
|
||||
* it from the seed. Settled v0.5.0 and re-confirmed 2026-08-23 after the question was raised again:
|
||||
* a Heavy Grade always sits BETWEEN two districts (or beyond an end Division Point next to one),
|
||||
* never inside one player's own, so there is no player with a fair claim to the choice — and the
|
||||
* choice is not cosmetic, because it decides which of the three modifiers below can ever pay and
|
||||
* therefore which direction of traffic is favoured, permanently. Giving it to the Superintendent was
|
||||
* considered and rejected in that re-examination: the office rotates every three Stages, the
|
||||
* advantage does not. Full reasoning in implications.md §10 Q11.
|
||||
*
|
||||
* Each applicable card takes a Stage off, never below one: a train cannot cross in no time.
|
||||
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces
|
||||
* (`MAINLINE_MODIFIER_RULES`, `requiresOnCard`).
|
||||
*/
|
||||
function gradeReduction(
|
||||
profile: MainlineProfile,
|
||||
modifiers: readonly string[],
|
||||
direction: Direction,
|
||||
gradeUp: Direction,
|
||||
): number {
|
||||
if (profile.speed.kind !== 'grade') return 0;
|
||||
const downhill = direction !== gradeUp;
|
||||
let n = 0;
|
||||
if (downhill) {
|
||||
if (modifiers.includes('brakeman')) n++;
|
||||
if (modifiers.includes('airbrakes')) n++;
|
||||
} else if (modifiers.includes('helpers')) {
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
|
||||
export function mainlineProfile(kind: MainlineKind): MainlineProfile {
|
||||
const p = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||
@@ -769,7 +857,8 @@ export const SPACE_USE_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'flopHouse', name: 'Flop house', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
|
||||
{ key: 'watertower', name: 'Watertower', copies: 1, placement: 'adjacent to any straight, turnout on Running Track', effect: 'Burns tablespace.' },
|
||||
{ key: 'hoboJungle', name: 'Hobo Jungle', copies: 1, placement: 'adjacent to any straight, turnout, Limit on Running Track', effect: 'Burns tablespace. Vandalism can loot a boxcar passing it.' },
|
||||
{ key: 'sectionHouse', name: 'Section House', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
|
||||
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26), the same treatment as the ladder.
|
||||
{ key: 'sectionHouse', name: 'Section House', copies: 0, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
|
||||
{ key: 'cityBlocks', name: 'City blocks', copies: 4, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
|
||||
{ key: 'engineShops', name: 'Engine Shops', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
|
||||
{ key: 'tenderloin', name: 'Tenderloin District', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
|
||||
@@ -876,16 +965,32 @@ export function enhancementRule(key: string): EnhancementRule | null {
|
||||
}
|
||||
|
||||
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
|
||||
{ key: 'interlocking', name: 'Interlocking', copies: 1, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
||||
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). It answers Derail, which is itself an
|
||||
// Event held out until built, so at zero it defends against nothing that can be dealt anyway.
|
||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
|
||||
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
|
||||
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
|
||||
{ key: 'waterColumn', name: 'Water column', copies: 2, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
|
||||
{ key: 'waterColumn', name: 'Water column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
|
||||
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
|
||||
{ key: 'telegraph', name: 'Telegraph', copies: 3, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' },
|
||||
{ key: 'telephone', name: 'Telephone', copies: 2, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' },
|
||||
{ key: 'radio', name: 'Radio', copies: 2, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' },
|
||||
{ key: 'absSignals', name: 'ABS Signals', copies: 2, placement: 'any Mainline card', effect: 'Trains on this card will not rear-end each other; they stop short of a collision.' },
|
||||
/**
|
||||
* THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment
|
||||
* Poling and the sharp curves already get, and for the same reason.
|
||||
*
|
||||
* `docs/Deck cards5.xlsx` does not list Telegraph, Telephone or Radio at any count, and **Jesse
|
||||
* confirmed (2026-08-26) that the removal is deliberate, not a row that failed to carry across**
|
||||
* from sheet 2. So no copy is dealt, which is what the sheet asks for.
|
||||
*
|
||||
* The rows and `ENHANCEMENT_RULES`' `dispatchBonus` chain stay exactly where they are. The rule
|
||||
* is implemented and tested — `advance.ts` reads the ladder when the Superintendent dispatches
|
||||
* facing trains, best device first — and deleting working machinery to express a count of zero
|
||||
* would throw away the only record of how it worked. At zero copies the code is unreachable: no
|
||||
* card is ever dealt, so nothing ever places one, so the bonus never applies.
|
||||
*/
|
||||
{ key: 'telegraph', name: 'Telegraph', copies: 0, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' },
|
||||
{ key: 'telephone', name: 'Telephone', copies: 0, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' },
|
||||
{ key: 'radio', name: 'Radio', copies: 0, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' },
|
||||
{ key: 'absSignals', name: 'ABS Signals', copies: 1, placement: 'any Mainline card', effect: 'Trains on this card will not rear-end each other; they stop short of a collision.' },
|
||||
];
|
||||
|
||||
export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
|
||||
@@ -893,7 +998,8 @@ export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'airbrakes', name: 'Airbrakes', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill. Brakeman must be in effect.' },
|
||||
{ key: 'helpers', name: 'Helpers', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage uphill.' },
|
||||
{ key: 'realignment', name: 'Realignment', copies: 2, placement: 'a Mainline card', effect: 'Convert one Mainline type to another. Not while a train is on it.' },
|
||||
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.', answers: 'Derail' },
|
||||
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26); see the Enhancement of the same name.
|
||||
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.', answers: 'Derail' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -904,8 +1010,9 @@ export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
|
||||
export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', copies: 1 };
|
||||
|
||||
export const MANEUVER_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'redFlags', name: 'Red Flags', copies: 5, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' },
|
||||
{ key: 'flyingSwitch', name: 'Flying Switch', copies: 1, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' },
|
||||
{ key: 'redFlags', name: 'Red Flags', copies: 3, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' },
|
||||
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). The reducer stays; nothing can reach it.
|
||||
{ key: 'flyingSwitch', name: 'Flying Switch', copies: 0, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' },
|
||||
// POLING IS OUT OF THE DECK, at 0 copies rather than deleted.
|
||||
//
|
||||
// It is the one card whose effect the source records as "TBD", so there is nothing to implement
|
||||
@@ -922,7 +1029,8 @@ export const ACTION_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'perDiemInventory', name: 'Per Diem inventory', copies: 1, placement: 'another player', effect: 'Lose one point per 2 empty cars on Secondary Tracks.' },
|
||||
{ key: 'demurrageCharge', name: 'Demurrage charge', copies: 1, placement: 'another player', effect: 'Lose one point per 2 loaded freight cars on Secondary Tracks.' },
|
||||
{ key: 'customerComplaints', name: 'Customer complaints', copies: 1, placement: 'another player', effect: 'Lose one point per 2 coaches in loading boxes.' },
|
||||
{ key: 'vandalism', name: 'Vandalism', copies: 1, placement: 'another player', effect: 'A train passing a Hobo Jungle has a boxcar looted (converted to empty).' },
|
||||
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26).
|
||||
{ key: 'vandalism', name: 'Vandalism', copies: 0, placement: 'another player', effect: 'A train passing a Hobo Jungle has a boxcar looted (converted to empty).' },
|
||||
{ key: 'hotbox', name: 'Hotbox', copies: 1, placement: 'another player', effect: 'A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs.' },
|
||||
{ key: 'outlawed', name: 'Outlawed', copies: 1, placement: 'another player', effect: 'A train just arrived may not depart for one turn — the crew’s hours have expired.' },
|
||||
];
|
||||
@@ -972,7 +1080,6 @@ export function crewTrayCount(players: number): number {
|
||||
return players + 3;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1058,13 +1165,36 @@ export type RevenueRules = {
|
||||
*/
|
||||
export type ExtraStartRule = 'divisionPointsOnly' | 'ownOffice' | 'anyOffice';
|
||||
|
||||
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules; extraStart: ExtraStartRule };
|
||||
export type HouseRules = {
|
||||
startingHand: StartingHand;
|
||||
revenue: RevenueRules;
|
||||
extraStart: ExtraStartRule;
|
||||
/**
|
||||
* §6.2 — MAY A TIMETABLED TRAIN BE THROWN AWAY? (Gitea#9, superseding Gitea#6.)
|
||||
*
|
||||
* Jesse: "Timetabled trains are at the choice of the player — they can either play or discard. If
|
||||
* someone else wants to pick it up, they are more than able to. The reason: I don't want, if you
|
||||
* decide to play a game longer than five days, to decide that maybe there are too many trains, the
|
||||
* stations are jammed, and the railroad doesn't need any more. You can toss it. Someone else might
|
||||
* disagree and pick it up."
|
||||
*
|
||||
* A setting rather than a flat rule because Jesse asked for it as one — the reasoning above is
|
||||
* about LONG games, and a five-Day game may well want the pressure Gitea#6 created. Discarding
|
||||
* puts the card face-up on a Department pile, so "someone else might pick it up" needs no
|
||||
* machinery of its own: that is where every discard already goes.
|
||||
*
|
||||
* AN EXTRA IS NEVER DISCARDED WHATEVER THIS SAYS. Gitea#9 is about the timetable filling up, and
|
||||
* an Extra never joins it — it runs once and ends in the Salvage Yard, so it cannot jam anything.
|
||||
*/
|
||||
discardTimetabled: boolean;
|
||||
};
|
||||
|
||||
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
|
||||
export type HouseRuleOverrides = {
|
||||
startingHand?: StartingHand;
|
||||
revenue?: Partial<RevenueRules>;
|
||||
extraStart?: ExtraStartRule;
|
||||
discardTimetabled?: boolean;
|
||||
};
|
||||
|
||||
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
||||
@@ -1078,6 +1208,10 @@ export const DEFAULT_HOUSE_RULES: HouseRules = {
|
||||
// it plays the way it always has. Jesse's call, so the 0.4.9 playtest line does not change under
|
||||
// its testers in the middle of a bugfix release.
|
||||
extraStart: 'anyOffice',
|
||||
// Gitea#9's ruling is the default, so a game dealt without naming it plays the rule Jesse most
|
||||
// recently gave rather than the one it replaced. This keeps main and the 0.4.9 playtest line —
|
||||
// which has no setting and simply allows it — playing the same game.
|
||||
discardTimetabled: true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1094,6 +1228,9 @@ export const LEGACY_HOUSE_RULES: HouseRules = {
|
||||
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
|
||||
// An Extra could always be started at a Control Point in these games, in any district.
|
||||
extraStart: 'anyOffice',
|
||||
// These games predate Gitea#6 as well as Gitea#9: a train card could simply be discarded. `true`
|
||||
// is what they were played under, and a replay that discards a Timetabled train needs it.
|
||||
discardTimetabled: true,
|
||||
};
|
||||
|
||||
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
||||
@@ -1113,6 +1250,7 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
|
||||
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
|
||||
},
|
||||
extraStart: given.extraStart ?? d.extraStart,
|
||||
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+26
-2
@@ -87,7 +87,12 @@ export type GameEvent =
|
||||
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
|
||||
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number }
|
||||
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
|
||||
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
|
||||
| { type: 'redFlagSpent'; seat: SeatIndex; side: Direction; trainNumber: number }
|
||||
/** §Q (Gitea#19) — the district's owner answered the out-of-phase "flag against this train?". */
|
||||
| { type: 'redFlagRuled'; player: PlayerIndex; trainId: TrayId; flag: boolean }
|
||||
| {
|
||||
type: 'trainsDestroyed';
|
||||
player: PlayerIndex;
|
||||
@@ -195,6 +200,25 @@ export type GameEvent =
|
||||
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number }
|
||||
// -- consequences
|
||||
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
|
||||
| { type: 'phaseEnded'; player: PlayerIndex; phase: string };
|
||||
| { type: 'phaseEnded'; player: PlayerIndex; phase: string }
|
||||
// -- §3.3, extended play (Gitea#11)
|
||||
/**
|
||||
* One seat's answer to "play one more Day?". Every seat votes; the vote is unanimous, and one
|
||||
* refusal ends it. In the log so that a table can see who is still being waited on, and who
|
||||
* called time.
|
||||
*/
|
||||
| { type: 'extensionVoted'; player: PlayerIndex; agree: boolean }
|
||||
/** The table agreed. `day` is the Day the extra one becomes — `config.days + extraDays`. */
|
||||
| { type: 'dayExtended'; day: number }
|
||||
/**
|
||||
* Play is over for good — somebody declined the extension.
|
||||
*
|
||||
* Distinct from the ending itself, which `checkVictory` already announced by way of the result: an
|
||||
* ending that COULD have been played past and was not is a decision the table made, and the log
|
||||
* should say so rather than simply stopping.
|
||||
*/
|
||||
| { type: 'playConcluded'; declinedBy: PlayerIndex }
|
||||
/** §11 (Gitea#5) — the district's owner answered the Yard Office offer. */
|
||||
| { type: 'yardOfficeRuled'; player: PlayerIndex; trainId: TrayId; take: boolean };
|
||||
|
||||
export type EventType = GameEvent['type'];
|
||||
|
||||
+59
-5
@@ -120,10 +120,24 @@ export type Intent =
|
||||
*/
|
||||
| { type: 'mainline.modify'; cardId: CardId; node: number }
|
||||
/**
|
||||
* Red Flags — protect a stopped train. The flagged train cannot be hit; an approaching train is
|
||||
* held instead of colliding.
|
||||
* §Q, RED FLAGS (Gitea#19) — plant a flag on one side of your own district.
|
||||
*
|
||||
* "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits from
|
||||
* that direction (i.e. Flag East holds westbound trains). You can do this if you see a problem or
|
||||
* wish to complete switching."
|
||||
*
|
||||
* `side` names the side of the district the flag goes on, so a train arriving from that side is
|
||||
* held. It REPLACES the old rule, which was played on a stopped train out on the Mainline and
|
||||
* protected it from a rear-ender: measured at 4,212 offers and 4 plays across 600 games, a
|
||||
* mechanic nobody used. ABS Signals already protects a train standing on a Mainline card.
|
||||
*/
|
||||
| { type: 'maneuver.redFlags'; cardId: CardId; trayId: TrayId }
|
||||
| { type: 'maneuver.redFlags'; cardId: CardId; side: Direction }
|
||||
/**
|
||||
* The same card, played OUT OF PHASE at the moment of danger (Gitea#19) — "COLLISION RISK! FLAG
|
||||
* AGAINST T2?". Answers a pending `redFlag` decision; `flag: false` declines and lets the
|
||||
* collision happen. The side is not asked for: the train is already coming from one.
|
||||
*/
|
||||
| { type: 'mainline.redFlag'; flag: boolean; cardId?: CardId }
|
||||
/**
|
||||
* Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without
|
||||
* the engine entering it.
|
||||
@@ -147,7 +161,37 @@ export type Intent =
|
||||
| { type: 'laborer.startLoad'; at: GridCoord }
|
||||
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
|
||||
| { type: 'laborer.beginUnload'; at: GridCoord; carIndex: number }
|
||||
| { type: 'loadUnload.end' };
|
||||
| { type: 'loadUnload.end' }
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — one vote on whether to play one more Day.
|
||||
*
|
||||
* ARRIVES OUT OF TURN, like `mainline.clearance`, and unlike it goes to EVERY seat rather than to
|
||||
* the Superintendent: it is a table decision, not a ruling. Unanimous, and one `agree: false`
|
||||
* ends the game immediately — nobody waits on a player who has already refused.
|
||||
*
|
||||
* It is an intent, rather than a button the client handles by itself, because a save is
|
||||
* `{ seed, config, history }` replayed through the engine: a decision that is not in the history
|
||||
* did not happen, and an extended game would evaporate on the next reload, Undo, or server
|
||||
* restart. This is the record of the table agreeing.
|
||||
*
|
||||
* CARRIES ITS VOTER, uniquely among intents, and it has to. A saved history is a flat `Intent[]`
|
||||
* with no seat recorded against each move: the replay DERIVES who acted from the turn order
|
||||
* (`fromMultiplayerSave`). That works for every other intent, including `mainline.clearance`,
|
||||
* because there is exactly one seat it could have been. Here there is not — every seat may vote,
|
||||
* in any order — so a vote whose voter is not written down cannot be replayed at all, and a
|
||||
* resumed server would refuse the save with `NO_ACTOR`. The server checks this against the seat
|
||||
* it authenticated (`NOT_YOUR_TURN`), so it is a record, never a claim.
|
||||
*/
|
||||
| { type: 'game.extend'; player: PlayerIndex; agree: boolean }
|
||||
/**
|
||||
* §11 (Gitea#5) — take the Yard Office, or the standard Office.
|
||||
*
|
||||
* Interrupts the Mainline Phase like `mainline.clearance`, and like it goes to one named player:
|
||||
* whoever sits in the district the train is arriving at. Offered only when a route exists, so
|
||||
* `take: true` always has somewhere to go — though it may still meet cars on the lead and crash,
|
||||
* which is the point of the rule.
|
||||
*/
|
||||
| { type: 'mainline.yardOffice'; take: boolean };
|
||||
|
||||
export type IntentType = Intent['type'];
|
||||
|
||||
@@ -276,7 +320,17 @@ export type RejectionCode =
|
||||
* §6.2, Jesse's ruling (Gitea#6) — a train card is never discarded. Hold it as long as you like;
|
||||
* the only way it leaves your hand is onto the timetable.
|
||||
*/
|
||||
| 'TRAINS_ARE_NEVER_DISCARDED';
|
||||
| 'TRAINS_ARE_NEVER_DISCARDED'
|
||||
/** §3.3 (Gitea#11) — `game.extend` when the game is not waiting on an extension vote. */
|
||||
| 'NOT_AWAITING_EXTENSION'
|
||||
/** §3.3 (Gitea#11) — this seat has already voted on this extension. */
|
||||
| 'ALREADY_VOTED'
|
||||
/** §11 (Gitea#5) — answering a Yard Office offer that is not open. */
|
||||
| 'NO_YARD_OFFICE_OFFER'
|
||||
/** §Q (Gitea#19) — answering a Red Flag prompt that is not open. */
|
||||
| 'NO_RED_FLAG_PROMPT'
|
||||
/** §Q (Gitea#19) — this district already has a flag on that side. */
|
||||
| 'ALREADY_FLAGGED';
|
||||
|
||||
export type Rejection = { code: RejectionCode; message: string };
|
||||
|
||||
|
||||
+30
-3
@@ -68,11 +68,37 @@ export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
|
||||
// The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1).
|
||||
if (s.clock.pendingDecision !== null) {
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the only thing on offer when the timetable has run out and the
|
||||
* table is being asked whether to play on.
|
||||
*
|
||||
* Returned EARLY rather than added to the list, because nothing else is legal in this state and
|
||||
* the phase switch below would otherwise generate a boardful of candidates for `check` to reject
|
||||
* one at a time. It also puts the vote in front of the bot driver through the ordinary path, which
|
||||
* is what lets a bot seat answer without the engine having to know which seats are bots.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') {
|
||||
if (s.extensionVotes[player] === null) {
|
||||
out.push({ type: 'game.extend', player, agree: true });
|
||||
out.push({ type: 'game.extend', player, agree: false });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The two interruptions of the Mainline Phase. Each goes to one named player — `check` is the
|
||||
// authority on which — so both are generated here and filtered there.
|
||||
if (s.clock.pendingDecision?.kind === 'clearance') {
|
||||
out.push({ type: 'mainline.clearance', allow: true });
|
||||
out.push({ type: 'mainline.clearance', allow: false });
|
||||
}
|
||||
if (s.clock.pendingDecision?.kind === 'yardOffice') {
|
||||
out.push({ type: 'mainline.yardOffice', take: true });
|
||||
out.push({ type: 'mainline.yardOffice', take: false });
|
||||
}
|
||||
if (s.clock.pendingDecision?.kind === 'redFlag') {
|
||||
out.push({ type: 'mainline.redFlag', flag: true });
|
||||
out.push({ type: 'mainline.redFlag', flag: false });
|
||||
}
|
||||
|
||||
switch (s.clock.phase) {
|
||||
case 'localOps':
|
||||
@@ -93,7 +119,8 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue;
|
||||
for (const [trayId] of s.trays) out.push({ type: 'maneuver.redFlags', cardId, trayId });
|
||||
// §Q (Gitea#19) — a flag goes on one side of your own district, so the only choice is which.
|
||||
for (const side of ['east', 'west'] as const) out.push({ type: 'maneuver.redFlags', cardId, side });
|
||||
}
|
||||
|
||||
out.push({ type: 'redFlag.play' });
|
||||
|
||||
+7
-3
@@ -43,7 +43,7 @@ import type {
|
||||
TrackCard,
|
||||
TrayId,
|
||||
} from './state.ts';
|
||||
import { coordKey, freshTurns } from './state.ts';
|
||||
import { coordKey, emptyTally, freshTurns } from './state.ts';
|
||||
|
||||
export type SetupOptions = {
|
||||
id: string;
|
||||
@@ -246,7 +246,7 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
||||
if (deck.length === 0) throw new Error('the Mainline deck ran out — too many players for it');
|
||||
const card = deck.splice(rng.nextInt(deck.length), 1)[0]!;
|
||||
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
|
||||
if (mainlineProfile(card).speed.kind === 'grade') {
|
||||
if (card === 'heavyGrade') {
|
||||
/**
|
||||
* SETTLED, not provisional (v0.5.0, Jesse's call) — this overrides the card's own printed
|
||||
* "Player sets orientation". A Heavy Grade sits on the shared west-to-east chain BETWEEN two
|
||||
@@ -424,7 +424,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
phase: 'localOps',
|
||||
currentActor: superintendent,
|
||||
pendingDecision: null,
|
||||
clearanceRuling: null,
|
||||
decisionAnswer: null,
|
||||
superintendent,
|
||||
actorOffset: 0,
|
||||
},
|
||||
@@ -434,6 +434,10 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
collisionsTotal: 0,
|
||||
status: 'active',
|
||||
outcome: null,
|
||||
extraDays: 0,
|
||||
extensionVotes: players.map(() => null),
|
||||
official: null,
|
||||
tally: emptyTally(playerCount),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+335
-30
@@ -445,20 +445,27 @@ export type CrewTray = {
|
||||
position: NodeRef;
|
||||
movesUsed: number;
|
||||
/**
|
||||
* X18 Circus Train — "one turn stopped on any track (circus set-up) earns 1 point", claimed once.
|
||||
* X18 Circus / X17 Campaign — Office Areas this train has already been paid for setting up in
|
||||
* (Gitea#13).
|
||||
*
|
||||
* Recorded on the tray rather than the player because it is the TRAIN that sets up, and an Extra
|
||||
* runs once and is gone; there is no second visit to claim it on.
|
||||
* "Once per stop in an office area. In a multiplayer game, each player could score if the circus
|
||||
* stops in their area" (Jesse, 2026-08-29). So the claim is per SEAT, not per train: a Circus
|
||||
* touring three districts is paid three times, and one that parks in the same district for six
|
||||
* Stages is paid once.
|
||||
*
|
||||
* Recorded on the tray, which also gives the other half of Jesse's ruling for free — "if the
|
||||
* circus train gets recycled and played a second time as a second extra, then it could again
|
||||
* score points later too". A train is made up onto a FRESH tray object every time, so a re-played
|
||||
* Extra starts with an empty list and no reset code is needed.
|
||||
*/
|
||||
stopPointClaimed?: boolean;
|
||||
stopPointSeats?: SeatIndex[];
|
||||
/**
|
||||
* 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 is expedited — it may be switched normally, but it faults (Q3) if it is left
|
||||
* off the Office square when a Mainline Phase begins. 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.
|
||||
* `stopPointSeats` — it is the TRAIN that stops, and a re-played Extra gets a fresh tray.
|
||||
*/
|
||||
speechMade?: boolean;
|
||||
};
|
||||
@@ -518,10 +525,23 @@ export type DivisionNode =
|
||||
* "Player sets orientation", so the direction is chosen when the card is placed.
|
||||
*/
|
||||
gradeUp?: Direction;
|
||||
/** Red Flags protecting a stopped train here, by tray. */
|
||||
redFlagged?: TrayId[];
|
||||
}
|
||||
| { kind: 'office'; seat: SeatIndex };
|
||||
| {
|
||||
kind: 'office';
|
||||
seat: SeatIndex;
|
||||
/**
|
||||
* §Q, RED FLAGS (Gitea#19) — the side of this district a flag is planted on.
|
||||
*
|
||||
* "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits
|
||||
* from that direction (i.e. Flag East holds westbound trains)." So the value names the SIDE,
|
||||
* and a train arriving from that side is held: a westbound train comes from the east.
|
||||
*
|
||||
* SPENT ON THE TRAIN IT STOPS (Jesse's ruling, 2026-08-29). One card, one train — the flag
|
||||
* comes down as it is used, so there is no lifting action to build, nothing to forget, and a
|
||||
* flag cannot quietly strangle the Division.
|
||||
*/
|
||||
redFlag?: Direction;
|
||||
};
|
||||
|
||||
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
|
||||
export type Division = { nodes: DivisionNode[] };
|
||||
@@ -585,10 +605,42 @@ export type Yards = {
|
||||
export type Phase = 'localOps' | 'newTrain' | 'mainline' | 'loadUnload' | 'shiftChange';
|
||||
|
||||
/** §8.1 fourth condition — the Superintendent rules on a following train. */
|
||||
export type SuperintendentClearance = {
|
||||
train: TrayId;
|
||||
occupiedBy: TrayId;
|
||||
};
|
||||
/**
|
||||
* AN INTERRUPTION TO THE AUTOMATIC MAINLINE PHASE — a question the driver cannot answer itself.
|
||||
*
|
||||
* There was one of these and it was hardcoded to one question asked of one player: the §8.1
|
||||
* clearance ruling, always to the Superintendent. Gitea#5 and Gitea#19 each need to stop the same
|
||||
* phase and ask a DIFFERENT player something different, so the shape is a union and `decisionActor`
|
||||
* below decides who answers.
|
||||
*
|
||||
* Every member names the `train` the question is about, because the answer has to be matched back
|
||||
* to it — see `DecisionAnswer`.
|
||||
*/
|
||||
export type PendingDecision =
|
||||
/** §8.1 — a following train in the same Subdivision. The Superintendent rules. */
|
||||
| { kind: 'clearance'; train: TrayId; occupiedBy: TrayId }
|
||||
/**
|
||||
* §11 (Gitea#5) — an inbound freight may take the Yard Office instead of the Train Order Office.
|
||||
* Asked of whoever sits in `seat`, on the Mainline Phase the train arrives.
|
||||
*/
|
||||
| { kind: 'yardOffice'; train: TrayId; seat: SeatIndex }
|
||||
/**
|
||||
* §Q (Gitea#19) — a train is about to enter this district into a collision, and its owner holds a
|
||||
* Red Flags card. "You can play the card normally or out of phase, but only if you need it."
|
||||
*/
|
||||
| { kind: 'redFlag'; train: TrayId; seat: SeatIndex; from: Direction };
|
||||
|
||||
/**
|
||||
* The answer, waiting to be consumed by the train that asked.
|
||||
*
|
||||
* Without this the driver would re-evaluate the same train, ask the same question, and never
|
||||
* advance. Keyed by `kind` as well as `train` so an answer can never be mistaken for the reply to a
|
||||
* different question about the same train.
|
||||
*/
|
||||
export type DecisionAnswer =
|
||||
| { kind: 'clearance'; train: TrayId; allow: boolean }
|
||||
| { kind: 'yardOffice'; train: TrayId; take: boolean }
|
||||
| { kind: 'redFlag'; train: TrayId; flag: boolean };
|
||||
|
||||
export type Clock = {
|
||||
day: number;
|
||||
@@ -597,13 +649,10 @@ export type Clock = {
|
||||
phase: Phase;
|
||||
/** Exactly one player may act at a time. Null during automatic Mainline movement. */
|
||||
currentActor: PlayerIndex | null;
|
||||
/** Interrupts the Mainline Phase to ask the Superintendent (§8.1). */
|
||||
pendingDecision: SuperintendentClearance | null;
|
||||
/**
|
||||
* The Superintendent's answer, waiting to be consumed by the train that asked. Without this the
|
||||
* driver would re-evaluate the same train and ask the same question forever.
|
||||
*/
|
||||
clearanceRuling: { train: TrayId; allow: boolean } | null;
|
||||
/** Interrupts the Mainline Phase to ask a player something (§8.1, §11). */
|
||||
pendingDecision: PendingDecision | null;
|
||||
/** The answer to `pendingDecision`, waiting to be consumed by the train that asked. */
|
||||
decisionAnswer: DecisionAnswer | null;
|
||||
superintendent: PlayerIndex;
|
||||
/**
|
||||
* How far round the table the current phase has got. Acting order starts at the Superintendent
|
||||
@@ -684,6 +733,139 @@ export type Outcome = {
|
||||
reason: OutcomeReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — which endings may be played past.
|
||||
*
|
||||
* Both days-based endings offer another Day: running out of timetable, and closing short of the
|
||||
* combined Revenue floor, are the same event seen twice — the last Day ended and this is what the
|
||||
* books say. A `collisionFloor` ending is NOT extendable, and neither is a collision breach that
|
||||
* happens during an extended Day: §3.4 stopped the game because the railroad was declared unsafe,
|
||||
* and carrying on regardless would contradict the rule that stopped it (Jesse's call, 2026-08-28).
|
||||
*/
|
||||
export function isExtendable(reason: OutcomeReason): boolean {
|
||||
return reason === 'daysElapsed' || reason === 'revenueFloor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Running counts of everything interesting that has happened, tallied from the event stream
|
||||
* (Gitea#16).
|
||||
*
|
||||
* WHY IT LIVES ON `GameState` rather than being computed by whoever happens to want it. Three
|
||||
* reasons, in ascending order of how much they cost to work around:
|
||||
*
|
||||
* 1. `snapshot()` already takes a `GameState`, so every number here reaches a MULTIPLAYER client
|
||||
* through the `Frame` it is already being sent — no new server route, no new `Push` field, no
|
||||
* new `Session` method, and no second implementation that can disagree with the first.
|
||||
* 2. It is REPLAY-EXACT. A save is `{ seed, config, history }` replayed through the engine
|
||||
* (`web/game.ts`'s `fromSave`), so a tally folded from the events that replay emits is rebuilt
|
||||
* identically every time — which is what makes Undo and a server restart correct here for free.
|
||||
* 3. The official result freezes a COPY of this at the moment the timetable ran out (`official`
|
||||
* below), and a frozen copy has to be taken from something that already exists.
|
||||
*
|
||||
* Aggregate counts only. Nothing here is seat-secret — no card ids, no hands — which is why
|
||||
* `test/redaction.test.ts` stays green with the whole thing on the Frame.
|
||||
*
|
||||
* NOT SCORING. Nothing in here feeds a rule; it is read by the results screen and by the badge work
|
||||
* that Gitea#16 leaves to a second pass. Adding a counter is always safe.
|
||||
*/
|
||||
export type Tally = {
|
||||
/** §8.3 — a train that ran the length of the Division and left it. */
|
||||
trainsCompleted: number;
|
||||
/**
|
||||
* Of those, how many did some switching between being made up and leaving.
|
||||
*
|
||||
* The join Gitea#16 asks for by name ("a player who completes an entire game where every train
|
||||
* that passed through did some switching on"). Counted as the train completes, against whether
|
||||
* that tray has coupled or dropped anything since it was made up — which is why `switchedSince`
|
||||
* below exists rather than this being derivable afterwards.
|
||||
*/
|
||||
trainsCompletedWithWork: number;
|
||||
/** §10 — trains lost to a collision, and the cars that went with them. */
|
||||
trainsDestroyed: number;
|
||||
carsDestroyed: number;
|
||||
/** §6 — switching volume, both directions. */
|
||||
carsCoupled: number;
|
||||
carsDropped: number;
|
||||
/** §9.1 — the MEN | AT | WORK pipeline: begun, and carried all the way through. */
|
||||
loadsStarted: number;
|
||||
loadsCompleted: number;
|
||||
unloadsBegun: number;
|
||||
unloadsCompleted: number;
|
||||
/** §9.2 — passenger work. */
|
||||
passengersBoarded: number;
|
||||
passengersDetrained: number;
|
||||
/** Colour, and the vocabulary the badge pass will draw on. */
|
||||
flyingSwitches: number;
|
||||
officeUpgrades: number;
|
||||
dispatchBonusesUsed: number;
|
||||
facilitiesUnjammed: number;
|
||||
expediteFaults: number;
|
||||
trainsHeld: number;
|
||||
trainsDiverted: number;
|
||||
secondSections: number;
|
||||
extrasStarted: number;
|
||||
cardsDrawn: number;
|
||||
cardsPlayed: number;
|
||||
cardsDiscarded: number;
|
||||
clearancesRequested: number;
|
||||
/** §8.1 — rulings that let the other train through. A refusal is a ruling too, but not this one. */
|
||||
clearancesAllowed: number;
|
||||
/**
|
||||
* X18 CIRCUS SET-UPS — a train that spent a Stage standing still and was paid for it (§X18).
|
||||
*
|
||||
* NOT "the longest an engine sat on a siding", which is what Gitea#16 asks for and what the
|
||||
* comment on that issue assumed this was. `trainStoodStill` is emitted ONCE IN A GAME PER SUCH
|
||||
* TRAIN — only for a train whose profile has `stopEarnsPoint`, and `advance.ts` sets
|
||||
* `stopPointClaimed` so it can never fire twice. There is no per-Stage "this train did not move"
|
||||
* signal in the engine at all, so a longest-stand streak cannot be folded from the event stream:
|
||||
* it needs an engine-side signal that does not exist yet. Recorded in `TODO.md` for the badge
|
||||
* pass rather than shipped as a statistic that would read "1 Stage" for ever.
|
||||
*/
|
||||
circusStops: { trainNumber: number; where: string }[];
|
||||
/**
|
||||
* Train numbers that have coupled or dropped something since they were made up, for
|
||||
* `trainsCompletedWithWork`. Cleared when the train is made up and when it leaves the Division.
|
||||
*/
|
||||
switchedSince: number[];
|
||||
/** Indexed by PLAYER. Only events that name a player reach these. */
|
||||
byPlayer: PlayerTally[];
|
||||
};
|
||||
|
||||
export type PlayerTally = {
|
||||
loads: number;
|
||||
unloads: number;
|
||||
passengersBoarded: number;
|
||||
passengersDetrained: number;
|
||||
cardsPlayed: number;
|
||||
/** §10 — collisions this player was faulted for, not collisions they were caught in. */
|
||||
collisions: number;
|
||||
/** Revenue gained and Revenue lost, kept apart: the net is already on `players[i].revenue`. */
|
||||
revenueGained: number;
|
||||
revenueLost: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* THE OFFICIAL RESULT, frozen at the moment the timetable ran out (Gitea#11).
|
||||
*
|
||||
* "The winner is based upon the original game length. In a five-day game, even if it's extended to
|
||||
* eight or nine days, the winner and the official answer is the winner at the end of five days"
|
||||
* (Jesse, 2026-08-28). So this is written ONCE, at the first ending, and never overwritten —
|
||||
* including by a §3.4 collision breach during an extended Day, which ends play without touching it.
|
||||
*
|
||||
* `state.outcome` keeps moving: it is always the CURRENT evaluation, which is what the live game
|
||||
* wants. Once `official` exists, everything after it is informational.
|
||||
*/
|
||||
export type FinalReport = {
|
||||
/** The Day the game was scheduled to end on — always `config.days`. */
|
||||
day: number;
|
||||
outcome: Outcome;
|
||||
/** Every player's Revenue at that moment, in player order. */
|
||||
revenues: number[];
|
||||
collisionsTotal: number;
|
||||
/** The Tally as it stood when the timetable ran out. */
|
||||
tally: Tally;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-Stage transient bookkeeping for the acting player. Reset when the actor changes.
|
||||
*
|
||||
@@ -726,6 +908,66 @@ export function freshTurns(players: number, moves: number): Map<PlayerIndex, Tur
|
||||
return turns;
|
||||
}
|
||||
|
||||
/** A Tally with everything at zero — the state every game starts in (Gitea#16). */
|
||||
export function emptyTally(players: number): Tally {
|
||||
return {
|
||||
trainsCompleted: 0,
|
||||
trainsCompletedWithWork: 0,
|
||||
trainsDestroyed: 0,
|
||||
carsDestroyed: 0,
|
||||
carsCoupled: 0,
|
||||
carsDropped: 0,
|
||||
loadsStarted: 0,
|
||||
loadsCompleted: 0,
|
||||
unloadsBegun: 0,
|
||||
unloadsCompleted: 0,
|
||||
passengersBoarded: 0,
|
||||
passengersDetrained: 0,
|
||||
flyingSwitches: 0,
|
||||
officeUpgrades: 0,
|
||||
dispatchBonusesUsed: 0,
|
||||
facilitiesUnjammed: 0,
|
||||
expediteFaults: 0,
|
||||
trainsHeld: 0,
|
||||
trainsDiverted: 0,
|
||||
secondSections: 0,
|
||||
extrasStarted: 0,
|
||||
cardsDrawn: 0,
|
||||
cardsPlayed: 0,
|
||||
cardsDiscarded: 0,
|
||||
clearancesRequested: 0,
|
||||
clearancesAllowed: 0,
|
||||
circusStops: [],
|
||||
switchedSince: [],
|
||||
byPlayer: Array.from({ length: players }, () => ({
|
||||
loads: 0,
|
||||
unloads: 0,
|
||||
passengersBoarded: 0,
|
||||
passengersDetrained: 0,
|
||||
cardsPlayed: 0,
|
||||
collisions: 0,
|
||||
revenueGained: 0,
|
||||
revenueLost: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A deep copy, for freezing the official result (`FinalReport`).
|
||||
*
|
||||
* Written out rather than reached for via `structuredClone` because a Tally is a flat bag of numbers
|
||||
* with two containers in it, and spelling the copy out means a field added later that needs deep
|
||||
* copying is a compile error here rather than a shared reference discovered in a results screen.
|
||||
*/
|
||||
export function cloneTally(t: Tally): Tally {
|
||||
return {
|
||||
...t,
|
||||
circusStops: t.circusStops.map((c) => ({ ...c })),
|
||||
switchedSince: [...t.switchedSince],
|
||||
byPlayer: t.byPlayer.map((p) => ({ ...p })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH WAY TO DRAW THE ENGINE — east or west, for every train, everywhere.
|
||||
*
|
||||
@@ -771,22 +1013,25 @@ export function standingSides(
|
||||
}
|
||||
|
||||
/**
|
||||
* The cut a train would run into if it left this card through `exit` — the cars between it and that
|
||||
* end of the card.
|
||||
* The cut a train would run into if it left this card by the `exit` END OF THE ROW — the cars
|
||||
* between it and that end. Returned in the order the train MEETS them, nearest first, which is what
|
||||
* `carsCoupled` wants.
|
||||
*
|
||||
* Only 'e' and 'w' can hold a cut: the array is a west-to-east row, so a train leaving north or
|
||||
* south off a curve or a spur is not running along it and meets nothing. Returned in the order the
|
||||
* train MEETS them, nearest first, which is what `carsCoupled` wants.
|
||||
* `exit` IS AN END OF THE ROW, NOT A PORT. It used to be a raw `Port`, and answered "you meet
|
||||
* nothing" for north and south on the reasoning that a leg leaving through an edge is not running
|
||||
* along the west-to-east row. It is: a `sw` curve's south leg IS the east end of that row, so a
|
||||
* crew standing on the curve pulled out through the leg and drove away leaving the cars beside it
|
||||
* standing, against §A.4's mandatory coupling (Gitea#17). Callers resolve the leg with `rowEndAt`
|
||||
* (`track.ts`), which lives there because only the card's arc can say which end a leg is — and the
|
||||
* narrowed type is what makes every caller do it.
|
||||
*/
|
||||
export function cutTowards(
|
||||
tray: { standingWest?: number | undefined },
|
||||
cars: readonly RollingStock[],
|
||||
exit: 'n' | 's' | 'e' | 'w',
|
||||
exit: 'e' | 'w',
|
||||
): RollingStock[] {
|
||||
const { west, east } = standingSides(tray, cars);
|
||||
if (exit === 'e') return east;
|
||||
if (exit === 'w') return [...west].reverse();
|
||||
return [];
|
||||
return exit === 'e' ? east : [...west].reverse();
|
||||
}
|
||||
|
||||
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
|
||||
@@ -863,8 +1108,34 @@ export type GameState = {
|
||||
collisionsToday: number;
|
||||
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||
collisionsTotal: number;
|
||||
status: 'setup' | 'active' | 'finished';
|
||||
/**
|
||||
* `awaitingExtension` is Gitea#11: the timetable has run out, the result is recorded, and the
|
||||
* table is being asked whether to play one more Day. It is a PAUSE, not an ending — `advance`
|
||||
* reports `needsInput` there, the server resumes it like any live game, and the only intent the
|
||||
* rules will accept is `game.extend`.
|
||||
*/
|
||||
status: 'setup' | 'active' | 'awaitingExtension' | 'finished';
|
||||
/** The CURRENT evaluation, re-decided at the end of every Day including extended ones. */
|
||||
outcome: Outcome | null;
|
||||
/**
|
||||
* §3.3 (Gitea#11) — Days granted beyond `config.days`, one vote at a time.
|
||||
*
|
||||
* `config.days` is deliberately never touched: it is what the official result was decided at, so
|
||||
* leaving it alone is what makes "the winner is decided at the original game length" a fact about
|
||||
* the code rather than a comment on it.
|
||||
*/
|
||||
extraDays: number;
|
||||
/**
|
||||
* Per PLAYER, while `awaitingExtension`. `null` means they have not voted yet.
|
||||
*
|
||||
* Unanimous, and one refusal is decisive: nobody is made to wait on a player who has already said
|
||||
* no (Jesse's call, 2026-08-28). Solitaire is the same code with one voter.
|
||||
*/
|
||||
extensionVotes: (boolean | null)[];
|
||||
/** Frozen at the FIRST ending and never overwritten. See `FinalReport`. */
|
||||
official: FinalReport | null;
|
||||
/** Gitea#16. Folded from the event stream; see `Tally`. */
|
||||
tally: Tally;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -909,6 +1180,40 @@ export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** This seat's node on the Division — where its Limits, and any Red Flag on them, live. */
|
||||
export function officeNodeFor(
|
||||
state: GameState,
|
||||
seat: SeatIndex,
|
||||
): Extract<DivisionNode, { kind: 'office' }> | null {
|
||||
for (const n of state.division.nodes) if (n.kind === 'office' && n.seat === seat) return n;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHO MUST ANSWER the interruption, or null when nothing is pending.
|
||||
*
|
||||
* The one place that knows which player each kind of question goes to. §8.1's clearance is the
|
||||
* Superintendent's ruling wherever it happens; the Yard Office is offered to whoever sits in the
|
||||
* district the train is arriving at, because it is their card and their yard.
|
||||
*/
|
||||
export function decisionActor(state: GameState): PlayerIndex | null {
|
||||
const d = state.clock.pendingDecision;
|
||||
if (!d) return null;
|
||||
return d.kind === 'clearance' ? state.clock.superintendent : playerAtSeat(state, d.seat);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHOSE MOVE IT IS RIGHT NOW — a pending interruption's owner if there is one, else the phase's
|
||||
* own actor.
|
||||
*
|
||||
* Written out six times across the engine, the sim, the web client and the tests as
|
||||
* `pendingDecision !== null ? superintendent : currentActor`, which stopped being right the moment
|
||||
* a second kind of question existed. One copy now, so a new decision kind cannot be half-adopted.
|
||||
*/
|
||||
export function actingPlayer(state: GameState): PlayerIndex | null {
|
||||
return decisionActor(state) ?? state.clock.currentActor;
|
||||
}
|
||||
|
||||
/** Where this player is sitting, and therefore which Office Area is theirs. */
|
||||
/**
|
||||
* The player `n` seats to the LEFT of this one, wrapping round the table.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The event tally — Gitea#16's statistics, folded from the event stream into `GameState.tally`.
|
||||
*
|
||||
* WHERE IT IS HOOKED, and why it is not in `reduce`. `apply.ts`'s `reduce` sees only the events an
|
||||
* INTENT produced; `advance.ts` mutates state directly and pushes its events without reducing them
|
||||
* at all — and `advance` is where `trainCompleted`, `trainsDestroyed` and `trainStoodStill` come
|
||||
* from, which are exactly the numbers this issue asks for. So the fold is hooked at the two places
|
||||
* every event in the game passes through exactly once on its way to a caller:
|
||||
*
|
||||
* - `applyIntent` (`apply.ts`), beside its `reduce` loop;
|
||||
* - `advance` (`advance.ts`), which now wraps the phase driver and folds what it returns.
|
||||
*
|
||||
* Exactly once matters in both directions: an event folded twice inflates a count, and an event
|
||||
* folded nowhere is a statistic that silently reads zero. `test/tally.test.ts` pins both by playing
|
||||
* real games and checking the tally against an independent count over the same event array.
|
||||
*
|
||||
* NOTHING HERE IS A RULE. The tally is read by the results screen and by the badge work Gitea#16
|
||||
* leaves to a second pass; no engine decision consults it. That is what makes adding a counter
|
||||
* always safe.
|
||||
*
|
||||
* WHAT IS NOT COUNTED PER PLAYER, and why. `carsCoupled` and `carsDropped` carry a `trayId` and no
|
||||
* `player` — switching is done BY a crew, and the event says which crew rather than which person.
|
||||
* Rather than guess an owner from whose turn it happened to be, those two are table totals only.
|
||||
* The events that do name a player (`loadCompleted`, `passengersBoarded`, `cardPlayed`,
|
||||
* `revenueChanged`, `trainsDestroyed`) are the ones `byPlayer` reports.
|
||||
*/
|
||||
|
||||
import type { GameEvent } from './events.ts';
|
||||
import type { GameState } from './state.ts';
|
||||
|
||||
/**
|
||||
* Fold one event into `s.tally`.
|
||||
*
|
||||
* The switch is deliberately not exhaustive — most of the 49 event types say nothing a player would
|
||||
* want counted, and listing them all to `break` would bury the ones that do. A `default` that does
|
||||
* nothing is the honest shape.
|
||||
*/
|
||||
export function tallyEvent(s: GameState, e: GameEvent): void {
|
||||
const t = s.tally;
|
||||
const mine = 'player' in e && typeof e.player === 'number' ? t.byPlayer[e.player] : undefined;
|
||||
|
||||
switch (e.type) {
|
||||
/**
|
||||
* §X18 — the Circus train set up and was paid for the Stage it spent standing.
|
||||
*
|
||||
* NOT a "longest stand" streak, which is what Gitea#16 wants and what its comment assumed this
|
||||
* event was. It fires once in a game per such train: only trains whose profile sets
|
||||
* `stopEarnsPoint` emit it at all, and `advance.ts` claims it once with `stopPointClaimed`. So
|
||||
* there is nothing to count a run of, and the honest thing to report is the event itself.
|
||||
*/
|
||||
case 'trainStoodStill':
|
||||
t.circusStops.push({ trainNumber: e.trainNumber, where: e.where });
|
||||
break;
|
||||
|
||||
/**
|
||||
* DID THIS TRAIN DO ANY SWITCHING — Gitea#16's "switching master" join, kept as it happens
|
||||
* rather than reconstructed afterwards.
|
||||
*
|
||||
* The two halves of the join are in different currencies: switching events name a `trayId` and
|
||||
* completion names a `trainNumber`, and no event carries both. The tray is looked up in LIVE
|
||||
* state, which is sound precisely here — a crew that has just coupled or dropped is still on the
|
||||
* board — where re-deriving it at completion time would not be, the tray having been released by
|
||||
* then. A lookup that misses costs one train its mark on a statistic; it cannot affect a rule.
|
||||
*/
|
||||
case 'carsCoupled':
|
||||
t.carsCoupled += e.stock.length;
|
||||
markSwitched(s, e.trayId);
|
||||
break;
|
||||
|
||||
case 'carsDropped':
|
||||
t.carsDropped += e.stock.length;
|
||||
markSwitched(s, e.trayId);
|
||||
break;
|
||||
|
||||
case 'flyingSwitch':
|
||||
t.flyingSwitches += 1;
|
||||
markSwitched(s, e.trayId);
|
||||
break;
|
||||
|
||||
// A tray is reused run after run, so a fresh train starts with a clean sheet.
|
||||
case 'trainMadeUp':
|
||||
t.switchedSince = t.switchedSince.filter((n) => n !== e.trainNumber);
|
||||
break;
|
||||
|
||||
case 'trainCompleted':
|
||||
t.trainsCompleted += 1;
|
||||
if (t.switchedSince.includes(e.trainNumber)) t.trainsCompletedWithWork += 1;
|
||||
t.switchedSince = t.switchedSince.filter((n) => n !== e.trainNumber);
|
||||
break;
|
||||
|
||||
case 'trainsDestroyed':
|
||||
t.trainsDestroyed += e.trains.length;
|
||||
for (const train of e.trains) t.carsDestroyed += train.consist.length;
|
||||
if (mine) mine.collisions += 1;
|
||||
break;
|
||||
|
||||
case 'officeUpgraded':
|
||||
t.officeUpgrades += 1;
|
||||
break;
|
||||
|
||||
case 'dispatchBonusUsed':
|
||||
t.dispatchBonusesUsed += 1;
|
||||
break;
|
||||
|
||||
case 'facilityUnjammed':
|
||||
t.facilitiesUnjammed += 1;
|
||||
break;
|
||||
|
||||
case 'expediteFault':
|
||||
t.expediteFaults += 1;
|
||||
break;
|
||||
|
||||
case 'trainHeld':
|
||||
t.trainsHeld += 1;
|
||||
break;
|
||||
|
||||
case 'trainDiverted':
|
||||
t.trainsDiverted += 1;
|
||||
break;
|
||||
|
||||
case 'secondSectionOrdered':
|
||||
t.secondSections += 1;
|
||||
break;
|
||||
|
||||
case 'extraStarted':
|
||||
t.extrasStarted += 1;
|
||||
break;
|
||||
|
||||
case 'cardDrawn':
|
||||
t.cardsDrawn += 1;
|
||||
break;
|
||||
|
||||
case 'cardPlayed':
|
||||
t.cardsPlayed += 1;
|
||||
if (mine) mine.cardsPlayed += 1;
|
||||
break;
|
||||
|
||||
case 'cardDiscarded':
|
||||
t.cardsDiscarded += 1;
|
||||
break;
|
||||
|
||||
case 'clearanceRequested':
|
||||
t.clearancesRequested += 1;
|
||||
break;
|
||||
|
||||
// §8.1 — a ruling is given either way; only a YES let the other train through.
|
||||
case 'clearanceGiven':
|
||||
if (e.allow) t.clearancesAllowed += 1;
|
||||
break;
|
||||
|
||||
case 'loadStarted':
|
||||
t.loadsStarted += 1;
|
||||
break;
|
||||
|
||||
case 'loadCompleted':
|
||||
t.loadsCompleted += 1;
|
||||
if (mine) mine.loads += 1;
|
||||
break;
|
||||
|
||||
case 'unloadBegan':
|
||||
t.unloadsBegun += 1;
|
||||
break;
|
||||
|
||||
case 'unloadCompleted':
|
||||
t.unloadsCompleted += 1;
|
||||
if (mine) mine.unloads += 1;
|
||||
break;
|
||||
|
||||
case 'passengersBoarded':
|
||||
t.passengersBoarded += 1;
|
||||
if (mine) mine.passengersBoarded += 1;
|
||||
break;
|
||||
|
||||
case 'passengersDetrained':
|
||||
t.passengersDetrained += 1;
|
||||
if (mine) mine.passengersDetrained += 1;
|
||||
break;
|
||||
|
||||
/**
|
||||
* Gained and lost are kept APART because the net is already on `players[i].revenue`. What the
|
||||
* results screen cannot otherwise say is how much of a modest final score was earned and then
|
||||
* handed back at a grade crossing — which is the whole difference between a quiet game and an
|
||||
* eventful one.
|
||||
*/
|
||||
case 'revenueChanged':
|
||||
if (mine) {
|
||||
if (e.delta >= 0) mine.revenueGained += e.delta;
|
||||
else mine.revenueLost += -e.delta;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function markSwitched(s: GameState, trayId: string): void {
|
||||
const n = s.trays.get(trayId)?.trainNumber;
|
||||
if (n === undefined || n === null) return;
|
||||
if (!s.tally.switchedSince.includes(n)) s.tally.switchedSince.push(n);
|
||||
}
|
||||
+63
-6
@@ -188,6 +188,40 @@ export function joins(a: TrackCard, p: Port, b: TrackCard): boolean {
|
||||
return slopeAt(a, p) === slopeAt(b, opposite(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH END OF THE WEST-TO-EAST ROW A PORT SITS AT.
|
||||
*
|
||||
* `TrackCard.standing` is ordered west to east (§A.3), so whether a train meets the row front to
|
||||
* back or back to front depends on which end it enters by — and a port is not always at one of
|
||||
* those two extremes. Every 45° leg leaves through the MIDDLE of its north or south edge, so its
|
||||
* end of the run is whichever end the arc does NOT reach: a `sw` curve's south leg is the EAST end
|
||||
* of the row, and an `se` curve's south leg is the WEST end. Same port, opposite answers, which is
|
||||
* why this has to ask the card rather than read the port.
|
||||
*
|
||||
* Gitea#17 is what both callers looked like without it. `exploreMoves` reversed the row for an 'e'
|
||||
* entry and for nothing else, so backing into a cut through a `sw` curve's south leg coupled it up
|
||||
* back to front — the caboose came out next to the engine, which §8.2 then calls badly made up.
|
||||
* `cutTowards` answered "you meet nothing" for a north or south exit, so a crew standing on a curve
|
||||
* pulled out through the leg and left the cars beside it standing, which §A.4 forbids.
|
||||
*
|
||||
* There is no north-south straight anywhere on the printed sheet (see the module comment), so a run
|
||||
* touching a 45° leg always has an east or west port at its other end and the answer is never
|
||||
* undefined. A TURNOUT is the one card whose row has three ends rather than two — and it is also
|
||||
* the one card no cut can ever stand on, since a train may not stop there (§A.1) and so never sets
|
||||
* anything out there. Its stem answers for it.
|
||||
*/
|
||||
export function rowEndAt(card: TrackCard, p: Port): 'e' | 'w' {
|
||||
if (p === 'e' || p === 'w') return p;
|
||||
for (const [a, b] of connectionsFor(card)) {
|
||||
const other = a === p ? b : b === p ? a : null;
|
||||
if (other === 'e') return 'w';
|
||||
if (other === 'w') return 'e';
|
||||
}
|
||||
// Not a card the printed sheet can produce. Reading the leg as the west end leaves the row in the
|
||||
// order it is stored rather than inventing a reversal on a card nothing knows the shape of.
|
||||
return 'w';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orientation (Gap 11)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -450,7 +484,7 @@ export function exploreMoves(
|
||||
*
|
||||
* Ordered nearest-first like every other card's, so it simply seeds the accumulator.
|
||||
*/
|
||||
const ownCut = cutTowards(startCard, carsOn(startCard), initialExit);
|
||||
const ownCut = cutTowards(startCard, carsOn(startCard), rowEndAt(startCard, initialExit));
|
||||
const startKey = coordKey(start);
|
||||
const queue: Frontier[] = [
|
||||
{
|
||||
@@ -502,12 +536,15 @@ export function exploreMoves(
|
||||
* overfill the tray is illegal, not a move that picks up fewer cars.
|
||||
*
|
||||
* NEAREST FIRST ALONG THE DIRECTION OF TRAVEL. `carsOn` runs west to east, so a train entering
|
||||
* through the card's EAST port meets them back to front and the row has to be reversed. Without
|
||||
* this the same parked cut produced an identical consist whichever way it was approached, when
|
||||
* the two must mirror — which is the difference between a run-around being worth a Move and
|
||||
* being pointless.
|
||||
* at the row's EAST end meets them back to front and the row has to be reversed. Without this
|
||||
* the same parked cut produced an identical consist whichever way it was approached, when the
|
||||
* two must mirror — which is the difference between a run-around being worth a Move and being
|
||||
* pointless.
|
||||
*
|
||||
* `rowEndAt` rather than `node.entry === 'e'`: a 45° leg is an end of the row too, and which
|
||||
* end it is depends on the card's arc (Gitea#17).
|
||||
*/
|
||||
const met = node.entry === 'e' ? [...carsOn(card)].reverse() : carsOn(card);
|
||||
const met = rowEndAt(card, node.entry) === 'e' ? [...carsOn(card)].reverse() : carsOn(card);
|
||||
const couples = [...node.couples, ...met];
|
||||
const nodeKey = coordKey(node.coord);
|
||||
const origins = [...node.origins, ...met.map(() => nodeKey)];
|
||||
@@ -658,6 +695,26 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
|
||||
// into a stub and cutting the Office off from the Limits.
|
||||
if (coord.row === area.runningRow && !carriesThroughTrack(card)) return false;
|
||||
|
||||
/**
|
||||
* ONE NEIGHBOUR MUST JOIN. THE OTHERS NEED NOT — AND THIS RULE HAS BEEN BOTH WAYS (Gitea#15).
|
||||
*
|
||||
* A card may be laid with an exit facing a card that has nothing to meet it. The rail stops dead
|
||||
* at that edge, and that is legal.
|
||||
*
|
||||
* The issue was filed the other way round — "if a card is placed in that space, it MUST connect" —
|
||||
* against a right-hand curve laid with its north leg against an Ice House and the turnout below it
|
||||
* pointing at its portless south edge. **RAR reversed it on review (2026-08-26): placing it is
|
||||
* fine, and a stub like that is useful — a siding to park cars on.**
|
||||
*
|
||||
* WHAT MATTERS INSTEAD IS THAT NOTHING CAN DRIVE ACROSS THE GAP, so the real requirement is on
|
||||
* MOVEMENT rather than on placement: two cards touching are not connected, and `exploreMoves` must
|
||||
* refuse the hop. It does — every step is gated on `joins`, never on a bare pair of `hasPort`
|
||||
* calls — and `track.test.ts` pins the reported geometry against exactly that.
|
||||
*
|
||||
* SO DO NOT ADD A PER-EDGE CHECK HERE. One was written and taken out again when the ruling
|
||||
* arrived. What survives is the weaker rule that was always here: the piece must touch the network
|
||||
* SOMEWHERE, which is what stops orphaned track being laid in an empty corner of the board.
|
||||
*/
|
||||
const ports: Port[] = ['n', 's', 'e', 'w'];
|
||||
for (const p of ports) {
|
||||
const neighbourCard = cardAt(area, neighbour(coord, p));
|
||||
|
||||
+30
-4
@@ -101,7 +101,13 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
async function serveStatic(distDir: string, urlPath: string, res: ServerResponse): Promise<void> {
|
||||
async function serveStatic(
|
||||
distDir: string,
|
||||
urlPath: string,
|
||||
res: ServerResponse,
|
||||
/** The request's `?v=` build tag, when it has one — see the `Cache-Control` note below. */
|
||||
buildTagged = false,
|
||||
): Promise<void> {
|
||||
const rel = urlPath === '/' ? '/index.html' : urlPath;
|
||||
// `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request
|
||||
// for `/../../etc/passwd` must not escape the one directory this is allowed to read from.
|
||||
@@ -113,7 +119,27 @@ async function serveStatic(distDir: string, urlPath: string, res: ServerResponse
|
||||
try {
|
||||
const info = await stat(full);
|
||||
if (!info.isFile()) throw new Error('not a file');
|
||||
res.writeHead(200, { 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', 'Content-Length': info.size });
|
||||
/**
|
||||
* ONLY A URL CARRYING A BUILD TAG MAY BE CACHED, AND NOTHING ELSE MAY BE.
|
||||
*
|
||||
* Nothing here sent a `Cache-Control` at all before, so a browser applied its own heuristic to
|
||||
* the pages as much as the modules. The pages are the one thing that CANNOT be versioned in
|
||||
* their own URL — a player types the address or follows a bookmark — so a cached `play.html`
|
||||
* pins that player to the entire build it names, including every `?v=` tag inside it. That is
|
||||
* half of why v0.7.5 and v0.7.6 did not reach the browser that asked for them; `build-web.ts`
|
||||
* publishing `?v=nogit` on every packaged release was the other half, and neither is enough on
|
||||
* its own.
|
||||
*
|
||||
* `?v=` is the exact condition rather than "not HTML": `build-web.ts` tags the modules and the
|
||||
* script tags that load them, and tags NOTHING else. An untagged URL — an image, the replay
|
||||
* manifest — has no way to announce a change, so a year of `immutable` on one would outlive
|
||||
* several releases of whatever it holds.
|
||||
*/
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[extname(full)] ?? 'application/octet-stream',
|
||||
'Content-Length': info.size,
|
||||
'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache',
|
||||
});
|
||||
createReadStream(full).pipe(res);
|
||||
} catch {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
@@ -281,7 +307,7 @@ export function startServer(opts: ServerOptions): void {
|
||||
// Unset means the routes are not here — indistinguishable from any other unknown path, so
|
||||
// nothing advertises an administrative surface to someone probing for one.
|
||||
if (!opts.adminSecret) {
|
||||
await serveStatic(opts.distDir, url.pathname, res);
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
return;
|
||||
}
|
||||
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
|
||||
@@ -700,7 +726,7 @@ export function startServer(opts: ServerOptions): void {
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(opts.distDir, url.pathname, res);
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
})().catch((err: unknown) => {
|
||||
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
||||
});
|
||||
|
||||
+67
-6
@@ -22,7 +22,7 @@ import { check } from '../engine/apply.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, submit } from '../web/game.ts';
|
||||
import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultiplayerGame, submit } from '../web/game.ts';
|
||||
import type { Game, Menu } from '../web/game.ts';
|
||||
import { deltaFrame } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
@@ -262,6 +262,52 @@ function buildSession(
|
||||
* in zero wall-clock time by definition. Called once at construction (a resume could land exactly
|
||||
* on a bot's turn) and once after every accepted human intent.
|
||||
*/
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the bots' half of a unanimous vote.
|
||||
*
|
||||
* "Bots will not disagree with the human. Humans get to vote first. If all humans vote yes, then
|
||||
* bots vote yes too. If a human votes no, it's not unanimous, it ends right then. If only bots are
|
||||
* playing, they never vote to extend" (Jesse, 2026-08-28).
|
||||
*
|
||||
* Which makes a bot's vote a formality rather than a policy decision, performed once the humans
|
||||
* have already settled it, so that the unanimity the engine checks is a real unanimity rather than
|
||||
* a special case carved into the rules for absent players.
|
||||
*
|
||||
* THE ALL-BOT TABLE IS THE CASE TO GET RIGHT, and getting it wrong hung the game. `driveBots`
|
||||
* cannot reach the vote — it loops on `currentActor`, which is null the moment the game stops —
|
||||
* so if this returns early with no humans to follow, nobody votes at all and a bot-only game sits
|
||||
* on the question for ever. It happened: an all-bot session never reached `finished`. With nobody
|
||||
* to follow, the bots' own answer stands, and it is no.
|
||||
*/
|
||||
function driveBotVotes(): void {
|
||||
if (game.state.status !== 'awaitingExtension') return;
|
||||
const humans = [...Array(playerNames.length).keys()].filter((p) => !botSeats.has(p));
|
||||
// A human who has voted `false` has already ended the game, so reaching here with humans still
|
||||
// outstanding means the table is genuinely waiting on a person. Bots wait with it.
|
||||
if (humans.length > 0 && !humans.every((p) => game.state.extensionVotes[p] === true)) return;
|
||||
const agree = humans.length > 0;
|
||||
for (const seat of botSeats) {
|
||||
if (game.state.extensionVotes[seat] === null) {
|
||||
submit(game, { type: 'game.extend', player: seat, agree }, seat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bots play, bots vote, and an agreed extension puts them back to playing — so the two drivers
|
||||
* alternate rather than running once each. Bounded because every pass must consume something: a
|
||||
* turn, or a vote that cannot be cast twice.
|
||||
*/
|
||||
function driveBotTurns(): void {
|
||||
for (let pass = 0; pass < 1_000; pass++) {
|
||||
const before = game.history.length;
|
||||
driveBots();
|
||||
driveBotVotes();
|
||||
if (game.history.length === before) return;
|
||||
}
|
||||
throw new Error('driveBotTurns: probable infinite loop');
|
||||
}
|
||||
|
||||
function driveBots(): void {
|
||||
let guard = 0;
|
||||
for (;;) {
|
||||
@@ -281,7 +327,7 @@ function buildSession(
|
||||
settleTiming();
|
||||
}
|
||||
}
|
||||
driveBots();
|
||||
driveBotTurns();
|
||||
// Whatever the opening bot turns earned belongs to a game nobody was connected to yet — dropped
|
||||
// here rather than fired at the first client to arrive. (It also stops `game.cues` growing without
|
||||
// bound on a server, which nothing was draining before this.)
|
||||
@@ -304,7 +350,17 @@ function buildSession(
|
||||
// never applied, so it is worth trying again (see the `lastSeq.set` below: only on success).
|
||||
if (lastSeq.get(seat) === seq) return { accepted: true, pushes: new Map(), timing: null };
|
||||
|
||||
if (seat !== currentActor(game)) return { accepted: false, code: 'NOT_YOUR_TURN' };
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the vote is the one intent with no actor to be.
|
||||
*
|
||||
* `currentActor` is null once the timetable has run out, so this guard would refuse every
|
||||
* vote with NOT_YOUR_TURN. Every seat may vote, and `check` is still the authority on whether
|
||||
* this particular seat may vote right now (it has voted already; the game is not waiting on a
|
||||
* vote at all), so skipping the turn test here gives nothing away.
|
||||
*/
|
||||
if (!isOutOfTurn(i) && seat !== currentActor(game)) {
|
||||
return { accepted: false, code: 'NOT_YOUR_TURN' };
|
||||
}
|
||||
|
||||
// Checked directly, rather than via `submit`'s boolean, for two reasons: `submit` writes a
|
||||
// "that is not allowed" line into the SHARED `game.log` on rejection, which would otherwise
|
||||
@@ -315,7 +371,7 @@ function buildSession(
|
||||
if (code) return { accepted: false, code };
|
||||
|
||||
const drawnBefore = game.justDrawn;
|
||||
const applied = submit(game, i);
|
||||
const applied = submit(game, i, isOutOfTurn(i) ? seat : null);
|
||||
if (game.justDrawn !== drawnBefore && game.justDrawn !== null) {
|
||||
lastDraw = { seat, cardId: game.justDrawn };
|
||||
}
|
||||
@@ -327,8 +383,9 @@ function buildSession(
|
||||
const timing = settleTiming();
|
||||
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism
|
||||
// diffs against whatever was last sent, so it captures the bots' moves along with the human's
|
||||
// in one push regardless of how many turns that took.
|
||||
driveBots();
|
||||
// in one push regardless of how many turns that took. Votes included, since Gitea#11: a human
|
||||
// agreeing to another Day is exactly the move the bots are waiting on to agree themselves.
|
||||
driveBotTurns();
|
||||
return { accepted: true, pushes: pushesForAll(), timing };
|
||||
},
|
||||
|
||||
@@ -338,6 +395,8 @@ function buildSession(
|
||||
config: game.state.config,
|
||||
playerNames: [...playerNames],
|
||||
history: [...game.history],
|
||||
// `awaitingExtension` is a game waiting on its table, not a game that is over — so it maps
|
||||
// to 'active' and `server/index.ts` resumes it on a restart like any other (Gitea#11).
|
||||
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||
createdAt,
|
||||
botSeats: [...botSeats],
|
||||
@@ -351,6 +410,8 @@ function buildSession(
|
||||
playerCount: playerNames.length,
|
||||
playerNames: [...playerNames],
|
||||
botSeats: [...botSeats],
|
||||
// `awaitingExtension` is a game waiting on its table, not a game that is over — so it maps
|
||||
// to 'active' and `server/index.ts` resumes it on a restart like any other (Gitea#11).
|
||||
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||
createdAt,
|
||||
lastMoveAt,
|
||||
|
||||
+278
-163
@@ -55,17 +55,41 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
*
|
||||
* West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
|
||||
*
|
||||
* SEATING. Players sit around a table, so the route is laid out the way they do: one row alone,
|
||||
* two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop
|
||||
* — trains enter at one Division Point and leave at the other — so the shape is deliberately left
|
||||
* open, with the two ends drawn as buffer stops facing each other across a marked gap. Closing it
|
||||
* into a ring would promise a connection the rules do not have.
|
||||
* ONE ROW AT EVERY SEAT COUNT (Gitea#18). It used to be laid out the way players sit — two rows
|
||||
* facing, a horseshoe of three, a square of four — and the reasoning for dropping that is at the
|
||||
* layout itself below. The Division is a LINE and not a loop — trains enter at one Division Point
|
||||
* and leave at the other — so the row is deliberately left open, with the two ends drawn as
|
||||
* buffer stops facing outward. Closing it into a ring would promise a connection the rules do
|
||||
* not have.
|
||||
*
|
||||
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
||||
* anything outside its own body.
|
||||
*/
|
||||
const CW = { dp: 118, ml: 152, run: 78 };
|
||||
const CH = 58;
|
||||
/**
|
||||
* TALL ENOUGH FOR TWO REGISTERS OF CHIPS, on every cell so the rail runs level across the row.
|
||||
* Was 58, when a cell held one row of trains.
|
||||
*/
|
||||
const CH = 76;
|
||||
/**
|
||||
* EVERY DISTRICT THE SAME WIDTH, sized for four chips two-by-two and NOT for its A/D count.
|
||||
*
|
||||
* Measured over 60 games: one office area holds at most 4 distinct trains, and up to 3 of those
|
||||
* can be crews switching below the Running Track — which do not occupy A/D tracks at all. So a
|
||||
* Whistle Post, with its single A/D track, can still have four trains to show, and sizing the cell
|
||||
* by capacity would overflow it. Sizing by OCCUPANCY is worse still: that is what "The Roster
|
||||
* Pass" fixed, because the cell then resizes as trains come and go and shoves the rest of the map
|
||||
* sideways. A fixed two-by-two block holds the map still all game, upgrades included.
|
||||
*/
|
||||
const OFFICE_W = 2 * 54 + 12;
|
||||
/**
|
||||
* THE VERTICAL ANATOMY OF A CELL, so the two chip registers and the rail cannot drift apart.
|
||||
* The rail sits above centre; A/D chips straddle it, and the district register hangs below —
|
||||
* which is where those trains are on the real board (Gitea#18).
|
||||
*/
|
||||
const RAIL_Y = 34;
|
||||
const CHIP_Y = RAIL_Y - 10;
|
||||
const BELOW_Y = RAIL_Y + 13;
|
||||
const GAP = 6;
|
||||
/**
|
||||
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
|
||||
@@ -85,7 +109,6 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
* this is back to what a buffer stop actually needs.
|
||||
*/
|
||||
const PAD = 22;
|
||||
const SIDE_GAP = 34;
|
||||
|
||||
const esc = (t: string): string =>
|
||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||
@@ -115,18 +138,27 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
seat: number | null;
|
||||
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
||||
owner?: { name: string; isTurn: boolean; isYou: boolean } | null;
|
||||
/**
|
||||
* Office cells only: trains in the district that are NOT holding an A/D track — a crew switching
|
||||
* below the Running Track, or a train standing on it away from the Office. Drawn in a second
|
||||
* register under the rail (Gitea#18).
|
||||
*/
|
||||
below?: Cell['trains'];
|
||||
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||||
regions: number;
|
||||
/**
|
||||
* Which way a Heavy Grade climbs, or null on every other card. Drawn as a wedge, because the
|
||||
* tooltip said "climbs east" and the card itself showed nothing — so the one card whose
|
||||
* orientation the PLAYER chooses was the one card you had to hover to read (Jesse, 2026-08-30).
|
||||
*/
|
||||
gradeUp?: string | null;
|
||||
w: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
const cells: Cell[] = [];
|
||||
const sides: number[][] = [];
|
||||
let side: number[] = [];
|
||||
|
||||
const push = (c: Omit<Cell, 'x' | 'y'>): void => {
|
||||
side.push(cells.length);
|
||||
cells.push({ ...c, x: 0, y: 0 });
|
||||
};
|
||||
|
||||
@@ -152,47 +184,54 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
}
|
||||
: null;
|
||||
|
||||
for (const rc of n.running ?? []) {
|
||||
const isOffice = rc.kind === 'office';
|
||||
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
|
||||
push({
|
||||
kind: 'run',
|
||||
label: isOffice && owner ? owner.name : rc.label,
|
||||
owner: isOffice ? owner : null,
|
||||
// With an owner on the headline the tier would otherwise vanish, so it joins the A/D
|
||||
// count on the line below.
|
||||
sub: isOffice ? (owner ? [rc.label, adLabel].filter(Boolean).join(' · ') : adLabel) : '',
|
||||
/**
|
||||
* A train standing at the Office occupies an A/D track, which is where it is — but it is
|
||||
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
|
||||
* drawn twice. Reported as two T10 chips on one Office.
|
||||
*/
|
||||
trains: isOffice
|
||||
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
|
||||
: rc.trains,
|
||||
cap: isOffice ? cap : null,
|
||||
tip: owner && isOffice
|
||||
? `${owner.name}'s ${rc.label}` +
|
||||
(owner.isYou ? ' — this is your railroad' : '') +
|
||||
// "their move" is wrong when the reader is the one being waited on.
|
||||
(owner.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '')
|
||||
: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
|
||||
seat: n.seat ?? null,
|
||||
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
|
||||
// occupies a card outright rather than a part of one.
|
||||
regions: 0,
|
||||
w: isOffice && cap !== null ? Math.max(CW.run, cap * CHIP_W + 12) : CW.run,
|
||||
});
|
||||
/**
|
||||
* ONE CELL PER DISTRICT — NO OFFICE-AREA DETAIL ON THIS MAP (Gitea#18).
|
||||
*
|
||||
* An Office used to expand into its whole Running Track, Limits to Limits, so this map carried
|
||||
* every straight, turnout, facility and Limits sign of every district. Two things were wrong
|
||||
* with that. It is the OFFICE map's job, and it draws all of it properly, with the rails; and
|
||||
* it made the Division map grow sideways as districts were built, shoving everything east of a
|
||||
* district along every time somebody laid a card.
|
||||
*
|
||||
* TRAINS STAY. "Trains within the office area should definitely be represented on the division
|
||||
* map" — at a glance the number and which way it is pointing, and the consist on the tooltip.
|
||||
* They are split into two registers, because a train holding an A/D track and a crew switching
|
||||
* in the district are not the same thing: A/D occupancy is a hard capacity that causes
|
||||
* collisions, switching is not. The split is drawn as POSITION rather than colour — A/D on the
|
||||
* rail, the rest below it — which is where those trains actually are.
|
||||
*/
|
||||
const seen = new Set(ad.map((t) => t.label));
|
||||
const below: typeof ad = [];
|
||||
for (const t of [...(n.running ?? []).flatMap((rc) => rc.trains), ...(n.switching ?? [])]) {
|
||||
if (seen.has(t.label)) continue;
|
||||
seen.add(t.label);
|
||||
below.push(t);
|
||||
}
|
||||
// A crew below the Running Track has no position ON it, so it is reported against the
|
||||
// district rather than drawn somewhere it is not.
|
||||
const below = n.switching ?? [];
|
||||
if (below.length > 0) {
|
||||
const last = cells[cells.length - 1];
|
||||
if (last) last.sub = `${below.length} switching below`;
|
||||
}
|
||||
sides.push(side);
|
||||
side = [];
|
||||
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
|
||||
push({
|
||||
kind: 'run',
|
||||
label: owner ? owner.name : n.label,
|
||||
owner,
|
||||
sub: [owner ? n.label : '', adLabel, below.length > 0 ? `${below.length} switching` : '']
|
||||
.filter(Boolean)
|
||||
.join(' \u00b7 '),
|
||||
trains: ad,
|
||||
below,
|
||||
cap,
|
||||
tip:
|
||||
(owner ? `${owner.name}'s ${n.label}` : n.label) +
|
||||
(owner?.isYou ? ' — this is your railroad' : '') +
|
||||
// "their move" is wrong when the reader is the one being waited on.
|
||||
(owner?.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '') +
|
||||
`\n\nThe district itself is drawn on the Office map — this cell is the whole of it, with the ` +
|
||||
`trains standing in it: those holding an A/D track on the rail, and any crew switching in ` +
|
||||
`the district below it.`,
|
||||
seat: n.seat ?? null,
|
||||
// No regions in a district: a crew moves by Moves there, not by Stages, so it occupies a
|
||||
// card outright rather than a part of one.
|
||||
regions: 0,
|
||||
w: OFFICE_W,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dp = n.kind === 'dp';
|
||||
@@ -226,61 +265,35 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
seat: null,
|
||||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||||
regions: dp ? 1 : (n.regions ?? 0),
|
||||
gradeUp: dp ? null : (n.gradeUp ?? null),
|
||||
w: dp ? CW.dp : CW.ml,
|
||||
});
|
||||
}
|
||||
if (side.length > 0) sides.push(side);
|
||||
|
||||
// Each player's side carries their district and the Mainline card leading into it; whatever is
|
||||
// left over (the last Mainline and the East DP) joins the final side.
|
||||
const seats = Math.max(1, Math.min(4, nodes.filter((n) => n.kind === 'office').length));
|
||||
const lanes: number[][] = [];
|
||||
for (let i = 0; i < seats; i++) lanes.push([]);
|
||||
sides.forEach((grp, i) => {
|
||||
const target = Math.min(i, seats - 1);
|
||||
for (const idx of grp) lanes[target]!.push(idx);
|
||||
});
|
||||
|
||||
// -- lay the sides out around the table -------------------------------------------------------
|
||||
// top → right → bottom (reversed) → left (reversed), which gives a row, two facing rows, a
|
||||
// horseshoe open to the west, and a square broken at the same place.
|
||||
const dir: ('top' | 'right' | 'bottom' | 'left')[] =
|
||||
seats === 1 ? ['top'] : seats === 2 ? ['top', 'bottom'] : seats === 3 ? ['top', 'right', 'bottom'] : ['top', 'right', 'bottom', 'left'];
|
||||
|
||||
const runLen = (idxs: number[]): number =>
|
||||
idxs.reduce((n, i) => n + cells[i]!.w + GAP, -GAP);
|
||||
const widest = Math.max(...lanes.map((l) => runLen(l)), 200);
|
||||
const tall = lanes.length > 1 ? Math.max(...lanes.map((l) => l.length), 1) * (CH + GAP) : CH;
|
||||
|
||||
const vertCount = dir.filter((d) => d === 'right' || d === 'left').length;
|
||||
const boardW = PAD * 2 + widest + (vertCount > 0 ? CW.run + SIDE_GAP : 0);
|
||||
const boardH = PAD * 2 + (dir.includes('bottom') ? CH * 2 + SIDE_GAP + (vertCount ? tall : 0) : CH) + 30;
|
||||
|
||||
lanes.forEach((idxs, i) => {
|
||||
const d = dir[i]!;
|
||||
if (d === 'top' || d === 'bottom') {
|
||||
const y = d === 'top' ? PAD : boardH - PAD - CH - 22;
|
||||
const order = d === 'bottom' ? [...idxs].reverse() : idxs;
|
||||
let x = PAD;
|
||||
for (const idx of order) {
|
||||
const c = cells[idx]!;
|
||||
c.x = x;
|
||||
c.y = y;
|
||||
x += c.w + GAP;
|
||||
}
|
||||
} else {
|
||||
const x = d === 'right' ? boardW - PAD - CW.run : PAD;
|
||||
const order = d === 'left' ? [...idxs].reverse() : idxs;
|
||||
let y = PAD + CH + SIDE_GAP;
|
||||
for (const idx of order) {
|
||||
const c = cells[idx]!;
|
||||
c.x = x;
|
||||
c.y = y;
|
||||
c.w = CW.run;
|
||||
y += CH + GAP;
|
||||
}
|
||||
}
|
||||
});
|
||||
/**
|
||||
* ONE ROW, WEST TO EAST (Gitea#18). The West Division Point is at the far left, the East at the
|
||||
* far right, and nothing wraps.
|
||||
*
|
||||
* IT USED TO BE LAID OUT AROUND A TABLE — one row for a single seat, two facing rows for two, a
|
||||
* horseshoe for three, a square for four — on the reasoning that players sit around a table so the
|
||||
* route should too. That cost more than it bought, and three separate reports came out of it: the
|
||||
* buffer stops pointed the wrong way once the route turned a corner, and, the one that decided it,
|
||||
* **east stopped being to the right**. A player's east could be drawn south, west or north
|
||||
* depending on which lane their district landed in, on a map whose whole job is saying which way
|
||||
* a train is going.
|
||||
*
|
||||
* A row is wider than a square — roughly 1,580px at four players against 842 — and that is
|
||||
* accepted: the map scrolls and zooms, and being able to rely on east meaning right is worth the
|
||||
* scroll.
|
||||
*/
|
||||
let x = PAD;
|
||||
for (const c of cells) {
|
||||
c.x = x;
|
||||
c.y = PAD;
|
||||
x += c.w + GAP;
|
||||
}
|
||||
const boardW = x - GAP + PAD;
|
||||
const boardH = PAD * 2 + CH + 30;
|
||||
|
||||
// -- draw -------------------------------------------------------------------------------------
|
||||
const rail = (x1: number, y: number, x2: number): string => {
|
||||
@@ -295,20 +308,29 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
return o;
|
||||
};
|
||||
|
||||
// The same rail turned through ninety degrees, for the sides of the table.
|
||||
const railV = (x: number, y1: number, y2: number): string => {
|
||||
let o =
|
||||
`<line class="bs-rail" x1="${x - 2.5}" y1="${y1}" x2="${x - 2.5}" y2="${y2}"/>` +
|
||||
`<line class="bs-rail" x1="${x + 2.5}" y1="${y1}" x2="${x + 2.5}" y2="${y2}"/>`;
|
||||
const n = Math.max(2, Math.floor(Math.abs(y2 - y1) / 9));
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const ty = y1 + ((y2 - y1) * i) / n;
|
||||
o += `<line class="bs-tie" x1="${x - 4.5}" y1="${ty}" x2="${x + 4.5}" y2="${ty}"/>`;
|
||||
}
|
||||
return o;
|
||||
};
|
||||
|
||||
let out = `<svg class="bs bs-div" viewBox="0 0 ${Math.ceil(boardW)} ${Math.ceil(boardH)}" preserveAspectRatio="xMinYMin meet">`;
|
||||
/**
|
||||
* DRAWN AT ITS OWN SIZE, SO IT SCROLLS RATHER THAN SHRINKING (Gitea#18).
|
||||
*
|
||||
* An SVG has a viewBox and a drawn size, and the browser scales one to the other. `.bs` is
|
||||
* `width:100%`, so the map is drawn at whatever the panel is wide — which was harmless while the
|
||||
* Division was 842px and wrapped around a table, and is not now that a single row is 1,580px. At
|
||||
* that width in an 800px panel every label renders at half size, on the map that needs reading
|
||||
* most. Setting the width to the viewBox width makes one unit one pixel, and the containers
|
||||
* already scroll (`#division`, `#vdivision`).
|
||||
*
|
||||
* THE PLAYABLE PAGE DOES NOT NEED THIS — `applyZoom` (`main.ts`) sets exactly the same width from
|
||||
* the same viewBox after every render, and overrides this when the zoom is not 100%. THE REPLAYS
|
||||
* DO: neither `replays.ts` nor the standalone `replay.ts` calls it, so without this they get the
|
||||
* `width:100%` shrink. It is inline rather than in `BOARD_CSS` because only this function knows
|
||||
* how wide the row came out.
|
||||
*
|
||||
* `flex:none` because `#division` is a flex container and a flex item may be shrunk below an
|
||||
* explicit width; there is no point pinning it and then letting the panel squeeze it anyway.
|
||||
*/
|
||||
let out =
|
||||
`<svg class="bs bs-div" viewBox="0 0 ${Math.ceil(boardW)} ${Math.ceil(boardH)}" ` +
|
||||
`style="width:${Math.ceil(boardW)}px;flex:none" ` +
|
||||
`preserveAspectRatio="xMinYMin meet">`;
|
||||
|
||||
// The joins between consecutive cells, drawn as rail so a connection is rail meeting rail. A join
|
||||
// that crosses from one player's side to the next is drawn heavier and labelled: that boundary is
|
||||
@@ -316,23 +338,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
for (let i = 0; i + 1 < cells.length; i++) {
|
||||
const a = cells[i]!;
|
||||
const b = cells[i + 1]!;
|
||||
const sameRow = Math.abs(a.y - b.y) < 1;
|
||||
const sameCol = Math.abs(a.x - b.x) < 1;
|
||||
if (sameRow && b.x > a.x) out += rail(a.x + a.w, a.y + CH / 2, b.x);
|
||||
else if (sameRow && b.x < a.x) out += rail(b.x + b.w, a.y + CH / 2, a.x);
|
||||
else if (sameCol) {
|
||||
// Stacked down one side of the table: still one straight run of track, not a turn.
|
||||
const top = Math.min(a.y + CH, b.y + CH);
|
||||
const bot = Math.max(a.y, b.y);
|
||||
out += railV(a.x + a.w / 2, top, bot);
|
||||
} else {
|
||||
// A turn between sides: an elbow, so the route is visibly continuous around the table.
|
||||
const ax = a.x + a.w / 2;
|
||||
const bx = b.x + b.w / 2;
|
||||
const ay = a.y + CH;
|
||||
const by = b.y;
|
||||
out += `<path class="bs-turn" d="M${ax} ${ay} L${ax} ${(ay + by) / 2} L${bx} ${(ay + by) / 2} L${bx} ${by}"/>`;
|
||||
}
|
||||
out += rail(a.x + a.w, a.y + RAIL_Y, b.x);
|
||||
}
|
||||
|
||||
cells.forEach((c) => {
|
||||
@@ -348,15 +354,81 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
const mark = c.owner ? ` bs-owner${c.owner.isTurn ? ' bs-turn' : ''}${c.owner.isYou ? ' bs-you' : ''}` : '';
|
||||
const suffix = c.owner?.isYou ? ' (you)' : '';
|
||||
out += `<text class="bs-name${mark}" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label + suffix)}</text>`;
|
||||
out += rail(c.x + 6, c.y + 32, c.x + c.w - 6);
|
||||
out += rail(c.x + 6, c.y + RAIL_Y, c.x + c.w - 6);
|
||||
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
|
||||
|
||||
// REGIONS. §2.1 divides a Mainline card into two, and §8.2 moves a train one region per Stage.
|
||||
// The bars are the card's DISTANCE and never vary; what varies is how fast a train covers them,
|
||||
// so a 60 card is crossed in one Stage and a slow train on a 30 takes three.
|
||||
// REGIONS. A Mainline card is 1 to 3 of them (Gitea#3) and a train advances one per Stage. The
|
||||
// bars are the card's DISTANCE and never vary; where a train STARTS is what does.
|
||||
const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0;
|
||||
for (let r = 0; r < c.regions; r++) {
|
||||
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
|
||||
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + RAIL_Y - 14}" x2="${c.x + 6 + RW * r}" y2="${c.y + RAIL_Y + 10}"/>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
|
||||
*
|
||||
* The tooltip has said "climbs east" since the Frame carried `gradeUp`, and the card showed
|
||||
* nothing — so the one Mainline card whose orientation the PLAYER sets, and the one where a
|
||||
* Helpers or Brakeman modifier means opposite things at opposite ends, was the one you had to
|
||||
* hover to read (Jesse, 2026-08-30).
|
||||
*
|
||||
* A wedge rising toward the climb, with an arrow up its slope. Two cues rather than one: the
|
||||
* wedge alone asks the reader to judge which end is taller, which at eleven pixels of rise is a
|
||||
* comparison rather than a glance. Bottom-right, clear of the left-aligned capacity line and
|
||||
* below the region bars, so it never lands under a train chip.
|
||||
*
|
||||
* `east` is RIGHT on this map and always has been (Gitea#18) — that is what makes a wedge
|
||||
* readable without a compass, and it is why the row layout is worth its width.
|
||||
*/
|
||||
if (c.gradeUp === 'east' || c.gradeUp === 'west') {
|
||||
const s = c.gradeUp === 'east' ? 1 : -1;
|
||||
const GW = 58;
|
||||
const RISE = 24;
|
||||
const gx1 = c.x + c.w - 9 - GW;
|
||||
const gx2 = c.x + c.w - 9;
|
||||
const yb = c.y + CH - 6;
|
||||
const peakX = s === 1 ? gx2 : gx1;
|
||||
out += `<polygon class="bs-grade" points="${gx1},${yb} ${gx2},${yb} ${peakX},${yb - RISE}"/>`;
|
||||
|
||||
/**
|
||||
* THE ARROW LIES ALONG THE WEDGE'S OWN SLOPE, CENTRED IN IT (Jesse, 2026-08-30).
|
||||
*
|
||||
* Parallel to the hypotenuse is the shape that fits: the perpendicular gap to the slope is
|
||||
* then CONSTANT along the whole arrow, instead of closing at one end the way a steeper line
|
||||
* does. The earlier 30° pass had to be tucked into the fat half to survive, because 30° is
|
||||
* steeper than this wedge climbs — at 58×24 the slope is 22.5°, and the arrow simply lies on
|
||||
* it.
|
||||
*
|
||||
* Centred on the TRIANGLE'S CENTROID (2/3 along the base, 1/3 up), which is the balance point
|
||||
* of the form rather than of its bounding box — centring on the box would push the arrow into
|
||||
* the thin corner where there is no height for it.
|
||||
*
|
||||
* The wedge grew 50×22 → 58×24 to pay for that: the centroid sits only ~7px from the
|
||||
* hypotenuse, so a centred arrow has less room than an off-centre one and needs a bigger form
|
||||
* to keep it. Sizes are the best fit found by search, clearing every edge by 2.88px;
|
||||
* `web.test.ts` re-derives it and fails under 2px.
|
||||
*
|
||||
* Worked in the wedge's own frame — `u` along the base from the thin corner, `h` up from it —
|
||||
* so a westward climb is one sign on `u` rather than a second set of coordinates.
|
||||
*/
|
||||
const L = 20;
|
||||
const HL = 6;
|
||||
const HW = 3.5;
|
||||
const T = 1.4;
|
||||
const A = Math.atan2(RISE, GW);
|
||||
const cos = Math.cos(A);
|
||||
const sin = Math.sin(A);
|
||||
const cu = (2 * GW) / 3;
|
||||
const ch = RISE / 3;
|
||||
const pt = (lx: number, ly: number): string => {
|
||||
const u = cu + lx * cos - ly * sin;
|
||||
const h = ch + lx * sin + ly * cos;
|
||||
return `${s === 1 ? gx1 + u : gx2 - u},${yb - h}`;
|
||||
};
|
||||
const H = L / 2;
|
||||
out +=
|
||||
`<polygon class="bs-gradeup" points="${pt(-H, -T)} ${pt(H - HL, -T)} ${pt(H - HL, -HW)} ` +
|
||||
`${pt(H, 0)} ${pt(H - HL, HW)} ${pt(H - HL, T)} ${pt(-H, T)}"/>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,36 +443,25 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
* So this keeps the two things the Division map is actually for — where a train is and which way
|
||||
* it is going — and leaves the cars to the tooltip and to the district.
|
||||
*/
|
||||
c.trains.forEach((t, k) => {
|
||||
/**
|
||||
* A TRAIN IS A CHIP — its number, which way it points, and how many cars.
|
||||
*
|
||||
* It was drawn as a full consist here, matching the Office Area card, and reported as too large
|
||||
* and hard to read. The Office card is where a consist is worth drawing, because that is where
|
||||
* the switching decisions are made and where there is room to read it. So this keeps the two
|
||||
* things the Division map is for — where a train is and which way it is going — and leaves the
|
||||
* cars to the tooltip and to the district.
|
||||
*/
|
||||
const chip = (t: NonNullable<Cell['trains']>[number], tx: number, ty: number, w: number): void => {
|
||||
const cars = t.cars ?? [];
|
||||
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
||||
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
|
||||
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
|
||||
|
||||
/**
|
||||
* THE OFFICE RUNNING CELL GETS FIXED SLOTS, ONE PER A/D TRACK — never a centre spread.
|
||||
*
|
||||
* Centred spreading pushes its outer chips outward as MORE trains arrive, and the cell was
|
||||
* sized for the cards it holds, not for its trains — so two chips at a Station used to land at
|
||||
* x 215–267 and 271–316 inside a cell spanning only 230–308, spilling onto the Limits cards
|
||||
* either side. A fixed slot per A/D track cannot overflow the cell at any occupancy, because
|
||||
* the cell was sized for exactly that many slots (see `CHIP_W` above).
|
||||
*/
|
||||
const isOfficeRun = c.kind === 'run' && c.cap !== null && c.cap > 0;
|
||||
const slotW = isOfficeRun ? (c.w - 12) / c.cap! : 0;
|
||||
const w = isOfficeRun ? Math.min(slotW - 4, label.length * 6.6 + 12) : Math.min(c.w - 8, label.length * 6.6 + 12);
|
||||
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
|
||||
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
||||
const tx = isOfficeRun
|
||||
? c.x + 6 + slotW * (k + 0.5)
|
||||
: (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) +
|
||||
(inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4));
|
||||
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
|
||||
const stages =
|
||||
typeof t.stagesLeft === 'number'
|
||||
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` +
|
||||
' (Stages, not regions: a card is two regions of fixed distance, and how many Stages a' +
|
||||
' train takes over them depends on the card speed and the train)'
|
||||
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card`
|
||||
: '';
|
||||
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
||||
cars.length ? ` (${loaded} loaded)` : ''
|
||||
@@ -409,15 +470,61 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
// me?" gets asked, and EXPEDITED is the answer more often than not.
|
||||
t.what ? `\n\n${esc(t.what)}` : ''
|
||||
}">` +
|
||||
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
|
||||
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(label)}</text>`;
|
||||
`<rect x="${tx - w / 2}" y="${ty}" width="${w}" height="19" rx="3"/>` +
|
||||
`<text class="bs-tlab" x="${tx}" y="${ty + 13}" text-anchor="middle">${esc(label)}</text>`;
|
||||
out += '</g>';
|
||||
};
|
||||
|
||||
const textW = (t: NonNullable<Cell['trains']>[number]): number =>
|
||||
(`${t.label} \u25b6${(t.cars ?? []).length || ''}`).length * 6.6 + 12;
|
||||
|
||||
/**
|
||||
* TWO CHIPS TO A REGISTER ON A DISTRICT, in fixed slots — never a centre spread.
|
||||
*
|
||||
* Centred spreading pushes its outer chips outward as more trains arrive, which is how two chips
|
||||
* at a Station once landed outside the cell that held them. Fixed slots cannot overflow, because
|
||||
* the cell was sized for exactly that many (`OFFICE_W`).
|
||||
*/
|
||||
const isDistrict = c.kind === 'run';
|
||||
const SLOTS = 2;
|
||||
const slotW = (c.w - 12) / SLOTS;
|
||||
c.trains.forEach((t, k) => {
|
||||
if (isDistrict) {
|
||||
// Row-major within the A/D register: two across, then wrap under. A district can hold four
|
||||
// trains and only two fit across it.
|
||||
const col = k % SLOTS;
|
||||
const row = Math.floor(k / SLOTS);
|
||||
chip(t, c.x + 6 + slotW * (col + 0.5), c.y + CHIP_Y + row * 21, Math.min(slotW - 4, textW(t)));
|
||||
return;
|
||||
}
|
||||
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
|
||||
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
||||
const w = Math.min(c.w - 8, textW(t));
|
||||
const tx = (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) +
|
||||
(inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4));
|
||||
chip(t, tx, c.y + CHIP_Y, w);
|
||||
});
|
||||
|
||||
/**
|
||||
* THE SECOND REGISTER, under the rail: trains in the district that hold no A/D track (Gitea#18).
|
||||
*
|
||||
* A crew switching below the Running Track and a train standing at an A/D track are different
|
||||
* things — A/D occupancy is a hard capacity that causes collisions, switching is not — and the
|
||||
* difference is drawn as POSITION rather than as a colour to learn, because below the rail is
|
||||
* where those trains actually are.
|
||||
*/
|
||||
(c.below ?? []).forEach((t, k) => {
|
||||
const col = k % SLOTS;
|
||||
const row = Math.floor(k / SLOTS);
|
||||
chip(t, c.x + 6 + slotW * (col + 0.5), c.y + BELOW_Y + row * 21, Math.min(slotW - 4, textW(t)));
|
||||
});
|
||||
|
||||
out += '</g>';
|
||||
});
|
||||
|
||||
// THE ENDS. The route stops at both Division Points; drawing buffer stops and naming the gap is
|
||||
// what stops a seated layout being read as a loop.
|
||||
// THE ENDS. The route stops at both Division Points, and the buffer stops say so — a Division is
|
||||
// a LINE, not a loop. With a single row (Gitea#18) they simply face outward at the two ends, west
|
||||
// on the left and east on the right, which is the bug reported twice against the wrapped layout.
|
||||
const first = cells[0];
|
||||
const last = cells[cells.length - 1];
|
||||
/**
|
||||
@@ -1090,6 +1197,14 @@ export const BOARD_CSS = `
|
||||
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
|
||||
train is measured against, not something to look at instead of the train. */
|
||||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||
/* THE HEAVY GRADE WEDGE. Terrain, so it is coloured as terrain rather than as a warning.
|
||||
SOLID BROWN, fill and border the same (Jesse, 2026-08-30) — the first pass paired a desaturated
|
||||
fill with an amber arrow and the pair read reddish, which on a map that spends amber on "it is
|
||||
happening here" made a fixed piece of landscape look like a live alert. One flat brown recedes
|
||||
into scenery; the arrow is bone so the DIRECTION, which is the fact being reported, is the part
|
||||
that carries. */
|
||||
.bs-grade{fill:#6b5334;stroke:#6b5334;stroke-width:1}
|
||||
.bs-gradeup{fill:#f2e8d5}
|
||||
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
||||
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
|
||||
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they
|
||||
|
||||
+72
-16
@@ -35,7 +35,7 @@ import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { coordKey, turnOf } from '../engine/state.ts';
|
||||
import { actingPlayer, coordKey, turnOf } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
@@ -138,13 +138,39 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
||||
|
||||
choose(s, player, options) {
|
||||
lastReason = 'no specific reason — first legal option';
|
||||
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — a bot never asks for another Day.
|
||||
*
|
||||
* "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28), which is what keeps
|
||||
* the balance harness and every bot-only game ending at the timetable it was dealt with. It also
|
||||
* makes this the SAFE DEFAULT everywhere else: a bot's agreement in a game with humans in it is
|
||||
* decided by `server/session.ts`, which votes on the bots' behalf only once every human has
|
||||
* already said yes, and never reaches this policy at all.
|
||||
*/
|
||||
const extend = options.find((i) => i.type === 'game.extend' && i.agree === false);
|
||||
if (extend) return because('a bot plays the timetable it was dealt and no more', extend);
|
||||
|
||||
const clearance = ruleOnClearance(options);
|
||||
if (clearance) return because('the Superintendent must rule on a following train', clearance);
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — the bot keeps its trains at the Train Order Office.
|
||||
*
|
||||
* A deliberate policy, not an oversight, and the cautious half of a real choice: the Yard Office
|
||||
* frees an A/D track, which is worth something on a busy district, but the lead into it may be
|
||||
* fouled and the bot does not read its own yard well enough to tell (`TODO.md`, Bot
|
||||
* Performance — it cannot spot a car at a stub industry either). Declining is always safe, and
|
||||
* it keeps the balance harness comparable with every measurement taken before this rule existed.
|
||||
* Worth revisiting when the bot can judge the lead.
|
||||
*/
|
||||
const yardOffice = options.find((i) => i.type === 'mainline.yardOffice' && i.take === false);
|
||||
if (yardOffice) return because('the bot does not judge the lead into a yard, so it stays at the Office', yardOffice);
|
||||
|
||||
// Red Flags come before anything else — protection is only worth playing at the moment the
|
||||
// collision is actually pending, and that moment passes.
|
||||
const flags = worthFlagging(s, options);
|
||||
if (flags) return because('a train of ours is stopped on a Mainline card with another train on it — Red Flags now or not at all', flags);
|
||||
if (flags) return because('the engine says this arrival collides, and we hold a Red Flag — now or never', flags);
|
||||
|
||||
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
|
||||
//
|
||||
@@ -1017,19 +1043,20 @@ function facilityWantsAt(
|
||||
}
|
||||
|
||||
/**
|
||||
* Red Flags — "any time". Worth spending only when a train of ours is stopped out on the Mainline
|
||||
* with another train on the same card, which is the situation that becomes a rear-ender.
|
||||
* §Q, RED FLAGS (Gitea#19) — spent only at the moment of danger.
|
||||
*
|
||||
* The card was redefined: it plants a directional flag on your own Limits rather than protecting a
|
||||
* stopped train out on the Mainline, so the old heuristic ("is a train of ours sharing a Mainline
|
||||
* card") no longer describes anything the card does.
|
||||
*
|
||||
* The bot now flags ONLY through the out-of-phase prompt, which the engine raises exactly when an
|
||||
* arrival would collide (`redFlagStop`). That is a better policy than the old one and a much
|
||||
* simpler one: the engine has already established the danger, so there is nothing for the bot to
|
||||
* judge. It never plants a flag speculatively — it cannot tell whether it wants time to switch, and
|
||||
* a flag spent early is a flag not there when a train is actually bearing down.
|
||||
*/
|
||||
function worthFlagging(s: GameState, options: Intent[]): Intent | null {
|
||||
for (const i of options) {
|
||||
if (i.type !== 'maneuver.redFlags') continue;
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray || tray.position.at !== 'mainline') continue;
|
||||
const node = s.division.nodes[tray.position.index];
|
||||
if (node?.kind !== 'mainline') continue;
|
||||
if (node.transits.length > 1) return i;
|
||||
}
|
||||
return null;
|
||||
function worthFlagging(_s: GameState, options: Intent[]): Intent | null {
|
||||
return options.find((i) => i.type === 'mainline.redFlag' && i.flag === true) ?? null;
|
||||
}
|
||||
|
||||
/** A one-line account of which Load/Unload action was taken, and why it ranked first. */
|
||||
@@ -1861,8 +1888,37 @@ export function playGame(
|
||||
tally(pumpFn(s));
|
||||
if (s.status === 'finished') break;
|
||||
|
||||
const actor =
|
||||
s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — a simulated game plays the timetable it was dealt.
|
||||
*
|
||||
* DECIDED BY THE DRIVER, not by the policy, and that distinction is the whole point. A bot that
|
||||
* is merely handed the two votes among its legal options will sometimes take another Day —
|
||||
* `randomBot` does so half the time — and since the table can go on granting Days for ever, the
|
||||
* game then runs until `maxTurns`. That is not a hypothetical: it turned `test/sim.test.ts` from
|
||||
* under a second into an unbounded hang, because every seeded game in the harness suddenly played
|
||||
* fifty thousand turns instead of two hundred.
|
||||
*
|
||||
* The harness exists to measure games of a configured length against a configured floor, so
|
||||
* "would you like more Days?" has one answer here whatever the policy. `developerBot` declines on
|
||||
* its own account too, which is what the server relies on when a table is all bots; this is the
|
||||
* guarantee that holds for every OTHER policy, including ones not written yet.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') {
|
||||
const voter = s.extensionVotes.findIndex((v) => v === null);
|
||||
if (voter < 0) break;
|
||||
const decline: Intent = { type: 'game.extend', player: voter, agree: false };
|
||||
intents.push(decline.type);
|
||||
history.push(decline);
|
||||
const declined = applyIntent(s, voter, decline);
|
||||
// A broken invariant, not a game ending early: the status says a vote is pending and `voter` is
|
||||
// a seat that has not cast one. Thrown rather than broken out of, matching the illegal-action
|
||||
// check below — silently returning a short game is how a dead replay looks like a real one.
|
||||
if (!declined.ok) throw new Error(`the extension vote was refused with ${declined.code}`);
|
||||
tally(declined.events);
|
||||
continue;
|
||||
}
|
||||
|
||||
const actor = actingPlayer(s);
|
||||
if (actor === null) break;
|
||||
|
||||
const options = legalActions(s, actor);
|
||||
|
||||
+139
-4
@@ -18,7 +18,7 @@
|
||||
import { MAX_CONSIST } from '../engine/content.ts';
|
||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
||||
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -218,10 +218,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
|
||||
: `Played ${e.key} on Mainline card ${e.node}`,
|
||||
};
|
||||
case 'redFlagSpent':
|
||||
return {
|
||||
tone: 'good',
|
||||
text: `RED FLAG — Train ${e.trainNumber} stopped short of the ${e.side === 'east' ? 'Eastern' : 'Western'} Limits. The flag comes down with it.`,
|
||||
};
|
||||
case 'redFlagRuled':
|
||||
return e.flag
|
||||
? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` }
|
||||
: { tone: 'plain', text: `Player ${e.player} waved the train through` };
|
||||
case 'redFlagsSet':
|
||||
return {
|
||||
tone: 'good',
|
||||
text: `Red Flags set out to protect train ${e.trayId} on Mainline card ${e.node} — an approaching train must stop`,
|
||||
text: `RED FLAGS set out on the ${e.side === 'east' ? 'Eastern' : 'Western'} Limits — the next train from that way is held short`,
|
||||
};
|
||||
case 'flyingSwitch':
|
||||
return {
|
||||
@@ -488,6 +497,22 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
|
||||
case 'phaseEnded':
|
||||
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
|
||||
|
||||
// -- §3.3, extended play (Gitea#11)
|
||||
case 'extensionVoted':
|
||||
return e.agree
|
||||
? { tone: 'plain', text: `Player ${e.player} would play one more Day` }
|
||||
: { tone: 'plain', text: `Player ${e.player} called time — the game ends here` };
|
||||
case 'dayExtended':
|
||||
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
|
||||
case 'playConcluded':
|
||||
return { tone: 'clock', text: '── The railroad is put to bed. Final results stand. ──' };
|
||||
|
||||
// -- §11, the Yard Office (Gitea#5)
|
||||
case 'yardOfficeRuled':
|
||||
return e.take
|
||||
? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` }
|
||||
: { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,14 +549,124 @@ export type Impediment = { where: string; why: string; severity: 'stuck' | 'wait
|
||||
* This is the panel that should answer the standing questions: whether facilities jam, whether
|
||||
* trains are held for want of a crew, whether the Office is about to cause a collision.
|
||||
*/
|
||||
/** `coordKey`'s inverse — the grid is keyed by string and the engine predicates take coordinates. */
|
||||
function uncoordKey(key: string): GridCoord {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
return { row: row ?? 0, col: col ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* One of `passengerRefusal`'s codes, in words a player can act on.
|
||||
*
|
||||
* `NO_EMPTY_COACH_IN_YARD` gets the longest answer because it is the one that looks like a broken
|
||||
* game: the Division Yard is visibly full of cars, and the single type that has run out is the one
|
||||
* §9.2 needs. Where the missing coaches ARE, and the condition that brings them back, is the whole
|
||||
* of what the player needs to know — §2.2 returns the Classification Yard only when the Division
|
||||
* Yard is bare, so a yard with fifty freight cars in it will not refill for a long time.
|
||||
*/
|
||||
function passengerReason(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
at: GridCoord,
|
||||
dir: 'board' | 'detrain',
|
||||
): string {
|
||||
const code = passengerRefusal(s, player, at, dir);
|
||||
switch (code) {
|
||||
case 'NO_TRAIN_AT_OFFICE':
|
||||
return dir === 'board'
|
||||
? 'passengers waiting, no train at the platform to take them'
|
||||
: 'no train at the platform';
|
||||
case 'NOT_A_TERMINAL':
|
||||
return 'the only train here stops at Terminals only — Porters may not work it at this Office';
|
||||
case 'NO_PASSENGER_WORK':
|
||||
return 'the only train here is one its card bars Porters from working';
|
||||
case 'NO_EMPTY_COACH':
|
||||
return 'passengers waiting, but every coach on the train is already full';
|
||||
case 'INBOUND_BOX_FULL':
|
||||
return 'arrivals aboard, but the red Unloading slots are all occupied';
|
||||
case 'LOADED_IN_THIS_DISTRICT':
|
||||
return 'the loaded coaches all boarded here — passengers must be carried to another Office ' +
|
||||
'Area before they can alight';
|
||||
case 'NO_EMPTY_COACH_IN_YARD': {
|
||||
const stuck = s.yards.classificationYard.filter((c) => c.type === 'coach').length;
|
||||
const total = s.yards.divisionYard.length;
|
||||
return (
|
||||
'arrivals aboard, but §9.2 needs a white empty coach from the Division Yard to swap in and ' +
|
||||
`there is none left${stuck > 0 ? ` — ${stuck} ${stuck === 1 ? 'coach is' : 'coaches are'} in the Classification Yard` : ''}. ` +
|
||||
`Classification returns only when the Division Yard is bare, and it still holds ${total} cars.`
|
||||
);
|
||||
}
|
||||
default:
|
||||
return `Porters cannot work here (${code})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] {
|
||||
const out: Impediment[] = [];
|
||||
const area = areaOf(s, player);
|
||||
|
||||
for (const [key, card] of area.grid) {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight') continue;
|
||||
const name = card.geometry.kind === 'facility' ? card.geometry.facility : 'facility';
|
||||
if (!f) continue;
|
||||
/**
|
||||
* A Freight Facility names itself off its own card; a Passenger Facility does NOT — it rides on
|
||||
* the `office` card, so `geometry.kind` is `'office'` and it fell through to the literal
|
||||
* "facility". Every passenger impediment therefore read `facility 0,0`, next to a freight row
|
||||
* saying `mineTipple 1,-3`. The Office Area's tier is the name it should carry, and there is
|
||||
* exactly one Office per Area, so `area.tier` is that card's own.
|
||||
*/
|
||||
const name =
|
||||
card.geometry.kind === 'facility'
|
||||
? card.geometry.facility
|
||||
: card.geometry.kind === 'office'
|
||||
? area.tier
|
||||
: 'facility';
|
||||
|
||||
/**
|
||||
* WHY THE PORTERS ARE STANDING THERE (Gitea#2).
|
||||
*
|
||||
* "Note that the sparrow (with two loaded coaches) pulled into the station. There are two
|
||||
* passengers on the platform. Four porters. My thought was to unload two and load two. I never
|
||||
* get the chance to load the last two."
|
||||
*
|
||||
* The engine was right — §9.2 needs a white coach out of the Division Yard to de-train into,
|
||||
* §2.2 returns the Classification Yard only when the Division Yard is BARE, and the Division
|
||||
* Yard was one empty coach short with eight more sitting in Classification unable to come back.
|
||||
* Jesse's ruling is that the shortage stays: "it is possible to run out — that's part of the
|
||||
* strategy." What was missing was any way to SEE it. A Porter action that cannot be taken is
|
||||
* simply absent from the menu, and this panel — the one that answers "why is nothing moving?" —
|
||||
* covered freight facilities only, so the platform had nothing to say for itself at all.
|
||||
*
|
||||
* The reason comes from `passengerRefusal`, the engine's own, so what is on screen is the rule
|
||||
* that actually refused rather than a second guess at it.
|
||||
*/
|
||||
if (f.kind === 'passenger') {
|
||||
if (portersLeft(f) > 0) {
|
||||
const coord = uncoordKey(key);
|
||||
// Passengers standing on the platform with nothing carrying them away.
|
||||
if (f.outboundBox.some((c) => c.type === 'coach' && c.loaded) && !canBoard(s, player, coord)) {
|
||||
out.push({
|
||||
where: `${name} ${key}`,
|
||||
why: passengerReason(s, player, coord, 'board'),
|
||||
severity: 'waiting',
|
||||
});
|
||||
}
|
||||
// A coach full of arrivals that cannot be emptied.
|
||||
const arriving = area.adOccupancy.some((id) =>
|
||||
s.trays.get(id)?.consist.some((c) => c.type === 'coach' && c.loaded && c.origin !== seatOf(s, player)),
|
||||
);
|
||||
if (arriving && !canDetrain(s, player, coord)) {
|
||||
out.push({
|
||||
where: `${name} ${key}`,
|
||||
why: passengerReason(s, player, coord, 'detrain'),
|
||||
severity: 'stuck',
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (f.kind !== 'freight') continue;
|
||||
const want = facilityCarType(f);
|
||||
|
||||
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
|
||||
|
||||
+18
-2
@@ -34,6 +34,8 @@ import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
||||
import { actingPlayer } from '../engine/state.ts';
|
||||
import { reasonSentence } from '../web/panels.ts';
|
||||
import { developerBot, lastChoiceReason } from './bot.ts';
|
||||
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
|
||||
// The view-model lives in its own module so the browser build can import it without dragging in
|
||||
@@ -135,7 +137,7 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
if (s.status === 'finished') break;
|
||||
if (!r.needsInput) continue;
|
||||
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
const actor = actingPlayer(s);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) break;
|
||||
@@ -147,12 +149,26 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
push(applied.events);
|
||||
}
|
||||
|
||||
/**
|
||||
* IN WORDS, NOT AS AN ENUM (`TODO.md` #34). This heading read `loss — revenueFloor`, which is
|
||||
* exactly the defect Gitea#16 was filed about on the playable page — it just outlived the fix
|
||||
* here, because nothing player-facing pointed at it. `reasonSentence` is shared rather than
|
||||
* reimplemented, so the replay and the results screen cannot end up explaining the same ending
|
||||
* two different ways.
|
||||
*
|
||||
* Fed the LAST frame, which is the state the outcome was decided in and is already recorded.
|
||||
* Tags are stripped: this lands in an `<h1>` and in a console line, neither of which wants markup.
|
||||
*/
|
||||
const o = s.outcome;
|
||||
const last = frames[frames.length - 1];
|
||||
const why = o && last ? reasonSentence(last, o, last.day).replace(/<[^>]+>/g, '') : '';
|
||||
return {
|
||||
seed,
|
||||
length,
|
||||
frames,
|
||||
outcome: o ? `${o.result} — ${o.reason} · final Revenue ${s.players[0]?.revenue ?? 0}` : 'unfinished',
|
||||
outcome: o
|
||||
? `${o.result === 'win' ? 'won' : 'lost'} — ${why} Final Revenue ${s.players[0]?.revenue ?? 0}.`
|
||||
: 'unfinished',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,21 @@ export type PlayedGame = {
|
||||
export function playForReplay(seed: number, policy: BotPolicy, maxTurns = 50_000): PlayedGame {
|
||||
const game = newGame(seed);
|
||||
for (let t = 0; t < maxTurns; t++) {
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — a recorded replay is a game played to its end.
|
||||
*
|
||||
* The timetable running out leaves the game on "play one more Day?", where `currentActor` is
|
||||
* null and this loop would otherwise stop — recording a file that replays to a question nobody
|
||||
* answered rather than to a finished game. A recording bot plays the timetable it was dealt, the
|
||||
* same rule `playGame` follows, so it declines and the file ends where a real game would.
|
||||
*/
|
||||
if (game.state.status === 'awaitingExtension') {
|
||||
const voter = game.state.extensionVotes.findIndex((v) => v === null);
|
||||
if (voter < 0) break;
|
||||
if (!submit(game, { type: 'game.extend', player: voter, agree: false }, voter)) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
|
||||
+29
-5
@@ -19,6 +19,8 @@ export type TurnChartFrame = {
|
||||
phase: string;
|
||||
phaseKey: string;
|
||||
actor: number | null;
|
||||
/** What the game has stopped to ask, when it has. Null while a phase is simply running. */
|
||||
awaiting?: { asks: string; train: string } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -93,8 +95,21 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
||||
);
|
||||
}).join('');
|
||||
|
||||
// An automatic phase is waiting on nobody, and saying so is more use than a blank.
|
||||
/**
|
||||
* WAITING ON WHOM, AND FOR WHAT.
|
||||
*
|
||||
* "nobody — the Division is running itself" is true of an automatic phase and was being printed
|
||||
* over the top of three interruptions that are emphatically waiting on a person: §8.1's clearance
|
||||
* ruling, the Yard Office offer and the Red Flag prompt. The Frame carried the phase's actor,
|
||||
* which is null throughout the Mainline Phase, so a game stopped on a named player's decision
|
||||
* reported that nobody was holding it up (Jesse, 2026-08-30). `Frame.actor` is `actingPlayer` now
|
||||
* and answers who; `awaiting` says what, because "waiting on Bob" with no more than that is a
|
||||
* game that looks stuck to everyone except Bob.
|
||||
*/
|
||||
const who = actorName ?? 'nobody — the Division is running itself';
|
||||
const asked = f.awaiting
|
||||
? ` <span class="tc-asks">${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}</span>`
|
||||
: '';
|
||||
const fedora =
|
||||
superName === null
|
||||
? ''
|
||||
@@ -105,9 +120,12 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
||||
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
||||
`<span class="dim">${esc(f.clock)}</span></div>` +
|
||||
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
||||
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` +
|
||||
fedora +
|
||||
`<ol class="tc-phases">${chips}</ol>`
|
||||
`<div class="tc-who">waiting on <b>${esc(who)}</b>${asked}</div>` +
|
||||
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
|
||||
// between the phases and everything above them, which put a thing that changes every third
|
||||
// Stage in the middle of the things that change every Stage. The row it belongs beside is the
|
||||
// one whose last chip is Supervisor Shift — the phase that passes it.
|
||||
`<div class="tc-row"><ol class="tc-phases">${chips}</ol>${fedora}</div>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,12 +148,18 @@ export const TURNCHART_CSS = `
|
||||
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
|
||||
glances at most often, so it gets its own chip rather than hiding in the phase text. */
|
||||
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3}
|
||||
.tc-asks{color:#a99ac4;font-style:italic}
|
||||
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
||||
border-radius:11px;padding:1px 9px;font-size:12px}
|
||||
/* WHO HOLDS THE FEDORA. Violet like the rest of the chart — this is "where you are" news, not
|
||||
something to press — but unfilled, so the eye still lands on "waiting on" first: that is the one
|
||||
that changes every turn, while this changes four times a Day. */
|
||||
.tc-super{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3;cursor:help}
|
||||
/* The phase row and the Fedora on one line, the hat pushed to the far end (TODO.md #29): the row
|
||||
is the Stage, and the Superintendent is who holds it. Wraps under the phases on a narrow screen
|
||||
rather than squeezing the chips. */
|
||||
.tc-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.tc-row ol.tc-phases{flex:1 1 auto}
|
||||
.tc-super{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3;cursor:help}
|
||||
.tc-super b{color:#cbb6f2;border:1px solid #6b5a94;border-radius:11px;padding:1px 9px;font-size:12px}
|
||||
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
|
||||
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px;
|
||||
|
||||
+148
-38
@@ -10,6 +10,7 @@
|
||||
* drift into two different pictures of the same board.
|
||||
*/
|
||||
|
||||
import { regionOfTransit } from '../engine/advance.ts';
|
||||
import {
|
||||
areaAtSeat,
|
||||
areaOf,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
laborersLeft,
|
||||
movesFor,
|
||||
ownCutFor,
|
||||
isTrainCard,
|
||||
keepReason,
|
||||
portersLeft,
|
||||
resolveExtraStart,
|
||||
selectDestination,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
MANEUVER_CARDS,
|
||||
MODIFIER_PROFILES,
|
||||
REALIGNMENTS,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
OFFICE_ORDER,
|
||||
SPACE_USE_CARDS,
|
||||
enhancementRule,
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
} from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import { carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||
import { actingPlayer, carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||
@@ -354,7 +354,21 @@ export type Frame = {
|
||||
* replay recorder, which sees the events; the live game keeps its own on the Game object.
|
||||
*/
|
||||
cues?: string[];
|
||||
/**
|
||||
* WHO THE GAME IS WAITING ON — the phase's actor, or the owner of a pending interruption when
|
||||
* there is one. It carried `clock.currentActor` alone until 2026-08-30, which is null during the
|
||||
* Mainline Phase, so a game stopped dead on a Superintendent's clearance ruling reported "waiting
|
||||
* on nobody — the Division is running itself" while it waited on a named person to click
|
||||
* (reported by Jesse). The engine had the answer the whole time in `actingPlayer`.
|
||||
*/
|
||||
actor: number | null;
|
||||
/**
|
||||
* WHAT that player is being asked, when the game is stopped on a question rather than a turn.
|
||||
* Null whenever the phase is simply running. Naming the person is not enough on its own: three
|
||||
* different interruptions can be waiting, and "waiting on Bob" with no more than that is a game
|
||||
* that looks stuck to everyone except Bob.
|
||||
*/
|
||||
awaiting: { asks: string; train: string } | null;
|
||||
superintendent: number;
|
||||
revenue: number;
|
||||
/**
|
||||
@@ -398,6 +412,24 @@ export type Frame = {
|
||||
collisionsTotal: number;
|
||||
status: GameState['status'];
|
||||
outcome: GameState['outcome'];
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11). `days` above stays the ORIGINAL timetable — it is what the
|
||||
* official result was decided at — so the Day the game now runs to is `days + extraDays`.
|
||||
*/
|
||||
extraDays: number;
|
||||
/** Per PLAYER, while `status` is `awaitingExtension`. `null` is a seat that has not voted. */
|
||||
extensionVotes: (boolean | null)[];
|
||||
/** The official result, frozen when the original timetable ran out. Null until then. */
|
||||
official: GameState['official'];
|
||||
/**
|
||||
* Gitea#16 — everything interesting that has happened, folded from the event stream.
|
||||
*
|
||||
* Aggregate counts only, which is why it can ride the Frame at all: `test/redaction.test.ts`
|
||||
* proves a Frame carries no other seat's secrets, and a count of trains is nobody's secret. Being
|
||||
* here rather than on a side channel is what gets the results screen the same numbers in
|
||||
* multiplayer as in solitaire, from one implementation.
|
||||
*/
|
||||
tally: GameState['tally'];
|
||||
/**
|
||||
* Every PLAYER's public standing — names and Revenue. "The race is the game" (protocol.md §4).
|
||||
*
|
||||
@@ -492,12 +524,22 @@ export type Frame = {
|
||||
/**
|
||||
* Whether each hand card may be DISCARDED, in the same order.
|
||||
*
|
||||
* §6.2 as ruled by Jesse (Gitea#6): a train card never can be. The player has to be told which
|
||||
* cards those are, not merely find that a button is missing — that silence is the whole of the
|
||||
* Gitea#2 complaint, where a blocked platform left the board with nothing to click and no reason.
|
||||
* Named for the rule rather than for trains, since it answers the question the panel is asking.
|
||||
* The player has to be told which cards those are, not merely find that a button is missing —
|
||||
* that silence is the whole of the Gitea#2 complaint, where a blocked platform left the board with
|
||||
* nothing to click and no reason. Named for the rule rather than for trains, since it answers the
|
||||
* question the panel is asking.
|
||||
*/
|
||||
handDiscardable: boolean[];
|
||||
/**
|
||||
* WHY a card may not be discarded, in the same order; `null` where it may.
|
||||
*
|
||||
* Carried rather than written on the page because §6.2 now fails for two different reasons
|
||||
* (Gitea#9): an Extra is never discardable, and a Timetabled train is not discardable only when
|
||||
* the `discardTimetabled` house rule is off. A panel that hard-codes one sentence tells half the
|
||||
* players the wrong thing, and a panel that reconstructs the rule is a second implementation of
|
||||
* it. `keepReason` is the engine's own, so the card says the rule that actually refused.
|
||||
*/
|
||||
handKeepWhy: (string | null)[];
|
||||
deck: number;
|
||||
/** The face-up card on top of each Department pile — the only one that may be drawn. */
|
||||
departments: string[];
|
||||
@@ -1015,15 +1057,41 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`;
|
||||
}
|
||||
case 'maneuver.redFlags':
|
||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
||||
return (
|
||||
`FLAG ${i.side === 'east' ? 'EAST' : 'WEST'} — hold the next ${i.side === 'east' ? 'westbound' : 'eastbound'} ` +
|
||||
'train short of your Limits, so you can finish switching'
|
||||
);
|
||||
// §Q, the out-of-phase play (Gitea#19) — "COLLISION RISK! FLAG AGAINST T2?"
|
||||
case 'mainline.redFlag':
|
||||
return i.flag
|
||||
? 'FLAG IT — stop the train short of your Limits, spending a Red Flags card'
|
||||
: 'wave it through — let it come in';
|
||||
/**
|
||||
* §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to
|
||||
* carry the whole question — there is no surrounding context on screen to lean on, and the
|
||||
* player is being asked about a train they were not otherwise thinking about.
|
||||
*/
|
||||
case 'mainline.yardOffice':
|
||||
return i.take
|
||||
? 'take the YARD OFFICE — straight into the yard, leaving the Train Order Office free'
|
||||
: 'keep it at the Train Order Office — the ordinary arrival, onto an A/D track';
|
||||
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
|
||||
// options through this list like any other, and the label is what the history says it chose.
|
||||
case 'game.extend':
|
||||
return i.agree
|
||||
? 'play one more Day — the result already recorded still stands'
|
||||
: 'end the game here';
|
||||
case 'maneuver.flyingSwitch':
|
||||
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
|
||||
case 'mainline.clearance': {
|
||||
// The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint
|
||||
// that granting it risks a rear-ender, or that refusing merely costs time.
|
||||
// Narrowed to the clearance question: `pendingDecision` is a union since Gitea#5, and only
|
||||
// this member names a train ahead.
|
||||
const pending = s.clock.pendingDecision;
|
||||
const who = pending ? trainName(s, pending.train) : 'the train';
|
||||
const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead';
|
||||
const clearance = pending?.kind === 'clearance' ? pending : null;
|
||||
const who = clearance ? trainName(s, clearance.train) : 'the train';
|
||||
const ahead = clearance ? trainName(s, clearance.occupiedBy) : 'the train ahead';
|
||||
// NOT "risks a collision, −5". A rear-end on a Mainline card is described by §10 and is what
|
||||
// ABS Signals exists to prevent, but no such collision is implemented — granting clearance is
|
||||
// currently free. Saying otherwise invents a consequence the engine will never deliver.
|
||||
@@ -1063,7 +1131,30 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
case 'redFlag.play':
|
||||
return 'play your red flag';
|
||||
case 'maneuver.redFlags':
|
||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
||||
return (
|
||||
`FLAG ${i.side === 'east' ? 'EAST' : 'WEST'} — hold the next ${i.side === 'east' ? 'westbound' : 'eastbound'} ` +
|
||||
'train short of your Limits, so you can finish switching'
|
||||
);
|
||||
// §Q, the out-of-phase play (Gitea#19) — "COLLISION RISK! FLAG AGAINST T2?"
|
||||
case 'mainline.redFlag':
|
||||
return i.flag
|
||||
? 'FLAG IT — stop the train short of your Limits, spending a Red Flags card'
|
||||
: 'wave it through — let it come in';
|
||||
/**
|
||||
* §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to
|
||||
* carry the whole question — there is no surrounding context on screen to lean on, and the
|
||||
* player is being asked about a train they were not otherwise thinking about.
|
||||
*/
|
||||
case 'mainline.yardOffice':
|
||||
return i.take
|
||||
? 'take the YARD OFFICE — straight into the yard, leaving the Train Order Office free'
|
||||
: 'keep it at the Train Order Office — the ordinary arrival, onto an A/D track';
|
||||
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
|
||||
// options through this list like any other, and the label is what the history says it chose.
|
||||
case 'game.extend':
|
||||
return i.agree
|
||||
? 'play one more Day — the result already recorded still stands'
|
||||
: 'end the game here';
|
||||
default: {
|
||||
// Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes
|
||||
// that a COMPILE error the day someone adds an intent without describing it — the playable UI
|
||||
@@ -1155,36 +1246,27 @@ export function snapshot(
|
||||
// Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it
|
||||
// with how long each still has to run.
|
||||
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
|
||||
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
|
||||
const isGrade = n.card === 'heavyGrade';
|
||||
/**
|
||||
* WHERE ON THE CARD, from what the crossing already cost.
|
||||
* WHERE ON THE CARD — now simply what the card says.
|
||||
*
|
||||
* §2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage.
|
||||
* The engine crosses in `crossingStages` Stages instead, which varies by card speed, train
|
||||
* speed, passengers and modifiers — so the printed model is recovered by treating the entry
|
||||
* point as the thing that varies, exactly as the cards do:
|
||||
* This used to recover a printed two-region model from a crossing time computed out of the
|
||||
* card's mph, the train's Fast/Slow class, its consist and any modifiers, by treating the
|
||||
* ENTRY point as the thing that varied: `entry = 2 - stagesTotal`. It even had to cope with a
|
||||
* negative entry, for a slow train needing three Stages to cross a card with two regions.
|
||||
*
|
||||
* entry = REGIONS - stagesTotal position = entry + elapsed
|
||||
*
|
||||
* A 60 card is one Stage, so the train enters at the second region and is gone — which is
|
||||
* what "Start positions further along the card" means on the printed art. A 30 card is two
|
||||
* Stages, giving one region per Stage, which is §8.2 exactly. A slow train needing three
|
||||
* Stages cannot fit three steps into two regions, so it holds in the first for a Stage: the
|
||||
* card's distance is fixed and the train is simply slow across it.
|
||||
* Gitea#3 turned that the right way up. Regions are the primary thing — printed on the card,
|
||||
* one per Stage — and the entry point is what the rules actually move. There is nothing left
|
||||
* to reconstruct.
|
||||
*/
|
||||
const place = (t: { stagesRemaining: number; stagesTotal: number }): number => {
|
||||
// `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into
|
||||
// two regions, so it notionally starts before the card and spends the extra Stage getting
|
||||
// to the first region. Clamping only the final position keeps that Stage at the START,
|
||||
// where being slow shows; clamping `entry` first would have parked it at the exit instead.
|
||||
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
|
||||
const elapsed = t.stagesTotal - t.stagesRemaining;
|
||||
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
|
||||
};
|
||||
// One region per Stage, straight off the card's own count: what a train has LEFT to run says
|
||||
// where it is standing. `regionOfTransit` is the engine's own answer, so the picture and the
|
||||
// collision rule cannot disagree about who is where.
|
||||
const place = (t: { stagesRemaining: number }): number => regionOfTransit(n.card, t.stagesRemaining);
|
||||
return {
|
||||
kind: 'ml',
|
||||
label: name,
|
||||
regions: REGIONS_PER_MAINLINE_CARD,
|
||||
regions: mainlineProfile(n.card).regions,
|
||||
trains: [n.transits.map((t) => {
|
||||
const chip = trainChip(s, t.tray);
|
||||
return {
|
||||
@@ -1274,7 +1356,19 @@ export function snapshot(
|
||||
clock: clockTime(s.clock.stage),
|
||||
phase: phaseLabel(s.clock.phase),
|
||||
phaseKey: s.clock.phase,
|
||||
actor: s.clock.currentActor,
|
||||
actor: actingPlayer(s),
|
||||
/**
|
||||
* The three interruptions §8.1 and Gitea#5/#19 can raise, said in the words the prompt itself
|
||||
* uses. `decisionActor` above decides WHO; this is only what they are looking at.
|
||||
*/
|
||||
awaiting: (() => {
|
||||
const d = s.clock.pendingDecision;
|
||||
if (!d) return null;
|
||||
const train = trainName(s, d.train);
|
||||
if (d.kind === 'clearance') return { asks: 'a clearance ruling', train };
|
||||
if (d.kind === 'yardOffice') return { asks: 'the Yard Office offer', train };
|
||||
return { asks: 'a Red Flag', train };
|
||||
})(),
|
||||
superintendent: s.clock.superintendent,
|
||||
revenue: s.players[viewer]?.revenue ?? 0,
|
||||
lines,
|
||||
@@ -1294,7 +1388,8 @@ export function snapshot(
|
||||
*/
|
||||
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
|
||||
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
||||
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => !isTrainCard(s, id)),
|
||||
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id) === null),
|
||||
handKeepWhy: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id)),
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((pile) => {
|
||||
const top = pile[pile.length - 1];
|
||||
@@ -1333,6 +1428,10 @@ export function snapshot(
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
status: s.status,
|
||||
outcome: s.outcome,
|
||||
extraDays: s.extraDays,
|
||||
extensionVotes: [...s.extensionVotes],
|
||||
official: s.official,
|
||||
tally: s.tally,
|
||||
players: s.players.map((p) => ({
|
||||
index: p.index,
|
||||
seat: seatOf(s, p.index),
|
||||
@@ -1575,7 +1674,9 @@ export function trainRules(t: {
|
||||
}
|
||||
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.pickUpEmptiesOnly) {
|
||||
parts.push('EMPTIES ONLY — it may not couple a loaded car. A caboose is not a load.');
|
||||
}
|
||||
if (p.rules.stopThenExpedite) {
|
||||
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
|
||||
}
|
||||
@@ -1775,7 +1876,16 @@ const SIMPLE_CARDS = [
|
||||
* view of its own. `0` means no floor is configured — nothing to pace against.
|
||||
*/
|
||||
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
|
||||
const { days, minCombinedRevenue: target } = s.config;
|
||||
const { minCombinedRevenue: target } = s.config;
|
||||
/**
|
||||
* PACED AGAINST THE TIMETABLE ACTUALLY BEING PLAYED, extensions included (Gitea#11).
|
||||
*
|
||||
* `config.days` alone would say "the last Day is over" through every extended Day, and pace an
|
||||
* eight-Day game against five — both of which the status line used to do the moment play carried
|
||||
* on past the end. The official result is still decided at `config.days`; that is `checkVictory`'s
|
||||
* business, and nothing here feeds it.
|
||||
*/
|
||||
const days = s.config.days + s.extraDays;
|
||||
const revenue = s.players[viewer]?.revenue ?? 0;
|
||||
const daysLeft = Math.max(0, days - s.clock.day + 1);
|
||||
const elapsed = days - daysLeft + 1;
|
||||
|
||||
+114
-20
@@ -30,6 +30,7 @@ import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||
import { actingPlayer } from '../engine/state.ts';
|
||||
import { playerAtSeat } 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,
|
||||
@@ -133,10 +134,25 @@ export type NewGameOptions = {
|
||||
|
||||
/** The same config with the New Game dialog's answers in it. */
|
||||
export function configWith(opts: NewGameOptions): GameConfig {
|
||||
const days = opts.days ?? SOLO_CONFIG.days;
|
||||
return {
|
||||
...SOLO_CONFIG,
|
||||
days: opts.days ?? SOLO_CONFIG.days,
|
||||
minCombinedRevenue: opts.minCombinedRevenue ?? SOLO_CONFIG.minCombinedRevenue,
|
||||
days,
|
||||
/**
|
||||
* DERIVED FROM THE DAYS ACTUALLY IN PLAY, not from `SOLO_CONFIG`'s five-Day constant.
|
||||
*
|
||||
* It fell back to the constant until 2026-08-30, so `configWith({ days: 1 })` asked a one-Day
|
||||
* game to clear **15** — a floor a five-Day game averages barely half of — and
|
||||
* `configWith({ days: 10 })` asked for the same 15 a five-Day game does. The two fields silently
|
||||
* disagreed, which is the one thing a "build me a config" helper must not let happen.
|
||||
*
|
||||
* Not a live fault when it was found: `createLocalSession` is the only caller, and the page
|
||||
* always writes `minCombinedRevenue` itself (`solitaireDefaults` re-derives it from the preset).
|
||||
* Found by a throwaway probe that passed only `days` — which is exactly how the next caller
|
||||
* would use this. At the default day count the answer is unchanged, since `SOLO_CONFIG`'s own
|
||||
* floor is this same formula at `DEFAULT_DAYS`.
|
||||
*/
|
||||
minCombinedRevenue: opts.minCombinedRevenue ?? collectiveRevenueFloor(1, days),
|
||||
maxCollisionsPerDay: opts.maxCollisionsPerDay ?? SOLO_CONFIG.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal,
|
||||
optionalRules: { ...SOLO_CONFIG.optionalRules, ...(opts.optionalRules ?? {}) },
|
||||
@@ -266,6 +282,7 @@ export type Game = {
|
||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||
const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
|
||||
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
|
||||
{ prefix: 'mainline.yardOffice', title: 'Where does this train arrive?' },
|
||||
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
|
||||
{ prefix: 'switch.', title: 'Switching' },
|
||||
// Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the
|
||||
@@ -334,9 +351,9 @@ export function drain(game: Game): void {
|
||||
/** Whose turn it is, or null if the game is over or waiting on nothing. */
|
||||
export function currentActor(game: Game): PlayerIndex | null {
|
||||
if (game.state.status !== 'active') return null;
|
||||
return game.state.clock.pendingDecision !== null
|
||||
? game.state.clock.superintendent
|
||||
: game.state.clock.currentActor;
|
||||
// `actingPlayer` (state.ts) knows which player each kind of interruption goes to — the
|
||||
// Superintendent for a §8.1 clearance, the district's owner for a Yard Office offer.
|
||||
return actingPlayer(game.state);
|
||||
}
|
||||
|
||||
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
|
||||
@@ -465,13 +482,26 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
*/
|
||||
if (prefix === 'mainline.clearance') {
|
||||
const pending = game.state.clock.pendingDecision;
|
||||
if (pending) {
|
||||
if (pending?.kind === 'clearance') {
|
||||
headed =
|
||||
`Superintendent — may ${trainName(game.state, pending.train)} follow ` +
|
||||
`${trainName(game.state, pending.occupiedBy)} onto the same Mainline card?`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — the Yard Office offer interrupts the Mainline Phase, so it arrives with no
|
||||
* context around it: the player was not thinking about this train a moment ago.
|
||||
*/
|
||||
if (prefix === 'mainline.yardOffice') {
|
||||
const pending = game.state.clock.pendingDecision;
|
||||
if (pending?.kind === 'yardOffice') {
|
||||
headed =
|
||||
`${trainName(game.state, pending.train)} is arriving with no coaches — ` +
|
||||
'take it into the Yard Office, or hold it at the Train Order Office?';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A PENDING EXTRA IS ITS OWN QUESTION, and its own heading.
|
||||
*
|
||||
@@ -1014,9 +1044,42 @@ export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean {
|
||||
return hand.length > limit;
|
||||
}
|
||||
|
||||
/** Submit an action. Returns false and changes nothing if the engine rejects it. */
|
||||
export function submit(game: Game, intent: Intent): boolean {
|
||||
const actor = currentActor(game);
|
||||
/**
|
||||
* Intents any seat may send regardless of whose turn it is — and, for `game.extend`, regardless of
|
||||
* whether the game is still running at all (§3.3, Gitea#11).
|
||||
*
|
||||
* `currentActor` is null once the timetable has run out, which is correct for everything else and
|
||||
* exactly wrong for the vote on playing another Day. Rather than teach `currentActor` about a state
|
||||
* where EVERY seat may act at once — which it has no way to express — callers name the seat.
|
||||
*/
|
||||
export function isOutOfTurn(intent: Intent): intent is Extract<Intent, { type: 'game.extend' }> {
|
||||
return intent.type === 'game.extend';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHO ACTED, when replaying a saved history.
|
||||
*
|
||||
* A save is a flat `Intent[]` with no seat written beside each move, so a replay normally derives
|
||||
* the actor from the turn order — the same order the live game went round in, reproduced exactly.
|
||||
* That breaks for exactly one intent: the extension vote, which every seat may cast in any order,
|
||||
* and which `currentActor` answers `null` for because the game has stopped. Replaying such a save
|
||||
* used to fail outright with `NO_ACTOR`, which is to say an extended game could not be resumed at
|
||||
* all — found by `test/server/session.test.ts`'s resume test, and the reason `game.extend` carries
|
||||
* its voter (`intents.ts`).
|
||||
*/
|
||||
function replayActor(game: Game, intent: Intent): PlayerIndex | null {
|
||||
return isOutOfTurn(intent) ? intent.player : currentActor(game);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an action. Returns false and changes nothing if the engine rejects it.
|
||||
*
|
||||
* `as` names the seat for an out-of-turn intent (see `isOutOfTurn`). It cannot be used to smuggle an
|
||||
* ordinary move past the turn order: `check` is still the authority and still asks `isActor`, so a
|
||||
* named seat that is not the actor is refused exactly as it would have been.
|
||||
*/
|
||||
export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null): boolean {
|
||||
const actor = as ?? currentActor(game);
|
||||
if (actor === null) return false;
|
||||
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
@@ -1059,6 +1122,24 @@ export function view(game: Game, seat: PlayerIndex = 0): Frame {
|
||||
return snapshot(game.state, [], null, null, null, false, seat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a narration's opening word into the middle of a sentence — "Chose to draw" after a name has
|
||||
* to read "Player Bob chose to draw".
|
||||
*
|
||||
* ONLY A SENTENCE-CASED WORD, which is the whole point. It used to be a flat
|
||||
* `text.charAt(0).toLowerCase()`, so every line opening with an all-caps keyword came out mangled:
|
||||
* `EXTRA X18 started…` rendered as `Player Solitaire eXTRA X18 started…`, and the same happened to
|
||||
* `TRAIN 1 MADE UP` and `COLLISION`. Those words are shouted deliberately.
|
||||
*
|
||||
* `^[A-Z][a-z]` is the test — a capital followed by a lower-case letter is an ordinary word that was
|
||||
* capitalised because it began a sentence, and nothing else is. It leaves all-caps keywords alone,
|
||||
* and it also leaves alone a word whose second character is a digit or a hyphen (`X22 Pee-Dee`),
|
||||
* which a naive "is it uppercase?" check would get wrong because `'2'.toUpperCase() === '2'`.
|
||||
*/
|
||||
function uncapitalise(text: string): string {
|
||||
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
|
||||
}
|
||||
|
||||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||||
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
||||
for (const e of events) {
|
||||
@@ -1071,7 +1152,7 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
||||
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
||||
const mine = who !== null && 'player' in e;
|
||||
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
|
||||
const text = mine ? `Player ${who} ${uncapitalise(n.text)}` : n.text;
|
||||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||||
|
||||
}
|
||||
@@ -1167,12 +1248,23 @@ export function undo(game: Game, config: GameConfig = game.state.config): Game |
|
||||
export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const game = newGame(save.seed, configFor(save, config));
|
||||
for (const intent of save.history) {
|
||||
const actor = currentActor(game);
|
||||
const actor = replayActor(game, intent);
|
||||
if (actor === null) break;
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
if (!result.ok) break;
|
||||
game.history.push(intent);
|
||||
record(game, result.events);
|
||||
/**
|
||||
* `actor` IS PASSED HERE, so a replayed game narrates exactly as the live one did.
|
||||
*
|
||||
* It was omitted, and the omission was invisible in solitaire for a reason worth keeping: the
|
||||
* only test that compares logs ("leaves nothing in the log describing a move that was taken
|
||||
* back") compares one `fromSave`-built log against ANOTHER, so the missing attribution cancelled
|
||||
* out on both sides. Live play attributes (`submit` passes `actor`) and so does multiplayer's
|
||||
* replay (`fromMultiplayerSave`) — this was the one path of the three that did not, which meant
|
||||
* a restored save, an undone game (undo rebuilds through here) and the replay viewer all
|
||||
* described the same moves in different words from the game that produced them.
|
||||
*/
|
||||
record(game, result.events, actor);
|
||||
drain(game);
|
||||
}
|
||||
return game;
|
||||
@@ -1186,16 +1278,18 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||||
* this function only ever reconstructs from history that is already known to have been recorded
|
||||
* under the currently-running rules.
|
||||
*
|
||||
* UNLIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
|
||||
* LIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
|
||||
* `game.ts` above) — found while testing Phase 3's resume path: without it, every replayed line loses
|
||||
* its "Player X" attribution and reads as anonymous "Chose to..." narration, which `record`'s own
|
||||
* comment calls "unreadable the moment there is more than one seat" — exactly the multiplayer case a
|
||||
* resumed game hits every time. `fromSave` has the same gap (it predates multiplayer and nothing ever
|
||||
* compares its output against a LIVE-played log, so it has gone unnoticed — `undo`'s rebuilt game is
|
||||
* itself `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compares one
|
||||
* unattributed replay against another). Flagged in `TODO.md` rather than fixed there in this pass —
|
||||
* out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a
|
||||
* touch-in-passing.
|
||||
* resumed game hits every time.
|
||||
*
|
||||
* `fromSave` HAD THE SAME GAP AND NO LONGER DOES (fixed 2026-08-30). It predated multiplayer, and
|
||||
* nothing ever compared its output against a LIVE-played log: `undo`'s rebuilt game is itself
|
||||
* `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compared one unattributed
|
||||
* replay against another and the gap cancelled out on both sides. The test that now pins it plays a
|
||||
* game live, restores it from its own save, and asserts the two logs are identical — which is the
|
||||
* comparison that had been missing rather than a new requirement.
|
||||
*/
|
||||
/**
|
||||
* Why the intent a replay stopped at is reported rather than swallowed.
|
||||
@@ -1218,7 +1312,7 @@ export function fromMultiplayerSave(
|
||||
): { game: Game; stopped: ReplayStop | null } {
|
||||
const game = newMultiplayerGame(seed, config, playerNames);
|
||||
for (const [index, intent] of history.entries()) {
|
||||
const actor = currentActor(game);
|
||||
const actor = replayActor(game, intent);
|
||||
if (actor === null) {
|
||||
return { game, stopped: { index, intent, code: 'NO_ACTOR' } };
|
||||
}
|
||||
|
||||
+5
-5
@@ -79,13 +79,13 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
<span class="go" id="door-multiplayer-go">Set up a game →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./play.html">
|
||||
<a class="door" href="./play.html?solitaire">
|
||||
<h2>Play solitaire</h2>
|
||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
||||
Your game data is saved in your browser — if you close the tab and reopen this site
|
||||
<p>Play by yourself and run the entire division for five full days. Clear the Revenue floor of
|
||||
15 by the end or the game is a loss. Your game data is saved in your browser — if you close the tab and reopen this site
|
||||
without clearing your cache, your game is preserved and you can continue automatically.
|
||||
During the game you can also explicitly save your progress for later replay.</p>
|
||||
<span class="go">Start a game →</span>
|
||||
<span class="go">Set up a game →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./replays.html">
|
||||
@@ -100,7 +100,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
|
||||
<footer>
|
||||
<span>build <span id="build">__BUILD__</span></span>
|
||||
<span>solitaire runs entirely in your browser — no server code required</span>
|
||||
<span>Multiplayer runs on StartOS server. Solitaire runs entirely in your browser.</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
|
||||
+18
-25
@@ -256,25 +256,21 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
|
||||
else if (type === 'custom') type = base;
|
||||
for (const r of typeRadios()) r.checked = r.value === type;
|
||||
|
||||
const scoring = preset(base).scoring;
|
||||
const note = $('lb-type-note');
|
||||
if (type === 'custom') {
|
||||
note.textContent =
|
||||
`${gameTypeLabel('custom', scoring)} · ${differing.length} ` +
|
||||
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(base).label}.`;
|
||||
note.className = 'ng-note changed-note';
|
||||
// A Custom game is nobody's default: open the block that says how it differs.
|
||||
$<HTMLDetailsElement>('lb-settings').open = true;
|
||||
} else {
|
||||
note.textContent = preset(type as PresetName).blurb;
|
||||
note.className = 'ng-note';
|
||||
}
|
||||
/**
|
||||
* NO SENTENCE UNDER THE RADIOS since 2026-08-30 — it restated the type just chosen to the person
|
||||
* who had just chosen it, and the row is already labelled and already carries its own
|
||||
* description (Jesse: "There's no need to repeat it below"). `form.mark` still puts a hint on
|
||||
* each row that actually differs, which is where a Custom game's differences can be acted on.
|
||||
*/
|
||||
// A Custom game is nobody's default: open the block that says how it differs.
|
||||
if (type === 'custom') $<HTMLDetailsElement>('lb-settings').open = true;
|
||||
}
|
||||
|
||||
for (const r of typeRadios()) {
|
||||
// Solitaire is on this screen so the two screens read as one list, but there is nothing here to
|
||||
// deal it with — the New Game dialog is where a solitaire game comes from.
|
||||
if (r.value === 'solitaire') markUnavailable(r, 'dealt with the New game button, not here');
|
||||
// deal it with. Dimmed and left to speak for itself: the heading says "Game type
|
||||
// (multi-player)", which is the explanation (Jesse, 2026-08-30).
|
||||
if (r.value === 'solitaire') markUnavailable(r);
|
||||
r.onchange = () => {
|
||||
if (!r.checked) return;
|
||||
if (r.value === 'custom') {
|
||||
@@ -615,18 +611,15 @@ export function prefillCode(code: string): void {
|
||||
*
|
||||
* Reported by Jesse 2026-08-23: "solitaire is disabled, but really hard to tell." A bare `disabled`
|
||||
* on a radio leaves the whole row at full strength — the dot simply refuses the click, which reads
|
||||
* as a broken control rather than an unavailable one. Dims the row and says why, once.
|
||||
* as a broken control rather than an unavailable one.
|
||||
*
|
||||
* The dimming is the whole signal now. It used to append a reason to the row as well, and dropped
|
||||
* that in 2026-08-30 along with the same text on the solitaire screen: one heading naming which
|
||||
* game the screen deals says it once, where three dimmed rows each said it again.
|
||||
*/
|
||||
function markUnavailable(radio: HTMLInputElement, why: string): void {
|
||||
function markUnavailable(radio: HTMLInputElement): void {
|
||||
radio.disabled = true;
|
||||
const row = radio.closest('label');
|
||||
if (!row) return;
|
||||
row.classList.add('disabled');
|
||||
if (row.querySelector('.lb-why')) return;
|
||||
const note = document.createElement('span');
|
||||
note.className = 'lb-why';
|
||||
note.textContent = ` — ${why}`;
|
||||
row.querySelector('span')?.appendChild(note);
|
||||
radio.closest('label')?.classList.add('disabled');
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
|
||||
+692
-224
File diff suppressed because it is too large
Load Diff
+380
-6
@@ -31,16 +31,16 @@ export function cardRow(name: string, why: string, playable: boolean | null): st
|
||||
}
|
||||
|
||||
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||
// §6.2 (Gitea#6) — say so on the card itself. A player who cannot discard a train needs to read
|
||||
// that on the train, not deduce it from a button that is not there.
|
||||
const held = 'You may hold this for as many Stages and Days as you like — but a train card is ' +
|
||||
'never discarded. The only way it leaves your hand is onto the timetable.';
|
||||
// §6.2 — say so on the card itself. A player who cannot discard a train needs to read that on the
|
||||
// train, not deduce it from a button that is not there. The sentence comes off the Frame
|
||||
// (`handKeepWhy`) rather than being written here, because since Gitea#9 there are two of them and
|
||||
// which one applies depends on the card AND the game's rules.
|
||||
return f.hand.length
|
||||
? f.hand
|
||||
.map((h, i) => {
|
||||
const what = f.handWhat[i] ?? '';
|
||||
const keep = f.handDiscardable[i] === false;
|
||||
return cardRow(h, keep ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
|
||||
const held = f.handKeepWhy[i];
|
||||
return cardRow(h, held ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
|
||||
})
|
||||
.join('')
|
||||
: '<span class="dim">empty</span>';
|
||||
@@ -153,6 +153,356 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
|
||||
return `<div class="tt">${slots}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE DAY THAT JUST ENDED — the body of the dialog `main.ts` puts up at every Day rollover.
|
||||
*
|
||||
* Reported as Gitea#10: "as the game rolls off the end of the day, you get a dialog saying such.
|
||||
* Hard to keep track of time." The clock was on screen the whole time, but a Day turns over inside
|
||||
* the automatic phases — between one click and the next — and neither the phase banner (2.6s) nor
|
||||
* the announcement flash (4.2s) survives long enough to be noticed by someone reading the board.
|
||||
* A modal is the point: it stops, and it waits to be dismissed.
|
||||
*
|
||||
* It is written from the FRAME AFTER the rollover, so `f.day` is the Day about to start and the one
|
||||
* that ended is the Day before it. Standings are in Revenue order rather than seat order: the
|
||||
* question at the end of a Day is who is ahead.
|
||||
*/
|
||||
export function dayEndHtml(f: Frame): string {
|
||||
const ended = f.day - 1;
|
||||
const left = f.days + f.extraDays - ended;
|
||||
|
||||
const ahead =
|
||||
left <= 0
|
||||
? '<p>That was the last Day on the timetable.</p>'
|
||||
: `<p><b>Day ${f.day} of ${f.days + f.extraDays}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h">Day ${ended} has ended</h3>` +
|
||||
ahead +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared between the Day-end dialog and the end-of-game results screen.
|
||||
//
|
||||
// Gitea#16 asked for the results screen and Gitea#10's dialog had already assembled most of it. The
|
||||
// three blocks below are the overlap, factored out rather than written twice: the two screens report
|
||||
// the same numbers about the same game, and the one thing they must never do is disagree.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every player in Revenue order, the viewer marked.
|
||||
*
|
||||
* `winner` rings the player the OFFICIAL result named, which is not always the player at the top:
|
||||
* in an extended game the standings keep moving after the result is settled, and showing the leader
|
||||
* without saying who actually won would be the screen contradicting itself.
|
||||
*/
|
||||
function standingsHtml(f: Frame, winner: number | null = null): string {
|
||||
const rows = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map((p) => {
|
||||
const marks =
|
||||
(p.index === f.viewer ? ' <span class="dim">(you)</span>' : '') +
|
||||
(p.index === winner ? ' <span class="wins">— winner</span>' : '');
|
||||
return (
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}${marks}</td>` +
|
||||
`<td class="num">${p.revenue}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<table class="dayend-t"><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The target is a COMBINED floor in every mode that sets one, so it is reported against the whole
|
||||
* table's Revenue rather than the viewer's — showing one player's score against a four-player
|
||||
* target reads as a hopeless position when the table may be comfortably ahead.
|
||||
*/
|
||||
function targetHtml(f: Frame): string {
|
||||
if (f.minCombinedRevenue <= 0) return '';
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
const met = combined >= f.minCombinedRevenue;
|
||||
return (
|
||||
`<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>` +
|
||||
`${met ? ' — cleared.' : ' — short.'}</p>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only when the game is actually scored on collisions.
|
||||
*
|
||||
* Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the
|
||||
* checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on
|
||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
function collisionsHtml(f: Frame): string {
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
return scoredOnCollisions
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY THE GAME ENDED, as a sentence (Gitea#16).
|
||||
*
|
||||
* The page used to interpolate `outcome.reason` straight into the DOM, so a player who finished a
|
||||
* game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment
|
||||
* the game has the player's whole attention. Each reason gets a sentence that says what actually
|
||||
* happened, with this game's own numbers in it.
|
||||
*
|
||||
* EXPORTED for the developer replay recorder (`sim/replay.ts`), which was still printing
|
||||
* `loss — revenueFloor` into its own heading a release after this was written — the same defect the
|
||||
* issue was filed about, surviving in the one place nobody had looked (`TODO.md` #34). One
|
||||
* implementation, so the two cannot say the game ended for different reasons.
|
||||
*/
|
||||
export function reasonSentence(f: Frame, o: NonNullable<Frame['outcome']>, day: number): string {
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
switch (o.reason) {
|
||||
case 'daysElapsed':
|
||||
return `Day ${day} was the last on the timetable, and it ran out.`;
|
||||
case 'revenueFloor':
|
||||
return (
|
||||
`The Division closed short: <b>${combined}</b> Revenue between everyone, against a floor of ` +
|
||||
`<b>${f.minCombinedRevenue}</b>. §3.3 — miss the floor and the whole table loses, whoever ` +
|
||||
`earned the most.`
|
||||
);
|
||||
case 'collisionFloor':
|
||||
return (
|
||||
`Too many collisions — <b>${f.collisionsToday}</b> in one Day and <b>${f.collisionsTotal}</b> ` +
|
||||
`in all, against limits of ${f.maxCollisionsPerDay || '—'} and ${f.maxCollisionsTotal || '—'}. ` +
|
||||
`§3.4 — the railroad was declared unsafe and the game was stopped.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rules this game was actually dealt under — Gitea#16's "what the rules of the game were". */
|
||||
function rulesHtml(f: Frame): string {
|
||||
const mode =
|
||||
f.mode === 'coop' ? 'Co-op — the table scores together' :
|
||||
f.mode === 'competitive' ? 'Competitive — highest Revenue wins' :
|
||||
'Solitaire';
|
||||
const optional = [
|
||||
f.optionalRules.employeeRotation ? 'Employee Rotation' : null,
|
||||
f.optionalRules.reducedVisibility ? 'Reduced Visibility' : null,
|
||||
f.optionalRules.emergencyToolbox ? 'Emergency Toolbox' : null,
|
||||
].filter((x): x is string => x !== null);
|
||||
const r = f.houseRules.revenue;
|
||||
|
||||
const rows: [string, string][] = [
|
||||
['Scoring', mode],
|
||||
['Timetable', f.extraDays > 0
|
||||
? `${f.days} Days, extended by ${f.extraDays} more`
|
||||
: `${f.days} Day${f.days === 1 ? '' : 's'}`],
|
||||
['Revenue floor', f.minCombinedRevenue > 0 ? `${f.minCombinedRevenue} combined` : 'none'],
|
||||
['Collision limits', f.maxCollisionsPerDay > 0 || f.maxCollisionsTotal > 0
|
||||
? `${f.maxCollisionsPerDay || '—'} per Day, ${f.maxCollisionsTotal || '—'} in all`
|
||||
: 'not scored'],
|
||||
['Pay rates', `${r.freightPerLoad} per load, ${r.passengerPerCoach} per coach, ${r.trainPerTransit} per transit`],
|
||||
['Extras start', f.houseRules.extraStart === 'divisionPointsOnly' ? 'Division Points and the Interchange'
|
||||
: f.houseRules.extraStart === 'ownOffice' ? 'those, plus your own Control Point'
|
||||
: 'those, plus any Control Point'],
|
||||
['Timetabled trains', f.houseRules.discardTimetabled ? 'may be discarded' : 'are never discarded'],
|
||||
['Optional rules', optional.length ? optional.join(', ') : 'none'],
|
||||
];
|
||||
|
||||
return `<h4 class="res-h">The rules in play</h4>${factTable(rows)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-column table of plain-text facts.
|
||||
*
|
||||
* BOTH HALVES ESCAPED, exactly once, which is the only reason this is a shared helper rather than a
|
||||
* template repeated twice. The rows it is given today are numbers and fixed phrases, but they are
|
||||
* assembled from the Frame — and the day somebody adds a row carrying a player's name, or a facility
|
||||
* label, the escaping has to already be here rather than be remembered.
|
||||
*/
|
||||
function factTable(rows: [string, string][]): string {
|
||||
return (
|
||||
'<table class="res-t"><tbody>' +
|
||||
rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join('') +
|
||||
'</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE RAILROAD — what actually happened out there, from `GameState.tally` (Gitea#16).
|
||||
*
|
||||
* Rows that would read zero for a reason (no passenger work in a game that had none, no collisions
|
||||
* in a clean one) are dropped rather than printed as `0`: a screen of zeroes reads as a bug, and the
|
||||
* absence of a line is the same information more quietly. A zero that is genuinely interesting —
|
||||
* trains through the Division — stays.
|
||||
*/
|
||||
function tallyHtml(t: Frame['tally']): string {
|
||||
const rows: [string, string][] = [['Trains through the Division', String(t.trainsCompleted)]];
|
||||
|
||||
if (t.trainsCompleted > 0) {
|
||||
rows.push([
|
||||
'Of those, worked en route',
|
||||
`${t.trainsCompletedWithWork} of ${t.trainsCompleted}` +
|
||||
(t.trainsCompletedWithWork === t.trainsCompleted ? ' — every one' : ''),
|
||||
]);
|
||||
}
|
||||
const push = (label: string, n: number, detail = ''): void => {
|
||||
if (n > 0) rows.push([label, `${n}${detail}`]);
|
||||
};
|
||||
push('Loads made up', t.loadsCompleted);
|
||||
push('Loads broken', t.unloadsCompleted);
|
||||
/**
|
||||
* BOTH HALVES OF THE MEN | AT | WORK PIPELINE, not just the loading one.
|
||||
*
|
||||
* §9.1 makes loading and unloading the same shape — begun, then carried through — and the Tally
|
||||
* has counted both since it was written. The screen reported only the loading side, so a player
|
||||
* with three unloads part-finished at the final whistle was told nothing about them while the
|
||||
* equivalent loads were listed. Found 2026-08-30 auditing which Frame fields nothing reads:
|
||||
* `unloadsBegun` was one of four, and the only one whose absence was visible on screen.
|
||||
*/
|
||||
push('Loads still in the pipeline', t.loadsStarted - t.loadsCompleted);
|
||||
push('Unloads still in the pipeline', t.unloadsBegun - t.unloadsCompleted);
|
||||
push('Passengers boarded', t.passengersBoarded);
|
||||
push('Passengers detrained', t.passengersDetrained);
|
||||
push('Cars coupled', t.carsCoupled);
|
||||
push('Cars set out', t.carsDropped);
|
||||
push('Extras run', t.extrasStarted);
|
||||
push('Second sections ordered', t.secondSections);
|
||||
push('Flying switches', t.flyingSwitches);
|
||||
push('Offices upgraded', t.officeUpgrades);
|
||||
push('Facilities unjammed', t.facilitiesUnjammed);
|
||||
push('Trains held', t.trainsHeld);
|
||||
push('Trains diverted', t.trainsDiverted);
|
||||
push('Expedite faults', t.expediteFaults);
|
||||
push('Dispatch bonuses used', t.dispatchBonusesUsed);
|
||||
if (t.clearancesRequested > 0) {
|
||||
rows.push(['Clearances', `${t.clearancesAllowed} allowed of ${t.clearancesRequested} asked`]);
|
||||
}
|
||||
if (t.trainsDestroyed > 0) {
|
||||
rows.push([
|
||||
'Trains destroyed',
|
||||
`${t.trainsDestroyed}, taking ${t.carsDestroyed} car${t.carsDestroyed === 1 ? '' : 's'} with them`,
|
||||
]);
|
||||
}
|
||||
// §X18 only — the one card in the deck that pays a train for standing still. Reported as what it
|
||||
// is rather than as "the longest an engine sat on a siding", which the engine cannot answer (see
|
||||
// `Tally.circusStops`).
|
||||
if (t.circusStops.length > 0) {
|
||||
rows.push([
|
||||
'Circus set-ups',
|
||||
t.circusStops.map((c) => `Train ${c.trainNumber} at ${c.where}`).join(', '),
|
||||
]);
|
||||
}
|
||||
push('Cards drawn', t.cardsDrawn);
|
||||
push('Cards played', t.cardsPlayed);
|
||||
// Gitea#9 made throwing a Timetabled train away a legal and deliberate move, so a discard is a
|
||||
// CHOICE the player made rather than an accident of the hand limit — and the engine has counted
|
||||
// it all along while the screen listed only draws and plays beside it.
|
||||
push('Cards discarded', t.cardsDiscarded);
|
||||
|
||||
return `<h4 class="res-h">The railroad</h4>${factTable(rows)}`;
|
||||
}
|
||||
|
||||
/** Per-player work, for a table that wants to know who did what rather than only who won. */
|
||||
function perPlayerHtml(f: Frame): string {
|
||||
const t = f.tally;
|
||||
if (f.players.length < 2) return '';
|
||||
const head =
|
||||
'<tr><th></th><th class="num">Rev</th><th class="num">Loads</th><th class="num">Unloads</th>' +
|
||||
'<th class="num">Pass.</th><th class="num">Cards</th><th class="num">Crashes</th></tr>';
|
||||
const rows = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map((p) => {
|
||||
const q = t.byPlayer[p.index];
|
||||
if (!q) return '';
|
||||
return (
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}</td>` +
|
||||
`<td class="num">${p.revenue}</td><td class="num">${q.loads}</td>` +
|
||||
`<td class="num">${q.unloads}</td>` +
|
||||
`<td class="num">${q.passengersBoarded + q.passengersDetrained}</td>` +
|
||||
`<td class="num">${q.cardsPlayed}</td><td class="num">${q.collisions}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<h4 class="res-h">Who did what</h4><table class="res-t res-wide"><tbody>${head}${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE END-OF-GAME RESULTS SCREEN — Gitea#16, first pass.
|
||||
*
|
||||
* Everything the Frame already knew plus everything the event tally counted, in the order a player
|
||||
* asks for it: what happened, who won, by how much, under what rules, and then what the railroad
|
||||
* actually did all game. Badges and the "what would make this exciting" brainstorm are the second
|
||||
* pass the issue asks for and are deliberately not here.
|
||||
*
|
||||
* THE OFFICIAL RESULT IS THE ONE AT THE TOP, always. In an extended game (Gitea#11) the standings go
|
||||
* on moving after the winner is settled, so this screen reports the frozen result first and puts
|
||||
* everything that happened afterwards in its own section, marked as informational. "In a five-day
|
||||
* game, even if it's extended to eight or nine days, the winner and the official answer is the
|
||||
* winner at the end of five days" (Jesse, 2026-08-28).
|
||||
*/
|
||||
export function resultsHtml(f: Frame): string {
|
||||
// `official` is written by the engine the moment any game ends, so it is present on every finished
|
||||
// game. The fallback keeps this rendering something sane for a Frame that predates it — a replay
|
||||
// of a save recorded before this release, which the replay viewer will happily hand us.
|
||||
const report = f.official;
|
||||
const o = report?.outcome ?? f.outcome;
|
||||
if (!o) return '<p class="dim">This game has not ended.</p>';
|
||||
|
||||
const officialDay = report?.day ?? f.days;
|
||||
const winnerName =
|
||||
o.winner === null ? null : (f.players.find((p) => p.index === o.winner)?.name ?? null);
|
||||
|
||||
const headline =
|
||||
o.result === 'loss'
|
||||
? 'The Division failed'
|
||||
: winnerName === null
|
||||
? 'The Division ran'
|
||||
: `${esc(winnerName)} takes the Division`;
|
||||
|
||||
/**
|
||||
* The result is reported against the standings AS THEY WERE at the official ending, not as they
|
||||
* are now — in an extended game those are different numbers, and the winner has to be shown
|
||||
* winning. `revenues` is frozen alongside the outcome for exactly this.
|
||||
*/
|
||||
const frozen = report
|
||||
? { ...f, players: f.players.map((p) => ({ ...p, revenue: report.revenues[p.index] ?? p.revenue })) }
|
||||
: f;
|
||||
|
||||
const result =
|
||||
o.result === 'loss'
|
||||
? '<p>Nobody wins this one.</p>'
|
||||
: o.winner === null
|
||||
? '<p>The table clears it together — a Co-op game has no individual winner.</p>'
|
||||
: `<p><b>${esc(winnerName ?? '')}</b> finishes ahead on Revenue.</p>`;
|
||||
|
||||
const extended =
|
||||
f.extraDays > 0
|
||||
? '<h4 class="res-h">After the timetable</h4>' +
|
||||
`<p>The table played on for ${f.extraDays} more Day${f.extraDays === 1 ? '' : 's'}, ` +
|
||||
`through Day ${f.days + f.extraDays}. None of it changed the result above — it is recorded ` +
|
||||
'here because it happened.</p>' +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f) +
|
||||
tallyHtml(f.tally)
|
||||
: '';
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h res-${o.result}">${headline}</h3>` +
|
||||
`<p>${reasonSentence(frozen, o, officialDay)}</p>` +
|
||||
result +
|
||||
standingsHtml(frozen, o.winner) +
|
||||
targetHtml(frozen) +
|
||||
collisionsHtml(frozen) +
|
||||
perPlayerHtml(frozen) +
|
||||
rulesHtml(f) +
|
||||
tallyHtml(report?.tally ?? f.tally) +
|
||||
extended
|
||||
);
|
||||
}
|
||||
|
||||
export function blockedHtml(f: Frame): string {
|
||||
return f.blocked.length === 0
|
||||
? '<li class="dim">nothing blocked</li>'
|
||||
@@ -407,4 +757,28 @@ ul.blocked{margin:0;padding-left:18px}
|
||||
.fstat.good{background:rgba(40,140,60,.28)}
|
||||
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
||||
.fstat.idle{opacity:.6}
|
||||
/* THE DAY-END DIALOG (Gitea#10). The dialog chrome is play.html's; these are its contents, here
|
||||
because dayEndHtml is here — a panel and its styling stay together. */
|
||||
.dayend-h{font-size:15px;text-transform:none;letter-spacing:0;color:#e6e9ee;margin:0 0 8px}
|
||||
.dayend-t{border-collapse:collapse;margin:9px 0;min-width:210px}
|
||||
.dayend-t td{padding:3px 12px 3px 0;border-top:1px solid #2c333d}
|
||||
.dayend-t tr:first-child td{border-top:0}
|
||||
.dayend-t .num{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;padding-right:0}
|
||||
.dayend-t .you td{color:#8fd6a0}
|
||||
.dayend-t .wins{color:#e8c56a;font-weight:700}
|
||||
|
||||
/* END-OF-GAME RESULTS (Gitea#16). Same family as the Day-end dialog above, which is the point —
|
||||
the two screens share their standings/target/collision blocks and should look like each other. */
|
||||
.res-h{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b95a3;
|
||||
margin:16px 0 6px;border-top:1px solid #2c333d;padding-top:10px}
|
||||
.res-win{color:#8fd6a0}
|
||||
.res-loss{color:#d98f8f}
|
||||
.res-t{border-collapse:collapse;margin:4px 0;width:100%}
|
||||
.res-t td,.res-t th{padding:3px 12px 3px 0;border-top:1px solid #232a33;vertical-align:top}
|
||||
.res-t tr:first-child td{border-top:0}
|
||||
.res-t td:first-child{color:#8b95a3;white-space:nowrap}
|
||||
.res-t th{color:#6d7783;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
|
||||
.res-t .num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.res-wide td:first-child{color:#e6e9ee}
|
||||
.res-t .you td{color:#8fd6a0}
|
||||
`;
|
||||
|
||||
+332
-192
@@ -76,11 +76,24 @@ dialog input:focus{outline:none;border-color:#4d6fa8}
|
||||
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
|
||||
.ng-buttons button:hover{border-color:#4d6fa8}
|
||||
#ng-deal{background:#31527f;border-color:#4d6fa8}
|
||||
/* The save warning is the one thing on this screen that describes something IRREVERSIBLE, and it
|
||||
sat in `.ng-note` — the same dim 11px grey as the twenty explanatory notes above it, which is
|
||||
where the eye has already learned there is nothing to act on. Sized and coloured to be read
|
||||
(Jesse, 2026-08-30). Amber rather than red: losing a saved game is a real cost, not a danger, and
|
||||
red here would outrank the actual rules of the game sitting above it. */
|
||||
#ss-saved-note{font-size:15px;font-weight:500;line-height:1.55;color:#ffcf70;background:#332a15;
|
||||
border:1px solid #b8912c;border-left:5px solid #e0a83c;border-radius:5px;padding:12px 14px;
|
||||
margin:18px 0 0}
|
||||
#ss-saved-note b{color:#ffe3a6}
|
||||
/* Two live choices, so neither is the quiet one: `Create new game` keeps the primary blue it has when
|
||||
it is the only button, and `Continue` is given the same weight rather than reading as a cancel. */
|
||||
#ss-deal{background:#31527f;border-color:#4d6fa8}
|
||||
#ss-resume{background:#2f5340;border-color:#4f8a68}
|
||||
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
padding:10px 12px;margin-bottom:12px}
|
||||
#lobby{max-width:1040px;margin:0 auto;padding:14px}
|
||||
#lobby,#solitairesetup{max-width:1040px;margin:0 auto;padding:14px}
|
||||
/* The create form is two short lists, not one long one: what game this is on the left, what its
|
||||
rules are on the right. Collapses to one column where there is no room for two. */
|
||||
.lb-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:22px;align-items:start}
|
||||
@@ -95,9 +108,8 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
lobby and nothing on it said so — a radio that silently refuses reads as a broken radio. */
|
||||
.ng-radio.disabled{opacity:.45;cursor:not-allowed}
|
||||
.ng-radio.disabled:hover{background:none}
|
||||
.lb-why{color:#e0b060;font-size:11px}
|
||||
#lobby h2{margin-top:0}
|
||||
#lobby h3{margin-bottom:2px}
|
||||
#lobby h2,#solitairesetup h2{margin-top:0}
|
||||
#lobby h3,#solitairesetup h3{margin-bottom:2px}
|
||||
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
|
||||
.lb-seat:last-child{border-bottom:none}
|
||||
.lb-seat .who{flex:1}
|
||||
@@ -203,6 +215,17 @@ button.act{display:inline-block}
|
||||
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
/* A SEGMENTED CONTROL, BECAUSE A CYCLE COULD NOT REACH EVERY STATE (TODO #16).
|
||||
One button that steps auto -> pinned -> auto can only ever offer the pin OPPOSITE to whatever
|
||||
auto is doing at that moment, which depends on the phase — so "always hidden" was unreachable
|
||||
from "always showing" without waiting for the right phase in between. Three controls, one per
|
||||
mode, and the current one is lit. The buttons still say what they DO rather than what the panel
|
||||
is doing, which was the earlier fix and is worth keeping. */
|
||||
.seg{display:inline-flex;margin-left:10px;vertical-align:middle;border-radius:4px;overflow:hidden;
|
||||
border:1px solid #4a5361}
|
||||
.seg button.ghost{margin:0;border:0;border-radius:0;border-left:1px solid #4a5361}
|
||||
.seg button.ghost:first-child{border-left:0}
|
||||
.seg button.ghost[aria-pressed="true"]{background:#2f3a4a;color:var(--fg);font-weight:600}
|
||||
#district.folded #grid{display:none}
|
||||
#district.folded .districtrule{display:none}
|
||||
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
||||
@@ -212,6 +235,16 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
.districtrule b{color:#cfd6e0}
|
||||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||
#districtsummary{display:none}
|
||||
/* The This Game card folds the same way the district does, and for the same reason: a panel that
|
||||
vanishes entirely reads as broken, so the summary line is what a folded card still says. */
|
||||
#gamecard.folded #gamecardbody{display:none}
|
||||
#gamecard #gamecardsummary{display:none}
|
||||
#gamecard.folded #gamecardsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||
#gamecardbody dl{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:4px 0 10px}
|
||||
#gamecardbody dt{color:#8b94a3;font-size:11px}
|
||||
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
|
||||
#gamecardbody dd.changed{color:#f0b64a}
|
||||
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
|
||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||
so a disabled button must not wear it. */
|
||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||
@@ -222,6 +255,14 @@ h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe
|
||||
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
|
||||
.over.win{background:rgba(40,140,60,.35)}
|
||||
.over.loss{background:rgba(160,60,60,.3)}
|
||||
/* THE EXTENSION VOTE (Gitea#11) — unanimous, so who has not answered yet is the useful half. */
|
||||
.vote-tally{display:flex;flex-wrap:wrap;gap:4px 12px;margin:0 0 9px;font-size:12px}
|
||||
.vote.yes{color:#8fd6a0}
|
||||
.vote.no{color:#d98f8f}
|
||||
.vote.wait{color:var(--dim)}
|
||||
/* Wider than the New Game dialog: the results carry a seven-column per-player table (Gitea#16). */
|
||||
#resultsdlg{max-width:640px}
|
||||
#resultsdlg table{max-width:100%}
|
||||
/* cards, log, blocked */
|
||||
.card.gone{opacity:.35;text-decoration:line-through}
|
||||
.subj{display:block;width:100%;margin:2px 0}
|
||||
@@ -299,9 +340,10 @@ ul.blocked li{padding:2px 0}
|
||||
|
||||
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
|
||||
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
|
||||
`main.ts` only decides whether THIS div or `#gameui` below is the one currently visible.
|
||||
`#newgamedlg` at the very end of the body is solitaire-only, and asks the same questions through
|
||||
the same shared module (`settings-form.ts`) — the two blocks are generated from one template. -->
|
||||
`main.ts` only decides whether THIS div, `#solitairesetup` or `#gameui` is the one visible.
|
||||
`#solitairesetup` asks the same questions of a solitaire player, through the same shared module
|
||||
(`settings-form.ts`) — the two blocks are generated from one template, and since 2026-08-30
|
||||
they are the ONLY two: the in-game dialog that was a third copy is gone. -->
|
||||
<div id="lobby" hidden>
|
||||
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
|
||||
|
||||
@@ -394,12 +436,13 @@ ul.blocked li{padding:2px 0}
|
||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||
<!-- WITH THE TABLE SIZE IT IS ABOUT, not below the rules block — reported by Jesse, who found
|
||||
it separated from the control it explains by fifteen settings. -->
|
||||
<p class="ng-note">Every chair has to be taken before the game can start — by a person or by a
|
||||
bot. Pick the size of the table now; it cannot change once the game is created.</p>
|
||||
<p class="ng-note">Every chair must be filled before the game can start. For solitaire,
|
||||
there’s only one player. For multiplayer, that must be filled by a person or a
|
||||
bot. The number of players cannot be changed once the game is created.</p>
|
||||
</div>
|
||||
|
||||
<div class="lb-col">
|
||||
<h3>Game type</h3>
|
||||
<h3>Game type (multi-player)</h3>
|
||||
<div class="set-row" id="lb-type-row">
|
||||
<label class="ng-radio"><input type="radio" name="lb-type" value="solitaire">
|
||||
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||
@@ -413,7 +456,7 @@ ul.blocked li{padding:2px 0}
|
||||
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
||||
</div>
|
||||
|
||||
<p class="ng-note" id="lb-type-note"></p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- FULL WIDTH WHEN IT OPENS. Reported by Jesse: opened inside the right-hand column it made a
|
||||
@@ -423,7 +466,7 @@ ul.blocked li{padding:2px 0}
|
||||
<summary>Game settings</summary>
|
||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
||||
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
|
||||
started from; clicking a type again resets all of them back to it. They are fixed when the
|
||||
started from; changing the game type resets all of them back to it. They are fixed when the
|
||||
game is created and cannot be changed once it starts.</p>
|
||||
<div class="set-groups">
|
||||
|
||||
@@ -497,13 +540,13 @@ ul.blocked li{padding:2px 0}
|
||||
</div>
|
||||
<div class="set-row" id="lb-colday-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="lb-colday-on" checked>
|
||||
<span>The game ends and everyone loses if collisions in one Day reach</span>
|
||||
<span>The game ends immediately and results in a loss if collisions in one Day reach</span>
|
||||
<input id="lb-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="lb-colday-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-coltotal-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="lb-coltotal-on" checked>
|
||||
<span>The game ends and everyone loses after this many collisions in the whole game</span>
|
||||
<span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
|
||||
<input id="lb-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="lb-coltotal-hint"></span>
|
||||
</div>
|
||||
@@ -514,7 +557,7 @@ ul.blocked li{padding:2px 0}
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
|
||||
<p class="ng-note">Each one changes how the game plays.</p>
|
||||
<div class="set-row" id="lb-visibility-row">
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
@@ -534,13 +577,20 @@ ul.blocked li{padding:2px 0}
|
||||
<input id="lb-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-toolbox-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-tossloco-row">
|
||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
|
||||
be played onto the timetable. An Extra is never discardable either way</span>
|
||||
<input id="lb-tossloco" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-tossloco-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="lb-span">
|
||||
<button id="lb-create" type="button">Create game</button>
|
||||
<button id="lb-create" type="button">Create new game</button>
|
||||
<p class="lb-error" id="lb-create-err" role="alert"></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -577,6 +627,208 @@ ul.blocked li{padding:2px 0}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ===================================================================
|
||||
SOLITAIRE SETUP — the same question multiplayer already asks first,
|
||||
now asked here too (Jesse, 2026-08-29): a genuinely fresh visit deals
|
||||
nothing until this screen's own Deal button is pressed. A saved game,
|
||||
an explicit `?seed=`, or a URL already carrying a Deal's answers (any
|
||||
of the shared block's fields — `hand` names the one always written)
|
||||
all skip straight past this screen, exactly as `?lobby` already skips
|
||||
past it into the lobby: those are not "no plan yet", they are a
|
||||
choice already made, elsewhere.
|
||||
|
||||
THE SAME BLOCK THE DIALOG AND THE LOBBY USE, same shared module
|
||||
(`settings-form.ts`), same order — three screens are one design now
|
||||
instead of two. Only Solitaire can be dealt from here, so the other
|
||||
four types are shown exactly as the in-game dialog shows them: present,
|
||||
disabled, with a note pointing at the Multiplayer door instead.
|
||||
==================================================================== -->
|
||||
<div id="solitairesetup" hidden>
|
||||
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Solitaire</span></header>
|
||||
|
||||
<section>
|
||||
<h2>New solitaire game</h2>
|
||||
<p class="ng-note">One railroad, one player, five full days by default — everything below is
|
||||
yours to change before you deal. Clearing the Revenue floor wins; falling short loses.</p>
|
||||
|
||||
<!-- THE SAME THREE PARAMETERS THE LOBBY ASKS, in the same order, with the same note under them
|
||||
(Jesse, 2026-08-30: "everything beneath that should be the same"). The table size is here
|
||||
rather than hidden because it is one of the three things that describe a game, and leaving
|
||||
it out made this screen a different form that happened to share a rules block. It is LOCKED
|
||||
at one: a `LocalSession` runs the engine in this browser and a table needs a server, which
|
||||
is the same reason the four multiplayer game types are shown disabled below. -->
|
||||
<div class="lb-params">
|
||||
<label class="ng-num"><span>Seed</span>
|
||||
<input id="ss-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
||||
<label class="ng-num"><span>Players at the table</span>
|
||||
<select id="ss-players" disabled>
|
||||
<option value="1" selected>1</option>
|
||||
</select></label>
|
||||
<label class="ng-num"><span>Days</span>
|
||||
<input id="ss-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||
</div>
|
||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||
<p class="ng-note">Every chair must be filled before the game can start. For solitaire,
|
||||
there’s only one player. For multiplayer, that must be filled by a person or a
|
||||
bot. The number of players cannot be changed once the game is created.</p>
|
||||
|
||||
<h3>Game type (solitaire)</h3>
|
||||
<div class="set-row" id="ss-type-row">
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="solitaire" checked>
|
||||
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="coop">
|
||||
<span><b>Co-op</b><br><span class="dim">Everyone’s Revenue is one table score. You win together or lose together.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="competitive">
|
||||
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="cutthroat">
|
||||
<span><b>Cutthroat</b><br><span class="dim">Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="custom">
|
||||
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<details id="ss-settings" open>
|
||||
<summary>Game settings</summary>
|
||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
||||
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
|
||||
started from; changing the game type resets all of them back to it. They are fixed when the
|
||||
game is created and cannot be changed once it starts.</p>
|
||||
<div class="set-groups">
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Starting hand</h3>
|
||||
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
|
||||
either way — deal six and the first turn is spent choosing which of them to keep.</p>
|
||||
<div class="set-row" id="ss-hand-row">
|
||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeRandom">
|
||||
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="sixRandom" checked>
|
||||
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeTrackThreeOther">
|
||||
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
|
||||
<span class="set-hint" id="ss-hand-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Where an Extra may start</h3>
|
||||
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes,
|
||||
and the place decides which way it runs — a Division Point sends it away from itself; in the
|
||||
middle of the railroad the player picks east or west. The Division Points and the Interchange
|
||||
belong to nobody and are always available. Starting one inside a district is the part that
|
||||
favours a seat, so it is set here. An Office must be a Control Point whatever this says: a
|
||||
Whistle Post never qualifies.</p>
|
||||
<div class="set-row" id="ss-extra-row">
|
||||
<label class="ng-radio"><input type="radio" name="ss-extra" value="divisionPointsOnly">
|
||||
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading. Every Extra begins on shared ground.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-extra" value="ownOffice">
|
||||
<span><b>Also the playing player’s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else’s district.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-extra" value="anyOffice">
|
||||
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player’s district.</span></span></label>
|
||||
<span class="set-hint" id="ss-extra-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Revenue</h3>
|
||||
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and
|
||||
again when it is detrained; a load pays when it is made up and again when it is broken. Zero
|
||||
switches an economy off so the others can be read.</p>
|
||||
<div class="set-row" id="ss-passenger-row">
|
||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||
<input id="ss-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="ss-passenger-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-freight-row">
|
||||
<label class="ng-num"><span>Freight revenue per load</span>
|
||||
<input id="ss-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="ss-freight-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-transit-row">
|
||||
<label class="ng-num"><span>Train revenue per transit</span>
|
||||
<input id="ss-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||
<span class="set-hint" id="ss-transit-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the
|
||||
Division — the one thing nobody has to work for.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Victory conditions</h3>
|
||||
<p class="ng-note">The ways this game can end badly. Each one is switched on or off in its own
|
||||
right; how long the game runs is set above, with the table size.</p>
|
||||
<div class="set-row" id="ss-minrev-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
|
||||
<span>You lose if Revenue at the end is under</span>
|
||||
<input id="ss-minrev" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-minrev-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-colday-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked>
|
||||
<span>The game ends immediately and results in a loss if collisions in one Day reach</span>
|
||||
<input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-colday-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-coltotal-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked>
|
||||
<span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
|
||||
<input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-coltotal-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">The opponent-directed cards — Derail, Watertower, Hobo Jungle and the
|
||||
nineteen others, along with the seven that answer them — are not implemented yet, so no game
|
||||
type deals them whatever else is set here.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Each one changes how the game plays.</p>
|
||||
<div class="set-row" id="ss-visibility-row">
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
<input id="ss-visibility" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-visibility-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-rotation-row">
|
||||
<label class="ng-num"><span>Employee Rotation — not applicable for solitaire</span>
|
||||
<input id="ss-rotation" type="checkbox" disabled></label>
|
||||
<span class="set-hint" id="ss-rotation-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-toolbox-row">
|
||||
<label class="ng-num"><span>Emergency Toolbox — start holding a Red Flag, so a hand of four;
|
||||
play or discard down to three on the first turn</span>
|
||||
<input id="ss-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-toolbox-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-tossloco-row">
|
||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||
Department slot. Turn this off and a train card can only ever be played onto the timetable.
|
||||
An Extra is never discardable either way</span>
|
||||
<input id="ss-tossloco" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-tossloco-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Shown only when `station-master.save.v1` holds a game. Dealing from this screen CLEARS that
|
||||
save (`commitNewGame` calls `clearSave`), so without a way back the door would be a way to
|
||||
lose a game in progress — and the door is reached by clicking "Play solitaire", which nobody
|
||||
reads as "discard what I was playing". -->
|
||||
<p id="ss-saved-note" hidden><b>You have a solitaire game in progress.</b> Creating a new game
|
||||
replaces it permanently — there is no undo. Choose <b>Continue saved game</b> to pick it
|
||||
up where you left off.</p>
|
||||
|
||||
<menu class="ng-buttons">
|
||||
<button id="ss-resume" type="button" hidden>Continue saved game</button>
|
||||
<button id="ss-deal" type="button">Create new game</button>
|
||||
</menu>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="gameui" hidden>
|
||||
<div class="topbar">
|
||||
<header>
|
||||
@@ -585,20 +837,23 @@ ul.blocked li{padding:2px 0}
|
||||
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
|
||||
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
|
||||
<span id="objective" class="pace">—</span>
|
||||
<span class="dim">seed <span id="seed">—</span></span>
|
||||
<!-- THE COLLISION COUNTS, WHICH ARE A LIVE SCORE AND NOT A SETTING (TODO #28). The Frame has
|
||||
carried `collisionsToday` and `collisionsTotal` since v0.7.0 and nothing on the board drew
|
||||
them, so the one victory condition that can end a game early was invisible while it ran.
|
||||
Empty and collapsed when both limits are 0 — a game that cannot end this way should not be
|
||||
counting towards it. The limits themselves live in the This Game card; this is progress. -->
|
||||
<span class="dim" id="collisions" title=""></span>
|
||||
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
|
||||
into `LobbyReady` — so a seated player could not say which game they were in, could not match
|
||||
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
|
||||
Empty (and collapsed) in solitaire, where there is no code. -->
|
||||
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
|
||||
<!-- Co-op, Competitive, Cutthroat or Custom, derived from the config the Frame carries
|
||||
(`presets.ts`). A Cutthroat game used to look exactly like a Co-op one from the board. -->
|
||||
<span class="dim" id="gametype" title=""></span>
|
||||
<!-- WHICH RULES THIS GAME IS BEING PLAYED UNDER. The settings are chosen when the game is dealt
|
||||
and then never mentioned again, which makes a playtest note ("scored 4") unreadable a week
|
||||
later: at 0 revenue per transit that is a different game from the same seed at 5. Short enough
|
||||
to keep the header on one line; the tooltip spells it out. -->
|
||||
<span class="dim" id="houserules" title="">—</span>
|
||||
<!-- The seed, the seat, the game type and every house rule MOVED TO THE THIS GAME CARD, 2026-08-30
|
||||
(TODO #28). Jesse: "we can give complete information about all the game options and not take
|
||||
up valuable real estate at the top of the screen… it is not something that they're likely to
|
||||
need all the time." What stays here is what is glanced at every turn — Revenue, the objective,
|
||||
the collision counts — plus the game code, which is identity rather than settings: it is how
|
||||
you say WHICH game you are in, out loud, without opening anything. -->
|
||||
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||||
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
||||
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
||||
@@ -608,7 +863,7 @@ ul.blocked li{padding:2px 0}
|
||||
</span>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
|
||||
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
|
||||
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
|
||||
<!-- LEAVING A RUNNING GAME. Reported by Jesse 2026-08-23: "if I'm a player in the middle of the
|
||||
game and I need to leave, how do I leave the game, clear the token from my browser so I can
|
||||
@@ -642,7 +897,7 @@ ul.blocked li{padding:2px 0}
|
||||
<section id="district">
|
||||
<h2>Your Office Area
|
||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
||||
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||
</h2>
|
||||
<div id="districtsummary" class="dim"></div>
|
||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||
@@ -686,179 +941,64 @@ ul.blocked li{padding:2px 0}
|
||||
</section>
|
||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||
<!-- THIS GAME — the settings it was dealt under, off the top line and out of the way (TODO #28).
|
||||
Last in the column and folded by default because it is looked up, not watched: "Oh wait,
|
||||
what did we set that to?" The body is `rulesListHtml`, the same renderer the join preview
|
||||
and the seating screen draw, so what you agreed to in the lobby and what you can read
|
||||
mid-game cannot drift apart. -->
|
||||
<section id="gamecard" class="folded"><h2>This Game
|
||||
<button id="gamecardtoggle" class="ghost" type="button" title="The seed, the seat, the game type and every rule this game was dealt under.">show</button>
|
||||
</h2>
|
||||
<div id="gamecardsummary" class="dim"></div>
|
||||
<div id="gamecardbody"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ===================================================================
|
||||
NEW GAME — the seed, the opening hand, and what the three economies pay.
|
||||
<!-- THE IN-GAME "NEW GAME" BUTTON GOES TO THE SOLITAIRE SETUP SCREEN — there is no second
|
||||
dialog any more (Jesse, 2026-08-30: "it should not go to a separate screen. We should reuse the
|
||||
Solitaire New Game Screen… in general we should reuse what we already have").
|
||||
|
||||
It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game
|
||||
you are about to play had no way in at all: the opening hand had been changed twice with no
|
||||
way back to the earlier rule, and the revenue rates were constants in the source. Balance is
|
||||
the open question in this game (`TODO.md`), and the way to settle it is to deal several games
|
||||
at different settings — which needs a dialog, not a rebuild.
|
||||
`#newgamedlg` was a third copy of the same questions, and the one that drifted: it kept the
|
||||
multiplayer wording ("Everyone loses if COMBINED Revenue…") on a screen only ever shown to a
|
||||
solitaire player, and explained Employee Rotation in full beside a control it had disabled.
|
||||
Deleting it removes the drift rather than re-wording it.
|
||||
|
||||
Every control has a default that is the recommended answer, so DEAL with nothing touched is a
|
||||
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
|
||||
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
|
||||
==================================================================== -->
|
||||
Nothing is lost by navigating away mid-game: `render()` calls `save()` on every frame, so the
|
||||
game in progress is always on disk, and the setup screen offers "Continue saved game"
|
||||
to come straight back to it. -->
|
||||
</div><!-- /gameui -->
|
||||
|
||||
<dialog id="newgamedlg" aria-labelledby="ng-title">
|
||||
<form method="dialog" id="newgameform">
|
||||
<h2 class="big" id="ng-title">New game</h2>
|
||||
|
||||
<!-- THE SAME BLOCK THE LOBBY USES, same shared module, same order — the two screens are one
|
||||
design. Only Solitaire can be dealt here; the multiplayer types are shown disabled rather
|
||||
than hidden, so what this screen offers and what the lobby offers read as one list. -->
|
||||
<div class="lb-params">
|
||||
<label class="ng-num"><span>Seed</span>
|
||||
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
||||
<label class="ng-num"><span>Days</span>
|
||||
<input id="ng-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||
</div>
|
||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||
|
||||
<h3>Game type</h3>
|
||||
<div class="set-row" id="ng-type-row">
|
||||
<label class="ng-radio"><input type="radio" name="ng-type" value="solitaire">
|
||||
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-type" value="coop" checked>
|
||||
<span><b>Co-op</b><br><span class="dim">Everyone’s Revenue is one table score. You win together or lose together.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-type" value="competitive">
|
||||
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-type" value="cutthroat">
|
||||
<span><b>Cutthroat</b><br><span class="dim">Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-type" value="custom">
|
||||
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
||||
</div>
|
||||
|
||||
<p class="ng-note" id="ng-type-note"></p>
|
||||
|
||||
<details id="ng-settings" open>
|
||||
<summary>Game settings</summary>
|
||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
||||
Changing any of them selects <b>Custom</b>; clicking a type again resets all of them back
|
||||
to it.</p>
|
||||
<div class="set-groups">
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Starting hand</h3>
|
||||
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
|
||||
either way — deal six and the first turn is spent choosing which of them to keep.</p>
|
||||
<div class="set-row" id="ng-hand-row">
|
||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeRandom">
|
||||
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="sixRandom" checked>
|
||||
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeTrackThreeOther">
|
||||
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
|
||||
<span class="set-hint" id="ng-hand-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Where an Extra may start</h3>
|
||||
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes,
|
||||
and the place decides which way it runs — a Division Point sends it away from itself; in the
|
||||
middle of the railroad the player picks east or west. The Division Points and the Interchange
|
||||
belong to nobody and are always available. Starting one inside a district is the part that
|
||||
favours a seat, so it is set here. An Office must be a Control Point whatever this says: a
|
||||
Whistle Post never qualifies.</p>
|
||||
<div class="set-row" id="ng-extra-row">
|
||||
<label class="ng-radio"><input type="radio" name="ng-extra" value="divisionPointsOnly">
|
||||
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading. Every Extra begins on shared ground.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-extra" value="ownOffice">
|
||||
<span><b>Also the playing player’s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else’s district.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-extra" value="anyOffice">
|
||||
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player’s district.</span></span></label>
|
||||
<span class="set-hint" id="ng-extra-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Revenue</h3>
|
||||
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and
|
||||
again when it is detrained; a load pays when it is made up and again when it is broken. Zero
|
||||
switches an economy off so the others can be read.</p>
|
||||
<div class="set-row" id="ng-passenger-row">
|
||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||
<input id="ng-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="ng-passenger-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-freight-row">
|
||||
<label class="ng-num"><span>Freight revenue per load</span>
|
||||
<input id="ng-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="ng-freight-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-transit-row">
|
||||
<label class="ng-num"><span>Train revenue per transit</span>
|
||||
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||
<span class="set-hint" id="ng-transit-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the
|
||||
Division — the one thing nobody has to work for.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Victory conditions</h3>
|
||||
<p class="ng-note">The ways this game can end badly. Each one is switched on or off in its own
|
||||
right; how long the game runs is set above, with the table size.</p>
|
||||
<div class="set-row" id="ng-minrev-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ng-minrev-on" checked>
|
||||
<span>Everyone loses if combined Revenue at the end is under</span>
|
||||
<input id="ng-minrev" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ng-minrev-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-colday-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ng-colday-on" checked>
|
||||
<span>The game ends and everyone loses if collisions in one Day reach</span>
|
||||
<input id="ng-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ng-colday-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-coltotal-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ng-coltotal-on" checked>
|
||||
<span>The game ends and everyone loses after this many collisions in the whole game</span>
|
||||
<input id="ng-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ng-coltotal-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">The opponent-directed cards — Derail, Watertower, Hobo Jungle and the
|
||||
nineteen others, along with the seven that answer them — are not implemented yet, so no game
|
||||
type deals them whatever else is set here.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
|
||||
<div class="set-row" id="ng-visibility-row">
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
<input id="ng-visibility" type="checkbox"></label>
|
||||
<span class="set-hint" id="ng-visibility-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-rotation-row">
|
||||
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one
|
||||
chair left and takes over the next station up the line. Your Revenue and the Fedora go with
|
||||
you; the district stays where it is</span>
|
||||
<input id="ng-rotation" type="checkbox"></label>
|
||||
<span class="set-hint" id="ng-rotation-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-toolbox-row">
|
||||
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand
|
||||
of four; play or discard down to three on the first turn</span>
|
||||
<input id="ng-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="ng-toolbox-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- THE DAY ROLLING OVER (Gitea#10). A Day turns inside the automatic phases, so it happens
|
||||
between one click and the next; the phase banner and the announcement flash both fade before
|
||||
someone reading the board notices them. A modal stops and waits, which is the whole request:
|
||||
"hard to keep track of time". Filled by `dayEndHtml` and opened from `render()`. -->
|
||||
<dialog id="dayenddlg" aria-labelledby="de-title">
|
||||
<form method="dialog">
|
||||
<div id="dayendbody"></div>
|
||||
<menu class="ng-buttons">
|
||||
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Use the <b>Multiplayer</b> button instead — it creates or joins a game on this server.</span>
|
||||
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
|
||||
<button value="deal" id="ng-deal" type="submit">Deal</button>
|
||||
<button value="ok" id="de-ok" type="submit">Carry on</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- THE END-OF-GAME RESULTS (Gitea#16). Filled by `resultsHtml` and opened from `renderEnding`,
|
||||
which puts it up once per ending unasked and leaves a button to reopen it. Reopenable matters:
|
||||
Gitea#11 lets a table play past the end, and continuing must not cost you the results screen. -->
|
||||
<!-- THE EXTENSION QUESTION IS ASKED HERE, not only behind this dialog (Gitea#11 + #16).
|
||||
This opens ITSELF at every ending, and it is modal — so `renderEnding`'s "play one more Day"
|
||||
buttons, which it writes into `#actions`, sit underneath it. A player saw a results screen whose
|
||||
only control was Close and reasonably concluded the game was over: reported by Jesse
|
||||
2026-08-30, "Solitaire game ended. I did not have an option to extend the game by a day."
|
||||
Gitea#11 was verified over the HTTP API, which renders no dialog, so the browser never was.
|
||||
The two extension buttons are hidden unless the game is actually awaiting a vote. -->
|
||||
<dialog id="resultsdlg" aria-labelledby="rs-title">
|
||||
<form method="dialog">
|
||||
<div id="resultsbody"></div>
|
||||
<menu class="ng-buttons">
|
||||
<button value="extend-yes" id="rs-extend-yes" type="submit" hidden>Play One More Day</button>
|
||||
<button value="extend-no" id="rs-extend-no" type="submit" hidden>End the Game Here</button>
|
||||
<button value="ok" id="rs-ok" type="submit">Close</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
+23
-3
@@ -52,6 +52,8 @@ export type Settings = {
|
||||
reducedVisibility: boolean;
|
||||
employeeRotation: boolean;
|
||||
emergencyToolbox: boolean;
|
||||
/** §6.2 (Gitea#9) — may a Timetabled train be thrown away? An Extra never may, whatever this says. */
|
||||
discardTimetabled: boolean;
|
||||
};
|
||||
|
||||
export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
||||
@@ -66,6 +68,7 @@ export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
||||
'reducedVisibility',
|
||||
'employeeRotation',
|
||||
'emergencyToolbox',
|
||||
'discardTimetabled',
|
||||
];
|
||||
|
||||
export type Preset = {
|
||||
@@ -94,6 +97,12 @@ const NO_OPTIONAL_RULES = {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
/**
|
||||
* ON in every type (Gitea#9). Jesse's ruling is the rule now, and the setting exists so a table
|
||||
* can put Gitea#6's pressure back rather than so a type can choose for them — the reasoning is
|
||||
* about how long a game runs, which is a dial the table already sets for itself.
|
||||
*/
|
||||
discardTimetabled: true,
|
||||
} as const;
|
||||
|
||||
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a
|
||||
@@ -110,8 +119,14 @@ export const PRESETS: readonly Preset[] = [
|
||||
revenueFloor: (players, days) => collectiveRevenueFloor(players, days),
|
||||
rules: {
|
||||
startingHand: SIX,
|
||||
// Nobody else's district exists, so "any Control Point" and "your own" are the same rule.
|
||||
extraStart: 'anyOffice',
|
||||
/**
|
||||
* Nobody else's district exists, so "any Control Point" and "your own" are the same rule —
|
||||
* `apply.ts` only ever rejects `ownOffice` when `start.seat !== seatOf(s, player)`, which
|
||||
* cannot happen at one seat. It said `anyOffice` until 2026-08-30, which was true and read
|
||||
* wrong: a solitaire player has no "any player" to contrast themselves with, so the permissive
|
||||
* label described a permission nobody was being granted. Jesse's call; no gameplay effect.
|
||||
*/
|
||||
extraStart: 'ownOffice',
|
||||
passengerPerCoach: 1,
|
||||
freightPerLoad: 1,
|
||||
trainPerTransit: 0,
|
||||
@@ -211,6 +226,7 @@ export function settingsOf(config: GameConfig): Settings {
|
||||
reducedVisibility: config.optionalRules.reducedVisibility,
|
||||
employeeRotation: config.optionalRules.employeeRotation,
|
||||
emergencyToolbox: config.optionalRules.emergencyToolbox,
|
||||
discardTimetabled: rules.discardTimetabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,7 +274,7 @@ export function configFromFrame(f: {
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
optionalRules: GameConfig['optionalRules'];
|
||||
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules };
|
||||
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean };
|
||||
}): GameConfig {
|
||||
return {
|
||||
mode: f.mode,
|
||||
@@ -272,6 +288,9 @@ export function configFromFrame(f: {
|
||||
houseRules: {
|
||||
startingHand: f.houseRules.startingHand,
|
||||
extraStart: f.houseRules.extraStart,
|
||||
// Carried like the rest: this path describes SOMEONE ELSE'S game to a joiner, so a setting
|
||||
// dropped here shows them a rule the table is not playing (§6.2, Gitea#9).
|
||||
discardTimetabled: f.houseRules.discardTimetabled,
|
||||
revenue: f.houseRules.revenue,
|
||||
},
|
||||
};
|
||||
@@ -335,6 +354,7 @@ export function configFromSettings(
|
||||
houseRules: {
|
||||
startingHand: settings.startingHand,
|
||||
extraStart: settings.extraStart,
|
||||
discardTimetabled: settings.discardTimetabled,
|
||||
revenue: {
|
||||
passengerPerCoach: settings.passengerPerCoach,
|
||||
freightPerLoad: settings.freightPerLoad,
|
||||
|
||||
+2
-1
@@ -26,6 +26,7 @@ import { createGame } from '../engine/setup.ts';
|
||||
import { snapshot } from '../sim/view.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { SOLO_CONFIG } from './game.ts';
|
||||
import { actingPlayer } from '../engine/state.ts';
|
||||
|
||||
type Save = { seed: number; history: Intent[] };
|
||||
type Entry = { file: string; title: string; note?: string; seed?: number };
|
||||
@@ -69,7 +70,7 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
||||
push(pump(s));
|
||||
let stoppedEarly = false;
|
||||
for (const intent of save.history) {
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
const actor = actingPlayer(s);
|
||||
if (actor === null || s.status !== 'active') break;
|
||||
const r = applyIntent(s, actor, intent);
|
||||
if (!r.ok) {
|
||||
|
||||
+4
-1
@@ -27,6 +27,7 @@ import {
|
||||
currentActor,
|
||||
fromSave,
|
||||
handPlayable,
|
||||
isOutOfTurn,
|
||||
newGame,
|
||||
overHandLimit,
|
||||
submit,
|
||||
@@ -163,7 +164,9 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
overHandLimit: () => overHandLimit(game),
|
||||
handPlayable: () => handPlayable(game),
|
||||
submit: async (intent: Intent) => {
|
||||
const ok = submit(game, intent);
|
||||
// Seat 0 is the solitaire player, and the extension vote (Gitea#11) is the one intent that
|
||||
// arrives when `currentActor` is null — so it has to name its seat. See `isOutOfTurn`.
|
||||
const ok = submit(game, intent, isOutOfTurn(intent) ? 0 : null);
|
||||
if (ok) changed();
|
||||
return ok;
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* THE RULES BLOCK, driven identically on both screens.
|
||||
*
|
||||
* The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same eleven questions.
|
||||
* The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same twelve questions.
|
||||
* They used to ask them in two hand-written copies and had already drifted — the dialog had "where
|
||||
* an Extra may start" and no optional rules, the lobby the reverse — so this module owns reading,
|
||||
* writing, comparing and annotating the block, and each screen supplies only the id prefix its
|
||||
@@ -47,10 +47,11 @@ export const FIELDS: readonly Field[] = [
|
||||
{ key: 'reducedVisibility', kind: 'checkbox', id: 'visibility' },
|
||||
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
|
||||
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
|
||||
{ key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' },
|
||||
];
|
||||
|
||||
/**
|
||||
* What `play.html` must contain for a screen to be able to ask all eleven questions — the exact
|
||||
* What `play.html` must contain for a screen to be able to ask all twelve questions — the exact
|
||||
* attribute text, so `test/web.test.ts` can assert it against the built page.
|
||||
*
|
||||
* THIS IS THE DRIFT GUARD. The two blocks are generated from one template today; this is what says
|
||||
@@ -71,6 +72,7 @@ export function fieldSelectors(prefix: string): string[] {
|
||||
const FIELD_LABELS: Record<keyof Settings, string> = {
|
||||
startingHand: 'Starting hand',
|
||||
extraStart: 'An Extra may start at',
|
||||
discardTimetabled: 'A Timetabled train may be discarded',
|
||||
passengerPerCoach: 'Passenger per coach',
|
||||
freightPerLoad: 'Freight per load',
|
||||
trainPerTransit: 'Train per transit',
|
||||
@@ -115,6 +117,7 @@ export function rulesListHtml(config: GameConfig, players: number, days: number)
|
||||
return (
|
||||
head +
|
||||
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart'])}</dl>` +
|
||||
`<h4>Train cards</h4><dl>${rows(['discardTimetabled'])}</dl>` +
|
||||
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
|
||||
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
|
||||
`<h4>Optional rules</h4><dl>${rows(['reducedVisibility', 'employeeRotation', 'emergencyToolbox'])}</dl>`
|
||||
@@ -205,6 +208,7 @@ export function settingsForm(prefix: string): SettingsForm {
|
||||
reducedVisibility: el<HTMLInputElement>('visibility')?.checked === true,
|
||||
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
|
||||
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
|
||||
discardTimetabled: el<HTMLInputElement>('tossloco')?.checked === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +224,7 @@ export function settingsForm(prefix: string): SettingsForm {
|
||||
setChecked('visibility', values.reducedVisibility);
|
||||
setChecked('rotation', values.employeeRotation);
|
||||
setChecked('toolbox', values.emergencyToolbox);
|
||||
setChecked('tossloco', values.discardTimetabled);
|
||||
}
|
||||
|
||||
function setNumber(id: string, value: number): void {
|
||||
|
||||
+185
-30
@@ -66,6 +66,21 @@ function playToCompletion(s: GameState, seed = 1, maxTurns = 20_000): PlayStats
|
||||
tally(pump(s));
|
||||
if (s.status === 'finished') break;
|
||||
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — this harness plays the timetable it was dealt.
|
||||
*
|
||||
* It picks at random among legal options, and one of the two options here is "play another
|
||||
* Day" — so left alone it would extend the game forever and terminate only on `maxTurns`. That
|
||||
* is not a bug in the feature: a game genuinely does not end now until somebody says stop, and
|
||||
* `developerBot` says stop for exactly this reason. Said explicitly here rather than folded
|
||||
* into the random pick, because "how does this loop terminate" deserves an answer in the loop.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') {
|
||||
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: false });
|
||||
assert.ok(r.ok, 'a solitaire player could not decline an extension');
|
||||
break;
|
||||
}
|
||||
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
if (actor === null) break;
|
||||
|
||||
@@ -480,7 +495,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
assert.equal(r.needsInput, true, 'the phase must stop and ask');
|
||||
assert.notEqual(s.clock.pendingDecision, null);
|
||||
assert.equal(s.clock.pendingDecision!.train, 'behind');
|
||||
assert.equal(s.clock.pendingDecision!.occupiedBy, 'ahead');
|
||||
assert.equal((s.clock.pendingDecision as { occupiedBy: string }).occupiedBy, 'ahead');
|
||||
});
|
||||
|
||||
it('does not ask when the train ahead is coming the other way — that is an absolute bar', () => {
|
||||
@@ -526,7 +541,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
it('clears the decision once the Superintendent rules', () => {
|
||||
const s = game();
|
||||
s.clock.phase = 'mainline';
|
||||
s.clock.pendingDecision = { train: 'a', occupiedBy: 'b' };
|
||||
s.clock.pendingDecision = { kind: 'clearance', train: 'a', occupiedBy: 'b' };
|
||||
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.clock.pendingDecision, null);
|
||||
@@ -544,9 +559,13 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 0;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
// PAUSES rather than finishes since Gitea#11: a days-based ending offers another Day, and the
|
||||
// result is recorded either way. `official` is the frozen answer; `status` is only where play
|
||||
// has got to. The two collision tests at the foot of this block are the contrast.
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||
assert.equal(s.official!.outcome.reason, 'revenueFloor');
|
||||
});
|
||||
|
||||
it('wins a timed Solitaire game that clears minCombinedRevenue', () => {
|
||||
@@ -556,7 +575,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 10;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
});
|
||||
|
||||
@@ -567,7 +586,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 0;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
});
|
||||
|
||||
@@ -582,7 +601,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.players[0]!.revenue = 5; // best individual score...
|
||||
s.players[1]!.revenue = 3; // ...but combined (8) still misses the floor (20).
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||
});
|
||||
@@ -598,7 +617,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.players[0]!.revenue = 4;
|
||||
s.players[1]!.revenue = 6; // combined 10 clears the floor, neither alone would.
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
assert.equal(s.outcome!.winner, null, 'Co-op names an individual winner instead of a shared one');
|
||||
});
|
||||
@@ -636,13 +655,36 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
assert.notEqual(s.status, 'finished');
|
||||
});
|
||||
|
||||
it('solitaire never checks the collision floor, whatever the counts', () => {
|
||||
it('solitaire checks the collision floor too, like every other mode', () => {
|
||||
/**
|
||||
* REVERSED 2026-08-30, and this test previously asserted the opposite ("solitaire never checks
|
||||
* the collision floor, whatever the counts").
|
||||
*
|
||||
* The exclusion was never a stated rule — §3.4 does not carve solitaire out — and nothing on
|
||||
* screen reflected it: `SOLO_CONFIG` carried both limits, the New Game dialog offered them as
|
||||
* live settings, and the text beside them said the game would end in a loss. A solitaire player
|
||||
* could set a limit of 1 and crash all game. Found reviewing that screen's wording; Jesse's
|
||||
* ruling is that the settings do what they say.
|
||||
*/
|
||||
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 });
|
||||
s.collisionsToday = 99;
|
||||
s.collisionsTotal = 99;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
|
||||
it('still lets a solitaire game switch the collision floor off with 0', () => {
|
||||
// The disable path is what a player who does not want the new ending reaches for, so it has to
|
||||
// work at one seat exactly as it does at four.
|
||||
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 0, maxCollisionsTotal: 0 });
|
||||
s.collisionsToday = 99;
|
||||
s.collisionsTotal = 99;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.notEqual(s.status, 'finished');
|
||||
});
|
||||
});
|
||||
@@ -730,43 +772,156 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('X18 Circus Train — a point for standing still', () => {
|
||||
it('pays once for a Stage spent stopped, and never again', () => {
|
||||
/**
|
||||
* REPORTED: "Circus train TX18 was stopped on a siding for a full Stage and I did not get my
|
||||
* Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read
|
||||
* NOWHERE, along with eight other special-train rules. The one card in the deck that pays for
|
||||
* standing still paid nothing.
|
||||
*/
|
||||
const s = game();
|
||||
describe('X18 Circus / X17 Campaign — a point for setting up (Gitea#13)', () => {
|
||||
/**
|
||||
* REPORTED originally: "Circus train TX18 was stopped on a siding for a full Stage and I did not
|
||||
* get my Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read
|
||||
* NOWHERE, along with eight other special-train rules.
|
||||
*
|
||||
* REDEFINED by Gitea#13 (Jesse, 2026-08-29), and these tests carry the three parts of that
|
||||
* ruling: the point is paid ONCE PER OFFICE AREA rather than once per game, only when the train
|
||||
* is FULLY LOADED, and only in an Office Area at all.
|
||||
*/
|
||||
const circusAt = (s: GameState, seat: number, coord: { row: number; col: number }, consist: unknown[]) => {
|
||||
s.clock.phase = 'mainline';
|
||||
s.trays.set('circus', {
|
||||
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
|
||||
consist: [], direction: 'east',
|
||||
position: { at: 'grid', seat: 0, coord: { row: -1, col: 0 } },
|
||||
consist, direction: 'east',
|
||||
position: { at: 'grid', seat, coord },
|
||||
movesUsed: 0,
|
||||
} as never);
|
||||
// A card under it, so the crew is somewhere real rather than off the grid.
|
||||
areaOf(s, 0).grid.set('-1,0', {
|
||||
areaOf(s, seat as never).grid.set(`${coord.row},${coord.col}`, {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
} as never);
|
||||
};
|
||||
|
||||
const loaded = [
|
||||
{ type: 'boxcar', loaded: true },
|
||||
{ type: 'boxcar', loaded: true },
|
||||
{ type: 'coach', loaded: true },
|
||||
{ type: 'caboose', loaded: true },
|
||||
];
|
||||
|
||||
const runPhase = (s: GameState) => {
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return pump(s);
|
||||
};
|
||||
|
||||
it('pays a fully loaded Circus for a Stage spent set up', () => {
|
||||
const s = game();
|
||||
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||||
const before = s.players[0]!.revenue;
|
||||
const first = pump(s);
|
||||
assert.ok(
|
||||
first.some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18),
|
||||
pump(s).some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18),
|
||||
'the Circus Train stood still for a Stage and earned nothing',
|
||||
);
|
||||
assert.equal(s.players[0]!.revenue, before + 1, 'the point was not paid');
|
||||
});
|
||||
|
||||
// "One turn stopped" — once. A train that goes on standing there does not keep earning.
|
||||
const paidAgain = () => {
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return pump(s).some((e) => e.type === 'trainStoodStill');
|
||||
};
|
||||
assert.ok(!paidAgain(), 'the Circus Train collected a second time for the same set-up');
|
||||
it('pays once per Office Area, however long it parks there', () => {
|
||||
// "Once per stop in an office area" — a train that goes on standing in the same district does
|
||||
// not keep earning. This is the half that was already true, for a different reason.
|
||||
const s = game();
|
||||
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||||
pump(s);
|
||||
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||||
'the Circus collected twice for the same set-up');
|
||||
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||||
'the Circus collected a third time for the same set-up');
|
||||
});
|
||||
|
||||
it('pays AGAIN in a different district — each player can be visited', () => {
|
||||
/**
|
||||
* The half that is new. "In a multiplayer game, each player could score if the circus stops in
|
||||
* their area" — so the claim is per seat, and a touring Circus is paid by each district it sets
|
||||
* up in. Before Gitea#13 this paid once per GAME and the second district got nothing.
|
||||
*/
|
||||
const s = createGame({
|
||||
id: 'g', seed: 5, config: baseConfig({ mode: 'competitive' }), playerNames: ['A', 'B'],
|
||||
});
|
||||
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||||
pump(s);
|
||||
const paidFirst = s.players.map((p) => p.revenue);
|
||||
|
||||
// The same train, moved into the other player's district.
|
||||
const tray = s.trays.get('circus')!;
|
||||
areaOf(s, 1 as never).grid.set('-1,0', {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
} as never);
|
||||
tray.position = { at: 'grid', seat: 1, coord: { row: -1, col: 0 } } as never;
|
||||
|
||||
assert.ok(runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||||
'the Circus set up in a second district and earned nothing');
|
||||
const owner = s.seating[1]!;
|
||||
assert.equal(
|
||||
s.players[owner]!.revenue,
|
||||
paidFirst[owner]! + 1,
|
||||
'the point did not go to whoever sits in the district it stopped in',
|
||||
);
|
||||
});
|
||||
|
||||
it('pays nothing when the cars are empty — "not much of a circus"', () => {
|
||||
const s = game();
|
||||
circusAt(s, 0, { row: -1, col: 0 }, [
|
||||
{ type: 'boxcar', loaded: false },
|
||||
{ type: 'coach', loaded: true },
|
||||
{ type: 'caboose', loaded: true },
|
||||
]);
|
||||
const before = s.players[0]!.revenue;
|
||||
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
|
||||
'an empty car aboard still collected the set-up point');
|
||||
assert.equal(s.players[0]!.revenue, before, 'Revenue moved for a train that was not full');
|
||||
});
|
||||
|
||||
it('pays nothing to a train carrying nothing at all', () => {
|
||||
// `every` on an empty list is vacuously true, so the emptiest train of the lot is exactly the
|
||||
// one a careless test would pay.
|
||||
const s = game();
|
||||
circusAt(s, 0, { row: -1, col: 0 }, []);
|
||||
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
|
||||
'a Circus carrying nothing was paid for setting up');
|
||||
});
|
||||
|
||||
it('pays nothing for standing out on the Mainline', () => {
|
||||
/**
|
||||
* It used to, and it misattributed the point: `playerAtSeat` needs a seat, there is none off
|
||||
* the grid, and the fallback handed it to PLAYER 0 wherever the train was standing. Jesse's
|
||||
* ruling scopes the rule to Office Areas, which removes the bug rather than patching it.
|
||||
*/
|
||||
const s = game();
|
||||
s.clock.phase = 'mainline';
|
||||
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
s.trays.set('circus', {
|
||||
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
|
||||
consist: loaded, direction: 'east',
|
||||
position: { at: 'mainline', index },
|
||||
movesUsed: 0,
|
||||
} as never);
|
||||
const before = s.players[0]!.revenue;
|
||||
pump(s);
|
||||
assert.equal(s.players[0]!.revenue, before, 'a Mainline set-up paid a point');
|
||||
});
|
||||
|
||||
it('pays the Campaign Train only when its candidate is aboard', () => {
|
||||
// X17 carries one coach and no freight, so "fully loaded" is exactly "the coach is occupied".
|
||||
// It earned nothing at all before Gitea#13 — it had `stopThenExpedite` and no scoring rule.
|
||||
const occupied = game();
|
||||
circusAt(occupied, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: true }]);
|
||||
occupied.trays.get('circus')!.trainNumber = 17;
|
||||
const beforeOccupied = occupied.players[0]!.revenue;
|
||||
pump(occupied);
|
||||
assert.equal(occupied.players[0]!.revenue, beforeOccupied + 1, 'a full Campaign Train earned nothing');
|
||||
|
||||
const empty = game();
|
||||
circusAt(empty, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: false }]);
|
||||
empty.trays.get('circus')!.trainNumber = 17;
|
||||
const beforeEmpty = empty.players[0]!.revenue;
|
||||
pump(empty);
|
||||
assert.equal(empty.players[0]!.revenue, beforeEmpty, 'an empty Campaign Train was paid for its speech');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1272,7 +1427,7 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
|
||||
const r = advance(s);
|
||||
assert.equal(r.needsInput, true, 'the phase must stop and ask');
|
||||
assert.equal(s.clock.pendingDecision?.train, tray.id);
|
||||
assert.equal(s.clock.pendingDecision?.occupiedBy, 'ahead');
|
||||
assert.equal((s.clock.pendingDecision as { occupiedBy: string } | null)?.occupiedBy, 'ahead');
|
||||
|
||||
// HOLD keeps it in the yard.
|
||||
assert.ok(applyIntent(s, s.clock.superintendent, { type: 'mainline.clearance', allow: false }).ok);
|
||||
|
||||
+96
-29
@@ -262,19 +262,20 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'the turn cannot be ended even at the limit');
|
||||
});
|
||||
|
||||
describe('a train card is never discarded (Gitea#6)', () => {
|
||||
describe('which train cards may be discarded (Gitea#9, superseding Gitea#6)', () => {
|
||||
/**
|
||||
* Jesse's ruling, v0.4.9e playtest: "Players are not allowed to discard Train cards. They may
|
||||
* keep the card in their hand for multiple stages and even multiple days, but they may not
|
||||
* discard it. If a player has three train cards in their hand, and they draw a fourth, then they
|
||||
* must play one of those cards."
|
||||
* Gitea#6's ruling, v0.4.9e playtest, was that NO train card may be discarded. Gitea#9 narrows
|
||||
* it — Jesse, 2026-08-24: "Timetabled trains are at the choice of the player: they can either
|
||||
* play or discard. If someone else wants to pick it up, they are more than able to. The reason:
|
||||
* I don't want, if you decide to play a game longer than five days, to decide that maybe there
|
||||
* are too many trains, the stations are jammed, and the railroad doesn't need any more."
|
||||
*
|
||||
* Extras count too — an Extra is a train, even though it runs once and ends in the Salvage Yard
|
||||
* where a Timetabled card joins the timetable for the rest of the game.
|
||||
* So a Timetabled train is discardable, an EXTRA still is not — it never joins the timetable, so
|
||||
* it cannot be what jams it — and whether the Timetabled half applies is a New Game setting,
|
||||
* because the reasoning is about long games and a five-Day game may want Gitea#6's pressure.
|
||||
*
|
||||
* Note there is no new FORCING mechanism, and deliberately so: the corner is what the two
|
||||
* existing rules produce together. Nothing discardable plus "you may not end the turn over the
|
||||
* limit" leaves exactly one legal way on, and playing a train is unconditionally legal.
|
||||
* Note there is still no FORCING mechanism, and deliberately so: the corner is what the two
|
||||
* existing rules produce together whenever the setting is off.
|
||||
*/
|
||||
const handOf = (s: GameState, kinds: string[]): string[] => {
|
||||
// Hand-pick cards of the wanted kinds straight out of the catalogue, so the test does not
|
||||
@@ -292,35 +293,74 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
return picked;
|
||||
};
|
||||
|
||||
it('refuses the discard, for a Timetabled train and for an Extra alike', () => {
|
||||
/** The same game with the setting turned off — Gitea#6's rule, still reachable. */
|
||||
const strictGame = (): GameState =>
|
||||
createGame({
|
||||
id: 'g',
|
||||
seed: 77,
|
||||
config: { ...config, houseRules: { ...(config.houseRules ?? {}), discardTimetabled: false } },
|
||||
playerNames: ['Jesse'],
|
||||
});
|
||||
|
||||
it('lets a Timetabled train be discarded, and still refuses an Extra', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
|
||||
'TRAINS_ARE_NEVER_DISCARDED',
|
||||
null,
|
||||
'Gitea#9 allows this and it was refused',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
|
||||
'TRAINS_ARE_NEVER_DISCARDED',
|
||||
'an Extra never joins the timetable, so Gitea#9 does not reach it',
|
||||
);
|
||||
// And everything else is still discardable — the rule is about trains, not about discarding.
|
||||
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
|
||||
});
|
||||
|
||||
it('never offers the discard, so the bot needs no rule of its own', () => {
|
||||
it('puts the discarded train where a rival can pick it up', () => {
|
||||
// The other half of the ruling — "if someone else wants to pick it up, they are more than able
|
||||
// to" — needed no machinery, because a discard already goes face-up onto a Department pile.
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
||||
const offered = legalActions(s, 0).filter(
|
||||
(i) => i.type === 'card.discard' && i.cardId === timetabled,
|
||||
);
|
||||
assert.deepEqual(offered, [], 'a train discard was offered as a legal action');
|
||||
assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 1 }).ok);
|
||||
const pile = s.decks.departments[1]!;
|
||||
assert.equal(pile[pile.length - 1], timetabled, 'the train is not face-up on the pile');
|
||||
});
|
||||
|
||||
it('leaves PLAYING a train as the only way out of a hand of four trains', () => {
|
||||
it('offers the discard as a legal action, so the bot can take it', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const [timetabled, extra] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||
const offered = legalActions(s, 0).filter((i) => i.type === 'card.discard');
|
||||
assert.ok(
|
||||
offered.some((i) => i.type === 'card.discard' && i.cardId === timetabled),
|
||||
'a Timetabled train was not offered as a discard',
|
||||
);
|
||||
assert.ok(
|
||||
!offered.some((i) => i.type === 'card.discard' && i.cardId === extra),
|
||||
'an Extra was offered as a discard',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Gitea#6 reachable when the setting is off', () => {
|
||||
const s = strictGame();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||
for (const id of [timetabled!, extra!]) {
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }),
|
||||
'TRAINS_ARE_NEVER_DISCARDED',
|
||||
);
|
||||
}
|
||||
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
|
||||
});
|
||||
|
||||
it('leaves PLAYING a train as the only way out of a hand of four, setting off', () => {
|
||||
const s = strictGame();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const four = handOf(s, ['timetabledTrain', 'timetabledTrain', 'timetabledTrain', 'extraTrain']);
|
||||
assert.ok(four.length > HAND_LIMIT, 'this test needs a hand over the limit');
|
||||
|
||||
@@ -337,11 +377,26 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'playing a train did not free the turn');
|
||||
});
|
||||
|
||||
it('a hand of four Extras is the corner that survives Gitea#9 with the setting ON', () => {
|
||||
// Gitea#9 does not reach an Extra, so the deadlock-that-is-not-a-deadlock is still real in a
|
||||
// default game — worth pinning, since it is now the ONLY way to reach it.
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const four = handOf(s, ['extraTrain', 'extraTrain', 'extraTrain', 'extraTrain']);
|
||||
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
|
||||
for (const id of four) {
|
||||
assert.equal(check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED');
|
||||
}
|
||||
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: four[0]! }).ok);
|
||||
assert.equal(check(s, 0, { type: 'draw.end' }), null);
|
||||
});
|
||||
|
||||
it('lets a train be held across Stages and into the next Day', () => {
|
||||
// "They may keep the card in their hand for multiple stages and even multiple days." Nothing
|
||||
// sweeps a hand at a Stage or Day boundary, and this is what says so out loud.
|
||||
// sweeps a hand at a Stage or Day boundary, and this is what says so out loud. An Extra is
|
||||
// used, because it is the card that still cannot be got rid of any other way.
|
||||
const s = game();
|
||||
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
||||
const [extra] = handOf(s, ['extraTrain', 'track']);
|
||||
const startDay = s.clock.day;
|
||||
|
||||
// Play out Stages by taking whatever ends the current turn, until the Day turns over.
|
||||
@@ -357,24 +412,36 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
|
||||
assert.ok(s.clock.day > startDay, `the Day never turned (stopped at ${s.clock.day}/${s.clock.stage})`);
|
||||
assert.ok(
|
||||
(s.decks.hands.get(0) ?? []).includes(timetabled!),
|
||||
(s.decks.hands.get(0) ?? []).includes(extra!),
|
||||
'the train did not survive being held into the next Day',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
|
||||
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
|
||||
'TRAINS_ARE_NEVER_DISCARDED',
|
||||
'a Day boundary made a train discardable',
|
||||
'a Day boundary made an Extra discardable',
|
||||
);
|
||||
});
|
||||
|
||||
it('tells the player on the card itself, and on the button when every card is a train', () => {
|
||||
it('tells the player on the card itself which of the two rules applies', () => {
|
||||
// The Gitea#2 lesson: a rule the player cannot see is a board with nothing to click and no
|
||||
// reason given.
|
||||
// reason given. Since Gitea#9 there are TWO reasons, so the card has to say which.
|
||||
const s = game();
|
||||
handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||
const f = snapshot(s, [], null);
|
||||
// `hand` is reversed for display, so compare as a set rather than by position.
|
||||
assert.deepEqual([...f.handDiscardable].sort(), [false, false, true]);
|
||||
assert.deepEqual([...f.handDiscardable].sort(), [false, true, true]);
|
||||
const said = f.handKeepWhy.filter((w): w is string => w !== null);
|
||||
assert.equal(said.length, 1, 'exactly one card in this hand may not be discarded');
|
||||
assert.match(said[0]!, /An Extra is never discarded/);
|
||||
|
||||
const strict = strictGame();
|
||||
handOf(strict, ['timetabledTrain', 'extraTrain', 'track']);
|
||||
const sf = snapshot(strict, [], null);
|
||||
assert.deepEqual([...sf.handDiscardable].sort(), [false, false, true]);
|
||||
assert.ok(
|
||||
sf.handKeepWhy.some((w) => w !== null && /never discarded in this game/.test(w)),
|
||||
'the setting being off is not explained on the card',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -889,7 +956,7 @@ describe('the Superintendent clearance ruling (§8.1)', () => {
|
||||
const s = game();
|
||||
s.clock.phase = 'mainline';
|
||||
s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule
|
||||
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
|
||||
s.clock.pendingDecision = { kind: 'clearance', train: 'tray0', occupiedBy: 'tray1' };
|
||||
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.clock.pendingDecision, null);
|
||||
@@ -897,7 +964,7 @@ describe('the Superintendent clearance ruling (§8.1)', () => {
|
||||
|
||||
it('is refused to a player who is not the Superintendent', () => {
|
||||
const s = game();
|
||||
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
|
||||
s.clock.pendingDecision = { kind: 'clearance', train: 'tray0', occupiedBy: 'tray1' };
|
||||
s.clock.superintendent = 1;
|
||||
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT');
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts';
|
||||
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
@@ -53,12 +53,18 @@ function row(s: GameState, n: number): void {
|
||||
for (let c = 0; c < n; c++) addCard(s, at(1, c), straight());
|
||||
}
|
||||
|
||||
/**
|
||||
* `facing` is the PORT the engine points out through, which is not always an east-west one: a train
|
||||
* standing on a curve points along its 45° leg. `railFacing` carries the east-west sense the train
|
||||
* arrived with, so it keeps a straight answer whatever port the nose is on (`railFacingOf`).
|
||||
*/
|
||||
function placeTray(
|
||||
s: GameState,
|
||||
coord: GridCoord,
|
||||
consist: RollingStock[],
|
||||
facing: 'e' | 'w',
|
||||
facing: 'n' | 's' | 'e' | 'w',
|
||||
engineAt = 0,
|
||||
railFacing: 'e' | 'w' = facing === 'w' ? 'w' : 'e',
|
||||
): string {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
@@ -67,9 +73,9 @@ function placeTray(
|
||||
trainIsExtra: false,
|
||||
engineAt,
|
||||
consist,
|
||||
direction: facing === 'w' ? 'west' : 'east',
|
||||
direction: railFacing === 'w' ? 'west' : 'east',
|
||||
facing,
|
||||
railFacing: facing,
|
||||
railFacing,
|
||||
position: { at: 'grid', seat: 0, coord },
|
||||
movesUsed: 0,
|
||||
} as CrewTray);
|
||||
@@ -373,3 +379,85 @@ describe('taking your own cut back is undoing the drop, not a fresh pick-up', ()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)', () => {
|
||||
/**
|
||||
* Reported: "Cars were West to East Caboose, Loaded boxcar, Loaded boxcar, Loaded boxcar. After
|
||||
* backing into that square cars were attached to the train Loaded boxcar, Loaded boxcar, Loaded
|
||||
* boxcar, Caboose, Engine." The caboose came back next to the engine instead of at the far end,
|
||||
* which also leaves the train badly made up under §8.2.
|
||||
*
|
||||
* The square was a `sw` CURVE and the train backed in through its SOUTH leg. `standing` runs west
|
||||
* to east, and the two places that walk it both asked the PORT which end of the row they were at:
|
||||
* `exploreMoves` reversed the row for an 'e' entry and for nothing else, and `cutTowards` answered
|
||||
* "you meet nothing" for a north or south exit. Neither is a property of the port.
|
||||
*
|
||||
* A 45° leg leaves through the MIDDLE of its edge, so its end of the run is whichever end the arc
|
||||
* does not reach: the south leg of a `sw` curve is the row's EAST end, and the south leg of an
|
||||
* `se` curve is its WEST end. Same port, opposite answers — which is why `rowEndAt` has to ask the
|
||||
* card.
|
||||
*/
|
||||
const curve = (arc: TrackArc, standing: RollingStock[] = [], standingWest = 0): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* The reported board, minimally: a `sw` curve holding the cut, and an `ne` curve below it for the
|
||||
* train to run from. Both legs lie on the `ne_sw` diagonal, so the two cards actually join.
|
||||
*/
|
||||
function board(standing: RollingStock[], standingWest = standing.length): GameState {
|
||||
const s = game();
|
||||
addCard(s, at(1, 0), curve('sw', standing, standingWest));
|
||||
addCard(s, at(0, 0), curve('ne'));
|
||||
switching(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
it('backs into a cut through the south leg and meets the EAST end of the row first', () => {
|
||||
const s = board([car('caboose', true), car('boxcar', true), car('boxcar', true), car('boxcar', true)]);
|
||||
// Facing east on the `ne` curve, so reversing pulls out through its north leg and into the
|
||||
// curve above through that card's south leg — the move in the reported save.
|
||||
const id = placeTray(s, at(0, 0), [], 'e');
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true });
|
||||
assert.ok(r.ok, `the reverse move was refused: ${r.ok ? '' : r.code}`);
|
||||
// Coupled behind the engine nearest-car-first, and the nearest car is the one at the south end
|
||||
// — the LAST of a west-to-east row on a `sw` curve. The caboose was westmost, so it ends up
|
||||
// furthest from the engine, which is where §8.2 needs it.
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar', 'boxcar', 'boxcar', 'caboose']);
|
||||
});
|
||||
|
||||
it('meets the WEST end of the row first where the same leg belongs to an `se` curve', () => {
|
||||
// The mirror, and the reason the port alone cannot answer: an `se` curve's south leg is the
|
||||
// west end of its row, so the same reverse move meets the caboose first.
|
||||
const s = game();
|
||||
addCard(s, at(1, 0), curve('se', [car('caboose', true), car('boxcar', true)], 2));
|
||||
addCard(s, at(0, 0), curve('nw'));
|
||||
switching(s);
|
||||
const id = placeTray(s, at(0, 0), [], 'w');
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true });
|
||||
assert.ok(r.ok, `the reverse move was refused: ${r.ok ? '' : r.code}`);
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), ['caboose', 'boxcar']);
|
||||
});
|
||||
|
||||
it('takes its own cut back with it when it pulls out through the south leg (§A.4)', () => {
|
||||
// The other half of the same assumption: `cutTowards` said a train leaving north or south meets
|
||||
// nothing, so a crew standing on a curve drove away and left the cars beside it standing —
|
||||
// exactly what mandatory coupling forbids.
|
||||
const s = board([car('boxcar', true)], 0);
|
||||
// `standingWest` 0 puts the boxcar EAST of the train, which on a `sw` curve is between it and
|
||||
// the south leg it is about to leave by.
|
||||
const id = placeTray(s, at(1, 0), [], 's');
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: false });
|
||||
assert.ok(r.ok, `the move off the curve was refused: ${r.ok ? '' : r.code}`);
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar'], 'the cut beside the train was left standing');
|
||||
assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card');
|
||||
});
|
||||
});
|
||||
|
||||
+112
-5
@@ -14,7 +14,7 @@ import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDera
|
||||
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey, subdivisions, turnOf } from '../src/engine/state.ts';
|
||||
import { coordKey, decisionActor, subdivisions, turnOf } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
@@ -287,17 +287,124 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
||||
assert.equal(s.players[0]!.revenue, -5);
|
||||
});
|
||||
|
||||
it('diverts a coachless train to the Yard Office', () => {
|
||||
/**
|
||||
* §11, THE YARD OFFICE (Gitea#5) — offered, not imposed, and only down a route that exists.
|
||||
*
|
||||
* Jesse: "you have to ask if non-coach trains wish to go in there, rather than to the office",
|
||||
* "if the Yard Office is not accessible in one move, you should not get the option", and cars on
|
||||
* the way in "result in a crash". All three were missing: the train was teleported onto the card.
|
||||
*/
|
||||
const answer = (s: GameState, take: boolean) => {
|
||||
const who = decisionActor(s);
|
||||
assert.notEqual(who, null, 'nothing was pending, so there was nothing to answer');
|
||||
const r = applyIntent(s, who!, { type: 'mainline.yardOffice', take });
|
||||
assert.ok(r.ok, 'the district owner could not answer the Yard Office offer');
|
||||
advance(s);
|
||||
};
|
||||
|
||||
it('OFFERS the Yard Office to the district owner rather than diverting automatically', () => {
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(-1, 0), card);
|
||||
addCard(s, at(0, 2), card);
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'yardOffice', 'the phase did not stop to ask');
|
||||
assert.equal(decisionActor(s), 0, 'the question went to the wrong player');
|
||||
const pos = s.trays.get(id)!.position;
|
||||
assert.ok(pos.at === 'grid' && pos.coord.row === -1, 'arrived at the Yard Office');
|
||||
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'did not take an A/D track');
|
||||
assert.ok(pos.at !== 'grid' || pos.coord.col !== 2, 'the train moved before anyone answered');
|
||||
});
|
||||
|
||||
it('takes the Yard Office when the owner says yes', () => {
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
answer(s, true);
|
||||
const pos = s.trays.get(id)!.position;
|
||||
assert.ok(pos.at === 'grid' && pos.coord.col === 2, 'did not arrive at the Yard Office');
|
||||
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'took an A/D track anyway');
|
||||
assert.equal(s.players[0]!.revenue, 0, 'a clear lead should not have collided');
|
||||
});
|
||||
|
||||
it('goes to the Train Order Office when the owner says no', () => {
|
||||
// "They can of course still choose to have the train go to the standard office."
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
answer(s, false);
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'declining did not put it on an A/D track');
|
||||
});
|
||||
|
||||
it('does not offer what cannot be reached, and says why in the history', () => {
|
||||
/**
|
||||
* Jesse, 2026-08-29: "make sure this is logged in history — why can't move so user knows why
|
||||
* they can't get to yard." A silent absence is indistinguishable from a broken feature, which
|
||||
* is how the missing reachability check survived this long.
|
||||
*/
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
// Far off the Running Track, with nothing laid between: no route in one move.
|
||||
addCard(s, at(3, 4), card);
|
||||
inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
const events = advance(s).events;
|
||||
assert.equal(s.clock.pendingDecision, null, 'offered a Yard Office it cannot reach');
|
||||
const said = events.find(
|
||||
(e) => e.type === 'trainDiverted' && e.reason.includes('could not be offered'),
|
||||
);
|
||||
assert.ok(said, `nothing in the history explains why:\n${JSON.stringify(events, null, 1)}`);
|
||||
assert.match(
|
||||
(said as { reason: string }).reason,
|
||||
/one move/,
|
||||
'the reason does not say it is out of reach in one move',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a fouled lead, and taking it collides', () => {
|
||||
/**
|
||||
* The third missing condition. "Just like other trains finding cars on the tracks you use to
|
||||
* get into either result in a crash" — and Jesse's ruling keeps the OFFER: a route that exists
|
||||
* is offered, and the consequence of taking it is the player's. §8.3 already reads cars in the
|
||||
* path of an arriving train as a collision rather than a coupling.
|
||||
*/
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
// A car standing on the lead between the Office and the yard.
|
||||
areaOf(s, 0).grid.get(coordKey(at(0, 1)))!.standing = [{ type: 'boxcar', loaded: false }];
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'yardOffice', 'a fouled lead was not offered at all');
|
||||
|
||||
answer(s, true);
|
||||
assert.equal(s.players[0]!.revenue, -5, 'running through standing cars did not collide');
|
||||
assert.ok(!s.trays.has(id), 'the train survived the collision');
|
||||
});
|
||||
|
||||
it('declining a fouled lead is safe — the standard Office is unaffected', () => {
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
areaOf(s, 0).grid.get(coordKey(at(0, 1)))!.standing = [{ type: 'boxcar', loaded: false }];
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
answer(s, false);
|
||||
assert.equal(s.players[0]!.revenue, 0, 'declining the Yard Office still cost a collision');
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'the train did not reach the Office');
|
||||
});
|
||||
|
||||
it('does not divert a train carrying coaches', () => {
|
||||
|
||||
@@ -48,6 +48,15 @@ const KNOWN_UNREDUCED = [
|
||||
'dispatchBonusUsed',
|
||||
'expediteFault',
|
||||
'phaseBegan',
|
||||
/**
|
||||
* §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so
|
||||
* this is described rather than reduced like everything else here.
|
||||
*
|
||||
* ADDED DELIBERATELY, and it cost a bug first: the flag was originally taken down in a `reduce`
|
||||
* case, which never fires for an event `advance.ts` emits — so it stayed up and held every train
|
||||
* that came. That is precisely the failure this list exists to make visible.
|
||||
*/
|
||||
'redFlagSpent',
|
||||
// Employee Rotation moves `seating` in the phase driver and then describes what it did, which is
|
||||
// the pattern every entry on this list follows.
|
||||
'seatsRotated',
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY — Gitea#11, "when game ends allow players to continue playing if they wish".
|
||||
*
|
||||
* The rule as Jesse specified it (2026-08-28), which is what these tests are written against:
|
||||
*
|
||||
* - the OFFICIAL result is decided at the original game length and never changes. "In a five-day
|
||||
* game, even if it's extended to eight or nine days, the winner and the official answer is the
|
||||
* winner at the end of five days";
|
||||
* - extending grants exactly ONE Day, and the question is put again at the end of it;
|
||||
* - solitaire: the player decides alone. Multiplayer: unanimous, and one refusal ends it there;
|
||||
* - only days-based endings offer it. A §3.4 collision breach is final, during an extended Day
|
||||
* just as during the regular game.
|
||||
*
|
||||
* The persistence half matters as much as the rules half: a save is `{ seed, config, history }`
|
||||
* replayed through the engine, so an extension that is not an INTENT does not survive a reload, an
|
||||
* Undo, or a server restart. `replays the extension` below is the test that pins that.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, check } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
|
||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
mode: 'solitaire',
|
||||
days: 3,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
});
|
||||
|
||||
const game = (over: Partial<GameConfig> = {}, names = ['Jesse']): GameState =>
|
||||
createGame({ id: 'g', seed: 1, config: baseConfig(over), playerNames: names });
|
||||
|
||||
/** Parks a game on the last Stage of its final Day, so one `advance` runs the clock off the end. */
|
||||
function atTheEnd(s: GameState): GameState {
|
||||
s.clock.day = s.config.days + s.extraDays + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('§3.3 extended play — the ending pauses rather than stopping (Gitea#11)', () => {
|
||||
it('offers another Day on a days-based ending, and records the result anyway', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
assert.equal(s.status, 'awaitingExtension', 'a days-based ending did not offer another Day');
|
||||
assert.ok(s.outcome, 'the result was not decided');
|
||||
assert.ok(s.official, 'the official result was not frozen');
|
||||
assert.equal(s.official!.day, s.config.days, 'the official Day is not the original game length');
|
||||
});
|
||||
|
||||
it('does NOT offer another Day after a collision breach — §3.4 is final', () => {
|
||||
const s = game({ mode: 'competitive', maxCollisionsPerDay: 2 });
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished', 'a railroad declared unsafe offered to carry on');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
|
||||
it('refuses every ordinary intent while the extension question is open', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'WRONG_PHASE');
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), null, 'the vote itself was refused');
|
||||
});
|
||||
|
||||
it('offers only the two votes, and only to a seat that has not voted', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
advance(s);
|
||||
assert.deepEqual(
|
||||
legalActions(s, 0).map((i) => i.type),
|
||||
['game.extend', 'game.extend'],
|
||||
'something other than the vote is legal while the game is stopped',
|
||||
);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(legalActions(s, 0).length, 0, 'a seat was offered a second vote');
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'ALREADY_VOTED');
|
||||
assert.equal(legalActions(s, 1).length, 2, 'the seat still to vote was not offered the vote');
|
||||
});
|
||||
|
||||
it('refuses the vote when no extension is pending', () => {
|
||||
const s = game();
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'NOT_AWAITING_EXTENSION');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — one Day at a time (Gitea#11)', () => {
|
||||
it('grants exactly one Day in solitaire, then asks again at the end of it', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const official = { ...s.official!.outcome };
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(s.status, 'active', 'agreeing did not resume play');
|
||||
assert.equal(s.extraDays, 1, 'more or less than one Day was granted');
|
||||
assert.deepEqual(s.extensionVotes, [null], 'the votes were not cleared for the next question');
|
||||
|
||||
// Run the extra Day off the end: the question comes round again.
|
||||
atTheEnd(s);
|
||||
advance(s);
|
||||
assert.equal(s.status, 'awaitingExtension', 'the second ending did not ask again');
|
||||
assert.equal(s.extraDays, 1, 'a second Day was granted without being asked for');
|
||||
assert.deepEqual(s.official!.outcome, official, 'the official result was rewritten');
|
||||
assert.equal(s.official!.day, s.config.days, 'the official Day moved with the extension');
|
||||
});
|
||||
|
||||
it('ends the moment one seat declines, without waiting for the rest', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(s.status, 'awaitingExtension', 'one yes ended the vote');
|
||||
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
|
||||
assert.equal(s.status, 'finished', 'a refusal did not end the game immediately');
|
||||
assert.equal(s.extraDays, 0, 'a Day was granted despite a refusal');
|
||||
assert.equal(check(s, 2, { type: 'game.extend', player: 2, agree: true }), 'NOT_AWAITING_EXTENSION',
|
||||
'the seat that never voted is still being waited on');
|
||||
});
|
||||
|
||||
it('needs every seat before it grants the Day', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(s.status, 'awaitingExtension', 'two of three votes granted the Day');
|
||||
applyIntent(s, 2, { type: 'game.extend', player: 2, agree: true });
|
||||
assert.equal(s.status, 'active', 'a unanimous table was not given its Day');
|
||||
assert.equal(s.extraDays, 1);
|
||||
});
|
||||
|
||||
it('keeps the official result when a collision ends an EXTENDED Day', () => {
|
||||
// The case the freeze exists for: a railroad declared unsafe on the extra Day does not retract
|
||||
// who won on the last scheduled one.
|
||||
const s = atTheEnd(game({ mode: 'competitive', maxCollisionsPerDay: 2 }, ['A', 'B']));
|
||||
s.players[0]!.revenue = 9;
|
||||
s.players[1]!.revenue = 2;
|
||||
advance(s);
|
||||
const official = { ...s.official!.outcome };
|
||||
assert.equal(official.result, 'win');
|
||||
assert.equal(official.winner, 0);
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(s.status, 'active');
|
||||
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor', 'the current evaluation was not updated');
|
||||
assert.deepEqual(s.official!.outcome, official, 'a late collision rewrote a recorded win');
|
||||
});
|
||||
|
||||
it('decides the winner at the ORIGINAL game length, whoever leads afterwards', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
s.players[0]!.revenue = 9;
|
||||
s.players[1]!.revenue = 2;
|
||||
advance(s);
|
||||
assert.equal(s.official!.outcome.winner, 0);
|
||||
assert.deepEqual(s.official!.revenues, [9, 2], 'the official standings were not frozen');
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
|
||||
// B overtakes A during the extra Day, and it changes nothing official.
|
||||
s.players[1]!.revenue = 40;
|
||||
atTheEnd(s);
|
||||
advance(s);
|
||||
assert.equal(s.official!.outcome.winner, 0, 'overtaking after the timetable took the win');
|
||||
assert.deepEqual(s.official!.revenues, [9, 2], 'the frozen standings moved');
|
||||
assert.equal(s.outcome!.winner, 1, 'the informational evaluation did not follow the new leader');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — it survives being replayed (Gitea#11)', () => {
|
||||
it('reproduces an extended game from seed and intents alone', () => {
|
||||
// The reason the vote is an intent at all. A save is a replay, so a decision that is not in the
|
||||
// history did not happen — an extended game would evaporate on the next reload.
|
||||
const play = (): GameState => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
return s;
|
||||
};
|
||||
const a = play();
|
||||
const b = play();
|
||||
assert.equal(a.extraDays, b.extraDays);
|
||||
assert.equal(a.status, b.status);
|
||||
assert.deepEqual(a.official!.outcome, b.official!.outcome);
|
||||
});
|
||||
|
||||
it('narrates the vote, so a table can see who called time', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
advance(s);
|
||||
const yes = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.ok(yes.ok);
|
||||
assert.deepEqual(yes.events.map((e) => e.type), ['extensionVoted']);
|
||||
const no = applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
|
||||
assert.ok(no.ok);
|
||||
assert.deepEqual(no.events.map((e) => e.type), ['extensionVoted', 'playConcluded']);
|
||||
});
|
||||
|
||||
it('announces the granted Day', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.ok(r.ok);
|
||||
assert.deepEqual(r.events.map((e) => e.type), ['extensionVoted', 'dayExtended']);
|
||||
const extended = r.events.find((e) => e.type === 'dayExtended');
|
||||
assert.equal(extended && 'day' in extended ? extended.day : null, s.config.days + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — bots play the timetable they were dealt (Gitea#11)', () => {
|
||||
it('REGRESSION: a simulated game terminates whatever the policy would vote', () => {
|
||||
/**
|
||||
* `playGame` declines on the driver's own account rather than leaving it to the policy, and this
|
||||
* is why. `randomBot` picks uniformly among its legal options, so it takes another Day about
|
||||
* half the time — and since a table may go on granting Days for ever, the game then runs to
|
||||
* `maxTurns`. It did: `test/sim.test.ts` went from under a second to an unbounded hang, every
|
||||
* seeded game in the harness playing fifty thousand turns instead of a couple of hundred.
|
||||
*
|
||||
* `randomBot` is the policy that exposes it, but the guarantee has to hold for any policy,
|
||||
* including ones not written yet — hence the fix living in the driver and the test living here.
|
||||
*/
|
||||
const s = createGame({ id: 'g', seed: 9, config: baseConfig({ days: 1 }), playerNames: ['Jesse'] });
|
||||
const out = playGame(s, randomBot(7), pump, 4_000);
|
||||
assert.equal(s.status, 'finished', 'a simulated game did not finish');
|
||||
assert.equal(s.extraDays, 0, 'the harness played Days the game was not dealt');
|
||||
assert.ok(out.turns < 4_000, `ran to the turn cap (${out.turns}) instead of ending`);
|
||||
});
|
||||
|
||||
it('developerBot declines, so a bot-only game ends on schedule', () => {
|
||||
// "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28). The server votes
|
||||
// yes on a bot's behalf once every human has already agreed; this policy is what is left when
|
||||
// there are no humans to follow.
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const choice = developerBot.choose(s, 0, legalActions(s, 0));
|
||||
assert.equal(choice.type, 'game.extend');
|
||||
assert.equal('agree' in choice ? choice.agree : null, false, 'a bot asked for another Day');
|
||||
});
|
||||
});
|
||||
+34
-9
@@ -228,14 +228,28 @@ describe('the game conserves Rolling Stock', () => {
|
||||
for (let t = 0; t < 50_000; t++) {
|
||||
const before = census(s);
|
||||
const pumped = pump(s);
|
||||
// §10 — a collision destroys both trains and everything they were carrying. That is the one
|
||||
// legitimate way the count falls, so the expectation follows it down.
|
||||
for (const e of pumped) {
|
||||
if (e.type === 'trainsDestroyed') for (const tr of e.trains) expected -= tr.consist.length;
|
||||
}
|
||||
assert.ok(
|
||||
census(s) === before || pumped.some((e) => e.type === 'trainsDestroyed'),
|
||||
`seed ${seed}: the engine changed the census by ${census(s) - before} outside a collision`,
|
||||
/**
|
||||
* A COLLISION DESTROYS NO CAR, and this used to assume it destroyed all of them.
|
||||
*
|
||||
* The subtraction that stood here — `expected -= tr.consist.length` for every train in a
|
||||
* `trainsDestroyed` event — describes a rule the engine does not have. Gap 2c (`advance.ts`,
|
||||
* "TAKE THE WRECK OFF THE CARD") sends the wreck's cabooses back to the Division Yard and
|
||||
* everything else to Classification, so the stock is conserved through a collision like any
|
||||
* other move. The train is destroyed; its cars are not.
|
||||
*
|
||||
* It passed for as long as it did because none of the six seeds below ever collided, so the
|
||||
* branch never ran. Changing the deck to the sheet's counts (Gitea#14) moved the deals, seed
|
||||
* 24757 collided, and the test failed claiming the engine had CONJURED three cars — the
|
||||
* exact opposite of what had happened.
|
||||
*
|
||||
* So the census is now held flat, unconditionally, which is both the real invariant and a
|
||||
* stronger test than the one it replaces: there is no longer any event that excuses a
|
||||
* change, and `expected` cannot drift away from the supply it was dealt.
|
||||
*/
|
||||
assert.equal(
|
||||
census(s),
|
||||
before,
|
||||
`seed ${seed}: the engine changed the census by ${census(s) - before} while pumping`,
|
||||
);
|
||||
if (s.status === 'finished') break;
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
@@ -290,7 +304,18 @@ describe('every published replay actually replays', () => {
|
||||
`${f} is dead — it replays ${back.history.length} of ${save.history.length} intents. ` +
|
||||
'Re-record it with save-replay.ts rather than editing the file.',
|
||||
);
|
||||
assert.equal(back.state.status, 'finished', `${f} does not reach the end of its game`);
|
||||
/**
|
||||
* `awaitingExtension` counts as the end since Gitea#11 — and for a published file it is the
|
||||
* EXPECTED end. These saves were recorded before extended play existed, so their histories
|
||||
* carry no `game.extend` vote: replaying one runs the timetable out and stops on the question
|
||||
* nobody was there to answer. That is a game that reached the end of its own history, which is
|
||||
* what this test is about. `active` would still be a dead replay.
|
||||
*/
|
||||
assert.ok(
|
||||
back.state.status === 'finished' || back.state.status === 'awaitingExtension',
|
||||
`${f} does not reach the end of its game — status ${back.state.status}`,
|
||||
);
|
||||
assert.ok(back.state.official !== null, `${f} ends without recording a result`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+498
-94
@@ -12,6 +12,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { MainlineKind } from '../src/engine/content.ts';
|
||||
import { advance } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameEvent } from '../src/engine/events.ts';
|
||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey, turnOf } from '../src/engine/state.ts';
|
||||
import { coordKey, decisionActor, turnOf } from '../src/engine/state.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
@@ -43,6 +44,20 @@ const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, player
|
||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
|
||||
/** Puts a card of `kind`/`key` in hand and returns its id. */
|
||||
/**
|
||||
* Puts a specific rules card in hand and returns its id, MINTING ONE IF THE DECK NO LONGER DEALS IT.
|
||||
*
|
||||
* A card at `copies: 0` is still a card: the catalogue keeps its row and the engine keeps its rule,
|
||||
* so the design stays visible and the mechanic works the moment it is dealt again. Poling and the
|
||||
* sharp curves have been treated that way for a while, and Gitea#14 put the dispatching ladder,
|
||||
* Facing Point Locks, Flying Switch, Section House and Vandalism there too — none of them are in
|
||||
* `docs/Deck cards5.xlsx`.
|
||||
*
|
||||
* This used to throw when it could not find one, which made "dealt zero copies" and "deleted"
|
||||
* indistinguishable from a test's point of view: zeroing Flying Switch took five passing tests of a
|
||||
* rule that had not changed at all down with it. Minting keeps the rule under test independently of
|
||||
* whether the deck currently deals the card, which is the whole reason for keeping the row.
|
||||
*/
|
||||
function hand(s: GameState, kind: string, key: string): string {
|
||||
for (const [id, card] of s.cards) {
|
||||
const k = card.kind as { kind: string; key?: string };
|
||||
@@ -51,7 +66,10 @@ function hand(s: GameState, kind: string, key: string): string {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no ${kind} card: ${key}`);
|
||||
const id = `zero-copy-${kind}-${key}`;
|
||||
s.cards.set(id, { id, kind: { kind, key } } as never);
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,53 +108,124 @@ function drawTurn(s: GameState): void {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('grade modifiers change crossing time', () => {
|
||||
it('a Heavy Grade takes two Stages bare', () => {
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false), 2);
|
||||
/**
|
||||
* Crossing time on the region model (Gitea#3). `cross` fills in the parts each test does not care
|
||||
* about, so the numbers below read as the table RAR gave rather than as argument lists.
|
||||
*/
|
||||
const cross = (
|
||||
kind: Parameters<typeof crossingStages>[0],
|
||||
over: Partial<Parameters<typeof crossingStages>[1]> = {},
|
||||
): number =>
|
||||
crossingStages(kind, {
|
||||
trainSpeed: 'fast',
|
||||
direction: 'east',
|
||||
gradeUp: 'east',
|
||||
modifiers: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('a card costs one Stage per printed region (Gitea#3)', () => {
|
||||
/**
|
||||
* RAR, 2026-08-26, and this REPLACES the two rules that were here before — Q1, "the printed 60/30
|
||||
* are mph expressed as crossing time", and Q2, "a Slow train adds one Stage to every card".
|
||||
*
|
||||
* "Ignore speed signs, they are just graphics. Regions shown on cards indicate how many stages it
|
||||
* takes to cross. Plains is 1. Double track is 1, tunnel is 2, curves is 2, heavy grade is 3
|
||||
* unless you have help."
|
||||
*
|
||||
* The report that opened the issue was a Slow train taking two Stages to clear Double Track. Q2 is
|
||||
* what did that, and it is gone.
|
||||
*/
|
||||
it('crosses in the number of regions the card prints, whatever the train', () => {
|
||||
for (const speed of ['fast', 'slow'] as const) {
|
||||
assert.equal(cross('plains', { trainSpeed: speed }), 1, `plains, ${speed}`);
|
||||
assert.equal(cross('doubleTrack', { trainSpeed: speed }), 1, `double track, ${speed}`);
|
||||
assert.equal(cross('trestle', { trainSpeed: speed }), 1, `trestle, ${speed}`);
|
||||
assert.equal(cross('curves', { trainSpeed: speed }), 2, `curves, ${speed}`);
|
||||
assert.equal(cross('tunnel', { trainSpeed: speed }), 2, `tunnel, ${speed}`);
|
||||
assert.equal(cross('heavyGrade', { trainSpeed: speed }), 3, `heavy grade, ${speed}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads Fast/Slow on Hilly and on nothing else', () => {
|
||||
// "Some cards say fast / slow… Fast / Slow does not apply to every card — just those that say
|
||||
// fast / slow on them. Currently this is only hilly." A fast train starts in the second region.
|
||||
assert.equal(cross('hilly', { trainSpeed: 'fast' }), 1);
|
||||
assert.equal(cross('hilly', { trainSpeed: 'slow' }), 2);
|
||||
});
|
||||
|
||||
it('does not read the consist any more', () => {
|
||||
// Hilly used to take its split off the printed P60/F30 and decide by whether the train carried a
|
||||
// coach, so a fast freight crossed slower than a slow passenger train. RAR: "I notice that you
|
||||
// are basing stages in mainline cards off coach/non-coach. Actually, all trains are rated as
|
||||
// FAST and SLOW." `crossingStages` no longer takes a consist at all — this test is here so the
|
||||
// deletion is deliberate rather than incidental.
|
||||
assert.equal(cross('hilly', { trainSpeed: 'fast' }), cross('hilly', { trainSpeed: 'fast' }));
|
||||
});
|
||||
|
||||
it('runs a train through a siding or an Interchange in one Stage', () => {
|
||||
// Both print a back region that is not part of the road, so a train passing through starts past
|
||||
// it. What that region is FOR is tested below and in the collision tests.
|
||||
assert.equal(cross('uncontrolledSiding'), 1);
|
||||
assert.equal(cross('interchange'), 1);
|
||||
});
|
||||
|
||||
it('costs the extra Stage to anything starting in that back region', () => {
|
||||
// The Uncontrolled Siding with a train already on it, and an Extra beginning its run at an
|
||||
// Interchange. Both start at the back and have the whole card to run.
|
||||
assert.equal(cross('uncontrolledSiding', { startsAtBack: true }), 2);
|
||||
assert.equal(cross('interchange', { startsAtBack: true }), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grade modifiers move the start, not the clock', () => {
|
||||
it('a Heavy Grade takes three Stages bare', () => {
|
||||
// Three regions, up from the two the old 30mph reading gave it.
|
||||
assert.equal(cross('heavyGrade'), 3);
|
||||
});
|
||||
|
||||
it('Brakeman speeds the descent but not the climb', () => {
|
||||
// Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which
|
||||
// way is UPHILL. With up = east, a westbound train is descending.
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'east'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'east'), 2);
|
||||
// Q11 — the card prints "(Up)" and "Player sets orientation", so `gradeUp` is which way is
|
||||
// UPHILL. With up = east, a westbound train is descending.
|
||||
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west' }), 2);
|
||||
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'east' }), 3);
|
||||
});
|
||||
|
||||
it('follows the orientation the player chose, not a fixed compass direction', () => {
|
||||
// The same train on the same card, with the card turned around: Brakeman helps a westbound
|
||||
// train on an east-climbing grade, and an eastbound one when the grade climbs west.
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'west'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'west'), 2);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'west'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'west'), 2);
|
||||
it('follows the orientation the card was dealt, not a fixed compass direction', () => {
|
||||
// The same train on the same card, with the card turned around.
|
||||
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'east', gradeUp: 'west' }), 2);
|
||||
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west', gradeUp: 'west' }), 3);
|
||||
assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'west', gradeUp: 'west' }), 2);
|
||||
assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'east', gradeUp: 'west' }), 3);
|
||||
});
|
||||
|
||||
it('Helpers speed the climb but not the descent', () => {
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2);
|
||||
// RAR: "helpers… helps all trains going up hill by starting 1 region easier — so 2 to traverse,
|
||||
// not 3."
|
||||
assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'east' }), 2);
|
||||
assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'west' }), 3);
|
||||
});
|
||||
|
||||
it('Airbrakes stack with Brakeman on a slow train', () => {
|
||||
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3);
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2);
|
||||
assert.equal(
|
||||
crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
||||
1,
|
||||
);
|
||||
it('Airbrakes stack on top of Brakeman', () => {
|
||||
// "Airbrakes is an upgrade from brakemen (which must be played first)", so a fully-equipped
|
||||
// grade is one Stage downhill — and `check` refuses Airbrakes without Brakeman already there.
|
||||
assert.equal(cross('heavyGrade', { direction: 'west' }), 3);
|
||||
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west' }), 2);
|
||||
assert.equal(cross('heavyGrade', { modifiers: ['brakeman', 'airbrakes'], direction: 'west' }), 1);
|
||||
});
|
||||
|
||||
it('never lets a train cross in no time', () => {
|
||||
// Three modifiers on a three-region card would otherwise put the start past the far edge.
|
||||
assert.equal(
|
||||
crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
||||
cross('heavyGrade', { modifiers: ['brakeman', 'airbrakes', 'helpers'], direction: 'west' }),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves non-grade cards alone', () => {
|
||||
// Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
|
||||
assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west', 'east'), 1);
|
||||
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2);
|
||||
assert.equal(cross('plains', { modifiers: ['brakeman'], direction: 'west' }), 1);
|
||||
assert.equal(cross('curves', { modifiers: ['helpers'] }), 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,8 +298,8 @@ describe('Realignment converts one Mainline type to another', () => {
|
||||
|
||||
assert.ok(r.ok);
|
||||
assert.equal(node.card, 'plains', 'Curves realigns to Plains');
|
||||
// The point of the card: Curves is a 30 (two Stages), Plains a 60 (one).
|
||||
assert.equal(crossingStages(node.card, 'fast', false), 1);
|
||||
// The point of the card: Curves prints two regions, Plains one, so realigning halves the time.
|
||||
assert.equal(cross(node.card), 1);
|
||||
});
|
||||
|
||||
it('refuses a card with no conversion listed', () => {
|
||||
@@ -228,77 +317,177 @@ describe('Realignment converts one Mainline type to another', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Red Flags protect a stopped train', () => {
|
||||
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
|
||||
function rearEnder(s: GameState) {
|
||||
const node = pinned(s, 1, 'plains');
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
describe('Red Flags hold a train out of your Limits (Gitea#19)', () => {
|
||||
/**
|
||||
* REPLACES the old rule outright (Jesse, 2026-08-29). Red Flags used to be played on a stopped
|
||||
* train out on the Mainline and protected it from a rear-ender — measured at 4,212 offers and 4
|
||||
* plays across 600 games, a mechanic nobody used. ABS Signals already does that job better.
|
||||
*
|
||||
* Now: "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits
|
||||
* from that direction (i.e. Flag East holds westbound trains)." Spent on the train it stops —
|
||||
* one card, one train.
|
||||
*/
|
||||
|
||||
/** A westbound train one Stage from entering seat 0's district from the east. */
|
||||
function approaching(s: GameState) {
|
||||
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const node = pinned(s, officeIndex + 1, 'plains');
|
||||
node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' });
|
||||
s.trays.set('inbound', {
|
||||
id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'hopper', loaded: true }], direction: 'west',
|
||||
position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0,
|
||||
});
|
||||
s.trays.set('behind', {
|
||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
const dp = s.division.nodes[0];
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push('behind');
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return node;
|
||||
return s.division.nodes[officeIndex] as Extract<typeof s.division.nodes[0], { kind: 'office' }>;
|
||||
}
|
||||
|
||||
it('holds the approaching train instead of letting it close', () => {
|
||||
it('FLAG EAST holds a westbound train short of the Limits', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
node.redFlagged = ['ahead'];
|
||||
const office = approaching(s);
|
||||
office.redFlag = 'east';
|
||||
|
||||
advance(s);
|
||||
assert.deepEqual(
|
||||
s.trays.get('behind')!.position,
|
||||
{ at: 'divisionPoint', side: 'west' },
|
||||
'the flagged train must not be approached',
|
||||
);
|
||||
const pos = s.trays.get('inbound')!.position;
|
||||
assert.equal(pos.at, 'mainline', 'the flagged train came in anyway');
|
||||
assert.ok(!areaOf(s, 0).adOccupancy.includes('inbound'), 'it reached an A/D track');
|
||||
});
|
||||
|
||||
it('comes in when the protected train rolls', () => {
|
||||
it('is spent on the train it stops — one card, one train', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
node.redFlagged = ['ahead'];
|
||||
// Bring the protected train to the end of its crossing so it leaves the card.
|
||||
node.transits[0]!.stagesRemaining = 1;
|
||||
const office = approaching(s);
|
||||
office.redFlag = 'east';
|
||||
|
||||
for (let i = 0; i < 12 && (node.redFlagged?.length ?? 0) > 0; i++) advance(s);
|
||||
assert.deepEqual(node.redFlagged, [], 'flags come in once the train moves off');
|
||||
advance(s);
|
||||
assert.equal(office.redFlag, undefined, 'the flag stayed up after stopping a train');
|
||||
});
|
||||
|
||||
it('only protects a train out on the Mainline', () => {
|
||||
it('lets the train in on the next Mainline Phase', () => {
|
||||
// "Loses one Mainline Phase" — it buys a Stage to clear the lead, not permanent protection.
|
||||
const s = game();
|
||||
const office = approaching(s);
|
||||
office.redFlag = 'east';
|
||||
advance(s);
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
advance(s);
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'the train never came in');
|
||||
});
|
||||
|
||||
it('does not hold a train coming from the OTHER side', () => {
|
||||
// "Flag East holds westbound trains" — an eastbound train arrives from the west.
|
||||
const s = game();
|
||||
const office = approaching(s);
|
||||
office.redFlag = 'west';
|
||||
|
||||
advance(s);
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'a west flag held a train from the east');
|
||||
assert.equal(office.redFlag, 'west', 'the wrong-side flag was spent');
|
||||
});
|
||||
|
||||
it('is played on a side, not on a train', () => {
|
||||
const s = game();
|
||||
rearEnder(s);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
const cardId = hand(s, 'maneuver', 'redFlags');
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'behind' }),
|
||||
'NO_PLACEMENT',
|
||||
'a train sitting at a Division Point cannot be rear-ended',
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), null);
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), null);
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null);
|
||||
});
|
||||
|
||||
it('will not double-flag the same train', () => {
|
||||
it('will not double-flag the same side', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
const cardId = hand(s, 'maneuver', 'redFlags');
|
||||
node.redFlagged = ['ahead'];
|
||||
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const office = s.division.nodes[officeIndex] as { redFlag?: string };
|
||||
office.redFlag = 'east';
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }),
|
||||
'OPTION_ALREADY_CHOSEN',
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), 'ALREADY_FLAGGED');
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null,
|
||||
'the other side should still be free');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Red Flags offered at the moment of danger (Gitea#19)', () => {
|
||||
/**
|
||||
* "In actual cases of danger… if there is a train or cars on the track and there will be a
|
||||
* collision, then you break in with a dialog that says COLLISION RISK! FLAG AGAINST T2? This way,
|
||||
* you can play the card normally or out of phase, but only if you need it."
|
||||
*
|
||||
* The engine establishes the danger, so the player is never asked to judge it — which is also why
|
||||
* the bot can now use this card at all. It is offered ONLY to somebody holding one.
|
||||
*/
|
||||
function dangerous(s: GameState, giveCard: boolean) {
|
||||
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const node = pinned(s, officeIndex + 1, 'plains');
|
||||
node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' });
|
||||
s.trays.set('inbound', {
|
||||
id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'hopper', loaded: true }], direction: 'west',
|
||||
position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0,
|
||||
});
|
||||
// A hopper fouling the Running Track: §8.3 makes this arrival a collision.
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.get(coordKey(area.officeCoord))!.standing = [{ type: 'hopper', loaded: false }];
|
||||
if (giveCard) hand(s, 'maneuver', 'redFlags');
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
}
|
||||
|
||||
it('breaks in to offer the flag when an arrival would collide', () => {
|
||||
const s = game();
|
||||
dangerous(s, true);
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'redFlag', 'no prompt before a certain collision');
|
||||
assert.equal(decisionActor(s), 0, 'the prompt went to the wrong player');
|
||||
});
|
||||
|
||||
it('flagging holds the train and costs the card', () => {
|
||||
const s = game();
|
||||
dangerous(s, true);
|
||||
advance(s);
|
||||
const before = (s.decks.hands.get(0) ?? []).length;
|
||||
|
||||
const r = applyIntent(s, 0, { type: 'mainline.redFlag', flag: true });
|
||||
assert.ok(r.ok, 'the flag was refused');
|
||||
advance(s);
|
||||
|
||||
assert.equal(s.players[0]!.revenue, 0, 'the collision happened anyway');
|
||||
assert.equal(s.trays.get('inbound')!.position.at, 'mainline', 'the train came in regardless');
|
||||
assert.equal((s.decks.hands.get(0) ?? []).length, before - 1, 'the card was not spent');
|
||||
});
|
||||
|
||||
it('declining lets the collision happen', () => {
|
||||
const s = game();
|
||||
dangerous(s, true);
|
||||
advance(s);
|
||||
|
||||
assert.ok(applyIntent(s, 0, { type: 'mainline.redFlag', flag: false }).ok);
|
||||
advance(s);
|
||||
assert.equal(s.players[0]!.revenue, -5, 'waving it through did not collide');
|
||||
});
|
||||
|
||||
it('does not offer a flag to a player holding none', () => {
|
||||
// A prompt with one button is not a choice, and it leaks that a collision is coming.
|
||||
const s = game();
|
||||
dangerous(s, false);
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision, null, 'offered a flag to a player with no card');
|
||||
assert.equal(s.players[0]!.revenue, -5, 'the collision should have happened');
|
||||
});
|
||||
|
||||
it('stays quiet when the arrival is safe', () => {
|
||||
const s = game();
|
||||
dangerous(s, true);
|
||||
// Clear the hazard: nothing fouling the Running Track, and room at the Office.
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.get(coordKey(area.officeCoord))!.standing = [];
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision, null, 'interrupted the phase for a safe arrival');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -681,6 +870,100 @@ describe('a turnout may be laid on top of a card already down', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extras that must run loaded, and one that must not (Gitea#13)', () => {
|
||||
/**
|
||||
* "I've redefined some of the extra trains that they have to run full boxcars — military trains,
|
||||
* circus trains, etc. If not loaded, then empty, and if none available, run without."
|
||||
*
|
||||
* A PREFERENCE ORDER, so every test here is about what the DIVISION YARD still holds. The rule
|
||||
* has nothing to say about a train once it is running; it decides which car may be taken next.
|
||||
*/
|
||||
const madeUp = (trainNumber: number) => {
|
||||
const s = game();
|
||||
s.clock.phase = 'newTrain';
|
||||
s.trays.set('t', {
|
||||
id: 't', trainNumber, trainIsExtra: true, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const place = (carType: string, loaded: boolean) =>
|
||||
({ type: 'newTrain.placeCar', trayId: 't', carType, loaded }) as never;
|
||||
/** Leaves the Division Yard holding exactly the cars described. */
|
||||
const stockYard = (s: ReturnType<typeof madeUp>, cars: { type: string; loaded: boolean }[]) => {
|
||||
s.yards.divisionYard.length = 0;
|
||||
s.yards.divisionYard.push(...(cars as never[]));
|
||||
};
|
||||
|
||||
it('refuses an empty while the yard can still supply a loaded one (X18 Circus)', () => {
|
||||
const s = madeUp(18);
|
||||
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('boxcar', false)), 'NO_SUITABLE_CAR',
|
||||
'an empty was accepted while a loaded boxcar was still in the yard');
|
||||
assert.equal(check(s, 0, place('boxcar', true)), null, 'the loaded boxcar was refused');
|
||||
});
|
||||
|
||||
it('accepts an empty once the yard has no loaded car of that kind left', () => {
|
||||
// "If not loaded, then empty." The rule releases as soon as the yard cannot supply.
|
||||
const s = madeUp(18);
|
||||
stockYard(s, [{ type: 'boxcar', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('boxcar', false)), null,
|
||||
'an empty was refused when the yard held no loaded car at all');
|
||||
});
|
||||
|
||||
it('does not let a loaded car of the WRONG category unlock the rule', () => {
|
||||
// A loaded coach is no reason to refuse an empty boxcar: the preference is per category, since
|
||||
// that is the slot the car is competing for.
|
||||
const s = madeUp(18);
|
||||
stockYard(s, [{ type: 'coach', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('boxcar', false)), null,
|
||||
'a loaded coach blocked an empty boxcar');
|
||||
});
|
||||
|
||||
it('exempts the caboose, which is never empty in the supply', () => {
|
||||
const s = madeUp(18);
|
||||
stockYard(s, [{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }]);
|
||||
assert.equal(check(s, 0, place('caboose', true)), null, 'the caboose its card calls for was refused');
|
||||
});
|
||||
|
||||
it('applies to the Military train too', () => {
|
||||
const s = madeUp(19);
|
||||
stockYard(s, [{ type: 'coach', loaded: true }, { type: 'coach', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('coach', false)), 'NO_SUITABLE_CAR',
|
||||
'the Military train took an empty coach over a loaded one');
|
||||
});
|
||||
|
||||
it('leaves trains without the rule alone', () => {
|
||||
// X21 Freight Extra has no loading rule: an empty is as good as a loaded one.
|
||||
const s = madeUp(21);
|
||||
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('boxcar', false)), null,
|
||||
'a train with no loading rule was made to prefer loaded cars');
|
||||
});
|
||||
|
||||
it('REGRESSION: X13 Appleseed is empties-only, and now the rules say so too', () => {
|
||||
/**
|
||||
* `ConsistSpec.emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)"
|
||||
* by `web/game.ts` and `sim/view.ts`, and enforced by NOTHING — `acceptsCar` never read it. So
|
||||
* the Appleseed could be made up with loaded cars while its own card said it could not. Found
|
||||
* while building Gitea#13, which is the same rule pointing the other way.
|
||||
*/
|
||||
const s = madeUp(13);
|
||||
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('boxcar', true)), 'NO_SUITABLE_CAR',
|
||||
'the Appleseed took a loaded car despite printing "empties only"');
|
||||
assert.equal(check(s, 0, place('boxcar', false)), null, 'the Appleseed refused an empty');
|
||||
});
|
||||
|
||||
it('still lets the Appleseed take the caboose its consist calls for', () => {
|
||||
// Every caboose in ROLLING_STOCK_SUPPLY is minted loaded, so an unexempted empties-only rule
|
||||
// would bar the one car the card explicitly lists.
|
||||
const s = madeUp(13);
|
||||
stockYard(s, [{ type: 'caboose', loaded: true }]);
|
||||
assert.equal(check(s, 0, place('caboose', true)), null, 'the empties-only rule ate the caboose');
|
||||
});
|
||||
});
|
||||
|
||||
describe("a train is made up to its card's consist (§8.2)", () => {
|
||||
it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => {
|
||||
// Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and
|
||||
@@ -796,18 +1079,35 @@ describe('Q13 — a train that catches the one ahead runs into it', () => {
|
||||
* then region 1 — so it catches up whichever order the phase happens to process them in.
|
||||
*/
|
||||
const twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => {
|
||||
const s = createGame({
|
||||
id: 'rear', seed: 3,
|
||||
config: {
|
||||
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['bot'],
|
||||
});
|
||||
const index = s.division.nodes.findIndex(
|
||||
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
|
||||
);
|
||||
assert.ok(index >= 0, 'no single-track Mainline card in this Division');
|
||||
/**
|
||||
* THE SEED IS SEARCHED FOR, NOT WRITTEN DOWN.
|
||||
*
|
||||
* This asked for seed 3 and asserted that its Division held a single-track Mainline card. It
|
||||
* does not any more: the Division is laid out from the same RNG stream the card deck is
|
||||
* shuffled from, so changing the SIZE of that deck re-deals the Division too. Gitea#14's deck
|
||||
* counts moved it, and the test failed on its own precondition — "no single-track Mainline card
|
||||
* in this Division" — which says nothing about the rule under test.
|
||||
*
|
||||
* The fixture needs A Division with a card trains may not pass on, not one particular one, so
|
||||
* it now takes the first seed that provides one. That is stable across any future retune, and
|
||||
* it fails loudly if such a Division stops being reachable at all.
|
||||
*/
|
||||
let s!: GameState;
|
||||
let index = -1;
|
||||
for (let seed = 3; seed < 200 && index < 0; seed++) {
|
||||
s = createGame({
|
||||
id: 'rear', seed,
|
||||
config: {
|
||||
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['bot'],
|
||||
});
|
||||
index = s.division.nodes.findIndex(
|
||||
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
|
||||
);
|
||||
}
|
||||
assert.ok(index >= 0, 'no seed under 200 deals a Division holding a single-track Mainline card');
|
||||
const node = s.division.nodes[index]!;
|
||||
assert.ok(node.kind === 'mainline');
|
||||
if (node.kind !== 'mainline') throw new Error('unreachable');
|
||||
@@ -863,12 +1163,116 @@ describe('Q13 — a train that catches the one ahead runs into it', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves trains alone on a card that prints "trains may pass"', () => {
|
||||
// Double Track and Uncontrolled Siding hold two trains because they HAVE two roads. Catching up
|
||||
// there means going past, which is what the card is for. Without this the mechanic fired 0.41
|
||||
// times a game while the bot never once granted clearance — the tell that they were all
|
||||
// passing cards.
|
||||
/**
|
||||
* ENTERING an occupied region, as opposed to catching up inside the card (Gitea#3).
|
||||
*
|
||||
* A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train
|
||||
* granted clearance is in the same place as the train ahead the moment it arrives. Nothing tested
|
||||
* that: the catch-up check lives inside `stagesRemaining > 1`, which a one-Stage crossing never
|
||||
* reaches, so entering behind another train on a Plains was silently free.
|
||||
*/
|
||||
const enteringBehind = (card: MainlineKind, opts: { absSignals?: boolean } = {}) => {
|
||||
const s = game();
|
||||
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
const node = s.division.nodes[index]!;
|
||||
assert.ok(node.kind === 'mainline');
|
||||
if (node.kind !== 'mainline') throw new Error('unreachable');
|
||||
node.card = card;
|
||||
node.transits = [];
|
||||
if (opts.absSignals) node.absSignals = true;
|
||||
|
||||
/**
|
||||
* THE TRAIN ALREADY THERE IS THE JUNIOR ONE, and that is what makes the situation reachable.
|
||||
*
|
||||
* Trains move lowest number first, so a card's occupant normally clears before anything behind
|
||||
* it is even considered — put train 9 on the card and train 11 at the Division Point and 9 has
|
||||
* gone by the time 11 enters. The conflict is a SUPERIOR train catching an inferior one that has
|
||||
* not got out of the way yet, so the numbers run the other way round here.
|
||||
*/
|
||||
const leader = s.freeTrays.pop()!;
|
||||
s.trays.set(leader, {
|
||||
id: leader, trainNumber: 11, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: false }], direction: 'east',
|
||||
position: { at: 'mainline', index },
|
||||
} as never);
|
||||
const total = crossingStages(card, { trainSpeed: 'fast', direction: 'east', gradeUp: 'east', modifiers: [] });
|
||||
node.transits.push({ tray: leader, stagesRemaining: total, stagesTotal: total, direction: 'east' });
|
||||
|
||||
// And the one arriving, held at the Division Point west of it.
|
||||
const dp = s.division.nodes[index - 1];
|
||||
assert.ok(dp && dp.kind === 'divisionPoint', 'expected a Division Point west of the first card');
|
||||
if (!dp || dp.kind !== 'divisionPoint') throw new Error('unreachable');
|
||||
const follower = s.freeTrays.pop()!;
|
||||
s.trays.set(follower, {
|
||||
id: follower, trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: false }], direction: 'east',
|
||||
position: { at: 'divisionPoint', side: dp.side },
|
||||
} as never);
|
||||
dp.holding.push(follower);
|
||||
|
||||
/**
|
||||
* THE SUPERINTENDENT LETS IT IN, which is the whole point.
|
||||
*
|
||||
* A same-direction train in the Subdivision is not an absolute bar — §8.1 makes it a judgment
|
||||
* call, and `advance` stops and asks. Granting it is what puts one train in behind another, and
|
||||
* §10 is then explicit that the wreck is the Superintendent's fault. So the fixture answers
|
||||
* `allow: true` whenever it is asked, and the collision below is the consequence of that
|
||||
* ruling rather than of a rule firing on its own.
|
||||
*/
|
||||
s.clock.phase = 'mainline';
|
||||
const events: GameEvent[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
events.push(...advance(s).events);
|
||||
if (s.clock.pendingDecision !== null) {
|
||||
const who = s.clock.superintendent;
|
||||
const r = applyIntent(s, who, { type: 'mainline.clearance', allow: true });
|
||||
assert.ok(r.ok, `clearance refused: ${r.ok ? '' : r.code}`);
|
||||
events.push(...r.events);
|
||||
}
|
||||
}
|
||||
return { s, node, events, follower };
|
||||
};
|
||||
|
||||
it('runs a train into the one ahead when it ENTERS an occupied region', () => {
|
||||
const { events } = enteringBehind('plains');
|
||||
const smash = events.find((e) => e.type === 'trainsDestroyed');
|
||||
assert.ok(smash, 'a train entered a one-region card behind another and nothing happened');
|
||||
});
|
||||
|
||||
it('holds it short instead when the card carries ABS Signals', () => {
|
||||
// RAR: "ABS. This is played on a mainline card to prevent collisions. If a collision would
|
||||
// normally occur, the train moving onto the card is instead held back."
|
||||
const { events } = enteringBehind('plains', { absSignals: true });
|
||||
assert.ok(!events.some((e) => e.type === 'trainsDestroyed'), 'ABS Signals did not prevent it');
|
||||
assert.ok(
|
||||
events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)),
|
||||
'nothing was held short of the train ahead',
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the siding instead of colliding on an Uncontrolled Siding', () => {
|
||||
// "If a train already exists when you arrive, you go in the second stage back — you are in the
|
||||
// siding and are one behind the other train. This prevents a collision, since you are not in
|
||||
// same exact location." So: no wreck, both trains on the card, and the newcomer paying the
|
||||
// extra Stage for the detour.
|
||||
const { node, events, follower } = enteringBehind('uncontrolledSiding');
|
||||
assert.ok(!events.some((e) => e.type === 'trainsDestroyed'), 'the siding did not prevent a collision');
|
||||
const mine = node.transits.find((t) => t.tray === follower);
|
||||
assert.ok(mine, 'the arriving train never made it onto the card');
|
||||
assert.equal(mine.stagesTotal, 2, 'it should have entered at the back of the card, not the front');
|
||||
});
|
||||
|
||||
it('leaves trains alone on the one card that prints "trains may pass"', () => {
|
||||
// Double Track holds two trains because it HAS two roads. Catching up there means going past,
|
||||
// which is what the card is for.
|
||||
//
|
||||
// THE UNCONTROLLED SIDING USED TO BE IN THIS LIST AND IS NOT ANY MORE (Gitea#3). It holds two
|
||||
// trains as well, but not by letting them share a place: the second one takes the siding and
|
||||
// sits a region behind, which is what keeps them apart — "you are in the siding and are one
|
||||
// behind the other train. This prevents a collision, since you are not in same exact location."
|
||||
// Marked "may pass" it skipped the collision test entirely, so the siding did nothing at all and
|
||||
// two trains could occupy the same region of it unchallenged.
|
||||
const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind);
|
||||
assert.deepEqual(passing, ['doubleTrack', 'uncontrolledSiding']);
|
||||
assert.deepEqual(passing, ['doubleTrack']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,8 +59,11 @@ describe('what each game type is', () => {
|
||||
assert.equal(presetSettings('cutthroat', 4, 5).extraStart, 'anyOffice');
|
||||
assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice');
|
||||
assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice');
|
||||
// At one player the two rules are the same rule.
|
||||
assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'anyOffice');
|
||||
// At one player the two rules ARE the same rule — `apply.ts` only rejects `ownOffice` when the
|
||||
// start is another seat's, which cannot happen. Solitaire said `anyOffice` until 2026-08-30:
|
||||
// true, and it read wrong, since a lone player has no "any player" to be contrasted with. The
|
||||
// label changed and the behaviour did not.
|
||||
assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'ownOffice');
|
||||
});
|
||||
|
||||
it('leaves every optional rule off, in every type', () => {
|
||||
|
||||
@@ -99,6 +99,30 @@ describe('redaction — a seat\'s Frame never carries another seat\'s secrets',
|
||||
assert.ok(!serialized.includes(String(s.seed)), 'the seed value leaked into the Frame some other way');
|
||||
});
|
||||
|
||||
it('the tally that rides the Frame is aggregate counts, never a card id (Gitea#16)', () => {
|
||||
// Gitea#16's statistics live on `GameState` and reach a remote client on the Frame, which is
|
||||
// only safe because nothing in a Tally identifies a card. That is a property of what
|
||||
// `tally.ts` chooses to count, and nothing in the type system enforces it — so it is asserted
|
||||
// here, where a future counter that stashed a `cardId` "just for badges" would be caught.
|
||||
for (const players of [3, 4]) {
|
||||
const s = midGame(players, 5000 + players);
|
||||
const secrets = new Set<string>([...s.decks.homeOffice]);
|
||||
for (const hand of s.decks.hands.values()) for (const id of hand) secrets.add(id);
|
||||
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
|
||||
const serialized = JSON.stringify(snapshot(s, [], null, null, null, false, viewer).tally);
|
||||
for (const cardId of secrets) {
|
||||
assert.ok(!serialized.includes(`"${cardId}"`), `the tally carries card id "${cardId}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every seat sees the SAME tally — it is the table\'s account, not a private one', () => {
|
||||
const s = midGame(3, 5555);
|
||||
const tallies = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p as PlayerIndex).tally);
|
||||
for (const t of tallies) assert.deepEqual(t, tallies[0], 'the tally differs by seat');
|
||||
});
|
||||
|
||||
it('only the viewer\'s own hand and handCount are non-public — everything else matches across seats', () => {
|
||||
// The redaction surface is four fields (§7), not sixty event types. Cross-check that seats agree
|
||||
// on everything else a Frame carries about shared state.
|
||||
|
||||
+110
-2
@@ -11,8 +11,10 @@ import assert from 'node:assert/strict';
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiveRevenueFloor } from '../src/engine/content.ts';
|
||||
import type { GameEvent } from '../src/engine/events.ts';
|
||||
import { areaOf } from '../src/engine/apply.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig } from '../src/engine/state.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
||||
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
||||
@@ -138,6 +140,109 @@ describe('impediments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Gitea#2 — "four porters, two passengers on the platform, and I never get the chance to work them."
|
||||
*
|
||||
* The engine was faithful at every step; what was missing was any way to SEE why. A Porter action
|
||||
* that cannot be taken is simply absent from the menu, and this panel — the one that answers "why is
|
||||
* nothing moving?" — opened with `f.kind !== 'freight'`, so a platform had never had anything to say
|
||||
* for itself at all.
|
||||
*/
|
||||
describe('a blocked platform says why (Gitea#2)', () => {
|
||||
/** Raise the Whistle Post to a working Terminal: the tier's printed numbers, applied directly. */
|
||||
function platform(s: GameState) {
|
||||
const area = areaOf(s, 0);
|
||||
area.tier = 'terminal';
|
||||
const card = area.grid.get(coordKey(area.officeCoord))!;
|
||||
const f = card.facility!;
|
||||
f.allows = { outbound: true, inbound: true };
|
||||
f.porters = 3;
|
||||
f.capacity = { outbound: 3, inbound: 3 };
|
||||
return { area, f };
|
||||
}
|
||||
|
||||
/** A tray standing on an A/D track at the Office, carrying whatever it is given. */
|
||||
function atOffice(s: GameState, consist: { type: 'coach'; loaded: boolean; origin?: number }[]): void {
|
||||
const area = areaOf(s, 0);
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist,
|
||||
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push(id);
|
||||
}
|
||||
|
||||
it('reports passengers standing on a platform with no train to take them', () => {
|
||||
// The whole of the bug's second half: before this, `impediments` returned an EMPTY list here.
|
||||
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||
const { f } = platform(s);
|
||||
f.outboundBox = [{ type: 'coach', loaded: true }];
|
||||
const found = impediments(s, 0);
|
||||
const platformRow = found.find((b) => /platform/.test(b.why));
|
||||
assert.ok(platformRow, `nothing reported for the platform:\n${JSON.stringify(found, null, 2)}`);
|
||||
assert.match(platformRow.why, /passengers waiting, no train at the platform/);
|
||||
assert.equal(platformRow.severity, 'waiting');
|
||||
});
|
||||
|
||||
it('names the Office by its tier rather than the word "facility"', () => {
|
||||
// A Passenger Facility rides on the `office` card, so the freight branch's `geometry.facility`
|
||||
// is not there to read and every passenger row read `facility 0,0` next to `mineTipple 1,-3`.
|
||||
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||
const { f } = platform(s);
|
||||
f.outboundBox = [{ type: 'coach', loaded: true }];
|
||||
const row = impediments(s, 0).find((b) => /platform/.test(b.why))!;
|
||||
assert.match(row.where, /^terminal /, `the Office is unnamed: ${row.where}`);
|
||||
});
|
||||
|
||||
it('explains the coach shortage that made the game look broken', () => {
|
||||
// The reported state: a train in with passengers to set down, red slots free, four porters —
|
||||
// and §9.2 needs a white coach out of the Division Yard to swap in. There was none, with eight
|
||||
// more sitting in the Classification Yard that §2.2 returns only when the Division Yard is BARE.
|
||||
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||
const { f } = platform(s);
|
||||
atOffice(s, [{ type: 'coach', loaded: true, origin: 1 }]);
|
||||
s.yards.divisionYard = s.yards.divisionYard.filter((c) => !(c.type === 'coach' && !c.loaded));
|
||||
s.yards.classificationYard = [
|
||||
{ type: 'coach', loaded: false },
|
||||
{ type: 'coach', loaded: false },
|
||||
];
|
||||
const row = impediments(s, 0).find((b) => /§9\.2/.test(b.why));
|
||||
assert.ok(row, `the coach shortage was not explained:\n${JSON.stringify(impediments(s, 0), null, 2)}`);
|
||||
assert.equal(row.severity, 'stuck', 'a train that cannot be emptied is stuck, not merely waiting');
|
||||
assert.match(row.why, /2 coaches are in the Classification Yard/, `where the coaches are is not said: ${row.why}`);
|
||||
assert.match(row.why, /Classification returns only when the Division Yard is bare/);
|
||||
assert.ok(f.inboundBox.length === 0, 'the red slots were free — the shortage is the only cause');
|
||||
});
|
||||
|
||||
it('says why the game ended in words, not as a raw enum (TODO #34)', () => {
|
||||
/**
|
||||
* The heading read `loss — revenueFloor` — the exact defect Gitea#16 was filed about on the
|
||||
* playable page, still alive here a release after that was fixed, because nothing
|
||||
* player-facing pointed at the developer replay. It shares `reasonSentence` with the results
|
||||
* screen now, so the two cannot explain one ending in two ways.
|
||||
*/
|
||||
const rec = record(1234, 'standard');
|
||||
for (const raw of ['revenueFloor', 'daysElapsed', 'collisionFloor']) {
|
||||
assert.ok(!rec.outcome.includes(raw), `the summary still prints the raw reason "${raw}"`);
|
||||
}
|
||||
assert.doesNotMatch(rec.outcome, /<[^>]+>/, 'markup leaked into a heading and a console line');
|
||||
assert.match(rec.outcome, /Revenue/, 'the summary says nothing about how the game went');
|
||||
// And the sentence is the shared one, with this game's own numbers in it.
|
||||
assert.match(rec.outcome, /closed short|last on the timetable|declared unsafe/,
|
||||
'the ending is not explained in the words the results screen uses');
|
||||
});
|
||||
|
||||
it('says nothing about a platform that is working fine', () => {
|
||||
// Passengers waiting AND a train with an empty coach to take them: no impediment.
|
||||
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||
const { f } = platform(s);
|
||||
f.outboundBox = [{ type: 'coach', loaded: true }];
|
||||
atOffice(s, [{ type: 'coach', loaded: false }]);
|
||||
const found = impediments(s, 0).filter((b) => /platform|§9\.2|Porters/.test(b.why));
|
||||
assert.deepEqual(found, [], `a working platform reported an impediment:\n${JSON.stringify(found, null, 2)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay recording', () => {
|
||||
const rec = record(1234, 'standard');
|
||||
|
||||
@@ -170,7 +275,10 @@ describe('replay recording', () => {
|
||||
const last = rec.frames[rec.frames.length - 1]!;
|
||||
assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine');
|
||||
assert.equal(last.day, s.clock.day, 'final Day disagrees with the engine');
|
||||
assert.match(rec.outcome, new RegExp(stats.result));
|
||||
// The summary says "won"/"lost" rather than the engine's `win`/`loss` (TODO #34 — it is a
|
||||
// sentence for a reader now, not an enum). Mapped here so this still checks the two AGREE,
|
||||
// which is what the test is for, rather than checking they are spelled the same.
|
||||
assert.match(rec.outcome, new RegExp(stats.result === 'win' ? 'won' : 'lost'));
|
||||
});
|
||||
|
||||
it('narrates every frame', () => {
|
||||
|
||||
@@ -412,3 +412,122 @@ describe('the four transient signals (2026-08-23)', () => {
|
||||
assert.equal(later.scheduled, undefined, 'a reconnecting client was re-sent an old timetable flash');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§3.3 extended play across the server (Gitea#11)', () => {
|
||||
/**
|
||||
* A one-Day game, so these tests reach the end of the timetable by actually PLAYING to it.
|
||||
*
|
||||
* There is no back door into a session's engine state and there should not be — `connect`,
|
||||
* `intent`, `exportSave` and `summary` are the whole surface. So the clock is run down through the
|
||||
* same calls a client makes, which has the side benefit of exercising the real path: what is under
|
||||
* test here is the session's handling of the vote (the turn guard, the bots, the saved status),
|
||||
* and reaching it any other way would prove less.
|
||||
*/
|
||||
const oneDay: GameConfig = { ...config, days: 1 };
|
||||
|
||||
/** What seat `seat` can currently see. `connect` always yields a full Frame, never a delta. */
|
||||
const frameOf = (session: GameSession, seat: PlayerIndex) => session.connect(seat).frame!;
|
||||
|
||||
/** Plays until the game stops asking for ordinary moves. Returns the Frame it stopped on. */
|
||||
function playToTheEnd(session: GameSession, seats: PlayerIndex[]) {
|
||||
let seq = 0;
|
||||
for (let i = 0; i < 5_000; i++) {
|
||||
const acting = seats.find((s) => session.connect(s).menu !== null);
|
||||
if (acting === undefined) break;
|
||||
const menu = session.connect(acting).menu!;
|
||||
if (menu.options.length === 0) break;
|
||||
if (!session.intent(acting, seq++, menu.options[0]!).accepted) break;
|
||||
}
|
||||
return { frame: frameOf(session, seats[0]!), seq };
|
||||
}
|
||||
|
||||
it('stops to ask rather than ending, and every seat can see the question', () => {
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
const { frame } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frame.status, 'awaitingExtension', 'the game did not stop to ask');
|
||||
assert.ok(frame.official, 'the official result did not reach the client');
|
||||
assert.deepEqual(frame.extensionVotes, [null, null], 'the votes did not reach the client');
|
||||
});
|
||||
|
||||
it('accepts the vote from a seat that is not the current actor', () => {
|
||||
// `currentActor` is null once the game has stopped, so the ordinary turn guard would refuse
|
||||
// every vote with NOT_YOUR_TURN. Both seats vote here and neither of them is the actor.
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
const a = session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(a.accepted, true, 'seat 0 could not vote');
|
||||
const b = session.intent(1 as PlayerIndex, seq + 2, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(b.accepted, true, 'seat 1 could not vote');
|
||||
|
||||
const after = frameOf(session, 0 as PlayerIndex);
|
||||
assert.equal(after.extraDays, 1, 'a unanimous table was not given its Day');
|
||||
assert.equal(after.status, 'active', 'play did not resume');
|
||||
});
|
||||
|
||||
it('bots agree only once every human has, and never lead', () => {
|
||||
// "Bots will not disagree with the human. Humans get to vote first" (Jesse, 2026-08-28).
|
||||
const session = createSession(42, oneDay, ['Alice', 'Botty'], [1 as PlayerIndex]);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).status, 'awaitingExtension');
|
||||
assert.equal(
|
||||
frameOf(session, 0 as PlayerIndex).extensionVotes[1],
|
||||
null,
|
||||
'the bot voted before the human did',
|
||||
);
|
||||
|
||||
session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true });
|
||||
const after = frameOf(session, 0 as PlayerIndex);
|
||||
assert.equal(after.extraDays, 1, 'the bot did not follow the human into another Day');
|
||||
assert.equal(after.status, 'active');
|
||||
});
|
||||
|
||||
it('a human refusal ends it, and no bot overrides that', () => {
|
||||
const session = createSession(42, oneDay, ['Alice', 'Botty'], [1 as PlayerIndex]);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: false });
|
||||
const after = frameOf(session, 0 as PlayerIndex);
|
||||
assert.equal(after.status, 'finished');
|
||||
assert.equal(after.extraDays, 0);
|
||||
});
|
||||
|
||||
it('REGRESSION: an all-bot game ends rather than hanging on the question', () => {
|
||||
/**
|
||||
* `driveBots` loops on `currentActor`, which is null the moment the game stops to ask — so it
|
||||
* cannot cast the vote itself, and the bots' vote is driven separately. The first cut of that
|
||||
* driver returned early when there were no humans to follow, on the reasoning that a bot-only
|
||||
* table would decline through the ordinary path. It has no ordinary path: nothing ever asked
|
||||
* the bots, and an all-bot session sat on the question for ever without reaching `finished`.
|
||||
* Caught by `summary()`'s own "a finished game waits on nobody" test.
|
||||
*/
|
||||
const session = createSession(4242, oneDay, ['A', 'B'], [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).status, 'finished', 'the bots never answered');
|
||||
assert.equal(session.summary().waitingOn, null);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).extraDays, 0, 'bots voted themselves another Day');
|
||||
});
|
||||
|
||||
it('saves a game awaiting its vote as ACTIVE, so a restart resumes it', () => {
|
||||
// `server/index.ts` never loads a `finished` game back into memory. A game paused on the
|
||||
// extension question is waiting on its table, not over — persisting it as finished would strand
|
||||
// it on disk mid-decision.
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).status, 'awaitingExtension');
|
||||
assert.equal(session.exportSave().status, 'active', 'a paused game was saved as finished');
|
||||
assert.equal(session.summary().status, 'active');
|
||||
});
|
||||
|
||||
it('resumes a paused game from its history, vote and all', () => {
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true });
|
||||
|
||||
const resumed = resumeSession(session.exportSave());
|
||||
const a = frameOf(session, 0 as PlayerIndex);
|
||||
const b = frameOf(resumed, 0 as PlayerIndex);
|
||||
assert.deepEqual(b.extensionVotes, a.extensionVotes, 'the votes did not survive the replay');
|
||||
assert.equal(b.extraDays, a.extraDays, 'the extra Day did not survive the replay');
|
||||
assert.deepEqual(b.official, a.official, 'the official result did not survive the replay');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,13 @@ describe('a local session plays the same game as the calls it replaced', () => {
|
||||
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
||||
|
||||
const f = session.view();
|
||||
assert.equal(f.status, 'finished');
|
||||
/**
|
||||
* `awaitingExtension` since Gitea#11, not `finished`: both loops stop when there is no actor,
|
||||
* and a days-based ending now parks the game on the "play one more Day?" question rather than
|
||||
* ending it outright. What this test is actually about is unchanged — the two sides reach the
|
||||
* SAME position — and the assertion below is still the one carrying that.
|
||||
*/
|
||||
assert.equal(f.status, 'awaitingExtension');
|
||||
assert.equal(f.status, view(game).status);
|
||||
assert.equal(f.day, view(game).day);
|
||||
assert.deepEqual(f.cells, view(game).cells, 'the board differs across the boundary');
|
||||
|
||||
+75
-42
@@ -63,56 +63,73 @@ const gameDealtWith = (startingHand: StartingHand, seed = 1234) =>
|
||||
|
||||
describe('card catalogue (component 1)', () => {
|
||||
it('composes the deck from the design', () => {
|
||||
// Transcribed from docs/Deck cards2.xlsx. The sheet's own totals are "Sum other 115" and
|
||||
// "Total track 104", i.e. 219, plus 12 start cards for its grand total of 231.
|
||||
// THE WHOLE CATALOGUE IS docs/Deck cards5.xlsx NOW (Gitea#14). Sheet 5's own totals are
|
||||
// "Total track 48" and "Total other (in play) 107", i.e. 155 shuffled, plus 12 start cards for
|
||||
// its grand total of 167. Card for card, 84 rows agree with it exactly and the only ones that
|
||||
// do not are listed below — every one of them a deliberate hold, in one direction or the other.
|
||||
//
|
||||
// We are at 235 rather than 219 because of three deliberate departures, all flagged in
|
||||
// content.ts: 18 extra industry cards (Gap 12, industries 9 → 27), 7 extra office cards (Q12,
|
||||
// offices 7 → 14), and the 8 sharp curves taken back OUT. The first two were tuned against a deck
|
||||
// that had NO track in it, so both are due a re-measurement now that 96 track cards share the
|
||||
// draw.
|
||||
// EVERY COUNT IN THE CATALOGUE IS NOW THE SHEET'S. The two deliberate departures that used to
|
||||
// sit here are gone with Gitea#14 — the Q12 office doubling (offices 14 → 7) and the Gap 12
|
||||
// industry tripling (27 → 9) — because both were measured against a deck holding 96 track
|
||||
// cards, and sheet 5 halves that. content.ts carries the measurements that decided it.
|
||||
//
|
||||
// Two entries are dealt ZERO copies and kept in the catalogue so the design stays visible:
|
||||
// Poling, whose effect is "TBD in the source", and the sharp curves, whose only difference from
|
||||
// an ordinary curve was a Move cost nothing ever charged.
|
||||
// DECK_SIZE is the CATALOGUE, 235. The deck actually dealt is smaller: the 22 opponent-directed
|
||||
// cards are held back in every mode until they are implemented, so `buildDeck` returns 213.
|
||||
assert.equal(DECK_SIZE, 235);
|
||||
// We are at 143 rather than the sheet's 155 for ONE reason: the ten Safety, Event, Inspection
|
||||
// and Space-use cards sheet 5 adds are not built, and stay out until they are (Jesse,
|
||||
// 2026-08-26) — Cargo Theft, Civic Improvement, Civilian angel, Delayed Clearance, Flares 2,
|
||||
// Robbery, Service Delays, Shipper complaints, Strike, Union Hall 2. Twelve copies in all.
|
||||
//
|
||||
// NOTHING RUNS THE OTHER WAY ANY MORE. Every card sheet 5 does not list is dealt ZERO copies
|
||||
// rather than deleted, so the design stays visible and the rules stay implemented: the
|
||||
// Telegraph/Telephone/Radio ladder, Facing Point Locks (both), Flying Switch, Section House and
|
||||
// Vandalism, all removed from the design on purpose; Poling, whose effect the source records as
|
||||
// "TBD"; and the sharp curves, whose only difference from an ordinary curve was a Move cost
|
||||
// nothing ever charged — sheet 5 deals those zero too, so the catalogue and the design agree.
|
||||
//
|
||||
// DECK_SIZE is the CATALOGUE, 143. The deck actually dealt is smaller: the 20 opponent-directed
|
||||
// cards are held back in every mode until they are implemented, so `buildDeck` returns 123.
|
||||
assert.equal(DECK_SIZE, 143);
|
||||
assert.equal(buildDeck().length, SOLITAIRE_DECK_SIZE);
|
||||
});
|
||||
|
||||
it('matches the design deck composition exactly', () => {
|
||||
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
|
||||
assert.deepEqual(byCategory, {
|
||||
// 96, not the sheet's 104: the 8 SHARP CURVES are dealt zero copies. The only thing that made
|
||||
// one different from an ordinary curve was a Move cost that nothing ever charged, so they were
|
||||
// geometric duplicates taking 8 draws. Kept in the catalogue at zero, as Poling is.
|
||||
track: 96,
|
||||
// 14, not the sheet's 7 — Q12 office density; see OFFICE_PROFILES.
|
||||
office: 14,
|
||||
// 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES.
|
||||
industry: 27,
|
||||
// Sheet 5's track counts exactly (Gitea#14): 16 straights, 8+8 curves, 8+8 turnouts, and the
|
||||
// sharp curves dealt none — which is where the catalogue already had them, and where sheet 5
|
||||
// now puts them too.
|
||||
track: 48,
|
||||
// The sheet's 7 — the Q12 doubling came out in Gitea#14; see OFFICE_PROFILES.
|
||||
office: 7,
|
||||
// The sheet's 9 — the Gap 12 tripling came out in Gitea#14; see INDUSTRY_PROFILES.
|
||||
industry: 9,
|
||||
modifier: 23,
|
||||
train: 22,
|
||||
spaceUse: 12,
|
||||
enhancement: 18,
|
||||
mainlineModifier: 7,
|
||||
// 6, not 7 — Poling is dealt no copies until its rule is known.
|
||||
maneuver: 6,
|
||||
action: 10,
|
||||
spaceUse: 11,
|
||||
// 6 — the dispatching ladder and Facing Point Locks are dealt 0 copies (see
|
||||
// ENHANCEMENT_CARDS), and Interlocking, Water column and ABS Signals came down to the sheet's
|
||||
// single copies. What is left is the sheet's Enhancements exactly, bar Railroad crossing,
|
||||
// which sheet 5 moved here from the Action cards and which is still counted there below.
|
||||
enhancement: 6,
|
||||
// 5 — Facing Point Locks came out of the Mainline modifiers too.
|
||||
mainlineModifier: 5,
|
||||
// 3 — Red Flags is the sheet's 3; Flying Switch and Poling are both dealt none.
|
||||
maneuver: 3,
|
||||
// 9 — Vandalism is dealt none. The rest are opponent-directed and held out of every deck.
|
||||
action: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes opponent-directed cards from a solitaire deck', () => {
|
||||
// Q6 took Space-use and Action cards out of solitaire, where they have no legal target. They are
|
||||
// now out of the COMPETITIVE deck too, until they are implemented: `checkPlay` answers both
|
||||
// categories NOT_IMPLEMENTED, so dealing them would make 22 of 235 draws (9%) reject outright.
|
||||
// 206, not 213: the 22 opponent-directed cards come out, and so do the SEVEN that exist only to
|
||||
// answer them — Facing Point Locks (both the Enhancement and the Mainline modifier, 2 each), two
|
||||
// Water Columns and one Overpass. A defence with nothing to defend against is the same dead draw
|
||||
// as the attack would be. `SimpleCard.answers` names the pairing, so they return together.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 206);
|
||||
assert.equal(DEFENCE_ONLY_COPIES, 7);
|
||||
// categories NOT_IMPLEMENTED, so dealing them would be a dead draw.
|
||||
// 121, not 123: the 20 opponent-directed cards come out, and so do the TWO that exist only to
|
||||
// answer them — one Water Column and one Overpass. A defence with nothing to defend against is
|
||||
// the same dead draw as the attack would be. `SimpleCard.answers` names the pairing, so they
|
||||
// return together. It was seven until Gitea#14 dealt Facing Point Locks zero copies: a card at
|
||||
// zero is already out, so it no longer needs holding back.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 121);
|
||||
assert.equal(DEFENCE_ONLY_COPIES, 2);
|
||||
for (const c of DEFENCE_ONLY_CARDS) {
|
||||
assert.ok(c.answers, `${c.name} is held back without saying what it answers`);
|
||||
assert.ok(
|
||||
@@ -131,11 +148,12 @@ describe('card catalogue (component 1)', () => {
|
||||
});
|
||||
|
||||
it('deals track FROM the deck, at the sheet\'s counts', () => {
|
||||
// Column B of Deck cards2.xlsx, "Number in Deck": 32 straights, 16+16 curves, 16+16 turnouts —
|
||||
// and 4+4 sharp curves, which are dealt none. An earlier reading took the sheet's LAST column,
|
||||
// "Track Per Player" (26), as a separate stack outside the deck; it is the sheet's 104 shared out
|
||||
// among four players, not a second pile.
|
||||
assert.equal(TRACK_IN_DECK, 96);
|
||||
// Column B of Deck cards5.xlsx, "Number in Deck": 16 straights, 8+8 curves, 8+8 turnouts, and
|
||||
// 0+0 sharp curves. Sheet 2 had each of those at double, which is what the deck dealt until
|
||||
// Gitea#14. An earlier reading took the sheet's LAST column, "Track Per Player" (26), as a
|
||||
// separate stack outside the deck; it is the sheet's total shared out among four players, not a
|
||||
// second pile.
|
||||
assert.equal(TRACK_IN_DECK, 48);
|
||||
const deck = buildDeck();
|
||||
for (const t of TRACK_CARDS) {
|
||||
const n = deck.filter(
|
||||
@@ -146,11 +164,26 @@ describe('card catalogue (component 1)', () => {
|
||||
});
|
||||
|
||||
it('makes track the largest category in the deck', () => {
|
||||
// 96 of 235. Building a district is paid for in the industry or train you did not draw, which
|
||||
// 48 of 121. Building a district is paid for in the industry or train you did not draw, which
|
||||
// is the whole reason it matters that track is a card rather than a private supply.
|
||||
//
|
||||
// This asked for a THIRD of the deck until Gitea#14, which was only ever a rule of thumb. It
|
||||
// asks its own question now — is track still the biggest single thing you can draw — plus a
|
||||
// loose band, because the exact share is not settled yet and should not be pinned as though it
|
||||
// were. Sheet 5 puts track at 48 of the 155 cards it would have you shuffle, i.e. 31%; we read
|
||||
// 40% because the Space-use, Safety, Event and Inspection cards are held out, which concentrates
|
||||
// everything that is left. The share falls TOWARDS the sheet as those land, so the band is set
|
||||
// to hold across that whole journey rather than to be re-edited at each step.
|
||||
const deck = buildDeck();
|
||||
const track = deck.filter((c) => c.kind.kind === 'track').length;
|
||||
assert.ok(track > deck.length / 3, `track is only ${track} of ${deck.length} cards`);
|
||||
const counts = new Map<string, number>();
|
||||
for (const c of deck) counts.set(c.kind.kind, (counts.get(c.kind.kind) ?? 0) + 1);
|
||||
const track = counts.get('track') ?? 0;
|
||||
for (const [kind, n] of counts) {
|
||||
if (kind === 'track') continue;
|
||||
assert.ok(track > n, `${kind} has ${n} cards against track's ${track}`);
|
||||
}
|
||||
const share = track / deck.length;
|
||||
assert.ok(share > 0.28 && share < 0.45, `track is ${(share * 100).toFixed(1)}% of the deck`);
|
||||
});
|
||||
|
||||
it('has 12 timetabled trains, odd westbound and even eastbound', () => {
|
||||
|
||||
+62
-9
@@ -191,15 +191,26 @@ describe('the revenue chain works end to end (regression)', () => {
|
||||
// only because unloads were mis-scored as completed loads after one Laborer action instead of
|
||||
// four. Correcting that dropped mean revenue from 24.8 to ~4.6 and the win rate to zero, so
|
||||
// "did anyone win" is no longer a safe proxy for "does freight work".
|
||||
/**
|
||||
* TWO HUNDRED GAMES, up from forty (Gitea#3). Completed freight loads got scarcer when the
|
||||
* Mainline went onto the region model, and measurably so — on these seeds: 40 games yield 0
|
||||
* loads, 80 yield 3 (1 game), 120 yield 10 (4 games), 200 yield 21 (9 games). Forty could no
|
||||
* longer reach the precondition it exists to establish.
|
||||
*
|
||||
* WHY it got scarcer is not settled and is worth someone's attention rather than a guess — the
|
||||
* change speeds crossings up, which ought to put MORE trains through a district, not fewer.
|
||||
* Freight share of gross fell from 8% to 5% over 100 games across the same change. Recorded in
|
||||
* TODO.md under Play Balance; the assertion itself is untouched.
|
||||
*/
|
||||
const report = simulate({
|
||||
games: 40,
|
||||
games: 200,
|
||||
length: 'standard',
|
||||
mode: 'solitaire',
|
||||
players: ['bot'],
|
||||
policy: developerBot,
|
||||
});
|
||||
const freight = report.perGame.reduce((n, g) => n + g.revenue.freightLoad, 0);
|
||||
assert.ok(freight > 0, 'no freight load completed across 40 games');
|
||||
assert.ok(freight > 0, 'no freight load completed across 200 games');
|
||||
});
|
||||
|
||||
it('grows the Office Area off the Running Track, on either side', () => {
|
||||
@@ -351,7 +362,32 @@ describe('end-of-game statistics', () => {
|
||||
* rule that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
|
||||
* and so removing this line is what proves the bot has been fixed.
|
||||
*/
|
||||
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch'];
|
||||
/**
|
||||
* RED FLAGS JOINS IT, and the reason CHANGED with Gitea#19 — the exemption stays, but it no
|
||||
* longer means what it used to.
|
||||
*
|
||||
* IT USED TO MEAN "the bot will not take it": measured over 600 games under the old rule,
|
||||
* `maneuver.redFlags` was OFFERED 4,212 times and PLAYED 4. The card protected a stopped train
|
||||
* out on the Mainline, it was always available, and the bot simply declined it.
|
||||
*
|
||||
* SINCE Gitea#19 the bot would take it every time — `worthFlagging` accepts the out-of-phase
|
||||
* prompt unconditionally, because the engine only raises that prompt when an arrival is
|
||||
* certainly about to collide, so there is nothing left for the bot to judge. It still never
|
||||
* plays one. MEASURED after the redesign, 200 solitaire games: `redFlagsSet` fires ZERO times.
|
||||
*
|
||||
* The reason is now arithmetic rather than judgement, and it is worth writing down because it
|
||||
* says what would actually change it. The prompt needs two things to coincide — an arrival that
|
||||
* would collide (0.14 collisions per game, so roughly one game in seven) AND the district's
|
||||
* owner holding a Red Flags card at that moment, out of a three-card hand drawn from 121. The
|
||||
* bot also never plants a flag speculatively, which is the other half of the card and the half
|
||||
* a human would use to buy time for switching.
|
||||
*
|
||||
* So this canary is measuring deck luck, not reachability. `test/mainline-cards.test.ts`
|
||||
* exercises both halves of the rule end to end on a hand-built board, which is where the
|
||||
* behaviour is actually pinned. Removing this line still proves something worth proving — that
|
||||
* the bot has learned to plant a flag on purpose rather than only when handed one.
|
||||
*/
|
||||
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch', 'event redFlagsSet'];
|
||||
const found = anomalies(report.perGame);
|
||||
const never = found
|
||||
.filter((a) => a.severity === 'never')
|
||||
@@ -643,12 +679,17 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
|
||||
// Measured across 100 games: tank cars boarded a train 0.07 times a game and were dropped by a
|
||||
// crew ZERO times, while boxcars were 67% of every drop — and 23 of 79 waiting loads were
|
||||
// sitting at an industry that wanted a tank.
|
||||
// A HUNDRED GAMES, not thirty — the comment above says the original measurement used 100, and
|
||||
// the sample has to be that big to mean anything: measured now, a tank is set out in 3% of games
|
||||
// and a reefer in 5%. Thirty games passed on luck and stopped the moment the opening deal moved
|
||||
// which cards a seed puts in reach. Deterministic seeds, so this either holds or it does not.
|
||||
// THREE HUNDRED GAMES, up from a hundred, because the sheet's industry density (Gitea#14) makes
|
||||
// the rare commodities much rarer. Measured on these exact seeds, the game at which each type is
|
||||
// first set out by a crew: caboose 5, boxcar 12, hopper 37, reefer 44, coach 64, **tank 216**.
|
||||
//
|
||||
// A Refinery is one card in a hundred and fifty now, so a tank moving at all needs that card
|
||||
// drawn, placed, reached and worked. The old sample of 100 stopped covering it — not because the
|
||||
// rule broke, but because the deck did what the sheet asks. The sample follows the measurement
|
||||
// rather than the assertion being softened: tank is still the strict test, for the reason below.
|
||||
// Deterministic seeds, so this either holds or it does not.
|
||||
const dropped = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const s = createGame({
|
||||
id: `cs-${i}`,
|
||||
seed: 1000 + i * 7919,
|
||||
@@ -998,6 +1039,12 @@ describe('the bot does not lay track that cannot work (regression)', () => {
|
||||
// A TIE-BREAKER rather than a veto, so this is a rate and not a zero: forbidding it outright
|
||||
// measured WORSE (-0.62 revenue a game), while preferring the cleaner of two equally good
|
||||
// placements measured better and cut these from 28% of pieces to 7%.
|
||||
//
|
||||
// AND IT STAYS A TIE-BREAKER. Gitea#15 was filed as "track placements must connect" and briefly
|
||||
// became a rule here; RAR reversed it on review (2026-08-26) — a rail may stop dead against its
|
||||
// neighbour, and such a stub is useful as a siding to park cars on. What the engine must refuse
|
||||
// is a TRAIN crossing the gap, which is `exploreMoves`' job and is tested in `track.test.ts`.
|
||||
// So laying one of these is a preference, exactly as it was, and the rate below is the bar.
|
||||
let laid = 0;
|
||||
let dead = 0;
|
||||
/**
|
||||
@@ -1094,8 +1141,14 @@ describe('the freight figures count both halves (regression)', () => {
|
||||
* The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than
|
||||
* unloads started — and `unloads > 0` is only the precondition that makes the comparison mean
|
||||
* anything. Widening the sample restores the precondition without weakening the assertion.
|
||||
*
|
||||
* A HUNDRED AND FIFTY DEALS, up from forty, for the same reason as the commodity test above:
|
||||
* Gitea#14 put the deck on the sheet's industry density and completed unloads went with it.
|
||||
* Measured on these seeds, the first deal to EARN unload Revenue is number **46**, and 19 deals
|
||||
* in 400 earn any — so forty could not reach the precondition it exists to establish. 150 clears
|
||||
* it with room, and the assertion itself is untouched.
|
||||
*/
|
||||
for (let i = 0; i < 40; i++) {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const seed = 1000 + i * 7919;
|
||||
const s = createGame({
|
||||
id: `fu-${seed}`, seed,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* THE EVENT TALLY — Gitea#16's statistics, and the one property they depend on.
|
||||
*
|
||||
* "I don't know if we keep statistics on…" is the question the issue opens with. Nothing was being
|
||||
* kept; `GameState.tally` now is, folded from the event stream at the two places every event passes
|
||||
* through (`engine/tally.ts` explains which and why).
|
||||
*
|
||||
* The property that matters is EXACTLY ONCE. A statistic folded twice reads high and a statistic
|
||||
* folded nowhere reads zero, and both are indistinguishable from a quiet game when you are looking
|
||||
* at a results screen. So the central test here does not assert particular numbers: it plays real
|
||||
* games, collects every event the engine emitted along the way, counts them independently, and
|
||||
* checks the tally against that count. A fold hooked in the wrong place fails it whatever the seed.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance } from '../src/engine/advance.ts';
|
||||
import { tallyEvent } from '../src/engine/tally.ts';
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { developerBot } from '../src/sim/bot.ts';
|
||||
import type { GameEvent } from '../src/engine/events.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
|
||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
mode: 'solitaire',
|
||||
days: 3,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
});
|
||||
|
||||
/** Plays a whole game with the developer bot, keeping every event the engine produced. */
|
||||
function playKeepingEvents(
|
||||
seed: number,
|
||||
over: Partial<GameConfig> = {},
|
||||
names = ['Jesse'],
|
||||
): { state: GameState; events: GameEvent[] } {
|
||||
const state = createGame({ id: 'g', seed, config: baseConfig(over), playerNames: names });
|
||||
const events: GameEvent[] = [];
|
||||
for (let i = 0; i < 20_000; i++) {
|
||||
const r = advance(state);
|
||||
events.push(...r.events);
|
||||
if (state.status === 'finished') break;
|
||||
if (!r.needsInput) continue;
|
||||
|
||||
// The bot declines an extension, so this terminates on the timetable it was dealt (Gitea#11).
|
||||
const actor =
|
||||
state.status === 'awaitingExtension'
|
||||
? state.extensionVotes.findIndex((v) => v === null)
|
||||
: state.clock.pendingDecision !== null
|
||||
? state.clock.superintendent
|
||||
: state.clock.currentActor;
|
||||
if (actor === null || actor < 0) break;
|
||||
const options = legalActions(state, actor);
|
||||
if (options.length === 0) break;
|
||||
const applied = applyIntent(state, actor, developerBot.choose(state, actor, options));
|
||||
if (!applied.ok) break;
|
||||
events.push(...applied.events);
|
||||
}
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
/** Counts events the way `tally.ts` should have, without sharing any of its code. */
|
||||
function countIndependently(events: GameEvent[]) {
|
||||
let sum = 0;
|
||||
const n = (type: GameEvent['type']): number => events.filter((e) => e.type === type).length;
|
||||
for (const e of events) {
|
||||
if (e.type === 'carsCoupled' || e.type === 'carsDropped') sum += e.stock.length;
|
||||
}
|
||||
return {
|
||||
trainsCompleted: n('trainCompleted'),
|
||||
loadsCompleted: n('loadCompleted'),
|
||||
unloadsCompleted: n('unloadCompleted'),
|
||||
loadsStarted: n('loadStarted'),
|
||||
unloadsBegun: n('unloadBegan'),
|
||||
passengersBoarded: n('passengersBoarded'),
|
||||
passengersDetrained: n('passengersDetrained'),
|
||||
cardsDrawn: n('cardDrawn'),
|
||||
cardsPlayed: n('cardPlayed'),
|
||||
cardsDiscarded: n('cardDiscarded'),
|
||||
officeUpgrades: n('officeUpgraded'),
|
||||
flyingSwitches: n('flyingSwitch'),
|
||||
extrasStarted: n('extraStarted'),
|
||||
secondSections: n('secondSectionOrdered'),
|
||||
trainsHeld: n('trainHeld'),
|
||||
trainsDiverted: n('trainDiverted'),
|
||||
expediteFaults: n('expediteFault'),
|
||||
facilitiesUnjammed: n('facilityUnjammed'),
|
||||
dispatchBonusesUsed: n('dispatchBonusUsed'),
|
||||
clearancesRequested: n('clearanceRequested'),
|
||||
switchedCars: sum,
|
||||
};
|
||||
}
|
||||
|
||||
describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
for (const seed of [1, 7, 42, 116956197]) {
|
||||
it(`agrees with an independent count of the event stream — seed ${seed}`, () => {
|
||||
const { state, events } = playKeepingEvents(seed);
|
||||
const want = countIndependently(events);
|
||||
const t = state.tally;
|
||||
|
||||
assert.equal(t.trainsCompleted, want.trainsCompleted, 'trains through the Division');
|
||||
assert.equal(t.loadsCompleted, want.loadsCompleted, 'loads made up');
|
||||
assert.equal(t.unloadsCompleted, want.unloadsCompleted, 'loads broken');
|
||||
assert.equal(t.loadsStarted, want.loadsStarted, 'loads started');
|
||||
assert.equal(t.unloadsBegun, want.unloadsBegun, 'unloads begun');
|
||||
assert.equal(t.passengersBoarded, want.passengersBoarded, 'passengers boarded');
|
||||
assert.equal(t.passengersDetrained, want.passengersDetrained, 'passengers detrained');
|
||||
assert.equal(t.cardsDrawn, want.cardsDrawn, 'cards drawn');
|
||||
assert.equal(t.cardsPlayed, want.cardsPlayed, 'cards played');
|
||||
assert.equal(t.cardsDiscarded, want.cardsDiscarded, 'cards discarded');
|
||||
assert.equal(t.officeUpgrades, want.officeUpgrades, 'offices upgraded');
|
||||
assert.equal(t.flyingSwitches, want.flyingSwitches, 'flying switches');
|
||||
assert.equal(t.extrasStarted, want.extrasStarted, 'extras started');
|
||||
assert.equal(t.secondSections, want.secondSections, 'second sections');
|
||||
assert.equal(t.trainsHeld, want.trainsHeld, 'trains held');
|
||||
assert.equal(t.trainsDiverted, want.trainsDiverted, 'trains diverted');
|
||||
assert.equal(t.expediteFaults, want.expediteFaults, 'expedite faults');
|
||||
assert.equal(t.facilitiesUnjammed, want.facilitiesUnjammed, 'facilities unjammed');
|
||||
assert.equal(t.dispatchBonusesUsed, want.dispatchBonusesUsed, 'dispatch bonuses');
|
||||
assert.equal(t.clearancesRequested, want.clearancesRequested, 'clearances requested');
|
||||
assert.equal(t.carsCoupled + t.carsDropped, want.switchedCars, 'cars switched');
|
||||
});
|
||||
}
|
||||
|
||||
it('actually counted something — a tally of zeroes would pass the check above vacuously', () => {
|
||||
// The trap `stats.ts` warns about in its own doc comment: "this never happened" is a finding,
|
||||
// not something to scroll past. A fold hooked nowhere at all agrees perfectly with an
|
||||
// independent count of an event stream nobody looked at, so the exactly-once tests above cannot
|
||||
// catch it on their own.
|
||||
//
|
||||
// ASSERTED ON WHAT THE DEVELOPER BOT ACTUALLY DOES, which is not much: `node src/sim/harness.ts
|
||||
// 12 standard` means 1.1 Revenue per game at an 8% freight share and loses every game on the
|
||||
// revenue floor, and it goes whole games without coupling a single car. That is a known
|
||||
// property of the bot (TODO.md, Bot Performance) and not this fold's business — so this test
|
||||
// asserts on traffic and cards, which happen in every game, rather than on switching, which
|
||||
// would make it a bot-strength test wearing a statistics test's clothes.
|
||||
const { state, events } = playKeepingEvents(1);
|
||||
assert.ok(events.length > 500, `only ${events.length} events — the game barely ran`);
|
||||
assert.ok(state.tally.cardsDrawn > 0, 'no cards were drawn all game');
|
||||
assert.ok(state.tally.trainsCompleted > 0, 'no train ever left the Division');
|
||||
assert.ok(state.tally.cardsPlayed > 0, 'no card was ever played');
|
||||
});
|
||||
|
||||
it('splits Revenue into what was earned and what was given back', () => {
|
||||
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
|
||||
// lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually
|
||||
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
|
||||
// being told apart rather than both sitting at zero.
|
||||
for (const seed of [1, 7, 42]) {
|
||||
const { state } = playKeepingEvents(seed);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.equal(
|
||||
me.revenueGained - me.revenueLost,
|
||||
state.players[0]!.revenue,
|
||||
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
|
||||
);
|
||||
}
|
||||
const { state } = playKeepingEvents(42);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted');
|
||||
});
|
||||
|
||||
it('records a Circus set-up as the one-off it is, not as a streak', () => {
|
||||
/**
|
||||
* `trainStoodStill` is NOT "this train did not move this Stage". It fires only for a train whose
|
||||
* profile sets `stopEarnsPoint` — the X18 Circus — and `advance.ts` claims it once per train
|
||||
* with `stopPointClaimed`, so it can never fire twice for the same one.
|
||||
*
|
||||
* Gitea#16 asks for "longest engine sat on a siding" and its comment assumed this event would
|
||||
* answer it. It cannot, and a streak folded from it would have read "1 Stage" for ever. Pinned
|
||||
* here so that the day a real per-Stage signal is added, whoever adds it finds this test rather
|
||||
* than the old wrong assumption.
|
||||
*/
|
||||
const s = createGame({ id: 'g', seed: 1, config: baseConfig(), playerNames: ['Jesse'] });
|
||||
const feed = (e: GameEvent): void => tallyEvent(s, e);
|
||||
feed({ type: 'trainStoodStill', trainNumber: 18, where: '(0,0)' });
|
||||
assert.deepEqual(s.tally.circusStops, [{ trainNumber: 18, where: '(0,0)' }]);
|
||||
assert.ok(!('longestStand' in s.tally), 'a streak that cannot be computed is being reported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the official result freezes the tally with it (Gitea#11 + #16)', () => {
|
||||
it('records the statistics as they stood when the timetable ran out', () => {
|
||||
const { state } = playKeepingEvents(1);
|
||||
assert.ok(state.official, 'no official result was recorded');
|
||||
// Nothing was played after the ending in this game, so the two agree — which is the check that
|
||||
// the freeze happens AFTER the last batch of events is folded rather than before it.
|
||||
assert.equal(state.official!.tally.trainsCompleted, state.tally.trainsCompleted);
|
||||
assert.ok(state.official!.tally.cardsDrawn > 0, 'the frozen tally is empty');
|
||||
});
|
||||
|
||||
it('keeps the frozen copy still while the live tally moves on', () => {
|
||||
const s = createGame({ id: 'g', seed: 1, config: baseConfig(), playerNames: ['Jesse'] });
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
const frozen = s.official!.tally.cardsDrawn;
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
tallyEvent(s, { type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'x' });
|
||||
assert.equal(s.tally.cardsDrawn, frozen + 1, 'the live tally did not move');
|
||||
assert.equal(s.official!.tally.cardsDrawn, frozen, 'the frozen tally moved with it');
|
||||
});
|
||||
});
|
||||
@@ -540,6 +540,100 @@ describe('placement and drop-off', () => {
|
||||
assert.ok(!canPlaceAt(area, at(-1, 1), straight()), 'an east-west straight cannot meet a 45° leg');
|
||||
});
|
||||
|
||||
describe('a rail that stops dead against its neighbour (Gitea#15)', () => {
|
||||
/**
|
||||
* REPORTED, THEN REVERSED. The issue first read "if a card is placed in that space, it MUST
|
||||
* connect", against a right-hand curve laid at (1,-1) with an Ice House above it and a turnout
|
||||
* with a north-facing leg below. **RAR reviewed it and ruled the other way (2026-08-26): the
|
||||
* placement is fine, and a stub like that has a use — a siding to park cars on.**
|
||||
*
|
||||
* "We need to confirm, however, that trains are not allowed to traverse from the turnout below
|
||||
* to that right-hand curve since the tracks do not connect." That is what these tests are: the
|
||||
* rule lives in MOVEMENT, not in placement.
|
||||
*
|
||||
* The save cannot carry this any more — Gitea#14 took the deck from 206 cards to 121, so its
|
||||
* card ids no longer exist and the history stops at the first `card.play`. The geometry is what
|
||||
* mattered, and it is rebuilt here directly.
|
||||
*/
|
||||
/** A Modifier card — an Ice House. Not track: no ports on any edge. */
|
||||
const modifierCard = (): TrackCard => ({
|
||||
geometry: { kind: 'modifier', modifier: 'iceHouse' },
|
||||
baseOperationalRail: false,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
/** A Grocer's Warehouse — a Facility, so a plain east-west through track. */
|
||||
const warehouse = (): TrackCard => ({
|
||||
geometry: { kind: 'facility', facility: 'grocersWarehouse' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* The reported district. `withCurve` puts the disputed right-hand curve on the square; without
|
||||
* it, the square is empty and the placement itself is under test.
|
||||
*
|
||||
* The turnout's leg goes NORTH on the `nw_se` diagonal; the curve is `ne`, which is `ne_sw` and
|
||||
* has no south port at all. Two reasons the two do not join, either of which is enough.
|
||||
*/
|
||||
const board = (withCurve: boolean): OfficeArea =>
|
||||
areaFrom(
|
||||
{
|
||||
[coordKey(at(0, -1))]: turnout({ stem: 'w', through: 'e', diverge: 'n' }),
|
||||
[coordKey(at(0, 0))]: officeCard(),
|
||||
[coordKey(at(1, 0))]: warehouse(),
|
||||
[coordKey(at(2, -1))]: modifierCard(),
|
||||
...(withCurve ? { [coordKey(at(1, -1))]: curve('ne') } : {}),
|
||||
},
|
||||
at(0, 0),
|
||||
);
|
||||
|
||||
it('allows the reported placement, which connects on one side and nothing else', () => {
|
||||
// RAR's ruling. The curve joins the warehouse to its east; its north leg faces an Ice House
|
||||
// that carries no rail, and the turnout below faces its portless south edge. All legal.
|
||||
assert.ok(canPlaceAt(board(false), at(1, -1), curve('ne')), 'the reported play was refused');
|
||||
});
|
||||
|
||||
it('will not let a train cross from the turnout below onto that curve', () => {
|
||||
// The confirmation the issue actually asks for. Running west out of the Office and into the
|
||||
// turnout, the 45° leg goes north — and stops at the curve's blank south edge.
|
||||
const dests = reachableDestinations(ctxFor(board(true)), at(0, 0), 'w');
|
||||
assert.ok(!has(dests, 1, -1), 'a train drove across rails that do not meet');
|
||||
});
|
||||
|
||||
it('still reaches the curve from the side that DOES join', () => {
|
||||
// Otherwise the test above would pass on a card that is simply unreachable, which proves
|
||||
// nothing. East of the curve is the warehouse, and east-west edges always meet.
|
||||
const dests = reachableDestinations(ctxFor(board(true)), at(1, 0), 'w');
|
||||
assert.ok(has(dests, 1, -1), 'the curve was unreachable from the side that joins');
|
||||
});
|
||||
|
||||
it('will not let a train cross a north edge onto a card with no rail at all', () => {
|
||||
// The Ice House above. A Modifier is scenery beside the rails — Jesse confirmed a rail may
|
||||
// point at a building — so what stops a train is the same `joins` test, not a placement rule.
|
||||
const dests = reachableDestinations(ctxFor(board(true)), at(1, 0), 'w');
|
||||
assert.ok(!has(dests, 2, -1), 'a train drove into an Ice House');
|
||||
});
|
||||
|
||||
it('still allows an exit that faces a BLANK square', () => {
|
||||
// Unchanged by the reversal, and the reason a district can grow at all: a turnout laid on the
|
||||
// Running Track with nothing yet beside its diverging leg is a perfectly good play.
|
||||
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
||||
assert.ok(
|
||||
canPlaceAt(area, at(0, 1), turnout({ stem: 'w', through: 'e', diverge: 'n' })),
|
||||
'a turnout whose leg faces open space was refused',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a card that connects to nothing', () => {
|
||||
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
||||
assert.ok(!canPlaceAt(area, at(3, 3), straight()), 'orphaned track is never legal');
|
||||
|
||||
@@ -74,6 +74,8 @@ const west = (s: GameState, n = 2): GridCoord => {
|
||||
};
|
||||
|
||||
const boxcar = (loaded = false): RollingStock => ({ type: 'boxcar', loaded });
|
||||
/** As `ROLLING_STOCK_SUPPLY` mints them: there is no empty caboose in the game. */
|
||||
const caboose = (): RollingStock => ({ type: 'caboose', loaded: true });
|
||||
const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -141,6 +143,23 @@ describe('§7 — what a train may couple', () => {
|
||||
'EMPTIES_ONLY',
|
||||
);
|
||||
});
|
||||
|
||||
it('X22 Pee-Dee may still couple a caboose, which is not a load (Gitea#8)', () => {
|
||||
// Every caboose in the game is minted `loaded: true` because the supply table's loaded/empty
|
||||
// split doubles as a piece count. Taken literally that left the per-diem train unable to pick
|
||||
// up ANY caboose, its own included: set it out at the end of a sweep and it was stranded there.
|
||||
const s = game();
|
||||
switching(s, 22, true, [], [caboose()]);
|
||||
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
|
||||
|
||||
// The restriction itself is untouched — a loaded car alongside the caboose still refuses.
|
||||
const withLoad = game();
|
||||
switching(withLoad, 22, true, [], [caboose(), boxcar(true)]);
|
||||
assert.equal(
|
||||
check(withLoad, 0, { type: 'switch.move', trayId: 't', to: west(withLoad), reverse: false }),
|
||||
'EMPTIES_ONLY',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('§7 — one freight car per location (trains 3/4)', () => {
|
||||
|
||||
+1031
-106
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user