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:
co-authored by
Claude Opus 5
parent
42adfda390
commit
06db36e5b5
+537
-107
@@ -14,12 +14,9 @@ import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardH
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import { playCue } from './sound.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
EXTRA_START_LABELS,
|
||||
STARTING_HAND_LABELS,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
} from '../engine/content.ts';
|
||||
import type { ExtraStartRule, HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts';
|
||||
@@ -27,8 +24,19 @@ import type { NewGameOptions } from './game.ts';
|
||||
import type { LocalSession, Session } from './session.ts';
|
||||
import { createLocalSession, createRemoteSession } from './session.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import { runLobby } from './lobby.ts';
|
||||
import { notice, prefillCode, runLobby } from './lobby.ts';
|
||||
import type { LobbyReady } from './lobby.ts';
|
||||
import {
|
||||
closestPreset,
|
||||
configFromFrame,
|
||||
gameTypeLabel,
|
||||
preset,
|
||||
presetOf,
|
||||
presetSettings,
|
||||
settingsOf,
|
||||
} from './presets.ts';
|
||||
import type { GameType, PresetName } from './presets.ts';
|
||||
import { settingsForm } from './settings-form.ts';
|
||||
|
||||
const SAVE_KEY = 'station-master.save.v1';
|
||||
const SETTINGS_KEY = 'station-master.settings.v1';
|
||||
@@ -227,7 +235,11 @@ function piecePreview(links: string[], label: string): string {
|
||||
*/
|
||||
function renderTurnChart(f: Frame): void {
|
||||
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null);
|
||||
$('turnchart').innerHTML = turnChartHtml(f, actorName);
|
||||
// Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a
|
||||
// chip that can never change is a chip to read past.
|
||||
const superName =
|
||||
f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null;
|
||||
$('turnchart').innerHTML = turnChartHtml(f, actorName, superName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,6 +283,19 @@ const VICTORY_PARAMS = {
|
||||
coltotal: 'maxCollisionsTotal',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Appendix B's three, in the URL like everything else the dialog asks (2026-08-23).
|
||||
*
|
||||
* The dialog navigates to a URL and `start()` reads the game back out of it, so a setting missing
|
||||
* from here is a setting the dialog silently discards — which is exactly what happened to these
|
||||
* three for as long as only the lobby offered them.
|
||||
*/
|
||||
const OPTIONAL_PARAMS = {
|
||||
vis: 'reducedVisibility',
|
||||
rot: 'employeeRotation',
|
||||
tool: 'emergencyToolbox',
|
||||
} as const;
|
||||
|
||||
function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions {
|
||||
const rules: HouseRuleOverrides = {};
|
||||
const hand = params.get('hand');
|
||||
@@ -296,9 +321,52 @@ function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions {
|
||||
options[key] = Math.max(0, Math.round(Number(raw)));
|
||||
}
|
||||
}
|
||||
const optional: Partial<NonNullable<NewGameOptions['optionalRules']>> = {};
|
||||
for (const [param, key] of Object.entries(OPTIONAL_PARAMS)) {
|
||||
const raw = params.get(param);
|
||||
// Present and not "0" means on: `?rot=1` and `?rot` alike, since a bare flag reads as ''.
|
||||
if (raw !== null) optional[key] = raw !== '0';
|
||||
}
|
||||
if (Object.keys(optional).length > 0) options.optionalRules = optional;
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SOLITAIRE GAME TYPE'S RULES, under whatever the URL actually named.
|
||||
*
|
||||
* `SOLO_CONFIG` is the ENGINE's fallback and stays where it is — every engine test and every sim run
|
||||
* is measured against it, and moving it would silently re-deal all of them. What a PLAYER is dealt
|
||||
* when they open the page is a different question, and its answer is the Solitaire game type
|
||||
* (`presets.ts`) — which since 2026-08-23 opens with six cards, like every other type, so that the
|
||||
* New Game dialog and the lobby agree about what "Solitaire" means.
|
||||
*/
|
||||
function solitaireDefaults(options: NewGameOptions): NewGameOptions {
|
||||
const days = options.days ?? 5;
|
||||
const p = presetSettings('solitaire', 1, days);
|
||||
const revenue = options.houseRules?.revenue ?? {};
|
||||
return {
|
||||
days,
|
||||
minCombinedRevenue: options.minCombinedRevenue ?? p.minCombinedRevenue,
|
||||
maxCollisionsPerDay: options.maxCollisionsPerDay ?? p.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: options.maxCollisionsTotal ?? p.maxCollisionsTotal,
|
||||
optionalRules: {
|
||||
reducedVisibility: options.optionalRules?.reducedVisibility ?? p.reducedVisibility,
|
||||
// One player, so there is nobody to rotate with whatever a hand-edited URL says.
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: options.optionalRules?.emergencyToolbox ?? p.emergencyToolbox,
|
||||
},
|
||||
houseRules: {
|
||||
startingHand: options.houseRules?.startingHand ?? p.startingHand,
|
||||
extraStart: options.houseRules?.extraStart ?? p.extraStart,
|
||||
revenue: {
|
||||
passengerPerCoach: revenue.passengerPerCoach ?? p.passengerPerCoach,
|
||||
freightPerLoad: revenue.freightPerLoad ?? p.freightPerLoad,
|
||||
trainPerTransit: revenue.trainPerTransit ?? p.trainPerTransit,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (seed !== '') params.set('seed', seed);
|
||||
@@ -311,18 +379,212 @@ function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): s
|
||||
const value = options[key];
|
||||
if (value !== undefined) params.set(param, String(value));
|
||||
}
|
||||
for (const [param, key] of Object.entries(OPTIONAL_PARAMS)) {
|
||||
// Only the ones that are ON: a URL that spells out three `=0`s says nothing extra and is three
|
||||
// parameters longer.
|
||||
if (options.optionalRules?.[key] === true) params.set(param, '1');
|
||||
}
|
||||
return `?${params}`;
|
||||
}
|
||||
|
||||
function loadRemote(): LobbyReady | null {
|
||||
/**
|
||||
* WHAT THIS BROWSER REMEMBERS ABOUT A MULTIPLAYER GAME, and when.
|
||||
*
|
||||
* It used to be written only at `Lobby.Start`, which meant a refresh while SEATED — before the host
|
||||
* started — orphaned the chair: the token existed nowhere else, so the player could not return and
|
||||
* the seat could not be freed, and a table that needs every chair filled could no longer start.
|
||||
* The record is written the moment a seat is taken, and `stage` says how far it got.
|
||||
*/
|
||||
type RemoteRecord = {
|
||||
token: string;
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
/** Only known once the game exists; a lobby-stage record has no seat yet. */
|
||||
seat?: PlayerIndex;
|
||||
stage: 'lobby' | 'game';
|
||||
};
|
||||
|
||||
/**
|
||||
* EVERY multiplayer game this browser holds a seat in, and which was last played.
|
||||
*
|
||||
* It used to be ONE record under one key, so joining a second game overwrote the first — and since
|
||||
* the token IS the identity (`lobby-and-sessions.md` §1), that seat was then locked out for good.
|
||||
* `TODO.md` had it as "a second, nearer limit" under the lost-token item; Jesse hit it from the
|
||||
* other side, asking how to leave a game and play a different one later.
|
||||
*/
|
||||
type RemoteStore = { games: Record<string, RemoteRecord>; last: string | null };
|
||||
|
||||
function readStore(): RemoteStore {
|
||||
try {
|
||||
const raw = localStorage.getItem(REMOTE_KEY);
|
||||
return raw ? (JSON.parse(raw) as LobbyReady) : null;
|
||||
if (!raw) return { games: {}, last: null };
|
||||
const parsed = JSON.parse(raw) as Partial<RemoteStore> & Partial<RemoteRecord>;
|
||||
// The single-record shape written before 2026-08-23 — carried across rather than dropped, so an
|
||||
// update does not throw away the game somebody is in the middle of.
|
||||
if (typeof parsed.token === 'string' && typeof parsed.gameId === 'string') {
|
||||
const one: RemoteRecord = {
|
||||
token: parsed.token,
|
||||
gameId: parsed.gameId,
|
||||
gameCode: parsed.gameCode ?? '',
|
||||
...(parsed.seat === undefined ? {} : { seat: parsed.seat }),
|
||||
stage: parsed.stage ?? 'game',
|
||||
};
|
||||
return { games: { [one.gameId]: one }, last: one.gameId };
|
||||
}
|
||||
const games = parsed.games ?? {};
|
||||
return { games, last: parsed.last ?? null };
|
||||
} catch {
|
||||
return null;
|
||||
return { games: {}, last: null };
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: RemoteStore): void {
|
||||
try {
|
||||
localStorage.setItem(REMOTE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// A full or disabled localStorage must not take the game down with it — the session in memory
|
||||
// keeps working, it simply will not survive a reload.
|
||||
}
|
||||
}
|
||||
|
||||
/** The game to re-enter on a bare page load: the one most recently played. */
|
||||
function loadRemote(): RemoteRecord | null {
|
||||
const store = readStore();
|
||||
return store.last === null ? null : (store.games[store.last] ?? null);
|
||||
}
|
||||
|
||||
function saveRemote(record: RemoteRecord): void {
|
||||
const store = readStore();
|
||||
store.games[record.gameId] = record;
|
||||
store.last = record.gameId;
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
/** Deliberate, and the one irreversible thing on the lobby screen: the token is the only proof of
|
||||
* who you are, so forgetting it gives up the seat with no way back from this browser. */
|
||||
function forgetRemote(gameId: string): void {
|
||||
const store = readStore();
|
||||
delete store.games[gameId];
|
||||
if (store.last === gameId) store.last = null;
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
function knownRemote(): RemoteRecord[] {
|
||||
return Object.values(readStore().games);
|
||||
}
|
||||
|
||||
/** The handlers the lobby drives this page through — one place, since four callers open a lobby.
|
||||
* Storage lives here rather than in `lobby.ts`, which owns the screen and not the browser. */
|
||||
const lobbyHandlers = {
|
||||
onReady: (ready: LobbyReady): void => beginRemote(ready),
|
||||
onSeated: (s: { token: string; gameId: string; gameCode: string }): void =>
|
||||
saveRemote({ ...s, stage: 'lobby' }),
|
||||
onLeft: (gameId?: string): void => {
|
||||
const target = gameId ?? readStore().last;
|
||||
if (target !== null && target !== undefined) forgetRemote(target);
|
||||
},
|
||||
known: (): { gameId: string; gameCode: string; stage: 'lobby' | 'game' }[] =>
|
||||
knownRemote().map((r) => ({ gameId: r.gameId, gameCode: r.gameCode, stage: r.stage })),
|
||||
forget: (gameId: string): void => forgetRemote(gameId),
|
||||
rejoin: (gameId: string): void => {
|
||||
const record = readStore().games[gameId];
|
||||
if (!record) return;
|
||||
if (record.stage === 'game' && record.seat !== undefined) {
|
||||
beginRemote({ ...record, seat: record.seat });
|
||||
return;
|
||||
}
|
||||
// Still seated in a lobby that had not started: the stream puts us back on the seating screen,
|
||||
// and its own probe handles a game that began while we were away.
|
||||
showScreen('lobby');
|
||||
runLobby(lobbyHandlers, { token: record.token, gameId: record.gameId, gameCode: record.gameCode });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* THE GAME CODE THIS PAGE IS IN, once it is in one.
|
||||
*
|
||||
* It ended at the lobby door before 2026-08-23 — `LobbyReady` carried the token, the game id and the
|
||||
* seat, and the code (the only one of the four a person can read out) was dropped. A seated player
|
||||
* could not say which game they were in, match it against the administrator's Games in Progress
|
||||
* list, or pass it to a latecomer. Empty in solitaire, where there is no code.
|
||||
*/
|
||||
let gameCode = '';
|
||||
|
||||
/** How long the handoff curtain holds, so the start of a game is a moment rather than a snap. */
|
||||
const HANDOFF_BEAT_MS = 1500;
|
||||
/** How long to wait before saying the board has not arrived. */
|
||||
const HANDOFF_STALL_MS = 8000;
|
||||
let handoffOpenedAt = 0;
|
||||
let handoffStall: number | null = null;
|
||||
let firstFrameSeen = false;
|
||||
|
||||
function setText(id: string, text: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
/** The curtain between `Lobby.Start` and the first Frame — see `#handoff` in `play.html`. */
|
||||
function openHandoff(): void {
|
||||
const el = document.getElementById('handoff');
|
||||
if (!el) return;
|
||||
firstFrameSeen = false;
|
||||
handoffOpenedAt = Date.now();
|
||||
el.classList.add('shown');
|
||||
setText('handoff-title', 'Dealing the railroad…');
|
||||
setText('handoff-note', 'Laying out the Division and rolling for seats.');
|
||||
if (handoffStall !== null) clearTimeout(handoffStall);
|
||||
handoffStall = window.setTimeout(() => {
|
||||
if (firstFrameSeen) return;
|
||||
setText('handoff-title', 'Still waiting for the server');
|
||||
setText(
|
||||
'handoff-note',
|
||||
'The game exists, but its board has not arrived yet. It will appear as soon as the server sends it.',
|
||||
);
|
||||
}, HANDOFF_STALL_MS);
|
||||
}
|
||||
|
||||
function closeHandoff(): void {
|
||||
document.getElementById('handoff')?.classList.remove('shown');
|
||||
if (handoffStall !== null) clearTimeout(handoffStall);
|
||||
handoffStall = null;
|
||||
}
|
||||
|
||||
/** Flash a one-line announcement over the board. Shared by the session's own announcements and by
|
||||
* the "the game has begun" line, which comes from the page rather than from an event. */
|
||||
function flashAnnounce(text: string): void {
|
||||
const el = document.getElementById('announce');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = 'shown';
|
||||
window.setTimeout(() => {
|
||||
if (el.textContent === text) el.className = '';
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE FIRST FRAME OF A MULTIPLAYER GAME — the one moment nobody had ever seen drawn.
|
||||
*
|
||||
* The board simply appeared, mid-Local-Operations, with a log already several bot turns deep and
|
||||
* nothing saying this was the game just set up. `#phasenote` cannot help: it announces a CHANGE of
|
||||
* phase, and there is no previous phase to have changed from.
|
||||
*/
|
||||
function noteFirstFrame(f: Frame): void {
|
||||
if (firstFrameSeen || isLocal(session)) return;
|
||||
firstFrameSeen = true;
|
||||
const held = Date.now() - handoffOpenedAt;
|
||||
const config = configFromFrame(f);
|
||||
const type = gameTypeLabel(presetOf(config, f.players.length, f.days), f.mode);
|
||||
window.setTimeout(
|
||||
() => {
|
||||
closeHandoff();
|
||||
flashAnnounce(
|
||||
`The game has begun — ${type} · ${f.players.length} players · Day ${f.day}, Stage ${f.stage}`,
|
||||
);
|
||||
},
|
||||
Math.max(0, HANDOFF_BEAT_MS - held),
|
||||
);
|
||||
}
|
||||
|
||||
/** Toggles the two mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4)
|
||||
* and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither
|
||||
* ever flashes before `start()` decides which one this load actually needs. */
|
||||
@@ -338,12 +600,14 @@ function showScreen(which: 'lobby' | 'gameui'): void {
|
||||
* so it is always written back here before anything else happens.
|
||||
*/
|
||||
function beginRemote(ready: LobbyReady): void {
|
||||
localStorage.setItem(REMOTE_KEY, JSON.stringify(ready));
|
||||
saveRemote({ token: ready.token, gameId: ready.gameId, gameCode: ready.gameCode, seat: ready.seat, stage: 'game' });
|
||||
gameCode = ready.gameCode;
|
||||
showScreen('gameui');
|
||||
// Nothing can be drawn until the first push arrives, and a page showing nothing at all is
|
||||
// indistinguishable from a page that is broken — which is exactly what a dead session used to
|
||||
// look like, forever.
|
||||
$('presence').textContent = '… connecting to the game';
|
||||
// look like, forever. This is its own state now rather than a borrowed line in the DISCONNECT
|
||||
// banner (`#presence`), and it holds a beat so the game visibly begins.
|
||||
openHandoff();
|
||||
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
|
||||
applyCapabilities();
|
||||
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
|
||||
@@ -366,16 +630,19 @@ function beginRemote(ready: LobbyReady): void {
|
||||
* Forgetting the token is what makes the next load land in the lobby instead of repeating it.
|
||||
*/
|
||||
function abandonRemote(): void {
|
||||
localStorage.removeItem(REMOTE_KEY);
|
||||
// That one game is gone; any OTHER game this browser is in is untouched.
|
||||
const store = readStore();
|
||||
if (store.last !== null) forgetRemote(store.last);
|
||||
closeHandoff();
|
||||
showScreen('lobby');
|
||||
$('presence').textContent = '';
|
||||
runLobby(beginRemote);
|
||||
const note = document.getElementById('lb-create-err');
|
||||
if (note) {
|
||||
note.textContent =
|
||||
'That game is no longer on this server — it was either ended by whoever runs it, or the ' +
|
||||
'service was updated, which does not carry games in progress across. Create or join a new one.';
|
||||
}
|
||||
runLobby(lobbyHandlers);
|
||||
// The lobby's own notice slot, not the create form's error line: the player may well have been a
|
||||
// joiner, and with the two doors that line is behind a panel they are not looking at.
|
||||
notice(
|
||||
'That game is no longer on this server — it was either ended by whoever runs it, or the ' +
|
||||
'service was updated, which does not carry games in progress across. Create or join a new one.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -388,21 +655,44 @@ function abandonRemote(): void {
|
||||
function start(): void {
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
/**
|
||||
* ASKING FOR THE LOBBY BEATS RESUMING A GAME.
|
||||
*
|
||||
* The splash's "Play multiplayer" door and an invite link both land here with `?lobby`, and both
|
||||
* mean "I want to pick a game" — but a remembered session used to be checked first, so anyone
|
||||
* already in a game was dropped straight back into it and could never reach the lobby from the
|
||||
* door at all. A BARE load still resumes, which is the common case and the one D11 is about.
|
||||
*/
|
||||
const invited = params.get('code');
|
||||
if (params.get('lobby') !== null || invited !== null) {
|
||||
showScreen('lobby');
|
||||
if (invited !== null && invited !== '') prefillCode(invited);
|
||||
runLobby(lobbyHandlers);
|
||||
return;
|
||||
}
|
||||
|
||||
// Entered without checking it still exists — deliberately. Verifying up front would mean an
|
||||
// await before anything renders on the common path, where the game IS still there; instead the
|
||||
// session reports a dead game through `abandonRemote`, which lands in the lobby.
|
||||
const remembered = loadRemote();
|
||||
if (remembered) {
|
||||
beginRemote(remembered);
|
||||
if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) {
|
||||
beginRemote({ ...remembered, seat: remembered.seat });
|
||||
return;
|
||||
}
|
||||
|
||||
// The splash's "Play multiplayer" door (index.html) lands here — straight into the lobby,
|
||||
// rather than dealing a solitaire game first and leaving the player to find the in-game
|
||||
// Multiplayer button themselves.
|
||||
if (params.get('lobby') !== null) {
|
||||
/**
|
||||
* A SEAT TAKEN BUT NOT YET PLAYING — this browser reloaded while the lobby was still seating.
|
||||
*
|
||||
* The lobby stream answers all three cases from here without another route: it pushes the seating
|
||||
* screen if the lobby is still open, and its `onerror` probe finds either a game that started
|
||||
* while we were away (straight in) or a lobby that is gone (back to the doors, with a reason).
|
||||
*/
|
||||
if (remembered) {
|
||||
showScreen('lobby');
|
||||
runLobby(beginRemote);
|
||||
runLobby(lobbyHandlers, {
|
||||
token: remembered.token,
|
||||
gameId: remembered.gameId,
|
||||
gameCode: remembered.gameCode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -410,7 +700,7 @@ function start(): void {
|
||||
const requested = params.get('seed');
|
||||
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||
const local = createLocalSession(seed, gameOptionsFromUrl(params));
|
||||
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
|
||||
session = local;
|
||||
|
||||
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
|
||||
@@ -445,6 +735,8 @@ function applyCapabilities(): void {
|
||||
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
|
||||
// page offers — same reasoning as `newgame`, and the same capability answers both.
|
||||
hide('multiplayer', c.newGame);
|
||||
// The mirror of the three above: leaving a game is the one control that only a REMOTE session has.
|
||||
hide('leavegame', !c.newGame);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,16 +781,67 @@ function renderSeatingChain(f: Frame): void {
|
||||
* collapses the banner rather than leaving a reassuring "all connected" line nobody needs to read.
|
||||
*/
|
||||
function renderPresence(f: Frame): void {
|
||||
const away = session
|
||||
.presence()
|
||||
.filter((p) => !p.connected)
|
||||
.map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${seatLabel(p.seat)}`);
|
||||
$('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`;
|
||||
const name = (seat: PlayerIndex): string =>
|
||||
f.players.find((pl) => pl.index === seat)?.name ?? `Seat ${seatLabel(seat)}`;
|
||||
const presence = session.presence();
|
||||
/**
|
||||
* TWO DIFFERENT ABSENCES, and they call for two different things from the table.
|
||||
*
|
||||
* A seat the server has never heard from has not opened the game yet — somebody needs to send them
|
||||
* the link. A seat that WAS here and dropped will probably be back. The server reports both from
|
||||
* `/api/stream`'s connect push (2026-08-23); before that a client learnt of a seat only when it
|
||||
* disconnected AFTER you connected, so a table where two people had not shown up yet said nothing
|
||||
* at all.
|
||||
*/
|
||||
const dropped = presence.filter((p) => p.seen && !p.connected).map((p) => name(p.seat));
|
||||
const never = presence.filter((p) => !p.seen).map((p) => name(p.seat));
|
||||
const parts: string[] = [];
|
||||
if (dropped.length > 0) parts.push(`${dropped.join(', ')} — disconnected`);
|
||||
if (never.length > 0) parts.push(`${never.join(', ')} — not here yet`);
|
||||
$('presence').textContent = parts.length === 0 ? '' : `⚠ waiting on ${parts.join(' · ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH GAME THIS IS, in the header: its code and its type.
|
||||
*
|
||||
* Neither used to be anywhere on the board. The code ended at the lobby door, and the type was never
|
||||
* on the Frame at all — so a player could read what a load paid but not whether they were in a Co-op
|
||||
* game or a Cutthroat one, which is the difference between helping the table and racing it.
|
||||
*/
|
||||
function renderGameIdentity(f: Frame): void {
|
||||
const codeEl = document.getElementById('gamecode');
|
||||
if (codeEl) codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`;
|
||||
|
||||
const el = document.getElementById('gametype');
|
||||
if (!el) return;
|
||||
const config = configFromFrame(f);
|
||||
const players = f.players.length;
|
||||
const type = presetOf(config, players, f.days);
|
||||
const near = closestPreset(config, players, f.days);
|
||||
el.textContent = gameTypeLabel(type, f.mode);
|
||||
el.title =
|
||||
type === 'custom'
|
||||
? `A custom game, scored as ${preset(near.name).scoring === 'coop' ? 'Co-op' : 'Competitive'}. ` +
|
||||
`${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} ` +
|
||||
`from ${preset(near.name).label}.\n\n${rulesSummary(f)}`
|
||||
: `${preset(type).blurb}\n\n${rulesSummary(f)}`;
|
||||
}
|
||||
|
||||
/** The victory conditions in force, spelled out for the header's tooltip. */
|
||||
function rulesSummary(f: Frame): string {
|
||||
const off = (n: number): string => (n === 0 ? 'off' : String(n));
|
||||
return (
|
||||
`Days: ${f.days}\n` +
|
||||
`Combined Revenue floor: ${off(f.minCombinedRevenue)}\n` +
|
||||
`Collisions in one Day that end the game: ${off(f.maxCollisionsPerDay)}\n` +
|
||||
`Collisions in the whole game that end it: ${off(f.maxCollisionsTotal)}`
|
||||
);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const f = session.view();
|
||||
const menu = session.menu();
|
||||
noteFirstFrame(f);
|
||||
|
||||
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
|
||||
// coordinate list into a board: you pick the thing, then click where it goes.
|
||||
@@ -535,6 +878,7 @@ function render(): void {
|
||||
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
|
||||
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
|
||||
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${seatLabel(session.seat())}`;
|
||||
renderGameIdentity(f);
|
||||
renderHouseRules(f.houseRules);
|
||||
|
||||
// -- division
|
||||
@@ -809,10 +1153,20 @@ function render(): void {
|
||||
|
||||
// -- log
|
||||
const log = $('log');
|
||||
log.innerHTML = session.lines()
|
||||
.slice(-60)
|
||||
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
|
||||
.join('');
|
||||
const allLines = session.lines();
|
||||
const shownLines = allLines.slice(-60);
|
||||
/**
|
||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||
* by the time the board paints the log already has several turns in it and nothing says which of
|
||||
* them are yours to have missed. Only drawn while the whole log is on screen: past sixty lines the
|
||||
* top of the panel is no longer the start of the game, and a marker claiming otherwise would lie.
|
||||
*/
|
||||
const startMarker =
|
||||
!isLocal(session) && allLines.length === shownLines.length
|
||||
? '<div class="line t-phase">— the game began —</div>'
|
||||
: '';
|
||||
log.innerHTML =
|
||||
startMarker + shownLines.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`).join('');
|
||||
log.scrollTop = log.scrollHeight;
|
||||
|
||||
/**
|
||||
@@ -837,14 +1191,7 @@ function render(): void {
|
||||
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
|
||||
*/
|
||||
const announcement = session.takeAnnouncement();
|
||||
if (announcement) {
|
||||
const el = $('announce');
|
||||
el.textContent = announcement;
|
||||
el.className = 'shown';
|
||||
window.setTimeout(() => {
|
||||
if (el.textContent === announcement) el.className = '';
|
||||
}, 4200);
|
||||
}
|
||||
if (announcement) flashAnnounce(announcement);
|
||||
|
||||
renderDistrict(f);
|
||||
renderActions(menu, f, justSet);
|
||||
@@ -1074,13 +1421,13 @@ function renderActions(
|
||||
*/
|
||||
const scheduledNote =
|
||||
justSet !== null && f.timetable[justSet] != null
|
||||
? `<div class="scheduled" data-tip="§7 — the Stage is rolled on 1D12 when the card is played; if that Stage is taken the train works down the column to the next free one. From now on it runs at this time every Day.">` +
|
||||
? `<div class="scheduled" data-tip="The Stage is rolled on 1D12 when the card is played; if that Stage is taken the train works down the column to the next free one. From now on it runs at this time every Day.">` +
|
||||
`Train ${f.timetable[justSet]} is scheduled to depart at Stage ${justSet + 1} — see the Timetable</div>`
|
||||
: '';
|
||||
|
||||
const movesNote =
|
||||
f.movesLeft !== null
|
||||
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
|
||||
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="Six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
|
||||
`${f.movesLeft} of ${MOVES_PER_LOCAL_OPS} Moves left</div>`
|
||||
: '';
|
||||
|
||||
@@ -1163,7 +1510,7 @@ function renderActions(
|
||||
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.`
|
||||
: menu.makeUp.pass !== null
|
||||
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
||||
: 'Nothing in the Division Yard may join this train, and §7 does not allow passing while the yard holds cars.') +
|
||||
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
|
||||
`</div>` +
|
||||
/**
|
||||
* WHICH ORDER TO ADD THEM IN, when the order is what decides whether the train can work.
|
||||
@@ -1177,7 +1524,7 @@ function renderActions(
|
||||
? `<div class="makeup-advice ${menu.makeUp.advice.tone}">${esc(menu.makeUp.advice.text)}</div>`
|
||||
: '') +
|
||||
(menu.makeUp.pass !== null
|
||||
? `<button class="act" data-i="${menu.makeUp.pass}" data-tip="§8.2 — a train may depart with FEWER cars than its card lists, but never with the wrong ones. This sends it out as it stands.">no more cars — send it out</button>`
|
||||
? `<button class="act" data-i="${menu.makeUp.pass}" data-tip="A train may depart with FEWER cars than its card lists, but never with the wrong ones. This sends it out as it stands.">no more cars — send it out</button>`
|
||||
: '') +
|
||||
`</div>`;
|
||||
}
|
||||
@@ -1237,11 +1584,11 @@ function renderActions(
|
||||
*/
|
||||
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
|
||||
const tip = stuck
|
||||
? '§6.2 — you may not end a turn holding more than three cards, and a TRAIN CARD IS NEVER ' +
|
||||
? 'You may not end a turn holding more than three cards, and a TRAIN CARD IS NEVER ' +
|
||||
'DISCARDED. Every card you hold is a train, so the only way on is to play one onto the ' +
|
||||
'timetable. A train may be held for as many Stages and Days as you like; it just cannot be ' +
|
||||
'thrown away.'
|
||||
: '§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play ' +
|
||||
: 'You may not end a turn holding more than three cards (four with a Red Flag). Play ' +
|
||||
'one onto the board, or discard one face-up to a Department slot. A train card is never ' +
|
||||
'discardable and can only be played.';
|
||||
html +=
|
||||
@@ -1356,7 +1703,35 @@ if (multiplayerBtn) {
|
||||
const started = f.status === 'active' && (f.day > 1 || f.stage > 1);
|
||||
if (started && !confirm(`Leave this game (seed ${session.seed()}, Day ${f.day}) for multiplayer?`)) return;
|
||||
showScreen('lobby');
|
||||
runLobby(beginRemote);
|
||||
runLobby(lobbyHandlers);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LEAVE A RUNNING GAME — back to the lobby, seat and token kept.
|
||||
*
|
||||
* Reported by Jesse 2026-08-23: a player who has to go had no way out at all. The page re-entered
|
||||
* the same game on every load, and the only thing that ever let go of a session was the game itself
|
||||
* being destroyed. Leaving does NOT give up the seat: `lobby-and-sessions.md` §5 keeps it and the
|
||||
* table waits, which is the design — nothing moves on an absent player's behalf. The token is kept
|
||||
* too, so the game can be re-entered from "Games you are in"; forgetting it is a separate, deliberate
|
||||
* act on that list, because the token is the only proof of who you are.
|
||||
*/
|
||||
const leaveBtn = document.getElementById('leavegame');
|
||||
if (leaveBtn) {
|
||||
leaveBtn.onclick = () => {
|
||||
if (isLocal(session)) return;
|
||||
const f = session.view();
|
||||
const code = gameCode === '' ? 'this game' : gameCode;
|
||||
if (!confirm(`Leave ${code} (Day ${f.day}, Stage ${f.stage})? Your seat is kept and the game waits for you.`)) return;
|
||||
// Stop listening before leaving the screen, so the table sees the seat go quiet rather than
|
||||
// being told somebody is present who is not.
|
||||
session.close?.();
|
||||
closeHandoff();
|
||||
showScreen('lobby');
|
||||
$('presence').textContent = '';
|
||||
runLobby(lobbyHandlers);
|
||||
notice(`You left ${code}. It is still yours — rejoin it under "Games you are in" whenever you like.`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1364,38 +1739,99 @@ const newBtn = document.getElementById('newgame');
|
||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
||||
if (newBtn && dlg) {
|
||||
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||
type Mode = 'solitaire' | 'competitive' | 'coop';
|
||||
const ngForm = settingsForm('ng-');
|
||||
|
||||
/**
|
||||
* PICKING A TYPE JUST SETS THE FIELDS BELOW TO THAT TYPE'S DEFAULTS (Jesse's design, 2026-08-20) —
|
||||
* every number stays editable afterward, so "Competitive" isn't a fixed ruleset, it's a starting
|
||||
* point. `players = 4` for Competitive/Co-op is a nominal stand-in: there's no lobby yet to ask who
|
||||
* is actually seated (Phase 4), so this is a suggestion a real seat count will replace.
|
||||
* THE SAME FIVE GAME TYPES THE LOBBY OFFERS, and the same shared rules block under them.
|
||||
*
|
||||
* Only Solitaire can be dealt today — Deal disables itself for the other two, with a note, rather
|
||||
* than pretending a click would do something (`RemoteSession` is Phase 2).
|
||||
* The dialog used to carry its own copy of the questions and its own idea of the defaults, which
|
||||
* is how it ended up with "where an Extra may start" that the lobby did not have and none of the
|
||||
* three optional rules that it did. Both screens now read `presets.ts` and drive their block
|
||||
* through `settings-form.ts`; only Solitaire can actually be DEALT here, so the three multiplayer
|
||||
* types are shown disabled rather than hidden — what this screen offers and what the lobby offers
|
||||
* should read as one list, not two.
|
||||
*/
|
||||
function applyModePreset(mode: Mode): void {
|
||||
const days = 5;
|
||||
const players = mode === 'solitaire' ? 1 : 4;
|
||||
field<HTMLInputElement>('ng-days').value = String(days);
|
||||
field<HTMLInputElement>('ng-minrev').value = String(collectiveRevenueFloor(players, days));
|
||||
field<HTMLInputElement>('ng-colday').value = String(DEFAULT_MAX_COLLISIONS_PER_DAY);
|
||||
field<HTMLInputElement>('ng-coltotal').value = String(DEFAULT_MAX_COLLISIONS_TOTAL);
|
||||
let ngBase: PresetName = 'solitaire';
|
||||
let ngType: GameType = 'solitaire';
|
||||
/** As in the lobby: the floor is derived from the length until the player sets one themselves. */
|
||||
let ngFloorTyped = false;
|
||||
|
||||
// No valid target for these cards in Solitaire or Co-op — forced off, not merely defaulted off.
|
||||
const pvp = field<HTMLInputElement>('ng-pvp');
|
||||
pvp.checked = mode === 'competitive';
|
||||
pvp.disabled = mode !== 'competitive';
|
||||
const ngDays = (): number => {
|
||||
const raw = Number(field<HTMLInputElement>('ng-days').value);
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
|
||||
};
|
||||
|
||||
field<HTMLButtonElement>('ng-deal').disabled = mode !== 'solitaire';
|
||||
field<HTMLElement>('ng-multiplayer-note').style.visibility = mode === 'solitaire' ? 'hidden' : 'visible';
|
||||
const ngTypeRadios = (): HTMLInputElement[] =>
|
||||
Array.from(dlg.querySelectorAll<HTMLInputElement>('input[name="ng-type"]'));
|
||||
|
||||
function ngRefresh(): void {
|
||||
const differing = ngForm.mark(ngBase, 1, ngDays());
|
||||
if (differing.length > 0) ngType = 'custom';
|
||||
else if (ngType === 'custom') ngType = ngBase;
|
||||
for (const r of ngTypeRadios()) r.checked = r.value === ngType;
|
||||
const note = field<HTMLElement>('ng-type-note');
|
||||
note.textContent =
|
||||
ngType === 'custom'
|
||||
? `${gameTypeLabel('custom', preset(ngBase).scoring)} · ${differing.length} ` +
|
||||
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(ngBase).label}.`
|
||||
: preset(ngType as PresetName).blurb;
|
||||
}
|
||||
|
||||
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-mode"]')) {
|
||||
input.onchange = () => applyModePreset(input.value as Mode);
|
||||
function ngSelectPreset(name: PresetName): void {
|
||||
ngBase = name;
|
||||
ngType = name;
|
||||
ngFloorTyped = false;
|
||||
const values = presetSettings(name, 1, ngDays());
|
||||
ngForm.write(values, values);
|
||||
ngRefresh();
|
||||
}
|
||||
|
||||
for (const r of ngTypeRadios()) {
|
||||
// Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and
|
||||
// a table needs a server. The lobby is the door, and the row says so rather than just refusing
|
||||
// the click (Jesse, 2026-08-23 — a disabled radio that looks enabled reads as a broken one).
|
||||
if (r.value !== 'solitaire' && r.value !== 'custom') {
|
||||
r.disabled = true;
|
||||
const row = r.closest('label');
|
||||
if (row && !row.querySelector('.lb-why')) {
|
||||
row.classList.add('disabled');
|
||||
const note = document.createElement('span');
|
||||
note.className = 'lb-why';
|
||||
note.textContent = ' — use the Multiplayer button; a table needs a server';
|
||||
row.querySelector('span')?.appendChild(note);
|
||||
}
|
||||
}
|
||||
r.onchange = () => {
|
||||
if (!r.checked) return;
|
||||
if (r.value === 'custom') {
|
||||
ngType = 'custom';
|
||||
ngRefresh();
|
||||
return;
|
||||
}
|
||||
ngSelectPreset(r.value as PresetName);
|
||||
};
|
||||
}
|
||||
|
||||
ngForm.onEdit((key) => {
|
||||
if (key === 'minCombinedRevenue') ngFloorTyped = true;
|
||||
ngType = 'custom';
|
||||
ngRefresh();
|
||||
});
|
||||
|
||||
// Days is a parameter, not a rule: it re-derives the floor and never makes a game Custom by itself.
|
||||
field<HTMLInputElement>('ng-days').oninput = () => {
|
||||
if (!ngFloorTyped) {
|
||||
const values = ngForm.read();
|
||||
const want = presetSettings(ngBase, 1, ngDays());
|
||||
ngForm.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
|
||||
}
|
||||
ngRefresh();
|
||||
};
|
||||
|
||||
// "Everyone moves one chair left" has no meaning at a table of one — disabled with the rest of the
|
||||
// block still visible, so the two screens read the same.
|
||||
ngForm.setEmployeeRotationAvailable(false);
|
||||
|
||||
/**
|
||||
* ASK FOR ALL OF IT, rather than documenting URL parameters in the title bar.
|
||||
*
|
||||
@@ -1405,8 +1841,9 @@ if (newBtn && dlg) {
|
||||
*
|
||||
* The dialog OPENS ON THE RULES IN PLAY rather than on the defaults: dealing a second game to
|
||||
* compare against the first is the common case, and re-entering settings each time is how a
|
||||
* comparison silently stops comparing. Mode always reopens on Solitaire — it's the only one a
|
||||
* previous session could actually have been, since Deal is disabled for the other two.
|
||||
* comparison silently stops comparing. Which TYPE that is comes out of the comparison — a game
|
||||
* dealt at the Solitaire defaults reopens on Solitaire, and one that was tuned reopens on Custom
|
||||
* with every changed field marked.
|
||||
*/
|
||||
newBtn.onclick = () => {
|
||||
// The button itself is hidden for a session that cannot deal (`applyCapabilities`), but the
|
||||
@@ -1420,26 +1857,15 @@ if (newBtn && dlg) {
|
||||
const started = f.status === 'active' && (day > 1 || f.stage > 1);
|
||||
if (started && !confirm(`Forget this game (seed ${local.seed()}, Day ${day}) and deal a new one?`)) return;
|
||||
|
||||
const current = f.houseRules;
|
||||
field<HTMLInputElement>('ng-seed').value = '';
|
||||
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-mode"]')) {
|
||||
input.checked = input.value === 'solitaire';
|
||||
}
|
||||
applyModePreset('solitaire');
|
||||
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-hand"]')) {
|
||||
input.checked = input.value === current.startingHand;
|
||||
}
|
||||
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-extra"]')) {
|
||||
input.checked = input.value === current.extraStart;
|
||||
}
|
||||
field<HTMLInputElement>('ng-passenger').value = String(current.revenue.passengerPerCoach);
|
||||
field<HTMLInputElement>('ng-freight').value = String(current.revenue.freightPerLoad);
|
||||
field<HTMLInputElement>('ng-transit').value = String(current.revenue.trainPerTransit);
|
||||
// Overwrite the preset with the actual rules in play — solitaire is the only real session today.
|
||||
field<HTMLInputElement>('ng-days').value = String(f.days);
|
||||
field<HTMLInputElement>('ng-minrev').value = String(f.minCombinedRevenue);
|
||||
field<HTMLInputElement>('ng-colday').value = String(f.maxCollisionsPerDay);
|
||||
field<HTMLInputElement>('ng-coltotal').value = String(f.maxCollisionsTotal);
|
||||
ngBase = 'solitaire';
|
||||
ngType = 'solitaire';
|
||||
ngFloorTyped = false;
|
||||
// The rules actually in play, then the comparison decides what to call them.
|
||||
ngForm.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
|
||||
ngForm.setEmployeeRotationAvailable(false);
|
||||
ngRefresh();
|
||||
dlg.showModal();
|
||||
};
|
||||
|
||||
@@ -1449,8 +1875,6 @@ if (newBtn && dlg) {
|
||||
*
|
||||
* The answers go into the URL and the page navigates, which is the same path `?seed=` already
|
||||
* took: `start()` reads them back, so there is exactly one place that turns a URL into a game.
|
||||
* Deal is disabled whenever the mode radio isn't Solitaire, so this never actually runs for the
|
||||
* other two — nothing here needs to branch on mode.
|
||||
*/
|
||||
dlg.addEventListener('close', () => {
|
||||
if (dlg.returnValue !== 'deal') return;
|
||||
@@ -1459,24 +1883,30 @@ if (newBtn && dlg) {
|
||||
// A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable
|
||||
// both mean "surprise me", which is what leaving the box alone plainly asks for.
|
||||
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
|
||||
const picked = dlg.querySelector<HTMLInputElement>('input[name="ng-hand"]:checked')?.value;
|
||||
const extra = dlg.querySelector<HTMLInputElement>('input[name="ng-extra"]:checked')?.value;
|
||||
const settings = ngForm.read();
|
||||
const rules = houseRules({
|
||||
houseRules: {
|
||||
...(STARTING_HAND_LABELS.some((o) => o.value === picked) ? { startingHand: picked as StartingHand } : {}),
|
||||
...(EXTRA_START_LABELS.some((o) => o.value === extra) ? { extraStart: extra as ExtraStartRule } : {}),
|
||||
startingHand: settings.startingHand,
|
||||
extraStart: settings.extraStart,
|
||||
revenue: {
|
||||
passengerPerCoach: Number(field<HTMLInputElement>('ng-passenger').value),
|
||||
freightPerLoad: Number(field<HTMLInputElement>('ng-freight').value),
|
||||
trainPerTransit: Number(field<HTMLInputElement>('ng-transit').value),
|
||||
passengerPerCoach: settings.passengerPerCoach,
|
||||
freightPerLoad: settings.freightPerLoad,
|
||||
trainPerTransit: settings.trainPerTransit,
|
||||
},
|
||||
},
|
||||
});
|
||||
const victory: NewGameOptions = {
|
||||
days: Math.max(1, Math.round(Number(field<HTMLInputElement>('ng-days').value)) || 5),
|
||||
minCombinedRevenue: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-minrev').value)) || 0),
|
||||
maxCollisionsPerDay: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-colday').value)) || 0),
|
||||
maxCollisionsTotal: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-coltotal').value)) || 0),
|
||||
days: Math.max(1, ngDays()),
|
||||
minCombinedRevenue: settings.minCombinedRevenue,
|
||||
maxCollisionsPerDay: settings.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: settings.maxCollisionsTotal,
|
||||
optionalRules: {
|
||||
reducedVisibility: settings.reducedVisibility,
|
||||
// Never on at a table of one, whatever the box says — the control is disabled for the same
|
||||
// reason, and this is the half that reaches the engine.
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: settings.emergencyToolbox,
|
||||
},
|
||||
};
|
||||
|
||||
clearSave();
|
||||
|
||||
Reference in New Issue
Block a user