Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ab25e320c | ||
|
|
441447648d | ||
|
|
603d38602c |
+300
@@ -19,6 +19,306 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.7.2 — 2026-08-26
|
||||||
|
|
||||||
|
Five issues off the tracker. Two are engine bugs a player hit at the board, two are the design
|
||||||
|
catching up with rulings from RAR that the code had got wrong or never had, and one is the Division
|
||||||
|
map being redrawn. The first four ship as **0.4.9h** on the 0.4.9 line; the map does not — it is a
|
||||||
|
multiplayer redesign, and pushing one of those into a build people are mid-playtest on invalidates
|
||||||
|
the feedback.
|
||||||
|
|
||||||
|
### A 45° leg is part of the row (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 both places that walk that row asked the PORT which end they were at: `exploreMoves`
|
||||||
|
reversed the row for an `'e'` entry and 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 — a `sw` curve's south leg is
|
||||||
|
the row's EAST end and an `se` curve's south leg is its WEST end.
|
||||||
|
|
||||||
|
`rowEndAt` answers it from the card, and `cutTowards` is narrowed from `Port` to `'e' | 'w'` so the
|
||||||
|
type checker forces every caller to resolve rather than leaving a fourth to be found later.
|
||||||
|
|
||||||
|
**THE SECOND HALF WAS LIVE TOO, and is a report we had failed to reproduce.** With `cutTowards`
|
||||||
|
returning nothing for a leg, a crew standing on a curve pulled out through it and DROVE AWAY LEAVING
|
||||||
|
ITS OWN CUT STANDING — against §A.4's mandatory coupling. That is "cars left behind when backing up
|
||||||
|
over them", carried as NOT REPRODUCED since 2026-08-22; the earlier sweep had tried that case only
|
||||||
|
with an east or west exit. The report's second sentence — "I can later drive right through them" —
|
||||||
|
is still unexplained and still open.
|
||||||
|
|
||||||
|
### The deck is the sheet (Gitea#14)
|
||||||
|
|
||||||
|
`docs/Deck cards5.xlsx` is in the repo, and every count in the catalogue is now its count. Track is
|
||||||
|
halved: 16 straights, 8+8 curves, 8+8 turnouts, sharp curves at zero — which is where they already
|
||||||
|
were, and where sheet 5 independently puts them.
|
||||||
|
|
||||||
|
**BOTH LONG-STANDING MULTIPLIERS COME OUT WITH IT.** The Q12 office doubling (14 → 7) and the Gap 12
|
||||||
|
industry tripling (27 → 9) were measured against a deck holding 96 track cards, and halving the track
|
||||||
|
turns the tripling 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 multipliers / 48
|
||||||
|
with them / 48 without: reefer cars set out by a crew **49 / 0 / 39**, mean revenue **−0.20 / +0.22 /
|
||||||
|
+0.27**. The middle column wipes out the reefer chain completely. The sheet's own density is the best
|
||||||
|
of the three on both counts.
|
||||||
|
|
||||||
|
Q12's own failure was re-measured rather than assumed: 43 of 100 games now never upgrade off a
|
||||||
|
Whistle Post, up from 25 — but they average −0.2 revenue against +1.4 for games that do, where the
|
||||||
|
gap used to be −6.0 against −0.4, and collisions fell from 26 per 100 games to 6. Staying at a
|
||||||
|
Whistle Post is now common and survivable rather than rare and fatal.
|
||||||
|
|
||||||
|
Everything 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 dispatching ladder, Facing
|
||||||
|
Point Locks, Flying Switch, Section House and Vandalism — all confirmed by Jesse as deliberate
|
||||||
|
removals — plus Poling and the sharp curves, which were already there. Card for card, **84 rows agree
|
||||||
|
with the sheet**; the only ones that do not are the ten Safety, Event, Inspection and Space-use cards
|
||||||
|
it adds that are not built, held out until they are.
|
||||||
|
|
||||||
|
### A rail may stop dead against its neighbour (Gitea#15)
|
||||||
|
|
||||||
|
Filed as "track placements must connect", against a right-hand curve laid with its north leg against
|
||||||
|
an Ice House and the turnout below pointing at its portless south edge. **RAR reversed it on review:**
|
||||||
|
the placement is fine, and a stub like that has a use — a siding to park cars on. What he asked to
|
||||||
|
confirm instead is that no train can traverse the gap.
|
||||||
|
|
||||||
|
It could not, and cannot: `exploreMoves` gates every hop on `joins`, which tests both ports AND that
|
||||||
|
two 45° legs lie on the same diagonal. That was already true and simply unpinned; `track.test.ts`
|
||||||
|
now holds it against the reported geometry, including a check that the curve IS reachable from the
|
||||||
|
side that joins, so the negative test cannot pass on a card that is merely unreachable.
|
||||||
|
|
||||||
|
A per-edge placement check was written and then taken out, along with a matching guard on
|
||||||
|
`checkTurnoutUpgrade`. Both are documented in place as deliberately absent, because this is exactly
|
||||||
|
the rule someone will "fix" again. A Modifier is scenery (Jesse): a rail pointing at a building is
|
||||||
|
fine.
|
||||||
|
|
||||||
|
### Crossing a Mainline card is regions, not miles per hour (Gitea#3)
|
||||||
|
|
||||||
|
**REPORTED:** a train taking two Stages to clear Double Track, which prints 60. RAR, on review:
|
||||||
|
"Ignore speed signs, they are just graphics. Regions shown on cards indicate how many stages it takes
|
||||||
|
to cross."
|
||||||
|
|
||||||
|
Two recorded rulings are superseded together — Q1, that the printed 60/30 are crossing time, and Q2,
|
||||||
|
that a Slow train adds a Stage to every card. Q2 is what produced the report. Cards carry a region
|
||||||
|
count now and **where a train STARTS is what varies**: Plains 1, Double Track 1, Trestle 1, Curves 2,
|
||||||
|
Tunnel 2, Heavy Grade 3. Fast/Slow is read on Hilly and nowhere else — and Hilly no longer reads the
|
||||||
|
consist, which had a fast freight crossing slower than a slow passenger train. The grade 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 road: a train through starts past it, one arriving to find the siding occupied
|
||||||
|
takes it and runs a region behind, and an Extra beginning its run at an Interchange starts there.
|
||||||
|
|
||||||
|
**THREE THINGS WERE MISSING RATHER THAN WRONG.** With Plains now one region, a following train is in
|
||||||
|
the same place as the train ahead the moment it enters — and nothing tested that, because the
|
||||||
|
catch-up check sits inside `stagesRemaining > 1`, which a one-Stage crossing never reaches. That is
|
||||||
|
the case ABS describes ("the train moving onto the card is instead held back") and it needed
|
||||||
|
building. ABS was also holding SILENTLY on the Office and Division Point paths, so the one card whose
|
||||||
|
purpose is preventing a wreck did its job invisibly. And `collide` never released the A/D track,
|
||||||
|
which did not matter while every collision happened out on the road — a train destroyed as it LEAVES
|
||||||
|
is still standing at the Office.
|
||||||
|
|
||||||
|
The Uncontrolled Siding was marked "trains may pass", which skipped the collision test altogether and
|
||||||
|
made the siding do nothing at all. It is `false` now, with the siding entry doing the work.
|
||||||
|
|
||||||
|
Measured: on the Mainline cards of a 3-player Division, a fast train pays ~5.6 Stages against ~5.4
|
||||||
|
before and a slow one ~6.0 against ~9.4. **Fast traffic is unchanged; slow traffic is about a third
|
||||||
|
quicker**, and the gap across a Division collapses from roughly four Stages to under one — so Q2's
|
||||||
|
recorded consequence, that every Slow train is still on the road when the next Day begins holding its
|
||||||
|
Crew Tray, no longer holds and `players + 3` is due a re-examination.
|
||||||
|
|
||||||
|
Two effects are recorded in `TODO.md` rather than acted on: freight share fell 8% → 5% and completed
|
||||||
|
freight loads got scarcer, which runs against the obvious expectation and nobody knows why yet; and
|
||||||
|
the bot stopped playing Red Flags — offered 4,212 times in 600 games, played 4.
|
||||||
|
|
||||||
|
### The Division is one row (Gitea#18)
|
||||||
|
|
||||||
|
**REPORTED:** "track design should not be horseshoe / square, but a single row… Division map should
|
||||||
|
not show any office area detail."
|
||||||
|
|
||||||
|
It was 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 one. Three reports came out of
|
||||||
|
that, and the one that decided it is that **east stopped being to the right**: a player's east could
|
||||||
|
be drawn south, west or north depending which lane their district landed in, on a map whose whole job
|
||||||
|
is saying which way a train is going. `WDP · ML · Office · … · ML · EDP`, left to right, and the
|
||||||
|
buffer stops simply face outward at the two ends.
|
||||||
|
|
||||||
|
An Office no longer expands into its Running Track either, so the map stops carrying every straight,
|
||||||
|
turnout and Limits sign of every district — that is the Office map's job, and it draws them properly,
|
||||||
|
with the rails. It also stops the Division map growing sideways every time somebody lays a card.
|
||||||
|
|
||||||
|
The trains stay, in two registers: those holding an A/D track ON the rail, and crews switching in the
|
||||||
|
district UNDER it — the distinction drawn as position rather than colour, because that is where those
|
||||||
|
trains are. Each chip carries its number, a direction arrow and a car count, with the consist and the
|
||||||
|
train's printed card on the tooltip. Every district cell is four chips two-by-two regardless of tier,
|
||||||
|
because a Whistle Post with one A/D track can still hold four trains when three of them are crews,
|
||||||
|
and sizing by occupancy is what "The Roster Pass" fixed. 1,580px wide at four players against 842
|
||||||
|
before, drawn at 1:1 so it scrolls rather than shrinking — Jesse's call, "zooming and scrolling
|
||||||
|
worked fine".
|
||||||
|
|
||||||
|
This supersedes `TODO.md` item 24 outright and makes 19, 20, 21, 22, 25 and 26 irrelevant; item 27 is
|
||||||
|
fixed.
|
||||||
|
|
||||||
|
### Six test fixtures that pinned a seed and meant "a game like this"
|
||||||
|
|
||||||
|
Every one of them broke on a rules change and none was about the rule that changed: the deck's SIZE
|
||||||
|
moves the RNG stream, so any count change re-deals every fixture that names a seed.
|
||||||
|
`mainline-cards` now searches for a Division holding a single-track card, `multiplayer` for a game
|
||||||
|
that reaches Day 3, `web` clicks every play verb rather than assuming the first goes on the board and
|
||||||
|
searches for a seed that builds a board, and the `sim` commodity samples were re-measured — a tank is
|
||||||
|
first set out at game 216 now, unload Revenue at game 46. A seventh, `mainline-cards`' `hand()`
|
||||||
|
helper, threw when a card was not in the deck, so dealing Flying Switch zero copies took five passing
|
||||||
|
tests of an unchanged rule with it; it mints one now, which is the point of keeping a row at zero.
|
||||||
|
|
||||||
|
**And one real bug, in a test.** `the game conserves Rolling Stock` went red claiming the engine had
|
||||||
|
conjured three cars. The engine was right: Gap 2c sends a wreck's cabooses to the Division Yard and
|
||||||
|
everything else to Classification, destroying nothing. The test subtracted the wreck's consist from
|
||||||
|
the expected census — a rule the engine does not have — and the branch had never executed, because
|
||||||
|
none of its six seeds had ever collided. The census is held flat unconditionally now, which is both
|
||||||
|
the true invariant and stricter than what it replaced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.7.1 — 2026-08-25
|
||||||
|
|
||||||
|
Four issues off the Gitea tracker, all of them things a player saw at the board. Two are engine or
|
||||||
|
page bugs, one is a rules ruling that supersedes a ruling from three days earlier, and the fourth
|
||||||
|
turned out not to be a bug at all — the fix there is that the game now says so. The same four ship
|
||||||
|
as **0.4.9g** on the 0.4.9 line.
|
||||||
|
|
||||||
|
### A caboose is not a load (Gitea#8)
|
||||||
|
|
||||||
|
**REPORTED:** X22 Pee-Dee could not couple a caboose — including the one it was made up with. Drop
|
||||||
|
it at the end of a sweep and it was stranded there, which makes a train whose whole card is a
|
||||||
|
restriction ("may only pick up MTs") unplayable rather than merely restricted.
|
||||||
|
|
||||||
|
`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 there
|
||||||
|
is no white caboose to make a train up from. So `.loaded` carries two meanings, and the second one
|
||||||
|
escaped in exactly one place: `pickUpEmptiesOnly`'s `fresh.some((c) => c.loaded)`. Every other read
|
||||||
|
of the flag in `apply.ts` is already scoped to a coach or to a named car type.
|
||||||
|
|
||||||
|
`carriesLoad` now answers the question the rule is actually asking — a caboose carries the crew, not
|
||||||
|
freight, so it is never a load — and the restriction itself is untouched: a loaded car alongside the
|
||||||
|
caboose still refuses. `trainRules` says so on the card ("A caboose is not a load"), because a player
|
||||||
|
reading "EMPTIES ONLY" has no way to know which reading the game took.
|
||||||
|
|
||||||
|
### The Day rolls over and says so (Gitea#10)
|
||||||
|
|
||||||
|
**REPORTED:** "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep
|
||||||
|
track of time."
|
||||||
|
|
||||||
|
Nothing on screen was wrong. The clock, the turn chart and the timetable all said which Day it was.
|
||||||
|
What is wrong is WHEN it changes: a Day turns over inside the phases that run themselves, so it
|
||||||
|
happens between one click and the next, while the player is watching the board and waiting for their
|
||||||
|
turn. The two transient signals the page already had are both gone before that — the phase banner at
|
||||||
|
2.6s, the announcement flash at 4.2s. A modal is the whole request: it stops, and it waits.
|
||||||
|
|
||||||
|
`dayEndHtml` writes it from the frame AFTER the rollover, so the Day that ended is `f.day - 1`.
|
||||||
|
It carries the standings in **Revenue order** rather than seat order (the question at the end of a
|
||||||
|
Day is who is ahead), the Days left to run, and the combined target — reported against the whole
|
||||||
|
table's Revenue, because `minCombinedRevenue` is a combined floor and one player's score against a
|
||||||
|
four-player target reads as hopeless when the table is comfortably ahead. Collisions appear only
|
||||||
|
where §3.4 actually scores them: competitive and co-op, and only when a dial is non-zero. A solitaire
|
||||||
|
game carries the default dials and enforces neither, so printing a collision budget there would put a
|
||||||
|
rule on screen that this game does not have.
|
||||||
|
|
||||||
|
Three suppressions, each of them a way it would otherwise lie:
|
||||||
|
|
||||||
|
- **the first frame** — arriving in a game already on Day 3 is not Day 2 ending, and a page reloaded
|
||||||
|
mid-game would announce a rollover that happened before it was watching;
|
||||||
|
- **the Day going DOWN** — that is Undo stepping back across the rollover, not a Day passing. Undo
|
||||||
|
also clears `lastDay`, so replaying forward through the same rollover does not announce it twice;
|
||||||
|
- **the Day the game ends on** — the outcome panel is the thing to read then. Verified by playing a
|
||||||
|
full solitaire game through: the dialog fires four times in a 5-Day game, not five.
|
||||||
|
|
||||||
|
### A Timetabled train may be thrown away (Gitea#9)
|
||||||
|
|
||||||
|
**REPORTED:** "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."
|
||||||
|
|
||||||
|
This **supersedes Gitea#6**, shipped three days earlier in 0.6.2, which made every train card
|
||||||
|
unconditionally undiscardable. Two things narrow it:
|
||||||
|
|
||||||
|
- an **Extra** is still never discardable. 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;
|
||||||
|
- the Timetabled half is a **New Game setting** — `discardTimetabled`, on by default — because
|
||||||
|
Jesse's reasoning is explicitly about games run LONGER than five days, and a five-Day game may well
|
||||||
|
want Gitea#6's pressure back. Jesse asked for it as a setting on this line and as the plain rule on
|
||||||
|
the 0.4.9 line, which has no scaffolding for one; both play the same game at main's default.
|
||||||
|
|
||||||
|
**The second half of the ruling needed no code at all.** "Someone else might pick it up" — a discard
|
||||||
|
already goes face-up onto a Department pile, and a Department pile is exactly what a rival draws
|
||||||
|
from. Only the first half was a change.
|
||||||
|
|
||||||
|
**One place decides, and the card says which rule refused.** `keepReason` returns the sentence a
|
||||||
|
player should read, or `null` if the card may go; `check`, the hand panel and the blocked "End Local
|
||||||
|
Operations" button all ask it. 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 the panel that used to
|
||||||
|
hard-code one of them would now tell half the players the wrong thing. It reaches the page as the
|
||||||
|
Frame's `handKeepWhy`, replacing text `panels.ts` and `main.ts` each wrote for themselves.
|
||||||
|
|
||||||
|
**Two places would have dropped the setting silently**, both found by looking rather than by `tsc` —
|
||||||
|
`HouseRuleOverrides`' fields are all optional, so omitting one compiles and falls back to the
|
||||||
|
default. The New Game dialog's close handler builds its own `houseRules` object (the comment
|
||||||
|
directly above it warns of exactly this: "a setting missing from here is a setting the dialog
|
||||||
|
silently discards"), and `presets.ts`'s Frame-to-config path, which is how a JOINER is shown someone
|
||||||
|
else's game — a setting dropped there shows them a rule the table is not playing.
|
||||||
|
|
||||||
|
**Gitea#6's corner survives, narrowed.** A hand of four undiscardable trains still has exactly one
|
||||||
|
legal way on — play one — with nothing in the engine computing "you must play a train". With the
|
||||||
|
setting on, the only hand that reaches it is four Extras; with it off, any four trains, as before.
|
||||||
|
Both are pinned by tests.
|
||||||
|
|
||||||
|
### Why nothing is moving on the platform (Gitea#2)
|
||||||
|
|
||||||
|
**REPORTED:** "The Sparrow pulled into the station with two loaded coaches. 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 is not deviating from the rules**, and this was checked step by step against the
|
||||||
|
reported save. §9.2 discards the white coach into the Classification Yard on boarding and draws one
|
||||||
|
from the Division Yard on de-training; §2.2 returns the Classification Yard only when the Division
|
||||||
|
Yard is empty. All three are implemented exactly. What bites is the interaction: both halves of every
|
||||||
|
passenger cycle consume coaches one-way, and a single global refill condition over a pile of six
|
||||||
|
commodities means they do not come back. Traced over the reported game the coach pool goes 8+/8− on
|
||||||
|
Day 1 to 0+/1− by Day 5, with eight coaches stranded in Classification behind ~60 other cars.
|
||||||
|
|
||||||
|
**Jesse's ruling is that the shortage stays** — "it is possible to run out, that's part of the
|
||||||
|
strategy" — so the three balance options written up in `TODO.md` are declined rather than deferred.
|
||||||
|
|
||||||
|
What was unambiguously a bug is that the game said **nothing**. A Porter action that cannot be taken
|
||||||
|
is simply absent from the menu, and `impediments()` — the panel whose entire job is "why is nothing
|
||||||
|
moving?" — opened with `if (!f || f.kind !== 'freight') continue`, so a platform had never had
|
||||||
|
anything to say for itself. The player was not merely blocked; he was given no reason.
|
||||||
|
|
||||||
|
A Passenger Facility now reports both directions: passengers standing with no train to take them, a
|
||||||
|
train whose card bars Porters from working it, a Terminals-only train at a lesser Office, every coach
|
||||||
|
already full, the red slots full, the same-district rule, and the coach shortage itself — that last
|
||||||
|
one naming how many coaches are sitting in Classification and the condition that brings them back,
|
||||||
|
because a yard visibly full of cars that will not yield one coach is the state that looks like a
|
||||||
|
broken game. The reason text comes from `passengerRefusal`, the engine's own predicate (exported for
|
||||||
|
this), so what is on screen is the rule that actually refused rather than a second guess at it.
|
||||||
|
|
||||||
|
**A second defect fell out of fixing it.** The row's name is read off `card.geometry.facility`, which
|
||||||
|
a Passenger Facility does not have — it rides on the `office` card — so every passenger impediment
|
||||||
|
would have read `facility 0,0` beside a freight row saying `mineTipple 1,-3`. It is named by the
|
||||||
|
Office Area's tier now (`terminal 0,0`), and there is exactly one Office per Area, so that tier is
|
||||||
|
the card's own.
|
||||||
|
|
||||||
|
Verified by replaying the reported save (`playtests/station-master-seed947338225-day5(1).json`)
|
||||||
|
through `fromSave` and printing the panel. Worth noting for anyone who tries it: that save is a
|
||||||
|
v0.4.9-line recording and stops at intent 250 of 323 on the 0.7 engine, because Gitea#4, #6 and #7
|
||||||
|
changed the rules its later intents were recorded against. That is expected divergence, not a
|
||||||
|
save-format bug — a replay reproduces a game from decisions, and the decisions no longer mean the
|
||||||
|
same thing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.7.0 — 2026-08-23
|
## 0.7.0 — 2026-08-23
|
||||||
|
|
||||||
The multiplayer set-up, the lobby, the start of a game, and four things a remote client had never
|
The multiplayer set-up, the lobby, the start of a game, and four things a remote client had never
|
||||||
|
|||||||
@@ -50,8 +50,10 @@ Queued 2026-08-22, from a v0.4.9d gameplay-testing report (six bugs, forwarded b
|
|||||||
it just boarded**~~ — done in v0.4.9e / the release below.
|
it just boarded**~~ — done in v0.4.9e / the release below.
|
||||||
11. ~~**The Grocer's Warehouse ships and the Refinery receives**~~ — done in v0.4.9e / the release
|
11. ~~**The Grocer's Warehouse ships and the Refinery receives**~~ — done in v0.4.9e / the release
|
||||||
below: both are one-way again.
|
below: both are one-way again.
|
||||||
12. **NOT REPRODUCED: cars left behind when backing up over them** — see Rules Questions below. The
|
12. **PARTLY REPRODUCED: cars left behind when backing up over them** — see Rules Questions below.
|
||||||
one report of the six that is still open, and it needs a board from whoever filed it.
|
Half of it turned out to be the second half of Gitea#17 and is fixed (2026-08-26): a train
|
||||||
|
pulling out through a 45° leg left its own cut standing. The "I can later drive right through
|
||||||
|
them" half is still unexplained and still needs a board from whoever filed it.
|
||||||
|
|
||||||
Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issues.
|
Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issues.
|
||||||
|
|
||||||
@@ -69,12 +71,40 @@ Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issue
|
|||||||
and Extra alike; the forced play falls out of the hand limit rather than needing a mechanism of
|
and Extra alike; the forced play falls out of the hand limit rather than needing a mechanism of
|
||||||
its own. Reasoning in `docs/rules/implications.md` §6.2.
|
its own. Reasoning in `docs/rules/implications.md` §6.2.
|
||||||
|
|
||||||
12b. **Gitea#2 — four porters, two passengers on the platform, and only one may be worked.**
|
12b. ~~**Gitea#2 — four porters, two passengers on the platform, and only one may be worked**~~ —
|
||||||
DIAGNOSED, AWAITING JESSE'S RULING — see Play Balance below. The engine is faithful to the
|
RULED AND FIXED in the release below, though not the way the report implies. The engine is
|
||||||
written rules at every step; what bites is that BOTH directions of porter work move coaches
|
faithful to the written rules at every step; what bites is that BOTH directions of porter work
|
||||||
one-way into a Classification Yard that comes back only when the Division Yard is bare of all
|
move coaches one-way into a Classification Yard that comes back only when the Division Yard is
|
||||||
~60 cars. Sixteen coaches in the game, and the reported save runs dry on Day 5 with eight of
|
bare of all ~60 cars. Sixteen coaches in the game, and the reported save runs dry on Day 5 with
|
||||||
them stranded in Classification.
|
eight of them stranded in Classification. **Jesse's ruling: the shortage stays** — "it is
|
||||||
|
possible to run out, that's part of the strategy" — so the three balance options in Play Balance
|
||||||
|
below are declined, not deferred. What was actually wrong is that the game said NOTHING: a Porter
|
||||||
|
action that cannot be taken is simply absent from the menu, and the "why is nothing moving?"
|
||||||
|
panel covered freight facilities only. That half is fixed.
|
||||||
|
|
||||||
|
12e. ~~**Gitea#8 — the per-diem train could not couple a caboose**~~ — done in the release below.
|
||||||
|
`ROLLING_STOCK_SUPPLY` mints all six cabooses `loaded: true` because §2.2's "coloured is loaded,
|
||||||
|
white is empty" doubles as a PIECE COUNT there and there is no white caboose. X22 Pee-Dee, whose
|
||||||
|
whole card is "may only pick up MTs", read that literally and refused every caboose including the
|
||||||
|
one it was made up with — set it out and the train was stranded, which made it unplayable rather
|
||||||
|
than merely restricted. A caboose carries the crew, not freight, so it is never a load.
|
||||||
|
|
||||||
|
12g. ~~**Gitea#9 — a Timetabled train may be discarded**~~ — done in the release below, and it
|
||||||
|
SUPERSEDES Gitea#6 (item 12c above), shipped three days earlier. A Timetabled train may be tossed
|
||||||
|
face-up to a Department slot, where a rival may pick it up — which needed no machinery, since
|
||||||
|
that is where every discard already goes. An Extra still may not: it never joins the timetable,
|
||||||
|
so it can never be what jams it. On this line the Timetabled half is a New Game setting
|
||||||
|
(`discardTimetabled`, on by default), because Jesse's reasoning is about games run longer than
|
||||||
|
five Days; the 0.4.9 line takes the plain rule. Reasoning in `docs/rules/implications.md` §6.2.
|
||||||
|
|
||||||
|
12f. ~~**Gitea#10 — a dialog when the Day rolls over**~~ — done in the release below. "Hard to keep
|
||||||
|
track of time." Nothing on screen was wrong — the clock, the turn chart and the timetable all
|
||||||
|
said which Day it was — but a Day turns over inside the phases that run themselves, so it passes
|
||||||
|
between one click and the next, and the two transient signals the page had (the phase banner at
|
||||||
|
2.6s, the announcement flash at 4.2s) are both gone before a player reading the board notices.
|
||||||
|
A modal stops and waits, and carries the standings, the Days left and the combined target.
|
||||||
|
Suppressed on the first frame, on Undo stepping back across a rollover, and on the Day the game
|
||||||
|
ends — the outcome panel is the thing to read then.
|
||||||
|
|
||||||
13. **Show me the other players' moves, bots included** — raised by Jesse 2026-08-22 from playing a
|
13. **Show me the other players' moves, bots included** — raised by Jesse 2026-08-22 from playing a
|
||||||
multiplayer game. Reasoning in Multiplayer below.
|
multiplayer game. Reasoning in Multiplayer below.
|
||||||
@@ -99,30 +129,31 @@ Queued 2026-08-22, from a session looking at the screen rather than the rules. A
|
|||||||
means something different and useful: hide it now, bring it back at the end of the phase.
|
means something different and useful: hide it now, bring it back at the end of the phase.
|
||||||
18. **Give every phase a visible beat.** The automatic phases are not too fast — they are never
|
18. **Give every phase a visible beat.** The automatic phases are not too fast — they are never
|
||||||
drawn at all, because `pump` runs them all before the page renders once.
|
drawn at all, because `pump` runs them all before the page renders once.
|
||||||
19. **Turn the track art vertical on a Division card laid vertically** — a side lane currently reads
|
19. ~~**Turn the track art vertical on a Division card laid vertically**~~ — **SUPERSEDED by
|
||||||
as stacked left-right segments rather than one continuous run.
|
Gitea#18** (Jesse, 2026-08-26). There are no vertical lanes any more: the Division draws as a
|
||||||
20. **Run the inter-row connector round the OUTSIDE**, draw it as rail rather than a plain line, and
|
single row, west to east.
|
||||||
give the corners an angled piece.
|
20. ~~**Run the inter-row connector round the OUTSIDE**~~ — **SUPERSEDED by Gitea#18.** No second
|
||||||
21. **The Division Point captions overflow the map**, and the buffer stops point the wrong way once
|
row, so nothing to connect.
|
||||||
the route wraps.
|
21. ~~**The Division Point captions overflow the map**, and the buffer stops point the wrong way once
|
||||||
22. **Fill the dead centre of the Division map with the common board** — timetable, yards, the
|
the route wraps~~ — **SUPERSEDED by Gitea#18.** Nothing wraps; both ends face outward.
|
||||||
Department and Salvage decks. Then the map is what everyone shares and the right column is yours.
|
22. ~~**Fill the dead centre of the Division map with the common board**~~ — **SUPERSEDED by
|
||||||
|
Gitea#18.** A row has no centre to fill.
|
||||||
23. **History: newest at the top?** Plus the timestamps question from item 14, which lands here.
|
23. **History: newest at the top?** Plus the timestamps question from item 14, which lands here.
|
||||||
24. **Put the viewer's own district at the BOTTOM of the Division map** and wrap the table around
|
24. ~~**Put the viewer's own district at the BOTTOM of the Division map** and wrap the table around
|
||||||
them, so the screen sits you at the table. Needs no new data; the cost is that "west to east"
|
them~~ — **DEFINITIVELY SUPERSEDED by Gitea#18** (Jesse's word, 2026-08-26). A single row and a
|
||||||
stops starting where you start reading.
|
table wrapped around the viewer cannot both hold, and the row wins: being able to rely on east
|
||||||
|
meaning right is worth more than being seated at the table.
|
||||||
|
|
||||||
Queued 2026-08-23, from Jesse playing the v0.7.0 build on StartOS. **All three are the same drawing
|
Queued 2026-08-23, from Jesse playing the v0.7.0 build on StartOS. **All three were the same drawing
|
||||||
pass as 19-21 and 24 above, and he asked for them to be discussed together rather than picked off:**
|
pass as 19-21 and 24 above, and all three are answered by Gitea#18 rather than fixed:**
|
||||||
|
|
||||||
25. **The Division map does not draw track geometry at all** — a turnout laid on the Running Track
|
25. ~~**The Division map does not draw track geometry at all**~~ — **SUPERSEDED by Gitea#18.** The
|
||||||
looks exactly like the straight it replaced, because the only thing that changes is a caption.
|
Division map stops drawing office-area detail altogether, so there is no Running Track on it to
|
||||||
Reasoning and the measurement in Display below.
|
draw geometry for. The geometry belongs to the Office map, which already draws it.
|
||||||
26. **CONFIRMED IN PLAY: the buffer stop points the wrong way** at two players — see item 21, now
|
26. ~~**CONFIRMED IN PLAY: the buffer stop points the wrong way** at two players~~ — **SUPERSEDED by
|
||||||
reported from a real game rather than read off the code.
|
Gitea#18**, with item 21.
|
||||||
27. **CONFIRMED IN PLAY: east is not always to the right.** As a train crosses the Division the route
|
27. ~~**CONFIRMED IN PLAY: east is not always to the right.**~~ — **FIXED OUTRIGHT by Gitea#18**, and
|
||||||
wraps through the lanes, so a player's east can be drawn south, west or north. See item 20; it is
|
the reason it wins over item 24. One row means east is always to the right.
|
||||||
the same wrap that puts the buffer stop in the wrong place.
|
|
||||||
|
|
||||||
28. **INVESTIGATE: move the game's settings off the top line and into a card of their own** — and
|
28. **INVESTIGATE: move the game's settings off the top line and into a card of their own** — and
|
||||||
show ALL of them, not the four that fit. Reasoning in Display below.
|
show ALL of them, not the four that fit. Reasoning in Display below.
|
||||||
@@ -130,6 +161,29 @@ pass as 19-21 and 24 above, and he asked for them to be discussed together rathe
|
|||||||
29. **Put the Fedora at the right-hand end of the phase row**, with (or in) the Supervisor Shift
|
29. **Put the Fedora at the right-hand end of the phase row**, with (or in) the Supervisor Shift
|
||||||
pill. Reasoning in Display below.
|
pill. Reasoning in Display below.
|
||||||
|
|
||||||
|
Queued 2026-08-25, from releasing 0.7.1 / 0.4.9g. **Both are blocked on the two commits being made
|
||||||
|
and pushed** — they were still uncommitted when the session ended.
|
||||||
|
|
||||||
|
30. **Close the Gitea issues by hand, each with a comment naming the commit that fixed it.** Raised
|
||||||
|
by Jesse 2026-08-25. `Closes #<n>` is in the main commit message and will auto-close #2, #8, #9
|
||||||
|
and #10 when it reaches the default branch, but auto-closing leaves the issue with no record of
|
||||||
|
WHICH commit or WHICH release answered it — and #9 supersedes #6, which nothing on #6 will ever
|
||||||
|
say. So each issue wants a comment of its own: the commit SHA on both lines, the version each
|
||||||
|
shipped in (0.7.1 and 0.4.9g), and for #2 and #9 the ruling that decided it, since neither was
|
||||||
|
fixed the way the report implies. #6 wants a comment too, pointing at #9. The token and the API
|
||||||
|
calls are in the workspace's `AGENTS.local.md`.
|
||||||
|
|
||||||
|
31. **Bump the StartOS wrapper to 0.7.1.** `station-master-startos` is clean, on `master`, pinned to
|
||||||
|
`v0.7.0` and versioned `0.7.0:3`. Once v0.7.1 is tagged: move the submodule pin to the tag, set
|
||||||
|
`startos/versions/current.ts` to **`0.7.1:0`** (a new upstream tag resets the downstream digit —
|
||||||
|
`UPDATING.md`), and write release notes in all five locales. **No new version FILE and no
|
||||||
|
migration** — `current.ts`'s `migrations.up` is empty, so nothing is carried from 0.7.0, and a
|
||||||
|
0.7.0 save replays under 0.7.1 unchanged (its intents cannot contain a Timetabled discard,
|
||||||
|
because that was illegal when they were recorded). `instructions.md` and `README.md` both name
|
||||||
|
0.7.0 and both need the three user-visible changes: the Day-end dialog, the Timetabled discard
|
||||||
|
and its New Game checkbox, and the reasons a blocked platform now gives. Remember the branch
|
||||||
|
trap: the game pushes to `main`, the wrapper to `master`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Replay / Save Games
|
## Replay / Save Games
|
||||||
@@ -264,6 +318,18 @@ The replay viewer, the save format, and how a game gets shared.
|
|||||||
|
|
||||||
## Bot Performance
|
## Bot Performance
|
||||||
|
|
||||||
|
- [ ] **THE BOT WILL NOT PLAY RED FLAGS, and the reachability canary now says so.** Measured over
|
||||||
|
600 games (2026-08-26): `maneuver.redFlags` is OFFERED **4,212 times**, first in game 5 — the
|
||||||
|
rule is live and constantly available — and the bot PLAYS it **4 times**, first in game 252.
|
||||||
|
It is exempted by name in `sim.test.ts`'s anomaly test alongside `flyingSwitch`, and deleting
|
||||||
|
that line is what proves this fixed.
|
||||||
|
|
||||||
|
Two changes compounded to make it rarer without breaking anything: Gitea#14 took Red Flags from
|
||||||
|
5 copies to the sheet's 3, and Gitea#3 shortened most crossings to a single Stage, so the
|
||||||
|
window in which a train is STANDING on a Mainline card — the only place the card may be played
|
||||||
|
— is now usually one Stage wide.
|
||||||
|
|
||||||
|
|
||||||
What the developer bot can and cannot yet do, measured. Every revenue figure below measured before
|
What the developer bot can and cannot yet do, measured. Every revenue figure below measured before
|
||||||
v0.4.7 is low by roughly half a point — see the stub-industry entry — and the rebalance pass should
|
v0.4.7 is low by roughly half a point — see the stub-industry entry — and the rebalance pass should
|
||||||
not read that drop as a deck problem.
|
not read that drop as a deck problem.
|
||||||
@@ -377,6 +443,30 @@ not read that drop as a deck problem.
|
|||||||
|
|
||||||
## Play Balance
|
## Play Balance
|
||||||
|
|
||||||
|
- [ ] **FREIGHT GOT SCARCER WHEN THE MAINLINE WENT ONTO REGIONS, and nobody knows why yet.**
|
||||||
|
Gitea#3, measured 2026-08-26 across the same 100 games: freight share of gross fell **8% → 5%**,
|
||||||
|
and completed freight loads went from something a 40-game sample caught reliably to needing
|
||||||
|
200 — on `sim.test.ts`'s seeds, 40 games now yield 0 loads, 80 yield 3, 120 yield 10, 200
|
||||||
|
yield 21.
|
||||||
|
|
||||||
|
**It runs against the obvious expectation.** The change SPEEDS crossings up, so more trains
|
||||||
|
should reach more districts, not fewer. Revenue is flat (−0.2 against 0.0) and collisions are
|
||||||
|
unchanged at 0.1 a game, so nothing is obviously eating the traffic. Candidates worth checking:
|
||||||
|
trains now clear a district before a crew can work them; the entry-time collision rule
|
||||||
|
(below) destroying trains at the Office; or simply that faster turnover means fewer trains
|
||||||
|
standing where freight can be loaded.
|
||||||
|
|
||||||
|
- [ ] **THE CREW TRAY COUNT IS DUE A RE-EXAMINATION, and this is the change that triggers it.**
|
||||||
|
`players + 3` was set when a Slow train took roughly twice as long to cross as a Fast one, and
|
||||||
|
Q2's recorded consequence was that "every Slow train is still on the road when the next Day
|
||||||
|
begins, holding its Crew Tray". Gitea#3 removed the Slow penalty from every card but Hilly.
|
||||||
|
Measured on the Mainline cards alone, a 3-player Division now costs a fast train ~5.6 Stages
|
||||||
|
and a slow one ~6.0, against ~5.4 and ~9.4 before: **fast traffic is unchanged, slow traffic is
|
||||||
|
about a third quicker**, and the gap across a whole Division collapses from roughly four Stages
|
||||||
|
to less than one. RAR's own closing note on the issue: "been worried about the time it takes to
|
||||||
|
cross the division. More thunking on this is needed."
|
||||||
|
|
||||||
|
|
||||||
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
|
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
|
||||||
target is settled and freight carries its intended share; read no balance conclusion from a revenue
|
target is settled and freight carries its intended share; read no balance conclusion from a revenue
|
||||||
number until the rules stop moving.
|
number until the rules stop moving.
|
||||||
@@ -466,19 +556,27 @@ number until the rules stop moving.
|
|||||||
~60 cars) are consumed by both halves of every passenger cycle. Traced over the reported game
|
~60 cars) are consumed by both halves of every passenger cycle. Traced over the reported game
|
||||||
the coach pool goes 8+/8− to 0+/1− by Day 5.
|
the coach pool goes 8+/8− to 0+/1− by Day 5.
|
||||||
|
|
||||||
**Three ways out, and it is Jesse's call which:** (a) refill when the Division Yard is dry of
|
**RULED — the shortage stays, and none of the three is being built.** Jesse: "it is possible
|
||||||
the type-and-state being asked for rather than dry of everything — the reading a player
|
to run out, that's part of the strategy." For the record, the options were (a) refill when the
|
||||||
rummaging a table-top pile actually uses, and the biggest balance change; (b) the same trigger
|
Division Yard is dry of the type-and-state being asked for rather than dry of everything; (b)
|
||||||
but return only the cars of that type; (c) leave the rules alone and raise the coach count in
|
the same trigger but return only the cars of that type; (c) leave the rules alone and raise the
|
||||||
`ROLLING_STOCK_SUPPLY`, which the item below already sanctions — lowest risk, but it delays the
|
coach count in `ROLLING_STOCK_SUPPLY`. All three are declined. What shipped instead is the
|
||||||
wall rather than removing it. Measure (a) or (b) over 400 paired seeds before shipping.
|
EXPLANATION — the impediments panel now says the Division Yard has no white coach, how many are
|
||||||
|
stranded in Classification, and that Classification returns only when the Division Yard is
|
||||||
|
bare. Running dry is a position to play out of, not a broken game, once the screen says so.
|
||||||
|
|
||||||
- [ ] **A blocked PASSENGER facility produces no impediment at all.** `impediments()`
|
- [x] **A blocked PASSENGER facility produces no impediment at all — FIXED.** `impediments()`
|
||||||
(`src/sim/narrate.ts`) opens with `if (!f || f.kind !== 'freight') continue`, so the "why
|
(`src/sim/narrate.ts`) opened with `if (!f || f.kind !== 'freight') continue`, so the "why
|
||||||
nothing is moving" panel has never had anything to say about a platform. That is the second
|
nothing is moving" panel had never had anything to say about a platform. That was the second
|
||||||
half of Gitea#2 and the half that is unambiguously a bug: the player above was not merely
|
half of Gitea#2 and the half that was unambiguously a bug: the player above was not merely
|
||||||
blocked, he was given no reason — the button simply was not there. Worth fixing whichever way
|
blocked, he was given no reason — the button simply was not there. A platform now reports
|
||||||
the supply question is settled.
|
passengers with no train, a train the card bars Porters from working, full coaches, full red
|
||||||
|
slots, the same-district rule, and the coach shortage itself — the last naming how many coaches
|
||||||
|
are stranded in Classification and what brings them back. The reason comes from
|
||||||
|
`passengerRefusal`, the engine's own predicate, so the panel cannot drift from the rule that
|
||||||
|
actually refused. Fixing the label found a second defect: a Passenger Facility rides on the
|
||||||
|
`office` card, so every passenger row would have read `facility 0,0` next to `mineTipple 1,-3`;
|
||||||
|
it is named by its tier now.
|
||||||
|
|
||||||
- [ ] **The log lowercases the first letter of every narration it attributes to a player**, so
|
- [ ] **The log lowercases the first letter of every narration it attributes to a player**, so
|
||||||
`EXTRA X18 started…` renders as `Player Solitaire eXTRA X18 started…` (`src/web/game.ts`:1065,
|
`EXTRA X18 started…` renders as `Player Solitaire eXTRA X18 started…` (`src/web/game.ts`:1065,
|
||||||
@@ -1119,6 +1217,14 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
|||||||
|
|
||||||
## Display
|
## Display
|
||||||
|
|
||||||
|
- [ ] **THE BOARD STILL DOES NOT SAY WHICH WAY A HEAVY GRADE CLIMBS.** RAR, twice: "grade should
|
||||||
|
tell you which way is up." `gradeUp` is dealt at setup and drives which of Helpers or
|
||||||
|
Brakeman/Airbrakes can ever pay, and Gitea#3 made it matter more — the modifiers now move a
|
||||||
|
train's STARTING REGION, so playing the wrong one is three Stages of climb instead of two. The
|
||||||
|
tooltip says it (`mainlineDescription`); the map does not. Untouched by Gitea#3, which was
|
||||||
|
about the rules rather than the drawing.
|
||||||
|
|
||||||
|
|
||||||
What is on the screen and where. Split out of Other 2026-08-22; the rules are elsewhere.
|
What is on the screen and where. Split out of Other 2026-08-22; the rules are elsewhere.
|
||||||
|
|
||||||
- [ ] **The log's start marker only works while the whole log fits.** Added 2026-08-23: a multiplayer
|
- [ ] **The log's start marker only works while the whole log fits.** Added 2026-08-23: a multiplayer
|
||||||
@@ -1470,17 +1576,31 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el
|
|||||||
|
|
||||||
## Rules Questions
|
## Rules Questions
|
||||||
|
|
||||||
- [ ] **NOT REPRODUCED: "when I back up to collect standing cars and, further down the tracks, the
|
- [ ] **PARTLY REPRODUCED: "when I back up to collect standing cars and, further down the tracks, the
|
||||||
caboose, I get the caboose but the cars remain. I can later drive right through them."**
|
caboose, I get the caboose but the cars remain. I can later drive right through them."**
|
||||||
Reported against v0.4.9d by a playtester (not Jesse, who forwarded it and could not add detail;
|
Reported against v0.4.9d by a playtester (not Jesse, who forwarded it and could not add detail;
|
||||||
his guess was that the cars were spotted at an industry).
|
his guess was that the cars were spotted at an industry).
|
||||||
|
|
||||||
|
**HALF OF IT IS NOW REPRODUCED AND FIXED (2026-08-26), as the second half of Gitea#17.** The
|
||||||
|
one case the earlier sweep did not try is a train pulling out through a 45° LEG rather than an
|
||||||
|
east or west port. `cutTowards` answered "you meet nothing" for a north or south exit, so a
|
||||||
|
crew standing on a curve drove away and left the cut beside it standing — the reported symptom
|
||||||
|
exactly, and against §A.4's mandatory coupling. `rowEndAt` (`track.ts`) fixes it, and
|
||||||
|
`cut-ordering.test.ts` pins it.
|
||||||
|
|
||||||
|
**WHAT IS STILL UNEXPLAINED is the second sentence — "I can later drive right through them."**
|
||||||
|
Nothing found so far accounts for that. A card is swept by `carsOn` whenever a train enters it,
|
||||||
|
whichever port it enters by, so a later pass over those cars picks them up. Until that half has
|
||||||
|
a board behind it this stays open: the fix above may be the whole report, or only the part that
|
||||||
|
happened to be reachable from the code.
|
||||||
|
|
||||||
**What was tried, all of which works.** Cars on plain track on the way to the caboose; cars
|
**What was tried, all of which works.** Cars on plain track on the way to the caboose; cars
|
||||||
SPOTTED AT AN INDUSTRY on the way; the train's own cut standing on the square it is pulling out
|
SPOTTED AT AN INDUSTRY on the way; the train's own cut standing on the square it is pulling out
|
||||||
of; a stale `standingWest` on the intermediate card; the industry locked by MEN AT WORK (which
|
of *through an east or west port*; a stale `standingWest` on the intermediate card; the industry
|
||||||
correctly blocks the whole route rather than letting the crew past). Every one couples the lot.
|
locked by MEN AT WORK (which correctly blocks the whole route rather than letting the crew
|
||||||
The first three are pinned in `apply.test.ts` — "backing up over a cut to something beyond it
|
past). Every one couples the lot. The first three are pinned in `apply.test.ts` — "backing up
|
||||||
takes both" — so if the case is found later it is somewhere none of them cover.
|
over a cut to something beyond it takes both" — so if the remaining case is found later it is
|
||||||
|
somewhere none of them cover.
|
||||||
|
|
||||||
**Why it is hard to make happen.** Coupling is mandatory (§A.4) and `exploreMoves` accumulates
|
**Why it is hard to make happen.** Coupling is mandatory (§A.4) and `exploreMoves` accumulates
|
||||||
what it meets card by card, so a route that reaches the caboose has already met everything
|
what it meets card by card, so a route that reaches the caboose has already met everything
|
||||||
@@ -1511,6 +1631,87 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el
|
|||||||
|
|
||||||
Doesn't fit the above.
|
Doesn't fit the above.
|
||||||
|
|
||||||
|
- [ ] **THE DECK IS `docs/Deck cards5.xlsx` EXACTLY, BAR TEN CARDS THAT ARE NOT BUILT.** Gitea#14,
|
||||||
|
2026-08-26. Card for card, **84 rows agree with the sheet** and the only ones that do not are
|
||||||
|
the ten it adds that we have never implemented — Cargo Theft, Civic Improvement, Civilian
|
||||||
|
angel, Delayed Clearance, Flares 2, Robbery, Service Delays, Shipper complaints, Strike,
|
||||||
|
Union Hall 2: **12 copies**, held out on Jesse's instruction until they are built. Gitea#12
|
||||||
|
partly specifies the Inspections among them.
|
||||||
|
|
||||||
|
What landed: track halved; the Q12 office doubling and the Gap 12 industry tripling both
|
||||||
|
removed; Interlocking 2→1, Water column 2→1, ABS Signals 2→1, Red Flags 5→3. **Everything
|
||||||
|
sheet 5 does not list is dealt 0 copies rather than deleted** — the Telegraph/Telephone/Radio
|
||||||
|
dispatching ladder, Facing Point Locks (Enhancement and Mainline both), Flying Switch, Section
|
||||||
|
House, Vandalism, all confirmed by Jesse as deliberate removals from the design, plus Poling
|
||||||
|
and the sharp curves which were already there. The rows and their rules stay, so the design
|
||||||
|
stays visible and each mechanic works the moment it is dealt again. Deck 206 → **121** dealt.
|
||||||
|
|
||||||
|
Measured, 100 games, developer bot: revenue per player **−0.2 → +0.4**, trains scheduled
|
||||||
|
1.3 → 1.6, cards played 16.8 → 13.0. Freight share fell 9% → 4%, and part of that is Flying
|
||||||
|
Switch going to zero — it was a freight mechanic. Worth a look if freight is meant to carry
|
||||||
|
more.
|
||||||
|
|
||||||
|
**NOT A DISCREPANCY, though it looks like one in a card-by-card diff:** Second Section is on
|
||||||
|
neither sheet and is not a drawn card here either. It is a New Train phase intent
|
||||||
|
(`newTrain.secondSection`), and the `copies: 1` on the `SECOND_SECTION` constant is vestigial —
|
||||||
|
nothing deals it. Worth removing that field so the next diff does not flag it again.
|
||||||
|
|
||||||
|
**The deck reads 40% track against the sheet's 31%**, and the whole of that gap is the ten
|
||||||
|
held-out cards concentrating everything else. Building them moves the ratio to the sheet's on
|
||||||
|
its own, which is why the share is held to a loose band in `setup.test.ts` rather than pinned.
|
||||||
|
|
||||||
|
- [ ] **FIVE TEST FIXTURES PINNED A SEED AND MEANT "A GAME LIKE THIS".** All five broke on Gitea#14
|
||||||
|
and none of them was about card counts — the deck's SIZE moves the RNG stream, so changing it
|
||||||
|
re-deals every fixture that names a seed. Fixed in place: `mainline-cards` now searches for a
|
||||||
|
Division holding a single-track card, `multiplayer` for a game that reaches Day 3, `web` clicks
|
||||||
|
every play verb rather than assuming the first one goes on the board, and the two `sim`
|
||||||
|
commodity samples were re-measured (tank is first set out at game **216** now, unload Revenue
|
||||||
|
at game **46**). The `web` fix is on BOTH lines — it broke on `main` at the next count change,
|
||||||
|
exactly as predicted. `multiplayer`'s seed search is still playtest-only; port it when
|
||||||
|
convenient.
|
||||||
|
|
||||||
|
A sixth turned up when the dropped cards went to zero: `mainline-cards`' `hand()` helper threw
|
||||||
|
if the card it wanted was not in the deck, so zeroing Flying Switch took five passing tests of
|
||||||
|
an UNCHANGED rule down with it. It mints a card that is no longer dealt now — which is the
|
||||||
|
point of keeping a row at zero, and the same will hold for the ladder if anyone tests it.
|
||||||
|
|
||||||
|
- [ ] **THE 0.4.9 PLAYTEST LINE IS BEHIND ON A RULES RULING, and that was checked rather than
|
||||||
|
assumed.** Recorded 2026-08-23, when Jesse asked whether any of v0.7.0 needed porting to the
|
||||||
|
`playtest` branch. Almost none of it does — that line has no lobby and no server, and the
|
||||||
|
`undo()` config fix is inert there because its `GameConfig` carries no victory dials, so
|
||||||
|
`configWith` only ever varies the house rules the save already restores.
|
||||||
|
|
||||||
|
**But one v0.5.0 ruling is a CODE difference the testers do not have.** §A.4, the Local's
|
||||||
|
coach — so on that build a coach still may not be set out at the Office.
|
||||||
|
|
||||||
|
**IT IS THREE SITES, NOT ONE.** This entry named only the first until 2026-08-25, when a full
|
||||||
|
branch diff found the other two. Porting just the `apply.ts` line would leave the build in a
|
||||||
|
WORSE state than either line is in today: the coach could be set out at the Office and the next
|
||||||
|
arriving train would then collide with it.
|
||||||
|
|
||||||
|
1. `src/engine/apply.ts` — `main` reads `if (dropRules.coachStaysOnStationTrack &&
|
||||||
|
cut.some(coach) && !atOffice)`; `playtest`'s is the same line **without `&& !atOffice`**.
|
||||||
|
This is the one that refuses the drop.
|
||||||
|
2. `src/engine/track.ts` — `canDropCarsAt(area, coord, count, coachesOnly)` takes a fourth
|
||||||
|
`coachesOnly` parameter on `main` and returns the Office square as droppable when it is set.
|
||||||
|
`playtest`'s signature has no such parameter and returns `false` for the Office outright.
|
||||||
|
3. `src/engine/advance.ts` — the §8.3 "cars fouling the Running Track" check. `main` reads
|
||||||
|
`officeCard.standing.some((c) => c.type !== 'coach')`, so a coach parked at the Office is
|
||||||
|
not a hazard to the next arrival; `playtest` reads `officeCard.standing.length > 0`, which
|
||||||
|
collides with anything standing there. **This one is behavioural and easy to miss** — it is
|
||||||
|
in a different file from the drop rules and reads as a collision fix rather than a coach one.
|
||||||
|
|
||||||
|
The other two questions the 0.4.9 README calls open are documentation-only there (Poling is
|
||||||
|
already at 0 copies; Heavy Grade behaves identically — both lines run the same
|
||||||
|
`rng.nextInt(2)`, re-verified 2026-08-25 — and it even deals the Mainline deck without
|
||||||
|
replacement, so the correction applies word for word).
|
||||||
|
|
||||||
|
**Jesse's call, 2026-08-23: do not port it now.** A settled rules change is not a playtest bug
|
||||||
|
fix, and pushing one into the build people are mid-playtest on would invalidate the feedback
|
||||||
|
that build exists to collect. Recorded so the divergence is a decision rather than a surprise —
|
||||||
|
and so the 0.4.9 README is not "corrected" to match `main`'s wording, which would then describe
|
||||||
|
behaviour that build does not have.
|
||||||
|
|
||||||
- [ ] **Documentation generated from the implementation, not written alongside it.** Raised by Jesse
|
- [ ] **Documentation generated from the implementation, not written alongside it.** Raised by Jesse
|
||||||
2026-08-22, immediately after Gitea#7 changed the coach counts on four train cards and the
|
2026-08-22, immediately after Gitea#7 changed the coach counts on four train cards and the
|
||||||
answer to "where do we keep track of that?" turned out to be **five places of three different
|
answer to "where do we keep track of that?" turned out to be **five places of three different
|
||||||
@@ -1611,6 +1812,32 @@ Doesn't fit the above.
|
|||||||
|
|
||||||
## Done, kept for the reasoning
|
## Done, kept for the reasoning
|
||||||
|
|
||||||
|
- **Gitea#15 — a rail may stop dead against its neighbour, and the rule is on MOVEMENT.**
|
||||||
|
Filed 2026-08-25 as "track placements must connect": a right-hand curve had been laid with its
|
||||||
|
north leg against an Ice House and the turnout below pointing at its portless south edge, and the
|
||||||
|
report called that illegal. **RAR reversed it on review (2026-08-26)** — the placement is fine, and
|
||||||
|
a stub like that has a use, as a siding to park cars on. What he asked to confirm instead is that
|
||||||
|
no train can traverse the gap.
|
||||||
|
|
||||||
|
It could not, and cannot: `exploreMoves` gates every hop on `joins`, which tests both ports AND
|
||||||
|
that two 45° legs lie on the same diagonal — never a bare pair of `hasPort` calls. That was already
|
||||||
|
true; `track.test.ts` now pins it against the reported geometry, including the check that the curve
|
||||||
|
IS reachable from the side that joins, so the negative test cannot pass on a card that is merely
|
||||||
|
unreachable.
|
||||||
|
|
||||||
|
**A per-edge placement check was written and then taken out**, along with the matching guard on
|
||||||
|
`checkTurnoutUpgrade`. Both are documented in place as deliberately absent, because this is exactly
|
||||||
|
the rule someone will "fix" again. `canPlaceAt` keeps only the weaker requirement it always had:
|
||||||
|
the piece must touch the network somewhere, which is what stops orphaned track.
|
||||||
|
|
||||||
|
**A Modifier is scenery** (Jesse, 2026-08-26): a rail pointing at a building is fine, so nothing
|
||||||
|
guards Modifier placement either. Measured before the ruling: 24 of 283 Modifiers across 200 bot
|
||||||
|
games sit where a neighbour's rail points at them, and that is simply legal.
|
||||||
|
|
||||||
|
The attached save is dead — Gitea#14 took the deck from 206 cards to 121, so its card ids no longer
|
||||||
|
exist and the replay stops at the first `card.play`. **Every save filed before that deck change is
|
||||||
|
in the same position**, including Gitea#17's. Reproduce from the geometry, not the file.
|
||||||
|
|
||||||
- [x] **Put rolling stock back into circulation.** The Classification Yard was write-only — seven
|
- [x] **Put rolling stock back into circulation.** The Classification Yard was write-only — seven
|
||||||
writers, no readers — so 37% of all rolling stock left the game by Day 5. Returning it at the
|
writers, no readers — so 37% of all rolling stock left the game by Day 5. Returning it at the
|
||||||
Day boundary is **+2.32 ± 0.52 (t = 8.79)**, the largest single change measured on this bot,
|
Day boundary is **+2.32 ± 0.52 (t = 8.79)**, the largest single change measured on this bot,
|
||||||
|
|||||||
Binary file not shown.
+83
-18
@@ -40,8 +40,9 @@ All nine answers are implemented, **170 tests passing**:
|
|||||||
|
|
||||||
| Answer | Implemented as |
|
| 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. |
|
| ~~Q1 crossing time~~ | **SUPERSEDED 2026-08-26 — see Q1a below.** Was: a 60 card takes 1 Stage, a 30 takes 2. |
|
||||||
| Q2 Fast/Slow | Slow adds one Stage to every card. Hilly reads the consist (any coach = passenger). |
|
| ~~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. |
|
| 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`. |
|
| Q4 Lockouts | `isLockedOut()` rejects the placement with `FACILITY_LOCKED`. |
|
||||||
| Q5 Run-around | Nothing to do — reachability is geometric, so a built bypass already works. |
|
| 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
|
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.
|
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
|
**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
|
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
|
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
|
**SUPERSEDED ONCE. Read both rulings; the second narrows the first.**
|
||||||
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 count.** An Extra is a train, even though it runs once and ends in the Salvage Yard where a
|
**Gitea#6, v0.4.9e playtest:** "Players are not allowed to discard Train cards. They may keep the
|
||||||
Timetabled card joins the timetable for the rest of the game.
|
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
|
§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
|
exception for any card type, so both of these are rulings rather than gaps — the prototype rules do
|
||||||
address it either way.
|
not address it either way.
|
||||||
|
|
||||||
### It needs no forcing mechanism, and that is the point
|
### 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:
|
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).
|
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 —
|
A player holding four undiscardable trains therefore has exactly one legal way to conclude the turn —
|
||||||
without anything in the engine ever computing "you must play a train". The corner cannot lock a
|
play one — without anything in the engine ever computing "you must play a train". The corner cannot
|
||||||
player in, because **playing a train card is unconditionally legal**: `card.play`'s train case
|
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
|
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
|
hand (it simply schedules nothing). Confirmed by playing it: such a hand offers zero discards, no
|
||||||
discards, no `draw.end`, and four plays.
|
`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
|
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`
|
`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",
|
"name": "station-master",
|
||||||
"version": "0.7.0",
|
"version": "0.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"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
+168
-28
@@ -23,16 +23,16 @@ import {
|
|||||||
enhancementRule,
|
enhancementRule,
|
||||||
crossingStages,
|
crossingStages,
|
||||||
trainProfile,
|
trainProfile,
|
||||||
|
startRegion,
|
||||||
MOVES_PER_LOCAL_OPS,
|
MOVES_PER_LOCAL_OPS,
|
||||||
MOVES_PER_LOCAL_OPS_NIGHT,
|
MOVES_PER_LOCAL_OPS_NIGHT,
|
||||||
STAGES_PER_DAY,
|
STAGES_PER_DAY,
|
||||||
STAGES_PER_SHIFT,
|
STAGES_PER_SHIFT,
|
||||||
houseRules,
|
houseRules,
|
||||||
officeProfile,
|
officeProfile,
|
||||||
REGIONS_PER_MAINLINE_CARD,
|
|
||||||
mainlineProfile,
|
mainlineProfile,
|
||||||
} from './content.ts';
|
} from './content.ts';
|
||||||
import type { Direction } from './content.ts';
|
import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||||
import type { GameEvent } from './events.ts';
|
import type { GameEvent } from './events.ts';
|
||||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
// `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.
|
// the legality test cannot disagree about which train is being assembled.
|
||||||
@@ -451,6 +451,104 @@ function badlyMadeUp(tray: CrewTray): string | null {
|
|||||||
return caboose === rear ? null : 'not made up — the caboose must be at the rear of the train';
|
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. */
|
/** Puts a train onto a Mainline card with its crossing time already computed. */
|
||||||
function enterMainline(
|
function enterMainline(
|
||||||
s: GameState,
|
s: GameState,
|
||||||
@@ -458,17 +556,9 @@ function enterMainline(
|
|||||||
id: TrayId,
|
id: TrayId,
|
||||||
tray: CrewTray,
|
tray: CrewTray,
|
||||||
index: number,
|
index: number,
|
||||||
|
startsAtBack = false,
|
||||||
): void {
|
): void {
|
||||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
const stages = crossingStages(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node)));
|
||||||
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',
|
|
||||||
);
|
|
||||||
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
|
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
|
||||||
tray.position = { at: 'mainline', index };
|
tray.position = { at: 'mainline', index };
|
||||||
// It is running now, so it is no longer being assembled (state.ts). A train at a Division Point
|
// It is running now, so it is no longer being assembled (state.ts). A train at a Division Point
|
||||||
@@ -577,6 +667,10 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
|||||||
if (clearance === 'blocked') return 'held';
|
if (clearance === 'blocked') return 'held';
|
||||||
if (clearance === 'ask') return 'needsClearance';
|
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);
|
enterMainline(s, node, id, tray, target);
|
||||||
const dp = s.division.nodes[dpIndex];
|
const dp = s.division.nodes[dpIndex];
|
||||||
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
|
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
|
||||||
@@ -636,6 +730,11 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
|||||||
if (clearance === 'blocked') return 'held';
|
if (clearance === 'blocked') return 'held';
|
||||||
if (clearance === 'ask') return 'needsClearance';
|
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);
|
enterMainline(s, node, id, tray, target);
|
||||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||||
events.push({
|
events.push({
|
||||||
@@ -696,8 +795,19 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
|||||||
}
|
}
|
||||||
if (clearance === 'ask') return 'needsClearance';
|
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);
|
node.holding = node.holding.filter((t) => t !== id);
|
||||||
enterMainline(s, node, id, tray, index);
|
enterMainline(s, node, id, tray, index, true);
|
||||||
events.push({
|
events.push({
|
||||||
type: 'trainHighballed',
|
type: 'trainHighballed',
|
||||||
trainNumber: tray.trainNumber ?? 0,
|
trainNumber: tray.trainNumber ?? 0,
|
||||||
@@ -729,21 +839,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.
|
* ABS Signals does what it says instead: the follower stops SHORT of the collision and holds.
|
||||||
*/
|
*/
|
||||||
const regionOf = (t: { stagesTotal: number; stagesRemaining: number }): number => {
|
// NOT on a card that prints "trains may pass" — Double Track holds two trains because it HAS
|
||||||
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
|
// two roads, so a train catching another there goes past it. That is what the card is for.
|
||||||
const elapsed = t.stagesTotal - t.stagesRemaining;
|
//
|
||||||
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
|
// 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
|
||||||
// NOT on a card that prints "trains may pass". Double Track and Uncontrolled Siding hold two
|
// them share a place. Marking it "may pass" skipped this test entirely and made the siding do
|
||||||
// trains because they HAVE two roads, so a train catching another there goes past it — that
|
// nothing at all.
|
||||||
// is what the card is for. Without this the mechanic fired 0.41 times a game while the bot
|
|
||||||
// never once granted clearance, which is the tell: those were all passing cards.
|
|
||||||
const mayPass = mainlineProfile(node.card).trainsMayPass;
|
const 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
|
const ahead = mayPass
|
||||||
? undefined
|
? undefined
|
||||||
: node.transits.find(
|
: 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) {
|
if (ahead) {
|
||||||
@@ -882,10 +993,27 @@ function evaluateClearance(
|
|||||||
// they are not, and the protection belongs where the train it protects actually is.
|
// they are not, and the protection belongs where the train it protects actually is.
|
||||||
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
|
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
|
||||||
|
|
||||||
// ABS Signals — "trains on this card will not rear-end each other; they stop short of a
|
/**
|
||||||
// collision". With signals in place a following train simply holds, and the Superintendent has
|
* ABS Signals — "this is played on a mainline card to prevent collisions. If a collision would
|
||||||
// no judgment call to make. This is the amendment to Gap 2's unconditional collisions.
|
* normally occur, the train moving onto the card is instead held back" (RAR, Gitea#3).
|
||||||
if (onNode?.kind === 'mainline' && onNode.absSignals) return 'blocked';
|
*
|
||||||
|
* 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).
|
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
||||||
@@ -1041,6 +1169,18 @@ function collide(
|
|||||||
if (n.holding) n.holding = n.holding.filter((t) => t !== id);
|
if (n.holding) n.holding = n.holding.filter((t) => t !== id);
|
||||||
if (n.redFlagged) n.redFlagged = n.redFlagged.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lost.length > 0) {
|
if (lost.length > 0) {
|
||||||
|
|||||||
+77
-23
@@ -77,6 +77,7 @@ import {
|
|||||||
facilityVariants,
|
facilityVariants,
|
||||||
opposite,
|
opposite,
|
||||||
reachableDestinations,
|
reachableDestinations,
|
||||||
|
rowEndAt,
|
||||||
variantsFor,
|
variantsFor,
|
||||||
withinLimits,
|
withinLimits,
|
||||||
} from './track.ts';
|
} from './track.ts';
|
||||||
@@ -331,6 +332,15 @@ function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCo
|
|||||||
|
|
||||||
if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED';
|
if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED';
|
||||||
if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,14 +427,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"
|
* A Timetabled or Extra train card (§6.2) — the one place that decides what "a train card" means.
|
||||||
* means, so the rule, the UI's reason text and any test all ask the same question.
|
|
||||||
*/
|
*/
|
||||||
export function isTrainCard(s: GameState, cardId: CardId): boolean {
|
export function isTrainCard(s: GameState, cardId: CardId): boolean {
|
||||||
const kind = s.cards.get(cardId)?.kind.kind;
|
const kind = s.cards.get(cardId)?.kind.kind;
|
||||||
return kind === 'timetabledTrain' || kind === 'extraTrain';
|
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
|
* 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.
|
* all use, so a placement can never be checked against one square and made on another.
|
||||||
@@ -563,6 +601,21 @@ function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
|
|||||||
|
|
||||||
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
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?
|
* May this train work these freight cars on this square?
|
||||||
*
|
*
|
||||||
@@ -662,7 +715,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
|
* 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.
|
* reported "no train at the Office", which is both wrong and unhelpful.
|
||||||
*/
|
*/
|
||||||
function passengerRefusal(
|
export function passengerRefusal(
|
||||||
s: GameState,
|
s: GameState,
|
||||||
player: PlayerIndex,
|
player: PlayerIndex,
|
||||||
at: GridCoord,
|
at: GridCoord,
|
||||||
@@ -777,7 +830,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
*/
|
*/
|
||||||
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
||||||
if (rules.dropOnly) 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;
|
const freight = fresh.filter(isFreight).length;
|
||||||
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
||||||
}
|
}
|
||||||
@@ -881,30 +934,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
|
* `keepReason` holds the rule; this asks it. A Timetabled train is discardable unless the
|
||||||
* pressure on it — but it never goes onto a Department pile. The consequence is the point of the
|
* `discardTimetabled` house rule is off, and an Extra never is.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Extras count. They are trains — Jesse's ruling in the same breath — even though an Extra runs
|
* WHERE THE DISCARD GOES IS THE OTHER HALF OF THE RULING. "If someone else wants to pick it up,
|
||||||
* once and ends in the Salvage Yard while a Timetabled card joins the timetable for the rest of
|
* they are more than able to" — a discard goes face-up on a Department pile, which is exactly
|
||||||
* the game.
|
* 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
|
* The corner Gitea#6 created still exists when the setting is off, and is still deliberate: a
|
||||||
* stops being offered for these cards; the bot needs no separate rule and already reaches for
|
* player holding four undiscardable trains has one way forward, which is to PLAY one. `draw.end`
|
||||||
* `card.play` before it reaches for a discard.
|
* 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': {
|
case 'card.discard': {
|
||||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||||
const hand = s.decks.hands.get(player) ?? [];
|
const hand = s.decks.hands.get(player) ?? [];
|
||||||
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
||||||
if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,7 +972,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
||||||
const on = node.modifiers ?? [];
|
const on = node.modifiers ?? [];
|
||||||
if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN';
|
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';
|
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
|
// "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
|
// restriction exists to prevent. A train standing in the Interchange's yard counts: it is on
|
||||||
@@ -1428,7 +1480,7 @@ export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, rev
|
|||||||
const facing = facingPort(s, trayId);
|
const facing = facingPort(s, trayId);
|
||||||
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
|
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
|
||||||
const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard();
|
const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard();
|
||||||
return cutTowards(card, carsOn(card), exit);
|
return cutTowards(card, carsOn(card), rowEndAt(card, exit));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1546,14 +1598,16 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
*/
|
*/
|
||||||
const grid = areaOf(s, player).grid;
|
const grid = areaOf(s, player).grid;
|
||||||
const startCard = grid.get(coordKey(from)) ?? emptyCard();
|
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 = [
|
const lifted = [
|
||||||
...(startCut.length > 0 ? [from] : []),
|
...(startCut.length > 0 ? [from] : []),
|
||||||
...dest.path.map((step) => step.coord),
|
...dest.path.map((step) => step.coord),
|
||||||
i.to,
|
i.to,
|
||||||
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
||||||
const sides = standingSides(startCard, carsOn(startCard));
|
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
|
// §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
|
// 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
|
// up, they couple behind. Which end they land on is the whole point of a run-around: it
|
||||||
|
|||||||
+290
-165
@@ -72,6 +72,26 @@ export type TrackProfile = {
|
|||||||
* from `docs/Deck cards2.xlsx`, a fixed document, and stay here as the audit trail for the
|
* 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.
|
* 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
|
* 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
|
* 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.
|
* 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.
|
* published replay still plays. Reordering to look tidy would silently re-deal every saved game.
|
||||||
*/
|
*/
|
||||||
export const TRACK_CARDS: readonly TrackProfile[] = [
|
export const TRACK_CARDS: readonly TrackProfile[] = [
|
||||||
{ geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 32, 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: 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: 16, 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.
|
* 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: '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: '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: 'right', name: 'Turnout (right)', copiesInDeck: 8, isOperationalRail: false, moveCost: 1 },
|
||||||
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, 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. */
|
/** 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).
|
* 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
|
* THE Q12 DOUBLING IS GONE (Gitea#14). Every tier used to be dealt at twice the sheet, to remove a
|
||||||
* automatic collision (§8.3, Gap 2a). Measured at the original density, 25 of 100 games never drew
|
* 25% chance of an unwinnable opening deal: players always start at a Whistle Post, which has ONE
|
||||||
* a Depot and never escaped: they averaged **−6.0** revenue against **−0.4** for games that
|
* A/D track, so a second arrival is an automatic collision (§8.3, Gap 2a), and measured at the
|
||||||
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post.
|
* 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
|
* 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
|
* their raw counts imply — Terminal needs all three cards in order.
|
||||||
* doubled with Depot to keep that ladder in proportion rather than making Depot a special case.
|
|
||||||
*
|
*
|
||||||
* PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal,
|
* IF THE OPENING BITES AGAIN, the fix is not to re-double this. The note it replaces already listed
|
||||||
* not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and
|
* the better options: fewer Terminals, a cheaper first upgrade, or more A/D capacity at a Whistle
|
||||||
* dilutes every other category slightly. Revisit once the victory target is settled and freight is
|
* Post. Any of those answers the collision without diluting every other category to do it.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
|
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: '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: '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: 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: 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'];
|
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
|
* Industry density, at `docs/Deck cards5.xlsx`'s counts exactly (Gitea#14).
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Each industry's `copies` is TRIPLED against the sheet, which restores roughly the prototype's
|
* THE GAP-12 TRIPLING IS GONE. The recovered sheet listed 9 industries in a 115-card deck and the
|
||||||
* ratio while preserving the sheet's proportions exactly: the outbound/inbound balance and the
|
* prototype ran 10 in 52; at that density a game saw 1.6 Freight Facilities, freight was 10% of
|
||||||
* lockout structure are unchanged, because every kind scales by the same factor. The multiplier is
|
* gross revenue, and `carsCoupled` fired 4 times per 100 games, so the freight loop effectively
|
||||||
* the decision; the resulting totals are in the rows below and move with every retune.
|
* 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:
|
* 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.
|
* enforced for every kind in `isLockedOut`, not repeated in each row here.
|
||||||
*/
|
*/
|
||||||
export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
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: '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: 6 },
|
{ 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
|
* 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".
|
* 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.
|
* 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".
|
* `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: '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: 6 },
|
{ 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: 3 },
|
{ 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
|
* 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
|
* 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
|
* other example. The Truck Dock (+1 inbound) and Local Small Groceries (+1 Laborer) are the two
|
||||||
* that do work here.
|
* 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. */
|
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
|
||||||
@@ -504,23 +543,35 @@ export type MainlineKind =
|
|||||||
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange';
|
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade prints
|
* THE PRINTED SPEEDS ARE GRAPHICS. RAR, 2026-08-26 (Gitea#3): "please ignore the speed signs I put
|
||||||
* "G" — no number at all, plus "Player sets orientation", **which the game deliberately does not do**
|
* on the cards — those are nothing but scene-setting graphics that mimic the speed you are
|
||||||
* (see `gradeReduction` below, and implications.md §10 Q11).
|
* travelling. It's just ambiance, nothing more."
|
||||||
*
|
*
|
||||||
* WHAT THESE NUMBERS MEAN IS NOT YET SETTLED — see implications.md §10 Q2. Transcribed as data so
|
* They used to decide everything: a `MainlineSpeed` of 60 meant one Stage and a 30 meant two, plus
|
||||||
* the answer can be applied without re-reading the cards.
|
* 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 = {
|
export type MainlineProfile = {
|
||||||
kind: MainlineKind;
|
kind: MainlineKind;
|
||||||
name: string;
|
name: string;
|
||||||
speed: MainlineSpeed;
|
/** Regions printed on the card. A train advances one per Stage, so a full run costs `regions`. */
|
||||||
/** Double Track and Uncontrolled Siding: "Trains may pass". */
|
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;
|
trainsMayPass: boolean;
|
||||||
/** Interchange: "Sort cars in new order". */
|
/** Interchange: "Sort cars in new order". */
|
||||||
sortsCars: boolean;
|
sortsCars: boolean;
|
||||||
@@ -529,30 +580,56 @@ export type MainlineProfile = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
||||||
{ kind: 'plains', name: 'Plains', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
{ kind: 'plains', name: 'Plains', regions: 1, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||||
{ kind: 'curves', name: 'Curves', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
{ kind: 'curves', name: 'Curves', regions: 2, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||||
{ kind: 'hilly', name: 'Hilly', speed: { kind: 'byTrainType', passenger: 60, freight: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['passenger', 'freight'] },
|
|
||||||
/**
|
/**
|
||||||
* `entryPoints` is TRANSCRIBED, NOT READ — nothing anywhere reads this field on any profile, and
|
* THE ONLY CARD THAT READS A TRAIN'S FAST/SLOW RATING. A fast train starts in the second region
|
||||||
* the printed start positions are not modelled: `crossingStages` counts Stages instead. Recorded
|
* and is across in one Stage; a slow one starts at the beginning and takes two.
|
||||||
* 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
|
* It used to read the CONSIST instead — any coach aboard made the train "passenger" for this card
|
||||||
* settled against `Mainline Cards.pdf` if the starts are ever implemented — not quietly "fixed"
|
* — off the printed P60/F30. RAR corrected that directly: "I notice that you are basing stages in
|
||||||
* now, when nothing depends on it either way.
|
* 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: 'hilly', name: 'Hilly', regions: 2, defaultStart: 0, speedStarts: { fast: 1, slow: 0 }, trainsMayPass: false, sortsCars: false, entryPoints: ['fast', 'slow'] },
|
||||||
{ 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'] },
|
|
||||||
/**
|
/**
|
||||||
* RENAMED FROM "Yard" after play. The card is unchanged — same 60, same "sort cars in new
|
* THREE REGIONS, AND THE MODIFIERS MOVE THE START RATHER THAN CUTTING THE TIME — which comes to
|
||||||
* order", same entry points, same art — but "Yard" collided with the Division Yard, the
|
* the same number of Stages and is how the card is actually printed and played. "If you play the
|
||||||
* Classification Yard, the Salvage Yard, the Yard Office and the Small Yard, none of which are
|
* home deck card 'helpers' against the mainline card heavy grade, it remains there the rest of the
|
||||||
* this. Its own key is renamed with it, so the two never drift apart. Those OTHER yards are
|
* game and helps all trains going up hill by starting 1 region easier — so 2 to traverse, not 3.
|
||||||
* deliberately left alone: they are different things that merely shared a word.
|
* 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 +656,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,
|
* "Regions shown on cards indicate how many stages it takes to cross. Plains is 1. Double track is
|
||||||
* a 30 card takes two. The cells drawn on the cards are decoration.
|
* 1, tunnel is 2, curves is 2, heavy grade is 3 unless you have help." A train advances one region
|
||||||
* Q2 — a Slow train adds one Stage to every card.
|
* 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
|
* FOUR THINGS MOVE THE START, and nothing else does:
|
||||||
* coach is "passenger" for this purpose.
|
*
|
||||||
|
* 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(
|
export type MainlineEntry = {
|
||||||
kind: MainlineKind,
|
trainSpeed: TrainSpeed;
|
||||||
trainSpeed: TrainSpeed,
|
direction: Direction;
|
||||||
carriesPassengers: boolean,
|
gradeUp: Direction;
|
||||||
modifiers: readonly string[] = [],
|
modifiers: readonly string[];
|
||||||
direction: Direction = 'east',
|
/**
|
||||||
gradeUp: Direction = 'east',
|
* The Uncontrolled Siding with a train already on it: this one takes the siding and sits a region
|
||||||
): number {
|
* behind, which is what keeps them out of the same place. Also the Interchange, where an Extra
|
||||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
* beginning its run starts in the holding region rather than on the road.
|
||||||
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
*/
|
||||||
|
startsAtBack?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
let mph: number;
|
export function startRegion(kind: MainlineKind, entry: MainlineEntry): number {
|
||||||
switch (profile.speed.kind) {
|
const profile = mainlineProfile(kind);
|
||||||
case 'uniform':
|
if (entry.startsAtBack) return 0;
|
||||||
mph = profile.speed.value;
|
|
||||||
break;
|
const base = profile.speedStarts
|
||||||
case 'byTrainType':
|
? profile.speedStarts[entry.trainSpeed]
|
||||||
mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight;
|
: profile.defaultStart;
|
||||||
break;
|
|
||||||
case 'grade':
|
const climbing = entry.direction === entry.gradeUp;
|
||||||
// Heavy Grade has no printed number; the modifier cards are what improve it, so it is a 30
|
let help = 0;
|
||||||
// until one is placed.
|
if (kind === 'heavyGrade') {
|
||||||
mph = 30;
|
if (climbing) {
|
||||||
break;
|
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;
|
/** How many Stages a train needs to cross a Mainline card — the regions it has left to run. */
|
||||||
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
|
export function crossingStages(kind: MainlineKind, entry: MainlineEntry): number {
|
||||||
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
|
return mainlineProfile(kind).regions - startRegion(kind, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -634,35 +730,50 @@ export function crossingStages(
|
|||||||
export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'east'): string {
|
export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'east'): string {
|
||||||
const p = mainlineProfile(kind);
|
const p = mainlineProfile(kind);
|
||||||
const stages = (n: number): string => `${n} Stage${n === 1 ? '' : 's'}`;
|
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[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (p.speed.kind === 'byTrainType') {
|
parts.push(`${p.regions} region${p.regions === 1 ? '' : 's'} — one Stage each.`);
|
||||||
// Hilly. The split is by CONSIST, not by the train's speed class: anything with a coach on it
|
|
||||||
// takes the passenger figure.
|
if (p.speedStarts) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`P${p.speed.passenger} / F${p.speed.freight} — a train carrying ANY coach crosses as a ` +
|
`This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses ` +
|
||||||
`${p.speed.passenger} (${stages(crossingStages(kind, 'fast', true))} for a fast train), and a ` +
|
`in ${stages(run({ trainSpeed: 'fast' }))}, a slow one in ${stages(run({ trainSpeed: 'slow' }))}. ` +
|
||||||
`freight-only train as a ${p.speed.freight} (${stages(crossingStages(kind, 'fast', false))}). ` +
|
`No other card cares which it is.`,
|
||||||
`A slow train adds one Stage either way.`,
|
|
||||||
);
|
);
|
||||||
} else if (p.speed.kind === 'grade') {
|
} else if (kind === 'heavyGrade') {
|
||||||
parts.push(
|
parts.push(
|
||||||
`A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. It crosses as a 30 — ` +
|
`A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. ` +
|
||||||
`${stages(crossingStages(kind, 'fast', false, [], gradeUp, gradeUp))} for a fast train, and one ` +
|
`${stages(run({ direction: gradeUp }))} to climb it and ${stages(run({ direction: gradeUp === 'east' ? 'west' : 'east' }))} to run down, ` +
|
||||||
`more for a slow one. Brakeman and Airbrakes each take a Stage off a train running DOWNHILL; ` +
|
`before help. Helpers start an UPHILL train a region further on; Brakeman does the same ` +
|
||||||
`Helpers takes one off a train running UPHILL. Never below one Stage.`,
|
`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 {
|
} else {
|
||||||
|
parts.push(`${stages(run({}))} for every train — the printed speed is scenery.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'uncontrolledSiding') {
|
||||||
parts.push(
|
parts.push(
|
||||||
`${p.speed.value} — ${stages(crossingStages(kind, 'fast', false))} for a fast train, ` +
|
'UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region ' +
|
||||||
`${stages(crossingStages(kind, 'slow', false))} for a slow one.`,
|
'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) {
|
if (p.trainsMayPass) {
|
||||||
parts.push(
|
parts.push(
|
||||||
'TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held ' +
|
'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 {
|
} else {
|
||||||
parts.push('One train at a time — anything following has to wait for it to clear.');
|
parts.push('One train at a time — anything following has to wait for it to clear.');
|
||||||
@@ -672,43 +783,7 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
|
|||||||
return parts.join(' · ');
|
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 {
|
export function mainlineProfile(kind: MainlineKind): MainlineProfile {
|
||||||
const p = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
const p = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||||
@@ -769,7 +844,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: '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: '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: '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: '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: '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.' },
|
{ key: 'tenderloin', name: 'Tenderloin District', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
|
||||||
@@ -876,16 +952,32 @@ export function enhancementRule(key: string): EnhancementRule | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
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: 'interlocking', name: 'Interlocking', copies: 1, 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' },
|
// 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: '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: '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: '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.' },
|
* THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment
|
||||||
{ key: 'radio', name: 'Radio', copies: 2, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' },
|
* Poling and the sharp curves already get, and for the same reason.
|
||||||
{ 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.' },
|
*
|
||||||
|
* `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[] = [
|
export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
|
||||||
@@ -893,7 +985,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: '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: '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: '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 +997,9 @@ export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
|
|||||||
export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', copies: 1 };
|
export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', copies: 1 };
|
||||||
|
|
||||||
export const MANEUVER_CARDS: readonly SimpleCard[] = [
|
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: '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.' },
|
||||||
{ 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.' },
|
// 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.
|
// 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
|
// It is the one card whose effect the source records as "TBD", so there is nothing to implement
|
||||||
@@ -922,7 +1016,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: '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: '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: '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: '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.' },
|
{ 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 +1067,6 @@ export function crewTrayCount(players: number): number {
|
|||||||
return players + 3;
|
return players + 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const REGIONS_PER_MAINLINE_CARD = 2; // provisional, pending §10 Q2
|
|
||||||
export const STAGES_PER_DAY = 12;
|
export const STAGES_PER_DAY = 12;
|
||||||
export const STAGES_PER_SHIFT = 3;
|
export const STAGES_PER_SHIFT = 3;
|
||||||
export const HAND_LIMIT = 3;
|
export const HAND_LIMIT = 3;
|
||||||
@@ -1058,13 +1152,36 @@ export type RevenueRules = {
|
|||||||
*/
|
*/
|
||||||
export type ExtraStartRule = 'divisionPointsOnly' | 'ownOffice' | 'anyOffice';
|
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()`. */
|
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
|
||||||
export type HouseRuleOverrides = {
|
export type HouseRuleOverrides = {
|
||||||
startingHand?: StartingHand;
|
startingHand?: StartingHand;
|
||||||
revenue?: Partial<RevenueRules>;
|
revenue?: Partial<RevenueRules>;
|
||||||
extraStart?: ExtraStartRule;
|
extraStart?: ExtraStartRule;
|
||||||
|
discardTimetabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
||||||
@@ -1078,6 +1195,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
|
// 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.
|
// its testers in the middle of a bugfix release.
|
||||||
extraStart: 'anyOffice',
|
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 +1215,9 @@ export const LEGACY_HOUSE_RULES: HouseRules = {
|
|||||||
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
|
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
|
||||||
// An Extra could always be started at a Control Point in these games, in any district.
|
// An Extra could always be started at a Control Point in these games, in any district.
|
||||||
extraStart: 'anyOffice',
|
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. */
|
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
||||||
@@ -1113,6 +1237,7 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
|
|||||||
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
|
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
|
||||||
},
|
},
|
||||||
extraStart: given.extraStart ?? d.extraStart,
|
extraStart: given.extraStart ?? d.extraStart,
|
||||||
|
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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');
|
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 card = deck.splice(rng.nextInt(deck.length), 1)[0]!;
|
||||||
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
|
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
|
* 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
|
* "Player sets orientation". A Heavy Grade sits on the shared west-to-east chain BETWEEN two
|
||||||
|
|||||||
+12
-9
@@ -771,22 +771,25 @@ export function standingSides(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The cut a train would run into if it left this card through `exit` — the cars between it and that
|
* The cut a train would run into if it left this card by the `exit` END OF THE ROW — the cars
|
||||||
* end of the card.
|
* 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
|
* `exit` IS AN END OF THE ROW, NOT A PORT. It used to be a raw `Port`, and answered "you meet
|
||||||
* south off a curve or a spur is not running along it and meets nothing. Returned in the order the
|
* nothing" for north and south on the reasoning that a leg leaving through an edge is not running
|
||||||
* train MEETS them, nearest first, which is what `carsCoupled` wants.
|
* 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(
|
export function cutTowards(
|
||||||
tray: { standingWest?: number | undefined },
|
tray: { standingWest?: number | undefined },
|
||||||
cars: readonly RollingStock[],
|
cars: readonly RollingStock[],
|
||||||
exit: 'n' | 's' | 'e' | 'w',
|
exit: 'e' | 'w',
|
||||||
): RollingStock[] {
|
): RollingStock[] {
|
||||||
const { west, east } = standingSides(tray, cars);
|
const { west, east } = standingSides(tray, cars);
|
||||||
if (exit === 'e') return east;
|
return exit === 'e' ? east : [...west].reverse();
|
||||||
if (exit === 'w') return [...west].reverse();
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
|
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
|
||||||
|
|||||||
+63
-6
@@ -188,6 +188,40 @@ export function joins(a: TrackCard, p: Port, b: TrackCard): boolean {
|
|||||||
return slopeAt(a, p) === slopeAt(b, opposite(p));
|
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)
|
// Orientation (Gap 11)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -450,7 +484,7 @@ export function exploreMoves(
|
|||||||
*
|
*
|
||||||
* Ordered nearest-first like every other card's, so it simply seeds the accumulator.
|
* 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 startKey = coordKey(start);
|
||||||
const queue: Frontier[] = [
|
const queue: Frontier[] = [
|
||||||
{
|
{
|
||||||
@@ -502,12 +536,15 @@ export function exploreMoves(
|
|||||||
* overfill the tray is illegal, not a move that picks up fewer cars.
|
* 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
|
* 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
|
* at the row's EAST end meets them back to front and the row has to be reversed. Without this
|
||||||
* this the same parked cut produced an identical consist whichever way it was approached, when
|
* the same parked cut produced an identical consist whichever way it was approached, when the
|
||||||
* the two must mirror — which is the difference between a run-around being worth a Move and
|
* two must mirror — which is the difference between a run-around being worth a Move and being
|
||||||
* being pointless.
|
* 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 couples = [...node.couples, ...met];
|
||||||
const nodeKey = coordKey(node.coord);
|
const nodeKey = coordKey(node.coord);
|
||||||
const origins = [...node.origins, ...met.map(() => nodeKey)];
|
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.
|
// into a stub and cutting the Office off from the Limits.
|
||||||
if (coord.row === area.runningRow && !carriesThroughTrack(card)) return false;
|
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'];
|
const ports: Port[] = ['n', 's', 'e', 'w'];
|
||||||
for (const p of ports) {
|
for (const p of ports) {
|
||||||
const neighbourCard = cardAt(area, neighbour(coord, p));
|
const neighbourCard = cardAt(area, neighbour(coord, p));
|
||||||
|
|||||||
+190
-157
@@ -65,7 +65,30 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
* anything outside its own body.
|
* anything outside its own body.
|
||||||
*/
|
*/
|
||||||
const CW = { dp: 118, ml: 152, run: 78 };
|
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;
|
const GAP = 6;
|
||||||
/**
|
/**
|
||||||
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
|
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
|
||||||
@@ -115,6 +138,12 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
seat: number | null;
|
seat: number | null;
|
||||||
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
||||||
owner?: { name: string; isTurn: boolean; isYou: boolean } | null;
|
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. */
|
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||||||
regions: number;
|
regions: number;
|
||||||
w: number;
|
w: number;
|
||||||
@@ -122,11 +151,8 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
const cells: Cell[] = [];
|
const cells: Cell[] = [];
|
||||||
const sides: number[][] = [];
|
|
||||||
let side: number[] = [];
|
|
||||||
|
|
||||||
const push = (c: Omit<Cell, 'x' | 'y'>): void => {
|
const push = (c: Omit<Cell, 'x' | 'y'>): void => {
|
||||||
side.push(cells.length);
|
|
||||||
cells.push({ ...c, x: 0, y: 0 });
|
cells.push({ ...c, x: 0, y: 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,47 +178,54 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
for (const rc of n.running ?? []) {
|
/**
|
||||||
const isOffice = rc.kind === 'office';
|
* ONE CELL PER DISTRICT — NO OFFICE-AREA DETAIL ON THIS MAP (Gitea#18).
|
||||||
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
|
*
|
||||||
push({
|
* An Office used to expand into its whole Running Track, Limits to Limits, so this map carried
|
||||||
kind: 'run',
|
* every straight, turnout, facility and Limits sign of every district. Two things were wrong
|
||||||
label: isOffice && owner ? owner.name : rc.label,
|
* with that. It is the OFFICE map's job, and it draws all of it properly, with the rails; and
|
||||||
owner: isOffice ? owner : null,
|
* it made the Division map grow sideways as districts were built, shoving everything east of a
|
||||||
// With an owner on the headline the tier would otherwise vanish, so it joins the A/D
|
* district along every time somebody laid a card.
|
||||||
// count on the line below.
|
*
|
||||||
sub: isOffice ? (owner ? [rc.label, adLabel].filter(Boolean).join(' · ') : adLabel) : '',
|
* 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.
|
||||||
* A train standing at the Office occupies an A/D track, which is where it is — but it is
|
* They are split into two registers, because a train holding an A/D track and a crew switching
|
||||||
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
|
* in the district are not the same thing: A/D occupancy is a hard capacity that causes
|
||||||
* drawn twice. Reported as two T10 chips on one Office.
|
* 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.
|
||||||
trains: isOffice
|
*/
|
||||||
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
|
const seen = new Set(ad.map((t) => t.label));
|
||||||
: rc.trains,
|
const below: typeof ad = [];
|
||||||
cap: isOffice ? cap : null,
|
for (const t of [...(n.running ?? []).flatMap((rc) => rc.trains), ...(n.switching ?? [])]) {
|
||||||
tip: owner && isOffice
|
if (seen.has(t.label)) continue;
|
||||||
? `${owner.name}'s ${rc.label}` +
|
seen.add(t.label);
|
||||||
(owner.isYou ? ' — this is your railroad' : '') +
|
below.push(t);
|
||||||
// "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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// A crew below the Running Track has no position ON it, so it is reported against the
|
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
|
||||||
// district rather than drawn somewhere it is not.
|
push({
|
||||||
const below = n.switching ?? [];
|
kind: 'run',
|
||||||
if (below.length > 0) {
|
label: owner ? owner.name : n.label,
|
||||||
const last = cells[cells.length - 1];
|
owner,
|
||||||
if (last) last.sub = `${below.length} switching below`;
|
sub: [owner ? n.label : '', adLabel, below.length > 0 ? `${below.length} switching` : '']
|
||||||
}
|
.filter(Boolean)
|
||||||
sides.push(side);
|
.join(' \u00b7 '),
|
||||||
side = [];
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const dp = n.kind === 'dp';
|
const dp = n.kind === 'dp';
|
||||||
@@ -229,58 +262,31 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
w: dp ? CW.dp : CW.ml,
|
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.
|
* ONE ROW, WEST TO EAST (Gitea#18). The West Division Point is at the far left, the East at the
|
||||||
const seats = Math.max(1, Math.min(4, nodes.filter((n) => n.kind === 'office').length));
|
* far right, and nothing wraps.
|
||||||
const lanes: number[][] = [];
|
*
|
||||||
for (let i = 0; i < seats; i++) lanes.push([]);
|
* IT USED TO BE LAID OUT AROUND A TABLE — one row for a single seat, two facing rows for two, a
|
||||||
sides.forEach((grp, i) => {
|
* horseshoe for three, a square for four — on the reasoning that players sit around a table so the
|
||||||
const target = Math.min(i, seats - 1);
|
* route should too. That cost more than it bought, and three separate reports came out of it: the
|
||||||
for (const idx of grp) lanes[target]!.push(idx);
|
* 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
|
||||||
// -- lay the sides out around the table -------------------------------------------------------
|
* a train is going.
|
||||||
// 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.
|
* A row is wider than a square — roughly 1,580px at four players against 842 — and that is
|
||||||
const dir: ('top' | 'right' | 'bottom' | 'left')[] =
|
* accepted: the map scrolls and zooms, and being able to rely on east meaning right is worth the
|
||||||
seats === 1 ? ['top'] : seats === 2 ? ['top', 'bottom'] : seats === 3 ? ['top', 'right', 'bottom'] : ['top', 'right', 'bottom', 'left'];
|
* scroll.
|
||||||
|
*/
|
||||||
const runLen = (idxs: number[]): number =>
|
let x = PAD;
|
||||||
idxs.reduce((n, i) => n + cells[i]!.w + GAP, -GAP);
|
for (const c of cells) {
|
||||||
const widest = Math.max(...lanes.map((l) => runLen(l)), 200);
|
c.x = x;
|
||||||
const tall = lanes.length > 1 ? Math.max(...lanes.map((l) => l.length), 1) * (CH + GAP) : CH;
|
c.y = PAD;
|
||||||
|
x += c.w + GAP;
|
||||||
const vertCount = dir.filter((d) => d === 'right' || d === 'left').length;
|
}
|
||||||
const boardW = PAD * 2 + widest + (vertCount > 0 ? CW.run + SIDE_GAP : 0);
|
const boardW = x - GAP + PAD;
|
||||||
const boardH = PAD * 2 + (dir.includes('bottom') ? CH * 2 + SIDE_GAP + (vertCount ? tall : 0) : CH) + 30;
|
const boardH = PAD * 2 + 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// -- draw -------------------------------------------------------------------------------------
|
// -- draw -------------------------------------------------------------------------------------
|
||||||
const rail = (x1: number, y: number, x2: number): string => {
|
const rail = (x1: number, y: number, x2: number): string => {
|
||||||
@@ -295,20 +301,29 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
return o;
|
return o;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The same rail turned through ninety degrees, for the sides of the table.
|
/**
|
||||||
const railV = (x: number, y1: number, y2: number): string => {
|
* DRAWN AT ITS OWN SIZE, SO IT SCROLLS RATHER THAN SHRINKING (Gitea#18).
|
||||||
let o =
|
*
|
||||||
`<line class="bs-rail" x1="${x - 2.5}" y1="${y1}" x2="${x - 2.5}" y2="${y2}"/>` +
|
* An SVG has a viewBox and a drawn size, and the browser scales one to the other. `.bs` is
|
||||||
`<line class="bs-rail" x1="${x + 2.5}" y1="${y1}" x2="${x + 2.5}" y2="${y2}"/>`;
|
* `width:100%`, so the map is drawn at whatever the panel is wide — which was harmless while the
|
||||||
const n = Math.max(2, Math.floor(Math.abs(y2 - y1) / 9));
|
* Division was 842px and wrapped around a table, and is not now that a single row is 1,580px. At
|
||||||
for (let i = 0; i <= n; i++) {
|
* that width in an 800px panel every label renders at half size, on the map that needs reading
|
||||||
const ty = y1 + ((y2 - y1) * i) / n;
|
* most. Setting the width to the viewBox width makes one unit one pixel, and the containers
|
||||||
o += `<line class="bs-tie" x1="${x - 4.5}" y1="${ty}" x2="${x + 4.5}" y2="${ty}"/>`;
|
* already scroll (`#division`, `#vdivision`).
|
||||||
}
|
*
|
||||||
return o;
|
* 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
|
||||||
let out = `<svg class="bs bs-div" viewBox="0 0 ${Math.ceil(boardW)} ${Math.ceil(boardH)}" preserveAspectRatio="xMinYMin meet">`;
|
* `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
|
// 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
|
// that crosses from one player's side to the next is drawn heavier and labelled: that boundary is
|
||||||
@@ -316,23 +331,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
for (let i = 0; i + 1 < cells.length; i++) {
|
for (let i = 0; i + 1 < cells.length; i++) {
|
||||||
const a = cells[i]!;
|
const a = cells[i]!;
|
||||||
const b = cells[i + 1]!;
|
const b = cells[i + 1]!;
|
||||||
const sameRow = Math.abs(a.y - b.y) < 1;
|
out += rail(a.x + a.w, a.y + RAIL_Y, b.x);
|
||||||
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}"/>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cells.forEach((c) => {
|
cells.forEach((c) => {
|
||||||
@@ -348,15 +347,14 @@ 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 mark = c.owner ? ` bs-owner${c.owner.isTurn ? ' bs-turn' : ''}${c.owner.isYou ? ' bs-you' : ''}` : '';
|
||||||
const suffix = c.owner?.isYou ? ' (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 += `<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>`;
|
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.
|
// REGIONS. A Mainline card is 1 to 3 of them (Gitea#3) and a train advances one per Stage. The
|
||||||
// The bars are the card's DISTANCE and never vary; what varies is how fast a train covers them,
|
// bars are the card's DISTANCE and never vary; where a train STARTS is what does.
|
||||||
// so a 60 card is crossed in one Stage and a slow train on a 30 takes three.
|
|
||||||
const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0;
|
const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0;
|
||||||
for (let r = 0; r < c.regions; r++) {
|
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}"/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -371,36 +369,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
|
* 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.
|
* 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 cars = t.cars ?? [];
|
||||||
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
||||||
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
|
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
|
||||||
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
|
const 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 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 dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
|
||||||
const stages =
|
const stages =
|
||||||
typeof t.stagesLeft === 'number'
|
typeof t.stagesLeft === 'number'
|
||||||
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` +
|
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card`
|
||||||
' (Stages, not regions: a card is two regions of fixed distance, and how many Stages a' +
|
|
||||||
' train takes over them depends on the card speed and the train)'
|
|
||||||
: '';
|
: '';
|
||||||
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
||||||
cars.length ? ` (${loaded} loaded)` : ''
|
cars.length ? ` (${loaded} loaded)` : ''
|
||||||
@@ -409,15 +396,61 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
// me?" gets asked, and EXPEDITED is the answer more often than not.
|
// me?" gets asked, and EXPEDITED is the answer more often than not.
|
||||||
t.what ? `\n\n${esc(t.what)}` : ''
|
t.what ? `\n\n${esc(t.what)}` : ''
|
||||||
}">` +
|
}">` +
|
||||||
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
|
`<rect x="${tx - w / 2}" y="${ty}" width="${w}" height="19" rx="3"/>` +
|
||||||
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(label)}</text>`;
|
`<text class="bs-tlab" x="${tx}" y="${ty + 13}" text-anchor="middle">${esc(label)}</text>`;
|
||||||
out += '</g>';
|
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>';
|
out += '</g>';
|
||||||
});
|
});
|
||||||
|
|
||||||
// THE ENDS. The route stops at both Division Points; drawing buffer stops and naming the gap is
|
// THE ENDS. The route stops at both Division Points, and the buffer stops say so — a Division is
|
||||||
// what stops a seated layout being read as a loop.
|
// 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 first = cells[0];
|
||||||
const last = cells[cells.length - 1];
|
const last = cells[cells.length - 1];
|
||||||
/**
|
/**
|
||||||
|
|||||||
+113
-3
@@ -18,7 +18,7 @@
|
|||||||
import { MAX_CONSIST } from '../engine/content.ts';
|
import { MAX_CONSIST } from '../engine/content.ts';
|
||||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } 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';
|
import type { GameEvent } from '../engine/events.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -524,14 +524,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
|
* 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.
|
* 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[] {
|
export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] {
|
||||||
const out: Impediment[] = [];
|
const out: Impediment[] = [];
|
||||||
const area = areaOf(s, player);
|
const area = areaOf(s, player);
|
||||||
|
|
||||||
for (const [key, card] of area.grid) {
|
for (const [key, card] of area.grid) {
|
||||||
const f = card.facility;
|
const f = card.facility;
|
||||||
if (!f || f.kind !== 'freight') continue;
|
if (!f) continue;
|
||||||
const name = card.geometry.kind === 'facility' ? card.geometry.facility : 'facility';
|
/**
|
||||||
|
* 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);
|
const want = facilityCarType(f);
|
||||||
|
|
||||||
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
|
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
|
||||||
|
|||||||
+35
-31
@@ -10,6 +10,7 @@
|
|||||||
* drift into two different pictures of the same board.
|
* drift into two different pictures of the same board.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { regionOfTransit } from '../engine/advance.ts';
|
||||||
import {
|
import {
|
||||||
areaAtSeat,
|
areaAtSeat,
|
||||||
areaOf,
|
areaOf,
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
laborersLeft,
|
laborersLeft,
|
||||||
movesFor,
|
movesFor,
|
||||||
ownCutFor,
|
ownCutFor,
|
||||||
isTrainCard,
|
keepReason,
|
||||||
portersLeft,
|
portersLeft,
|
||||||
resolveExtraStart,
|
resolveExtraStart,
|
||||||
selectDestination,
|
selectDestination,
|
||||||
@@ -32,7 +33,6 @@ import {
|
|||||||
MANEUVER_CARDS,
|
MANEUVER_CARDS,
|
||||||
MODIFIER_PROFILES,
|
MODIFIER_PROFILES,
|
||||||
REALIGNMENTS,
|
REALIGNMENTS,
|
||||||
REGIONS_PER_MAINLINE_CARD,
|
|
||||||
OFFICE_ORDER,
|
OFFICE_ORDER,
|
||||||
SPACE_USE_CARDS,
|
SPACE_USE_CARDS,
|
||||||
enhancementRule,
|
enhancementRule,
|
||||||
@@ -492,12 +492,22 @@ export type Frame = {
|
|||||||
/**
|
/**
|
||||||
* Whether each hand card may be DISCARDED, in the same order.
|
* 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
|
* The player has to be told which cards those are, not merely find that a button is missing —
|
||||||
* cards those are, not merely find that a button is missing — that silence is the whole of the
|
* that silence is the whole of the Gitea#2 complaint, where a blocked platform left the board with
|
||||||
* Gitea#2 complaint, where a blocked platform left the board with nothing to click and no reason.
|
* nothing to click and no reason. Named for the rule rather than for trains, since it answers the
|
||||||
* Named for the rule rather than for trains, since it answers the question the panel is asking.
|
* question the panel is asking.
|
||||||
*/
|
*/
|
||||||
handDiscardable: boolean[];
|
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;
|
deck: number;
|
||||||
/** The face-up card on top of each Department pile — the only one that may be drawn. */
|
/** The face-up card on top of each Department pile — the only one that may be drawn. */
|
||||||
departments: string[];
|
departments: string[];
|
||||||
@@ -1155,36 +1165,27 @@ export function snapshot(
|
|||||||
// Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it
|
// 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.
|
// with how long each still has to run.
|
||||||
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
|
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.
|
* This used to recover a printed two-region model from a crossing time computed out of the
|
||||||
* The engine crosses in `crossingStages` Stages instead, which varies by card speed, train
|
* card's mph, the train's Fast/Slow class, its consist and any modifiers, by treating the
|
||||||
* speed, passengers and modifiers — so the printed model is recovered by treating the entry
|
* ENTRY point as the thing that varied: `entry = 2 - stagesTotal`. It even had to cope with a
|
||||||
* point as the thing that varies, exactly as the cards do:
|
* negative entry, for a slow train needing three Stages to cross a card with two regions.
|
||||||
*
|
*
|
||||||
* entry = REGIONS - stagesTotal position = entry + elapsed
|
* 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
|
||||||
* A 60 card is one Stage, so the train enters at the second region and is gone — which is
|
* to reconstruct.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
const place = (t: { stagesRemaining: number; stagesTotal: number }): number => {
|
// One region per Stage, straight off the card's own count: what a train has LEFT to run says
|
||||||
// `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into
|
// where it is standing. `regionOfTransit` is the engine's own answer, so the picture and the
|
||||||
// two regions, so it notionally starts before the card and spends the extra Stage getting
|
// collision rule cannot disagree about who is where.
|
||||||
// to the first region. Clamping only the final position keeps that Stage at the START,
|
const place = (t: { stagesRemaining: number }): number => regionOfTransit(n.card, t.stagesRemaining);
|
||||||
// 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));
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
kind: 'ml',
|
kind: 'ml',
|
||||||
label: name,
|
label: name,
|
||||||
regions: REGIONS_PER_MAINLINE_CARD,
|
regions: mainlineProfile(n.card).regions,
|
||||||
trains: [n.transits.map((t) => {
|
trains: [n.transits.map((t) => {
|
||||||
const chip = trainChip(s, t.tray);
|
const chip = trainChip(s, t.tray);
|
||||||
return {
|
return {
|
||||||
@@ -1294,7 +1295,8 @@ export function snapshot(
|
|||||||
*/
|
*/
|
||||||
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
|
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
|
||||||
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(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,
|
deck: s.decks.homeOffice.length,
|
||||||
departments: s.decks.departments.map((pile) => {
|
departments: s.decks.departments.map((pile) => {
|
||||||
const top = pile[pile.length - 1];
|
const top = pile[pile.length - 1];
|
||||||
@@ -1575,7 +1577,9 @@ export function trainRules(t: {
|
|||||||
}
|
}
|
||||||
if (p.rules.noPassengerWork) parts.push('NO PASSENGER WORK — Porters may not board or detrain it');
|
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.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) {
|
if (p.rules.stopThenExpedite) {
|
||||||
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
|
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-11
@@ -10,7 +10,7 @@ import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
|||||||
import type { Frame } from '../sim/view.ts';
|
import type { Frame } from '../sim/view.ts';
|
||||||
import { seatLabel } from '../sim/view.ts';
|
import { seatLabel } from '../sim/view.ts';
|
||||||
import type { Menu, Save } from './game.ts';
|
import type { Menu, Save } from './game.ts';
|
||||||
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||||
import { playCue } from './sound.ts';
|
import { playCue } from './sound.ts';
|
||||||
import {
|
import {
|
||||||
@@ -158,6 +158,13 @@ let zoom = settings.zoom;
|
|||||||
* ended or what happened in between. Reported exactly that way.
|
* ended or what happened in between. Reported exactly that way.
|
||||||
*/
|
*/
|
||||||
let lastPhase: string | null = null;
|
let lastPhase: string | null = null;
|
||||||
|
/**
|
||||||
|
* The Day the page last drew, so a Day rolling over can be shown as a dialog (Gitea#10).
|
||||||
|
*
|
||||||
|
* Null until the first frame: arriving in a game already on Day 3 is not Day 2 ending, and a page
|
||||||
|
* reloaded mid-game would otherwise announce a rollover that happened before it was watching.
|
||||||
|
*/
|
||||||
|
let lastDay: number | null = null;
|
||||||
/**
|
/**
|
||||||
* WHICH CREW THE BOARD IS DRAWING, when the district holds more than one.
|
* WHICH CREW THE BOARD IS DRAWING, when the district holds more than one.
|
||||||
*
|
*
|
||||||
@@ -302,6 +309,10 @@ function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions {
|
|||||||
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
|
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
|
||||||
const extra = params.get('extra');
|
const extra = params.get('extra');
|
||||||
if (EXTRA_START_LABELS.some((o) => o.value === extra)) rules.extraStart = extra as ExtraStartRule;
|
if (EXTRA_START_LABELS.some((o) => o.value === extra)) rules.extraStart = extra as ExtraStartRule;
|
||||||
|
// §6.2 (Gitea#9). This one defaults ON, so the URL only ever has to carry the OFF case — `?toss=0`.
|
||||||
|
// Read the same way the optional rules are: present and not "0" means on.
|
||||||
|
const toss = params.get('toss');
|
||||||
|
if (toss !== null) rules.discardTimetabled = toss !== '0';
|
||||||
|
|
||||||
const revenue: Partial<RevenueRules> = {};
|
const revenue: Partial<RevenueRules> = {};
|
||||||
for (const [param, key] of Object.entries(RULE_PARAMS)) {
|
for (const [param, key] of Object.entries(RULE_PARAMS)) {
|
||||||
@@ -358,6 +369,7 @@ function solitaireDefaults(options: NewGameOptions): NewGameOptions {
|
|||||||
houseRules: {
|
houseRules: {
|
||||||
startingHand: options.houseRules?.startingHand ?? p.startingHand,
|
startingHand: options.houseRules?.startingHand ?? p.startingHand,
|
||||||
extraStart: options.houseRules?.extraStart ?? p.extraStart,
|
extraStart: options.houseRules?.extraStart ?? p.extraStart,
|
||||||
|
discardTimetabled: options.houseRules?.discardTimetabled ?? p.discardTimetabled,
|
||||||
revenue: {
|
revenue: {
|
||||||
passengerPerCoach: revenue.passengerPerCoach ?? p.passengerPerCoach,
|
passengerPerCoach: revenue.passengerPerCoach ?? p.passengerPerCoach,
|
||||||
freightPerLoad: revenue.freightPerLoad ?? p.freightPerLoad,
|
freightPerLoad: revenue.freightPerLoad ?? p.freightPerLoad,
|
||||||
@@ -374,6 +386,9 @@ function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): s
|
|||||||
// The dialog answers reach `start()` through the URL and nowhere else, so a setting missing from
|
// The dialog answers reach `start()` through the URL and nowhere else, so a setting missing from
|
||||||
// here is a setting the dialog silently discards.
|
// here is a setting the dialog silently discards.
|
||||||
params.set('extra', rules.extraStart);
|
params.set('extra', rules.extraStart);
|
||||||
|
// Written only when OFF, for the reason the optional rules are written only when on: this one
|
||||||
|
// defaults to on, so `toss=1` on every link would say nothing and cost a parameter.
|
||||||
|
if (!rules.discardTimetabled) params.set('toss', '0');
|
||||||
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
|
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
|
||||||
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
|
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
|
||||||
const value = options[key];
|
const value = options[key];
|
||||||
@@ -561,6 +576,34 @@ function flashAnnounce(text: string): void {
|
|||||||
}, 4200);
|
}, 4200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE DAY ROLLING OVER, as a dialog that has to be dismissed (Gitea#10).
|
||||||
|
*
|
||||||
|
* "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of
|
||||||
|
* time." Nothing on screen was wrong — the clock, the turn chart and the timetable all said which
|
||||||
|
* Day it was — but a Day turns over inside the phases that run themselves, so it happens while the
|
||||||
|
* player is looking at the board waiting for their next turn. The two transient signals the page
|
||||||
|
* already had are both gone in under five seconds.
|
||||||
|
*
|
||||||
|
* Not shown when:
|
||||||
|
* - this is the first frame (`lastDay === null`) — arriving on Day 3 is not Day 2 ending;
|
||||||
|
* - the Day went DOWN, which is Undo stepping back across the rollover, not a Day passing;
|
||||||
|
* - the game finished on that rollover, when the outcome panel is the thing to read instead.
|
||||||
|
*/
|
||||||
|
function noteDayEnd(f: Frame): void {
|
||||||
|
const previous = lastDay;
|
||||||
|
lastDay = f.day;
|
||||||
|
if (previous === null || f.day <= previous) return;
|
||||||
|
if (f.status !== 'active') return;
|
||||||
|
const dlg = document.getElementById('dayenddlg') as HTMLDialogElement | null;
|
||||||
|
const body = document.getElementById('dayendbody');
|
||||||
|
if (!dlg || !body) return;
|
||||||
|
body.innerHTML = dayEndHtml(f);
|
||||||
|
// A second rollover cannot happen while this one is open, but a redraw can — `showModal` throws
|
||||||
|
// on an already-open dialog rather than doing nothing.
|
||||||
|
if (!dlg.open) dlg.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE FIRST FRAME OF A MULTIPLAYER GAME — the one moment nobody had ever seen drawn.
|
* THE FIRST FRAME OF A MULTIPLAYER GAME — the one moment nobody had ever seen drawn.
|
||||||
*
|
*
|
||||||
@@ -1186,6 +1229,8 @@ function render(): void {
|
|||||||
}
|
}
|
||||||
lastPhase = f.phaseKey;
|
lastPhase = f.phaseKey;
|
||||||
|
|
||||||
|
noteDayEnd(f);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A train completing its run pays every player and nobody took a turn to cause it, so it is said
|
* A train completing its run pays every player and nobody took a turn to cause it, so it is said
|
||||||
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
|
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
|
||||||
@@ -1236,6 +1281,9 @@ function renderUndo(): void {
|
|||||||
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
|
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
|
||||||
// different phase is not that event, so the banner is suppressed rather than fired backwards.
|
// different phase is not that event, so the banner is suppressed rather than fired backwards.
|
||||||
lastPhase = null;
|
lastPhase = null;
|
||||||
|
// Same for the Day: `noteDayEnd` already ignores a Day going down, but undoing back across a
|
||||||
|
// rollover and then replaying forward through it would announce the same Day ending twice.
|
||||||
|
lastDay = null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1577,20 +1625,21 @@ function renderActions(
|
|||||||
/**
|
/**
|
||||||
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
|
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
|
||||||
*
|
*
|
||||||
* §6.2 as ruled by Jesse (Gitea#6): a train card is never discarded, so a player holding four
|
* §6.2 (Gitea#9) leaves two kinds of undiscardable card — an Extra always, and a Timetabled
|
||||||
* trains has exactly one way forward — play one onto the timetable. The rule creates that corner
|
* train in a game whose `discardTimetabled` rule is off. Either way a player holding nothing but
|
||||||
* deliberately and needs no machinery, but it must not be a corner the player has to infer from
|
* those has exactly one way forward: play one. The rule creates that corner deliberately and
|
||||||
* a discard button that has quietly stopped appearing.
|
* needs no machinery, but it must not be a corner the player has to infer from a discard button
|
||||||
|
* that has quietly stopped appearing. The reason is the card's own (`handKeepWhy`), so this says
|
||||||
|
* what actually refused rather than assuming which of the two rules is in force.
|
||||||
*/
|
*/
|
||||||
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
|
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
|
||||||
|
const why = f.handKeepWhy.find((w) => w !== null) ?? '';
|
||||||
const tip = stuck
|
const tip = stuck
|
||||||
? 'You may not end a turn holding more than three cards, and a TRAIN CARD IS NEVER ' +
|
? 'You may not end a turn holding more than three cards, and every card you hold is one that ' +
|
||||||
'DISCARDED. Every card you hold is a train, so the only way on is to play one onto the ' +
|
`cannot be thrown away. ${why} The only way on is to play one.`
|
||||||
'timetable. A train may be held for as many Stages and Days as you like; it just cannot be ' +
|
|
||||||
'thrown away.'
|
|
||||||
: 'You may not end a turn holding more than three cards (four with a Red Flag). Play ' +
|
: 'You may not end a turn holding more than three cards (four with a Red Flag). Play ' +
|
||||||
'one onto the board, or discard one face-up to a Department slot. A train card is never ' +
|
'one onto the board, or discard one face-up to a Department slot — where a rival may pick ' +
|
||||||
'discardable and can only be played.';
|
'it up.';
|
||||||
html +=
|
html +=
|
||||||
`<div class="grp"><button class="act blocked" disabled data-tip="${tip.replace(/"/g, '"')}">` +
|
`<div class="grp"><button class="act blocked" disabled data-tip="${tip.replace(/"/g, '"')}">` +
|
||||||
(stuck
|
(stuck
|
||||||
@@ -1888,6 +1937,7 @@ if (newBtn && dlg) {
|
|||||||
houseRules: {
|
houseRules: {
|
||||||
startingHand: settings.startingHand,
|
startingHand: settings.startingHand,
|
||||||
extraStart: settings.extraStart,
|
extraStart: settings.extraStart,
|
||||||
|
discardTimetabled: settings.discardTimetabled,
|
||||||
revenue: {
|
revenue: {
|
||||||
passengerPerCoach: settings.passengerPerCoach,
|
passengerPerCoach: settings.passengerPerCoach,
|
||||||
freightPerLoad: settings.freightPerLoad,
|
freightPerLoad: settings.freightPerLoad,
|
||||||
|
|||||||
+78
-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 {
|
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
|
// §6.2 — say so on the card itself. A player who cannot discard a train needs to read that on the
|
||||||
// that on the train, not deduce it from a button that is not there.
|
// train, not deduce it from a button that is not there. The sentence comes off the Frame
|
||||||
const held = 'You may hold this for as many Stages and Days as you like — but a train card is ' +
|
// (`handKeepWhy`) rather than being written here, because since Gitea#9 there are two of them and
|
||||||
'never discarded. The only way it leaves your hand is onto the timetable.';
|
// which one applies depends on the card AND the game's rules.
|
||||||
return f.hand.length
|
return f.hand.length
|
||||||
? f.hand
|
? f.hand
|
||||||
.map((h, i) => {
|
.map((h, i) => {
|
||||||
const what = f.handWhat[i] ?? '';
|
const what = f.handWhat[i] ?? '';
|
||||||
const keep = f.handDiscardable[i] === false;
|
const held = f.handKeepWhy[i];
|
||||||
return cardRow(h, keep ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
|
return cardRow(h, held ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
|
||||||
})
|
})
|
||||||
.join('')
|
.join('')
|
||||||
: '<span class="dim">empty</span>';
|
: '<span class="dim">empty</span>';
|
||||||
@@ -153,6 +153,70 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
|
|||||||
return `<div class="tt">${slots}</div>`;
|
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 - ended;
|
||||||
|
const standings = [...f.players]
|
||||||
|
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}` +
|
||||||
|
`${p.index === f.viewer ? ' <span class="dim">(you)</span>' : ''}</td>` +
|
||||||
|
`<td class="num">${p.revenue}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||||
|
const target =
|
||||||
|
f.minCombinedRevenue > 0
|
||||||
|
? `<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>.</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.
|
||||||
|
*/
|
||||||
|
const scoredOnCollisions =
|
||||||
|
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||||
|
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||||
|
const collisions = scoredOnCollisions
|
||||||
|
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const ahead =
|
||||||
|
left <= 0
|
||||||
|
? '<p>That was the last Day on the timetable.</p>'
|
||||||
|
: `<p><b>Day ${f.day} of ${f.days}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<h3 class="dayend-h">Day ${ended} has ended</h3>` +
|
||||||
|
ahead +
|
||||||
|
`<table class="dayend-t"><tbody>${standings}</tbody></table>` +
|
||||||
|
target +
|
||||||
|
collisions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function blockedHtml(f: Frame): string {
|
export function blockedHtml(f: Frame): string {
|
||||||
return f.blocked.length === 0
|
return f.blocked.length === 0
|
||||||
? '<li class="dim">nothing blocked</li>'
|
? '<li class="dim">nothing blocked</li>'
|
||||||
@@ -407,4 +471,12 @@ ul.blocked{margin:0;padding-left:18px}
|
|||||||
.fstat.good{background:rgba(40,140,60,.28)}
|
.fstat.good{background:rgba(40,140,60,.28)}
|
||||||
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
||||||
.fstat.idle{opacity:.6}
|
.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}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -534,6 +534,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
<input id="lb-toolbox" type="checkbox"></label>
|
<input id="lb-toolbox" type="checkbox"></label>
|
||||||
<span class="set-hint" id="lb-toolbox-hint"></span>
|
<span class="set-hint" id="lb-toolbox-hint"></span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -850,6 +857,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
<input id="ng-toolbox" type="checkbox"></label>
|
<input id="ng-toolbox" type="checkbox"></label>
|
||||||
<span class="set-hint" id="ng-toolbox-hint"></span>
|
<span class="set-hint" id="ng-toolbox-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" id="ng-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="ng-tossloco" type="checkbox"></label>
|
||||||
|
<span class="set-hint" id="ng-tossloco-hint"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -863,6 +877,19 @@ ul.blocked li{padding:2px 0}
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<button value="ok" id="de-ok" type="submit">Carry on</button>
|
||||||
|
</menu>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
|
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
|
||||||
`beginRemote` used to write "… connecting to the game" into `#presence` — the DISCONNECT banner,
|
`beginRemote` used to write "… connecting to the game" into `#presence` — the DISCONNECT banner,
|
||||||
whose job is `⚠ waiting on Alice`. It worked only because the first render overwrote it, and it
|
whose job is `⚠ waiting on Alice`. It worked only because the first render overwrote it, and it
|
||||||
|
|||||||
+15
-1
@@ -52,6 +52,8 @@ export type Settings = {
|
|||||||
reducedVisibility: boolean;
|
reducedVisibility: boolean;
|
||||||
employeeRotation: boolean;
|
employeeRotation: boolean;
|
||||||
emergencyToolbox: 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)[] = [
|
export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
||||||
@@ -66,6 +68,7 @@ export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
|||||||
'reducedVisibility',
|
'reducedVisibility',
|
||||||
'employeeRotation',
|
'employeeRotation',
|
||||||
'emergencyToolbox',
|
'emergencyToolbox',
|
||||||
|
'discardTimetabled',
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Preset = {
|
export type Preset = {
|
||||||
@@ -94,6 +97,12 @@ const NO_OPTIONAL_RULES = {
|
|||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: 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;
|
} as const;
|
||||||
|
|
||||||
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a
|
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a
|
||||||
@@ -211,6 +220,7 @@ export function settingsOf(config: GameConfig): Settings {
|
|||||||
reducedVisibility: config.optionalRules.reducedVisibility,
|
reducedVisibility: config.optionalRules.reducedVisibility,
|
||||||
employeeRotation: config.optionalRules.employeeRotation,
|
employeeRotation: config.optionalRules.employeeRotation,
|
||||||
emergencyToolbox: config.optionalRules.emergencyToolbox,
|
emergencyToolbox: config.optionalRules.emergencyToolbox,
|
||||||
|
discardTimetabled: rules.discardTimetabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +268,7 @@ export function configFromFrame(f: {
|
|||||||
maxCollisionsPerDay: number;
|
maxCollisionsPerDay: number;
|
||||||
maxCollisionsTotal: number;
|
maxCollisionsTotal: number;
|
||||||
optionalRules: GameConfig['optionalRules'];
|
optionalRules: GameConfig['optionalRules'];
|
||||||
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules };
|
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean };
|
||||||
}): GameConfig {
|
}): GameConfig {
|
||||||
return {
|
return {
|
||||||
mode: f.mode,
|
mode: f.mode,
|
||||||
@@ -272,6 +282,9 @@ export function configFromFrame(f: {
|
|||||||
houseRules: {
|
houseRules: {
|
||||||
startingHand: f.houseRules.startingHand,
|
startingHand: f.houseRules.startingHand,
|
||||||
extraStart: f.houseRules.extraStart,
|
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,
|
revenue: f.houseRules.revenue,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -335,6 +348,7 @@ export function configFromSettings(
|
|||||||
houseRules: {
|
houseRules: {
|
||||||
startingHand: settings.startingHand,
|
startingHand: settings.startingHand,
|
||||||
extraStart: settings.extraStart,
|
extraStart: settings.extraStart,
|
||||||
|
discardTimetabled: settings.discardTimetabled,
|
||||||
revenue: {
|
revenue: {
|
||||||
passengerPerCoach: settings.passengerPerCoach,
|
passengerPerCoach: settings.passengerPerCoach,
|
||||||
freightPerLoad: settings.freightPerLoad,
|
freightPerLoad: settings.freightPerLoad,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* THE RULES BLOCK, driven identically on both screens.
|
* 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
|
* 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,
|
* 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
|
* 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: 'reducedVisibility', kind: 'checkbox', id: 'visibility' },
|
||||||
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
|
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
|
||||||
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
|
{ 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.
|
* 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
|
* 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> = {
|
const FIELD_LABELS: Record<keyof Settings, string> = {
|
||||||
startingHand: 'Starting hand',
|
startingHand: 'Starting hand',
|
||||||
extraStart: 'An Extra may start at',
|
extraStart: 'An Extra may start at',
|
||||||
|
discardTimetabled: 'A Timetabled train may be discarded',
|
||||||
passengerPerCoach: 'Passenger per coach',
|
passengerPerCoach: 'Passenger per coach',
|
||||||
freightPerLoad: 'Freight per load',
|
freightPerLoad: 'Freight per load',
|
||||||
trainPerTransit: 'Train per transit',
|
trainPerTransit: 'Train per transit',
|
||||||
@@ -115,6 +117,7 @@ export function rulesListHtml(config: GameConfig, players: number, days: number)
|
|||||||
return (
|
return (
|
||||||
head +
|
head +
|
||||||
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart'])}</dl>` +
|
`<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>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
|
||||||
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
|
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
|
||||||
`<h4>Optional rules</h4><dl>${rows(['reducedVisibility', 'employeeRotation', 'emergencyToolbox'])}</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,
|
reducedVisibility: el<HTMLInputElement>('visibility')?.checked === true,
|
||||||
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
|
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
|
||||||
emergencyToolbox: el<HTMLInputElement>('toolbox')?.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('visibility', values.reducedVisibility);
|
||||||
setChecked('rotation', values.employeeRotation);
|
setChecked('rotation', values.employeeRotation);
|
||||||
setChecked('toolbox', values.emergencyToolbox);
|
setChecked('toolbox', values.emergencyToolbox);
|
||||||
|
setChecked('tossloco', values.discardTimetabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNumber(id: string, value: number): void {
|
function setNumber(id: string, value: number): void {
|
||||||
|
|||||||
+94
-27
@@ -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');
|
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
|
* Gitea#6's ruling, v0.4.9e playtest, was that NO train card may be discarded. Gitea#9 narrows
|
||||||
* keep the card in their hand for multiple stages and even multiple days, but they may not
|
* it — Jesse, 2026-08-24: "Timetabled trains are at the choice of the player: they can either
|
||||||
* discard it. If a player has three train cards in their hand, and they draw a fourth, then they
|
* play or discard. If someone else wants to pick it up, they are more than able to. The reason:
|
||||||
* must play one of those cards."
|
* 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
|
* So a Timetabled train is discardable, an EXTRA still is not — it never joins the timetable, so
|
||||||
* where a Timetabled card joins the timetable for the rest of the game.
|
* 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
|
* Note there is still no 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
|
* existing rules produce together whenever the setting is off.
|
||||||
* limit" leaves exactly one legal way on, and playing a train is unconditionally legal.
|
|
||||||
*/
|
*/
|
||||||
const handOf = (s: GameState, kinds: string[]): string[] => {
|
const handOf = (s: GameState, kinds: string[]): string[] => {
|
||||||
// Hand-pick cards of the wanted kinds straight out of the catalogue, so the test does not
|
// 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;
|
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();
|
const s = game();
|
||||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||||
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
|
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(
|
assert.equal(
|
||||||
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
|
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
|
||||||
'TRAINS_ARE_NEVER_DISCARDED',
|
'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);
|
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();
|
const s = game();
|
||||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||||
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
||||||
const offered = legalActions(s, 0).filter(
|
assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 1 }).ok);
|
||||||
(i) => i.type === 'card.discard' && i.cardId === timetabled,
|
const pile = s.decks.departments[1]!;
|
||||||
);
|
assert.equal(pile[pile.length - 1], timetabled, 'the train is not face-up on the pile');
|
||||||
assert.deepEqual(offered, [], 'a train discard was offered as a legal action');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
const s = game();
|
||||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
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']);
|
const four = handOf(s, ['timetabledTrain', 'timetabledTrain', 'timetabledTrain', 'extraTrain']);
|
||||||
assert.ok(four.length > HAND_LIMIT, 'this test needs a hand over the limit');
|
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');
|
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', () => {
|
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
|
// "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 s = game();
|
||||||
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
const [extra] = handOf(s, ['extraTrain', 'track']);
|
||||||
const startDay = s.clock.day;
|
const startDay = s.clock.day;
|
||||||
|
|
||||||
// Play out Stages by taking whatever ends the current turn, until the Day turns over.
|
// 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.clock.day > startDay, `the Day never turned (stopped at ${s.clock.day}/${s.clock.stage})`);
|
||||||
assert.ok(
|
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',
|
'the train did not survive being held into the next Day',
|
||||||
);
|
);
|
||||||
assert.equal(
|
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',
|
'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
|
// 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();
|
const s = game();
|
||||||
handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||||
const f = snapshot(s, [], null);
|
const f = snapshot(s, [], null);
|
||||||
// `hand` is reversed for display, so compare as a set rather than by position.
|
// `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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||||
import { createGame } from '../src/engine/setup.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';
|
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
||||||
|
|
||||||
const config: GameConfig = {
|
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());
|
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(
|
function placeTray(
|
||||||
s: GameState,
|
s: GameState,
|
||||||
coord: GridCoord,
|
coord: GridCoord,
|
||||||
consist: RollingStock[],
|
consist: RollingStock[],
|
||||||
facing: 'e' | 'w',
|
facing: 'n' | 's' | 'e' | 'w',
|
||||||
engineAt = 0,
|
engineAt = 0,
|
||||||
|
railFacing: 'e' | 'w' = facing === 'w' ? 'w' : 'e',
|
||||||
): string {
|
): string {
|
||||||
const id = s.freeTrays.pop()!;
|
const id = s.freeTrays.pop()!;
|
||||||
s.trays.set(id, {
|
s.trays.set(id, {
|
||||||
@@ -67,9 +73,9 @@ function placeTray(
|
|||||||
trainIsExtra: false,
|
trainIsExtra: false,
|
||||||
engineAt,
|
engineAt,
|
||||||
consist,
|
consist,
|
||||||
direction: facing === 'w' ? 'west' : 'east',
|
direction: railFacing === 'w' ? 'west' : 'east',
|
||||||
facing,
|
facing,
|
||||||
railFacing: facing,
|
railFacing,
|
||||||
position: { at: 'grid', seat: 0, coord },
|
position: { at: 'grid', seat: 0, coord },
|
||||||
movesUsed: 0,
|
movesUsed: 0,
|
||||||
} as CrewTray);
|
} 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+22
-8
@@ -228,14 +228,28 @@ describe('the game conserves Rolling Stock', () => {
|
|||||||
for (let t = 0; t < 50_000; t++) {
|
for (let t = 0; t < 50_000; t++) {
|
||||||
const before = census(s);
|
const before = census(s);
|
||||||
const pumped = pump(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.
|
* A COLLISION DESTROYS NO CAR, and this used to assume it destroyed all of them.
|
||||||
for (const e of pumped) {
|
*
|
||||||
if (e.type === 'trainsDestroyed') for (const tr of e.trains) expected -= tr.consist.length;
|
* 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`,
|
||||||
assert.ok(
|
* "TAKE THE WRECK OFF THE CARD") sends the wreck's cabooses back to the Division Yard and
|
||||||
census(s) === before || pumped.some((e) => e.type === 'trainsDestroyed'),
|
* everything else to Classification, so the stock is conserved through a collision like any
|
||||||
`seed ${seed}: the engine changed the census by ${census(s) - before} outside a collision`,
|
* 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;
|
if (s.status === 'finished') break;
|
||||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||||
|
|||||||
+258
-48
@@ -12,6 +12,7 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import type { MainlineKind } from '../src/engine/content.ts';
|
||||||
import { advance } from '../src/engine/advance.ts';
|
import { advance } from '../src/engine/advance.ts';
|
||||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||||
import { legalActions } from '../src/engine/legal.ts';
|
import { legalActions } from '../src/engine/legal.ts';
|
||||||
@@ -43,6 +44,20 @@ const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, player
|
|||||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||||
|
|
||||||
/** Puts a card of `kind`/`key` in hand and returns its id. */
|
/** 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 {
|
function hand(s: GameState, kind: string, key: string): string {
|
||||||
for (const [id, card] of s.cards) {
|
for (const [id, card] of s.cards) {
|
||||||
const k = card.kind as { kind: string; key?: string };
|
const k = card.kind as { kind: string; key?: string };
|
||||||
@@ -51,7 +66,10 @@ function hand(s: GameState, kind: string, key: string): string {
|
|||||||
return id;
|
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', () => {
|
* Crossing time on the region model (Gitea#3). `cross` fills in the parts each test does not care
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false), 2);
|
* 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', () => {
|
it('Brakeman speeds the descent but not the climb', () => {
|
||||||
// Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which
|
// Q11 — the card prints "(Up)" and "Player sets orientation", so `gradeUp` is which way is
|
||||||
// way is UPHILL. With up = east, a westbound train is descending.
|
// UPHILL. With up = east, a westbound train is descending.
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'east'), 1);
|
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west' }), 2);
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'east'), 2);
|
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'east' }), 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('follows the orientation the player chose, not a fixed compass direction', () => {
|
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: Brakeman helps a westbound
|
// The same train on the same card, with the card turned around.
|
||||||
// train on an east-climbing grade, and an eastbound one when the grade climbs west.
|
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'east', gradeUp: 'west' }), 2);
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'west'), 1);
|
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west', gradeUp: 'west' }), 3);
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'west'), 2);
|
assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'west', gradeUp: 'west' }), 2);
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'west'), 1);
|
assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'east', gradeUp: 'west' }), 3);
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'west'), 2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Helpers speed the climb but not the descent', () => {
|
it('Helpers speed the climb but not the descent', () => {
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1);
|
// RAR: "helpers… helps all trains going up hill by starting 1 region easier — so 2 to traverse,
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2);
|
// 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', () => {
|
it('Airbrakes stack on top of Brakeman', () => {
|
||||||
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
|
// "Airbrakes is an upgrade from brakemen (which must be played first)", so a fully-equipped
|
||||||
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3);
|
// grade is one Stage downhill — and `check` refuses Airbrakes without Brakeman already there.
|
||||||
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2);
|
assert.equal(cross('heavyGrade', { direction: 'west' }), 3);
|
||||||
assert.equal(
|
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west' }), 2);
|
||||||
crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
assert.equal(cross('heavyGrade', { modifiers: ['brakeman', 'airbrakes'], direction: 'west' }), 1);
|
||||||
1,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never lets a train cross in no time', () => {
|
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(
|
assert.equal(
|
||||||
crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
cross('heavyGrade', { modifiers: ['brakeman', 'airbrakes', 'helpers'], direction: 'west' }),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves non-grade cards alone', () => {
|
it('leaves non-grade cards alone', () => {
|
||||||
// Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
|
// 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(cross('plains', { modifiers: ['brakeman'], direction: 'west' }), 1);
|
||||||
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2);
|
assert.equal(cross('curves', { modifiers: ['helpers'] }), 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,8 +298,8 @@ describe('Realignment converts one Mainline type to another', () => {
|
|||||||
|
|
||||||
assert.ok(r.ok);
|
assert.ok(r.ok);
|
||||||
assert.equal(node.card, 'plains', 'Curves realigns to Plains');
|
assert.equal(node.card, 'plains', 'Curves realigns to Plains');
|
||||||
// The point of the card: Curves is a 30 (two Stages), Plains a 60 (one).
|
// The point of the card: Curves prints two regions, Plains one, so realigning halves the time.
|
||||||
assert.equal(crossingStages(node.card, 'fast', false), 1);
|
assert.equal(cross(node.card), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses a card with no conversion listed', () => {
|
it('refuses a card with no conversion listed', () => {
|
||||||
@@ -796,18 +885,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.
|
* 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 twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => {
|
||||||
const s = createGame({
|
/**
|
||||||
id: 'rear', seed: 3,
|
* THE SEED IS SEARCHED FOR, NOT WRITTEN DOWN.
|
||||||
config: {
|
*
|
||||||
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
* This asked for seed 3 and asserted that its Division held a single-track Mainline card. It
|
||||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
* 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
|
||||||
playerNames: ['bot'],
|
* 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.
|
||||||
const index = s.division.nodes.findIndex(
|
*
|
||||||
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
|
* 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
|
||||||
assert.ok(index >= 0, 'no single-track Mainline card in this Division');
|
* 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]!;
|
const node = s.division.nodes[index]!;
|
||||||
assert.ok(node.kind === 'mainline');
|
assert.ok(node.kind === 'mainline');
|
||||||
if (node.kind !== 'mainline') throw new Error('unreachable');
|
if (node.kind !== 'mainline') throw new Error('unreachable');
|
||||||
@@ -863,12 +969,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
|
* ENTERING an occupied region, as opposed to catching up inside the card (Gitea#3).
|
||||||
// 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
|
* A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train
|
||||||
// passing cards.
|
* 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);
|
const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind);
|
||||||
assert.deepEqual(passing, ['doubleTrack', 'uncontrolledSiding']);
|
assert.deepEqual(passing, ['doubleTrack']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+88
-1
@@ -11,8 +11,10 @@ import assert from 'node:assert/strict';
|
|||||||
import { pump } from '../src/engine/advance.ts';
|
import { pump } from '../src/engine/advance.ts';
|
||||||
import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiveRevenueFloor } from '../src/engine/content.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 type { GameEvent } from '../src/engine/events.ts';
|
||||||
|
import { areaOf } from '../src/engine/apply.ts';
|
||||||
import { createGame } from '../src/engine/setup.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 { developerBot, playGame } from '../src/sim/bot.ts';
|
||||||
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
||||||
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
||||||
@@ -138,6 +140,91 @@ 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 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', () => {
|
describe('replay recording', () => {
|
||||||
const rec = record(1234, 'standard');
|
const rec = record(1234, 'standard');
|
||||||
|
|
||||||
|
|||||||
+75
-42
@@ -63,56 +63,73 @@ const gameDealtWith = (startingHand: StartingHand, seed = 1234) =>
|
|||||||
|
|
||||||
describe('card catalogue (component 1)', () => {
|
describe('card catalogue (component 1)', () => {
|
||||||
it('composes the deck from the design', () => {
|
it('composes the deck from the design', () => {
|
||||||
// Transcribed from docs/Deck cards2.xlsx. The sheet's own totals are "Sum other 115" and
|
// THE WHOLE CATALOGUE IS docs/Deck cards5.xlsx NOW (Gitea#14). Sheet 5's own totals are
|
||||||
// "Total track 104", i.e. 219, plus 12 start cards for its grand total of 231.
|
// "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
|
// EVERY COUNT IN THE CATALOGUE IS NOW THE SHEET'S. The two deliberate departures that used to
|
||||||
// content.ts: 18 extra industry cards (Gap 12, industries 9 → 27), 7 extra office cards (Q12,
|
// sit here are gone with Gitea#14 — the Q12 office doubling (offices 14 → 7) and the Gap 12
|
||||||
// offices 7 → 14), and the 8 sharp curves taken back OUT. The first two were tuned against a deck
|
// industry tripling (27 → 9) — because both were measured against a deck holding 96 track
|
||||||
// that had NO track in it, so both are due a re-measurement now that 96 track cards share the
|
// cards, and sheet 5 halves that. content.ts carries the measurements that decided it.
|
||||||
// draw.
|
|
||||||
//
|
//
|
||||||
// Two entries are dealt ZERO copies and kept in the catalogue so the design stays visible:
|
// We are at 143 rather than the sheet's 155 for ONE reason: the ten Safety, Event, Inspection
|
||||||
// Poling, whose effect is "TBD in the source", and the sharp curves, whose only difference from
|
// and Space-use cards sheet 5 adds are not built, and stay out until they are (Jesse,
|
||||||
// an ordinary curve was a Move cost nothing ever charged.
|
// 2026-08-26) — Cargo Theft, Civic Improvement, Civilian angel, Delayed Clearance, Flares 2,
|
||||||
// DECK_SIZE is the CATALOGUE, 235. The deck actually dealt is smaller: the 22 opponent-directed
|
// Robbery, Service Delays, Shipper complaints, Strike, Union Hall 2. Twelve copies in all.
|
||||||
// cards are held back in every mode until they are implemented, so `buildDeck` returns 213.
|
//
|
||||||
assert.equal(DECK_SIZE, 235);
|
// 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);
|
assert.equal(buildDeck().length, SOLITAIRE_DECK_SIZE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches the design deck composition exactly', () => {
|
it('matches the design deck composition exactly', () => {
|
||||||
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
|
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
|
||||||
assert.deepEqual(byCategory, {
|
assert.deepEqual(byCategory, {
|
||||||
// 96, not the sheet's 104: the 8 SHARP CURVES are dealt zero copies. The only thing that made
|
// Sheet 5's track counts exactly (Gitea#14): 16 straights, 8+8 curves, 8+8 turnouts, and the
|
||||||
// one different from an ordinary curve was a Move cost that nothing ever charged, so they were
|
// sharp curves dealt none — which is where the catalogue already had them, and where sheet 5
|
||||||
// geometric duplicates taking 8 draws. Kept in the catalogue at zero, as Poling is.
|
// now puts them too.
|
||||||
track: 96,
|
track: 48,
|
||||||
// 14, not the sheet's 7 — Q12 office density; see OFFICE_PROFILES.
|
// The sheet's 7 — the Q12 doubling came out in Gitea#14; see OFFICE_PROFILES.
|
||||||
office: 14,
|
office: 7,
|
||||||
// 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES.
|
// The sheet's 9 — the Gap 12 tripling came out in Gitea#14; see INDUSTRY_PROFILES.
|
||||||
industry: 27,
|
industry: 9,
|
||||||
modifier: 23,
|
modifier: 23,
|
||||||
train: 22,
|
train: 22,
|
||||||
spaceUse: 12,
|
spaceUse: 11,
|
||||||
enhancement: 18,
|
// 6 — the dispatching ladder and Facing Point Locks are dealt 0 copies (see
|
||||||
mainlineModifier: 7,
|
// ENHANCEMENT_CARDS), and Interlocking, Water column and ABS Signals came down to the sheet's
|
||||||
// 6, not 7 — Poling is dealt no copies until its rule is known.
|
// single copies. What is left is the sheet's Enhancements exactly, bar Railroad crossing,
|
||||||
maneuver: 6,
|
// which sheet 5 moved here from the Action cards and which is still counted there below.
|
||||||
action: 10,
|
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', () => {
|
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
|
// 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
|
// 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.
|
// categories NOT_IMPLEMENTED, so dealing them would be a dead draw.
|
||||||
// 206, not 213: the 22 opponent-directed cards come out, and so do the SEVEN that exist only to
|
// 121, not 123: the 20 opponent-directed cards come out, and so do the TWO that exist only to
|
||||||
// answer them — Facing Point Locks (both the Enhancement and the Mainline modifier, 2 each), two
|
// answer them — one Water Column and one Overpass. A defence with nothing to defend against is
|
||||||
// Water Columns and one Overpass. A defence with nothing to defend against is the same dead draw
|
// the same dead draw as the attack would be. `SimpleCard.answers` names the pairing, so they
|
||||||
// as the attack would be. `SimpleCard.answers` names the pairing, so they return together.
|
// return together. It was seven until Gitea#14 dealt Facing Point Locks zero copies: a card at
|
||||||
assert.equal(SOLITAIRE_DECK_SIZE, 206);
|
// zero is already out, so it no longer needs holding back.
|
||||||
assert.equal(DEFENCE_ONLY_COPIES, 7);
|
assert.equal(SOLITAIRE_DECK_SIZE, 121);
|
||||||
|
assert.equal(DEFENCE_ONLY_COPIES, 2);
|
||||||
for (const c of DEFENCE_ONLY_CARDS) {
|
for (const c of DEFENCE_ONLY_CARDS) {
|
||||||
assert.ok(c.answers, `${c.name} is held back without saying what it answers`);
|
assert.ok(c.answers, `${c.name} is held back without saying what it answers`);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -131,11 +148,12 @@ describe('card catalogue (component 1)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('deals track FROM the deck, at the sheet\'s counts', () => {
|
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 —
|
// Column B of Deck cards5.xlsx, "Number in Deck": 16 straights, 8+8 curves, 8+8 turnouts, and
|
||||||
// and 4+4 sharp curves, which are dealt none. An earlier reading took the sheet's LAST column,
|
// 0+0 sharp curves. Sheet 2 had each of those at double, which is what the deck dealt until
|
||||||
// "Track Per Player" (26), as a separate stack outside the deck; it is the sheet's 104 shared out
|
// Gitea#14. An earlier reading took the sheet's LAST column, "Track Per Player" (26), as a
|
||||||
// among four players, not a second pile.
|
// separate stack outside the deck; it is the sheet's total shared out among four players, not a
|
||||||
assert.equal(TRACK_IN_DECK, 96);
|
// second pile.
|
||||||
|
assert.equal(TRACK_IN_DECK, 48);
|
||||||
const deck = buildDeck();
|
const deck = buildDeck();
|
||||||
for (const t of TRACK_CARDS) {
|
for (const t of TRACK_CARDS) {
|
||||||
const n = deck.filter(
|
const n = deck.filter(
|
||||||
@@ -146,11 +164,26 @@ describe('card catalogue (component 1)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('makes track the largest category in the deck', () => {
|
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.
|
// 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 deck = buildDeck();
|
||||||
const track = deck.filter((c) => c.kind.kind === 'track').length;
|
const counts = new Map<string, number>();
|
||||||
assert.ok(track > deck.length / 3, `track is only ${track} of ${deck.length} cards`);
|
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', () => {
|
it('has 12 timetabled trains, odd westbound and even eastbound', () => {
|
||||||
|
|||||||
+54
-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
|
// 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
|
// 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".
|
// "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({
|
const report = simulate({
|
||||||
games: 40,
|
games: 200,
|
||||||
length: 'standard',
|
length: 'standard',
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
players: ['bot'],
|
players: ['bot'],
|
||||||
policy: developerBot,
|
policy: developerBot,
|
||||||
});
|
});
|
||||||
const freight = report.perGame.reduce((n, g) => n + g.revenue.freightLoad, 0);
|
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', () => {
|
it('grows the Office Area off the Running Track, on either side', () => {
|
||||||
@@ -351,7 +362,24 @@ describe('end-of-game statistics', () => {
|
|||||||
* rule that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
|
* 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.
|
* 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 (Gitea#3), and for the same reason — the rule is reachable and the bot will
|
||||||
|
* not take it.
|
||||||
|
*
|
||||||
|
* MEASURED over 600 games: `maneuver.redFlags` is OFFERED 4,212 times, first in game 5 — so the
|
||||||
|
* rule is live and constantly available. The bot PLAYS it 4 times, first in game 252. At 200
|
||||||
|
* games this canary sees nothing and calls it unreachable, which is the opposite of the truth.
|
||||||
|
*
|
||||||
|
* It got rarer for two compounding reasons, neither of them a broken rule: Gitea#14 took Red
|
||||||
|
* Flags from 5 copies to the sheet's 3, and Gitea#3 shortened most crossings to a single Stage,
|
||||||
|
* so the window in which a train is STANDING on a Mainline card — the only place the card may be
|
||||||
|
* played — is now usually one Stage wide.
|
||||||
|
*
|
||||||
|
* The bot's unwillingness is the thing worth fixing, and it is in TODO.md under Bot Performance.
|
||||||
|
* Exempted BY NAME so the other forty-odd checks stay live, and so deleting this line is what
|
||||||
|
* proves the bot has learned to use it.
|
||||||
|
*/
|
||||||
|
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch', 'event redFlagsSet'];
|
||||||
const found = anomalies(report.perGame);
|
const found = anomalies(report.perGame);
|
||||||
const never = found
|
const never = found
|
||||||
.filter((a) => a.severity === 'never')
|
.filter((a) => a.severity === 'never')
|
||||||
@@ -643,12 +671,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
|
// 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
|
// 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.
|
// sitting at an industry that wanted a tank.
|
||||||
// A HUNDRED GAMES, not thirty — the comment above says the original measurement used 100, and
|
// THREE HUNDRED GAMES, up from a hundred, because the sheet's industry density (Gitea#14) makes
|
||||||
// the sample has to be that big to mean anything: measured now, a tank is set out in 3% of games
|
// the rare commodities much rarer. Measured on these exact seeds, the game at which each type is
|
||||||
// and a reefer in 5%. Thirty games passed on luck and stopped the moment the opening deal moved
|
// first set out by a crew: caboose 5, boxcar 12, hopper 37, reefer 44, coach 64, **tank 216**.
|
||||||
// which cards a seed puts in reach. Deterministic seeds, so this either holds or it does not.
|
//
|
||||||
|
// 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>();
|
const dropped = new Set<string>();
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 300; i++) {
|
||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `cs-${i}`,
|
id: `cs-${i}`,
|
||||||
seed: 1000 + i * 7919,
|
seed: 1000 + i * 7919,
|
||||||
@@ -998,6 +1031,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
|
// 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
|
// 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%.
|
// 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 laid = 0;
|
||||||
let dead = 0;
|
let dead = 0;
|
||||||
/**
|
/**
|
||||||
@@ -1094,8 +1133,14 @@ describe('the freight figures count both halves (regression)', () => {
|
|||||||
* The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than
|
* 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
|
* 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.
|
* 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 seed = 1000 + i * 7919;
|
||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `fu-${seed}`, seed,
|
id: `fu-${seed}`, seed,
|
||||||
|
|||||||
@@ -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');
|
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', () => {
|
it('refuses a card that connects to nothing', () => {
|
||||||
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
||||||
assert.ok(!canPlaceAt(area, at(3, 3), straight()), 'orphaned track is never legal');
|
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 });
|
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 });
|
const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -141,6 +143,23 @@ describe('§7 — what a train may couple', () => {
|
|||||||
'EMPTIES_ONLY',
|
'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)', () => {
|
describe('§7 — one freight car per location (trains 3/4)', () => {
|
||||||
|
|||||||
+234
-18
@@ -22,10 +22,11 @@ import { cardDescription, cardName, describeIntent, variantLabel } from '../src/
|
|||||||
import { variantsFor } from '../src/engine/track.ts';
|
import { variantsFor } from '../src/engine/track.ts';
|
||||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||||
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
||||||
import { facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
import { dayEndHtml, facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
||||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||||
|
import type { Frame } from '../src/sim/view.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||||
import {
|
import {
|
||||||
@@ -946,17 +947,29 @@ describe('the page explains itself', () => {
|
|||||||
// A played card becomes a cell with a name on it — "turnout", "Freight House", "waiting area" —
|
// A played card becomes a cell with a name on it — "turnout", "Freight House", "waiting area" —
|
||||||
// and the explanation that was visible while it sat in hand disappears exactly when it starts
|
// and the explanation that was visible while it sat in hand disappears exactly when it starts
|
||||||
// mattering. Play a long way in so every card kind reaches the grid.
|
// mattering. Play a long way in so every card kind reaches the grid.
|
||||||
const game = newGame(111);
|
/**
|
||||||
for (let i = 0; i < 400; i++) {
|
* THE SEED IS SEARCHED FOR, not written down. This took seed 111 flat and asserted its board
|
||||||
if (currentActor(game) === null) break;
|
* ended up with more than four cards on it. Gitea#3 changed how fast trains cross, which changes
|
||||||
const { options } = actionGroups(game);
|
* how a game unfolds, and 111 stopped building enough of a district — so the test failed on its
|
||||||
if (options.length === 0) break;
|
* own precondition rather than on anything about tooltips.
|
||||||
const pick = options.find((o) => o.type === 'card.play' && o.placement) ?? options[0]!;
|
*
|
||||||
if (!submit(game, pick)) break;
|
* It needs A well-built board, not one particular one, so it takes the first seed that gives it.
|
||||||
|
*/
|
||||||
|
let game = newGame(111);
|
||||||
|
for (let seed = 111; seed < 211; seed++) {
|
||||||
|
game = newGame(seed);
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
if (currentActor(game) === null) break;
|
||||||
|
const { options } = actionGroups(game);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
const pick = options.find((o) => o.type === 'card.play' && o.placement) ?? options[0]!;
|
||||||
|
if (!submit(game, pick)) break;
|
||||||
|
}
|
||||||
|
if (view(game).cells.length > 4) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cells = view(game).cells;
|
const cells = view(game).cells;
|
||||||
assert.ok(cells.length > 4, 'not enough of the board was built to be a real check');
|
assert.ok(cells.length > 4, 'no seed under 211 built enough of a board to be a real check');
|
||||||
for (const c of cells) {
|
for (const c of cells) {
|
||||||
assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`);
|
assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`);
|
||||||
// camelCase on the board is the failure that keeps recurring — labels AND descriptions.
|
// camelCase on the board is the failure that keeps recurring — labels AND descriptions.
|
||||||
@@ -1585,7 +1598,40 @@ describe('the static build', () => {
|
|||||||
(target['onclick'] as (() => void) | null)?.();
|
(target['onclick'] as (() => void) | null)?.();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
assert.ok(clickVerb(hand, 'play'), 'no card in hand offers a play verb');
|
/**
|
||||||
|
* TRY EVERY PLAY BUTTON, NOT JUST THE FIRST — a card offering "play" does not necessarily play
|
||||||
|
* ONTO THE BOARD.
|
||||||
|
*
|
||||||
|
* This clicked the first play verb it found and then asserted the board had lit up. A train card
|
||||||
|
* plays to the timetable and a Department discard to the piles, so neither lights a square, and
|
||||||
|
* whether the first playable card in this deal happens to be a track or facility card is luck.
|
||||||
|
* Gitea#14's deck counts re-dealt seed 555, the first play verb landed on Train 2, and the test
|
||||||
|
* failed claiming the page highlighted nothing — when the page was right and the card simply had
|
||||||
|
* no square to point at.
|
||||||
|
*
|
||||||
|
* So it clicks each play button in turn until the board lights, which is the property the test
|
||||||
|
* is named for. It still fails loudly if NO card in hand can light a square.
|
||||||
|
*
|
||||||
|
* EACH BUTTON IS CLICKED EXACTLY ONCE. Picking a card is a toggle, so clicking one to check it
|
||||||
|
* and then clicking it again inside the loop would UNPICK it — which is how the first draft of
|
||||||
|
* this managed to fail on a deal whose very first play card was a good one.
|
||||||
|
*/
|
||||||
|
const playButtons = (el: Record<string, unknown>): Record<string, unknown>[] => {
|
||||||
|
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
|
||||||
|
return fn
|
||||||
|
.call(el, 'button.cardact')
|
||||||
|
.filter((n) => (n['dataset'] as Record<string, string>)['verb'] === 'play');
|
||||||
|
};
|
||||||
|
assert.ok(playButtons(hand).length > 0, 'no card in hand offers a play verb');
|
||||||
|
let litTheBoard = false;
|
||||||
|
for (const target of playButtons(hand)) {
|
||||||
|
(target['onclick'] as (() => void) | null)?.();
|
||||||
|
const drawn = String(grid['innerHTML']);
|
||||||
|
if ((grid['highlighted'] as () => unknown[])().length > 0 || /data-ghost="/.test(drawn)) {
|
||||||
|
litTheBoard = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Without the board stylesheet every shape is drawn black on a near-black background: the page
|
// Without the board stylesheet every shape is drawn black on a near-black background: the page
|
||||||
// looks empty even though the markup is perfect.
|
// looks empty even though the markup is perfect.
|
||||||
@@ -1598,7 +1644,10 @@ describe('the static build', () => {
|
|||||||
assert.match(html, /data-cell="/, 'the board drew no addressable cards');
|
assert.match(html, /data-cell="/, 'the board drew no addressable cards');
|
||||||
const lit = (grid['highlighted'] as () => unknown[])();
|
const lit = (grid['highlighted'] as () => unknown[])();
|
||||||
const ghosts = /data-ghost="/.test(html);
|
const ghosts = /data-ghost="/.test(html);
|
||||||
assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board');
|
assert.ok(
|
||||||
|
litTheBoard || lit.length > 0 || ghosts,
|
||||||
|
'no card in hand, picked in turn, ever highlighted a square on the board',
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POINTING AT THE SQUARE A BUTTON MEANS — wired on the emitted bundle, not asserted off the menu.
|
* POINTING AT THE SQUARE A BUTTON MEANS — wired on the emitted bundle, not asserted off the menu.
|
||||||
@@ -2761,18 +2810,44 @@ describe('the Division map shows the whole route', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seats 1 to 4 players without overlapping or spilling off the canvas', () => {
|
it('draws 1 to 4 players as ONE row, west to east, without overlapping or spilling', () => {
|
||||||
// A row, two facing rows, a horseshoe and a square. The layout is geometry with no visual
|
/**
|
||||||
// feedback loop, so this is the only thing standing between a change and an unreadable board.
|
* Gitea#18. This used to check "a row, two facing rows, a horseshoe and a square" — the route
|
||||||
|
* was laid out around a table, on the reasoning that players sit around one. It cost three
|
||||||
|
* reports, and the one that decided it was that **east stopped being to the right**: a player's
|
||||||
|
* east could be drawn south, west or north depending which lane their district landed in, on a
|
||||||
|
* map whose whole job is saying which way a train is going.
|
||||||
|
*
|
||||||
|
* So the property is now stronger and much simpler to state — every cell on one row, ordered
|
||||||
|
* west to east — which is exactly what makes "east is right" true and is the thing that would
|
||||||
|
* silently regress if anyone reintroduced lanes. The overlap and canvas checks are kept: the
|
||||||
|
* layout is geometry with no visual feedback loop.
|
||||||
|
*/
|
||||||
for (const players of [1, 2, 3, 4]) {
|
for (const players of [1, 2, 3, 4]) {
|
||||||
const svg = divisionFor(players);
|
const svg = divisionFor(players);
|
||||||
const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg);
|
const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg);
|
||||||
assert.ok(vb, `${players}p produced no viewBox`);
|
assert.ok(vb, `${players}p produced no viewBox`);
|
||||||
const W = Number(vb![1]);
|
const W = Number(vb![1]);
|
||||||
const H = Number(vb![2]);
|
const H = Number(vb![2]);
|
||||||
const rects = [...svg.matchAll(/class="bs-dcell[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
|
const rects = [...svg.matchAll(/class="bs-dcell bs-d(\w+)[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
|
||||||
.map((m) => ({ x: +m[1]!, y: +m[2]!, w: +m[3]!, h: +m[4]! }));
|
.map((m) => ({ kind: m[1]!, x: +m[2]!, y: +m[3]!, w: +m[4]!, h: +m[5]! }));
|
||||||
assert.ok(rects.length >= 5, `${players}p drew only ${rects.length} cells`);
|
// WDP · (ML · Office) × players · ML · EDP — including the Mainline card before the East
|
||||||
|
// Division Point, which the issue's own sketch left out.
|
||||||
|
assert.equal(rects.length, 2 * players + 3, `${players}p drew ${rects.length} cells`);
|
||||||
|
assert.equal(rects[0]!.kind, 'dp', `${players}p does not start at a Division Point`);
|
||||||
|
assert.equal(rects[rects.length - 1]!.kind, 'dp', `${players}p does not end at a Division Point`);
|
||||||
|
assert.equal(rects[rects.length - 2]!.kind, 'ml', `${players}p has no Mainline card before the East DP`);
|
||||||
|
|
||||||
|
// ONE ROW: every cell at the same y, and x strictly increasing.
|
||||||
|
const ys = new Set(rects.map((r) => r.y));
|
||||||
|
assert.equal(ys.size, 1, `${players}p drew ${ys.size} rows — the Division must be one`);
|
||||||
|
for (let i = 1; i < rects.length; i++) {
|
||||||
|
assert.ok(
|
||||||
|
rects[i]!.x > rects[i - 1]!.x,
|
||||||
|
`${players}p: cell ${i} is not east of the one before it — east is no longer to the right`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < rects.length; i++) {
|
for (let i = 0; i < rects.length; i++) {
|
||||||
const a = rects[i]!;
|
const a = rects[i]!;
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -2788,6 +2863,52 @@ describe('the Division map shows the whole route', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws no office-area detail on the Division map, but keeps the trains', () => {
|
||||||
|
// Gitea#18: "Division map should not show any office area detail (no limits, no running track,
|
||||||
|
// etc.)" — an Office used to expand into its whole Running Track, Limits to Limits, so this map
|
||||||
|
// carried every straight, turnout and Limits sign of every district and grew sideways as
|
||||||
|
// districts were built. One cell per district now.
|
||||||
|
//
|
||||||
|
// The trains stay: "trains within the office area should definitely be represented on the
|
||||||
|
// division map", split into the A/D register and the crews switching below it.
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'div-collapse', seed: 7,
|
||||||
|
config: {
|
||||||
|
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['Solitaire'],
|
||||||
|
});
|
||||||
|
const area = areaOf(s, 0);
|
||||||
|
area.tier = 'terminal';
|
||||||
|
const place = (id: string, n: number, coord: { row: number; col: number }, ad: boolean): void => {
|
||||||
|
s.trays.set(id, {
|
||||||
|
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [{ type: 'boxcar', loaded: true }],
|
||||||
|
direction: 'east', facing: 'e',
|
||||||
|
position: { at: 'grid', seat: 0, coord }, movesUsed: 0,
|
||||||
|
} as never);
|
||||||
|
if (ad) area.adOccupancy.push(id);
|
||||||
|
};
|
||||||
|
place('ad1', 9, area.officeCoord, true);
|
||||||
|
place('sw1', 7, { row: area.runningRow - 1, col: 0 }, false);
|
||||||
|
|
||||||
|
const svg = divisionSvg(snapshot(s, [], null).division);
|
||||||
|
// ONE district cell, not one per Running Track card.
|
||||||
|
const districts = [...svg.matchAll(/class="bs-dcell bs-drun/g)].length;
|
||||||
|
assert.equal(districts, 1, `the district drew as ${districts} cells`);
|
||||||
|
assert.doesNotMatch(svg, /Limits/, 'a Limits sign reached the Division map');
|
||||||
|
|
||||||
|
// Both trains are on it, in two registers — the A/D one above the crew switching below.
|
||||||
|
const chips = [...svg.matchAll(/class="bs-train" data-tip="(T\d+)[^"]*"><rect x="[\d.]+" y="([\d.]+)"/g)]
|
||||||
|
.map((m) => ({ label: m[1]!, y: +m[2]! }));
|
||||||
|
const ad = chips.find((c) => c.label === 'T9');
|
||||||
|
const sw = chips.find((c) => c.label === 'T7');
|
||||||
|
assert.ok(ad, 'the train holding an A/D track is not on the map');
|
||||||
|
assert.ok(sw, 'the crew switching in the district is not on the map');
|
||||||
|
assert.ok(sw.y > ad.y, 'the switching crew should be drawn BELOW the A/D register, not beside it');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps every roster chip inside the Office cell it belongs to, at any occupancy', () => {
|
it('keeps every roster chip inside the Office cell it belongs to, at any occupancy', () => {
|
||||||
// "Two Trains, One Card": sizing the cell by OCCUPANCY moved the East Division Point sideways
|
// "Two Trains, One Card": sizing the cell by OCCUPANCY moved the East Division Point sideways
|
||||||
// every time an A/D track filled or cleared. Sizing by CAPACITY (docs/plans/switching-paths.md)
|
// every time an A/D track filled or cleared. Sizing by CAPACITY (docs/plans/switching-paths.md)
|
||||||
@@ -3090,6 +3211,84 @@ describe('the three places a game is drawn stay in step', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the Day rolling over says so (Gitea#10)', () => {
|
||||||
|
// "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of
|
||||||
|
// time." A Day turns inside the phases that run themselves, so it passes between one click and
|
||||||
|
// the next — the phase banner and the announcement flash are both gone in a few seconds.
|
||||||
|
const frameAt = (day: number, days = 5): Frame => {
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'dayend', seed: 4021,
|
||||||
|
config: {
|
||||||
|
mode: 'solitaire', days, minCombinedRevenue: 12, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['Solitaire'],
|
||||||
|
});
|
||||||
|
s.clock.day = day;
|
||||||
|
return snapshot(s, [], null);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('names the Day that ENDED, not the one starting', () => {
|
||||||
|
// Written from the frame after the rollover, so an off-by-one here would congratulate a player
|
||||||
|
// on finishing a Day they have not played yet.
|
||||||
|
const html = dayEndHtml(frameAt(3));
|
||||||
|
assert.ok(html.includes('Day 2 has ended'), `wrong Day named:\n${html}`);
|
||||||
|
assert.ok(html.includes('Day 3 of 5'), 'the Day now beginning is not named');
|
||||||
|
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the last Day as the last Day rather than promising more', () => {
|
||||||
|
const html = dayEndHtml(frameAt(6));
|
||||||
|
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');
|
||||||
|
assert.ok(html.includes('last Day on the timetable'), `still offering Days to run:\n${html}`);
|
||||||
|
assert.ok(!html.includes('Days left'), 'promises more Days after the last one');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scores the table against the COMBINED target, not one player against it', () => {
|
||||||
|
// `minCombinedRevenue` is a floor for the whole table. Showing one player's Revenue against a
|
||||||
|
// four-player target reads as hopeless when the table may be well ahead.
|
||||||
|
const f = frameAt(2);
|
||||||
|
f.players = [
|
||||||
|
{ index: 0, seat: 0, name: 'Ada', revenue: 4, hand: 3 },
|
||||||
|
{ index: 1, seat: 1, name: 'Bo', revenue: 7, hand: 2 },
|
||||||
|
];
|
||||||
|
f.viewer = 0;
|
||||||
|
const html = dayEndHtml(f);
|
||||||
|
assert.ok(html.includes('<b>11</b>'), `combined Revenue is not 4 + 7:\n${html}`);
|
||||||
|
assert.ok(html.includes('<b>12</b>'), 'the target is not shown');
|
||||||
|
// Revenue order, so the leader is first: Bo (7) above Ada (4).
|
||||||
|
assert.ok(html.indexOf('Bo') < html.indexOf('Ada'), 'the standings are not in Revenue order');
|
||||||
|
assert.ok(html.includes('(you)'), 'the viewer is not marked in the standings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing about collisions in a game that is not scored on them', () => {
|
||||||
|
// §3.4's collision checks run in competitive and co-op ONLY, and `0` on a dial turns that check
|
||||||
|
// off besides. A solitaire game carries the default dials and enforces neither, so a collision
|
||||||
|
// budget on screen there would be a rule this game does not have.
|
||||||
|
const solo = frameAt(2);
|
||||||
|
assert.ok(!dayEndHtml(solo).includes('Collision'), 'reports a collision budget in solitaire');
|
||||||
|
|
||||||
|
const coop = frameAt(2);
|
||||||
|
coop.mode = 'coop';
|
||||||
|
coop.maxCollisionsTotal = 3;
|
||||||
|
coop.collisionsTotal = 1;
|
||||||
|
assert.ok(dayEndHtml(coop).includes('Collision'), 'hides collisions in a game scored on them');
|
||||||
|
|
||||||
|
const noDials = frameAt(2);
|
||||||
|
noDials.mode = 'coop';
|
||||||
|
noDials.maxCollisionsTotal = 0;
|
||||||
|
noDials.maxCollisionsPerDay = 0;
|
||||||
|
assert.ok(!dayEndHtml(noDials).includes('Collision'), 'reports collisions with both dials off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the page the dialog to fill', () => {
|
||||||
|
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||||
|
for (const id of ['dayenddlg', 'dayendbody']) {
|
||||||
|
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('every file the build needs is actually in the repo (regression)', () => {
|
describe('every file the build needs is actually in the repo (regression)', () => {
|
||||||
it('does not gitignore a source page', () => {
|
it('does not gitignore a source page', () => {
|
||||||
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
||||||
@@ -3569,7 +3768,7 @@ describe('the New Game dialog', () => {
|
|||||||
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
|
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
|
||||||
* settings, so what this really pins is that all of them ride in the URL and come back out.
|
* settings, so what this really pins is that all of them ride in the URL and come back out.
|
||||||
*
|
*
|
||||||
* REBUILT 2026-08-23 with the five game types. The dialog and the lobby now ask the same eleven
|
* REBUILT 2026-08-23 with the five game types. The dialog and the lobby now ask the same twelve
|
||||||
* questions through `settings-form.ts`, which addresses its radio groups by NAME through the
|
* questions through `settings-form.ts`, which addresses its radio groups by NAME through the
|
||||||
* DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog.
|
* DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog.
|
||||||
*/
|
*/
|
||||||
@@ -3681,6 +3880,7 @@ describe('the New Game dialog', () => {
|
|||||||
transit: els.get('ng-transit')!['value'],
|
transit: els.get('ng-transit')!['value'],
|
||||||
days: els.get('ng-days')!['value'],
|
days: els.get('ng-days')!['value'],
|
||||||
minrev: els.get('ng-minrev')!['value'],
|
minrev: els.get('ng-minrev')!['value'],
|
||||||
|
tossloco: els.get('ng-tossloco')!['checked'],
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
|
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
|
||||||
@@ -3699,6 +3899,22 @@ describe('the New Game dialog', () => {
|
|||||||
assert.equal(form.hand, 'sixRandom', 'the opening hand in play was not preselected');
|
assert.equal(form.hand, 'sixRandom', 'the opening hand in play was not preselected');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries §6.2 in the URL, written only when it is OFF (Gitea#9)', async () => {
|
||||||
|
// The setting defaults ON, so a link that spelled out `toss=1` every time would say nothing and
|
||||||
|
// cost a parameter — the same reason the optional rules are written only when they are on. What
|
||||||
|
// has to survive the trip is therefore the OFF case, which is the one that changes the game.
|
||||||
|
const on = await load('?seed=430');
|
||||||
|
(on.els.get('newgame')!['onclick'] as () => void)();
|
||||||
|
assert.equal(readForm(on.els, on.groups).tossloco, true, 'a default game did not allow the discard');
|
||||||
|
|
||||||
|
const off = await load('?seed=430&toss=0');
|
||||||
|
(off.els.get('newgame')!['onclick'] as () => void)();
|
||||||
|
const form = readForm(off.els, off.groups);
|
||||||
|
assert.equal(form.tossloco, false, '?toss=0 did not reach the dialog');
|
||||||
|
// And it counts as a rule change, so the game is no longer the named type.
|
||||||
|
assert.equal(form.type, 'custom', 'turning the rule off still read as Solitaire');
|
||||||
|
});
|
||||||
|
|
||||||
it('reopens on Solitaire when the game in play is one, and on Custom when it was tuned', async () => {
|
it('reopens on Solitaire when the game in play is one, and on Custom when it was tuned', async () => {
|
||||||
// The type is DERIVED (`presets.ts`) rather than remembered, so what the dialog says a game is
|
// The type is DERIVED (`presets.ts`) rather than remembered, so what the dialog says a game is
|
||||||
// has to follow from its numbers — including a game whose numbers were hand-edited into the URL.
|
// has to follow from its numbers — including a game whose numbers were hand-edited into the URL.
|
||||||
|
|||||||
Reference in New Issue
Block a user