aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hardening_tick_lifecycle_test.go
blob: b5b3bb9ca3e97e80bfb4e9ce27119d20525e2856 (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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
package game

// Tick and lifecycle liveness regressions: a pending aggro retry must never
// start combat for a departed player, an in-combat disconnect must not pin the
// character in the registry forever, per-tick systems must stay safe with a
// nil Hub and must advance in a reproducible order, and the per-tick session
// snapshots must not drop the wander broadcasts they replaced.

import (
	"fmt"
	"path/filepath"
	"strings"
	"testing"

	"thehouseoficarus/internal/behavior"
	"thehouseoficarus/internal/net"
	"thehouseoficarus/internal/player"
)

// TestFireTickNilHubIsSafe guards the mandatory tick-system guard: FireTick
// reaches cancelStokers -> g.Hub.PlayersInRoom when a fire burns out, and the
// master tick subscriber can fire before SetHub during startup.
func TestFireTickNilHubIsSafe(t *testing.T) {
	t.Run("nil_hub_burning_fire", func(t *testing.T) {
		g := escapeTestGame()
		// Quality 1 burns out on this tick, which is the path that touches
		// the hub; without the guard it panics on g.Hub == nil.
		g.World.AddObjInstance(1, "fire", 1)
		g.Hub = nil

		defer func() {
			if r := recover(); r != nil {
				t.Fatalf("FireTick panicked with a nil Hub: %v", r)
			}
		}()
		g.FireTick()
	})

	t.Run("hub_present_still_burns_out", func(t *testing.T) {
		h := newHarness(t)
		h.g.RunOnOwner(func() {
			h.g.World.AddObjInstance(1, "fire", 1)
			h.g.FireTick()
			if fires := h.g.World.AllFireStates(); len(fires) != 0 {
				t.Errorf("fire should have burned out, %d states remain", len(fires))
			}
		})
	})
}

// TestSequenceTickFiresInDeterministicOrder guards the sorted iteration over
// g.sequences: map iteration randomly chose which simultaneous sequence
// advanced first, contradicting the determinism contract in
// building_guide/tick_order.md.
func TestSequenceTickFiresInDeterministicOrder(t *testing.T) {
	g := escapeTestGame()
	g.sequences = make(map[string]*sequence)
	g.loggedInChars = make(map[string]*net.Session)

	const rounds = 32
	for round := 0; round < rounds; round++ {
		conn := newChanConn()
		p := player.New(fmt.Sprintf("seq_order_%d", round))
		p.RoomID = 1
		sess := &net.Session{Player: p, Conn: conn}
		sess.SetState(net.StateGame)
		g.loggedInChars[p.Name] = sess

		// Inserted in reverse key order: only sorting can make the advance
		// order stable.
		g.seqMu.Lock()
		g.sequences["z_second"] = &sequence{
			sess: sess, player: p, playerName: p.Name, roomID: p.RoomID,
			steps: []behavior.Step{{Messages: []string{"ORDER-MARKER-SECOND"}}},
			scope: scopePlayer, key: "z_second",
		}
		g.sequences["a_first"] = &sequence{
			sess: sess, player: p, playerName: p.Name, roomID: p.RoomID,
			steps: []behavior.Step{{Messages: []string{"ORDER-MARKER-FIRST"}}},
			scope: scopePlayer, key: "a_first",
		}
		g.seqMu.Unlock()

		g.SequenceTick()

		out := string(conn.Bytes())
		first, second := strings.Index(out, "ORDER-MARKER-FIRST"), strings.Index(out, "ORDER-MARKER-SECOND")
		if first < 0 || second < 0 {
			t.Fatalf("round %d: both sequences should have fired, got %q", round, out)
		}
		if first > second {
			t.Fatalf("round %d: sequences advanced in map order instead of key order: %q", round, out)
		}
	}
}

// TestWanderTickBroadcastsWithSingleSessionSnapshot guards the hoisted
// AllSessions snapshot: the mob leave/enter broadcasts to other rooms must
// still fire after the per-move snapshot was replaced by one per system.
func TestWanderTickBroadcastsWithSingleSessionSnapshot(t *testing.T) {
	h := newHarness(t)
	writeSeedFile(t, filepath.Join(h.dir, "mobs", "wanderer.yaml"), []byte(
		"id: wanderer\nname: wanderer\ncombat:\n    kind: combat\n    stats:\n        hp: 1\n"))

	// Sessions without a write queue: broadcasts reach the transport
	// synchronously with the tick, so the assertions need no polling.
	aliceConn := wanderTestSession(h, "alice", 1)
	bobConn := wanderTestSession(h, "bob", 2)

	var mobRoom int
	h.g.RunOnOwner(func() {
		// Room 1 has a north exit to room 2 (see seedWorld).
		h.g.MobStore.ReloadDefs(h.dir)
		def, err := h.g.MobStore.LoadDef("wanderer")
		if err != nil {
			t.Errorf("load seeded mob: %v", err)
			return
		}
		inst := h.g.MobStore.SpawnTransient(def, &behavior.SpawnMobConfig{ID: "wanderer"}, 1, "")
		if inst.HP <= 0 {
			t.Errorf("seeded mob has no HP (%d); it would be skipped", inst.HP)
			return
		}
		inst.WanderInterval = 1
		inst.WanderTickCounter = 1
		inst.WanderRooms = []int{2}

		h.g.WanderTick()
		mobRoom = inst.RoomID
	})

	if mobRoom != 2 {
		t.Fatalf("mob did not wander into room 2 (room %d)", mobRoom)
	}
	if sessions := h.g.Hub.AllSessions(); len(sessions) != 2 {
		t.Fatalf("snapshot changed hub membership: %d sessions", len(sessions))
	}

	leaving := string(aliceConn.Bytes())
	entering := string(bobConn.Bytes())
	if !strings.Contains(leaving, "wanderer") || !strings.Contains(leaving, "moves away.") {
		t.Errorf("departing room did not get the leave broadcast: %q", leaving)
	}
	if !strings.Contains(entering, "wanderer") || !strings.Contains(entering, "drifts in.") {
		t.Errorf("arrival room did not get the enter broadcast: %q", entering)
	}
	if strings.Contains(leaving, "drifts in.") {
		t.Errorf("enter broadcast leaked into the departing room: %q", leaving)
	}
	if strings.Contains(entering, "moves away.") {
		t.Errorf("leave broadcast leaked into the arrival room: %q", entering)
	}
}

// wanderTestSession registers a live in-game session for a fresh character in
// roomID, bypassing the login menu and the write queue.
func wanderTestSession(h *harness, name string, roomID int) *chanConn {
	h.t.Helper()
	p := player.New(name)
	p.RoomID = roomID
	conn := newChanConn()
	sess := &net.Session{Conn: conn, Player: p}
	sess.SetState(net.StateGame)
	h.hub.Add(sess)
	h.hub.EnterRoom(sess, roomID)
	return conn
}

// TestAggroRetryStopsForDepartedPlayer guards keepRolling's liveness guard: a
// pending aggro timer outlives its session, and must never start combat for a
// player who has logged out, disconnected, or re-logged in elsewhere.
func TestAggroRetryStopsForDepartedPlayer(t *testing.T) {
	t.Run("player_detached", func(t *testing.T) {
		defer patchAggroRand(0.5)()
		g := escapeTestGame()
		spawnTestMob(g, 100)
		p := player.New("aggro_ghost")
		p.RoomID = 100
		sess := escapeTestSession(p)

		g.checkAggro(sess)
		if g.Combat.Get(p.Name) != nil {
			t.Fatal("inline roll should have missed (val=0.5)")
		}
		// The logout path nils the session's player before the retry fires.
		sess.Player = nil
		rollAggroRand = func() float64 { return 0.0 }
		g.Ticks.Tick()

		if cs := g.Combat.Get(p.Name); cs != nil {
			t.Fatalf("combat started for a departed player: %+v", cs)
		}
	})

	t.Run("session_disconnecting", func(t *testing.T) {
		defer patchAggroRand(0.5)()
		g := escapeTestGame()
		spawnTestMob(g, 150)
		p := player.New("aggro_offline")
		p.RoomID = 150
		sess := escapeTestSession(p)

		g.checkAggro(sess)
		sess.StartDisconnect(10)
		rollAggroRand = func() float64 { return 0.0 }
		g.Ticks.Tick()

		if g.Combat.Get(p.Name) != nil {
			t.Error("combat started for a session whose transport is gone")
		}
	})

	t.Run("replaced_by_new_session", func(t *testing.T) {
		defer patchAggroRand(0.5)()
		g := escapeTestGame()
		spawnTestMob(g, 200)
		p := player.New("aggro_relogin")
		p.RoomID = 200
		sess := escapeTestSession(p)

		g.checkAggro(sess)
		// The same character logged in again on another connection.
		g.loggedInChars = map[string]*net.Session{p.Name: escapeTestSession(p)}
		rollAggroRand = func() float64 { return 0.0 }
		g.Ticks.Tick()

		if g.Combat.Get(p.Name) != nil {
			t.Error("combat started for the retired session of a re-logged-in character")
		}
	})

	t.Run("live_session_still_rolls", func(t *testing.T) {
		defer patchAggroRand(0.5)()
		g := escapeTestGame()
		spawnTestMob(g, 300)
		p := player.New("aggro_live")
		p.RoomID = 300
		sess := escapeTestSession(p)
		g.loggedInChars = map[string]*net.Session{p.Name: sess}

		g.checkAggro(sess)
		rollAggroRand = func() float64 { return 0.0 }
		g.Ticks.Tick()

		if g.Combat.Get(p.Name) == nil {
			t.Error("the live session's retry should still start combat")
		}
	})
}

// TestDisconnectTickCombatDeferralIsBounded guards the disconnect countdown:
// an unresolvable fight used to freeze it forever, so the session (and the
// character's "logged in elsewhere" lock) never cleared.
func TestDisconnectTickCombatDeferralIsBounded(t *testing.T) {
	h := newHarness(t)
	h.seedAccount("alice")
	h.seedCharacter("alice", "alice")
	sess, _ := h.connect("alice")
	p := sess.Player

	var sessionsAfterFirstTick, remaining int
	h.g.RunOnOwner(func() {
		sess.StartDisconnect(1)
		// A fight that never resolves (no subscriber ever ends it).
		h.g.Combat.Enter(p.Name, "skeleton")

		h.g.DisconnectTick()
		sessionsAfterFirstTick = len(h.g.Hub.AllSessions())

		for i := 0; i < disconnectCombatGraceTicks+5 && len(h.g.Hub.AllSessions()) > 0; i++ {
			h.g.DisconnectTick()
		}
		remaining = len(h.g.Hub.AllSessions())
	})

	if sessionsAfterFirstTick != 1 {
		t.Fatalf("in-combat session should be deferred on the first tick, %d sessions remain", sessionsAfterFirstTick)
	}
	if remaining != 0 {
		t.Fatalf("in-combat disconnect never completed: %d sessions remain", remaining)
	}
	if _, err := h.g.AccountStore.LoadCharacter("alice"); err != nil {
		t.Fatalf("character was not saved before the hard remove: %v", err)
	}
	h.g.charsMu.Lock()
	_, stillRegistered := h.g.loggedInChars["alice"]
	h.g.charsMu.Unlock()
	if stillRegistered {
		t.Error("character registry entry survived the hard remove")
	}
}