package engine import ( "math/rand" "sort" "sync" "time" ) type Callback func() bool type subscriber struct { id uint64 interval int callback Callback ticks int } type Engine struct { mu sync.Mutex subscribers map[uint64]*subscriber nextID uint64 ticker *time.Ticker running bool stopCh chan struct{} } func New() *Engine { return &Engine{ subscribers: make(map[uint64]*subscriber), } } func (e *Engine) Subscribe(interval int, cb Callback) uint64 { e.mu.Lock() defer e.mu.Unlock() e.nextID++ e.subscribers[e.nextID] = &subscriber{ id: e.nextID, interval: interval, callback: cb, } return e.nextID } func (e *Engine) Unsubscribe(id uint64) { e.mu.Lock() defer e.mu.Unlock() delete(e.subscribers, id) } func (e *Engine) Start(tickLengthMs int) { e.mu.Lock() defer e.mu.Unlock() if e.running { return } e.running = true if tickLengthMs < 50 { tickLengthMs = 50 } e.ticker = time.NewTicker(time.Duration(tickLengthMs) * time.Millisecond) e.stopCh = make(chan struct{}) go func() { for { select { case <-e.ticker.C: e.processTick() case <-e.stopCh: return } } }() } func (e *Engine) Stop() { e.mu.Lock() defer e.mu.Unlock() if e.ticker != nil { e.ticker.Stop() } e.running = false if e.stopCh != nil { close(e.stopCh) } } // 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)) for id, sub := range e.subscribers { snapshot[id] = sub } e.mu.Unlock() 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 if !sub.callback() { e.mu.Lock() delete(e.subscribers, id) e.mu.Unlock() } } } } func ToTicks(base float64) int { if base < 1 { base = 1 } floor := int(base) if rand.Float64() < base-float64(floor) { return floor + 1 } return floor } func ValuesEqual(a, b any) bool { ai, aok := NumericValue(a) bi, bok := NumericValue(b) if aok && bok { return ai == bi } return a == b } func NumericValue(v any) (float64, bool) { switch x := v.(type) { case int: return float64(x), true case int64: return float64(x), true case float64: return x, true } return 0, false }