aboutsummaryrefslogtreecommitdiff
path: root/internal/game/tick_systems_test.go
blob: 6b44e913c45e3f448f6a8387680e9946ca305607 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
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{},
	}
}