Files
station-master-startos/startos/actions/restoreSeat.ts
T
JesseandClaude Opus 5 81f9d06705 0.8.0.13:0 — bundle Station Master v0.8.0.13
Nine fixes from the Day 1-2 playtest of 0.8.0.12, almost all of them about what
the table can see. The one that mattered moved a car: the Division Yard chips
stayed clickable while a player's board was catching up on other people's turns,
so a click submitted a real intent against a position several moves stale. They
obey the catch-up queue now, and the yard counts beside them are read from the
board on screen rather than the live game. The district picker gained a button
for your own seat and is ordered west to east as the map draws it; the Fedora
passing is announced and logged; a collision names whose district it was and who
loses the 5 Revenue; and a Depot that cannot stock another passenger says which
car it is short of instead of the action silently vanishing.

Packaging is the submodule pin, the version and the words. No action, route,
file model or interface changed — the whole release is inside the bundled game.

GAMES IN PROGRESS RESUME NORMALLY, measured rather than assumed:
`git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is two ADDED lines — a
superintendentChanged variant on the GameEvent union, and the events.push that
emits it beside the actorChanged already there. Nothing was removed or edited:
check(), legal.ts and every predicate are untouched, so no once-legal move
became illegal, and events are derived by replaying a save rather than stored in
one, so widening the union cannot invalidate anything on disk. README,
instructions.md and the release notes in all five locales say so.

Two files come along that this release did not otherwise touch:
`prettier --write startos` reflowed a call in restoreSeat.ts and a return type
in serverApi.ts, formatting drift left by the 0.8.0.12 commit.

The 0.8.0.12 release it follows was deployed and verified at a table: a seat was
recovered end to end from a one-time claim code minted by the StartOS action,
which is what the previous commit said had not yet been exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
2026-09-17 05:08:10 -04:00

128 lines
5.2 KiB
TypeScript

import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { listGames, mintClaim } from '../serverApi'
import { uiPort } from '../utils'
const { InputSpec, Value } = sdk
/**
* PUT A PLAYER BACK IN THEIR SEAT — Gitea#33.
*
* A session token is the only identity the game has, and it lives in exactly one place the player
* controls: their browser's local storage, scoped to the address they joined at. Lose it — another
* browser, a cleared profile, a private window — and the seat is unreachable, because nothing else on
* the server will accept a claim to it. Seen at a real table: the joining player came back to an
* empty lobby while their token sat intact in the server's own files, and the only way in was an
* administrator reading it off the data volume and the player pasting it into a devtools console.
*
* THE LINK CARRIES A CODE, NOT THE TOKEN. The game's `lobby-and-sessions.md` §1 says to keep the
* token out of URLs so it is not shoulder-surfed or pasted into a chat — and a recovery link is
* exactly what gets pasted into a chat. The code is single-use and expires in thirty minutes; the
* page trades it for the real token over a POST as it loads.
*
* ADMINISTRATIVE ON PURPOSE. Deciding that a particular person has lost a particular seat is a
* judgement, and there is no safe way to automate it — anyone able to mint their own code could take
* any chair at the table.
*/
export const restoreSeat = sdk.Action.withInput(
'restore-seat',
async () => ({
name: i18n('Restore a Seat'),
description: i18n(
'Create a one-time link that puts a player back into their seat',
),
warning: i18n(
'A restore link works once and expires in 30 minutes. Whoever opens it takes that seat — send it to the right person, not to a public channel.',
),
// Nothing about a live game is readable from disk, so the server has to be up to answer which
// seats exist — the same reason `manageGame` declares this.
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
}),
async ({ effects }) => {
const games = await listGames(effects)
const values: Record<string, string> = {}
for (const g of games) {
// A lobby has no seats to restore: nobody has a game to be put back into yet.
if (g.state !== 'running') continue
for (const player of g.seatedPlayers ?? []) {
const name = g.playerNames[player] ?? `${i18n('Seat')} ${player + 1}`
values[`${g.gameId}:${player}`] = `${g.gameCode ?? g.gameId} — ${name}`
}
}
// A select needs a default from its own options, and a server with no seated players has none.
if (Object.keys(values).length === 0)
values['none'] = i18n('No seated players on this server')
return InputSpec.of({
seat: Value.select({
name: i18n('Seat'),
description: i18n('Which player to restore'),
default: Object.keys(values)[0]!,
values,
}),
})
},
// Nothing to pre-fill: which seat you mean is the whole question being asked.
async () => null,
async ({ effects, input }) => {
if (input.seat === 'none') {
return {
version: '1',
title: i18n('Restore a Seat'),
message: i18n('No seated players on this server'),
result: null,
}
}
const [gameId, seatText] = input.seat.split(':')
const { code } = await mintClaim(effects, gameId!, Number(seatText))
/**
* The link is built from the interface's OWN address, so it is clickable rather than a code the
* administrator has to graft onto a URL by hand — which was the whole complaint about the
* devtools workaround this replaces.
*
* Public addresses first, then any other network-reachable one: a player who joined over the
* internet cannot use a `.local` name, and preferring the address the table actually uses is the
* difference between a link that works and one that needs explaining. `once()` rather than
* `const()` — an action wants today's answer, not a subscription that re-runs it.
*/
const host = await sdk.host.getOwn(effects, 'ui-multi').once()
const addresses = host?.bindings[uiPort]?.interfaces['ui']?.addressInfo
const urls = addresses
? [
...addresses.public.format('urlstring'),
...addresses.nonLocal.format('urlstring'),
]
: []
const base = urls[0]
// No address to hand? Return the path anyway: the administrator knows the address they use, and
// a code with nowhere to go is still better than no code at all.
const link = `${(base ?? '').replace(/\/+$/, '')}/play.html?claim=${encodeURIComponent(code)}`
return {
version: '1',
title: i18n('Restore Link'),
message: i18n(
'Send this to the player and have them open it in their own browser. It works once, expires in 30 minutes, and puts them back in their seat. It does not contain their session token.',
),
result: {
type: 'single',
name: i18n('Restore Link'),
description: null,
value: link,
// Not masked: it is meant to be read out, copied and sent, and it is worthless once spent.
masked: false,
copyable: true,
qr: true,
},
}
},
)