aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hardening_queue_cleanup_test.go
blob: 328538159255a775cc9d317774d6bbcf1de11641 (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
package game

// Session-lifecycle regressions for the per-player command queues: a session
// that logs out, quits, or disconnects must not leave queued commands behind.
// Leftovers retain the departed *net.Session (and its transport) and replay
// against the next session that logs in as the same character — including the
// nil-Player crash path in ProcessQueuedCommands.

import (
	"reflect"
	"testing"
	"time"

	"thehouseoficarus/internal/net"
)

// TestClearPlayerDropsAllQueues proves ClearPlayer empties the free, active,
// and consume queues, and that detachSession applies it.
func TestClearPlayerDropsAllQueues(t *testing.T) {
	h := newHarness(t)
	h.seedAccount("alice")
	h.seedCharacter("alice", "alice")
	sess, _ := h.connect("alice")

	h.g.RunOnOwner(func() {
		h.g.queue.EnqueueFree("alice", QueuedCommand{Session: sess, Command: "wear", Args: "sword"})
		h.g.queue.EnqueueActive("alice", QueuedCommand{Session: sess, Command: "wear", Args: "sword"})
		h.g.queue.EnqueueConsume("alice", QueuedCommand{Session: sess, Command: "eat", Args: "bread"})

		h.g.detachSession("alice")
	})

	if cmds := h.g.queue.PeekFree("alice"); len(cmds) != 0 {
		t.Errorf("free queue retained %d commands after detach", len(cmds))
	}
	if _, ok := h.g.queue.PeekActive("alice"); ok {
		t.Error("active queue retained a command after detach")
	}
	if _, ok := h.g.queue.DrainConsume("alice"); ok {
		t.Error("consume queue retained a command after detach")
	}
}

// TestQuitCountdownDropsQueuedCommands covers the logout path in cmd_quit.go:
// the countdown drops the registry entry, so it must drop the queues too. The
// consume slot is asserted because nothing else ever drains it — a leftover
// there survives the logout entirely without the cleanup.
func TestQuitCountdownDropsQueuedCommands(t *testing.T) {
	h := newHarness(t)
	h.seedAccount("alice")
	h.seedCharacter("alice", "alice")
	sess, _ := h.connect("alice")

	h.g.RunOnOwner(func() {
		h.g.queue.EnqueueConsume("alice", QueuedCommand{
			Session:   sess,
			Command:   "eat",
			Args:      "bread",
			Timestamp: time.Now(),
		})
	})
	h.g.HandleSessionOnOwner(sess, "quit")
	// "quit" is an active command: it executes on the next tick drain. The
	// harness registers no master tick system, so drain it explicitly.
	h.g.RunOnOwner(func() { h.g.ProcessQueuedCommands() })

	deadline := time.Now().Add(5 * time.Second)
	for time.Now().Before(deadline) && sess.State() != net.StateMenu {
		time.Sleep(5 * time.Millisecond)
	}
	if got := sess.State(); got != net.StateMenu {
		t.Fatalf("logout countdown never completed (state %v)", got)
	}

	if qc, ok := h.g.queue.DrainConsume("alice"); ok {
		t.Errorf("consume command survived logout and would replay on re-login: %+v", qc)
	}
	if cmds := h.g.queue.PeekFree("alice"); len(cmds) != 0 {
		t.Errorf("free commands survived logout: %d", len(cmds))
	}
	if _, ok := h.g.queue.PeekActive("alice"); ok {
		t.Error("active command survived logout")
	}
}

// TestProcessQueuedCommandsSkipsStaleSession reproduces the crash path where a
// free command enqueued on a session that has since departed (Player nil) is
// drained while a live session for the same character is in the hub.
func TestProcessQueuedCommandsSkipsStaleSession(t *testing.T) {
	h := newHarness(t)
	h.seedAccount("alice")
	h.seedCharacter("alice", "alice")
	h.connect("alice")

	stale := &net.Session{Conn: newChanConn()}
	stale.SetState(net.StateGame) // departed session: no Player

	h.g.RunOnOwner(func() {
		h.g.queue.EnqueueFree("alice", QueuedCommand{
			Session:   stale,
			Command:   "wear",
			Args:      "sword",
			Timestamp: time.Now(),
		})
	})

	before := h.g.owner.panics.Load()
	h.g.RunOnOwner(func() { h.g.ProcessQueuedCommands() })
	if got := h.g.owner.panics.Load(); got != before {
		t.Fatalf("stale queued command panicked the owner (panic count %d -> %d)", before, got)
	}
	if cmds := h.g.queue.PeekFree("alice"); len(cmds) != 0 {
		t.Errorf("free queue not drained for the live session: %d commands left", len(cmds))
	}
}

// TestActivePositionsReturnsResolutionOrderAndOneBasedPosition pins the
// contract of ActivePositions: names in actual resolution order (earliest
// Timestamp first, matching ProcessQueuedCommands), position 1-indexed, 0 when
// the player holds no active command.
func TestActivePositionsReturnsResolutionOrderAndOneBasedPosition(t *testing.T) {
	q := NewCommandQueue()
	base := time.Now()
	// Queue deliberately out of alphabetical order: resolution is first-to-queue.
	q.EnqueueActive("alice", QueuedCommand{Command: "look", Timestamp: base.Add(3 * time.Millisecond)})
	q.EnqueueActive("bob", QueuedCommand{Command: "look", Timestamp: base.Add(2 * time.Millisecond)})
	q.EnqueueActive("carol", QueuedCommand{Command: "look", Timestamp: base.Add(1 * time.Millisecond)})

	names, pos := q.ActivePositions("bob")
	if want := []string{"carol", "bob", "alice"}; !reflect.DeepEqual(names, want) {
		t.Errorf("names = %v, want %v (timestamp order)", names, want)
	}
	if pos != 2 {
		t.Errorf("position of bob = %d, want 2", pos)
	}
	if _, pos := q.ActivePositions("carol"); pos != 1 {
		t.Errorf("position of carol = %d, want 1", pos)
	}
	if _, pos := q.ActivePositions("nobody"); pos != 0 {
		t.Errorf("position of an unqueued player = %d, want 0", pos)
	}
}