diff options
Diffstat (limited to 'dev_guide/tick_order.md')
| -rw-r--r-- | dev_guide/tick_order.md | 143 |
1 files changed, 143 insertions, 0 deletions
diff --git a/dev_guide/tick_order.md b/dev_guide/tick_order.md new file mode 100644 index 0000000..b1246dd --- /dev/null +++ b/dev_guide/tick_order.md @@ -0,0 +1,143 @@ +## 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 | `CasinoTick` | Advances casino betting deadlines and resolves completed casino rounds. | +| 3 | `MoveTick` | Counts down `p.MoveTicks`; on reach-zero, completes a pending run/walk and re-prompts. | +| 4 | `SequenceTick` | Advances every active trigger `sequence` (per-player and global) one step. | +| 5 | `TransientMobTick` | Despawns owned / `DespawnOnLeave` mobs whose owner has left their room. | +| 6 | `World.Tick` | World-internal: ground-item respawn/despawn/reserve timers and shared-object deplete timers. | +| 7 | `MobStore.Tick` | Mob shop restock and mob HP regenerate. Order: by `InstanceID` so processing is stable across ticks. | +| 8 | `RegenTick` | Player HP regeneration, accelerated by tech HP-regen multipliers. | +| 9 | `EnergyRegenTick` | Run-energy regeneration for every online player. | +| 10 | `DisconnectTick` | Counts down pending disconnects and hard-removes finished sessions (skipping players still in combat). | +| 11 | `WanderTick` | Mob wandering (exit-based) and object wandering (fishing spots), with enter/leave messages to observers. | +| 12 | `SharedDepletionTick` | Ticks shared-object deplete timers up (in use) or down (recovering). | +| 13 | `FireTick` | Decays fire objects; on burn-out, spawns the removal item and cancels stokers. | +| 14 | `AdvanceActions` | Advances every active player action by one tick; fires completion handlers per action type. | +| 15 | `TechTick` | Drains battery for active tech, applies equipment / PreserveDrain reductions, and deactivates all on empty. | +| 16 | `ConsumeTick` | Counts down `ConsumeCooldown` and `HomeTransportCooldown`. | +| 17 | `BroadcastRespawns` | Flushes respawned world objects and broadcasts their `respawn_broadcast` lines to players in those rooms. | +| 18 | `VisualTick` | Optional per-player "visual tick" line/counter for AFK feedback. | +| 19 | `SneakTick` | Advances guard watch timers and sneaking players' tick counters. | +| 20 | `FarmTick` | Crops grow, drain water, and yield on harvest (fires every `FarmTickInterval` ticks). | +| 21 | `BuffTick` | Potion buff duration countdown; expires and removes effects. | +| 22 | `SafespotTick` | Safespot unsafe-chance and decay-chance rolls per player. | +| 23 | `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 23 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. + (`main.go` now calls `SetHub` *before* `Ticks.Start`, so the production + server never ticks with a nil hub; the guards are belt-and-braces for a + `*Game` that tests construct without `SetHub`, such as + `newRunTickSystemsTestGame`.) +- 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` (table #3) runs before `SequenceTick` (table #4) — the table + fires top-to-bottom, so a move that completes this tick will have its + enter-room knock-on triggers fire (inside `ProcessQueuedCommands`, #1, or + `MoveTick`, #3) before trigger `sequences` advance in #4. +- `on_enter` / `on_exit` room triggers fire from command execution, not + from a per-tick system; they run inside `ProcessQueuedCommands` (#1) + or `MoveTick` (#3) when the move actually completes. +- Shop restock (`MobStore.Tick`, #7) happens *before* `RegenTick` (#8) and + *before* `AdvanceActions` (#14); if a gathering action completes this tick + it does so in #14, 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. |
