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:
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { getJoinSecret } from './getJoinSecret'
|
||||
|
||||
export const actions = sdk.Actions.of().addAction(getJoinSecret)
|
||||
@@ -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'),
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const setDependencies = sdk.setupDependencies(
|
||||
async ({ effects }) => ({}),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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, {})
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
})
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -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: {},
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { VersionGraph } from '@start9labs/start-sdk'
|
||||
import { current } from './current'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current,
|
||||
other: [],
|
||||
})
|
||||
Reference in New Issue
Block a user