diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/tick.go | 3 | ||||
| -rw-r--r-- | internal/game/tick_systems.go | 60 | ||||
| -rw-r--r-- | internal/game/tick_systems_test.go | 147 |
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 |
