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
87 lines
3.0 KiB
TypeScript
87 lines
3.0 KiB
TypeScript
import { i18n } from '../i18n'
|
|
import { sdk } from '../sdk'
|
|
import { listGames } from '../serverApi'
|
|
import { when } from '../utils'
|
|
import type { GameRow } from '../serverApi'
|
|
|
|
|
|
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
|
|
? // +1 for the same reason the game's own screens do it: seats are zero-based internally
|
|
// and one-based to a person, and an administrator reading "seat 0" against a player
|
|
// reading "Seat 1" is two people describing different chairs.
|
|
`${i18n('Waiting on')}: ${g.waitingOn.name} (${i18n('seat')} ${g.waitingOn.seat + 1})`
|
|
: `${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,
|
|
})),
|
|
},
|
|
}
|
|
},
|
|
)
|