aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/engine/tick.go21
-rw-r--r--internal/engine/tick_test.go62
2 files changed, 82 insertions, 1 deletions
diff --git a/internal/engine/tick.go b/internal/engine/tick.go
index ff1bace..251373c 100644
--- a/internal/engine/tick.go
+++ b/internal/engine/tick.go
@@ -2,6 +2,7 @@ package engine
import (
"math/rand"
+ "sort"
"sync"
"time"
)
@@ -85,6 +86,17 @@ func (e *Engine) Stop() {
}
}
+// processTick fires every due subscriber for the current engine tick.
+//
+// Subscribers are invoked in ascending subscription-ID order, i.e. the order in
+// which they were registered with Subscribe. IDs are allocated monotonically
+// (e.nextID++), so subscription order is a stable, deterministic sequence:
+// the bootstrap master subscriber (cmd/thoi/main.go) subscribes at startup as
+// ID 1 and therefore always fires first each tick, followed by each
+// subsequently-registered tick (combat rounds, aggro rolls, scheduled respawns,
+// etc.) in the order they registered. This guarantees predictable per-tick
+// ordering across runs — critical for features whose behaviour depends on the
+// relative ordering of combat resolution vs. movement vs. aggro checks.
func (e *Engine) processTick() {
e.mu.Lock()
snapshot := make(map[uint64]*subscriber, len(e.subscribers))
@@ -93,7 +105,14 @@ func (e *Engine) processTick() {
}
e.mu.Unlock()
- for id, sub := range snapshot {
+ ids := make([]uint64, 0, len(snapshot))
+ for id := range snapshot {
+ ids = append(ids, id)
+ }
+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+
+ for _, id := range ids {
+ sub := snapshot[id]
sub.ticks++
if sub.ticks >= sub.interval {
sub.ticks = 0
diff --git a/internal/engine/tick_test.go b/internal/engine/tick_test.go
new file mode 100644
index 0000000..520ef19
--- /dev/null
+++ b/internal/engine/tick_test.go
@@ -0,0 +1,62 @@
+package engine
+
+import (
+ "sync"
+ "testing"
+)
+
+// TestProcessTickFiresInSubscriptionOrder asserts the engine invokes due
+// tick subscribers in ascending subscription-ID order, i.e. the order in
+// which they registered. This is the determinism contract that callers
+// (notably the game's master subscriber at cmd/thoi/main.go, which subscribes
+// first as ID 1 and must fire before combat/aggro/respawn per-tick callbacks)
+// increasingly rely on.
+func TestProcessTickFiresInSubscriptionOrder(t *testing.T) {
+ e := New()
+ var mu sync.Mutex
+ var order []int
+
+ // Three interval-1 subscribers. Each appends its own index. All return
+ // true to stay subscribed (we Unsubscribe at the end).
+ id1 := e.Subscribe(1, func() bool {
+ mu.Lock()
+ order = append(order, 1)
+ mu.Unlock()
+ return true
+ })
+ id2 := e.Subscribe(1, func() bool {
+ mu.Lock()
+ order = append(order, 2)
+ mu.Unlock()
+ return true
+ })
+ id3 := e.Subscribe(1, func() bool {
+ mu.Lock()
+ order = append(order, 3)
+ mu.Unlock()
+ return true
+ })
+
+ if id1 >= id2 || id2 >= id3 {
+ t.Fatalf("ID monotonicity broken: got %d, %d, %d", id1, id2, id3)
+ }
+
+ // processTick is synchronous: it invokes every due interval-1 subscriber
+ // inline in turn before returning. So `order` is fully populated here.
+ e.processTick()
+
+ mu.Lock()
+ got := append([]int(nil), order...)
+ mu.Unlock()
+
+ e.Unsubscribe(id1)
+ e.Unsubscribe(id2)
+ e.Unsubscribe(id3)
+
+ if len(got) != 3 {
+ t.Fatalf("expected 3 subscriber firings, got %d: %v", len(got), got)
+ }
+ if got[0] != 1 || got[1] != 2 || got[2] != 3 {
+ t.Errorf("subscribers fired out of registration order: got %v, want [1 2 3]", got)
+ }
+}