diff options
Diffstat (limited to 'building_guide/concurrency.md')
| -rw-r--r-- | building_guide/concurrency.md | 122 |
1 files changed, 122 insertions, 0 deletions
diff --git a/building_guide/concurrency.md b/building_guide/concurrency.md new file mode 100644 index 0000000..1a61670 --- /dev/null +++ b/building_guide/concurrency.md @@ -0,0 +1,122 @@ +# Concurrency & durability + +This guide documents the runtime architecture decisions that keep live game +state safe: the single-owner loop, the durable money journal, the account +lifecycle transactions, and the graceful-shutdown sequence. + +## Single-owner game state + +One goroutine — the **owner loop** (`internal/game/owner.go`) — executes all +mutation of live game state: session input handlers, connect/disconnect +transitions, every tick callback (the master per-tick system sequence and all +dynamically registered callbacks), and admin mutations. Network and admin +goroutines submit tasks; they never touch live players, sessions, Hub +membership, or game runtime maps directly. + +Rules: + +- `Game.RunOnOwner(fn)` (and `Game.HandleSessionOnOwner` for session input) + block the caller until the closure has executed on the owner. Connection + goroutines submit one task per input line, giving per-client backpressure + without ever blocking another client. +- Code already running on the owner must NOT call `Game.RunOnOwner` — it + would deadlock waiting for itself. Owner-executed code calls functions + directly. +- Task execution is FIFO, so tick callbacks serialize against session input + and admin mutations in submission order. Tick callbacks still run in + engine registration-ID order (see tick_order.md); the owner wrapper only + moves their execution onto the owner goroutine. +- Slow clients cannot stall the world: `Session` writes are queued + (`net.Session.StartWriter` puts a transport session into queued mode) and a + dedicated per-session writer goroutine performs the network I/O with + per-message write deadlines. A full queue disconnects only that client. +- Saves run on the owner and serialize a value snapshot; concurrent + read-modify-write cycles of the same files cannot happen. + +Unit tests construct Games without an owner; such Games run submitted tasks +inline on the caller's goroutine (legacy behavior), which is fine for +synthetic tests but production Games always have the owner (started by +`game.New`). + +## Durable money (casino wallet) + +Every wager and payout is journaled before the balance mutation +(`internal/game/economy.go`, `data/economy/journal.jsonl`): + +1. A durable journal record with balance checkpoints (before/after) is + appended and fsynced. +2. The in-memory balance mutates. +3. The character save makes the balance durable. +4. A settlement marker closes the record. + +A failed save reverts the in-memory mutation and voids the record — the +game must not proceed on a stake that could not be made durable, and the +player is told the house could not process the transaction (distinct from an +insufficient-funds message). Payouts keep their record open when the save +fails; startup recovery re-pays the owed settlement. + +At startup (`game.New`), open records are reduced (latest line per record ID +wins) and compensated: + +- open **wager** with the file at the pre-debit balance → void (nothing was + charged); +- open **wager** with the file at the post-debit balance → the stake is + **refunded** (the round state was lost in the crash); +- open **payout** with the file at the pre-payout balance → the payout is + **re-applied** (the player is owed it); +- otherwise the record settles, or is surfaced for operator inspection when + the balance is ambiguous. + +Currency arithmetic is checked (`internal/casino/credits.go`): explicit +`MaxCredits` cap, bounded `DefaultMaxBet` default (the explicit unlimited +sentinel is still honored), floor conversion for payout multipliers, and +overflow rejected instead of wrapping. + +## Account lifecycle transactions + +Account and character lifecycle operations (create, rename, delete, purge) +are single journal transactions (`internal/persistence/txn.go`): a journal +entry with before-images is written durably BEFORE any file changes, the +operations apply, and the journal is removed at the commit point. Any +failure rolls back every applied operation; a crash mid-commit is rolled +back at the next startup by `persistence.Recover`. + +Characters carry a stable UUID (`player.Player.ID`) and owner field +(`player.Player.Account`), assigned at creation and backfilled at first +startup. Cached membership authorizes by the character file's owner, not by +its renameable filename, so a stale cached name can never authorize +connecting, renaming, or deleting a character reassigned to another account. + +After any lifecycle mutation, `Game.publishAccountUpdate` republishes the +account state to every authenticated session of that account — including +sessions sitting in menus — resetting stale pending selections. The online +check and the file mutation run as one owner step, closing the +check-then-rename gap. + +## Graceful shutdown + +Signals (SIGINT/SIGTERM) and the in-game `shutdown` command converge on +`Game.RequestShutdown`, which runs the sequence exactly once: + +1. **Admissions stop:** session input and disconnect forfeit/save callbacks + become no-ops (server-initiated shutdown is not a player disconnect). +2. **Tick stop and join:** `Engine.StopAndWait` stops the ticker and waits + for the in-flight callback to finish. +3. **Drain and persist (on the owner):** remaining queued commands execute, + outstanding wagers are refunded (`Casino.RefundAll`), and every online + character is saved. Save failures are logged for the operator. +4. **Owner stop:** the owner loop drains and stops. +5. **Transports close:** HTTP(S) servers drain, listeners close, and + sessions flush their outbound queues before their sockets drop. + `ListenAndServe` returns and main exits normally — no `os.Exit`. + +## Room-ID migration + +Live room-ID changes (admin rename, swapid) execute as one mutation plan: +the renamed room's file (id field, filename, self-loop references) plus every +other room whose references moved, applied as a single journal transaction, +followed by the full runtime migration (`Game.MigrateRoomIDs`): player +state, Hub room buckets, casino room keys, world room-keyed state (ground +items, seeded markers, object states), and live mob instances. Undo/redo +store the whole plan (all before/after images plus the idMap) and apply the +inverse/forward runtime migration through the same wired callback. |
