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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user