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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
|
# 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`).
## Admin editor isolation
All `/api/` routes run through one boundary in `internal/admin/owner.go`:
1. Authenticate, read the request body (maximum 8 MiB) on the HTTP goroutine.
2. Submit one synchronous `Game.RunAdminOnOwner` task. The **whole handler**
runs there: validation against current files, plan construction, file
commits, runtime migration/eviction, store reloads, and undo history.
3. Write the buffered response on the HTTP goroutine after the task finishes.
Reads use the same boundary so editor API reads cannot observe half an edit.
Neither slow uploads nor slow downloads hold the owner. File I/O does: large
bulk edits can briefly pause gameplay. This deliberately avoids a second
transaction scheduler, fine-grained locks, or optimistic plan retries. Use a
maintenance window for large world reorganizations.
`RunAdminOnOwner` rejects requests during shutdown with HTTP 503, including
requests queued before shutdown but not yet started. It never executes on the
HTTP goroutine after the owner stops. A disconnected request is skipped if it
has not started; once started, its edit completes even if the browser leaves.
A failed/lost HTTP response is not proof that the edit did not commit.
### Endpoint inventory and callback contract
| API family (all behind the same boundary) | Mutations / live dependencies |
| --- | --- |
| `/rooms`, `/rooms/{id}`, `/rooms/new-empty` | Room create/update/delete, room index and runtime room state |
| `/rooms/link`, `/rooms/insert`, `/rooms/remove`, `/rooms/move`, `/rooms/rename`, `/rooms/swap` | Multi-file room plans, course changes, room-ID migration or eviction where applicable |
| `/rooms/bulk-edit`, `/rooms/bulk-delete`, `/rooms/resolve-duplicate` | Bulk room changes, indexes and runtime cleanup |
| `/room-dirs/create`, `/room-dirs/rename` | Room file organization and indexes |
| `/items`, `/mobs`, `/drops`, `/hazards` and their ID routes | Definitions plus existing cache/index invalidation; items also rebuild crafting |
| `/objects`, `/techs`, `/modules`, `/triggers` and their ID routes | Definition files; existing manual-reload semantics remain unchanged |
| Entity `/rename`, `/move`, `/dirs` routes | Definition file organization, undo history, existing cache invalidation |
| `/courses`, `/courses/{id}` | Course files, exit reconciliation, course store reload |
| `/files`, `/files/rename` | Raw file data and history; NOT a semantic room-ID migration or automatic cache reload |
| `/colors`, `/colors/reset` | Persisted config and live color maps |
| `/undo/undo`, `/undo/redo` | File images, room-ID migration, index/cache rebuilds, history together |
| Remaining API routes | Read-only queries; serialized to get coherent snapshots |
Register new endpoints on `apiMux`, never outside this boundary. Do not wrap
individual callbacks in `RunOnOwner`: they are **already on the owner**.
`cmd/thoi/main.go` wires direct course reload, craft rebuild, casino eviction,
and `MigrateRoomIDsOnOwner` callbacks. `UndoStack`'s mutex protects history,
not gameplay: its apply path and the following rebuild must remain in the
same enclosing API task. Login password rehash saves and history clearing
also run on the owner, with expensive password hashing outside it.
This guarantees **isolation from gameplay**, not new crash-atomicity or
rollback guarantees for every editor operation. Existing file transaction
and error behavior is unchanged. Raw Files edits and external filesystem
writes still bypass semantic validation/migrations; use structured room
operations for live renumbering, and stop the game for external bulk edits.
Some definition editors still need the in-game `reload` command; serialization
does not add missing cache invalidations or migrate existing spawned entities.
## 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 within the same owner task by the full runtime migration
(`Game.MigrateRoomIDsOnOwner` for the editor): 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.
|