diff --git a/CHANGELOG.md b/CHANGELOG.md index 5769030..26e5fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,58 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.7.9 — 2026-08-30 + +Four pieces of feedback on the solitaire setup screen, plus the rules bug the second one exposed. + +### The collision limits were dead settings in solitaire — now they are not + +Asked to reword the collision entries to "the game ends immediately and results in a loss", which +turned out to be unwriteable: `advance.ts` gated the whole §3.4 check on +`mode === 'competitive' || mode === 'coop'`, and a solitaire game's mode is `'solitaire'`. So both +limits were offered on the New Game dialog as live settings, rode into the config, and never fired. +A solitaire player could set a limit of 1 and crash all game. The *existing* text beside them ("the +game ends in a loss") was already false; the new wording would only have made it more so. + +The exclusion was never a stated rule — §3.4 does not carve solitaire out — and nothing recorded a +reason for it. **Jesse's ruling: the settings do what they say**, so the mode gate is gone rather +than the controls. + +**A solitaire game can therefore now end early, and that is measured rather than asserted.** Over +200 standard games with the developer bot: `loss/collisionFloor` **1 game in 200**, with Days played +falling 5.00 → 4.98 mean and a *minimum of 1* — a bad opening Day can now end a game outright. +Collisions per game are unchanged (0.14 mean, max 3), which is the point: the ending is rare because +crashes are, not because the check is lenient. `0` still switches either limit off, at one seat +exactly as at four, and that path has its own test now. + +Every figure in `TODO.md` quoted from a full-length solitaire run predates this. + +### Where an Extra may start defaults to your own Control Point + +Was "any player's". At one seat the two are the *same rule* — `apply.ts` only rejects `ownOffice` +when `start.seat !== seatOf(s, player)`, which cannot happen — so this is a labelling fix with no +gameplay effect and nothing to re-measure. The permissive label described a permission a lone player +was never being granted, and named an "any player" they have no contrast with. + +### Three wording and layout fixes + +- **The collision entries** on all three screens now read "The game ends immediately and results in + a loss…". The Revenue floor keeps its own wording, since it is settled at the end rather than on + the spot. +- **Employee Rotation** on the solitaire screen says "not applicable for solitaire" instead of + "meaningless at a table of one, shown here so this screen and the lobby read as one list" — which + explained the page's own construction to somebody who had not asked. +- **The save warning is legible.** It sat in `.ng-note`, the same dim 11px grey as the twenty + explanatory notes above it, while being the only thing on the screen describing something + irreversible. It is 14px on an amber panel now — amber and not red because losing a save is a real + cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue + Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal". + +869 tests pass, two of them new; one existing test asserted the opposite of the ruling above and +says so where it was reversed. + +--- + ## 0.7.8 — 2026-08-30 ### The setup screen was unreachable for anyone who had ever played diff --git a/TODO.md b/TODO.md index 8072bc4..860404e 100644 --- a/TODO.md +++ b/TODO.md @@ -611,7 +611,19 @@ The replay viewer, the save format, and how a game gets shared. ## Play Balance -- [ ] **FREIGHT GOT SCARCER WHEN THE MAINLINE WENT ONTO REGIONS, and nobody knows why yet.** +- [ ] **A SOLITAIRE GAME CAN NOW END ON THE COLLISION FLOOR (v0.7.9, 2026-08-30) — small, but every + full-length figure in this file predates it.** §3.4's check was gated on + `mode === 'competitive' || mode === 'coop'`, so the two collision limits were live settings in + the solitaire dialog that did nothing; Jesse's ruling was that they should do what they say, and + the gate is gone. Measured over 200 standard developer-bot games: `loss/collisionFloor` fires in + **1 game in 200**, Days played 5.00 → 4.98 mean with a **minimum of 1**, and collisions per game + unchanged at 0.14 (max 3). + + Nothing here needs acting on — the effect is smaller than the noise on every number in this + section — but it is a new way for a run to be short, so **a mean taken over games that all ran + five Days is no longer quite what is being sampled.** Worth remembering when the rebalance pass + re-measures, and worth watching if a future change makes collisions more common, because the + cost of one stops being "−1 Revenue" and starts being "the game". 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 diff --git a/package.json b/package.json index 7ea057b..9e2bbb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.8", + "version": "0.7.9", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 2c44f06..41d79ad 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -1636,11 +1636,25 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage }); } - // §3.4 — every mode but competitive-and-coop-only: a Day's collisions against `maxCollisionsPerDay` - // and the game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not - // scaled by player count — Jesse's call, 2026-08-20: more players is more independent chances to - // collide, not a bigger shared budget. - if (s.config.mode === 'competitive' || s.config.mode === 'coop') { + /** + * §3.4 — EVERY MODE, SOLITAIRE INCLUDED: a Day's collisions against `maxCollisionsPerDay` and the + * game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not scaled + * by player count — Jesse's call, 2026-08-20: more players is more independent chances to collide, + * not a bigger shared budget. + * + * SOLITAIRE WAS EXCLUDED UNTIL 2026-08-30 and nothing said so. The gate here read `mode === + * 'competitive' || mode === 'coop'`, while `SOLO_CONFIG` carried both limits and the New Game + * dialog offered them as live settings — so a solitaire player could set a collision limit, read + * "the game ends in a loss" beside it, and crash as often as they liked. Found reviewing that + * screen's wording (Jesse, 2026-08-30); his ruling is that the settings should do what they say, + * so the gate is gone rather than the controls. + * + * A SOLITAIRE GAME CAN THEREFORE NOW END EARLY, which no measurement in `TODO.md` was taken + * under. At the shipped defaults (3 a Day, 5 total) it is a rare ending rather than a common one — + * the bot averages 0.06 collisions a game — but any figure quoted from a full-length run predates + * it. + */ + { const perDayBreach = s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay; const totalBreach = diff --git a/src/web/play.html b/src/web/play.html index 0c49eac..7ae102c 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -76,6 +76,17 @@ dialog input:focus{outline:none;border-color:#4d6fa8} padding:5px 14px;cursor:pointer;font:inherit;font-size:13px} .ng-buttons button:hover{border-color:#4d6fa8} #ng-deal{background:#31527f;border-color:#4d6fa8} +/* The save warning is the one thing on this screen that describes something IRREVERSIBLE, and it + sat in `.ng-note` — the same dim 11px grey as the twenty explanatory notes above it, which is + where the eye has already learned there is nothing to act on. Sized and coloured to be read + (Jesse, 2026-08-30). Amber rather than red: losing a saved game is a real cost, not a danger, and + red here would outrank the actual rules of the game sitting above it. */ +#ss-saved-note{font-size:14px;line-height:1.5;color:#f0c674;background:#2e2718; + border:1px solid #7a6320;border-left-width:4px;border-radius:5px;padding:10px 12px;margin:16px 0 0} +/* Two live choices, so neither is the quiet one: `Deal New Game` keeps the primary blue it has when + it is the only button, and `Continue` is given the same weight rather than reading as a cancel. */ +#ss-deal{background:#31527f;border-color:#4d6fa8} +#ss-resume{background:#2f5340;border-color:#4f8a68} main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start} @media(max-width:1100px){main{grid-template-columns:1fr}} section{background:var(--panel);border:1px solid var(--line);border-radius:7px; @@ -505,13 +516,13 @@ ul.blocked li{padding:2px 0}
@@ -717,13 +728,13 @@ ul.blocked li{padding:2px 0}
@@ -742,8 +753,7 @@ ul.blocked li{padding:2px 0}
-
@@ -769,12 +779,13 @@ ul.blocked li{padding:2px 0} save (`commitNewGame` calls `clearSave`), so without a way back the door would be a way to lose a game in progress — and the door is reached by clicking "Play solitaire", which nobody reads as "discard what I was playing". --> - + - - + + @@ -1015,13 +1026,13 @@ ul.blocked li{padding:2px 0}
diff --git a/src/web/presets.ts b/src/web/presets.ts index eec96b9..b3bd3a1 100644 --- a/src/web/presets.ts +++ b/src/web/presets.ts @@ -119,8 +119,14 @@ export const PRESETS: readonly Preset[] = [ revenueFloor: (players, days) => collectiveRevenueFloor(players, days), rules: { startingHand: SIX, - // Nobody else's district exists, so "any Control Point" and "your own" are the same rule. - extraStart: 'anyOffice', + /** + * Nobody else's district exists, so "any Control Point" and "your own" are the same rule — + * `apply.ts` only ever rejects `ownOffice` when `start.seat !== seatOf(s, player)`, which + * cannot happen at one seat. It said `anyOffice` until 2026-08-30, which was true and read + * wrong: a solitaire player has no "any player" to contrast themselves with, so the permissive + * label described a permission nobody was being granted. Jesse's call; no gameplay effect. + */ + extraStart: 'ownOffice', passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0, diff --git a/test/advance.test.ts b/test/advance.test.ts index 3f2b48a..bff91fc 100644 --- a/test/advance.test.ts +++ b/test/advance.test.ts @@ -655,13 +655,36 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => { assert.notEqual(s.status, 'finished'); }); - it('solitaire never checks the collision floor, whatever the counts', () => { + it('solitaire checks the collision floor too, like every other mode', () => { + /** + * REVERSED 2026-08-30, and this test previously asserted the opposite ("solitaire never checks + * the collision floor, whatever the counts"). + * + * The exclusion was never a stated rule — §3.4 does not carve solitaire out — and nothing on + * screen reflected it: `SOLO_CONFIG` carried both limits, the New Game dialog offered them as + * live settings, and the text beside them said the game would end in a loss. A solitaire player + * could set a limit of 1 and crash all game. Found reviewing that screen's wording; Jesse's + * ruling is that the settings do what they say. + */ const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 }); s.collisionsToday = 99; s.collisionsTotal = 99; s.clock.stage = 1; s.clock.phase = 'shiftChange'; advance(s); + assert.equal(s.status, 'finished'); + assert.equal(s.outcome!.reason, 'collisionFloor'); + }); + + it('still lets a solitaire game switch the collision floor off with 0', () => { + // The disable path is what a player who does not want the new ending reaches for, so it has to + // work at one seat exactly as it does at four. + const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 0, maxCollisionsTotal: 0 }); + s.collisionsToday = 99; + s.collisionsTotal = 99; + s.clock.stage = 1; + s.clock.phase = 'shiftChange'; + advance(s); assert.notEqual(s.status, 'finished'); }); }); diff --git a/test/presets.test.ts b/test/presets.test.ts index 681a0cb..6aa0f73 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -59,8 +59,11 @@ describe('what each game type is', () => { assert.equal(presetSettings('cutthroat', 4, 5).extraStart, 'anyOffice'); assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice'); assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice'); - // At one player the two rules are the same rule. - assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'anyOffice'); + // At one player the two rules ARE the same rule — `apply.ts` only rejects `ownOffice` when the + // start is another seat's, which cannot happen. Solitaire said `anyOffice` until 2026-08-30: + // true, and it read wrong, since a lone player has no "any player" to be contrasted with. The + // label changed and the behaviour did not. + assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'ownOffice'); }); it('leaves every optional rule off, in every type', () => { diff --git a/test/web.test.ts b/test/web.test.ts index 5a17ea7..f87ee8e 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -4124,7 +4124,7 @@ describe('the New Game dialog', () => { assert.equal( n.search, - '?seed=99&hand=threeTrackThreeOther&extra=anyOffice&passenger=5&freight=0&transit=2' + + '?seed=99&hand=threeTrackThreeOther&extra=ownOffice&passenger=5&freight=0&transit=2' + '&days=5&minrev=15&colday=3&coltotal=5&tool=1', ); }); @@ -4158,7 +4158,7 @@ describe('the New Game dialog', () => { // Dealing a random seed, disliking it and dealing again at the same settings produces the same // search string — and assigning `location.search` the value it already holds does nothing. const url = - '?hand=sixRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5'; + '?hand=sixRandom&extra=ownOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5'; const { els, nav: n } = await load(url); (els.get('newgame')!['onclick'] as () => void)(); const dlg = els.get('newgamedlg')!; @@ -4296,7 +4296,7 @@ describe('the New Game dialog', () => { assert.equal( n.search, - '?hand=sixRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5', + '?hand=sixRandom&extra=ownOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5', 'a bad seed was carried into the URL', ); });