服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
446 lines
9.9 KiB
Go
446 lines
9.9 KiB
Go
// Package timequeue provides a process-local due-time queue.
|
|
//
|
|
// It is intentionally not a persistence layer. Callers must reconstruct durable
|
|
// state from their repositories on login/start and treat callbacks as online
|
|
// reminders or runtime session timers.
|
|
package timequeue
|
|
|
|
import (
|
|
"container/heap"
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrNameRequired = errors.New("timequeue name is required")
|
|
ErrCallbackRequired = errors.New("timequeue callback is required")
|
|
ErrInvalidMoment = errors.New("timequeue moment hour or minute is invalid")
|
|
)
|
|
|
|
type Callback func(time.Time)
|
|
|
|
type Options struct {
|
|
Now func() time.Time
|
|
Location *time.Location
|
|
OnPanic func(name string, recovered any)
|
|
StaleThreshold int
|
|
}
|
|
|
|
type Service struct {
|
|
mu sync.Mutex
|
|
now func() time.Time
|
|
location *time.Location
|
|
onPanic func(string, any)
|
|
staleThreshold int
|
|
|
|
nextID uint64
|
|
items taskHeap
|
|
byName map[string]uint64
|
|
canceled map[uint64]struct{}
|
|
lastNow time.Time
|
|
wake chan struct{}
|
|
}
|
|
|
|
type Handle struct {
|
|
name string
|
|
id uint64
|
|
q *Service
|
|
}
|
|
|
|
type Snapshot struct {
|
|
Pending int
|
|
Named int
|
|
Canceled int
|
|
}
|
|
|
|
type taskKind byte
|
|
|
|
const (
|
|
kindOneShot taskKind = iota + 1
|
|
kindMinute
|
|
kindDaily
|
|
kindWeekly
|
|
)
|
|
|
|
type task struct {
|
|
id uint64
|
|
name string
|
|
due time.Time
|
|
kind taskKind
|
|
callback Callback
|
|
hour int
|
|
minute int
|
|
weekday time.Weekday
|
|
}
|
|
|
|
func New(options Options) *Service {
|
|
now := options.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
location := options.Location
|
|
if location == nil {
|
|
location = time.FixedZone("UTC+8", 8*60*60)
|
|
}
|
|
staleThreshold := options.StaleThreshold
|
|
if staleThreshold <= 0 {
|
|
staleThreshold = 64
|
|
}
|
|
return &Service{
|
|
now: now,
|
|
location: location,
|
|
onPanic: options.OnPanic,
|
|
staleThreshold: staleThreshold,
|
|
byName: make(map[string]uint64),
|
|
canceled: make(map[uint64]struct{}),
|
|
wake: make(chan struct{}, 1),
|
|
}
|
|
}
|
|
|
|
func (q *Service) ScheduleAfter(name string, delay time.Duration, callback Callback) (Handle, error) {
|
|
return q.ScheduleAt(name, q.now().Add(delay), callback)
|
|
}
|
|
|
|
func (q *Service) ScheduleAt(name string, due time.Time, callback Callback) (Handle, error) {
|
|
if err := validate(name, callback); err != nil {
|
|
return Handle{}, err
|
|
}
|
|
return q.addTask(&task{name: strings.TrimSpace(name), due: due.UTC(), kind: kindOneShot, callback: callback}, true), nil
|
|
}
|
|
|
|
func (q *Service) RegisterMinute(name string, callback Callback) (Handle, error) {
|
|
if err := validate(name, callback); err != nil {
|
|
return Handle{}, err
|
|
}
|
|
now := q.now().UTC()
|
|
due := now.Truncate(time.Minute).Add(time.Minute)
|
|
return q.addTask(&task{name: strings.TrimSpace(name), due: due, kind: kindMinute, callback: callback}, true), nil
|
|
}
|
|
|
|
func (q *Service) RegisterDaily(name string, hour, minute int, callback Callback) (Handle, error) {
|
|
if err := validate(name, callback); err != nil {
|
|
return Handle{}, err
|
|
}
|
|
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
|
return Handle{}, ErrInvalidMoment
|
|
}
|
|
now := q.now().UTC()
|
|
due := q.nextDaily(now, hour, minute)
|
|
return q.addTask(&task{name: strings.TrimSpace(name), due: due, kind: kindDaily, callback: callback, hour: hour, minute: minute}, true), nil
|
|
}
|
|
|
|
func (q *Service) RegisterWeekly(name string, weekday time.Weekday, hour, minute int, callback Callback) (Handle, error) {
|
|
if err := validate(name, callback); err != nil {
|
|
return Handle{}, err
|
|
}
|
|
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
|
return Handle{}, ErrInvalidMoment
|
|
}
|
|
now := q.now().UTC()
|
|
due := q.nextWeekly(now, weekday, hour, minute)
|
|
return q.addTask(&task{name: strings.TrimSpace(name), due: due, kind: kindWeekly, callback: callback, weekday: weekday, hour: hour, minute: minute}, true), nil
|
|
}
|
|
|
|
func (h Handle) Cancel() bool {
|
|
if h.q == nil || h.id == 0 {
|
|
return false
|
|
}
|
|
return h.q.cancelHandle(h.name, h.id)
|
|
}
|
|
|
|
func (q *Service) Cancel(name string) bool {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return false
|
|
}
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
id, ok := q.byName[name]
|
|
if !ok {
|
|
return false
|
|
}
|
|
delete(q.byName, name)
|
|
q.canceled[id] = struct{}{}
|
|
q.signalLocked()
|
|
return true
|
|
}
|
|
|
|
func (q *Service) CancelPrefix(prefix string) int {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if prefix == "" {
|
|
return 0
|
|
}
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
count := 0
|
|
for name, id := range q.byName {
|
|
if strings.HasPrefix(name, prefix) {
|
|
delete(q.byName, name)
|
|
q.canceled[id] = struct{}{}
|
|
count++
|
|
}
|
|
}
|
|
if count > 0 {
|
|
q.signalLocked()
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (q *Service) RunDue(now time.Time) {
|
|
callbacks := q.takeDue(now.UTC())
|
|
for _, item := range callbacks {
|
|
q.invoke(item)
|
|
}
|
|
}
|
|
|
|
func (q *Service) Start(ctx context.Context) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
timer := time.NewTimer(time.Hour)
|
|
defer timer.Stop()
|
|
for {
|
|
delay := q.nextDelay(q.now().UTC())
|
|
if delay < 0 {
|
|
delay = 0
|
|
}
|
|
resetTimer(timer, delay)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case now := <-timer.C:
|
|
q.RunDue(now)
|
|
case <-q.wake:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (q *Service) Snapshot() Snapshot {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
return Snapshot{Pending: len(q.items), Named: len(q.byName), Canceled: len(q.canceled)}
|
|
}
|
|
|
|
func validate(name string, callback Callback) error {
|
|
if strings.TrimSpace(name) == "" {
|
|
return ErrNameRequired
|
|
}
|
|
if callback == nil {
|
|
return ErrCallbackRequired
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (q *Service) addTask(item *task, replace bool) Handle {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
q.nextID++
|
|
item.id = q.nextID
|
|
if replace {
|
|
if old, ok := q.byName[item.name]; ok {
|
|
q.canceled[old] = struct{}{}
|
|
}
|
|
q.byName[item.name] = item.id
|
|
}
|
|
heap.Push(&q.items, item)
|
|
q.compactLocked()
|
|
q.signalLocked()
|
|
return Handle{name: item.name, id: item.id, q: q}
|
|
}
|
|
|
|
func (q *Service) cancelHandle(name string, id uint64) bool {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
if q.byName[name] != id {
|
|
return false
|
|
}
|
|
delete(q.byName, name)
|
|
q.canceled[id] = struct{}{}
|
|
q.signalLocked()
|
|
return true
|
|
}
|
|
|
|
func (q *Service) takeDue(now time.Time) []*task {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
if !q.lastNow.IsZero() && now.Before(q.lastNow) {
|
|
q.reanchorLocked(now)
|
|
q.lastNow = now
|
|
q.signalLocked()
|
|
return nil
|
|
}
|
|
q.lastNow = now
|
|
|
|
var callbacks []*task
|
|
for len(q.items) > 0 {
|
|
next := q.items[0]
|
|
if next.due.After(now) {
|
|
break
|
|
}
|
|
heap.Pop(&q.items)
|
|
if q.isCanceledLocked(next) {
|
|
continue
|
|
}
|
|
if q.byName[next.name] != next.id {
|
|
q.canceled[next.id] = struct{}{}
|
|
continue
|
|
}
|
|
callbacks = append(callbacks, next.cloneForCallback())
|
|
if next.kind == kindOneShot {
|
|
delete(q.byName, next.name)
|
|
continue
|
|
}
|
|
q.rescheduleRecurringLocked(next, now)
|
|
}
|
|
q.compactLocked()
|
|
q.signalLocked()
|
|
return callbacks
|
|
}
|
|
|
|
func (q *Service) isCanceledLocked(item *task) bool {
|
|
if _, ok := q.canceled[item.id]; ok {
|
|
delete(q.canceled, item.id)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (q *Service) rescheduleRecurringLocked(item *task, now time.Time) {
|
|
q.nextID++
|
|
item.id = q.nextID
|
|
switch item.kind {
|
|
case kindMinute:
|
|
item.due = now.Truncate(time.Minute).Add(time.Minute)
|
|
case kindDaily:
|
|
item.due = q.nextDaily(now, item.hour, item.minute)
|
|
case kindWeekly:
|
|
item.due = q.nextWeekly(now, item.weekday, item.hour, item.minute)
|
|
}
|
|
q.byName[item.name] = item.id
|
|
heap.Push(&q.items, item)
|
|
}
|
|
|
|
func (q *Service) reanchorLocked(now time.Time) {
|
|
for _, item := range q.items {
|
|
if q.byName[item.name] != item.id || item.kind == kindOneShot {
|
|
continue
|
|
}
|
|
switch item.kind {
|
|
case kindMinute:
|
|
item.due = now.Truncate(time.Minute).Add(time.Minute)
|
|
case kindDaily:
|
|
item.due = q.nextDaily(now, item.hour, item.minute)
|
|
case kindWeekly:
|
|
item.due = q.nextWeekly(now, item.weekday, item.hour, item.minute)
|
|
}
|
|
}
|
|
heap.Init(&q.items)
|
|
}
|
|
|
|
func (q *Service) compactLocked() {
|
|
if len(q.canceled) < q.staleThreshold {
|
|
return
|
|
}
|
|
filtered := q.items[:0]
|
|
for _, item := range q.items {
|
|
if _, ok := q.canceled[item.id]; ok {
|
|
delete(q.canceled, item.id)
|
|
continue
|
|
}
|
|
if q.byName[item.name] != item.id {
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
q.items = filtered
|
|
heap.Init(&q.items)
|
|
}
|
|
|
|
func (q *Service) nextDelay(now time.Time) time.Duration {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
for len(q.items) > 0 {
|
|
item := q.items[0]
|
|
if q.isCanceledLocked(item) || q.byName[item.name] != item.id {
|
|
heap.Pop(&q.items)
|
|
continue
|
|
}
|
|
return item.due.Sub(now)
|
|
}
|
|
return time.Hour
|
|
}
|
|
|
|
func (q *Service) invoke(item *task) {
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil && q.onPanic != nil {
|
|
q.onPanic(item.name, recovered)
|
|
}
|
|
}()
|
|
item.callback(item.due)
|
|
}
|
|
|
|
func (q *Service) signalLocked() {
|
|
select {
|
|
case q.wake <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (q *Service) nextDaily(now time.Time, hour, minute int) time.Time {
|
|
local := now.In(q.location)
|
|
dueLocal := time.Date(local.Year(), local.Month(), local.Day(), hour, minute, 0, 0, q.location)
|
|
if !dueLocal.After(local) {
|
|
dueLocal = dueLocal.Add(24 * time.Hour)
|
|
}
|
|
return dueLocal.UTC()
|
|
}
|
|
|
|
func (q *Service) nextWeekly(now time.Time, weekday time.Weekday, hour, minute int) time.Time {
|
|
local := now.In(q.location)
|
|
dueLocal := time.Date(local.Year(), local.Month(), local.Day(), hour, minute, 0, 0, q.location)
|
|
days := (int(weekday) - int(local.Weekday()) + 7) % 7
|
|
dueLocal = dueLocal.AddDate(0, 0, days)
|
|
if !dueLocal.After(local) {
|
|
dueLocal = dueLocal.AddDate(0, 0, 7)
|
|
}
|
|
return dueLocal.UTC()
|
|
}
|
|
|
|
func resetTimer(timer *time.Timer, delay time.Duration) {
|
|
if !timer.Stop() {
|
|
select {
|
|
case <-timer.C:
|
|
default:
|
|
}
|
|
}
|
|
timer.Reset(delay)
|
|
}
|
|
|
|
func (t *task) cloneForCallback() *task {
|
|
copy := *t
|
|
return ©
|
|
}
|
|
|
|
type taskHeap []*task
|
|
|
|
func (h taskHeap) Len() int { return len(h) }
|
|
func (h taskHeap) Less(i, j int) bool {
|
|
return h[i].due.Before(h[j].due) || h[i].due.Equal(h[j].due) && h[i].id < h[j].id
|
|
}
|
|
func (h taskHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
|
|
|
func (h *taskHeap) Push(x any) {
|
|
*h = append(*h, x.(*task))
|
|
}
|
|
|
|
func (h *taskHeap) Pop() any {
|
|
old := *h
|
|
n := len(old)
|
|
item := old[n-1]
|
|
*h = old[:n-1]
|
|
return item
|
|
}
|