v0.4.1 - bug fixes. initial d12 rolls determine what player in which seat.
This commit is contained in:
@@ -10,20 +10,31 @@ Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md).
|
||||
|
||||
## 1. Identity
|
||||
|
||||
No accounts to begin with. A player is a **display name** plus a **session token** the server issues
|
||||
on join and the client stores locally.
|
||||
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
|
||||
playerIndex
|
||||
player : PlayerIndex
|
||||
displayName
|
||||
```
|
||||
|
||||
The token is what makes reconnection work: it proves "I am the player who was sitting at seat 2,"
|
||||
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.
|
||||
**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.
|
||||
@@ -33,22 +44,45 @@ Real accounts can be layered on later without touching the rules engine, which i
|
||||
## 2. Creating and joining
|
||||
|
||||
```
|
||||
Lobby.Create { config } → { gameId, gameCode, token }
|
||||
Lobby.Join { gameCode, displayName } → { token, playerIndex }
|
||||
Lobby.Create { secret, config } → { gameId, gameCode, token }
|
||||
Lobby.Join { secret, gameCode, displayName } → { token, player }
|
||||
```
|
||||
|
||||
A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the whole discovery mechanism. No
|
||||
matchmaking, no browsing, no public game list. Players are already talking to each other; the code
|
||||
just needs to survive being read aloud.
|
||||
**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 creating player is the **host**: they set the config (§3) and start the game. Host-only rights
|
||||
end at `Lobby.Start` — the rules give no player special standing during play, and the Superintendent
|
||||
role rotates independently (§5).
|
||||
**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.
|
||||
|
||||
**Player counts.** Solitaire is 1. Competitive and Co-op need at least 2. The upper bound is a
|
||||
practical judgment rather than a rules limit: the Division grows by one Office and one Mainline card
|
||||
per player (§4.3), and every added player lengthens the Superintendent's rotation and the
|
||||
phase-major waiting. **6 is a sensible cap** for the prototype.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -67,6 +101,10 @@ These must lock at `Lobby.Start`. Changing `length` mid-game would move the fini
|
||||
`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
|
||||
@@ -82,80 +120,132 @@ So seating must be settled before the opening D12 rolls, and the lobby should sh
|
||||
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. Emit both
|
||||
as events so clients can show the rolls rather than just the outcome — it is the game's first moment
|
||||
of drama and there is no reason to hide it.
|
||||
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. Note what this means for the model: a player's
|
||||
*seat* changes while their *score* follows them, so `Player.index` and Office ownership must be
|
||||
separable. This is the one optional rule with real structural consequences — worth wiring in from the
|
||||
start rather than retrofitting.
|
||||
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
|
||||
|
||||
Turn-based with one actor at a time makes this far easier than it would be in a real-time game.
|
||||
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 `PlayerDisconnected` event so everyone else can see why nothing is
|
||||
happening — silence with no explanation is the worst version of this.
|
||||
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 plus
|
||||
the event tail it missed. Because state is `fold(events)` ([`protocol.md`](protocol.md)), catching up
|
||||
is a replay, not a special case.
|
||||
**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. An optional **turn timer** is worth having — a lobby setting, off by default,
|
||||
never part of the rules — that on expiry takes the safest legal action:
|
||||
host end the game.
|
||||
|
||||
| Phase | Timeout action |
|
||||
| --- | --- |
|
||||
| Local Operations | `Switch.End` / `Draw.End` — forfeit the remaining action |
|
||||
| New Train | `NewTrain.PassCar` if legal, else place any legal car |
|
||||
| Mainline clearance | **Deny** clearance — the safe answer; a held train costs a Stage, a wrecked one costs 5 Revenue and counts toward the collision floor |
|
||||
| Load/Unload | `LoadUnload.End` |
|
||||
**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.
|
||||
|
||||
Denying clearance on timeout is the right default and worth stating explicitly: the asymmetry between
|
||||
the two outcomes is large, and in Competitive mode a timed-out clearance that causes a wreck could
|
||||
end the game for everybody (§3.4).
|
||||
### The turn clock — a stopwatch, not a shot clock
|
||||
|
||||
**Do not substitute an AI player.** The Superintendent's clearance decisions materially affect other
|
||||
players' scores; a bot making them on an absent player's behalf changes the game rather than
|
||||
preserving it.
|
||||
**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 **event log**, not a state snapshot. It is smaller, it is the thing that already exists,
|
||||
and it makes a mid-game server restart a replay rather than a recovery.
|
||||
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 → { config, seed, status, createdAt, gameCode }
|
||||
events : gameId → ordered event list
|
||||
sessions : token → { gameId, playerIndex, displayName }
|
||||
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 log per game. The constraint
|
||||
worth honouring is that the **event log is append-only**: rewriting history breaks the one property
|
||||
that makes replay trustworthy.
|
||||
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 log grows long enough
|
||||
that replay feels slow, periodically store a state snapshot with its `eventSeq` and replay forward
|
||||
from there. Do not build this until it is needed.
|
||||
**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 event log, seed and config**, because post-game replay is
|
||||
a wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability))
|
||||
and the log is the only thing it needs. Do not delete finished games by default.
|
||||
**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.
|
||||
|
||||
---
|
||||
@@ -167,7 +257,8 @@ At small self-hosted scale these are not needed, and building them early costs m
|
||||
- **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, since a spectator is just a view with no `private`
|
||||
section and no ability to send intents — the same shape post-game replay needs.
|
||||
- **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.
|
||||
|
||||
Reference in New Issue
Block a user