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
|
## Per-Tick System Order
The server runs on a fixed game tick (600ms by default; configurable via
`tick_length` in `config.yaml`). Each tick, every online player's pending
commands resolve and a fixed sequence of systems advance world state. The
**canonical order** in which these per-tick systems fire is a load-bearing
contract: combat resolution, deplete/restock timers, regen, respawns, and
sequence-driven triggers all depend on it. Reordering it is a breaking change.
### Source of truth
The authoritative list lives in code, not in this doc:
- `internal/game/tick_systems.go` — the `tickSystemOrder` slice and
`(*Game).RunTickSystems()`, the method that fires them in order.
- `cmd/thoi/main.go` — the master per-tick subscriber, registered with the
engine as **subscription ID 1**, whose body is `g.RunTickSystems()`. The
engine (`internal/engine/tick.go` `processTick`) sorts subscribers by
registration ID, so the master subscriber fires first each tick, before
any dynamically-registered combat / aggro / respawn callbacks (which get
higher IDs as players act).
- `internal/game/tick_systems_test.go` — `TestTickSystemOrder` fails if the
table drifts from the list below. `TestRunTickSystemsEmptyGameIsSafe`
guards the "safe to tick with no players connected" contract every system
must uphold (see "Per-system contracts" below).
If you change the order, add a system, or remove one: update `tickSystemOrder`
in `tick_systems.go`, the `expectedTickSystemOrder` list in
`tick_systems_test.go`, and the table in this file. All three are intentional
duplicates so any one of them drifting fails the test loudly.
### Canonical sequence
| # | System | What it does |
| --- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| 1 | `ProcessQueuedCommands` | Drains each player's free-queued commands and the globally timestamp-sorted active queue; advances walk sequences. |
| 2 | `MoveTick` | Counts down `p.MoveTicks`; on reach-zero, completes a pending run/walk and re-prompts. |
| 3 | `SequenceTick` | Advances every active trigger `sequence` (per-player and global) one step. |
| 4 | `TransientMobTick` | Despawns owned / `DespawnOnLeave` mobs whose owner has left their room. |
| 5 | `World.Tick` | World-internal: ground-item respawn/despawn/reserve timers and shared-object deplete timers. |
| 6 | `MobStore.Tick` | Mob shop restock and mob HP regenerate. Order: by `InstanceID` so processing is stable across ticks. |
| 7 | `RegenTick` | Player HP regeneration, accelerated by tech HP-regen multipliers. |
| 8 | `EnergyRegenTick` | Run-energy regeneration for every online player. |
| 9 | `DisconnectTick` | Counts down pending disconnects and hard-removes finished sessions (skipping players still in combat). |
| 10 | `WanderTick` | Mob wandering (exit-based) and object wandering (fishing spots), with enter/leave messages to observers. |
| 11 | `SharedDepletionTick` | Ticks shared-object deplete timers up (in use) or down (recovering). |
| 12 | `FireTick` | Decays fire objects; on burn-out, spawns the removal item and cancels stokers. |
| 13 | `AdvanceActions` | Advances every active player action by one tick; fires completion handlers per action type. |
| 14 | `TechTick` | Drains battery for active tech, applies equipment / PreserveDrain reductions, and deactivates all on empty. |
| 15 | `ConsumeTick` | Counts down `ConsumeCooldown` and `HomeTransportCooldown`. |
| 16 | `BroadcastRespawns` | Flushes respawned world objects and broadcasts their `respawn_broadcast` lines to players in those rooms. |
| 17 | `VisualTick` | Optional per-player "visual tick" line/counter for AFK feedback. |
| 18 | `SneakTick` | Advances guard watch timers and sneaking players' tick counters. |
| 19 | `FarmTick` | Crops grow, drain water, and yield on harvest (fires every `FarmTickInterval` ticks). |
| 20 | `BuffTick` | Potion buff duration countdown; expires and removes effects. |
| 21 | `SafespotTick` | Safespot unsafe-chance and decay-chance rolls per player. |
| 22 | `HazardTick` | Room hazard damage to non-GodMode players. |
### Determinism contract
Three layers of ordering decide what happens on a given tick, in priority:
1. **Subscriber order** — deterministic. `engine.processTick` sorts
subscribers by registration ID. The master subscriber (ID 1, set in
`cmd/thoi/main.go`) always fires first; every system in
`RunTickSystems` then runs in the table order above. Dynamic combat /
aggro / respawn subscribers registered later get higher IDs and fire
after the canonical 22 systems. This is asserted by
`internal/engine/tick_test.go::TestProcessTickFiresInSubscriptionOrder`.
2. **Per-system iteration order** — deterministic, as of the deterministic
per-tick subscriber work. Systems that iterate over players or mobs use
stable ordering:
- `Hub.AllSessions()` and `Hub.PlayersInRoom` (`internal/net/server.go`)
sort sessions by `Session.seq`, a monotonic counter assigned in
`Hub.Add` in connection order.
- `MobStore.AllInstances()`, `MobStore.MobsInRoom()`, and `MobStore.Tick()`
(`internal/world/mob.go`) sort by `MobInstance.InstanceID`.
This means, for example, that cosmetic "X enters." / "Y leaves."
interleavings in `WanderTick`, shop-restock order in `MobStore.Tick`,
and per-player processing order in `RegenTick` / `HazardTick` /
`SneakTick` etc. are reproducible across runs.
3. **Random-number generation** — **not deterministic across server
restarts.** The `math/rand` top-level functions used across the engine
(`rand.Float64`, `rand.Intn`, `rand.Int`, `rand.Shuffle`) are
auto-seeded at program startup (Go 1.20+; calling `rand.Seed` is
deprecated and is never done here). So combat hit rolls, drop rolls,
wandering exit picks, and minigame randomness differ between server
runs. Two ticks from the same seeded run are reproducible *relative* to
each other (the global source is deterministic), but a fresh server
start produces a different stream.
Only `rollAggroRand` (`internal/game/sys_combat.go`) is replaceable for
tests; the rest are global-function calls. Reproducible full-tick
traces (e.g. for record/replay debugging) would require threading a
seeded `*math/rand.Rand`/`math/rand/v2.ChaCha8` through the engine; not
currently done.
### Per-system contracts (the empty-game invariant)
Every system in the table must be safe to invoke on a `*Game` that has its
stores initialized (`World`, `MobStore`, `Hub`, `queue`, etc. via
`game.New` + `SetHub`) but **no players connected**. Concretely:
- Systems that touch the hub: guard with `if g.Hub == nil { return }` at the
top, *or* guard each `Hub.*` call with `if g.Hub != nil`. Either is fine.
(Note: `Ticks.Start` runs *before* `SetHub` in `main.go`, so the master
subscriber can fire with `Hub == nil` during startup — guards are not
optional.)
- Systems that iterate over maps/slices held on `*Game` (`sequences`,
`restTimers`, `guardWatchTimers`, `hackingStates`, …) must tolerate empty
/ freshly-initialized maps. `game.New` initializes all of them; tests that
build a stub `*Game` must too (see `newRunTickSystemsTestGame`).
Violating either of these is caught by
`TestRunTickSystemsEmptyGameIsSafe` in `internal/game/tick_systems_test.go`.
If you add a new system that needs a new field initialized, update that
helper as well.
### Notes for builders
When writing content that depends on per-tick ordering (a trigger that
should fire *after* regen, a sequence whose step messages should appear
*before* the move completes, etc.), consult the table above and remember:
- `MoveTick` (step 2) runs before `SequenceTick` (step 3) — the table
fires top-to-bottom, so a move that completes this tick will have its
enter-room knock-on triggers fire (inside step 1 or step 2) before
trigger `sequences` advance in step 3.
- `on_enter` / `on_exit` room triggers fire from command execution, not
from a per-tick system; they run inside `ProcessQueuedCommands` (step 1)
or `MoveTick` (step 2) when the move actually completes.
- Shop restock (step 6) happens *before* `RegenTick` (step 7) and *before*
`AdvanceActions` (step 13); if a gathering action completes this tick
it does so in step 13, by which point shop state for this tick has
already advanced.
When in doubt, the test in `tick_systems_test.go` is the executable
specification.
|