265 lines
15 KiB
Markdown
265 lines
15 KiB
Markdown
# Lobby and Sessions
|
|
|
|
Everything outside the rules engine: creating and joining games, seating, reconnection, and
|
|
persistence. Target is **small self-hosted** scale — a handful of concurrent games among people who
|
|
know each other.
|
|
|
|
Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md).
|
|
|
|
---
|
|
|
|
## 1. Identity
|
|
|
|
No accounts. Access to the server is a **join secret** (§2); identity within a game is a **display
|
|
name** plus a **session token** the server issues on join and the client stores locally.
|
|
|
|
```
|
|
Session
|
|
token : opaque, unguessable
|
|
gameId
|
|
player : PlayerIndex
|
|
displayName
|
|
```
|
|
|
|
**It names the PLAYER, not the seat.** Those became different things in v0.4.0, and the difference is
|
|
exactly the case this field has to survive: under Employee Rotation (§4) a player changes chairs while
|
|
their Revenue and their identity stay with them. A token naming a seat would sit a returning player
|
|
down in someone else's Office. The seat is derived with `seatOf(state, player)` whenever it is needed,
|
|
which is one lookup and always current.
|
|
|
|
The token is what makes reconnection work: it proves "I am the player who was in this game," which is
|
|
the only identity claim the game needs. Keep it out of URLs so it is not shoulder-surfed or pasted
|
|
into a chat.
|
|
|
|
**Tokens are per-origin.** The server may be reached by more than one address — `stationmaster.<domain>`
|
|
and `<ip>:<port>` are both expected — and browser storage is scoped to the origin. A player who joined
|
|
at one address must come back to that address, or they are a stranger with no token. Say so in the
|
|
UI at join time rather than letting someone discover it when they cannot get back in.
|
|
|
|
Real accounts can be layered on later without touching the rules engine, which is exactly why
|
|
[`overview.md`](overview.md) keeps that boundary sharp.
|
|
|
|
---
|
|
|
|
## 2. Creating and joining
|
|
|
|
```
|
|
Lobby.Create { secret, config } → { gameId, gameCode, token }
|
|
Lobby.Join { secret, gameCode, displayName } → { token, player }
|
|
```
|
|
|
|
**These four messages are the one family with no types behind them**, because the engine has no
|
|
concept of a lobby — `intents.ts` starts at `localOps.choose`. Everywhere else, the code is the
|
|
protocol ([`protocol.md`](protocol.md)); here the prose is, until the server exists.
|
|
|
|
**The join secret gates the door.** Server-wide, set by environment variable, passed out of band by
|
|
whoever runs the server (`multiplayer.md` D14). Anyone holding it may create a game and may join any
|
|
created game that has not started. It is not per-game and it is not an account — it is the cheapest
|
|
thing that stops a clearnet-reachable box from being someone else's game server. Typed once and kept
|
|
per-origin, alongside the token.
|
|
|
|
A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the discovery mechanism *inside* the
|
|
door. No matchmaking, no browsing, no public game list. Players are already talking to each other; the
|
|
code just needs to survive being read aloud.
|
|
|
|
**One game at a time per person is expected usage and is deliberately not enforced** (`multiplayer.md`
|
|
§10). Enforcing it needs cross-game state whose only job is deciding when to release someone, and
|
|
getting that wrong locks a player out of their own server.
|
|
|
|
The creating player is the **host**: they set the config (§3) and start the game. **If the host leaves
|
|
before `Lobby.Start`, host rights pass to the earliest-joined remaining player.** No lobby should ever
|
|
be stuck waiting on somebody who closed a tab, and the alternative — everyone leaves and someone makes
|
|
a new game — throws away the seating they had already agreed. Host rights end at `Lobby.Start`
|
|
regardless: the rules give no player special standing during play, and the Superintendent rotates
|
|
independently (§4).
|
|
|
|
**Player counts.** Solitaire is 1. Competitive and Co-op are **2 to 4**. Nothing in the engine enforces
|
|
a limit, so the lobby is where it is enforced — and the number is set to what is actually exercised
|
|
(`test/multiplayer.test.ts` plays 2, 3 and 4 to a finish) rather than to a guess. The cost of more is
|
|
real: the Division grows by one Office and one Mainline card per player (§4.3), and every added player
|
|
lengthens both the Superintendent's rotation and the phase-major waiting. Raise the cap when somebody
|
|
has played a bigger game and reported back, not before.
|
|
|
|
This is a lobby judgment, **not a rules limit** — §7 of the rules describes what happens at five or
|
|
more players (the New Train round ends mid-pass when the consist fills) and the engine implements it.
|
|
The cap is about what has been played, not about what the game can do.
|
|
|
|
---
|
|
|
|
## 3. Configuration, and when it locks
|
|
|
|
Set before start, immutable after:
|
|
|
|
```
|
|
mode : solitaire | competitive | coop
|
|
victory : firstToTarget | highestAfterDays
|
|
length : short | standard | campaign
|
|
optionalRules : { reducedVisibility, sisterTrains, employeeRotation, emergencyToolbox }
|
|
```
|
|
|
|
These must lock at `Lobby.Start`. Changing `length` mid-game would move the finish line; changing
|
|
`mode` would switch which failure floors apply (§3.4, §3.5). Neither has a coherent meaning
|
|
mid-game, so the server should refuse rather than try.
|
|
|
|
`mode` and the seat count are checked together at start: solitaire is exactly 1, competitive and
|
|
co-op are 2 to 4 (§2). The engine will happily build a 7-player Division, so this is the only place
|
|
the limit exists.
|
|
|
|
---
|
|
|
|
## 4. Seating
|
|
|
|
**Seating order is not cosmetic.** The players form a physical west-to-east chain of Offices (§4.3),
|
|
and seat order determines three separate things:
|
|
|
|
1. Which Office is adjacent to which — and therefore where each player's trains arrive from.
|
|
2. The Superintendent rotation, which passes one seat left every three Stages (§5).
|
|
3. Acting order within a phase, which starts at the Superintendent and proceeds left (Gap 1).
|
|
|
|
So seating must be settled before the opening D12 rolls, and the lobby should show the chain
|
|
visually — a player should see whose Office lies east and west of theirs before the game begins.
|
|
|
|
**The opening rolls** (§4.4, §4.5) happen server-side at `Lobby.Start`, from the game seed: highest
|
|
D12 takes the Eastern Division Point, and a second roll picks the opening Superintendent.
|
|
|
|
**Both are implemented.** The Division roll was drawn and discarded until v0.4.1 — `void
|
|
divisionRolls`, with seating fixed by array order — so §4.4 decided nothing and the seat/player split
|
|
was untested at runtime. It orders the chain now: `seating[seat] = player`, ascending by roll from
|
|
seat 0 (west, beside the Western Division Point) to the last seat (east, "highest is the Eastern
|
|
Division Point").
|
|
|
|
The rule names only those two ends, because at a table the players are already sitting in a chain and
|
|
the roll only says which way round it is. There is no physical table here, so the roll orders
|
|
everybody — it uses a number every player is already told to roll, and it makes the roll matter to
|
|
more than the winner. Ties break toward the lower player index sitting further east, the same
|
|
first-max-wins convention the Superintendent roll uses.
|
|
|
|
Both rolls are kept on the state as `openingRolls`, indexed by player, **so the lobby can show the
|
|
rolls rather than only their outcome** — it is the game's first moment of drama and there is no reason
|
|
to hide it. Show the chain forming.
|
|
|
|
**Solitaire is unchanged and must stay so:** one player is one seat, seating is trivially `[0]`, and
|
|
every published replay depends on that.
|
|
|
|
**Employee Rotation** (Appendix B), if enabled, moves every player one seat left at the end of each
|
|
Day, carrying their Revenue and the Fedora with them. **The model supports this as of v0.4.0**:
|
|
offices and districts are keyed by seat, hands and Revenue and the Fedora by player, and `seating[]`
|
|
maps between them, so rotating is a one-line operation and every `areaOf(state, player)` caller
|
|
follows without changing. The *rule* is still unimplemented — nothing rotates `seating` yet — but the
|
|
structure it needs is in place and tested, which was the point of doing it early.
|
|
|
|
---
|
|
|
|
## 5. Disconnection and reconnection
|
|
|
|
Being turn-based makes this far easier than it would be in a real-time game. (It is no longer "one
|
|
actor at a time" — v0.4.0 gave every player their own `TurnState` precisely so that players can act in
|
|
parallel where the rules allow it, `multiplayer.md` D19. Nothing about disconnection changes: the game
|
|
still waits on whoever it is waiting on.)
|
|
|
|
**On disconnect:** keep the seat. Do not remove the player, do not auto-play. The game simply waits
|
|
if it was their turn. Broadcast a disconnect notice so everyone else can see why nothing is happening
|
|
— silence with no explanation is the worst version of this. There is no such event type today; it is
|
|
server-layer news about a *connection*, not about the game, so it belongs with the transport rather
|
|
than in `GameEvent`, which must stay replayable from a seed.
|
|
|
|
**On reconnect:** the client presents its token and the server replies with a **full current view**.
|
|
Not an event tail — a returning client needs the position, not the history of how it got there, and
|
|
the server can always produce the position because it holds the game
|
|
([`protocol.md`](protocol.md) §3). This is why catching up is not a special case.
|
|
|
|
**On a player who does not come back:** the honest options at this scale are to wait, or to let the
|
|
host end the game.
|
|
|
|
**Nothing moves on an absent player's behalf.** No forcing timer, no bot stepping in. Jesse's call,
|
|
and the reasoning is the same one that keeps bots out of running games generally: the Superintendent's
|
|
clearance decision materially affects *other* players' scores, so anything that answers it
|
|
automatically changes the game rather than preserving it. Bots fill empty seats **at game start
|
|
only** (`multiplayer.md` D8). Two ways out of a permanently-halted game are recorded in `TODO.md` and
|
|
deliberately not designed here — a bot playing minimally-damaging defensive moves for someone who
|
|
stepped away, and a player *consenting* to resign their railroad to a bot. Both need care; neither is
|
|
a timer.
|
|
|
|
### The turn clock — a stopwatch, not a shot clock
|
|
|
|
**Record how long each player takes on each turn.** Not to enforce anything: to find out where the
|
|
game actually goes. "A 4-player game takes an evening" is currently a guess, and the useful version of
|
|
that answer is per-phase — whether the wait is Local Operations, or making up a train, or one player
|
|
thinking about a clearance while three others watch. That is what tells you which part is worth
|
|
speeding up, and it is the one measurement no amount of bot simulation can produce, because the bot
|
|
does not think.
|
|
|
|
Where it lives matters:
|
|
|
|
- **Outside the rules engine.** The engine has no clock and must not acquire one — it is pure and
|
|
deterministic given a seed, which is what makes every replay and every test work.
|
|
- **Outside the canonical record.** Timings are stored beside `{ engineVersion, seed, config, history }`,
|
|
never inside it. A replay must reproduce a game from decisions alone; if it depended on wall-clock
|
|
data it would no longer be reproducible from a seed.
|
|
- **In the session host**, which already sees each intent arrive and already knows whose turn it is.
|
|
Wall-clock at the start of a turn, wall-clock at the intent that ends it, per player per phase.
|
|
|
|
Show the current turn's elapsed time in the UI if it is unobtrusive — a table can self-regulate on
|
|
information alone, which is the polite version of a shot clock and costs nothing.
|
|
|
|
---
|
|
|
|
## 6. Persistence
|
|
|
|
Persist the **intents**, not a state snapshot and not the event log. This section said "event log"
|
|
until v0.4.0, on the strength of a `state = fold(events)` claim that was never true
|
|
([`protocol.md`](protocol.md) §3) — folding the log rebuilds card plays and switching, and not one
|
|
train movement. The intents genuinely do reconstruct a game, they are smaller still, and `fromSave`
|
|
already replays them, so a mid-game server restart is a replay rather than a recovery.
|
|
|
|
```
|
|
games : gameId → { engineVersion, config, seed, status, createdAt, gameCode }
|
|
history : gameId → ordered Intent list
|
|
sessions : token → { gameId, player, displayName }
|
|
```
|
|
|
|
`engineVersion` is stored beside the seed because a replay is only faithful under the rules it was
|
|
recorded with. A version mismatch on load must be refused explicitly rather than replayed and quietly
|
|
diverged (`multiplayer.md` Phase 3).
|
|
|
|
Requirements are modest enough that the storage choice is genuinely open — SQLite on a single node
|
|
covers this comfortably, and so would flat files with an append-only list per game. The constraint
|
|
worth honouring is that the **history is append-only**: rewriting it breaks the one property that
|
|
makes replay trustworthy. (Undo is not an exception — it replays the history *without* the last
|
|
intent, and a server does not offer it at all.)
|
|
|
|
**Snapshotting** is an optimisation, not a requirement. If a Campaign game's history grows long
|
|
enough that replay feels slow, periodically store a state snapshot with its intent count and replay
|
|
forward from there. Do not build this until it is needed. Measured today: a finished solitaire game
|
|
is ~350 intents and a few hundred bytes.
|
|
|
|
**Retention.** Finished games **keep their history, seed and config**, because post-game replay is a
|
|
wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability))
|
|
and those three are the only things it needs — the replay viewer on the site already reads exactly
|
|
that file. Do not delete finished games by default.
|
|
|
|
Retention is an operator setting rather than an application decision — a self-hosted instance among
|
|
friends may as well keep everything, since a finished game's log is small. If a cap is wanted, expire
|
|
by age or by total count and say so plainly in the UI, because a replay that silently stops existing
|
|
is worse than one that was never offered.
|
|
|
|
**Turn timings** (§5) are stored per game beside the history, never inside it, and are retained on the
|
|
same terms — they are only interesting in aggregate, and only across enough games to see a pattern.
|
|
|
|
Session tokens are the exception: those can expire on a short window once their game has finished.
|
|
|
|
---
|
|
|
|
## 7. What this deliberately omits
|
|
|
|
At small self-hosted scale these are not needed, and building them early costs more than it returns:
|
|
|
|
- **Matchmaking or a public game browser.** The game code covers discovery.
|
|
- **Ranking, ladders, persistent profiles.** §3's timed modes produce comparable scores, so a
|
|
high-score table would be a natural first addition — but it is a feature, not infrastructure.
|
|
- **Spectators.** Straightforward to add later: a spectator is a `Frame` with no hand and no ability
|
|
to send intents. `snapshot` already takes a viewing seat, so the only new thing is a viewer that
|
|
belongs to no seat — the same shape post-game replay already uses.
|
|
- **Horizontal scaling and cross-process coordination.** One process holds every active game
|
|
in memory.
|