From 153acb7f644d755c45533b7c5311e3ffa47e8973 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 05:31:09 -0400 Subject: fix: harden engine across casino, economy, concurrency, security, and content --- internal/game/hardening_tick_lifecycle_test.go | 291 +++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 internal/game/hardening_tick_lifecycle_test.go (limited to 'internal/game/hardening_tick_lifecycle_test.go') 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") + } +} -- cgit v1.2.3