v0.7.9 — solitaire setup screen feedback, and the dead settings it exposed

Four pieces of feedback from Jesse on the solitaire setup screen.

THE COLLISION LIMITS DID NOTHING IN SOLITAIRE. Asked to reword those
entries to "the game ends immediately and results in a loss", which was
unwriteable: advance.ts gated the §3.4 check on competitive/coop, and a
solitaire game's mode is 'solitaire'. Both limits were offered as live
settings, rode into the config, and never fired — the existing text was
already false. The exclusion was never a stated rule and nothing
recorded a reason for it. Jesse's ruling: the settings do what they say,
so the gate is gone rather than the controls.

Measured, not asserted — 200 standard developer-bot games:
loss/collisionFloor 1 in 200, Days played 5.00 -> 4.98 mean with a
minimum of 1, collisions per game unchanged at 0.14. Recorded in
TODO.md under Play Balance, since full-length figures predate it.

Extra start defaults to ownOffice: at one seat it is the same rule as
anyOffice (apply.ts only rejects another seat's start), so this is a
label fix with no gameplay effect.

Also: collision wording on all three screens, Employee Rotation reads
"not applicable for solitaire", and the save warning is legible at 14px
on an amber panel with buttons that say Continue Existing Saved Game and
Deal New Game.

869 tests pass, two new; one asserted the opposite of the ruling and
says so where it was reversed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
This commit is contained in:
Jesse.Markowitz
2026-08-30 01:01:11 -04:00
co-authored by Claude Sonnet 5
parent 193800a649
commit f2c87b6871
9 changed files with 148 additions and 27 deletions
+52
View File
@@ -19,6 +19,58 @@ page as `v0.1.0 · <sha> · <date>`, 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 ## 0.7.8 — 2026-08-30
### The setup screen was unreachable for anyone who had ever played ### The setup screen was unreachable for anyone who had ever played
+13 -1
View File
@@ -611,7 +611,19 @@ The replay viewer, the save format, and how a game gets shared.
## Play Balance ## 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%**, 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 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 200 — on `sim.test.ts`'s seeds, 40 games now yield 0 loads, 80 yield 3, 120 yield 10, 200
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.7.8", "version": "0.7.9",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+19 -5
View File
@@ -1636,11 +1636,25 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage }); 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 * §3.4 — EVERY MODE, SOLITAIRE INCLUDED: a Day's collisions against `maxCollisionsPerDay` and the
// scaled by player count — Jesse's call, 2026-08-20: more players is more independent chances to * game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not scaled
// collide, not a bigger shared budget. * by player count — Jesse's call, 2026-08-20: more players is more independent chances to collide,
if (s.config.mode === 'competitive' || s.config.mode === 'coop') { * 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 = const perDayBreach =
s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay; s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay;
const totalBreach = const totalBreach =
+23 -12
View File
@@ -76,6 +76,17 @@ dialog input:focus{outline:none;border-color:#4d6fa8}
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px} padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
.ng-buttons button:hover{border-color:#4d6fa8} .ng-buttons button:hover{border-color:#4d6fa8}
#ng-deal{background:#31527f;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} 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}} @media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px; section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
@@ -505,13 +516,13 @@ ul.blocked li{padding:2px 0}
</div> </div>
<div class="set-row" id="lb-colday-row"> <div class="set-row" id="lb-colday-row">
<label class="ng-gate"><input type="checkbox" id="lb-colday-on" checked> <label class="ng-gate"><input type="checkbox" id="lb-colday-on" checked>
<span>The game ends and everyone loses if collisions in one Day reach</span> <span>The game ends immediately and results in a loss if collisions in one Day reach</span>
<input id="lb-colday" type="number" min="0" step="1" class="gate-num"></label> <input id="lb-colday" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="lb-colday-hint"></span> <span class="set-hint" id="lb-colday-hint"></span>
</div> </div>
<div class="set-row" id="lb-coltotal-row"> <div class="set-row" id="lb-coltotal-row">
<label class="ng-gate"><input type="checkbox" id="lb-coltotal-on" checked> <label class="ng-gate"><input type="checkbox" id="lb-coltotal-on" checked>
<span>The game ends and everyone loses after this many collisions in the whole game</span> <span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
<input id="lb-coltotal" type="number" min="0" step="1" class="gate-num"></label> <input id="lb-coltotal" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="lb-coltotal-hint"></span> <span class="set-hint" id="lb-coltotal-hint"></span>
</div> </div>
@@ -717,13 +728,13 @@ ul.blocked li{padding:2px 0}
</div> </div>
<div class="set-row" id="ss-colday-row"> <div class="set-row" id="ss-colday-row">
<label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked> <label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked>
<span>The game ends in a loss if collisions in one Day reach</span> <span>The game ends immediately and results in a loss if collisions in one Day reach</span>
<input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label> <input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="ss-colday-hint"></span> <span class="set-hint" id="ss-colday-hint"></span>
</div> </div>
<div class="set-row" id="ss-coltotal-row"> <div class="set-row" id="ss-coltotal-row">
<label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked> <label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked>
<span>The game ends in a loss after this many collisions in the whole game</span> <span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
<input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label> <input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="ss-coltotal-hint"></span> <span class="set-hint" id="ss-coltotal-hint"></span>
</div> </div>
@@ -742,8 +753,7 @@ ul.blocked li{padding:2px 0}
<span class="set-hint" id="ss-visibility-hint"></span> <span class="set-hint" id="ss-visibility-hint"></span>
</div> </div>
<div class="set-row" id="ss-rotation-row"> <div class="set-row" id="ss-rotation-row">
<label class="ng-num"><span>Employee Rotation — meaningless at a table of one, shown here so <label class="ng-num"><span>Employee Rotation — not applicable for solitaire</span>
this screen and the lobby read as one list</span>
<input id="ss-rotation" type="checkbox" disabled></label> <input id="ss-rotation" type="checkbox" disabled></label>
<span class="set-hint" id="ss-rotation-hint"></span> <span class="set-hint" id="ss-rotation-hint"></span>
</div> </div>
@@ -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 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 lose a game in progress — and the door is reached by clicking "Play solitaire", which nobody
reads as "discard what I was playing". --> reads as "discard what I was playing". -->
<p class="ng-note" id="ss-saved-note" hidden>You have a solitaire game in progress. Dealing a new <p id="ss-saved-note" hidden><b>You have a solitaire game in progress.</b> Dealing a new game
one below replaces it &mdash; there is no undo for that.</p> replaces it permanently &mdash; there is no undo. Choose <b>Continue Existing Saved Game</b>
to pick it up where you left off.</p>
<menu class="ng-buttons"> <menu class="ng-buttons">
<button id="ss-resume" type="button" hidden>Continue saved game</button> <button id="ss-resume" type="button" hidden>Continue Existing Saved Game</button>
<button id="ss-deal" type="button">Deal</button> <button id="ss-deal" type="button">Deal New Game</button>
</menu> </menu>
</section> </section>
</div> </div>
@@ -1015,13 +1026,13 @@ ul.blocked li{padding:2px 0}
</div> </div>
<div class="set-row" id="ng-colday-row"> <div class="set-row" id="ng-colday-row">
<label class="ng-gate"><input type="checkbox" id="ng-colday-on" checked> <label class="ng-gate"><input type="checkbox" id="ng-colday-on" checked>
<span>The game ends and everyone loses if collisions in one Day reach</span> <span>The game ends immediately and results in a loss if collisions in one Day reach</span>
<input id="ng-colday" type="number" min="0" step="1" class="gate-num"></label> <input id="ng-colday" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="ng-colday-hint"></span> <span class="set-hint" id="ng-colday-hint"></span>
</div> </div>
<div class="set-row" id="ng-coltotal-row"> <div class="set-row" id="ng-coltotal-row">
<label class="ng-gate"><input type="checkbox" id="ng-coltotal-on" checked> <label class="ng-gate"><input type="checkbox" id="ng-coltotal-on" checked>
<span>The game ends and everyone loses after this many collisions in the whole game</span> <span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
<input id="ng-coltotal" type="number" min="0" step="1" class="gate-num"></label> <input id="ng-coltotal" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="ng-coltotal-hint"></span> <span class="set-hint" id="ng-coltotal-hint"></span>
</div> </div>
+8 -2
View File
@@ -119,8 +119,14 @@ export const PRESETS: readonly Preset[] = [
revenueFloor: (players, days) => collectiveRevenueFloor(players, days), revenueFloor: (players, days) => collectiveRevenueFloor(players, days),
rules: { rules: {
startingHand: SIX, 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, passengerPerCoach: 1,
freightPerLoad: 1, freightPerLoad: 1,
trainPerTransit: 0, trainPerTransit: 0,
+24 -1
View File
@@ -655,13 +655,36 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
assert.notEqual(s.status, 'finished'); 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 }); const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 });
s.collisionsToday = 99; s.collisionsToday = 99;
s.collisionsTotal = 99; s.collisionsTotal = 99;
s.clock.stage = 1; s.clock.stage = 1;
s.clock.phase = 'shiftChange'; s.clock.phase = 'shiftChange';
advance(s); 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'); assert.notEqual(s.status, 'finished');
}); });
}); });
+5 -2
View File
@@ -59,8 +59,11 @@ describe('what each game type is', () => {
assert.equal(presetSettings('cutthroat', 4, 5).extraStart, 'anyOffice'); assert.equal(presetSettings('cutthroat', 4, 5).extraStart, 'anyOffice');
assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice'); assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice');
assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice'); assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice');
// At one player the two rules are the same rule. // At one player the two rules ARE the same rule — `apply.ts` only rejects `ownOffice` when the
assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'anyOffice'); // 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', () => { it('leaves every optional rule off, in every type', () => {
+3 -3
View File
@@ -4124,7 +4124,7 @@ describe('the New Game dialog', () => {
assert.equal( assert.equal(
n.search, 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', '&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 // 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. // search string — and assigning `location.search` the value it already holds does nothing.
const url = 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); const { els, nav: n } = await load(url);
(els.get('newgame')!['onclick'] as () => void)(); (els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!; const dlg = els.get('newgamedlg')!;
@@ -4296,7 +4296,7 @@ describe('the New Game dialog', () => {
assert.equal( assert.equal(
n.search, 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', 'a bad seed was carried into the URL',
); );
}); });