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
+6
View File
@@ -0,0 +1,6 @@
.git
.gitmodules
node_modules
*.s9pk
docker-images
javascript
+17
View File
@@ -0,0 +1,17 @@
name: Build
on:
workflow_dispatch:
pull_request:
paths-ignore: ['*.md']
branches: ['master']
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
build:
if: github.event.pull_request.draft == false
uses: Start9Labs/start-technologies/.github/workflows/build.yml@master
# No DEV_KEY — a PR build doesn't publish, so it doesn't need the signing key.
+19
View File
@@ -0,0 +1,19 @@
name: Release
on:
push:
tags:
- 'v*.*'
jobs:
release:
uses: Start9Labs/start-technologies/.github/workflows/release.yml@master
with:
RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }}
S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }}
secrets:
DEV_KEY: ${{ secrets.DEV_KEY }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
permissions:
contents: write
+24
View File
@@ -0,0 +1,24 @@
name: Sync next
# Carries every change that lands on the base branch onto the paired `next`
# iteration branch, so `next` never drifts behind what has already shipped.
# `next` is created on the first run if the repo does not have one yet.
#
# List every base branch this package maintains. Most packages have one; the
# multi-branch packages list each major line or flavor (e.g. 28.x, 29.x). A
# package whose default branch is `main` must say `main` here — a workflow
# pointed at a branch the repo does not use never runs.
#
# No paths-ignore: a docs-only commit on the base still has to reach `next`,
# or the next merge back re-introduces the stale copy.
on:
push:
branches: ['master']
workflow_dispatch:
jobs:
sync:
uses: Start9Labs/start-technologies/.github/workflows/syncNext.yml@master
permissions:
contents: write
pull-requests: write
+24
View File
@@ -0,0 +1,24 @@
name: Tag and Release
on:
push:
branches: ['master']
paths-ignore: ['*.md']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tag:
uses: Start9Labs/start-technologies/.github/workflows/tagAndRelease.yml@master
with:
REFERENCE_REGISTRY: ${{ vars.REFERENCE_REGISTRY }}
RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }}
S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }}
secrets:
DEV_KEY: ${{ secrets.DEV_KEY }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
permissions:
contents: write
+8
View File
@@ -0,0 +1,8 @@
*.s9pk
startos/*.js
node_modules/
.DS_Store
.vscode/
docker-images
javascript
ncc-cache
+3
View File
@@ -0,0 +1,3 @@
[submodule "station-master"]
path = station-master
url = https://draco.local:53871/Jesse.Markowitz/station-master.git
+49
View File
@@ -0,0 +1,49 @@
# AGENTS.md
This is a StartOS service-package repository — it builds a `.s9pk` for StartOS.
Develop it inside a StartOS packaging workspace created by `start-cli s9pk init-workspace`,
which provides the packaging guide and agent context one level up. If you're reading this in a
bare clone with no workspace, the full guide is at <https://docs.start9.com/packaging>.
**Start every task at the recipe index** — `../start-technologies/projects/start-sdk/docs/src/recipes.md`
(or <https://docs.start9.com/packaging/recipes.html>). It maps an intent ("prompt the user to create
admin credentials", "expose a web UI") to the constructs, the reference pages, and a named production
package to copy. Find the recipe before you read this package's neighbours: a package you reach by
grepping may be non-conformant, and the recipe outranks it.
Freshly scaffolded? Work the
[New Package Checklist](../start-technologies/projects/start-sdk/docs/src/new-package-checklist.md)
(or <https://docs.start9.com/packaging/new-package-checklist.html>) from top to bottom. It is a
guide page, not a file in this repo — read it, don't copy it in.
Keep `README.md` (technical reference for an AI support or administering agent) and
`instructions.md` (end-user docs) in sync with your changes.
**Bugs and feature requests are issues on this repo** (self-hosted Gitea, not GitHub) — file
them as you find them. Don't record work in the repo instead: no `TODO.md`, no `NOTES.md`, no
`PLAN.md`. What you verified, tried, and decided belongs in the commit message and the PR body.
## This repo
- **Station Master is a git submodule (`station-master/`), not vendored source.** It tracks
[Jesse's own game repo](https://draco.local:53871/Jesse.Markowitz/station-master), self-hosted,
pinned to a tag. See `UPDATING.md` for how the pin is bumped — the `Dockerfile`, `main.ts`, and
the manifest should never need to change for an ordinary version bump.
- **Built from source, not a published image.** The `Dockerfile` is entirely this repo's own:
a `node:*-slim` builder stage runs the submodule's `npm run build:web` to produce the static
client, then a runtime stage copies only `package.json`, `src/`, and the built `dist/` — no
`node_modules`, since `src/server/index.ts` has zero runtime dependencies (see the submodule's
own `TODO.md` for why) and Node's native TypeScript type-stripping means it needs no compile
step either.
- **The join secret (D14 in the game's `docs/architecture/multiplayer.md`) is this package's one
piece of state beyond the game data itself.** `startos/fileModels/store.json.ts` holds it,
seeded on install (`startos/init/generateJoinSecret.ts`) since the server refuses to start
without one, and rotated by the **Get Join Secret** action
(`startos/actions/getJoinSecret.ts`) — the same action handles first retrieval and rotation, per
`recipe-admin-credentials.md`.
- **One volume, one interface, no dependencies.** Everything the server persists —
`store.json` and every game's own save data — lives on the `data` volume; the single `ui`
interface carries the browser client, the lobby/intent HTTP API, and the SSE game stream from
one port, which is what keeps players reachable from different addresses in the same game
(`docs/architecture/deployment.md` §3 in the game repo).
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+23
View File
@@ -0,0 +1,23 @@
# Station Master's multiplayer server, built from source — there is no published image to wrap.
# Source is the `station-master` git submodule, pinned to a tag; see UPDATING.md for how that pin
# is bumped. This Dockerfile never needs to change for a version bump, only the submodule ref.
# --- builder: produces the static browser client (dist/) ---
# The server itself has zero runtime dependencies (station-master/TODO.md), but `npm run
# build:web` shells out to `tsc`, which lives in devDependencies — hence the separate stage.
FROM node:22.23.1-slim AS builder
WORKDIR /build
COPY station-master/package.json station-master/package-lock.json ./
RUN npm ci
COPY station-master/ ./
RUN npm run build:web
# --- runtime: the server, no node_modules needed at all ---
FROM node:22.23.1-slim
WORKDIR /app
# Node >=22.18 strips TypeScript types natively (station-master/package.json's engines field),
# so the server runs straight from source with no build/compile step.
COPY station-master/package.json ./
COPY station-master/src/ ./src/
COPY --from=builder /build/dist/ ./dist/
CMD ["node", "src/server/index.ts"]
+7
View File
@@ -0,0 +1,7 @@
Copyright (c) 2026 Jesse Markowitz
All rights reserved.
This software is proprietary. No license is granted to copy, modify,
distribute, or use this software, in whole or in part, without the express
prior written permission of the copyright holder.
+3
View File
@@ -0,0 +1,3 @@
ARCHES := x86 arm
# overrides to s9pk.mk must precede the include statement
include node_modules/@start9labs/start-sdk/s9pk.mk
+160
View File
@@ -0,0 +1,160 @@
<p align="center">
<img src="icon.svg" alt="Station Master Logo" width="21%">
</p>
# Station Master on StartOS
> Everything not listed in this document should behave the same as upstream
> Station Master. If a feature, setting, or behavior is not mentioned here,
> the upstream documentation is accurate and fully applicable — see the
> Documentation section of `instructions.md` for links.
Station Master is a railroad operations board game with an authoritative multiplayer server;
solitaire needs no server and is not what this package is for. This package runs that server —
the browser client, the lobby, and the intent/SSE API — as a single StartOS service.
---
## Table of Contents
- [Image and Container Runtime](#image-and-container-runtime)
- [Volume and Data Layout](#volume-and-data-layout)
- [File Models](#file-models)
- [Dependencies](#dependencies)
- [Network Access and Interfaces](#network-access-and-interfaces)
- [Installation and First-Run Flow](#installation-and-first-run-flow)
- [Actions](#actions)
- [Tasks](#tasks)
- [Health Checks](#health-checks)
- [Backups and Restore](#backups-and-restore)
- [Limitations and Differences](#limitations-and-differences)
- [Quick Reference for AI Consumers](#quick-reference-for-ai-consumers)
---
## Image and Container Runtime
Built from source with a custom `Dockerfile` — there is no published Station Master image.
| What to Document | Value |
| --- | --- |
| Image source | Custom multi-stage `Dockerfile`: `node:*-slim` builder runs `npm run build:web` to produce the static client, then a second `node:*-slim` stage copies only `package.json`, `src/`, and the built `dist/` — no `node_modules` in the runtime stage, since the server has zero runtime dependencies |
| Architectures | x86_64, aarch64 |
| Entrypoint | `node src/server/index.ts` — runs straight from TypeScript source; no compile step, see Limitations |
One subcontainer: `station-master-sub`, running the single daemon `server`.
## Volume and Data Layout
One volume, `data`, mounted at `/data` (`DATA_DIR`).
| What to Document | Value |
| --- | --- |
| Volume names | `data` |
| Mount points | `/data` |
| StartOS files | `store.json` — holds the join secret (see File Models) |
| Database | None — flat files. Each game is `games/<gameId>/game.json` (`{ engineVersion, seed, config, playerNames, history, status, createdAt }`) plus `turn-timings.json`, an `index.json` naming every game, and lobby state for games not yet started |
## File Models
One StartOS-managed file, `store.json`, on the `data` volume.
- **`store.json`** — JSON, holds `joinSecret` (a string). Seeded on install with a random
24-character value (`init/generateJoinSecret.ts`) — the daemon will not start without one, so
it is never left unset. Rewritten only by the **Get Join Secret** action, which generates a new
value on every run; the value is otherwise never touched, so a hand edit to it survives until
the action is next run. `main.ts` reads it reactively, so a rewrite restarts the daemon with the
new value automatically.
Everything else under `/data` — `games/`, `index.json`, lobby and session state — is the
application's own persistence, written directly by the server process, not by a StartOS file
model.
## Dependencies
None.
## Network Access and Interfaces
One interface, `ui`, on the single port the daemon listens on. It serves the browser client, the
lobby and intent HTTP API, and the per-seat SSE game stream — all from the same origin, which is
what lets a player reach the server from a LAN address and another player reach it from a
different one in the same game.
## Installation and First-Run Flow
No setup wizard. On install, a join secret is generated and stored immediately (see File Models),
and a critical task is raised pointing at **Get Join Secret** — the service starts and is usable
the moment the daemon is healthy, but a player cannot create or join a game until the join secret
has been retrieved and shared with them.
## Actions
- **Get Join Secret** (`get-join-secret`) — run this any time you want to read the current join
secret, or to invalidate it and issue a new one. Every run **generates a fresh secret,
overwrites the stored one, and restarts the daemon** with it — there is no read-only mode.
Rotating does not disconnect players already seated in a running game (D14's join secret gates
the lobby door, not an in-progress session); it only invalidates the old value for anyone who
has not yet joined or created a game. Completes in a few seconds, safe to repeat.
## Tasks
- **Get the join secret to share with players** — raised on install, severity `critical`. Points
at the **Get Join Secret** action. Clears the moment that action is run for the first time; it
does not return afterward (running it again to rotate does not re-raise it).
## Health Checks
- **Multiplayer Server** — `checkPortListening` against the daemon's port. "Not ready" means the
process has not yet bound its port; on a normal start this clears within a second or two; on a
cold container start it briefly reflects the daemon starting up.
## Backups and Restore
Strategy: the entire `data` volume is backed up wholesale (`ofVolumes`) — every file copied
exactly as it sits on disk, nothing dumped and replayed.
That includes every game's full intent history, so a restore can resume any in-progress game
exactly where it left off, and the join secret in `store.json`, so previously shared join links
keep working after a restore. Nothing needs to be rebuilt or re-entered after a restore; the
server resumes every saved game on boot the same way it does after an ordinary restart.
## Limitations and Differences
1. **No compile step.** The image runs the server directly from TypeScript source using Node's
native type stripping, rather than building a `dist/` for the server the way the browser
client is built. This is upstream's own deployment shape (`docs/architecture/deployment.md` in
the game repo), not something this package changed.
2. **The join secret has no per-player identity.** It is a single server-wide value (D14) that
gates who may create or join a game — it is not a username or password, and StartOS has no
visibility into who a player is once they've joined.
3. **No accounts, and one game at a time per person is expected but not enforced** — a deliberate
upstream design decision (`docs/architecture/multiplayer.md` §11 D13), not a StartOS-specific
limitation.
---
## Quick Reference for AI Consumers
```yaml
package_id: station-master
image: built from source (Dockerfile)
architectures: [x86_64, aarch64]
subcontainers: [station-master-sub]
volumes:
data: /data
file_models:
- store.json
startos_managed_env_vars:
- DATA_DIR
- JOIN_SECRET
dependencies: none
interfaces:
ui: { type: ui, port: 8081 }
actions:
- get-join-secret
tasks:
- { action: get-join-secret, severity: critical }
health_checks:
- Multiplayer Server
```
+41
View File
@@ -0,0 +1,41 @@
# Updating the upstream version
Station Master is built from a git submodule at `station-master/` (tracking
[`Jesse.Markowitz/station-master`](https://draco.local:53871/Jesse.Markowitz/station-master), a
self-hosted Gitea repo — this is Jesse's own game, not a third-party upstream, but it is packaged
the same way: `Dockerfile` builds whatever commit the submodule points at, with no pinned
`dockerTag` in the manifest. The submodule pin **is** the version.
Station Master tags its releases (`v0.5.1`, …) at the commit that bumped `package.json`. Nothing
else about this package should need to change for a bump — not the `Dockerfile`, not `main.ts`,
not the manifest — only the submodule pin and this package's own version (below).
## Determining the upstream version
```bash
cd station-master && git fetch --tags && git tag -l --sort=-v:refname | head -5
```
The current pin is the submodule's checked-out commit:
```bash
git submodule status station-master
```
## Applying the bump
```bash
cd station-master && git fetch --tags && git checkout v<new version>
cd .. && git add station-master
```
Then bump this package's own version in `startos/versions/current.ts` (see
[Versions](../start-technologies/projects/start-sdk/docs/src/versions.md)) with release notes
naming the Station Master version now bundled — e.g. "Bundles Station Master v0.5.2." — and
rebuild (`make`).
If the jump carries a multiplayer protocol or persistence-format change (check the game repo's
`CHANGELOG.md` and `docs/architecture/multiplayer.md`), read it before bumping: a running
server's saved games are stamped with `engineVersion` and refuse to resume under a mismatched
one (`src/server/index.ts` in the game repo) — nothing in this package works around that, so an
in-progress game must finish (or be accepted as lost) before an incompatible bump.
View File
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4F46E5"/>
<stop offset="100%" stop-color="#7C3AED"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
<path d="M256 120c-88.4 0-160 58.1-160 129.8 0 41.4 23.6 78.4 60.5 103.2l-12.9 51.5a8 8 0 0 0 11.6 8.8l62.6-36.1c12.3 2.5 25.2 3.8 38.2 3.8 88.4 0 160-58.1 160-129.8S344.4 120 256 120z" fill="white" opacity="0.95"/>
<text x="256" y="270" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-weight="bold" font-size="64" fill="#4F46E5">Hello!</text>
</svg>

After

Width:  |  Height:  |  Size: 692 B

+38
View File
@@ -0,0 +1,38 @@
# Station Master
## Documentation
- [Rules and design docs](https://draco.local:53871/Jesse.Markowitz/station-master/src/branch/main/docs)
— the rulebook implications, the multiplayer design, and the game's architecture.
## What you get on StartOS
This service runs the multiplayer server: a lobby, and a table for 2 to 4 players to play a
competitive or cooperative game together from their own browsers. Everything a player needs — the
game itself, the lobby, and the connection back to this server — is served from the single web
interface below; there is nothing else to install or configure on a player's side.
## Getting set up
1. Open this service's **Multiplayer Table** interface.
2. Run the **Get Join Secret** action from this service's Actions menu, and copy the value it
returns.
3. Share that secret, and the address you opened the interface at, with everyone you want to
play with. Anyone holding the secret can create a new game or join one that hasn't started yet.
4. In the browser, click **Multiplayer**, then **Create Game** (or **Join Game** with a game code
someone else created), and enter the join secret when asked.
## Using Station Master
### Multiplayer Table
Create or join a game, seat players (including filling empty seats with a bot), and start. Once a
game is underway, this same interface is where every player plays their turns and watches the
table — there is nothing else to open.
### Get Join Secret
Run this whenever you need the current join secret again, or want to shut out anyone still
holding an old one. Every run replaces the secret and immediately puts the new one into effect —
players already seated in a game are unaffected, but the old secret stops working for creating or
joining games from that point on.
+1670
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "station-master-startos",
"scripts": {
"build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
"prettier": "prettier --write startos",
"check": "tsc --noEmit"
},
"dependencies": {
"@start9labs/start-sdk": "2.0.9"
},
"overrides": {
"@start9labs/start-sdk": "$@start9labs/start-sdk"
},
"devDependencies": {
"@types/node": "^22.19.0",
"@vercel/ncc": "^0.38.4",
"prettier": "^3.6.2",
"typescript": "^6.0.3"
},
"prettier": {
"trailingComma": "all",
"tabWidth": 2,
"semi": false,
"singleQuote": true
}
}
+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: [],
})
+1
Submodule station-master added at e76bd77099
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@start9labs/start-sdk/tsconfig.base.json",
"include": ["startos/**/*.ts", "node_modules/**/startos"]
}