aboutsummaryrefslogtreecommitdiff
path: root/building_guide/concurrency.md
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 15:35:52 -0400
committerhistoria <[not public]>2026-09-15 15:35:52 -0400
commit0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch)
tree2ceaef5203595044f66a8f00064cd8e017f8f48d /building_guide/concurrency.md
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'building_guide/concurrency.md')
-rw-r--r--building_guide/concurrency.md122
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.