Bundle Station Master v0.5.3: games in progress, and a way to end one
Pin moves to v0.5.3, package version to 0.5.3:0.
The health check no longer just probes a port. It 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. A server that doesn't
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 makes an ordinary restart look like a
crash.
Two new actions, both only-running. Games in Progress is read-only and
lists every game and lobby with players, Day/Stage/phase, who it waits on,
when it started and when it last moved. Manage Game picks one from a
dropdown built live from the server and either exports it or ends it —
ending being the only way a game finishes other than being played out,
since an abandoned game otherwise stays active and is resumed on every
restart forever. Ending always returns the deleted game's save, so nothing
is destroyed without being handed back first.
They are only-running because none of it is readable from disk: a save is a
seed plus a list of moves, so whose-turn-it-is exists only after a replay
through the engine, which lives in the game repo rather than here. The
running server has already done that work and is asked for the answer.
startos/serverApi.ts is the single place that asks.
store.json gained adminSecret (32 chars) beside joinSecret, seeded on
install and backfilled on update for a volume written before the field
existed. It 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. init/generateJoinSecret.ts is renamed generateSecrets.ts
now that it mints both.
Also brought getJoinSecret's result strings under i18n(). They shipped as
plain strings two commits ago, which actions.md is explicit about — every
user-facing string including result titles, messages and thrown errors.
The new actions follow it, so the old one shouldn't be the odd one out.
Verified on phoenix.local, installed as an UPDATE from 0.5.2:0 rather than
a fresh install, which exercised three things at once: the boot log line
("Resuming 1 saved game(s)…"), the engine-version refusal firing for real
on a game recorded under 0.5.2, and the adminSecret backfill (store.json
came out with both secrets, 24 and 32 chars). Then, from inside the
container: the package-generated admin secret authenticating against
/api/games (200) while a wrong one is refused (403), health counts tracking
0 -> lobby 1 -> active 1 through a create/bot/start, and every field the
actions render present and correct on the listing.
NOT verified: the actions' own forms and result rendering. `start-cli
package action run` fails with a client-side deserialization error on every
action on this box — including the already-shipped get-join-secret and
actual-budget's equivalent — so it is a start-cli problem, not this
package's. The data path underneath them is verified above; the SDK
form/result rendering needs the web UI.
A test game (TRESTLE-3221, Alice + a bot) is left running on the box so
there is something for Games in Progress to show.
This commit is contained in:
@@ -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,
|
||||
})),
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string> = {}
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
+3
-3
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, LangDict>
|
||||
|
||||
@@ -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, {})
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
+44
-8
@@ -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<string, string>`.
|
||||
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: [],
|
||||
})
|
||||
|
||||
@@ -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.',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<HealthCounts | null> {
|
||||
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<Response> {
|
||||
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<GameRow[]> {
|
||||
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<unknown> {
|
||||
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 }
|
||||
}
|
||||
@@ -4,13 +4,23 @@ export const current = VersionInfo.of({
|
||||
// `<upstream>:<downstream>` — 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 }) => {},
|
||||
|
||||
Reference in New Issue
Block a user