v0.4.9h — a leg that is part of the row, the deck the sheet prints, and regions not miles per hour

The 0.4.9 line's port of v0.7.2, minus Gitea#18: the Division map redraw is a
multiplayer redesign and does not belong in a build people are mid-playtest on.

Gitea#17 — a 45° leg is an end of the west-to-east row, so backing into a cut
through a curve's south leg no longer couples it back to front, and a crew
pulling out through a leg takes its own cut with it.

Gitea#14 — every count is docs/Deck cards5.xlsx, including removing the Q12
office doubling and the Gap 12 industry tripling.

Gitea#15 — RAR reversed it: the placement is legal; what must hold is that no
train crosses the gap.

Gitea#3 — the printed speeds are scenery. One Stage per printed region, and
where a train starts is what varies.
This commit is contained in:
Jesse.Markowitz
2026-08-26 15:21:49 -04:00
parent f684c60f2e
commit e9683cc8c5
22 changed files with 4079 additions and 3055 deletions
+54 -9
View File
@@ -189,15 +189,26 @@ describe('the revenue chain works end to end (regression)', () => {
// only because unloads were mis-scored as completed loads after one Laborer action instead of
// four. Correcting that dropped mean revenue from 24.8 to ~4.6 and the win rate to zero, so
// "did anyone win" is no longer a safe proxy for "does freight work".
/**
* TWO HUNDRED GAMES, up from forty (Gitea#3). Completed freight loads got scarcer when the
* Mainline went onto the region model, and measurably so — on these seeds: 40 games yield 0
* loads, 80 yield 3 (1 game), 120 yield 10 (4 games), 200 yield 21 (9 games). Forty could no
* longer reach the precondition it exists to establish.
*
* WHY it got scarcer is not settled and is worth someone's attention rather than a guess — the
* change speeds crossings up, which ought to put MORE trains through a district, not fewer.
* Freight share of gross fell from 8% to 5% over 100 games across the same change. Recorded in
* TODO.md under Play Balance; the assertion itself is untouched.
*/
const report = simulate({
games: 40,
games: 200,
length: 'standard',
mode: 'solitaire',
players: ['bot'],
policy: developerBot,
});
const freight = report.perGame.reduce((n, g) => n + g.revenue.freightLoad, 0);
assert.ok(freight > 0, 'no freight load completed across 40 games');
assert.ok(freight > 0, 'no freight load completed across 200 games');
});
it('grows the Office Area off the Running Track, on either side', () => {
@@ -349,7 +360,24 @@ describe('end-of-game statistics', () => {
* rule that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
* and so removing this line is what proves the bot has been fixed.
*/
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch'];
/**
* RED FLAGS JOINS IT (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 never = found
.filter((a) => a.severity === 'never')
@@ -641,12 +669,17 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
// Measured across 100 games: tank cars boarded a train 0.07 times a game and were dropped by a
// crew ZERO times, while boxcars were 67% of every drop — and 23 of 79 waiting loads were
// sitting at an industry that wanted a tank.
// A HUNDRED GAMES, not thirty — the comment above says the original measurement used 100, and
// the sample has to be that big to mean anything: measured now, a tank is set out in 3% of games
// and a reefer in 5%. Thirty games passed on luck and stopped the moment the opening deal moved
// which cards a seed puts in reach. Deterministic seeds, so this either holds or it does not.
// THREE HUNDRED GAMES, up from a hundred, because the sheet's industry density (Gitea#14) makes
// the rare commodities much rarer. Measured on these exact seeds, the game at which each type is
// first set out by a crew: caboose 5, boxcar 12, hopper 37, reefer 44, coach 64, **tank 216**.
//
// A Refinery is one card in a hundred and fifty now, so a tank moving at all needs that card
// drawn, placed, reached and worked. The old sample of 100 stopped covering it — not because the
// rule broke, but because the deck did what the sheet asks. The sample follows the measurement
// rather than the assertion being softened: tank is still the strict test, for the reason below.
// Deterministic seeds, so this either holds or it does not.
const dropped = new Set<string>();
for (let i = 0; i < 100; i++) {
for (let i = 0; i < 300; i++) {
const s = createGame({
id: `cs-${i}`,
seed: 1000 + i * 7919,
@@ -996,6 +1029,12 @@ describe('the bot does not lay track that cannot work (regression)', () => {
// A TIE-BREAKER rather than a veto, so this is a rate and not a zero: forbidding it outright
// measured WORSE (-0.62 revenue a game), while preferring the cleaner of two equally good
// placements measured better and cut these from 28% of pieces to 7%.
//
// AND IT STAYS A TIE-BREAKER. Gitea#15 was filed as "track placements must connect" and briefly
// became a rule here; RAR reversed it on review (2026-08-26) — a rail may stop dead against its
// neighbour, and such a stub is useful as a siding to park cars on. What the engine must refuse
// is a TRAIN crossing the gap, which is `exploreMoves`' job and is tested in `track.test.ts`.
// So laying one of these is a preference, exactly as it was, and the rate below is the bar.
let laid = 0;
let dead = 0;
/**
@@ -1092,8 +1131,14 @@ describe('the freight figures count both halves (regression)', () => {
* The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than
* unloads started — and `unloads > 0` is only the precondition that makes the comparison mean
* anything. Widening the sample restores the precondition without weakening the assertion.
*
* A HUNDRED AND FIFTY DEALS, up from forty, for the same reason as the commodity test above:
* Gitea#14 put the deck on the sheet's industry density and completed unloads went with it.
* Measured on these seeds, the first deal to EARN unload Revenue is number **46**, and 19 deals
* in 400 earn any — so forty could not reach the precondition it exists to establish. 150 clears
* it with room, and the assertion itself is untouched.
*/
for (let i = 0; i < 40; i++) {
for (let i = 0; i < 150; i++) {
const seed = 1000 + i * 7919;
const s = createGame({
id: `fu-${seed}`, seed,