服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
292 lines
6.6 KiB
Go
292 lines
6.6 KiB
Go
package reload
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Trigger string
|
|
|
|
const (
|
|
TriggerManual Trigger = "manual"
|
|
TriggerWatch Trigger = "watch"
|
|
)
|
|
|
|
var ErrApplyRequired = errors.New("reload apply function is required")
|
|
|
|
type Config struct {
|
|
Enabled bool
|
|
PollInterval time.Duration
|
|
ApplyTimeout time.Duration
|
|
}
|
|
|
|
type Result struct {
|
|
Trigger string `json:"trigger"`
|
|
Reason string `json:"reason,omitempty"`
|
|
At time.Time `json:"at"`
|
|
DurationMS int64 `json:"duration_ms"`
|
|
Changed bool `json:"changed"`
|
|
Applied []string `json:"applied,omitempty"`
|
|
Skipped []string `json:"skipped,omitempty"`
|
|
RestartRequired []string `json:"restart_required,omitempty"`
|
|
Details map[string]any `json:"details,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type Snapshot struct {
|
|
Enabled bool `json:"enabled"`
|
|
PollIntervalSeconds int64 `json:"poll_interval_seconds"`
|
|
ApplyTimeoutSeconds int64 `json:"apply_timeout_seconds"`
|
|
Checks int64 `json:"checks"`
|
|
Reloads int64 `json:"reloads"`
|
|
Failures int64 `json:"failures"`
|
|
LastCheckedAt time.Time `json:"last_checked_at,omitempty"`
|
|
LastSuccessfulReload time.Time `json:"last_successful_reload,omitempty"`
|
|
LastResult Result `json:"last_result"`
|
|
}
|
|
|
|
type ApplyFunc func(context.Context, Trigger, string) (Result, error)
|
|
|
|
type Manager struct {
|
|
name string
|
|
apply ApplyFunc
|
|
logger *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
applyMu sync.Mutex
|
|
cfg Config
|
|
snapshot Snapshot
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
}
|
|
|
|
func New(name string, cfg Config, apply ApplyFunc, logger *slog.Logger) *Manager {
|
|
cfg = normalizeConfig(cfg)
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
name = "config-reloader"
|
|
}
|
|
return &Manager{
|
|
name: name,
|
|
cfg: cfg,
|
|
apply: apply,
|
|
logger: logger,
|
|
snapshot: Snapshot{
|
|
Enabled: cfg.Enabled,
|
|
PollIntervalSeconds: int64(cfg.PollInterval / time.Second),
|
|
ApplyTimeoutSeconds: int64(cfg.ApplyTimeout / time.Second),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Name() string {
|
|
if m == nil {
|
|
return "config-reloader"
|
|
}
|
|
return m.name
|
|
}
|
|
|
|
func (m *Manager) Start(ctx context.Context) error {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
if m.cancel != nil || !m.cfg.Enabled {
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
done := make(chan struct{})
|
|
interval := m.cfg.PollInterval
|
|
m.cancel = cancel
|
|
m.done = done
|
|
m.mu.Unlock()
|
|
|
|
go m.loop(runCtx, done, interval)
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) Stop(ctx context.Context) error {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
m.mu.Lock()
|
|
cancel := m.cancel
|
|
done := m.done
|
|
m.cancel = nil
|
|
m.done = nil
|
|
m.mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
if done == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-done:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Configure(cfg Config) {
|
|
if m == nil {
|
|
return
|
|
}
|
|
cfg = normalizeConfig(cfg)
|
|
m.mu.Lock()
|
|
wasEnabled := m.cfg.Enabled
|
|
changedInterval := m.cfg.PollInterval != cfg.PollInterval
|
|
m.cfg = cfg
|
|
m.snapshot.Enabled = cfg.Enabled
|
|
m.snapshot.PollIntervalSeconds = int64(cfg.PollInterval / time.Second)
|
|
m.snapshot.ApplyTimeoutSeconds = int64(cfg.ApplyTimeout / time.Second)
|
|
cancel := m.cancel
|
|
if wasEnabled && (!cfg.Enabled || changedInterval) {
|
|
m.cancel = nil
|
|
m.done = nil
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
if wasEnabled && (!cfg.Enabled || changedInterval) && cancel != nil {
|
|
cancel()
|
|
}
|
|
if cfg.Enabled && (!wasEnabled || changedInterval) {
|
|
_ = m.Start(context.Background())
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Reload(ctx context.Context, trigger Trigger, reason string) (Result, error) {
|
|
if m == nil {
|
|
return Result{}, ErrApplyRequired
|
|
}
|
|
m.applyMu.Lock()
|
|
defer m.applyMu.Unlock()
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
m.mu.Lock()
|
|
cfg := m.cfg
|
|
m.snapshot.Checks++
|
|
m.snapshot.LastCheckedAt = time.Now().UTC()
|
|
m.mu.Unlock()
|
|
|
|
if m.apply == nil {
|
|
result := Result{Trigger: string(trigger), Reason: strings.TrimSpace(reason), At: time.Now().UTC(), Error: ErrApplyRequired.Error()}
|
|
m.record(result, ErrApplyRequired)
|
|
return result, ErrApplyRequired
|
|
}
|
|
|
|
startedAt := time.Now().UTC()
|
|
applyCtx, cancel := context.WithTimeout(ctx, cfg.ApplyTimeout)
|
|
defer cancel()
|
|
result, err := m.apply(applyCtx, trigger, strings.TrimSpace(reason))
|
|
result.Trigger = string(trigger)
|
|
result.Reason = strings.TrimSpace(reason)
|
|
if result.At.IsZero() {
|
|
result.At = startedAt
|
|
}
|
|
result.DurationMS = time.Since(startedAt).Milliseconds()
|
|
if err != nil && result.Error == "" {
|
|
result.Error = err.Error()
|
|
}
|
|
m.record(result, err)
|
|
return result, err
|
|
}
|
|
|
|
func (m *Manager) Snapshot() Snapshot {
|
|
if m == nil {
|
|
return Snapshot{}
|
|
}
|
|
m.mu.Lock()
|
|
snapshot := m.snapshot
|
|
snapshot.LastResult = cloneResult(snapshot.LastResult)
|
|
m.mu.Unlock()
|
|
return snapshot
|
|
}
|
|
|
|
func (m *Manager) loop(ctx context.Context, done chan struct{}, interval time.Duration) {
|
|
defer close(done)
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if _, err := m.Reload(ctx, TriggerWatch, "watch"); err != nil && m.logger != nil {
|
|
m.logger.Warn("config reload failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) record(result Result, err error) {
|
|
m.mu.Lock()
|
|
m.snapshot.LastResult = cloneResult(result)
|
|
if result.Changed || len(result.Applied) > 0 {
|
|
m.snapshot.Reloads++
|
|
if err == nil {
|
|
m.snapshot.LastSuccessfulReload = time.Now().UTC()
|
|
}
|
|
}
|
|
if err != nil {
|
|
m.snapshot.Failures++
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func normalizeConfig(cfg Config) Config {
|
|
if cfg.PollInterval <= 0 {
|
|
cfg.PollInterval = 5 * time.Second
|
|
}
|
|
if cfg.ApplyTimeout <= 0 {
|
|
cfg.ApplyTimeout = 5 * time.Second
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func cloneResult(result Result) Result {
|
|
result.Applied = append([]string(nil), result.Applied...)
|
|
result.Skipped = append([]string(nil), result.Skipped...)
|
|
result.RestartRequired = append([]string(nil), result.RestartRequired...)
|
|
if len(result.Details) > 0 {
|
|
details := make(map[string]any, len(result.Details))
|
|
for key, value := range result.Details {
|
|
details[key] = cloneDetailValue(value)
|
|
}
|
|
result.Details = details
|
|
}
|
|
return result
|
|
}
|
|
|
|
func cloneDetailValue(value any) any {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return value
|
|
}
|
|
var out any
|
|
if err := json.Unmarshal(data, &out); err != nil {
|
|
return value
|
|
}
|
|
return out
|
|
}
|