aboutsummaryrefslogtreecommitdiff
path: root/building_guide/concurrency.md
blob: ff8c049b0754cb2df36725e894cfdcf9596dfc39 (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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# 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.
- A FULL task queue applies backpressure: submitters block until the owner
  drains a task, and a full queue never bypasses the owner — running a task
  on the caller's goroutine while the owner keeps executing would break the
  single-owner invariant. Only a STOPPED owner (graceful shutdown, after
  admissions have closed) lets stragglers execute inline.
- 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 credits
   and chips) is appended and fsynced.
2. The in-memory balance mutates.
3. The character save makes the balance durable.

Two record kinds exist:

- **wager** — a committed stake. The record stays OPEN while the stake is
  outstanding (round in flight) and is resolved when the round settles:
  won, lost, pushed, forfeited (disconnect), or refunded (shutdown). Games
  resolve stakes through `casino.Wallet.ResolveStakes` after settlement;
  `casino.Wallet.RefundOutstanding` returns every outstanding stake
  (chips as chips, credits durably) at shutdown/eviction, exactly once.
- **payout** — money movement (winnings, pushes, refunds). The record
  closes when the balance mutation is durable.

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). A failed payout keeps its record open (the
reported win is never announced as paid) and startup recovery re-pays the
owed settlement.

At startup (`game.New`), open records are compensated in two phases:

1. Open **payout** records (append order, evolving balance): file at the
   pre-payout balance → re-apply (the player is owed it); at the post-payout
   balance → already durable, settle; anything else stays open and is
   surfaced for operator inspection — a liability is never silently
   dropped.
2. Open **wager** records (newest first, so each refund restores the
   balance the previous record's checkpoints refer to): file at the
   pre-debit state (credits AND chips) → void (nothing was charged); at
   the post-debit state → the stake is **refunded** (the round state was
   lost in the crash) — unless a later payout record proves the round
   resolved (the win/push/refund already returned the stake inside that
   payout), in which case only the lost resolution marker is re-applied.
   Ambiguous records stay open for operator inspection.

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.