v0.7.0 — four game types, a lobby you can read and leave, and a multiplayer game that makes a sound

The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never
been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md.

- Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block —
  they had drifted, and each was missing a question the other asked.
- A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and
  keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is
  in, not just the last one.
- The start of a game is drawn: a handoff beat, an announcement, the code and type in the header.
- Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client;
  justDrawn goes to the seat that drew it and nobody else.
- Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board
  never named the Superintendent, bot seats were reported as absent players, and rule section
  numbers are out of every string a player reads.

Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
This commit is contained in:
Jesse.Markowitz
2026-08-23 05:46:42 -04:00
co-authored by Claude Opus 5
parent 42adfda390
commit 06db36e5b5
41 changed files with 4387 additions and 588 deletions
+470 -129
View File
@@ -80,7 +80,22 @@ main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14p
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
padding:10px 12px;margin-bottom:12px}
#lobby{max-width:640px;margin:0 auto;padding:14px}
#lobby{max-width:1040px;margin:0 auto;padding:14px}
/* The create form is two short lists, not one long one: what game this is on the left, what its
rules are on the right. Collapses to one column where there is no room for two. */
.lb-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:22px;align-items:start}
@media(max-width:860px){.lb-two{grid-template-columns:1fr;gap:0}}
.lb-col{min-width:0}
.lb-span{grid-column:1/-1;min-width:0}
/* The rules in as many columns as the width allows — one in the New Game dialog, which is narrow. */
.set-groups{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:0 26px;align-items:start}
.set-group{min-width:0;break-inside:avoid}
.set-group h3:first-child{margin-top:4px}
/* A DISABLED CHOICE HAS TO LOOK DISABLED. Reported by Jesse: Solitaire is not selectable in the
lobby and nothing on it said so — a radio that silently refuses reads as a broken radio. */
.ng-radio.disabled{opacity:.45;cursor:not-allowed}
.ng-radio.disabled:hover{background:none}
.lb-why{color:#e0b060;font-size:11px}
#lobby h2{margin-top:0}
#lobby h3{margin-bottom:2px}
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
@@ -227,6 +242,57 @@ h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
ul.blocked li{padding:2px 0}
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
/* THE RULES BLOCK, shared by the lobby and the New Game dialog (`settings-form.ts`).
`.changed` is the one signal that says "this game is not the type it claims" — amber, the palette's
attention colour, never red: a changed rule is a choice, not an error. */
.set-row{padding:3px 0}
.set-row.changed{border-left:3px solid #e0b060;padding-left:9px;margin-left:-12px;
background:rgba(224,176,96,.08);border-radius:0 4px 4px 0}
.set-hint{display:block;font-size:11px;color:var(--dim);margin:2px 0 0}
/* The line under the type radios when a game is no longer the type it started as. */
.changed-note{color:#e0b060}
.set-row.changed .set-hint{color:#e0b060}
.set-row.unavailable{opacity:.5}
.ng-gate{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:3px 0}
.ng-gate input[type=checkbox]{flex:none}
.gate-num{width:82px}
.ng-gate input:disabled{opacity:.45}
.lb-params{display:flex;gap:14px;flex-wrap:wrap;align-items:end;margin:8px 0}
.lb-params .ng-num{flex:1 1 150px}
/* An error a player must not scroll past. The grey `.dim` line this replaced was routinely missed —
it looked like the note above it. Red is the palette's `.sev-stop`. */
.lb-error{color:#e58080;font-size:14px;font-weight:600;margin:8px 0;padding:8px 11px;
border-left:3px solid #e58080;background:rgba(229,128,128,.12);border-radius:0 5px 5px 0;
empty-cells:hide}
.lb-error:empty{display:none}
.lb-warn{color:#e0b060;font-size:13px;margin:8px 0}
.lb-doors{display:flex;gap:9px;margin:14px 0 12px}
.lb-door{flex:1;background:#222831;color:var(--fg);border:1px solid var(--line);border-radius:7px;
padding:9px 12px;cursor:pointer;font:inherit;font-size:14px}
.lb-door:hover{border-color:#4d6fa8}
.lb-door.active{background:#2f3a4b;border-color:#6f8fc8;color:#cfe0f5;font-weight:600}
.lb-saved{display:flex;align-items:center;gap:10px}
.lb-known-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--line)}
.lb-known-row:last-child{border-bottom:none}
.lb-known-row .code{font-weight:700;letter-spacing:.06em;color:#f2e6cf;flex:1}
.lb-type{font-size:15px;font-weight:600;color:#cfe0f5;margin:2px 0 8px}
.lb-preview-head{background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px;margin:0 0 10px}
/* The read-only rule set — what a joining player reads before sitting down, and what the seating
screen keeps showing afterwards. Generated, never a form: nobody but the host may change these. */
.rules-list{border:1px solid var(--line);border-radius:7px;background:#171c23;padding:4px 12px;margin:0 0 12px}
.rules-list dl{display:grid;grid-template-columns:minmax(140px,auto) 1fr;gap:2px 14px;margin:8px 0}
.rules-list dt{color:var(--dim);font-size:12px}
.rules-list dd{margin:0;font-size:13px}
.rules-list dd.changed{color:#e0b060}
.rules-list h4{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:10px 0 0}
/* THE HANDOFF. Between the host pressing Start and the first Frame arriving there is nothing to
draw — and a blank board is what a broken game looks like too. */
#handoff{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center;
background:rgba(18,21,26,.94);text-align:center;padding:20px}
#handoff.shown{display:flex}
#handoff .hand-inner{max-width:460px}
#handoff h2{font-size:15px;text-transform:none;letter-spacing:.01em;color:#cfe0f5}
#handoff .hand-note{color:var(--dim);font-size:13px;margin-top:8px}
</style>
</head>
<body>
@@ -234,102 +300,251 @@ ul.blocked li{padding:2px 0}
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
`main.ts` only decides whether THIS div or `#gameui` below is the one currently visible.
`#newgamedlg` at the very end of the body is solitaire-only and untouched by any of this. -->
`#newgamedlg` at the very end of the body is solitaire-only, and asks the same questions through
the same shared module (`settings-form.ts`) — the two blocks are generated from one template. -->
<div id="lobby" hidden>
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
<!-- Whatever brought the player here when it was not their own click: a game that was ended under
them, or a lobby that closed. Never a field-level error — those sit with their fields. -->
<p class="lb-error" id="lb-notice" role="alert" hidden></p>
<section id="lb-secret-section">
<h2>Join secret</h2>
<p class="ng-note">Whoever is running this server gave you a secret out of band (a chat message, not a public page). It is kept in this browser only, never shown back, and sent with every lobby request.</p>
<label class="ng-num"><span>Join secret</span><input id="lb-secret" type="password" autocomplete="off"></label>
<!-- Saved: one line, not a password field asking to be filled in again. `lobby.ts` swaps these
two, and re-opens the ask automatically when the server answers 403. -->
<div id="lb-secret-saved" class="lb-saved" hidden>
<span class="dim">Join secret · saved in this browser</span>
<button id="lb-secret-change" class="ghost" type="button">Change</button>
</div>
<div id="lb-secret-ask">
<p class="ng-note">Whoever is running this server gave you a secret out of band (a chat message, not a public page). It is kept in this browser only, never shown back, and sent with every lobby request.</p>
<label class="ng-num"><span>Join secret</span><input id="lb-secret" type="password" autocomplete="off"></label>
</div>
</section>
<!-- EVERY GAME THIS BROWSER IS IN. `localStorage` used to hold exactly ONE multiplayer session, so
joining a second silently overwrote the first and locked that seat out for good. Keyed by game
now, and this is where they are picked between — and forgotten deliberately rather than by
accident. -->
<section id="lb-known" hidden>
<h2>Games you are in</h2>
<p class="ng-note">This browser holds a seat in these. Rejoining needs the token kept here, so
<b>Forget</b> is the one thing that cannot be undone from this screen.</p>
<div id="lb-known-list"></div>
</section>
<section id="lb-choice-section">
<h2>Create or join a game</h2>
<label class="ng-num"><span>Your display name</span><input id="lb-name" type="text" autocomplete="off" maxlength="40"></label>
<h3>Create a new game</h3>
<p class="ng-note">You become the host — you choose the mode and, once everyone's seated, start the game. 2 to 4 players.</p>
<label class="ng-radio"><input type="radio" name="lb-mode" value="competitive" checked>
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-mode" value="coop">
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
<label class="ng-num"><span>Players at the table</span>
<select id="lb-players">
<option value="2">2</option>
<option value="3">3</option>
<option value="4" selected>4</option>
</select></label>
<details id="lb-settings">
<summary>Game settings</summary>
<p class="ng-note">The same dials the solitaire New Game dialog has. Picking a mode or a table
size sets them to that game's defaults; every one stays yours to change. They are fixed when
the game is created and cannot be changed once it starts.</p>
<!-- TWO DOORS, one panel at a time. Joining used to be a heading at the BOTTOM of the create
form: someone sent a code had to scroll past fifteen fields they had no use for. -->
<div class="lb-doors">
<button id="lb-door-join" class="lb-door active" type="button">Join a game</button>
<button id="lb-door-create" class="lb-door" type="button">Create a game</button>
</div>
<h3>Seed</h3>
<section id="lb-join-panel">
<h2>Join a game</h2>
<p class="ng-note">Ask whoever created the game for its code. You will see the whole rule set
before you take a seat.</p>
<label class="ng-num"><span>Game code</span><input id="lb-code" type="text" autocomplete="off" placeholder="RAIL-1234"></label>
<button id="lb-look" type="button">Look up game</button>
<p class="lb-error" id="lb-join-err" role="alert"></p>
<!-- Filled by `/api/lobby/preview` — the config, who is seated, and nothing else. The SEED is
never in that response: it decides every shuffle in the game. -->
<div id="lb-preview" hidden>
<h3>Before you sit down</h3>
<div class="lb-preview-head">
<div class="lb-invite-code" id="lb-preview-code"></div>
<div id="lb-preview-type" class="lb-type"></div>
<div id="lb-preview-who" class="dim"></div>
</div>
<div id="lb-preview-rules" class="rules-list"></div>
<button id="lb-join" type="button">Join this game</button>
</div>
</section>
<section id="lb-create-panel" hidden>
<h2>Create a new game</h2>
<p class="ng-note">You become the host — you choose the game type and, once everyone is seated, start the game. 2 to 4 players.</p>
<!-- TWO COLUMNS WHERE THERE IS ROOM. One 640px-wide column made this form a very long scroll
for what is really two short lists: what game this is, and what its rules are. -->
<div class="lb-two">
<div class="lb-col">
<!-- THE PARAMETERS, above the type. Changing one of these does NOT make the game Custom: the
types are formulas in the table size and the length, so the Revenue floor re-derives and
"Co-op, 3 players, 8 days" is still Co-op. -->
<div class="lb-params">
<label class="ng-num"><span>Seed</span>
<input id="lb-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
<label class="ng-num"><span>Players at the table</span>
<select id="lb-players">
<option value="2">2</option>
<option value="3">3</option>
<option value="4" selected>4</option>
</select></label>
<label class="ng-num"><span>Days</span>
<input id="lb-days" type="number" min="1" max="20" step="1" value="5"></label>
</div>
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
can be shared, compared or replayed. Leave it blank for a random one.</p>
<input id="lb-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
<!-- WITH THE TABLE SIZE IT IS ABOUT, not below the rules block — reported by Jesse, who found
it separated from the control it explains by fifteen settings. -->
<p class="ng-note">Every chair has to be taken before the game can start — by a person or by a
bot. Pick the size of the table now; it cannot change once the game is created.</p>
</div>
<div class="lb-col">
<h3>Game type</h3>
<div class="set-row" id="lb-type-row">
<label class="ng-radio"><input type="radio" name="lb-type" value="solitaire">
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-type" value="coop" checked>
<span><b>Co-op</b><br><span class="dim">Everyone&#8217;s Revenue is one table score. You win together or lose together.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-type" value="competitive">
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-type" value="cutthroat">
<span><b>Cutthroat</b><br><span class="dim">Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-type" value="custom">
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
</div>
<p class="ng-note" id="lb-type-note"></p>
</div>
<!-- FULL WIDTH WHEN IT OPENS. Reported by Jesse: opened inside the right-hand column it made a
very long scroll with the left column standing empty beside it. It is a grid child of its
own now, spanning both, and its five groups flow into as many columns as fit. -->
<details id="lb-settings" class="lb-span">
<summary>Game settings</summary>
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
started from; clicking a type again resets all of them back to it. They are fixed when the
game is created and cannot be changed once it starts.</p>
<div class="set-groups">
<div class="set-group">
<h3>Starting hand</h3>
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
either way — deal six and the first turn is spent choosing which of them to keep.</p>
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeRandom" checked>
<span>Three random cards</span></label>
<label class="ng-radio"><input type="radio" name="lb-hand" value="sixRandom">
<span>Six random cards</span></label>
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeTrackThreeOther">
<span>Three track and three other</span></label>
<div class="set-row" id="lb-hand-row">
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeRandom">
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-hand" value="sixRandom" checked>
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeTrackThreeOther">
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
<span class="set-hint" id="lb-hand-hint"></span>
</div>
</div>
<div class="set-group">
<h3>Where an Extra may start</h3>
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes,
and the place decides which way it runs — a Division Point sends it away from itself; in the
middle of the railroad the player picks east or west. The Division Points and the Interchange
belong to nobody and are always available. Starting one inside a district is the part that
favours a seat, so it is set here. An Office must be a Control Point whatever this says: a
Whistle Post never qualifies.</p>
<div class="set-row" id="lb-extra-row">
<label class="ng-radio"><input type="radio" name="lb-extra" value="divisionPointsOnly">
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading. Every Extra begins on shared ground.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-extra" value="ownOffice">
<span><b>Also the playing player&#8217;s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else&#8217;s district.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-extra" value="anyOffice">
<span><b>Also any player&#8217;s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player&#8217;s district.</span></span></label>
<span class="set-hint" id="lb-extra-hint"></span>
</div>
</div>
<div class="set-group">
<h3>Revenue</h3>
<p class="ng-note">What each piece of work pays, 0 to 5. Zero switches an economy off so the
others can be read.</p>
<label class="ng-num"><span>Passenger revenue per coach</span>
<input id="lb-passenger" type="number" min="0" max="5" step="1" value="1"></label>
<label class="ng-num"><span>Freight revenue per load</span>
<input id="lb-freight" type="number" min="0" max="5" step="1" value="1"></label>
<label class="ng-num"><span>Train revenue per transit</span>
<input id="lb-transit" type="number" min="0" max="5" step="1" value="0"></label>
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and
again when it is detrained; a load pays when it is made up and again when it is broken. Zero
switches an economy off so the others can be read.</p>
<div class="set-row" id="lb-passenger-row">
<label class="ng-num"><span>Passenger revenue per coach</span>
<input id="lb-passenger" type="number" min="0" max="5" step="1" value="1"></label>
<span class="set-hint" id="lb-passenger-hint"></span>
</div>
<div class="set-row" id="lb-freight-row">
<label class="ng-num"><span>Freight revenue per load</span>
<input id="lb-freight" type="number" min="0" max="5" step="1" value="1"></label>
<span class="set-hint" id="lb-freight-hint"></span>
</div>
<div class="set-row" id="lb-transit-row">
<label class="ng-num"><span>Train revenue per transit</span>
<input id="lb-transit" type="number" min="0" max="5" step="1" value="0"></label>
<span class="set-hint" id="lb-transit-hint"></span>
</div>
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the
Division — the one thing nobody has to work for.</p>
</div>
<div class="set-group">
<h3>Victory conditions</h3>
<p class="ng-note">How long the game runs, and the ways it can end. 0 turns any of these off.
The combined-Revenue default is sized for the table you chose above.</p>
<label class="ng-num"><span>Days</span>
<input id="lb-days" type="number" min="1" max="20" step="1" value="5"></label>
<label class="ng-num"><span>Minimum combined Revenue to avoid a loss</span>
<input id="lb-minrev" type="number" min="0" step="1" value="60"></label>
<label class="ng-num"><span>Collisions in one Day that end the game</span>
<input id="lb-colday" type="number" min="0" step="1" value="3"></label>
<label class="ng-num"><span>Collisions across the whole game that end it</span>
<input id="lb-coltotal" type="number" min="0" step="1" value="5"></label>
<label class="ng-num"><span>Allow the opponent-directed cards</span>
<input id="lb-pvp" type="checkbox"></label>
<p class="ng-note" id="lb-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect
either way until then.</p>
<p class="ng-note">The ways this game can end badly. Each one is switched on or off in its own
right; how long the game runs is set above, with the table size.</p>
<div class="set-row" id="lb-minrev-row">
<label class="ng-gate"><input type="checkbox" id="lb-minrev-on" checked>
<span>Everyone loses if combined Revenue at the end is under</span>
<input id="lb-minrev" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="lb-minrev-hint"></span>
</div>
<div class="set-row" id="lb-colday-row">
<label class="ng-gate"><input type="checkbox" id="lb-colday-on" checked>
<span>The game ends and everyone loses if collisions in one Day reach</span>
<input id="lb-colday" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="lb-colday-hint"></span>
</div>
<div class="set-row" id="lb-coltotal-row">
<label class="ng-gate"><input type="checkbox" id="lb-coltotal-on" checked>
<span>The game ends and everyone loses after this many collisions in the whole game</span>
<input id="lb-coltotal" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="lb-coltotal-hint"></span>
</div>
<p class="ng-note">The opponent-directed cards — Derail, Watertower, Hobo Jungle and the
nineteen others, along with the seven that answer them — are not implemented yet, so no game
type deals them whatever else is set here.</p>
</div>
<div class="set-group">
<h3>Optional rules</h3>
<p class="ng-note">Appendix B. Off by default; each one changes how the game plays.</p>
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
night Stages (1&ndash;3 and 11&ndash;12)</span>
<input id="lb-visibility" type="checkbox"></label>
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one chair
left and takes over the next station up the line. Your Revenue and the Fedora go with you;
the district stays where it is</span>
<input id="lb-rotation" type="checkbox"></label>
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand of
four; play or discard down to three on the first turn</span>
<input id="lb-toolbox" type="checkbox"></label>
</details>
<p class="ng-note">Every chair has to be taken before the game can start — by a person or by a
bot. Pick the size of the table now; it cannot change once the game is created.</p>
<button id="lb-create">Create game</button>
<p class="dim" id="lb-create-err" role="alert"></p>
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
<div class="set-row" id="lb-visibility-row">
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
night Stages (1&ndash;3 and 11&ndash;12)</span>
<input id="lb-visibility" type="checkbox"></label>
<span class="set-hint" id="lb-visibility-hint"></span>
</div>
<div class="set-row" id="lb-rotation-row">
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one
chair left and takes over the next station up the line. Your Revenue and the Fedora go with
you; the district stays where it is</span>
<input id="lb-rotation" type="checkbox"></label>
<span class="set-hint" id="lb-rotation-hint"></span>
</div>
<div class="set-row" id="lb-toolbox-row">
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand
of four; play or discard down to three on the first turn</span>
<input id="lb-toolbox" type="checkbox"></label>
<span class="set-hint" id="lb-toolbox-hint"></span>
</div>
</div>
<h3>Join a game</h3>
<p class="ng-note">Ask whoever created the game for its code.</p>
<label class="ng-num"><span>Game code</span><input id="lb-code" type="text" autocomplete="off" placeholder="RAIL-1234"></label>
<button id="lb-join">Join game</button>
<p class="dim" id="lb-join-err" role="alert"></p>
</div>
</details>
<div class="lb-span">
<button id="lb-create" type="button">Create game</button>
<p class="lb-error" id="lb-create-err" role="alert"></p>
</div>
</div>
</section>
</section>
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
@@ -337,20 +552,28 @@ ul.blocked li{padding:2px 0}
<h2>Seating</h2>
<div class="lb-invite">
<div>
<div class="lb-invite-label">Send this code to your players</div>
<div class="lb-invite-label">Send this to your players</div>
<div class="lb-invite-code" id="lb-gamecode"></div>
</div>
<button id="lb-copy" class="ghost">Copy</button>
<button id="lb-copy" class="ghost" type="button">Copy code</button>
<button id="lb-copylink" class="ghost" type="button">Copy invite link</button>
<span class="ng-note" id="lb-copied"></span>
</div>
<p class="ng-note">They enter it under <b>Join a game</b>, along with the same join secret you used.</p>
<p class="ng-note">The host may fill an empty seat with a bot, and starts the game once every
seat is either a player or a bot. <b>These chairs are not the running order</b> — who sits
where along the Division is decided by a D12 roll when the game starts (§4.4), and the map
shows the result.</p>
<p class="ng-note">They enter the code under <b>Join a game</b>, along with the same join secret
you used — the link carries the code, never the secret.</p>
<p class="ng-note">The host may fill an empty seat with a bot or clear an occupied one, and
starts the game once every seat is either a player or a bot. <b>These chairs are not the
running order</b> — who sits where along the Division is decided by a D12 roll when the game
starts, and the map shows the result.</p>
<div id="lb-seats"></div>
<p class="lb-warn" id="lb-stream-note" role="status" hidden></p>
<button id="lb-start" disabled>Start game</button>
<p class="dim" id="lb-start-note"></p>
<button id="lb-leave" class="ghost" type="button">Leave</button>
<p class="lb-error" id="lb-start-note" role="alert"></p>
<h3>The game you are in</h3>
<div id="lb-seating-type" class="lb-type"></div>
<div id="lb-seating-rules" class="rules-list"></div>
</section>
</div>
@@ -363,6 +586,14 @@ ul.blocked li{padding:2px 0}
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
<span id="objective" class="pace">—</span>
<span class="dim">seed <span id="seed">—</span></span>
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
into `LobbyReady` — so a seated player could not say which game they were in, could not match
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
Empty (and collapsed) in solitaire, where there is no code. -->
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
<!-- Co-op, Competitive, Cutthroat or Custom, derived from the config the Frame carries
(`presets.ts`). A Cutthroat game used to look exactly like a Co-op one from the board. -->
<span class="dim" id="gametype" title=""></span>
<!-- WHICH RULES THIS GAME IS BEING PLAYED UNDER. The settings are chosen when the game is dealt
and then never mentioned again, which makes a playtest note ("scored 4") unreadable a week
later: at 0 revenue per transit that is a different game from the same seed at 5. Short enough
@@ -379,6 +610,12 @@ ul.blocked li{padding:2px 0}
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
<!-- LEAVING A RUNNING GAME. Reported by Jesse 2026-08-23: "if I'm a player in the middle of the
game and I need to leave, how do I leave the game, clear the token from my browser so I can
play a different game later?" There was no way at all — the page rejoined the same game on
every load and nothing let go of it. This keeps your seat — the table waits for you — and
your token, and puts you back at the lobby, which lists every game this browser is in. -->
<button id="leavegame" hidden title="Go back to the lobby. Your seat is kept and the game waits for you — the lobby lists it under Games you are in, so you can come back or hand the browser to a different game.">Leave game</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
@@ -471,60 +708,152 @@ ul.blocked li{padding:2px 0}
<form method="dialog" id="newgameform">
<h2 class="big" id="ng-title">New game</h2>
<!-- THE SAME BLOCK THE LOBBY USES, same shared module, same order — the two screens are one
design. Only Solitaire can be dealt here; the multiplayer types are shown disabled rather
than hidden, so what this screen offers and what the lobby offers read as one list. -->
<div class="lb-params">
<label class="ng-num"><span>Seed</span>
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
<label class="ng-num"><span>Days</span>
<input id="ng-days" type="number" min="1" max="20" step="1" value="5"></label>
</div>
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
can be shared, compared or replayed. Leave it blank for a random one.</p>
<h3>Game type</h3>
<p class="ng-note">Picking a type just sets the fields below to that type's defaults — every number stays yours to change afterward. Solitaire is the only type that can be dealt today; Competitive and Co-op need a server (coming soon).</p>
<label class="ng-radio"><input type="radio" name="ng-mode" value="solitaire" checked>
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. Everything below is real today.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-mode" value="competitive">
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-mode" value="coop">
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
<div class="set-row" id="ng-type-row">
<label class="ng-radio"><input type="radio" name="ng-type" value="solitaire">
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-type" value="coop" checked>
<span><b>Co-op</b><br><span class="dim">Everyone&#8217;s Revenue is one table score. You win together or lose together.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-type" value="competitive">
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-type" value="cutthroat">
<span><b>Cutthroat</b><br><span class="dim">Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-type" value="custom">
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
</div>
<h3>Seed</h3>
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game can be shared, compared or replayed. Leave it blank for a random one.</p>
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
<p class="ng-note" id="ng-type-note"></p>
<h3>Starting hand</h3>
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three either way — deal six and the first turn is spent choosing which of them to keep.</p>
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeRandom" checked>
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-hand" value="sixRandom">
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeTrackThreeOther">
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
<details id="ng-settings" open>
<summary>Game settings</summary>
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
Changing any of them selects <b>Custom</b>; clicking a type again resets all of them back
to it.</p>
<div class="set-groups">
<h3>Where an Extra may start</h3>
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes, and the place decides which way it runs — a Division Point sends it away from itself; in the middle of the railroad the player picks east or west. The Division Points and the Interchange belong to nobody and are always available. Starting one inside a district is the part that favours a seat, so it is set here. An Office must be a Control Point whatever this says: a Whistle Post never qualifies.</p>
<label class="ng-radio"><input type="radio" name="ng-extra" value="divisionPointsOnly">
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading of §7. Every Extra begins on shared ground.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-extra" value="ownOffice">
<span><b>Also the playing player’s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else’s district.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-extra" value="anyOffice" checked>
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive, and what the game did before this setting existed.</span></span></label>
<div class="set-group">
<h3>Starting hand</h3>
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
either way — deal six and the first turn is spent choosing which of them to keep.</p>
<div class="set-row" id="ng-hand-row">
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeRandom">
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-hand" value="sixRandom" checked>
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeTrackThreeOther">
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
<span class="set-hint" id="ng-hand-hint"></span>
</div>
</div>
<h3>Revenue</h3>
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and again when it is detrained; a load pays when it is made up and again when it is broken. Zero switches an economy off so the others can be read.</p>
<label class="ng-num"><span>Passenger revenue per coach</span>
<input id="ng-passenger" type="number" min="0" max="5" step="1" value="1"></label>
<label class="ng-num"><span>Freight revenue per load</span>
<input id="ng-freight" type="number" min="0" max="5" step="1" value="1"></label>
<label class="ng-num"><span>Train revenue per transit</span>
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the Division — the one thing nobody has to work for. It defaults to 0 for that reason.</p>
<div class="set-group">
<h3>Where an Extra may start</h3>
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes,
and the place decides which way it runs — a Division Point sends it away from itself; in the
middle of the railroad the player picks east or west. The Division Points and the Interchange
belong to nobody and are always available. Starting one inside a district is the part that
favours a seat, so it is set here. An Office must be a Control Point whatever this says: a
Whistle Post never qualifies.</p>
<div class="set-row" id="ng-extra-row">
<label class="ng-radio"><input type="radio" name="ng-extra" value="divisionPointsOnly">
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading. Every Extra begins on shared ground.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-extra" value="ownOffice">
<span><b>Also the playing player&#8217;s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else&#8217;s district.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-extra" value="anyOffice">
<span><b>Also any player&#8217;s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player&#8217;s district.</span></span></label>
<span class="set-hint" id="ng-extra-hint"></span>
</div>
</div>
<h3>Victory conditions</h3>
<p class="ng-note">How long the game runs, and the ways it can end. 0 turns any of these off. The combined-Revenue suggestion updates for Competitive/Co-op — it assumes a 4-player table until there's a lobby to ask who's actually seated.</p>
<label class="ng-num"><span>Days</span>
<input id="ng-days" type="number" min="1" max="20" step="1" value="5"></label>
<label class="ng-num"><span>Minimum combined Revenue to avoid a loss</span>
<input id="ng-minrev" type="number" min="0" step="1" value="15"></label>
<label class="ng-num"><span>Collisions in one Day that end the game</span>
<input id="ng-colday" type="number" min="0" step="1" value="3"></label>
<label class="ng-num"><span>Collisions across the whole game that end it</span>
<input id="ng-coltotal" type="number" min="0" step="1" value="5"></label>
<label class="ng-num"><span>Allow the opponent-directed cards</span>
<input id="ng-pvp" type="checkbox"></label>
<p class="ng-note" id="ng-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect either way until then.</p>
<div class="set-group">
<h3>Revenue</h3>
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and
again when it is detrained; a load pays when it is made up and again when it is broken. Zero
switches an economy off so the others can be read.</p>
<div class="set-row" id="ng-passenger-row">
<label class="ng-num"><span>Passenger revenue per coach</span>
<input id="ng-passenger" type="number" min="0" max="5" step="1" value="1"></label>
<span class="set-hint" id="ng-passenger-hint"></span>
</div>
<div class="set-row" id="ng-freight-row">
<label class="ng-num"><span>Freight revenue per load</span>
<input id="ng-freight" type="number" min="0" max="5" step="1" value="1"></label>
<span class="set-hint" id="ng-freight-hint"></span>
</div>
<div class="set-row" id="ng-transit-row">
<label class="ng-num"><span>Train revenue per transit</span>
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
<span class="set-hint" id="ng-transit-hint"></span>
</div>
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the
Division — the one thing nobody has to work for.</p>
</div>
<div class="set-group">
<h3>Victory conditions</h3>
<p class="ng-note">The ways this game can end badly. Each one is switched on or off in its own
right; how long the game runs is set above, with the table size.</p>
<div class="set-row" id="ng-minrev-row">
<label class="ng-gate"><input type="checkbox" id="ng-minrev-on" checked>
<span>Everyone loses if combined Revenue at the end is under</span>
<input id="ng-minrev" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="ng-minrev-hint"></span>
</div>
<div class="set-row" id="ng-colday-row">
<label class="ng-gate"><input type="checkbox" id="ng-colday-on" checked>
<span>The game ends and everyone loses if collisions in one Day reach</span>
<input id="ng-colday" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="ng-colday-hint"></span>
</div>
<div class="set-row" id="ng-coltotal-row">
<label class="ng-gate"><input type="checkbox" id="ng-coltotal-on" checked>
<span>The game ends and everyone loses after this many collisions in the whole game</span>
<input id="ng-coltotal" type="number" min="0" step="1" class="gate-num"></label>
<span class="set-hint" id="ng-coltotal-hint"></span>
</div>
<p class="ng-note">The opponent-directed cards — Derail, Watertower, Hobo Jungle and the
nineteen others, along with the seven that answer them — are not implemented yet, so no game
type deals them whatever else is set here.</p>
</div>
<div class="set-group">
<h3>Optional rules</h3>
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
<div class="set-row" id="ng-visibility-row">
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
night Stages (1&ndash;3 and 11&ndash;12)</span>
<input id="ng-visibility" type="checkbox"></label>
<span class="set-hint" id="ng-visibility-hint"></span>
</div>
<div class="set-row" id="ng-rotation-row">
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one
chair left and takes over the next station up the line. Your Revenue and the Fedora go with
you; the district stays where it is</span>
<input id="ng-rotation" type="checkbox"></label>
<span class="set-hint" id="ng-rotation-hint"></span>
</div>
<div class="set-row" id="ng-toolbox-row">
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand
of four; play or discard down to three on the first turn</span>
<input id="ng-toolbox" type="checkbox"></label>
<span class="set-hint" id="ng-toolbox-hint"></span>
</div>
</div>
</div>
</details>
<menu class="ng-buttons">
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Use the <b>Multiplayer</b> button instead — it creates or joins a game on this server.</span>
@@ -534,6 +863,18 @@ ul.blocked li{padding:2px 0}
</form>
</dialog>
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
`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
had nothing to say when the first push never came. This is its own state: it opens on the way
into a game, holds a deliberate beat so the game visibly begins, and closes on the first Frame. -->
<div id="handoff">
<div class="hand-inner">
<h2 id="handoff-title">Dealing the railroad…</h2>
<div id="handoff-note" class="hand-note"></div>
</div>
</div>
<script type="module" src="./web/main.js"></script>
</body>
</html>