Initial StartOS package for Station Master (v0.5.1)

Built from source via a git submodule pinned to a tag, not a published image
— the Dockerfile, main.ts, and manifest should never need to change for an
ordinary version bump, only the submodule pin (see UPDATING.md). One volume,
one interface serving the browser client + lobby/intent API + SSE stream
from a single origin, no dependencies. The server-wide join secret (D14) is
seeded on install, exposed via the Get Join Secret action, and blocks start
behind a critical task until retrieved — the same first-set/rotation pattern
as actual-budget-startos's admin password.

Verified on phoenix.local: installs, the critical task correctly blocks an
ordinary start, force-starting confirms the daemon binds its port and serves
the client, and store.json correctly holds the install-seeded join secret.
Not verified: the Get Join Secret action's execution end-to-end — start-cli's
`package action run` fails with a client-side deserialization error that
reproduces identically against actual-budget's already-shipped equivalent
action, so this looks like a start-cli issue rather than a defect here.

icon.svg is still the scaffold's hello-world placeholder — no real Station
Master icon exists yet to ship in its place.
This commit is contained in:
Jesse
2026-08-21 08:44:07 -04:00
commit de28b30eea
40 changed files with 2540 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
import { utils } from '@start9labs/start-sdk'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { storeJson } from '../fileModels/store.json'
/**
* Mints a fresh join secret on every run and returns it — first-set and rotation are the same
* action (recipe-admin-credentials.md). Writing a new value restarts the daemon with it: main.ts
* reads `joinSecret` reactively via `.const(effects)`, so a rotation here takes effect
* immediately rather than needing a manual restart.
*
* Rotating invalidates the old secret for anyone who hasn't joined yet, but never removes a
* player already seated in a game — D14's secret gates the lobby door only (join-secret ≠
* session token, per `lobby-and-sessions.md` §1).
*/
export const getJoinSecret = sdk.Action.withoutInput(
'get-join-secret',
async () => ({
name: i18n('Get Join Secret'),
description: i18n('Retrieve or rotate the secret players need to create or join a game'),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
async ({ effects }) => {
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.',
result: {
type: 'single',
name: 'Join Secret',
description: null,
value: joinSecret,
masked: true,
copyable: true,
qr: false,
},
}
},
)
+4
View File
@@ -0,0 +1,4 @@
import { sdk } from '../sdk'
import { getJoinSecret } from './getJoinSecret'
export const actions = sdk.Actions.of().addAction(getJoinSecret)
+8
View File
@@ -0,0 +1,8 @@
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'),
)
+5
View File
@@ -0,0 +1,5 @@
import { sdk } from './sdk'
export const setDependencies = sdk.setupDependencies(
async ({ effects }) => ({}),
)
+14
View File
@@ -0,0 +1,14 @@
import { FileHelper, z } from '@start9labs/start-sdk'
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.
*/
const shape = z.object({
joinSecret: z.string().optional().catch(undefined),
})
export const storeJson = FileHelper.json({ base: sdk.volumes.data, subpath: 'store.json' }, shape)
+24
View File
@@ -0,0 +1,24 @@
export const DEFAULT_LANG = 'en_US'
const dict = {
// main.ts
'Starting Station Master!': 0,
'Multiplayer Server': 1,
'The multiplayer server is ready': 2,
'The multiplayer server is not ready': 3,
// interfaces.ts
'Multiplayer Table': 4,
'Create or join a game, and play in the browser': 5,
// actions/getJoinSecret.ts
'Get Join Secret': 6,
'Retrieve or rotate the secret players need to create or join a game': 7,
// init/generateJoinSecret.ts
'Get the join secret to share with players': 8,
} as const
/**
* Plumbing. DO NOT EDIT.
*/
export type I18nKey = keyof typeof dict
export type LangDict = Record<(typeof dict)[I18nKey], string>
export default dict
+48
View File
@@ -0,0 +1,48 @@
import { LangDict } from './default'
export default {
es_ES: {
0: '¡Iniciando Station Master!',
1: 'Servidor multijugador',
2: 'El servidor multijugador está listo',
3: 'El servidor multijugador no está listo',
4: 'Mesa multijugador',
5: 'Crea o únete a una partida, y juega en el navegador',
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',
},
de_DE: {
0: 'Starte Station Master!',
1: 'Mehrspieler-Server',
2: 'Der Mehrspieler-Server ist bereit',
3: 'Der Mehrspieler-Server ist nicht bereit',
4: 'Mehrspielertisch',
5: 'Spiel erstellen oder beitreten und im Browser spielen',
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',
},
pl_PL: {
0: 'Uruchamianie Station Master!',
1: 'Serwer wieloosobowy',
2: 'Serwer wieloosobowy jest gotowy',
3: 'Serwer wieloosobowy nie jest gotowy',
4: 'Stół wieloosobowy',
5: 'Utwórz lub dołącz do gry i graj w przeglądarce',
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',
},
fr_FR: {
0: 'Démarrage de Station Master !',
1: 'Serveur multijoueur',
2: 'Le serveur multijoueur est prêt',
3: "Le serveur multijoueur n'est pas prêt",
4: 'Table multijoueur',
5: 'Créez ou rejoignez une partie, et jouez dans le navigateur',
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',
},
} satisfies Record<string, LangDict>
+8
View File
@@ -0,0 +1,8 @@
/**
* Plumbing. DO NOT EDIT this file.
*/
import { setupI18n } from '@start9labs/start-sdk'
import defaultDict, { DEFAULT_LANG } from './dictionaries/default'
import translations from './dictionaries/translations'
export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG)
+11
View File
@@ -0,0 +1,11 @@
/**
* Plumbing. DO NOT EDIT.
*/
export { createBackup } from './backups'
export { main } from './main'
export { init, uninit } from './init'
export { actions } from './actions'
import { buildManifest } from '@start9labs/start-sdk'
import { manifest as sdkManifest } from './manifest'
import { versionGraph } from './versions'
export const manifest = buildManifest(versionGraph, sdkManifest)
+26
View File
@@ -0,0 +1,26 @@
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, {})
}
})
+18
View File
@@ -0,0 +1,18 @@
import { sdk } from '../sdk'
import { setDependencies } from '../dependencies'
import { setInterfaces } from '../interfaces'
import { versionGraph } from '../versions'
import { actions } from '../actions'
import { restoreInit } from '../backups'
import { seedJoinSecret } from './generateJoinSecret'
export const init = sdk.setupInit(
restoreInit,
versionGraph,
seedJoinSecret,
setInterfaces,
setDependencies,
actions,
)
export const uninit = sdk.setupUninit(versionGraph)
+28
View File
@@ -0,0 +1,28 @@
import { i18n } from './i18n'
import { sdk } from './sdk'
import { uiPort } from './utils'
// One interface: the server serves the browser client, the lobby/intent HTTP API, and the SSE
// game stream all from this single port (deployment.md's same-origin rule in the game repo —
// baking in a second origin would break relative URLs). type: 'ui' is a label the client is
// meant for a browser; it does not stop the same port from also carrying the API calls that
// client makes back to itself.
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
const uiMultiOrigin = await uiMulti.bindPort(uiPort, { protocol: 'http' })
const ui = sdk.createInterface(effects, {
name: i18n('Multiplayer Table'),
id: 'ui',
description: i18n('Create or join a game, and play in the browser'),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
const uiReceipt = await uiMultiOrigin.export([ui])
return [uiReceipt]
})
+50
View File
@@ -0,0 +1,50 @@
import { i18n } from './i18n'
import { sdk } from './sdk'
import { uiPort, dataDir } from './utils'
import { storeJson } from './fileModels/store.json'
export const main = sdk.setupMain(async ({ effects }) => {
console.info(i18n('Starting Station Master!'))
// 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)
return sdk.Daemons.of(effects).addDaemon('server', {
subcontainer: sdk.SubContainer.of(
effects,
{ imageId: 'main' },
sdk.Mounts.of().mountVolume({
volumeId: 'data',
subpath: null,
mountpoint: dataDir,
readonly: false,
}),
'station-master-sub',
),
exec: {
command: ['node', 'src/server/index.ts'],
env: {
// PORT/BIND_ADDRESS/DIST_DIR are left at src/server/index.ts's own defaults (8081,
// 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
// empty — the fallback only avoids threading `string | undefined` through `env`, which
// wants `Record<string, string>`.
JOIN_SECRET: joinSecret ?? '',
},
},
// Health check, run on each polling interval. `checkPortListening` reports ready once the
// daemon binds `uiPort`; the 'ui' interface (interfaces.ts) exposes the same port.
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'),
}),
},
requires: [],
})
})
+41
View File
@@ -0,0 +1,41 @@
export const short = {
en_US: 'Railroad operations board game — solitaire or multiplayer',
es_ES: 'Juego de mesa de operaciones ferroviarias: solitario o multijugador',
de_DE: 'Eisenbahn-Betriebs-Brettspiel — Solitär oder Mehrspieler',
pl_PL: 'Gra planszowa o zarządzaniu koleją — pasjans lub tryb wieloosobowy',
fr_FR: 'Jeu de plateau de gestion ferroviaire — solo ou multijoueur',
}
export const long = {
en_US:
'Station Master is a railroad operations game: switch cars, load and unload freight, and ' +
'dispatch trains through a shared Division. This package runs the authoritative multiplayer ' +
'server — 2 to 4 players connect from their browsers and play a competitive or cooperative ' +
'game together. Solitaire needs no server at all; it runs entirely in the browser from the ' +
'static client this server also hosts.',
es_ES:
'Station Master es un juego de operaciones ferroviarias: maniobra vagones, carga y descarga ' +
'mercancías, y despacha trenes por una División compartida. Este paquete ejecuta el servidor ' +
'multijugador autoritativo — de 2 a 4 jugadores se conectan desde sus navegadores y juegan una ' +
'partida competitiva o cooperativa. El modo solitario no necesita servidor: se ejecuta ' +
'enteramente en el navegador desde el mismo cliente estático que este servidor aloja.',
de_DE:
'Station Master ist ein Eisenbahn-Betriebsspiel: Waggons rangieren, Fracht laden und löschen ' +
'und Züge durch eine gemeinsame Division disponieren. Dieses Paket betreibt den ' +
'autoritativen Mehrspieler-Server — 2 bis 4 Spieler verbinden sich über den Browser und ' +
'spielen gemeinsam kompetitiv oder kooperativ. Solitär benötigt keinen Server; es läuft ' +
'vollständig im Browser über denselben statischen Client, den dieser Server ebenfalls hostet.',
pl_PL:
'Station Master to gra o zarządzaniu koleją: manewruj wagonami, załaduj i rozładuj towar oraz ' +
'dysponuj pociągami we wspólnej Dywizji. Ten pakiet uruchamia autorytatywny serwer trybu ' +
'wieloosobowego — od 2 do 4 graczy łączy się z przeglądarek i rozgrywa partię rywalizacyjną ' +
'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.",
}
+29
View File
@@ -0,0 +1,29 @@
import { setupManifest } from '@start9labs/start-sdk'
import { long, short } from './i18n'
export const manifest = setupManifest({
id: 'station-master',
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',
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.
volumes: ['data'],
images: {
// Built from source, not a published image: `main` here is the arbitrary
// image id, matched in main.ts's SubContainer.of({ imageId: 'main' }).
// The Dockerfile COPYs the pinned `station-master` git submodule — see
// UPDATING.md for how that pin is bumped.
main: {
source: { dockerBuild: { workdir: '.', dockerfile: './Dockerfile' } },
arch: ['x86_64', 'aarch64'],
},
},
dependencies: {},
})
+9
View File
@@ -0,0 +1,9 @@
import { StartSdk } from '@start9labs/start-sdk'
import { manifest } from './manifest'
/**
* Plumbing. DO NOT EDIT.
*
* The exported "sdk" const is used throughout this package codebase.
*/
export const sdk = StartSdk.of().withManifest(manifest).build(true)
+8
View File
@@ -0,0 +1,8 @@
// Station Master's server default (src/server/index.ts's `PORT` fallback) — kept identical here
// so the env var below is documentation, not a real override.
export const uiPort = 8081
// Where the volume is mounted inside the subcontainer, and so also `DATA_DIR`'s value. The
// server's own per-game persistence (`games/<gameId>/`, `index.json`) and this package's
// store.json (the join secret) share this one directory — see README's Volume and Data Layout.
export const dataDir = '/data'
+16
View File
@@ -0,0 +1,16 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
export const current = VersionInfo.of({
version: '1.0.0:0',
releaseNotes: {
en_US: 'Initial StartOS package, bundling Station Master v0.5.1.',
es_ES: 'Paquete inicial para StartOS, con Station Master v0.5.1.',
de_DE: 'Erstes StartOS-Paket, mit Station Master v0.5.1.',
pl_PL: 'Pierwszy pakiet dla StartOS, zawiera Station Master v0.5.1.',
fr_FR: 'Premier paquet StartOS, avec Station Master v0.5.1.',
},
migrations: {
up: async ({ effects }) => {},
down: IMPOSSIBLE,
},
})
+7
View File
@@ -0,0 +1,7 @@
import { VersionGraph } from '@start9labs/start-sdk'
import { current } from './current'
export const versionGraph = VersionGraph.of({
current,
other: [],
})