diff --git a/AGENTS.md b/AGENTS.md index 9a87a02..42d85c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,12 +36,18 @@ them as you find them. Don't record work in the repo instead: no `TODO.md`, no ` `node_modules`, since `src/server/index.ts` has zero runtime dependencies (see the submodule's own `TODO.md` for why) and Node's native TypeScript type-stripping means it needs no compile step either. -- **The join secret (D14 in the game's `docs/architecture/multiplayer.md`) is this package's one - piece of state beyond the game data itself.** `startos/fileModels/store.json.ts` holds it, - seeded on install (`startos/init/generateJoinSecret.ts`) since the server refuses to start - without one, and rotated by the **Get Join Secret** action - (`startos/actions/getJoinSecret.ts`) — the same action handles first retrieval and rotation, per - `recipe-admin-credentials.md`. +- **Two secrets in `store.json` are this package's only state beyond the game data itself.** + `startos/init/generateSecrets.ts` seeds both on install. `joinSecret` (D14 in the game's + `docs/architecture/multiplayer.md`) is what players need, and the server refuses to start without + one; it is rotated by the **Get Join Secret** action, which handles first retrieval and rotation + in one, per `recipe-admin-credentials.md`. +- **`startos/serverApi.ts` is the only place this package talks to the game server, and the game + actions cannot work without it running.** Nothing about a live game is readable from disk: a save + is a seed plus a list of moves, so day/stage/phase and whose-turn-it-is only exist after a replay + through the engine — which is in the game repo, not here. Hence `allowedStatuses: 'only-running'` + on both game actions, and hence the health check fetching `/api/health` rather than probing a + port. There are two secrets in `store.json` for the same reason there are two on the server: + `adminSecret` gates `/api/games` and must never be the `joinSecret` every player holds. - **One volume, one interface, no dependencies.** Everything the server persists — `store.json` and every game's own save data — lives on the `data` volume; the single `ui` interface carries the browser client, the lobby/intent HTTP API, and the SSE game stream from diff --git a/README.md b/README.md index ad58c85..79010d9 100644 --- a/README.md +++ b/README.md @@ -52,19 +52,27 @@ One volume, `data`, mounted at `/data` (`DATA_DIR`). | --- | --- | | Volume names | `data` | | Mount points | `/data` | -| StartOS files | `store.json` — holds the join secret (see File Models) | -| Database | None — flat files. Each game is `games//game.json` (`{ engineVersion, seed, config, playerNames, history, status, createdAt }`) plus `turn-timings.json`, an `index.json` naming every game, and lobby state for games not yet started | +| StartOS files | `store.json` — holds the join and admin secrets (see File Models) | +| Database | None — flat files. The server holds any number of games at once: each is `games//game.json` (`{ engineVersion, seed, config, playerNames, history, status, createdAt, lastMoveAt, botSeats }`) plus `turn-timings.json`, with a top-level `index.json` naming every game and lobby state for those not yet started | ## File Models One StartOS-managed file, `store.json`, on the `data` volume. -- **`store.json`** — JSON, holds `joinSecret` (a string). Seeded on install with a random - 24-character value (`init/generateJoinSecret.ts`) — the daemon will not start without one, so - it is never left unset. Rewritten only by the **Get Join Secret** action, which generates a new - value on every run; the value is otherwise never touched, so a hand edit to it survives until - the action is next run. `main.ts` reads it reactively, so a rewrite restarts the daemon with the - new value automatically. +- **`store.json`** — JSON, holding two independent secrets, both seeded on install by + `init/generateSecrets.ts` and both read reactively by `main.ts`, so rewriting either restarts the + daemon with the new value. + - `joinSecret` — 24 characters. What players need to create or join a game. The daemon will not + start without one, so it is never left unset. Rewritten only by the **Get Join Secret** + action, which mints a new value on every run. + - `adminSecret` — 32 characters. Gates the server's `/api/games` routes, which the two game + actions use. **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. It is never shown to + a player and never leaves the package except as the daemon's `ADMIN_SECRET`. Backfilled on + update for a volume written before this field existed, and otherwise never rewritten. + + Neither key is re-asserted on start, so a hand edit to either survives until the relevant action + is next run. Everything else under `/data` — `games/`, `index.json`, lobby and session state — is the application's own persistence, written directly by the server process, not by a StartOS file @@ -83,13 +91,26 @@ different one in the same game. ## Installation and First-Run Flow -No setup wizard. On install, a join secret is generated and stored immediately (see File Models), +No setup wizard. On install, both secrets are generated and stored immediately (see File Models), and a critical task is raised pointing at **Get Join Secret** — the service starts and is usable the moment the daemon is healthy, but a player cannot create or join a game until the join secret has been retrieved and shared with them. +**Updating the package ends every game in progress.** A save records the engine version that wrote +it and the server refuses to resume one recorded under different rules, logging the refusal and +leaving the file untouched. This is upstream's deliberate design, not something this package works +around: a move that was legal under the old rules may not be under the new ones. Let games finish +before updating, or accept losing them — rolling the package version back makes them loadable +again, since nothing is deleted. + ## Actions +Two of the three read or change the games on the server, and both are `only-running`: what they +report exists only inside the live server process. A save is a seed plus a list of moves, so +"whose turn is it" is answerable only by replaying the game through the engine — which lives in +the game repo, not in this package. The server has already done that work and is asked for the +answer. + - **Get Join Secret** (`get-join-secret`) — run this any time you want to read the current join secret, or to invalidate it and issue a new one. Every run **generates a fresh secret, overwrites the stored one, and restarts the daemon** with it — there is no read-only mode. @@ -97,6 +118,21 @@ has been retrieved and shared with them. the lobby door, not an in-progress session); it only invalidates the old value for anyone who has not yet joined or created a game. Completes in a few seconds, safe to repeat. +- **Games in Progress** (`games-in-progress`) — read-only, changes nothing, safe to run at any + time. Lists every game and lobby on the server with its code, players, Day/Stage/phase, who it + is waiting on, when it started and when it last moved. Run it to find a game that has stalled — + a `Last move` days old with a named player under `Waiting on` is someone who is not coming back. + Returns quickly; the server answers from memory. + +- **Manage Game** (`manage-game`) — pick a game from a dropdown built live from the server, then + either **Export** it (returns the complete save as copyable text and changes nothing) or **End** + it (deletes it from the server, disconnects anyone still watching, and removes its files and + index entry). **Ending cannot be undone and is not idempotent** — a second attempt reports that + the game no longer exists. It always returns the deleted game's save, so nothing is destroyed + without being handed back first; that text is the only remaining copy, so keep it if the game is + worth replaying. This is the only way a game ends other than being played to a finish: an + abandoned game otherwise stays active and is resumed on every restart indefinitely. + ## Tasks - **Get the join secret to share with players** — raised on install, severity `critical`. Points @@ -105,9 +141,18 @@ has been retrieved and shared with them. ## Health Checks -- **Multiplayer Server** — `checkPortListening` against the daemon's port. "Not ready" means the - process has not yet bound its port; on a normal start this clears within a second or two; on a - cold container start it briefly reflects the daemon starting up. +- **Multiplayer Server** — fetches the server's own `/api/health` and reports what it says: + *"Multiplayer server is ready — 3 games in progress"*, with *", 1 waiting to start"* appended + only when a lobby exists, and *"no games in progress"* on an idle server. So the check that + proves the server is answering also says how much is going on. + + A server that does not answer is reported as **starting, never failed**. The server replays its + saved games before binding its port, so a boot legitimately looks like nothing is listening, and + calling that a failure would make an ordinary restart look like a crash. Replaying measures + around 100 ms per game and only unfinished games are replayed, so in practice this window is a + fraction of a second. A check stuck on "starting" for much longer means the daemon is failing to + come up — read the service logs, where a refused resume names the game and the version that + wrote it. ## Backups and Restore @@ -148,11 +193,14 @@ file_models: startos_managed_env_vars: - DATA_DIR - JOIN_SECRET + - ADMIN_SECRET dependencies: none interfaces: ui: { type: ui, port: 8081 } actions: - get-join-secret + - games-in-progress + - manage-game tasks: - { action: get-join-secret, severity: critical } health_checks: diff --git a/instructions.md b/instructions.md index 282e88d..34baba0 100644 --- a/instructions.md +++ b/instructions.md @@ -41,3 +41,27 @@ Run this whenever you need the current join secret again, or want to shut out an holding an old one. Every run replaces the secret and immediately puts the new one into effect — players already seated in a game are unaffected, but the old secret stops working for creating or joining games from that point on. + +### Games in Progress + +Shows every game on the server: who is playing, which Day and Stage they have reached, and whose +turn it is. It changes nothing, so run it whenever you are curious. It is also how you spot a game +nobody is coming back to — one whose last move was days ago, still waiting on a particular player. + +### Manage Game + +Pick a game, then either save a copy of it or end it. + +**Export** hands you the whole game as text and leaves it running. Save that text as a `.json` +file and you can open it in the replay viewer later to watch the game back. + +**End the game** deletes it and disconnects anyone still watching. This is the only way to clear +a game that will never be finished — otherwise it sits there, and comes back every time the +service restarts. It cannot be undone, so the game's save is handed back to you when it happens: +that text is the only copy left, so keep it if the game was worth watching. + +> One thing to know before you update this service: **games in progress do not survive an +> update.** The rules can change between versions, so a half-played game recorded under the old +> ones is refused rather than resumed under rules it was not played by. Nothing is deleted, and +> going back to the previous version makes them playable again — but the safe habit is to finish +> games before updating. diff --git a/startos/actions/gamesInProgress.ts b/startos/actions/gamesInProgress.ts new file mode 100644 index 0000000..b9bb8df --- /dev/null +++ b/startos/actions/gamesInProgress.ts @@ -0,0 +1,89 @@ +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { listGames } from '../serverApi' +import type { GameRow } from '../serverApi' + +/** "3 Aug 2026, 14:32" — a timestamp a person reads, not an epoch they decode. */ +function when(ms: number): string { + return new Date(ms).toLocaleString('en-GB', { + dateStyle: 'medium', + timeStyle: 'short', + }) +} + +function describe(g: GameRow): string { + const who = g.playerNames.join(', ') + if (g.state === 'lobby') { + return [ + i18n('Waiting to start — nobody has played a turn yet.'), + `${i18n('Seats')} (${g.playerCount}): ${who}`, + `${i18n('Created')}: ${when(g.createdAt)}`, + ].join('\n') + } + const waiting = + g.status === 'finished' + ? i18n('Finished — this game is over and waits on nobody.') + : g.waitingOn + ? `${i18n('Waiting on')}: ${g.waitingOn.name} (${i18n('seat')} ${g.waitingOn.seat})` + : `${i18n('Waiting on')}: ${i18n('nobody — the Mainline Phase is running itself')}` + return [ + `${i18n('Players')} (${g.playerCount}): ${who}`, + `${i18n('Day')} ${g.day}, ${i18n('Stage')} ${g.stage} — ${g.phase}`, + waiting, + `${i18n('Started')}: ${when(g.createdAt)}`, + `${i18n('Last move')}: ${g.lastMoveAt === undefined ? i18n('unknown') : when(g.lastMoveAt)}`, + ].join('\n') +} + +/** + * Read-only, and separate from Manage Game on purpose: this is the one you run without thinking, + * so it must not be the one that can delete anything. + * + * `only-running` because none of this exists on disk in readable form. A save is the seed plus the + * intents, so "whose turn is it" is only answerable by replaying the game through the engine — + * which lives in the game repo, not in this package. The running server has already done that work + * and is asked for the answer. + */ +export const gamesInProgress = sdk.Action.withoutInput( + 'games-in-progress', + + async () => ({ + name: i18n('Games in Progress'), + description: i18n( + 'List every game and lobby on this server, and where each one has got to', + ), + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }), + + async ({ effects }) => { + const games = await listGames(effects) + if (games.length === 0) { + return { + version: '1', + title: i18n('Games in Progress'), + message: i18n('There are no games on this server.'), + result: null, + } + } + return { + version: '1', + title: i18n('Games in Progress'), + message: `${games.length} ${i18n('game(s) on this server. Use Manage Game to save one for later, or to end it.')}`, + result: { + type: 'group', + value: games.map((g) => ({ + type: 'single' as const, + name: g.gameCode ?? g.gameId, + description: null, + value: describe(g), + masked: false, + copyable: false, + qr: false, + })), + }, + } + }, +) diff --git a/startos/actions/getJoinSecret.ts b/startos/actions/getJoinSecret.ts index 5ca4f58..010e69e 100644 --- a/startos/actions/getJoinSecret.ts +++ b/startos/actions/getJoinSecret.ts @@ -18,7 +18,9 @@ export const getJoinSecret = sdk.Action.withoutInput( async () => ({ name: i18n('Get Join Secret'), - description: i18n('Retrieve or rotate the secret players need to create or join a game'), + description: i18n( + 'Retrieve or rotate the secret players need to create or join a game', + ), warning: null, allowedStatuses: 'any', group: null, @@ -26,20 +28,21 @@ export const getJoinSecret = sdk.Action.withoutInput( }), async ({ effects }) => { - const joinSecret = utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 24 }) + const joinSecret = utils.getDefaultString({ + charset: 'a-z,A-Z,0-9', + len: 24, + }) await storeJson.merge(effects, { joinSecret }) return { version: '1', - title: 'Join Secret', - message: - 'Share this with anyone you want to be able to create or join a game on this server — ' + - "it doesn't identify a person or a seat, just who's allowed at the lobby door. Running " + - 'this action again generates a new one and restarts the server with it; anyone already ' + - 'seated in a game keeps playing, but the old secret stops working for new games and joins.', + title: i18n('Join Secret'), + message: i18n( + "Share this with anyone you want to be able to create or join a game on this server. It doesn't identify a person or a seat, just who's allowed at the lobby door. Running this action again generates a new one and restarts the server with it; anyone already seated in a game keeps playing, but the old secret stops working for new games and joins.", + ), result: { type: 'single', - name: 'Join Secret', + name: i18n('Join Secret'), description: null, value: joinSecret, masked: true, diff --git a/startos/actions/index.ts b/startos/actions/index.ts index bf57d09..eae622b 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -1,4 +1,9 @@ import { sdk } from '../sdk' +import { gamesInProgress } from './gamesInProgress' import { getJoinSecret } from './getJoinSecret' +import { manageGame } from './manageGame' -export const actions = sdk.Actions.of().addAction(getJoinSecret) +export const actions = sdk.Actions.of() + .addAction(getJoinSecret) + .addAction(gamesInProgress) + .addAction(manageGame) diff --git a/startos/actions/manageGame.ts b/startos/actions/manageGame.ts new file mode 100644 index 0000000..91f0965 --- /dev/null +++ b/startos/actions/manageGame.ts @@ -0,0 +1,125 @@ +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { deleteGame, exportGame, listGames } from '../serverApi' + +const { InputSpec, Value } = sdk + +/** + * Pick a game, then keep it or end it. + * + * The dropdown is built when the form opens rather than being a fixed list — `inputSpec` takes a + * function, so the options are whatever is on the server at that moment, labelled well enough to + * tell two games apart without opening Games in Progress first. + * + * Ending is the only way a game finishes other than being played out. Until this existed an + * abandoned game stayed active in the index and was faithfully resumed on every boot, forever, + * with no route, player action or resignation able to stop it. + */ +export const manageGame = sdk.Action.withInput( + 'manage-game', + + async () => ({ + name: i18n('Manage Game'), + description: i18n( + 'Save a game for later, or end one that will never finish', + ), + warning: i18n( + 'Ending a game cannot be undone. Its save is returned, so it can still be replayed.', + ), + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }), + + async ({ effects }) => { + const games = await listGames(effects) + const values: Record = {} + for (const g of games) { + const name = g.gameCode ?? g.gameId + values[g.gameId] = + g.state === 'lobby' + ? `${name} — ${i18n('waiting to start')}, ${g.playerCount} ${i18n('seats')}` + : `${name} — ${g.playerCount} ${i18n('players')}, ${i18n('Day')} ${g.day} ${i18n('Stage')} ${g.stage}` + + (g.waitingOn ? `, ${i18n('waiting on')} ${g.waitingOn.name}` : '') + } + // A select needs a default drawn from its own options, and an empty server has none to offer — + // so it says so in the dropdown rather than rendering an empty one. + if (Object.keys(values).length === 0) + values['none'] = i18n('No games on this server') + + return InputSpec.of({ + gameId: Value.select({ + name: i18n('Game'), + description: i18n('Which game to act on'), + default: Object.keys(values)[0]!, + values, + }), + operation: Value.select({ + name: i18n('What to do'), + description: i18n( + 'Export changes nothing. Ending deletes the game from the server.', + ), + default: 'export', + values: { + export: i18n('Export — save a copy, leave the game running'), + end: i18n('End the game — delete it from the server'), + }, + }), + }) + }, + + // Nothing to pre-fill: which game you mean is the question being asked, and Export is already + // the default because it is the choice that cannot lose anything. + async () => null, + + async ({ effects, input }) => { + if (input.gameId === 'none') { + return { + version: '1', + title: i18n('Manage Game'), + message: i18n('There are no games on this server.'), + result: null, + } + } + + if (input.operation === 'export') { + const save = await exportGame(effects, input.gameId) + return { + version: '1', + title: i18n('Game Saved'), + message: i18n( + 'This is the whole game — its seed and every move made in it. Save it as a .json file and open it in the replay viewer to watch the game back. The game itself is untouched and still running.', + ), + result: { + type: 'single', + name: i18n('Save'), + description: null, + value: JSON.stringify(save), + masked: false, + copyable: true, + qr: false, + }, + } + } + + const { gameCode, save } = await deleteGame(effects, input.gameId) + return { + version: '1', + title: i18n('Game Ended'), + message: + `${gameCode ?? i18n('The game')} — ` + + i18n( + 'deleted from the server, and anyone still connected to it has been disconnected. Its save is below: keep it if you want to watch the game back, because this is the only copy left.', + ), + result: { + type: 'single', + name: i18n('Save'), + description: null, + value: JSON.stringify(save), + masked: false, + copyable: true, + qr: false, + }, + } + }, +) diff --git a/startos/backups.ts b/startos/backups.ts index 07e9c95..5993a34 100644 --- a/startos/backups.ts +++ b/startos/backups.ts @@ -2,7 +2,7 @@ import { sdk } from './sdk' // Everything worth keeping is on this one volume: every game's { engineVersion, seed, config, // history } and turn timings (src/server/persistence.ts in the game repo), plus this package's -// own store.json (the join secret). No database, no second volume — see deployment.md §1. -export const { createBackup, restoreInit } = sdk.setupBackups(async ({ effects }) => - sdk.Backups.ofVolumes('data'), +// own store.json (the join and admin secrets). No database, no second volume — see deployment.md §1. +export const { createBackup, restoreInit } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.ofVolumes('data'), ) diff --git a/startos/fileModels/store.json.ts b/startos/fileModels/store.json.ts index 758f8e4..4c299a3 100644 --- a/startos/fileModels/store.json.ts +++ b/startos/fileModels/store.json.ts @@ -5,10 +5,20 @@ import { sdk } from '../sdk' * `joinSecret` is D14's server-wide secret (`docs/architecture/multiplayer.md` in the game repo): * whoever holds it may create a game, and may join any created game that has not started. The * server refuses to start without one (`src/server/index.ts`), so `joinSecret` is seeded on - * install (`init/generateJoinSecret.ts`) and never left unset. + * install (`init/generateSecrets.ts`) and never left unset. */ const shape = z.object({ joinSecret: z.string().optional().catch(undefined), + /** + * Gates the server's `/api/games` administration routes, and is deliberately a SECOND secret. + * Every player holds `joinSecret`, so gating a delete with it would let anyone at the table + * destroy anyone else's game. This one belongs to whoever runs the box; it is never shown to a + * player and never leaves this package except as the daemon's `ADMIN_SECRET`. + */ + adminSecret: z.string().optional().catch(undefined), }) -export const storeJson = FileHelper.json({ base: sdk.volumes.data, subpath: 'store.json' }, shape) +export const storeJson = FileHelper.json( + { base: sdk.volumes.data, subpath: 'store.json' }, + shape, +) diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index 46cecd8..6320385 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -12,8 +12,58 @@ const dict = { // actions/getJoinSecret.ts 'Get Join Secret': 6, 'Retrieve or rotate the secret players need to create or join a game': 7, - // init/generateJoinSecret.ts + // init/generateSecrets.ts 'Get the join secret to share with players': 8, + // main.ts — the health check's game counts + 'Multiplayer server is ready': 9, + 'no games in progress': 10, + '1 game in progress': 11, + 'games in progress': 12, + '1 waiting to start': 13, + 'waiting to start': 14, + // actions/gamesInProgress.ts, actions/manageGame.ts, serverApi.ts + 'Games in Progress': 15, + 'List every game and lobby on this server, and where each one has got to': 16, + 'There are no games on this server.': 17, + 'game(s) on this server. Use Manage Game to save one for later, or to end it.': 18, + 'Waiting to start \u2014 nobody has played a turn yet.': 19, + Seats: 20, + Created: 21, + 'Finished \u2014 this game is over and waits on nobody.': 22, + 'Waiting on': 23, + seat: 24, + 'nobody \u2014 the Mainline Phase is running itself': 25, + Players: 26, + Day: 27, + Stage: 28, + Started: 29, + 'Last move': 30, + unknown: 31, + 'Manage Game': 32, + 'Save a game for later, or end one that will never finish': 33, + 'Ending a game cannot be undone. Its save is returned, so it can still be replayed.': 34, + seats: 35, + players: 36, + 'waiting on': 37, + 'No games on this server': 38, + Game: 39, + 'Which game to act on': 40, + 'What to do': 41, + 'Export changes nothing. Ending deletes the game from the server.': 42, + 'Export \u2014 save a copy, leave the game running': 43, + 'End the game \u2014 delete it from the server': 44, + 'Game Saved': 45, + 'This is the whole game \u2014 its seed and every move made in it. Save it as a .json file and open it in the replay viewer to watch the game back. The game itself is untouched and still running.': 46, + Save: 47, + 'Game Ended': 48, + 'The game': 49, + 'deleted from the server, and anyone still connected to it has been disconnected. Its save is below: keep it if you want to watch the game back, because this is the only copy left.': 50, + 'No admin secret is stored for this service, so its games cannot be managed.': 51, + 'Could not reach the game server': 52, + 'that game no longer exists': 53, + 'the server answered': 54, + 'Join Secret': 55, + "Share this with anyone you want to be able to create or join a game on this server. It doesn't identify a person or a seat, just who's allowed at the lobby door. Running this action again generates a new one and restarts the server with it; anyone already seated in a game keeps playing, but the old secret stops working for new games and joins.": 56, } as const /** diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index 9a47188..aa59eaa 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -11,6 +11,54 @@ export default { 6: 'Obtener el secreto de acceso', 7: 'Recuperar o rotar el secreto que los jugadores necesitan para crear o unirse a una partida', 8: 'Obtén el secreto de acceso para compartirlo con los jugadores', + 9: 'El servidor multijugador está listo', + 10: 'ninguna partida en curso', + 11: '1 partida en curso', + 12: 'partidas en curso', + 13: '1 esperando para empezar', + 14: 'esperando para empezar', + 15: 'Partidas en curso', + 16: 'Lista todas las partidas y salas de este servidor, y en qu\u00e9 punto est\u00e1 cada una', + 17: 'No hay partidas en este servidor.', + 18: 'partida(s) en este servidor. Usa Gestionar partida para guardar una o finalizarla.', + 19: 'Esperando para empezar: nadie ha jugado un turno todav\u00eda.', + 20: 'Asientos', + 21: 'Creada', + 22: 'Finalizada: esta partida ha terminado y no espera a nadie.', + 23: 'Esperando a', + 24: 'asiento', + 25: 'nadie: la Fase de V\u00eda Principal se ejecuta sola', + 26: 'Jugadores', + 27: 'D\u00eda', + 28: 'Etapa', + 29: 'Comenzada', + 30: '\u00daltimo movimiento', + 31: 'desconocido', + 32: 'Gestionar partida', + 33: 'Guarda una partida para m\u00e1s tarde, o finaliza una que nunca acabar\u00e1', + 34: 'Finalizar una partida no se puede deshacer. Se devuelve su guardado, as\u00ed que a\u00fan se puede reproducir.', + 35: 'asientos', + 36: 'jugadores', + 37: 'esperando a', + 38: 'No hay partidas en este servidor', + 39: 'Partida', + 40: 'Sobre qu\u00e9 partida actuar', + 41: 'Qu\u00e9 hacer', + 42: 'Exportar no cambia nada. Finalizar borra la partida del servidor.', + 43: 'Exportar: guarda una copia y deja la partida en marcha', + 44: 'Finalizar la partida: borrarla del servidor', + 45: 'Partida guardada', + 46: 'Esto es la partida entera: su semilla y todos los movimientos. Gu\u00e1rdalo como archivo .json y \u00e1brelo en el visor de repeticiones para verla. La partida en s\u00ed sigue intacta y en marcha.', + 47: 'Guardado', + 48: 'Partida finalizada', + 49: 'La partida', + 50: 'borrada del servidor, y quien siguiera conectado ha sido desconectado. Su guardado est\u00e1 abajo: cons\u00e9rvalo si quieres ver la partida, porque es la \u00fanica copia que queda.', + 51: 'No hay un secreto de administraci\u00f3n guardado para este servicio, as\u00ed que no se pueden gestionar sus partidas.', + 52: 'No se pudo contactar con el servidor de la partida', + 53: 'esa partida ya no existe', + 54: 'el servidor respondi\u00f3', + 55: 'Secreto de acceso', + 56: 'Comp\u00e1rtelo con quien quieras que pueda crear una partida o unirse a una en este servidor. No identifica a una persona ni un asiento, solo a qui\u00e9n se le permite entrar. Ejecutar esta acci\u00f3n de nuevo genera otro y reinicia el servidor con \u00e9l; quien ya est\u00e9 sentado en una partida sigue jugando, pero el secreto antiguo deja de servir para nuevas partidas y uniones.', }, de_DE: { 0: 'Starte Station Master!', @@ -22,6 +70,54 @@ export default { 6: 'Beitrittsgeheimnis abrufen', 7: 'Das Geheimnis abrufen oder erneuern, das Spieler zum Erstellen oder Beitreten einer Partie benötigen', 8: 'Hole das Beitrittsgeheimnis, um es mit Spielern zu teilen', + 9: 'Mehrspieler-Server ist bereit', + 10: 'keine Spiele laufen', + 11: '1 laufendes Spiel', + 12: 'laufende Spiele', + 13: '1 wartet auf den Start', + 14: 'warten auf den Start', + 15: 'Laufende Spiele', + 16: 'Listet jedes Spiel und jede Lobby auf diesem Server auf, samt Spielstand', + 17: 'Auf diesem Server gibt es keine Spiele.', + 18: 'Spiel(e) auf diesem Server. Mit "Spiel verwalten" eines sichern oder beenden.', + 19: 'Wartet auf den Start \u2014 noch hat niemand einen Zug gemacht.', + 20: 'Pl\u00e4tze', + 21: 'Erstellt', + 22: 'Beendet \u2014 dieses Spiel ist vorbei und wartet auf niemanden.', + 23: 'Wartet auf', + 24: 'Platz', + 25: 'niemanden \u2014 die Hauptstrecken-Phase l\u00e4uft von selbst', + 26: 'Spieler', + 27: 'Tag', + 28: 'Abschnitt', + 29: 'Begonnen', + 30: 'Letzter Zug', + 31: 'unbekannt', + 32: 'Spiel verwalten', + 33: 'Ein Spiel f\u00fcr sp\u00e4ter sichern oder eines beenden, das nie fertig wird', + 34: 'Ein Spiel zu beenden l\u00e4sst sich nicht r\u00fcckg\u00e4ngig machen. Sein Spielstand wird zur\u00fcckgegeben und bleibt abspielbar.', + 35: 'Pl\u00e4tze', + 36: 'Spieler', + 37: 'wartet auf', + 38: 'Keine Spiele auf diesem Server', + 39: 'Spiel', + 40: 'Auf welches Spiel sich das bezieht', + 41: 'Was zu tun ist', + 42: 'Exportieren \u00e4ndert nichts. Beenden l\u00f6scht das Spiel vom Server.', + 43: 'Exportieren \u2014 eine Kopie sichern, das Spiel weiterlaufen lassen', + 44: 'Spiel beenden \u2014 vom Server l\u00f6schen', + 45: 'Spiel gesichert', + 46: 'Das ist das ganze Spiel \u2014 sein Seed und jeder darin gemachte Zug. Als .json-Datei sichern und im Replay-Viewer \u00f6ffnen, um es anzusehen. Das Spiel selbst bleibt unber\u00fchrt und l\u00e4uft weiter.', + 47: 'Spielstand', + 48: 'Spiel beendet', + 49: 'Das Spiel', + 50: 'vom Server gel\u00f6scht, und wer noch verbunden war, wurde getrennt. Der Spielstand steht unten: aufheben, wenn das Spiel noch angesehen werden soll \u2014 es ist die einzige verbliebene Kopie.', + 51: 'F\u00fcr diesen Dienst ist kein Admin-Geheimnis gespeichert, seine Spiele lassen sich daher nicht verwalten.', + 52: 'Der Spielserver war nicht erreichbar', + 53: 'dieses Spiel gibt es nicht mehr', + 54: 'der Server antwortete mit', + 55: 'Beitrittsgeheimnis', + 56: 'Weitergeben an alle, die auf diesem Server ein Spiel erstellen oder einem beitreten sollen. Es benennt weder eine Person noch einen Platz, nur wer an der T\u00fcr eingelassen wird. Diese Aktion erneut auszuf\u00fchren erzeugt ein neues und startet den Server damit neu; wer bereits an einem Spiel sitzt, spielt weiter, doch das alte Geheimnis funktioniert f\u00fcr neue Spiele und Beitritte nicht mehr.', }, pl_PL: { 0: 'Uruchamianie Station Master!', @@ -33,6 +129,54 @@ export default { 6: 'Pobierz sekret dołączania', 7: 'Pobierz lub wymień sekret potrzebny graczom do tworzenia gier i dołączania do nich', 8: 'Pobierz sekret dołączania, aby udostępnić go graczom', + 9: 'Serwer wieloosobowy jest gotowy', + 10: 'brak gier w toku', + 11: '1 gra w toku', + 12: 'gry w toku', + 13: '1 czeka na rozpoczęcie', + 14: 'czekają na rozpoczęcie', + 15: 'Gry w toku', + 16: 'Wy\u015bwietla wszystkie gry i poczekalnie na tym serwerze oraz ich stan', + 17: 'Na tym serwerze nie ma \u017cadnych gier.', + 18: 'gra/gry na tym serwerze. U\u017cyj \u201eZarz\u0105dzaj gr\u0105\u201d, aby zapisa\u0107 lub zako\u0144czy\u0107 gr\u0119.', + 19: 'Czeka na rozpocz\u0119cie \u2014 nikt jeszcze nie wykona\u0142 ruchu.', + 20: 'Miejsca', + 21: 'Utworzono', + 22: 'Zako\u0144czona \u2014 ta gra dobieg\u0142a ko\u0144ca i na nikogo nie czeka.', + 23: 'Czeka na', + 24: 'miejsce', + 25: 'nikogo \u2014 Faza Magistrali wykonuje si\u0119 sama', + 26: 'Gracze', + 27: 'Dzie\u0144', + 28: 'Etap', + 29: 'Rozpocz\u0119to', + 30: 'Ostatni ruch', + 31: 'nieznany', + 32: 'Zarz\u0105dzaj gr\u0105', + 33: 'Zapisz gr\u0119 na p\u00f3\u017aniej albo zako\u0144cz tak\u0105, kt\u00f3ra nigdy si\u0119 nie sko\u0144czy', + 34: 'Zako\u0144czenia gry nie mo\u017cna cofn\u0105\u0107. Jej zapis zostaje zwr\u00f3cony, wi\u0119c nadal mo\u017cna j\u0105 odtworzy\u0107.', + 35: 'miejsc', + 36: 'graczy', + 37: 'czeka na', + 38: 'Brak gier na tym serwerze', + 39: 'Gra', + 40: 'Kt\u00f3rej gry dotyczy dzia\u0142anie', + 41: 'Co zrobi\u0107', + 42: 'Eksport niczego nie zmienia. Zako\u0144czenie usuwa gr\u0119 z serwera.', + 43: 'Eksportuj \u2014 zapisz kopi\u0119, zostaw gr\u0119 w toku', + 44: 'Zako\u0144cz gr\u0119 \u2014 usu\u0144 j\u0105 z serwera', + 45: 'Gra zapisana', + 46: 'To ca\u0142a gra \u2014 jej ziarno i ka\u017cdy wykonany ruch. Zapisz jako plik .json i otw\u00f3rz w przegl\u0105darce powt\u00f3rek, aby j\u0105 obejrze\u0107. Sama gra pozostaje nietkni\u0119ta i nadal trwa.', + 47: 'Zapis', + 48: 'Gra zako\u0144czona', + 49: 'Gra', + 50: 'usuni\u0119ta z serwera, a wszyscy nadal po\u0142\u0105czeni zostali roz\u0142\u0105czeni. Zapis znajduje si\u0119 poni\u017cej: zachowaj go, je\u015bli chcesz obejrze\u0107 gr\u0119, bo to jedyna pozosta\u0142a kopia.', + 51: 'Dla tej us\u0142ugi nie zapisano sekretu administratora, wi\u0119c nie mo\u017cna zarz\u0105dza\u0107 jej grami.', + 52: 'Nie uda\u0142o si\u0119 po\u0142\u0105czy\u0107 z serwerem gry', + 53: 'ta gra ju\u017c nie istnieje', + 54: 'serwer odpowiedzia\u0142', + 55: 'Sekret do\u0142\u0105czania', + 56: 'Udost\u0119pnij je ka\u017cdemu, kto ma m\u00f3c utworzy\u0107 gr\u0119 na tym serwerze lub do niej do\u0142\u0105czy\u0107. Nie wskazuje osoby ani miejsca \u2014 tylko to, kogo wpuszcza si\u0119 do poczekalni. Ponowne uruchomienie tej akcji tworzy nowy sekret i restartuje z nim serwer; kto ju\u017c siedzi przy grze, gra dalej, ale stary sekret przestaje dzia\u0142a\u0107 przy nowych grach i do\u0142\u0105czeniach.', }, fr_FR: { 0: 'Démarrage de Station Master !', @@ -44,5 +188,53 @@ export default { 6: 'Obtenir le secret de connexion', 7: 'Récupérer ou renouveler le secret dont les joueurs ont besoin pour créer une partie ou la rejoindre', 8: 'Obtenez le secret de connexion à partager avec les joueurs', + 9: 'Le serveur multijoueur est prêt', + 10: 'aucune partie en cours', + 11: '1 partie en cours', + 12: 'parties en cours', + 13: '1 en attente de démarrage', + 14: 'en attente de démarrage', + 15: 'Parties en cours', + 16: 'Liste toutes les parties et salons de ce serveur, et o\u00f9 en est chacun', + 17: "Il n'y a aucune partie sur ce serveur.", + 18: 'partie(s) sur ce serveur. Utilisez G\u00e9rer la partie pour en sauvegarder une ou y mettre fin.', + 19: "En attente de d\u00e9marrage \u2014 personne n'a encore jou\u00e9.", + 20: 'Places', + 21: 'Cr\u00e9\u00e9e', + 22: "Termin\u00e9e \u2014 cette partie est finie et n'attend personne.", + 23: 'En attente de', + 24: 'place', + 25: 'personne \u2014 la phase de ligne principale se d\u00e9roule seule', + 26: 'Joueurs', + 27: 'Jour', + 28: '\u00c9tape', + 29: 'Commenc\u00e9e', + 30: 'Dernier coup', + 31: 'inconnu', + 32: 'G\u00e9rer la partie', + 33: 'Sauvegarder une partie pour plus tard, ou mettre fin \u00e0 une partie qui ne finira jamais', + 34: 'Mettre fin \u00e0 une partie est irr\u00e9versible. Sa sauvegarde est renvoy\u00e9e et reste rejouable.', + 35: 'places', + 36: 'joueurs', + 37: 'en attente de', + 38: 'Aucune partie sur ce serveur', + 39: 'Partie', + 40: 'Sur quelle partie agir', + 41: 'Que faire', + 42: "L'export ne change rien. Mettre fin supprime la partie du serveur.", + 43: 'Exporter \u2014 sauvegarder une copie, laisser la partie en cours', + 44: 'Mettre fin \u00e0 la partie \u2014 la supprimer du serveur', + 45: 'Partie sauvegard\u00e9e', + 46: 'Ceci est la partie enti\u00e8re \u2014 sa graine et chaque coup jou\u00e9. Enregistrez-la en .json et ouvrez-la dans le lecteur de rejeu pour la revoir. La partie elle-m\u00eame est intacte et continue.', + 47: 'Sauvegarde', + 48: 'Partie termin\u00e9e', + 49: 'La partie', + 50: "supprim\u00e9e du serveur, et toute personne encore connect\u00e9e a \u00e9t\u00e9 d\u00e9connect\u00e9e. Sa sauvegarde est ci-dessous : conservez-la pour revoir la partie, car c'est la seule copie restante.", + 51: "Aucun secret d'administration n'est enregistr\u00e9 pour ce service ; ses parties ne peuvent donc pas \u00eatre g\u00e9r\u00e9es.", + 52: 'Impossible de joindre le serveur de jeu', + 53: "cette partie n'existe plus", + 54: 'le serveur a r\u00e9pondu', + 55: 'Secret de connexion', + 56: "Partagez-le avec toute personne qui doit pouvoir cr\u00e9er une partie sur ce serveur ou en rejoindre une. Il n'identifie ni une personne ni une place, seulement qui est admis \u00e0 la porte. Relancer cette action en g\u00e9n\u00e8re un nouveau et red\u00e9marre le serveur avec ; qui est d\u00e9j\u00e0 assis \u00e0 une partie continue de jouer, mais l'ancien secret cesse de fonctionner pour les nouvelles parties et les arriv\u00e9es.", }, } satisfies Record diff --git a/startos/init/generateJoinSecret.ts b/startos/init/generateJoinSecret.ts deleted file mode 100644 index 7989e2d..0000000 --- a/startos/init/generateJoinSecret.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { utils } from '@start9labs/start-sdk' -import { getJoinSecret } from '../actions/getJoinSecret' -import { i18n } from '../i18n' -import { sdk } from '../sdk' -import { storeJson } from '../fileModels/store.json' - -/** - * The server exits immediately if `JOIN_SECRET` is unset (`src/server/index.ts` in the game - * repo), so a value has to exist before main.ts's daemon ever starts — seeded here rather than - * left for the user to generate via the action first. - */ -export const seedJoinSecret = sdk.setupOnInit(async (effects, kind) => { - if (kind === 'install') { - await storeJson.merge(effects, { - joinSecret: utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 24 }), - }) - await sdk.action.createOwnTask(effects, getJoinSecret, 'critical', { - reason: i18n('Get the join secret to share with players'), - }) - } else { - // 'update' and 'restore' — repairs a corrupted store.json without touching an existing - // joinSecret. A restored volume already carries one (it lives inside the backed-up volume, - // see backups.ts), so there is nothing to seed. - await storeJson.merge(effects, {}) - } -}) diff --git a/startos/init/generateSecrets.ts b/startos/init/generateSecrets.ts new file mode 100644 index 0000000..4c037d7 --- /dev/null +++ b/startos/init/generateSecrets.ts @@ -0,0 +1,45 @@ +import { utils } from '@start9labs/start-sdk' +import { getJoinSecret } from '../actions/getJoinSecret' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { storeJson } from '../fileModels/store.json' + +/** + * The two server-wide secrets, both minted here so they exist before main.ts's daemon ever starts. + * + * `JOIN_SECRET` is not optional — the server exits immediately without one (`src/server/index.ts` + * in the game repo) — so it cannot be left for the user to generate via the action first. + * `ADMIN_SECRET` is optional to the server, which switches its `/api/games` routes off rather + * than open when it is missing; it is seeded anyway, because an administrator who cannot list or + * end a game has no way to clear an abandoned one. + */ +export const seedSecrets = sdk.setupOnInit(async (effects, kind) => { + if (kind === 'install') { + await storeJson.merge(effects, { + joinSecret: utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 24 }), + adminSecret: utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 32 }), + }) + await sdk.action.createOwnTask(effects, getJoinSecret, 'critical', { + reason: i18n('Get the join secret to share with players'), + }) + } else { + // 'update' and 'restore' — repairs a corrupted store.json without touching secrets that are + // already there. A restored volume carries both (they live inside the backed-up volume, see + // backups.ts), so there is nothing to seed. An UPDATE from a version that predates + // `adminSecret` is the one case that needs filling in, and `merge` cannot do it without + // overwriting, so it is handled explicitly below. + await storeJson.merge(effects, {}) + // Backfill for a volume written before this package had an admin secret. Guarded on absence + // rather than merged unconditionally: minting a new one on every start would invalidate the + // running daemon's own secret on each restart. + const existing = await storeJson.read((s) => s.adminSecret).once() + if (!existing) { + await storeJson.merge(effects, { + adminSecret: utils.getDefaultString({ + charset: 'a-z,A-Z,0-9', + len: 32, + }), + }) + } + } +}) diff --git a/startos/init/index.ts b/startos/init/index.ts index f5f5020..a95b5a7 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -4,12 +4,12 @@ import { setInterfaces } from '../interfaces' import { versionGraph } from '../versions' import { actions } from '../actions' import { restoreInit } from '../backups' -import { seedJoinSecret } from './generateJoinSecret' +import { seedSecrets } from './generateSecrets' export const init = sdk.setupInit( restoreInit, versionGraph, - seedJoinSecret, + seedSecrets, setInterfaces, setDependencies, actions, diff --git a/startos/main.ts b/startos/main.ts index 191d89b..1a191d9 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -2,6 +2,7 @@ import { i18n } from './i18n' import { sdk } from './sdk' import { uiPort, dataDir } from './utils' import { storeJson } from './fileModels/store.json' +import { fetchHealth } from './serverApi' export const main = sdk.setupMain(async ({ effects }) => { console.info(i18n('Starting Station Master!')) @@ -9,6 +10,7 @@ export const main = sdk.setupMain(async ({ effects }) => { // Reactive, field-scoped: rotating the join secret (getJoinSecret.ts) rewrites store.json, // which re-runs setupMain and restarts the daemon with the new value. const joinSecret = await storeJson.read((s) => s.joinSecret).const(effects) + const adminSecret = await storeJson.read((s) => s.adminSecret).const(effects) return sdk.Daemons.of(effects).addDaemon('server', { subcontainer: sdk.SubContainer.of( @@ -29,21 +31,55 @@ export const main = sdk.setupMain(async ({ effects }) => { // 0.0.0.0, ./dist relative to the Dockerfile's WORKDIR) — nothing here needs to differ // from them, so only what actually must be supplied is set explicitly. DATA_DIR: dataDir, - // generateJoinSecret.ts seeds this before main.ts ever runs, so it is never actually + // generateSecrets.ts seeds this before main.ts ever runs, so it is never actually // empty — the fallback only avoids threading `string | undefined` through `env`, which // wants `Record`. JOIN_SECRET: joinSecret ?? '', + // Unset leaves the server's /api/games routes switched off entirely rather than open, so + // an empty string here fails closed — see src/server/http.ts in the game repo. + ADMIN_SECRET: adminSecret ?? '', }, }, - // Health check, run on each polling interval. `checkPortListening` reports ready once the - // daemon binds `uiPort`; the 'ui' interface (interfaces.ts) exposes the same port. + /** + * Reports what the server is actually doing, not merely that it is up. + * + * `/api/health` carries the game counts, so the check that proves the server is answering can + * say how many tables are in play in the same breath — which is the thing an administrator + * glancing at the service page wants to know. + * + * A refusal is reported as `starting`, never `failure`: the server replays its saved games + * before it binds the port, so a boot legitimately looks like nothing is listening. Calling + * that a failure would make an ordinary restart look like a crash. + */ ready: { display: i18n('Multiplayer Server'), - fn: () => - sdk.healthCheck.checkPortListening(effects, uiPort, { - successMessage: i18n('The multiplayer server is ready'), - errorMessage: i18n('The multiplayer server is not ready'), - }), + fn: async () => { + const counts = await fetchHealth() + if (counts === null) { + return { + result: 'starting', + message: i18n('The multiplayer server is not ready'), + } + } + const games = + counts.active === 0 + ? i18n('no games in progress') + : counts.active === 1 + ? i18n('1 game in progress') + : `${counts.active} ${i18n('games in progress')}` + // Only mentioned when there is one — a permanent "0 waiting to start" is noise on a line + // that is read at a glance. + const waiting = + counts.lobby === 0 + ? '' + : counts.lobby === 1 + ? `, ${i18n('1 waiting to start')}` + : `, ${counts.lobby} ${i18n('waiting to start')}` + return { + result: 'success', + message: `${i18n('Multiplayer server is ready')} — ${games}${waiting}`, + } + }, }, requires: [], }) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts index 1507df4..6218c70 100644 --- a/startos/manifest/i18n.ts +++ b/startos/manifest/i18n.ts @@ -32,10 +32,10 @@ export const long = { 'lub kooperacyjną. Pasjans nie wymaga serwera — działa w całości w przeglądarce, z tego ' + 'samego statycznego klienta, który hostuje ten serwer.', fr_FR: - "Station Master est un jeu de gestion ferroviaire : manœuvrez des wagons, chargez et " + - "déchargez du fret, et dispatchez des trains à travers une Division partagée. Ce paquet " + - "exécute le serveur multijoueur faisant autorité — 2 à 4 joueurs se connectent depuis leur " + - "navigateur pour une partie compétitive ou coopérative. Le mode solitaire ne nécessite aucun " + - "serveur : il tourne entièrement dans le navigateur, à partir du même client statique que ce " + - "serveur héberge.", + 'Station Master est un jeu de gestion ferroviaire : manœuvrez des wagons, chargez et ' + + 'déchargez du fret, et dispatchez des trains à travers une Division partagée. Ce paquet ' + + 'exécute le serveur multijoueur faisant autorité — 2 à 4 joueurs se connectent depuis leur ' + + 'navigateur pour une partie compétitive ou coopérative. Le mode solitaire ne nécessite aucun ' + + 'serveur : il tourne entièrement dans le navigateur, à partir du même client statique que ce ' + + 'serveur héberge.', } diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index ce4d3e5..192b5be 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -6,14 +6,15 @@ export const manifest = setupManifest({ title: 'Station Master', // Not open source — Jesse's own project, packaged for his own StartOS box. license: 'UNLICENSED', - packageRepo: 'https://draco.local:53871/Jesse.Markowitz/station-master-startos', + packageRepo: + 'https://draco.local:53871/Jesse.Markowitz/station-master-startos', upstreamRepo: 'https://draco.local:53871/Jesse.Markowitz/station-master', // No separate marketing site — the repo IS where there is more to learn. marketingUrl: 'https://draco.local:53871/Jesse.Markowitz/station-master', donationUrl: null, description: { short, long }, // Everything the server persists — game.json/index.json per game, turn timings, - // and this package's own store.json (the join secret) — lives on one volume. + // and this package's own store.json (both secrets) — lives on one volume. volumes: ['data'], images: { // Built from source, not a published image: `main` here is the arbitrary diff --git a/startos/serverApi.ts b/startos/serverApi.ts new file mode 100644 index 0000000..ac07b3e --- /dev/null +++ b/startos/serverApi.ts @@ -0,0 +1,98 @@ +import { storeJson } from './fileModels/store.json' +import { uiPort } from './utils' +import { T } from '@start9labs/start-sdk' +import { i18n } from './i18n' + +/** + * The one place this package talks to the game server. + * + * Everything the health check and the actions report — how many games are running, who is in them, + * whose turn it is — exists only inside the running server. This package cannot work any of it out + * for itself: the engine lives in the game repo, and answering "whose turn is it" means replaying + * a game's whole intent history through it. So the server is asked, and these functions require + * the daemon to be up. That is why the game actions declare `allowedStatuses: 'running'`. + */ +const base = `http://localhost:${uiPort}` + +export type GameRow = { + gameId: string + gameCode: string | null + state: 'running' | 'lobby' + playerCount: number + playerNames: string[] + createdAt: number + // Absent on a lobby — it has no game to be part-way through. + lastMoveAt?: number + status?: 'active' | 'finished' + day?: number + stage?: number + phase?: string + waitingOn?: { seat: number; name: string } | null +} + +export type HealthCounts = { active: number; lobby: number } + +/** `null` when nothing answered — a server still replaying saved games has not bound its port yet. */ +export async function fetchHealth(): Promise { + try { + const res = await fetch(`${base}/api/health`) + if (!res.ok) return null + const body = (await res.json()) as { games?: HealthCounts } + return body.games ?? { active: 0, lobby: 0 } + } catch { + return null + } +} + +async function adminFetch( + effects: T.Effects, + path: string, + init?: RequestInit, +): Promise { + const adminSecret = await storeJson.read((s) => s.adminSecret).once() + if (!adminSecret) { + throw new Error( + i18n( + 'No admin secret is stored for this service, so its games cannot be managed.', + ), + ) + } + const res = await fetch(`${base}${path}`, { + ...init, + headers: { ...init?.headers, 'x-admin-secret': adminSecret }, + }) + if (!res.ok) { + // 404 with a correct secret means the game is gone, not that the route is missing — the + // routes themselves 404 only when the server has no admin secret at all, and it was given + // one at install. + const detail = + res.status === 404 + ? i18n('that game no longer exists') + : `${i18n('the server answered')} ${res.status}` + throw new Error(`${i18n('Could not reach the game server')} — ${detail}.`) + } + return res +} + +export async function listGames(effects: T.Effects): Promise { + const res = await adminFetch(effects, '/api/games') + return ((await res.json()) as { games: GameRow[] }).games +} + +export async function exportGame( + effects: T.Effects, + gameId: string, +): Promise { + const res = await adminFetch(effects, `/api/games/${gameId}/save`) + return ((await res.json()) as { save: unknown }).save +} + +export async function deleteGame( + effects: T.Effects, + gameId: string, +): Promise<{ gameCode: string | null; save: unknown }> { + const res = await adminFetch(effects, `/api/games/${gameId}`, { + method: 'DELETE', + }) + return (await res.json()) as { gameCode: string | null; save: unknown } +} diff --git a/startos/versions/current.ts b/startos/versions/current.ts index f6deeb2..9ceef77 100644 --- a/startos/versions/current.ts +++ b/startos/versions/current.ts @@ -4,13 +4,23 @@ export const current = VersionInfo.of({ // `:` — the upstream half tracks the bundled Station Master tag exactly // (`versions.md`'s consistency checklist), so bumping the submodule pin and bumping this are one // action. The downstream half moves only for packaging-only changes. - version: '0.5.2:0', + version: '0.5.3:0', releaseNotes: { - en_US: 'Bundles Station Master v0.5.2: the splash page opens the multiplayer lobby.', - es_ES: 'Incluye Station Master v0.5.2: la página de inicio abre la sala multijugador.', - de_DE: 'Enthält Station Master v0.5.2: Die Startseite öffnet die Mehrspieler-Lobby.', - pl_PL: 'Zawiera Station Master v0.5.2: strona powitalna otwiera poczekalnię trybu wieloosobowego.', - fr_FR: "Inclut Station Master v0.5.2 : la page d'accueil ouvre le salon multijoueur.", + en_US: + 'Bundles Station Master v0.5.3. The health check reports how many games are in progress, and ' + + 'two new actions list every game on the server and let you save or end one.', + es_ES: + 'Incluye Station Master v0.5.3. La comprobación de estado indica cuántas partidas hay en curso, ' + + 'y dos acciones nuevas listan las partidas del servidor y permiten guardarlas o finalizarlas.', + de_DE: + 'Enthält Station Master v0.5.3. Die Zustandsprüfung meldet, wie viele Spiele laufen, und zwei ' + + 'neue Aktionen listen alle Spiele auf und sichern oder beenden eines davon.', + pl_PL: + 'Zawiera Station Master v0.5.3. Kontrola stanu podaje liczbę gier w toku, a dwie nowe akcje ' + + 'wyświetlają wszystkie gry na serwerze i pozwalają je zapisać lub zakończyć.', + fr_FR: + "Inclut Station Master v0.5.3. Le contrôle d'état indique le nombre de parties en cours, et " + + 'deux nouvelles actions listent toutes les parties du serveur et permettent d’en sauvegarder ou d’en terminer une.', }, migrations: { up: async ({ effects }) => {}, diff --git a/station-master b/station-master index 62b6ed7..2fbfe11 160000 --- a/station-master +++ b/station-master @@ -1 +1 @@ -Subproject commit 62b6ed7e1b81a4e5bb73c60e5314bd41e84d6ec9 +Subproject commit 2fbfe11977ab8bda38a8bd246aa74ae07b7227b5