aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-17 20:15:57 -0400
committerhistoria <[not public]>2026-07-17 20:15:57 -0400
commit9898d42f2d4ef61b5cbec7e212914d1495e04772 (patch)
treeafa604ad77a7a03832ebbd60e77c47d4961b9d77 /internal/game
parentc23c87b56fd568956d263bb8b8c5d26fbd8d01ee (diff)
downloadthehouseoficarus-9898d42f2d4ef61b5cbec7e212914d1495e04772.tar.gz
feat: add deterministic order to mob/player tick processing, document how tick processing works
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/tick.go3
-rw-r--r--internal/game/tick_systems.go60
-rw-r--r--internal/game/tick_systems_test.go147
3 files changed, 210 insertions, 0 deletions
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