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 // dev_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") } }