aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hardening_tick_lifecycle_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/hardening_tick_lifecycle_test.go')
-rw-r--r--internal/game/hardening_tick_lifecycle_test.go291
1 files changed, 291 insertions, 0 deletions
diff --git a/internal/game/hardening_tick_lifecycle_test.go b/internal/game/hardening_tick_lifecycle_test.go
new file mode 100644
index 0000000..b5b3bb9
--- /dev/null
+++ b/internal/game/hardening_tick_lifecycle_test.go
@@ -0,0 +1,291 @@
+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
+// building_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")
+ }
+}