aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--building_guide/tick_order.md141
-rw-r--r--cmd/thoi/main.go29
-rw-r--r--internal/game/tick.go3
-rw-r--r--internal/game/tick_systems.go60
-rw-r--r--internal/game/tick_systems_test.go147
-rw-r--r--internal/net/server.go19
-rw-r--r--internal/world/mob.go18
7 files changed, 394 insertions, 23 deletions
diff --git a/building_guide/tick_order.md b/building_guide/tick_order.md
new file mode 100644
index 0000000..2d22ac9
--- /dev/null
+++ b/building_guide/tick_order.md
@@ -0,0 +1,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:
+
+- Trigger `sequences` advance in step 3, **before** movement completes in
+ step 2 has any knock-on effects like enter-room triggers — wait, step 2
+ (MoveTick) actually runs *before* step 3 (SequenceTick) because the table
+ is fired top-to-bottom. Double-check the exact step your event lives in.
+- `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. \ No newline at end of file
diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go
index 254db69..27b32f4 100644
--- a/cmd/thoi/main.go
+++ b/cmd/thoi/main.go
@@ -45,29 +45,14 @@ func main() {
g.Ticks.Start(cfg.TickLength)
defer g.Ticks.Stop()
+ // The master per-tick subscriber. Registered as engine subscription ID 1,
+ // the engine fires it before any dynamically-registered combat/aggro/respawn
+ // callbacks (engine.processTick sorts subscribers by registration ID). Its
+ // body — the canonical per-tick system sequence — is enforced and tested in
+ // internal/game/tick_systems.go and documented in
+ // building_guide/tick_order.md. Reordering it is a breaking change.
g.Ticks.Subscribe(1, func() bool {
- g.ProcessQueuedCommands()
- g.MoveTick()
- g.SequenceTick()
- g.TransientMobTick()
- g.World.Tick()
- g.MobStore.Tick()
- g.RegenTick()
- g.EnergyRegenTick()
- g.DisconnectTick()
- g.WanderTick()
- g.SharedDepletionTick()
- g.FireTick()
- g.AdvanceActions()
- g.TechTick()
- g.ConsumeTick()
- g.BroadcastRespawns()
- g.VisualTick()
- g.SneakTick()
- g.FarmTick()
- g.BuffTick()
- g.SafespotTick()
- g.HazardTick()
+ g.RunTickSystems()
return true
})
diff --git a/internal/game/tick.go b/internal/game/tick.go
index d765983..8910137 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -161,6 +161,9 @@ func (g *Game) WanderTick() {
}
func (g *Game) SharedDepletionTick() {
+ if g.Hub == nil {
+ return
+ }
activeKeys := make(map[string]bool)
for _, sess := range g.Hub.AllSessions() {
p := sess.Player
diff --git a/internal/game/tick_systems.go b/internal/game/tick_systems.go
new file mode 100644
index 0000000..4d8d4b6
--- /dev/null
+++ b/internal/game/tick_systems.go
@@ -0,0 +1,60 @@
+package game
+
+// tickSystem is one step of the canonical per-tick system sequence. Name is
+// the human-readable identifier also encoded in building_guide/tick_order.md;
+// Run invokes the underlying system. The slice tickSystemOrder is the single
+// executable source of truth for the order in which per-tick systems fire.
+//
+// The order is what callers like the master subscriber in cmd/thoi/main.go
+// (registered as engine subscription ID 1, which the engine fires before all
+// dynamically-registered combat/aggro/respawn callbacks per processTick's
+// registration-ID sort) depend on. Reordering this slice is a breaking change
+// to game behavior; keep building_guide/tick_order.md in sync.
+type tickSystem struct {
+ Name string
+ Run func(*Game)
+}
+
+// TickSystemOrder returns the canonical per-tick system sequence. It is a
+// copy of the internal slice so callers cannot mutate the canonical order.
+func TickSystemOrder() []struct{ Name string } {
+ out := make([]struct{ Name string }, len(tickSystemOrder))
+ for i, s := range tickSystemOrder {
+ out[i] = struct{ Name string }{s.Name}
+ }
+ return out
+}
+
+var tickSystemOrder = []tickSystem{
+ {"ProcessQueuedCommands", (*Game).ProcessQueuedCommands},
+ {"MoveTick", (*Game).MoveTick},
+ {"SequenceTick", (*Game).SequenceTick},
+ {"TransientMobTick", (*Game).TransientMobTick},
+ {"World.Tick", func(g *Game) { g.World.Tick() }},
+ {"MobStore.Tick", func(g *Game) { g.MobStore.Tick() }},
+ {"RegenTick", (*Game).RegenTick},
+ {"EnergyRegenTick", (*Game).EnergyRegenTick},
+ {"DisconnectTick", (*Game).DisconnectTick},
+ {"WanderTick", (*Game).WanderTick},
+ {"SharedDepletionTick", (*Game).SharedDepletionTick},
+ {"FireTick", (*Game).FireTick},
+ {"AdvanceActions", (*Game).AdvanceActions},
+ {"TechTick", (*Game).TechTick},
+ {"ConsumeTick", (*Game).ConsumeTick},
+ {"BroadcastRespawns", (*Game).BroadcastRespawns},
+ {"VisualTick", (*Game).VisualTick},
+ {"SneakTick", (*Game).SneakTick},
+ {"FarmTick", (*Game).FarmTick},
+ {"BuffTick", (*Game).BuffTick},
+ {"SafespotTick", (*Game).SafespotTick},
+ {"HazardTick", (*Game).HazardTick},
+}
+
+// RunTickSystems fires every canonical per-tick system in registration order.
+// It is the body of the master per-tick subscriber in cmd/thoi/main.go and the
+// single place that encodes per-tick system execution order.
+func (g *Game) RunTickSystems() {
+ for _, s := range tickSystemOrder {
+ s.Run(g)
+ }
+} \ No newline at end of file
diff --git a/internal/game/tick_systems_test.go b/internal/game/tick_systems_test.go
new file mode 100644
index 0000000..6b44e91
--- /dev/null
+++ b/internal/game/tick_systems_test.go
@@ -0,0 +1,147 @@
+package game
+
+import (
+ "reflect"
+ "runtime"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/game/hacking"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+// expectedTickSystemOrder is the canonical per-tick system sequence. It MUST
+// stay in sync with tickSystemOrder in tick_systems.go and with
+// building_guide/tick_order.md. Updating one without the others is a bug.
+//
+// The list is intentionally spelled out here (rather than derived from
+// tickSystemOrder) so the test fails loudly when someone reorders, adds, or
+// removes a system in tickSystemOrder without also updating the documented
+// contract.
+var expectedTickSystemOrder = []string{
+ "ProcessQueuedCommands",
+ "MoveTick",
+ "SequenceTick",
+ "TransientMobTick",
+ "World.Tick",
+ "MobStore.Tick",
+ "RegenTick",
+ "EnergyRegenTick",
+ "DisconnectTick",
+ "WanderTick",
+ "SharedDepletionTick",
+ "FireTick",
+ "AdvanceActions",
+ "TechTick",
+ "ConsumeTick",
+ "BroadcastRespawns",
+ "VisualTick",
+ "SneakTick",
+ "FarmTick",
+ "BuffTick",
+ "SafespotTick",
+ "HazardTick",
+}
+
+// TestTickSystemOrder asserts the canonical per-tick system sequence matches
+// the documented order exactly. This is the regression guard referenced in
+// building_guide/tick_order.md and in the master subscriber doc comment in
+// cmd/thoi/main.go.
+func TestTickSystemOrder(t *testing.T) {
+ got := TickSystemOrder()
+ if len(got) != len(expectedTickSystemOrder) {
+ t.Fatalf("tick system count changed: got %d, want %d (update expectedTickSystemOrder and building_guide/tick_order.md)",
+ len(got), len(expectedTickSystemOrder))
+ }
+ for i, want := range expectedTickSystemOrder {
+ if got[i].Name != want {
+ t.Errorf("tick system %d: got %q, want %q (reordered? update building_guide/tick_order.md)",
+ i, got[i].Name, want)
+ }
+ }
+}
+
+// TestTickSystemTableRunsValidMethods sanity-checks that every Run entry in
+// tickSystemOrder is a non-nil callable that resolves to one of the *Game
+// tick methods (or one of the two wrapped delegations World.Tick /
+// MobStore.Tick). This guards against typos like swapping two method
+// pointers or leaving a nil entry. It deliberately does not invoke them,
+// since RunTickSystems itself is exercised by every game-package tick test.
+func TestTickSystemTableRunsValidMethods(t *testing.T) {
+ if len(tickSystemOrder) == 0 {
+ t.Fatal("tickSystemOrder is empty — RunTickSystems would do nothing on each tick")
+ }
+ for i, s := range tickSystemOrder {
+ if s.Name == "" {
+ t.Errorf("tick system %d has empty Name", i)
+ }
+ if s.Run == nil {
+ t.Errorf("tick system %d (%q) has nil Run", i, s.Name)
+ }
+ if s.Name != "World.Tick" && s.Name != "MobStore.Tick" {
+ // Wrapped closures: too fragile to introspect by reflect name; skip.
+ pc := reflect.ValueOf(s.Run).Pointer()
+ fn := runtime.FuncForPC(pc)
+ if fn == nil {
+ t.Errorf("tick system %d (%q): Run did not resolve to a *Game method", i, s.Name)
+ continue
+ }
+ full := fn.Name()
+ // full is like "thehouseoficarus/internal/game.(*Game).MoveTick"
+ if !strings.Contains(full, "(*Game).") {
+ t.Errorf("tick system %d (%q): Run did not resolve to a *Game method (got %q)",
+ i, s.Name, full)
+ continue
+ }
+ base := full[strings.LastIndex(full, ".")+1:]
+ if base != s.Name {
+ t.Errorf("tick system %d: Name=%q but Run points at %q", i, s.Name, base)
+ }
+ }
+ }
+}
+
+// TestRunTickSystemsEmptyGameIsSafe asserts RunTickSystems is safe to fire
+// with a fully-initialized Game that has no players connected. This matches
+// the production invariant: every per-tick *Game system early-returns when
+// g.Hub == nil (or, after SetHub, iterates zero sessions), and the
+// World/MobStore wrappers operate on their own stores. A future tick system
+// that touches g.Hub without a nil guard, or a store we forget to
+// initialize, would panic here and crash the live engine goroutine in
+// production. This test is the regression guard for that contract.
+func TestRunTickSystemsEmptyGameIsSafe(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ buf := make([]byte, 4096)
+ n := runtime.Stack(buf, false)
+ t.Fatalf("RunTickSystems panics on empty game: %v\n%s", r, string(buf[:n]))
+ }
+ }()
+ g := newRunTickSystemsTestGame()
+ g.RunTickSystems()
+}
+
+// newRunTickSystemsTestGame builds a *Game with the stores RunTickSystems
+// reaches (World, MobStore) initialized and a fresh Hub with no sessions,
+// matching the production state right after game.New + SetHub but before any
+// client connects.
+func newRunTickSystemsTestGame() *Game {
+ return &Game{
+ Deps: Deps{
+ World: world.New(""),
+ MobStore: world.NewMobStore(""),
+ },
+ Hub: net.NewHub(),
+ GlobalFlags: NewGlobalFlagStore(),
+ Combat: combat.NewTracker(),
+ flagIndex: newFlagTriggerIndex(),
+ queue: NewCommandQueue(),
+ safespot: NewSafespotManager(),
+ restTimers: map[string]uint64{},
+ guardWatchTimers: map[string]int{},
+ hackingStates: map[string]*hacking.Session{},
+ sequences: map[string]*sequence{},
+ }
+} \ No newline at end of file
diff --git a/internal/net/server.go b/internal/net/server.go
index 11339d7..9da90ea 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -11,7 +11,9 @@ import (
"math/big"
"net"
"net/http"
+ "sort"
"sync"
+ "sync/atomic"
"time"
"thehouseoficarus/internal/color"
@@ -70,8 +72,22 @@ type Session struct {
PendingUndigRoom int
promptVisible bool
writeMu sync.Mutex
+
+ // seq is a monotonically increasing sequence number assigned when the
+ // session is added to a Hub. Hub.AllSessions and Hub.PlayersInRoom sort
+ // sessions by seq so per-tick consumers iterate players in a stable,
+ // registration order instead of Go's randomized map iteration order.
+ // Sessions constructed without being added (e.g. in tests) keep seq=0
+ // and tie-break by map-append order, which is also deterministic.
+ seq uint64
}
+// sessionSeq is the source of Session.seq values. It is incremented under the
+// adding Hub's mutex, so no extra atomicity is needed for correctness; atomic
+// access keeps the package-level default counter safe if Hub.Add is ever
+// called concurrently across hubs (it isn't today, but this is defensive).
+var sessionSeq atomic.Uint64
+
type Server struct {
config *config.Config
telnetLn net.Listener
@@ -106,6 +122,7 @@ func (h *Hub) OnRemove(cb func(*Session)) {
func (h *Hub) Add(s *Session) {
h.mu.Lock()
defer h.mu.Unlock()
+ s.seq = sessionSeq.Add(1)
h.sessions[s] = true
}
@@ -165,6 +182,7 @@ func (h *Hub) AllSessions() []*Session {
for s := range h.sessions {
out = append(out, s)
}
+ sort.SliceStable(out, func(i, j int) bool { return out[i].seq < out[j].seq })
return out
}
@@ -177,6 +195,7 @@ func (h *Hub) PlayersInRoom(roomID int) []*Session {
out = append(out, s)
}
}
+ sort.SliceStable(out, func(i, j int) bool { return out[i].seq < out[j].seq })
return out
}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index fa0cdbf..f33b2f9 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -5,6 +5,7 @@ import (
"math/rand"
"os"
"path/filepath"
+ "sort"
"strings"
"sync"
@@ -476,6 +477,7 @@ func (s *MobStore) AllInstances() []*MobInstance {
for _, inst := range s.instances {
out = append(out, inst)
}
+ sort.SliceStable(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
return out
}
@@ -499,6 +501,20 @@ func (s *MobStore) MobsInRoom(roomID int) []*MobInstance {
out = append(out, inst)
}
}
+ sort.SliceStable(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
+ return out
+}
+
+// sortedInstancesLocked returns every mob instance sorted by InstanceID. It
+// must be called with s.mu held; callers iterate the returned slice instead of
+// ranging s.instances directly so per-tick processing order is stable across
+// Go's randomized map iteration.
+func (s *MobStore) sortedInstancesLocked() []*MobInstance {
+ out := make([]*MobInstance, 0, len(s.instances))
+ for _, inst := range s.instances {
+ out = append(out, inst)
+ }
+ sort.SliceStable(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
return out
}
@@ -506,7 +522,7 @@ func (s *MobStore) Tick() {
s.mu.Lock()
defer s.mu.Unlock()
- for _, inst := range s.instances {
+ for _, inst := range s.sortedInstancesLocked() {
if inst.Shop != nil {
s.tickShopLocked(inst)
}