asks what game you want Three queued items. The last matters most. A RELEASE NO LONGER DESTROYS EVERY GAME IN PROGRESS. Four consecutive releases killed every game on the box, one of them a release that changed only how the board is drawn. The reasoning behind the refusal was always right — a move legal under old rules may not be legal under new ones, and half-replaying a save is worse than refusing it. The TEST was wrong: it compared engineVersion for exact equality, and that stamp is the package version, which moves for a CSS fix. Whether a save still replays has an exact answer, so it is now asked directly. loadGame reads the file and judges nothing; tryResumeSession replays the intents and reports the first one the engine refuses. A save stamped with a version this server has never run resumes fine provided its moves replay — verified against a file hand-stamped 0.4.9-ancient. One that genuinely does not replay is still refused, but the log names the move rather than two version strings: "move 3 of 8 (localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN". fromMultiplayerSave had to stop lying first. It has always stopped at the first unacceptable intent and done so in silence, which was survivable only because the version gate meant a doomed replay was never attempted. Now that the replay IS the check, it returns where it stopped and why. Deliberately not done: resuming a partly-replayable game at its last good move. That silently rewinds a game to a position nobody played to while every browser holding a later Frame carries on unaware. Refusing leaves the file intact, so putting the previous version back still recovers it. EMPLOYEE ROTATION IS IMPLEMENTED, SISTER TRAINS IS DELETED. Two of the four optional-rule flags were read by nothing at all. Employee Rotation is four lines in advance.ts, because the seat/player split (D9) exists for precisely this rule: seating is the only thing that moves, so Revenue, hands, the Superintendent and whose turn it is travel with the player, and the Office, district, grid and any trains standing in it stay with the chair. Inheriting the district you move into is the point of the rule, not a side effect. "Left" is seat + 1, matching playerLeftOf. Sister Trains is deleted rather than built: Q9 records that the Second Section card supersedes it, and that card exists, so the flag was a toggle for a rule the game no longer has. THE LOBBY ASKS WHAT GAME YOU WANT TO PLAY. Creating a game asked for a name, a mode and a table size; every other dial was hardcoded. A Game settings block now carries the same set the solitaire dialog does — seed, starting hand, the three revenue rates, Days, the combined-Revenue floor, both collision caps, the opponent-card toggle — plus the three surviving optional rules. Mode and table size set the defaults and everything stays editable. The seed is honoured, so a game can be reproduced or compared. Verified: 682 tests pass (679 + 3). The rotation tests were mutation-checked both ways — disabling the rotation and turning the table the wrong way each fail the suite. Live: a save stamped 0.4.9-ancient resumed, an injected illegal move was refused by name, and a create with every dial set to a non-default value came back out of game.json with all of them intact, including seed 777. Two of my own assertions were wrong on the way and the tests caught them: the Fedora legitimately passes at Stage 12 (§5) so it cannot be compared against its own earlier value, and dispatchUsedToday is cleared at every Day boundary so it cannot mark a district.
531 lines
35 KiB
HTML
531 lines
35 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Station Master — play</title>
|
||
<style>
|
||
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250;--now:#b98cf0}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--bg);color:var(--fg);
|
||
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||
h1{font-size:17px;margin:0}
|
||
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);
|
||
margin:0 0 6px;font-weight:600}
|
||
h2.big{font-size:15px;text-transform:none;letter-spacing:.01em;color:#cfe0f5;
|
||
margin:0 0 9px;padding-bottom:5px;border-bottom:1px solid #2c333d}
|
||
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
|
||
.dim{color:var(--dim)}
|
||
/* THE TOP TWO ROWS TRAVEL TOGETHER.
|
||
The title bar was sticky and the turn chart under it was not, so scrolling down took away the one
|
||
thing you most need while reading the board — which Day and Stage it is, which phase is running,
|
||
and who it is waiting on. They are one block now, and it sticks as one. */
|
||
.topbar{position:sticky;top:0;z-index:5}
|
||
/* ONE LINE, AND IT HAS TO STAY ONE LINE. The build stamp was pushed to the far right by
|
||
`margin-left:auto` and wrapped the whole row onto a second line the moment the content grew.
|
||
Tighter gaps, a smaller stamp, and nothing on here that is not worth the width. */
|
||
header{background:var(--panel);border-bottom:1px solid var(--line);
|
||
padding:8px 12px;display:flex;gap:13px;align-items:baseline;flex-wrap:wrap;font-size:13px}
|
||
header b{font-size:16px}
|
||
header button{background:#2a3038;color:inherit;border:1px solid #2c333d;border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px}
|
||
header button:hover{border-color:#4d6fa8}
|
||
/* Nothing to take back yet. It keeps its place — a button that disappears reads as a bug — but it
|
||
stops inviting the press. */
|
||
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
|
||
header button:disabled:hover{border-color:#2c333d}
|
||
.zoom{display:inline-flex;align-items:center;gap:4px}
|
||
.zoom button{padding:3px 9px;line-height:1}
|
||
.lb-invite{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 10px;
|
||
background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px}
|
||
.lb-invite-label{font-size:11px;color:var(--dim)}
|
||
.lb-invite-code{font-size:22px;font-weight:700;letter-spacing:.08em;color:#f2e6cf}
|
||
#lb-settings{margin:10px 0;border:1px solid var(--line);border-radius:7px;padding:8px 12px;background:#171c23}
|
||
#lb-settings summary{cursor:pointer;font-size:13px;color:#9fb6d8}
|
||
#lb-settings h3{font-size:12px;margin:12px 0 4px;color:#9fb6d8}
|
||
.zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block}
|
||
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
|
||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||
.home:hover{color:#5aa9e6}
|
||
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
|
||
.pace.good{background:rgba(40,140,60,.32)}
|
||
.pace.behind{background:rgba(190,120,40,.28)}
|
||
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
|
||
/* NEW GAME DIALOG. Native <dialog>, so Esc closes it and focus is trapped without any of that
|
||
being written here. Everything below is only colour and spacing: the browser's own white-on-white
|
||
default is unreadable against this page. */
|
||
dialog{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:9px;
|
||
padding:16px 18px;max-width:520px;width:calc(100% - 32px);font:inherit;max-height:86vh;overflow:auto}
|
||
dialog::backdrop{background:rgba(0,0,0,.62)}
|
||
dialog h3{margin-top:15px}
|
||
/* The rationale under each heading, not beside each control — the reasons are sentences, and a
|
||
sentence squeezed into a label column wraps into noise. */
|
||
.ng-note{color:var(--dim);font-size:11.5px;margin:0 0 8px;line-height:1.45}
|
||
dialog input[type=text],dialog input[type=number]{background:#0f1318;color:var(--fg);
|
||
border:1px solid var(--line);border-radius:5px;padding:4px 7px;font:inherit;font-size:13px}
|
||
dialog input[type=text]{width:100%}
|
||
dialog input[type=number]{width:64px;text-align:right}
|
||
dialog input:focus{outline:none;border-color:#4d6fa8}
|
||
/* Whole rows, so the click target is the sentence and not the 13px circle beside it. */
|
||
.ng-radio{display:flex;gap:9px;align-items:flex-start;padding:6px 7px;border-radius:5px;cursor:pointer}
|
||
.ng-radio:hover{background:#20262e}
|
||
.ng-radio input{margin-top:3px;flex:0 0 auto}
|
||
.ng-radio .dim{font-size:11.5px}
|
||
.ng-num{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:4px 7px}
|
||
.ng-buttons{display:flex;justify-content:flex-end;gap:8px;margin:16px 0 0;padding:0}
|
||
.ng-buttons button{background:#2a3038;color:inherit;border:1px solid var(--line);border-radius:5px;
|
||
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
|
||
.ng-buttons button:hover{border-color:#4d6fa8}
|
||
#ng-deal{background:#31527f;border-color:#4d6fa8}
|
||
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;
|
||
padding:10px 12px;margin-bottom:12px}
|
||
#lobby{max-width:640px;margin:0 auto;padding:14px}
|
||
#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)}
|
||
.lb-seat:last-child{border-bottom:none}
|
||
.lb-seat .who{flex:1}
|
||
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
|
||
#presence:empty{display:none}
|
||
/* division strip */
|
||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
||
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
|
||
.node.office{border-color:#4d6fa8;background:rgba(60,100,170,.13)}
|
||
.node .nm{font-weight:600;font-size:12px}
|
||
.chip{display:inline-block;background:#2f6b3d;border-radius:4px;padding:1px 6px;margin:3px 3px 0 0;font-size:11px}
|
||
.consist{font-size:10px;color:var(--dim)}
|
||
.grade{font-size:10px;background:rgba(200,90,60,.35);border-radius:3px;padding:1px 4px;font-weight:400}
|
||
.mlmods{font-size:10px;background:rgba(200,150,60,.28);border-radius:3px;padding:1px 4px;margin-top:2px}
|
||
/* board */
|
||
#grid{display:grid;gap:5px;overflow-x:auto}
|
||
.cell{border:1px solid var(--line);border-radius:5px;padding:5px 6px;min-height:64px;background:#161b21}
|
||
.cell.run{background:var(--run)}
|
||
.cell.office{border-color:#4d6fa8}
|
||
.cell.modifier{background:#2b2333;border-style:dashed}
|
||
.cell.legal{outline:2px solid #5aa9e6;outline-offset:-2px;cursor:pointer;
|
||
box-shadow:0 0 0 3px rgba(90,169,230,.18) inset}
|
||
.cell.legal:hover{background:#233246}
|
||
.cell.ghost{border-style:dashed;background:rgba(90,169,230,.07);
|
||
display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
|
||
.cell.ghost .nm{font-weight:400;color:#5aa9e6;font-size:11px}
|
||
.cell .nm{font-size:12px;font-weight:600}
|
||
.cell .what{font-size:10px;line-height:1.25;margin-top:2px}
|
||
.cell{cursor:help}
|
||
.coord{font-size:10px}
|
||
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
|
||
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
|
||
.cars{font-size:10px;color:var(--dim)}
|
||
/* THE HAND IS THE ACTION SURFACE. Each card carries its own verbs, so the twelve-button discard
|
||
block and the separate "play a card" subject list are both gone. The buttons are small and quiet
|
||
until the card is picked — the card is the thing being read, they are what you do with it. */
|
||
.handcard .cardacts{display:flex;gap:4px;margin-top:5px}
|
||
button.cardact{margin:0;padding:1px 7px;font-size:10px;background:#2f3a48;border:1px solid #46536440;
|
||
border-radius:9px;color:#cfd6e0;text-align:center}
|
||
button.cardact:hover{background:#3d4c60;border-color:#5aa9e6}
|
||
button.cardact.on{background:#2b3444;border-color:#c8912f;color:#f2e6cf;font-weight:700}
|
||
button.cardact.discard{color:#d6b48a}
|
||
.handcard.picked{border-color:#c8912f;box-shadow:0 0 0 2px rgba(200,145,47,.18)}
|
||
#phasenote{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:40;
|
||
background:#241d33;border:1px solid var(--now);color:#e7dcfb;border-radius:14px;padding:5px 16px;
|
||
font-size:13px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
|
||
box-shadow:0 6px 22px rgba(0,0,0,.5)}
|
||
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
|
||
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
|
||
/* A train completing its run pays EVERY player, so it is announced rather than left to the log —
|
||
green like a revenue gain, and its own element so it cannot fight the phase note for the slot. */
|
||
#announce{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:41;
|
||
background:#16301f;border:1px solid #4c9a6a;color:#c8f0d8;border-radius:14px;padding:7px 18px;
|
||
font-size:14px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
|
||
box-shadow:0 6px 22px rgba(0,0,0,.5);max-width:min(680px,90vw);text-align:center}
|
||
#announce.shown{opacity:1;transform:translateX(-50%) translateY(0)}
|
||
@media(prefers-reduced-motion:reduce){#announce{transition:none}}
|
||
/* The die's answer, said where the player is looking. Green like a revenue gain — it is a good
|
||
thing that just happened — and it disappears with the next render, as a moment should. */
|
||
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
|
||
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
|
||
.makeup-note{font-size:11px;margin:0 0 5px}
|
||
/* WHICH TRAIN AM I SWITCHING. A row of crews rather than a stacked list — they are alternatives,
|
||
and the chosen one is the crew whose squares the board is drawing, so it wears the same violet
|
||
"you are here" the rest of the page uses. */
|
||
.crewpick{display:flex;flex-wrap:wrap;gap:6px;align-items:baseline}
|
||
.crewpick h3{flex:0 0 100%;margin:0 0 2px}
|
||
button.act.crew{width:auto;display:inline-block;margin:0}
|
||
button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);color:#e9dcff}
|
||
/* THE ORDER TO ADD THE CARS IN. Only ever drawn for a train whose make-up order can lock it (7/8
|
||
Local), so it is worth a coloured rule down the side: a player who sees this once needs to read
|
||
it, and will not see it again on the next train. `warn` is the train already made up badly. */
|
||
.makeup-advice{font-size:11px;line-height:1.45;margin:0 0 6px;padding:5px 8px;border-radius:4px;
|
||
border-left:3px solid #4d6fa8;background:rgba(60,100,170,.13)}
|
||
.makeup-advice.warn{border-left-color:#e0a060;background:rgba(200,150,60,.15)}
|
||
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
|
||
buttons underneath it; struck through in red once it is gone. */
|
||
.moves{display:inline-block;font-size:11px;margin:0 0 7px;padding:1px 8px;border-radius:10px;
|
||
background:rgba(200,145,47,.16);border:1px solid #8a6d1f;color:#f2e6cf}
|
||
.moves.spent{background:rgba(190,50,50,.20);border-color:#8a4040;color:#e58080}
|
||
/* actions */
|
||
#actions{max-height:none}
|
||
.grp{margin-bottom:6px}
|
||
/* THESE ARE THE THINGS YOU CAN DO. An action carried the same grey border as every other panel
|
||
on the page, so the one region that is clickable did not look it. Amber border and a lit face,
|
||
used nowhere else, so "this is a move" is answered before the label is read. */
|
||
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
|
||
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
|
||
button:hover{background:#39424e;border-color:#4d6fa8}
|
||
/* GENERIC, and it was not. `header button:disabled` and `#actions button:disabled` were the only
|
||
disabled styles on the page, so a disabled button anywhere else — #lb-start being the one that
|
||
mattered — kept its normal face AND still lit up under the cursor from the rule above. It was
|
||
advertising a click it would refuse. */
|
||
button:disabled{opacity:.45;cursor:not-allowed}
|
||
button:disabled:hover{background:#2a3038;border-color:var(--line)}
|
||
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
|
||
color:#f2e6cf;font-weight:600}
|
||
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
||
#actions button:focus-visible{outline:2px solid #f0b64a;outline-offset:2px}
|
||
button.act{display:inline-block}
|
||
/* The district collapses when nothing in it is changing. A summary line stays, because a panel
|
||
that vanishes entirely reads as broken rather than as folded away. */
|
||
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||
#district.folded #grid{display:none}
|
||
#district.folded .districtrule{display:none}
|
||
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
||
and never again, so it must not shout. */
|
||
.districtrule{font-size:11px;line-height:1.4;color:#8b94a3;margin:0 0 7px;padding:5px 8px;
|
||
border-left:2px solid #39424e;background:#171c23;border-radius:0 4px 4px 0}
|
||
.districtrule b{color:#cfd6e0}
|
||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||
#districtsummary{display:none}
|
||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||
so a disabled button must not wear it. */
|
||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||
color:#7d8794;box-shadow:none;cursor:not-allowed;font-weight:400}
|
||
#actions button.blocked:hover{background:#232830;border-color:#4a5361;box-shadow:none}
|
||
h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe0f5;
|
||
margin:0 0 7px;font-weight:700}
|
||
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
|
||
.over.win{background:rgba(40,140,60,.35)}
|
||
.over.loss{background:rgba(160,60,60,.3)}
|
||
/* cards, log, blocked */
|
||
.card.gone{opacity:.35;text-decoration:line-through}
|
||
.subj{display:block;width:100%;margin:2px 0}
|
||
.subj.open{border-color:#4d6fa8;background:#39424e}
|
||
.spots{margin:0 0 6px 12px;padding-left:8px;border-left:2px solid #4d6fa8}
|
||
.card{display:inline-block;background:#242c36;border:1px solid var(--line);border-radius:4px;
|
||
padding:2px 6px;margin:2px 2px 0 0;font-size:11px}
|
||
#log{max-height:230px;overflow:auto;font-size:12px}
|
||
.line{padding:1px 0}
|
||
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
|
||
/* A phase marker is a HEADING inside the log, and takes the same violet the turn chart uses for
|
||
"where you are" — the two are saying the same thing in different places. `act` is what the
|
||
player did, so it stays the brightest thing in the column. */
|
||
.t-phase{color:var(--now);font-weight:700;margin-top:4px;letter-spacing:.02em}
|
||
.t-act{color:#e6e9ee}
|
||
.t-start{color:#cfe0f5;font-weight:700;font-size:13px;border-bottom:1px solid var(--line);
|
||
padding-bottom:3px;margin-bottom:3px}
|
||
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}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- 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. -->
|
||
<div id="lobby" hidden>
|
||
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
|
||
|
||
<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>
|
||
</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>
|
||
|
||
<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="lb-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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–3 and 11–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>
|
||
|
||
<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>
|
||
</section>
|
||
|
||
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
|
||
<section id="lb-seating-section" hidden>
|
||
<h2>Seating</h2>
|
||
<div class="lb-invite">
|
||
<div>
|
||
<div class="lb-invite-label">Send this code to your players</div>
|
||
<div class="lb-invite-code" id="lb-gamecode"></div>
|
||
</div>
|
||
<button id="lb-copy" class="ghost">Copy</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>
|
||
<div id="lb-seats"></div>
|
||
<button id="lb-start" disabled>Start game</button>
|
||
<p class="dim" id="lb-start-note"></p>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="gameui" hidden>
|
||
<div class="topbar">
|
||
<header>
|
||
<b><a href="./index.html" class="home">Station Master</a></b>
|
||
<span>Revenue <b id="revenue">0</b></span>
|
||
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
|
||
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>
|
||
<!-- 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
|
||
to keep the header on one line; the tooltip spells it out. -->
|
||
<span class="dim" id="houserules" title="">—</span>
|
||
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
||
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
||
rendered SVG's own pixel dimensions; the existing scrollbar keeps doing the panning. -->
|
||
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
|
||
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
|
||
</span>
|
||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||
<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>
|
||
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||
</header>
|
||
|
||
<!-- The turn chart: where in the Day we are, and which of the five phases is running. Lifted out
|
||
of the title bar, where "phase: Mainline" said nothing about what came before or after it. -->
|
||
<!-- Filled by `turnChartHtml`, shared with both replay viewers so all three screens report where
|
||
you are in the Day identically. -->
|
||
<div id="turnchart"></div>
|
||
</div><!-- /topbar -->
|
||
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
|
||
what you miss when the automatic phases run between two clicks. -->
|
||
<div id="phasenote"></div>
|
||
<div id="announce"></div>
|
||
<!-- `lobby-and-sessions.md` §5 — a disconnect keeps the seat and the game simply waits; this is
|
||
what says WHY, instead of the table watching nothing happen with no explanation. Empty and
|
||
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
|
||
<div id="presence"></div>
|
||
|
||
<main>
|
||
<div>
|
||
<section><h2>The Division — west to east</h2><div id="division"></div>
|
||
<p class="ng-note" id="seating-chain"></p></section>
|
||
<section id="district">
|
||
<h2>Your Office Area
|
||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
||
</h2>
|
||
<div id="districtsummary" class="dim"></div>
|
||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||
It is enforced everywhere and was stated nowhere: the Limits signs are the only growth
|
||
point on the Running Track and they move outward as you build, and no card anywhere can
|
||
be inserted between two cards already down. -->
|
||
<div class="districtrule">A district only ever grows <b>outwards</b>. On the Running Track the
|
||
Limits signs are the growth point — lay a card on a sign and the sign moves one further out;
|
||
nothing may be built beyond it. Everywhere else a card needs an empty square that its rails
|
||
reach. <b>No card is ever inserted between two cards already down</b>, so nothing you have
|
||
built is pushed along by something laid later.</div>
|
||
<div id="grid"></div>
|
||
</section>
|
||
<section><h2>History</h2><div id="log"></div></section>
|
||
</div>
|
||
|
||
<div>
|
||
<section><h2 class="big">Your Move</h2><div id="actions"></div></section>
|
||
<section><h2 class="big">Cards Available</h2>
|
||
<h3>Cards in My Hand</h3><div id="hand" class="cardrow">—</div>
|
||
<h3>Department Decks & Salvage Yard — top card face up, with depth</h3><div id="depts" class="cardrow ref">—</div>
|
||
</section>
|
||
<section><h2 class="big">The Yards</h2>
|
||
<h3>Division Yard <span class="dim" id="divtot"></span></h3>
|
||
<div id="divyard" class="yard"></div>
|
||
<h3>Classification Yard <span class="dim" id="clstot"></span></h3>
|
||
<div id="clsyard" class="yard"></div>
|
||
<div class="dim" id="yardnote"></div>
|
||
</section>
|
||
<section><h2>Timetable — who is due out, and when</h2><div id="timetable"></div>
|
||
<!-- WHAT THE COLOURS MEAN. Asked directly in playtesting — "what do green and purple mean?"
|
||
Violet is "where you are" everywhere on this page, and the green is a flash rather than a
|
||
state, which is exactly why it needed saying. -->
|
||
<div class="tt-key">
|
||
<span class="k-due">Train due</span>
|
||
<span class="k-now">this Stage</span>
|
||
<span class="k-past">already gone</span>
|
||
<span class="k-fresh">just rolled</span>
|
||
</div>
|
||
<div class="dim" style="font-size:11px;margin-top:5px">A train's number is its seniority and direction — odd runs west, even runs east. The Stage it departs is set by a 1D12 roll when its card is played, and it then runs at that time every Day.</div>
|
||
</section>
|
||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- ===================================================================
|
||
NEW GAME — the seed, the opening hand, and what the three economies pay.
|
||
|
||
It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game
|
||
you are about to play had no way in at all: the opening hand had been changed twice with no
|
||
way back to the earlier rule, and the revenue rates were constants in the source. Balance is
|
||
the open question in this game (`TODO.md`), and the way to settle it is to deal several games
|
||
at different settings — which needs a dialog, not a rebuild.
|
||
|
||
Every control has a default that is the recommended answer, so DEAL with nothing touched is a
|
||
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
|
||
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
|
||
==================================================================== -->
|
||
</div><!-- /gameui -->
|
||
|
||
<dialog id="newgamedlg" aria-labelledby="ng-title">
|
||
<form method="dialog" id="newgameform">
|
||
<h2 class="big" id="ng-title">New game</h2>
|
||
|
||
<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>
|
||
|
||
<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">
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
|
||
<button value="deal" id="ng-deal" type="submit">Deal</button>
|
||
</menu>
|
||
</form>
|
||
</dialog>
|
||
|
||
<script type="module" src="./web/main.js"></script>
|
||
</body>
|
||
</html>
|