diff options
| author | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
| commit | 153acb7f644d755c45533b7c5311e3ffa47e8973 (patch) | |
| tree | 7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/game/hardening_queue_cleanup_test.go | |
| parent | 87d08f0151e643951769e323995f1c5ed89ee200 (diff) | |
| download | thehouseoficarus-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.go | 139 |
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) + } +} |
