v0.5.3 — a table you size yourself, and games an administrator can see and end

Both halves came out of playing the StartOS build. The wrapper's health
check and admin actions consume this; they land separately.

The host picks the table size (2-4) when creating a game, and the seats
array is built at that length once. Before, it GREW as people joined, so
the four rows on screen were partly fiction — a 2-player game just started
with a 2-long array, while a host who dropped a bot into a later chair
padded it with a null and silently disabled Start behind a one-line note.
A gap can no longer be written down rather than merely being refused.

That also avoided a trap. Compacting seats at Lobby.Start — the obvious
way to support a "closed" chair — would have shifted the player index that
every PlayerSession stamps at join time and that /api/stream and
/api/intent both route by, handing a player somebody else's railroad with
no error anywhere.

And it fixed a live balance bug: minCombinedRevenue is derived from the
player count, but the config was fixed at CREATE while the count wasn't
known until START, so the lobby guessed 4. Every 2-player game ran against
a floor of 60 instead of 30 — and missing the floor means everyone loses,
so a 2-player competitive game was set up to fail for a UI artifact rather
than a rule.

/api/health gained games:{active,lobby}, read from a new cheap summary()
on GameSession rather than exportSave(), which would copy every intent of
every game to answer a question about none of them. Three admin routes are
new behind an ADMIN_SECRET env var in an x-admin-secret header: GET
/api/games, GET /api/games/<id>/save, DELETE /api/games/<id>. Until now a
started game could not be ended by anyone — no route, no player action, no
resignation — so an abandoned game stayed active in the index and was
faithfully resumed on every boot, forever.

Three deliberate choices there: the admin secret is NOT the join secret,
which every player holds and which would therefore let anyone at the table
destroy anyone else's game; unset means the routes 404 exactly as any
unknown path does, with or without a header, so a server never given an
administrator doesn't advertise that it has one; and a delete returns the
deleted game's save, since the intents are the game (D5) — nothing is
destroyed without being handed to whoever destroyed it.

SavedGame gained an optional lastMoveAt (falling back to createdAt) so
"has this stalled?" survives a restart. Kept out of history for the same
reason the turn timings are: a replay must reproduce a game from decisions
alone, and wall-clock is not a decision.

index.ts logs "Resuming N saved games..." before the loop rather than one
line per game after it. Measured a full 4-player game at 100ms to replay,
and only unfinished games are replayed, so listening before loading would
have bought nothing for the cost of a "still loading" state everywhere.

Verified: 667 tests pass (662 + 5), and the new session tests were checked
against two mutations (lastMoveAt never advancing; resume dropping it) to
confirm they fail without the code. Live against a running server: health
counts tracking through the lobby->game transition, admin auth rejecting a
missing and a wrong secret, list/export/delete, the deleted game's files
and index entry actually gone from disk, a second delete 404ing, the admin
routes invisible when ADMIN_SECRET is unset, and a 3-player table refusing
a 4th player and a size of 5 refused at the door.

