aboutsummaryrefslogtreecommitdiff
path: root/internal/game/tick_systems_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/tick_systems_test.go')
-rw-r--r--internal/game/tick_systems_test.go147
1 files changed, 147 insertions, 0 deletions
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