aboutsummaryrefslogtreecommitdiff
path: root/building_guide/concurrency.md
blob: 1a616702c1df226f59b813e1e8e669423126d356 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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.