aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/game/tick.go3
-rw-r--r--internal/game/tick_systems.go60
-rw-r--r--internal/game/tick_systems_test.go147
-rw-r--r--internal/net/server.go19
-rw-r--r--internal/world/mob.go18
5 files changed, 246 insertions, 1 deletions
diff --git a/internal/game/tick.go b/internal/game/tick.go
index d765983..8910137 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -161,6 +161,9 @@ func (g *Game) WanderTick() {
}
func (g *Game) SharedDepletionTick() {
+ if g.Hub == nil {
+ return
+ }
activeKeys := make(map[string]bool)
for _, sess := range g.Hub.AllSessions() {
p := sess.Player
diff --git a/internal/game/tick_systems.go b/internal/game/tick_systems.go
new file mode 100644
index 0000000..4d8d4b6
--- /dev/null
+++ b/internal/game/tick_systems.go
@@ -0,0 +1,60 @@
+package game
+
+// tickSystem is one step of the canonical per-tick system sequence. Name is
+// the human-readable identifier also encoded in building_guide/tick_order.md;
+// Run invokes the underlying system. The slice tickSystemOrder is the single
+// executable source of truth for the order in which per-tick systems fire.
+//
+// The order is what callers like the master subscriber in cmd/thoi/main.go
+// (registered as engine subscription ID 1, which the engine fires before all
+// dynamically-registered combat/aggro/respawn callbacks per processTick's
+// registration-ID sort) depend on. Reordering this slice is a breaking change
+// to game behavior; keep building_guide/tick_order.md in sync.
+type tickSystem struct {
+ Name string
+ Run func(*Game)
+}
+
+// TickSystemOrder returns the canonical per-tick system sequence. It is a
+// copy of the internal slice so callers cannot mutate the canonical order.
+func TickSystemOrder() []struct{ Name string } {
+ out := make([]struct{ Name string }, len(tickSystemOrder))
+ for i, s := range tickSystemOrder {
+ out[i] = struct{ Name string }{s.Name}
+ }
+ return out
+}
+
+var tickSystemOrder = []tickSystem{
+ {"ProcessQueuedCommands", (*Game).ProcessQueuedCommands},
+ {"MoveTick", (*Game).MoveTick},
+ {"SequenceTick", (*Game).SequenceTick},
+ {"TransientMobTick", (*Game).TransientMobTick},
+ {"World.Tick", func(g *Game) { g.World.Tick() }},
+ {"MobStore.Tick", func(g *Game) { g.MobStore.Tick() }},
+ {"RegenTick", (*Game).RegenTick},
+ {"EnergyRegenTick", (*Game).EnergyRegenTick},
+ {"DisconnectTick", (*Game).DisconnectTick},
+ {"WanderTick", (*Game).WanderTick},
+ {"SharedDepletionTick", (*Game).SharedDepletionTick},
+ {"FireTick", (*Game).FireTick},
+ {"AdvanceActions", (*Game).AdvanceActions},
+ {"TechTick", (*Game).TechTick},
+ {"ConsumeTick", (*Game).ConsumeTick},
+ {"BroadcastRespawns", (*Game).BroadcastRespawns},
+ {"VisualTick", (*Game).VisualTick},
+ {"SneakTick", (*Game).SneakTick},
+ {"FarmTick", (*Game).FarmTick},
+ {"BuffTick", (*Game).BuffTick},
+ {"SafespotTick", (*Game).SafespotTick},
+ {"HazardTick", (*Game).HazardTick},
+}
+
+// RunTickSystems fires every canonical per-tick system in registration order.
+// It is the body of the master per-tick subscriber in cmd/thoi/main.go and the
+// single place that encodes per-tick system execution order.
+func (g *Game) RunTickSystems() {
+ for _, s := range tickSystemOrder {
+ s.Run(g)
+ }
+} \ No newline at end of file
diff --git a/internal/game/tick_systems_test.go b/internal/game/tick_systems_test.go
new file mode 100644
index 0000000..6b44e91
--- /dev/null
+++ b/internal/game/tick_systems_test.go
@@ -0,0 +1,147 @@
+package game
+
+import (
+ "reflect"
+ "runtime"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/game/hacking"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+// expectedTickSystemOrder is the canonical per-tick system sequence. It MUST
+// stay in sync with tickSystemOrder in tick_systems.go and with
+// building_guide/tick_order.md. Updating one without the others is a bug.
+//
+// The list is intentionally spelled out here (rather than derived from
+// tickSystemOrder) so the test fails loudly when someone reorders, adds, or
+// removes a system in tickSystemOrder without also updating the documented
+// contract.
+var expectedTickSystemOrder = []string{
+ "ProcessQueuedCommands",
+ "MoveTick",
+ "SequenceTick",
+ "TransientMobTick",
+ "World.Tick",
+ "MobStore.Tick",
+ "RegenTick",
+ "EnergyRegenTick",
+ "DisconnectTick",
+ "WanderTick",
+ "SharedDepletionTick",
+ "FireTick",
+ "AdvanceActions",
+ "TechTick",
+ "ConsumeTick",
+ "BroadcastRespawns",
+ "VisualTick",
+ "SneakTick",
+ "FarmTick",
+ "BuffTick",
+ "SafespotTick",
+ "HazardTick",
+}
+
+// TestTickSystemOrder asserts the canonical per-tick system sequence matches
+// the documented order exactly. This is the regression guard referenced in
+// building_guide/tick_order.md and in the master subscriber doc comment in
+// cmd/thoi/main.go.
+func TestTickSystemOrder(t *testing.T) {
+ got := TickSystemOrder()
+ if len(got) != len(expectedTickSystemOrder) {
+ t.Fatalf("tick system count changed: got %d, want %d (update expectedTickSystemOrder and building_guide/tick_order.md)",
+ len(got), len(expectedTickSystemOrder))
+ }
+ for i, want := range expectedTickSystemOrder {
+ if got[i].Name != want {
+ t.Errorf("tick system %d: got %q, want %q (reordered? update building_guide/tick_order.md)",
+ i, got[i].Name, want)
+ }
+ }
+}
+
+// TestTickSystemTableRunsValidMethods sanity-checks that every Run entry in
+// tickSystemOrder is a non-nil callable that resolves to one of the *Game
+// tick methods (or one of the two wrapped delegations World.Tick /
+// MobStore.Tick). This guards against typos like swapping two method
+// pointers or leaving a nil entry. It deliberately does not invoke them,
+// since RunTickSystems itself is exercised by every game-package tick test.
+func TestTickSystemTableRunsValidMethods(t *testing.T) {
+ if len(tickSystemOrder) == 0 {
+ t.Fatal("tickSystemOrder is empty — RunTickSystems would do nothing on each tick")
+ }
+ for i, s := range tickSystemOrder {
+ if s.Name == "" {
+ t.Errorf("tick system %d has empty Name", i)
+ }
+ if s.Run == nil {
+ t.Errorf("tick system %d (%q) has nil Run", i, s.Name)
+ }
+ if s.Name != "World.Tick" && s.Name != "MobStore.Tick" {
+ // Wrapped closures: too fragile to introspect by reflect name; skip.
+ pc := reflect.ValueOf(s.Run).Pointer()
+ fn := runtime.FuncForPC(pc)
+ if fn == nil {
+ t.Errorf("tick system %d (%q): Run did not resolve to a *Game method", i, s.Name)
+ continue
+ }
+ full := fn.Name()
+ // full is like "thehouseoficarus/internal/game.(*Game).MoveTick"
+ if !strings.Contains(full, "(*Game).") {
+ t.Errorf("tick system %d (%q): Run did not resolve to a *Game method (got %q)",
+ i, s.Name, full)
+ continue
+ }
+ base := full[strings.LastIndex(full, ".")+1:]
+ if base != s.Name {
+ t.Errorf("tick system %d: Name=%q but Run points at %q", i, s.Name, base)
+ }
+ }
+ }
+}
+
+// TestRunTickSystemsEmptyGameIsSafe asserts RunTickSystems is safe to fire
+// with a fully-initialized Game that has no players connected. This matches
+// the production invariant: every per-tick *Game system early-returns when
+// g.Hub == nil (or, after SetHub, iterates zero sessions), and the
+// World/MobStore wrappers operate on their own stores. A future tick system
+// that touches g.Hub without a nil guard, or a store we forget to
+// initialize, would panic here and crash the live engine goroutine in
+// production. This test is the regression guard for that contract.
+func TestRunTickSystemsEmptyGameIsSafe(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ buf := make([]byte, 4096)
+ n := runtime.Stack(buf, false)
+ t.Fatalf("RunTickSystems panics on empty game: %v\n%s", r, string(buf[:n]))
+ }
+ }()
+ g := newRunTickSystemsTestGame()
+ g.RunTickSystems()
+}
+
+// newRunTickSystemsTestGame builds a *Game with the stores RunTickSystems
+// reaches (World, MobStore) initialized and a fresh Hub with no sessions,
+// matching the production state right after game.New + SetHub but before any
+// client connects.
+func newRunTickSystemsTestGame() *Game {
+ return &Game{
+ Deps: Deps{
+ World: world.New(""),
+ MobStore: world.NewMobStore(""),
+ },
+ Hub: net.NewHub(),
+ GlobalFlags: NewGlobalFlagStore(),
+ Combat: combat.NewTracker(),
+ flagIndex: newFlagTriggerIndex(),
+ queue: NewCommandQueue(),
+ safespot: NewSafespotManager(),
+ restTimers: map[string]uint64{},
+ guardWatchTimers: map[string]int{},
+ hackingStates: map[string]*hacking.Session{},
+ sequences: map[string]*sequence{},
+ }
+} \ No newline at end of file
diff --git a/internal/net/server.go b/internal/net/server.go
index 11339d7..9da90ea 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -11,7 +11,9 @@ import (
"math/big"
"net"
"net/http"
+ "sort"
"sync"
+ "sync/atomic"
"time"
"thehouseoficarus/internal/color"
@@ -70,8 +72,22 @@ type Session struct {
PendingUndigRoom int
promptVisible bool
writeMu sync.Mutex
+
+ // seq is a monotonically increasing sequence number assigned when the
+ // session is added to a Hub. Hub.AllSessions and Hub.PlayersInRoom sort
+ // sessions by seq so per-tick consumers iterate players in a stable,
+ // registration order instead of Go's randomized map iteration order.
+ // Sessions constructed without being added (e.g. in tests) keep seq=0
+ // and tie-break by map-append order, which is also deterministic.
+ seq uint64
}
+// sessionSeq is the source of Session.seq values. It is incremented under the
+// adding Hub's mutex, so no extra atomicity is needed for correctness; atomic
+// access keeps the package-level default counter safe if Hub.Add is ever
+// called concurrently across hubs (it isn't today, but this is defensive).
+var sessionSeq atomic.Uint64
+
type Server struct {
config *config.Config
telnetLn net.Listener
@@ -106,6 +122,7 @@ func (h *Hub) OnRemove(cb func(*Session)) {
func (h *Hub) Add(s *Session) {
h.mu.Lock()
defer h.mu.Unlock()
+ s.seq = sessionSeq.Add(1)
h.sessions[s] = true
}
@@ -165,6 +182,7 @@ func (h *Hub) AllSessions() []*Session {
for s := range h.sessions {
out = append(out, s)
}
+ sort.SliceStable(out, func(i, j int) bool { return out[i].seq < out[j].seq })
return out
}
@@ -177,6 +195,7 @@ func (h *Hub) PlayersInRoom(roomID int) []*Session {
out = append(out, s)
}
}
+ sort.SliceStable(out, func(i, j int) bool { return out[i].seq < out[j].seq })
return out
}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index fa0cdbf..f33b2f9 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -5,6 +5,7 @@ import (
"math/rand"
"os"
"path/filepath"
+ "sort"
"strings"
"sync"
@@ -476,6 +477,7 @@ func (s *MobStore) AllInstances() []*MobInstance {
for _, inst := range s.instances {
out = append(out, inst)
}
+ sort.SliceStable(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
return out
}
@@ -499,6 +501,20 @@ func (s *MobStore) MobsInRoom(roomID int) []*MobInstance {
out = append(out, inst)
}
}
+ sort.SliceStable(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
+ return out
+}
+
+// sortedInstancesLocked returns every mob instance sorted by InstanceID. It
+// must be called with s.mu held; callers iterate the returned slice instead of
+// ranging s.instances directly so per-tick processing order is stable across
+// Go's randomized map iteration.
+func (s *MobStore) sortedInstancesLocked() []*MobInstance {
+ out := make([]*MobInstance, 0, len(s.instances))
+ for _, inst := range s.instances {
+ out = append(out, inst)
+ }
+ sort.SliceStable(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
return out
}
@@ -506,7 +522,7 @@ func (s *MobStore) Tick() {
s.mu.Lock()
defer s.mu.Unlock()
- for _, inst := range s.instances {
+ for _, inst := range s.sortedInstancesLocked() {
if inst.Shop != nil {
s.tickShopLocked(inst)
}