aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hardening_queue_cleanup_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/game/hardening_queue_cleanup_test.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/game/hardening_queue_cleanup_test.go')
-rw-r--r--internal/game/hardening_queue_cleanup_test.go139
1 files changed, 139 insertions, 0 deletions
diff --git a/internal/game/hardening_queue_cleanup_test.go b/internal/game/hardening_queue_cleanup_test.go
new file mode 100644
index 0000000..95b96bf
--- /dev/null
+++ b/internal/game/hardening_queue_cleanup_test.go
@@ -0,0 +1,139 @@
+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))
+ }
+}
+
+// TestActivePositionsReturnsSortedNamesAndOneBasedPosition pins the documented
+// contract of ActivePositions: names sorted, position 1-indexed, 0 when the
+// player holds no active command.
+func TestActivePositionsReturnsSortedNamesAndOneBasedPosition(t *testing.T) {
+ q := NewCommandQueue()
+ for _, name := range []string{"carol", "alice", "bob"} {
+ q.EnqueueActive(name, QueuedCommand{Command: "look", Timestamp: time.Now()})
+ }
+
+ names, pos := q.ActivePositions("bob")
+ if want := []string{"alice", "bob", "carol"}; !reflect.DeepEqual(names, want) {
+ t.Errorf("names = %v, want %v", names, want)
+ }
+ if pos != 2 {
+ t.Errorf("position of bob = %d, want 2", pos)
+ }
+ if _, pos := q.ActivePositions("alice"); pos != 1 {
+ t.Errorf("position of alice = %d, want 1", pos)
+ }
+ if _, pos := q.ActivePositions("nobody"); pos != 0 {
+ t.Errorf("position of an unqueued player = %d, want 0", pos)
+ }
+}