Also carries the TODO items raised on 2026-08-21: the lobby offering no
game parameters (the floor bug within it now fixed, the form still
missing), and the four optionalRules — of which only reducedVisibility and
emergencyToolbox are read by anything, while sisterTrains and
employeeRotation are declared, defaulted, and consulted nowhere.
This commit is contained in:
Jesse
2026-08-21 14:53:53 -04:00
parent 62b6ed7e1b
commit 2fbfe11977
13 changed files with 561 additions and 59 deletions
+129 -4
View File
@@ -27,8 +27,11 @@ import type { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import {
appendTiming,
deleteGame,
deleteLobby,
gameDir,
readIndex,
removeIndexEntry,
upsertIndexEntry,
writeGame,
writeLobby,
@@ -39,6 +42,7 @@ import type { GameSession, Push } from './session.ts';
import {
createLobby,
freshGameCode,
playerCountAllowed,
joinLobby,
reassignHost,
setBotSeat,
@@ -58,6 +62,14 @@ export type ServerOptions = {
dataDir: string;
/** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */
engineVersion: string;
/**
* Gates the administrative routes — listing, exporting and deleting games — and is DELIBERATELY
* not the join secret. Every player holds that one, so gating a delete with it would let anyone
* at the table destroy anyone else's game. This is held by whoever runs the server and nobody
* else. When it is unset the admin routes do not exist at all (404, the same answer as any other
* unknown path), so a server that was never given one cannot be administered by guessing.
*/
adminSecret?: string | undefined;
/** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */
initialGames: Map<string, GameSession>;
initialLobbies: Map<string, Lobby>;
@@ -212,24 +224,137 @@ export function startServer(opts: ServerOptions): void {
* is what the client is about to offer the player anyway. It reveals no game and no seat.
*/
if (url.pathname === '/api/health' && req.method === 'GET') {
sendJson(res, 200, { ok: true, service: 'station-master', engineVersion: opts.engineVersion });
// `summary()` rather than `exportSave()`: this is polled on a timer, and the save copies
// every intent of every game to answer a question about none of them.
let active = 0;
for (const g of games.values()) if (g.summary().status === 'active') active++;
sendJson(res, 200, {
ok: true,
service: 'station-master',
engineVersion: opts.engineVersion,
games: { active, lobby: lobbies.size },
});
return;
}
// -- Administration: listing, exporting and deleting games ------------------------------
if (url.pathname === '/api/games' || url.pathname.startsWith('/api/games/')) {
// Unset means the routes are not here — indistinguishable from any other unknown path, so
// nothing advertises an administrative surface to someone probing for one.
if (!opts.adminSecret) {
await serveStatic(opts.distDir, url.pathname, res);
return;
}
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
sendJson(res, 403, { error: 'bad or missing admin secret' });
return;
}
const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode]));
if (url.pathname === '/api/games' && req.method === 'GET') {
const running = [...games.entries()].map(([gameId, g]) => ({
gameId,
gameCode: codes.get(gameId) ?? null,
state: 'running' as const,
...g.summary(),
}));
// A lobby has no game to summarize yet — it is reported as what it is, so an
// administrator sees a table that never started rather than nothing at all.
const waiting = [...lobbies.values()].map((l) => ({
gameId: l.gameId,
gameCode: l.gameCode,
state: 'lobby' as const,
playerCount: l.seats.length,
playerNames: l.seats.map((seat) =>
seat === null ? '(empty)' : seat.kind === 'bot' ? 'Bot' : seat.displayName,
),
createdAt: l.createdAt,
}));
sendJson(res, 200, { games: [...running, ...waiting] });
return;
}
const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname);
const gameId = match?.[1];
if (!gameId) {
sendJson(res, 404, { error: 'no such route' });
return;
}
if (match?.[2] && req.method === 'GET') {
const session = games.get(gameId);
if (!session) {
sendJson(res, 404, { error: 'no such game' });
return;
}
sendJson(res, 200, { gameCode: codes.get(gameId) ?? null, save: session.exportSave() });
return;
}
if (req.method === 'DELETE') {
const session = games.get(gameId);
const lobby = lobbies.get(gameId);
if (!session && !lobby) {
sendJson(res, 404, { error: 'no such game' });
return;
}
// The save goes back with the deletion, so a game can never be destroyed without its
// record being handed to whoever destroyed it — the intents ARE the game (D5), so this
// is the whole thing, replayable later, not a summary of it.
const save = session?.exportSave() ?? null;
// Everyone watching is told the game is gone before its files are, rather than being
// left on a stream that will never push again.
for (const [, watcher] of gameConnections.get(gameId) ?? []) watcher.end();
gameConnections.delete(gameId);
for (const [, watcher] of lobbyConnections.get(gameId) ?? []) watcher.end();
lobbyConnections.delete(gameId);
games.delete(gameId);
lobbies.delete(gameId);
gameEventIds.delete(gameId);
const code = lobby?.gameCode ?? codes.get(gameId);
if (code) gameCodes.delete(code);
for (const [token, ps] of [...sessions]) if (ps.gameId === gameId) sessions.delete(token);
await removeIndexEntry(opts.dataDir, gameId);
await deleteGame(opts.dataDir, gameId);
sendJson(res, 200, { ok: true, gameCode: code ?? null, save });
return;
}
sendJson(res, 405, { error: 'method not allowed' });
return;
}
// -- Lobby: creating and joining (the door — join-secret gated) --------------------------
if (url.pathname === '/api/lobby/create' && req.method === 'POST') {
const body = (await readJson(req)) as { secret?: string; config?: GameConfig; displayName?: string };
const body = (await readJson(req)) as {
secret?: string;
config?: GameConfig;
displayName?: string;
players?: number;
};
if (body.secret !== opts.joinSecret) {
sendJson(res, 403, { error: 'bad or missing secret' });
return;
}
if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
sendJson(res, 400, { error: 'expected { secret, config, displayName }' });
sendJson(res, 400, { error: 'expected { secret, config, displayName, players }' });
return;
}
// The table size is the host's to choose and is fixed from here on, so it is validated at
// the door rather than at Start — `createLobby` builds the seats array from it.
const players = body.players ?? 0;
if (!Number.isInteger(players) || !playerCountAllowed(body.config.mode, players)) {
sendJson(res, 400, { error: 'BAD_PLAYER_COUNT' });
return;
}
const gameCode = freshGameCode((code) => gameCodes.has(code));
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode);
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players);
await persistLobby(lobby);
await persistSession(session);
sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player });
+16
View File
@@ -20,6 +20,13 @@ import type { Lobby, PlayerSession } from './lobby.ts';
const port = Number(process.env['PORT'] ?? 8081);
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0';
const joinSecret = process.env['JOIN_SECRET'];
/**
* Optional, unlike `JOIN_SECRET`: a server with no administrator is a perfectly good server, and
* refusing to boot without one would break every existing deployment and every dev run. Unset
* simply means the admin routes are not there (`http.ts`), which is the safe default — the
* capability has to be granted, never merely left ungated.
*/
const adminSecret = process.env['ADMIN_SECRET'];
const distDir = resolve(process.env['DIST_DIR'] ?? 'dist');
const dataDir = resolve(process.env['DATA_DIR'] ?? 'data');
@@ -38,6 +45,11 @@ const initialLobbies = new Map<string, Lobby>();
const initialSessions = new Map<string, PlayerSession>();
const index = await readIndex(dataDir);
// Said before the loop, not after it: replaying is the reason a restart pauses before the port
// opens, and a log that only reports each game once it is done gives no warning of how much is
// still to come.
const resumable = index.filter((e) => e.status !== 'finished').length;
if (resumable > 0) console.log(`Resuming ${resumable} saved game(s)…`);
for (const entry of index) {
const sessions = await readSessions(dataDir, entry.gameId);
for (const s of sessions) initialSessions.set(s.token, s);
@@ -69,6 +81,7 @@ startServer({
port,
bindAddress,
joinSecret,
adminSecret,
distDir,
dataDir,
engineVersion,
@@ -80,3 +93,6 @@ console.log(
`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` +
`${initialGames.size} game(s) and ${initialLobbies.size} lobby(ies) resumed.`,
);
if (!adminSecret) {
console.log('ADMIN_SECRET is unset — the /api/games administration routes are disabled.');
}
+40 -11
View File
@@ -81,16 +81,40 @@ export function playerCountAllowed(mode: GameConfig['mode'], count: number): boo
}
/** The creating player is the host and takes seat 0 (`lobby-and-sessions.md` §2). */
export function createLobby(config: GameConfig, hostDisplayName: string, gameCode: string): CreateResult {
/**
* THE TABLE SIZE IS FIXED WHEN THE GAME IS CREATED, and `seats.length` is it.
*
* The host says how many are playing, so the seats array is built at full length with the host in
* chair 0 and the rest empty. Nothing ever grows or shrinks it, which is what makes a gap
* impossible to express rather than merely illegal — and that matters more than it looks: seats
* used to be appended as people joined, so a bot dropped into a later chair padded the array with
* a hole that silently blocked Start. It also removes any need to compact the seats at
* `Lobby.Start`, and compaction would have shifted the `player` index every `PlayerSession`
* already carries (`joinLobby` stamps it at join time, and `/api/stream` and `/api/intent` route
* by it) — quietly handing a player somebody else's railroad.
*
* Knowing the count this early has one more consequence, and it is a bug fix: the config's
* `minCombinedRevenue` is derived from the player count, and the lobby previously had to guess it
* as 4 before anyone had sat down.
*/
export function createLobby(
config: GameConfig,
hostDisplayName: string,
gameCode: string,
players: number,
): CreateResult {
const gameId = randomUUID();
const token = randomUUID();
const session: PlayerSession = { token, gameId, player: 0, displayName: hostDisplayName };
const seats: LobbySeat[] = Array.from({ length: players }, (_, i) =>
i === 0 ? { kind: 'human', token, displayName: hostDisplayName } : null,
);
const lobby: Lobby = {
gameId,
gameCode,
hostToken: token,
config,
seats: [{ kind: 'human', token, displayName: hostDisplayName }],
seats,
joinOrder: [token],
createdAt: Date.now(),
};
@@ -103,10 +127,10 @@ export function createLobby(config: GameConfig, hostDisplayName: string, gameCod
* play in, but the running count is checked against `playerCountAllowed` at every join too, so a
* lobby can never grow the seats array past what could legally start). */
export function joinLobby(lobby: Lobby, displayName: string): JoinResult {
const cap = lobby.config.mode === 'solitaire' ? 1 : 4;
const empty = lobby.seats.findIndex((s) => s === null);
const seatIndex = empty >= 0 ? empty : lobby.seats.length;
if (seatIndex >= cap) return { ok: false, code: 'LOBBY_FULL' };
// The table was sized at creation, so joining takes an empty chair or none at all — there is no
// longer an "append another seat" path for a late arrival to grow the game through.
const seatIndex = lobby.seats.findIndex((s) => s === null);
if (seatIndex < 0) return { ok: false, code: 'LOBBY_FULL' };
const token = randomUUID();
const session: PlayerSession = { token, gameId: lobby.gameId, player: seatIndex, displayName };
@@ -124,7 +148,9 @@ export function joinLobby(lobby: Lobby, displayName: string): JoinResult {
* human seat; the host removes a person by them leaving, not by overwriting their seat. */
export function setBotSeat(lobby: Lobby, seat: PlayerIndex, filled: boolean): Lobby {
const seats = [...lobby.seats];
while (seats.length <= seat) seats.push(null);
// No padding: a seat outside the table the host chose is not a seat, and inventing one is how
// the old array grew holes in it.
if (seat < 0 || seat >= seats.length) return lobby;
if (filled) {
if (seats[seat] !== null) return lobby;
seats[seat] = { kind: 'bot' };
@@ -157,11 +183,14 @@ export function reassignHost(lobby: Lobby, departingToken: string): Lobby {
*/
export function startLobby(lobby: Lobby, callerToken: string): StartResult {
if (callerToken !== lobby.hostToken) return { ok: false, code: 'NOT_HOST' };
const filled = lobby.seats.filter((s) => s !== null);
if (filled.length !== lobby.seats.length || !playerCountAllowed(lobby.config.mode, filled.length)) {
// Every chair at the table must be taken. The size itself was validated at creation and cannot
// have moved since, so this is only ever waiting on the last empty seat to fill.
if (lobby.seats.some((s) => s === null) || !playerCountAllowed(lobby.config.mode, lobby.seats.length)) {
return { ok: false, code: 'BAD_PLAYER_COUNT' };
}
const playerNames = filled.map((s) => (s!.kind === 'human' ? s.displayName : 'Bot'));
const botSeats = filled.flatMap((s, i) => (s!.kind === 'bot' ? [i as PlayerIndex] : []));
// Seat index IS player index — no compaction, because there is nothing to compact past.
const taken = lobby.seats as Exclude<LobbySeat, null>[];
const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : 'Bot'));
const botSeats = taken.flatMap((s, i) => (s.kind === 'bot' ? [i as PlayerIndex] : []));
return { ok: true, playerNames, botSeats };
}
+19 -1
View File
@@ -11,7 +11,7 @@
* the measured scale (~350 intents, a few hundred bytes per game) there is nothing to optimize yet.
*/
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { SavedGame, TurnTiming } from './session.ts';
import type { Lobby, PlayerSession } from './lobby.ts';
@@ -111,6 +111,24 @@ export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry):
await writeIndex(dataDir, entries);
}
/**
* Removes a game from the index. Paired with `deleteGame` — the directory holds the game, the
* index says the game exists, and a delete that did one without the other would either resurrect
* it on the next boot or leave `index.json` pointing at nothing.
*/
export async function removeIndexEntry(dataDir: string, gameId: string): Promise<void> {
const entries = await readIndex(dataDir);
await writeIndex(
dataDir,
entries.filter((e) => e.gameId !== gameId),
);
}
/** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */
export async function deleteGame(dataDir: string, gameId: string): Promise<void> {
await rm(gameDir(dataDir, gameId), { recursive: true, force: true });
}
export async function writeLobby(dataDir: string, lobby: Lobby): Promise<void> {
const dir = gameDir(dataDir, lobby.gameId);
await mkdir(dir, { recursive: true });
+62 -2
View File
@@ -82,6 +82,33 @@ export type SavedGame = {
* guessing from a display name rather than reading a fact.
*/
botSeats: PlayerIndex[];
/**
* Wall-clock of the last accepted intent, so "has this game stalled?" survives a restart.
*
* Optional because it postdates the format, and defaulted to `createdAt` when absent — a game
* whose last move is unrecorded reads as untouched since it began, which is the honest answer
* rather than a fabricated one. Kept OUT of `history`, like the turn timings and for the same
* reason: a replay must reproduce a game from decisions alone, and wall-clock is not a decision.
*/
lastMoveAt?: number;
};
/** What an administrator needs to see about a game without replaying it themselves. */
export type GameSummary = {
playerCount: number;
playerNames: string[];
botSeats: PlayerIndex[];
status: 'active' | 'finished';
createdAt: number;
lastMoveAt: number;
day: number;
stage: number;
phase: string;
/**
* Whose move it is, or `null` — which is not an error state: the Mainline Phase runs itself, and
* a finished game waits on nobody.
*/
waitingOn: { seat: PlayerIndex; name: string } | null;
};
export type IntentResult =
@@ -96,6 +123,12 @@ export type GameSession = {
intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult;
/** Everything needed to persist this game and, later, rebuild it via `resumeSession`. */
exportSave(): SavedGame;
/**
* A cheap description of where this game has got to. Deliberately does not copy `history` the
* way `exportSave` must — the health check polls this on a timer, and an administrator listing
* games wants the state of each, not a copy of every intent in all of them.
*/
summary(): GameSummary;
};
type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number };
@@ -105,7 +138,9 @@ function buildSession(
playerNames: string[],
createdAt: number,
botSeats: Set<PlayerIndex>,
lastMoveAtInit: number,
): GameSession {
let lastMoveAt = lastMoveAtInit;
const lastSeq = new Map<PlayerIndex, number>();
const lastFrame = new Map<PlayerIndex, Frame>();
const sentLines = new Map<PlayerIndex, number>();
@@ -229,6 +264,7 @@ function buildSession(
if (!applied) return { accepted: false, code: 'REJECTED' };
lastSeq.set(seat, seq);
lastMoveAt = Date.now();
const timing = settleTiming();
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism
// diffs against whatever was last sent, so it captures the bots' moves along with the human's
@@ -246,6 +282,23 @@ function buildSession(
status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt,
botSeats: [...botSeats],
lastMoveAt,
};
},
summary() {
const actor = currentActor(game);
return {
playerCount: playerNames.length,
playerNames: [...playerNames],
botSeats: [...botSeats],
status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt,
lastMoveAt,
day: game.state.clock.day,
stage: game.state.clock.stage,
phase: game.state.clock.phase,
waitingOn: actor === null ? null : { seat: actor, name: playerNames[actor] ?? `Seat ${actor}` },
};
},
};
@@ -257,7 +310,8 @@ export function createSession(
playerNames: string[],
botSeats: PlayerIndex[] = [],
): GameSession {
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now(), new Set(botSeats));
const now = Date.now();
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, now, new Set(botSeats), now);
}
/**
@@ -267,5 +321,11 @@ export function createSession(
*/
export function resumeSession(saved: SavedGame): GameSession {
const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
return buildSession(game, saved.playerNames, saved.createdAt, new Set(saved.botSeats));
return buildSession(
game,
saved.playerNames,
saved.createdAt,
new Set(saved.botSeats),
saved.lastMoveAt ?? saved.createdAt,
);
}
+15 -10
View File
@@ -68,16 +68,15 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
$('lb-gamecode').textContent = `— code ${lobby.gameCode}`;
const isHost = lobby.hostToken === token;
const cap = lobby.config.mode === 'solitaire' ? 1 : 4;
let html = '';
for (let seat = 0; seat < cap; seat++) {
for (let seat = 0; seat < lobby.seats.length; seat++) {
const occupant = lobby.seats[seat] ?? null;
const isYou = occupant?.kind === 'human' && occupant.token === token;
const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken;
const who =
occupant === null
? '<span class="dim">— empty —</span>'
? '<span class="dim">— waiting —</span>'
: occupant.kind === 'bot'
? 'Bot'
: `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`;
@@ -97,16 +96,14 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: false });
}
const filled = lobby.seats.filter((s) => s !== null).length;
const noGaps = filled === lobby.seats.length;
const legalCount = lobby.config.mode === 'solitaire' ? filled === 1 : filled >= 2 && filled <= 4;
const waiting = lobby.seats.filter((s) => s === null).length;
const startBtn = $<HTMLButtonElement>('lb-start');
startBtn.hidden = !isHost;
startBtn.disabled = !(noGaps && legalCount);
startBtn.disabled = waiting > 0;
$('lb-start-note').textContent = isHost
? noGaps && legalCount
? waiting === 0
? ''
: 'Needs 2–4 seated players (human or bot), no empty seats in between.'
: `Waiting on ${waiting} more ${waiting === 1 ? 'player' : 'players'} — add a bot to any empty chair to start now.`
: 'Waiting for the host to start the game.';
startBtn.onclick = () => {
void postJson('/api/lobby/start', { token }).then(({ status, body }) => {
@@ -138,7 +135,15 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
setError('lb-create-err', 'enter a display name first');
return;
}
void postJson('/api/lobby/create', { secret: secret(), config: defaultMultiplayerConfig(mode), displayName }).then(
const players = Number($<HTMLSelectElement>('lb-players').value) || 4;
// The real seat count reaches `defaultMultiplayerConfig`, so the combined-Revenue floor is
// sized for the table actually being played rather than for an assumed four.
void postJson('/api/lobby/create', {
secret: secret(),
config: defaultMultiplayerConfig(mode, players),
displayName,
players,
}).then(
({ status, body }) => {
if (status !== 200) {
setError('lb-create-err', String(body['error'] ?? 'could not create the game'));
+8
View File
@@ -241,6 +241,14 @@ ul.blocked li{padding:2px 0}
<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>
<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>