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