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{}, } }