Files
station-master-startos/startos/actions/manageGame.ts
T
JesseandClaude Opus 5 d905c86e69 0.8.1.0:0 — bundle Station Master v0.8.1.0, and date the games in Manage Game
A table pass over 0.8.0.17. The second-digit bump is deliberate: 0.8.1 had been
reserved for the seatless display table, which is getting more thought, and this
batch earned the number on its own.

GAMES IN PROGRESS RESUME, AND IT WAS MEASURED BEFORE RELEASE rather than argued
from the diff. The bundled release changes what sits in the yards, so the risk
was real. All twelve saves on the test box were pulled and replayed through
`tryResumeSession` — the server's own boot check — against the new build: six
resume, six refuse, and the six refusals are the SAME six, at the same moves,
with the same codes, that 0.8.0.17 already logged. Nothing new was stranded.
That replay is the check worth repeating on any release that touches state,
because it answers before the install rather than after.

THE GAME'S OWN FIX, for the README's benefit: the Freight Agent's "clear the red
Inbound box" returned the car to the Classification Yard still marked loaded,
carrying a load already delivered and already paid for. `pooled()` strips a
load's `origin` stamp and keeps `loaded` on purpose — a train can retire at a
Division Point with freight aboard — so the call site's comment, "a car back in
a yard is back in the common supply, carrying nothing", described something it
never did. Coaches suffered worst: detraining takes `coach && !loaded` out of
the Division Yard, so a cleared coach came back as stock that could never unload
a passenger. 18 loaded coaches against 6 empty over five measured games.

THIS PACKAGE'S OWN CHANGE IS THE MANAGE GAME DROPDOWN. It listed each game's
code, seat count and position and nothing about when the game was last touched,
so four games on a server looked alike — reported 2026-09-22: "I've got four
games out there, but I have no idea which one's which or how old they are."
`lastMoveAt` was already on the wire from the server's `summary()` and simply
unused. Each option now carries both an age and a timestamp, because they answer
different questions: "5h ago" answers the one that was asked, and the date
beside it is what you quote when deciding to end a game. A lobby has had no
move, so it reports when it was created and says so.

`when()` moved out of `gamesInProgress.ts` into `utils.ts` alongside a new
`ago()`, shared by both actions so an administrator sees one date format
wherever a game is named — the second copy would have been the one that drifted.

No route, file model or interface changed. README and instructions.md carry the
release, and the notes in all five locales say games in progress are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-22 21:23:53 -04:00

127 lines
4.2 KiB
TypeScript

import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { deleteGame, exportGame, listGames } from '../serverApi'
import { ago, when } from '../utils'
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,
},
}
},
)