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,6 @@
|
||||
.git
|
||||
.gitmodules
|
||||
node_modules
|
||||
*.s9pk
|
||||
docker-images
|
||||
javascript
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
*.s9pk
|
||||
startos/*.js
|
||||
node_modules/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
docker-images
|
||||
javascript
|
||||
ncc-cache
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "station-master"]
|
||||
path = station-master
|
||||
url = https://draco.local:53871/Jesse.Markowitz/station-master.git
|
||||
@@ -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).
|
||||
+23
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
ARCHES := x86 arm
|
||||
# overrides to s9pk.mk must precede the include statement
|
||||
include node_modules/@start9labs/start-sdk/s9pk.mk
|
||||
@@ -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
@@ -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.
|
||||
@@ -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 |
@@ -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.
|
||||
Generated
+1670
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
})
|
||||
Submodule
+1
Submodule station-master added at e76bd77099
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@start9labs/start-sdk/tsconfig.base.json",
|
||||
"include": ["startos/**/*.ts", "node_modules/**/startos"]
|
||||
}
|
||||
Reference in New Issue
Block a user