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:
+15
-10
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user