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) } }