DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,185 @@
package playerloop
import (
"context"
"errors"
"log/slog"
"strings"
"sync"
"time"
"longheng.io/server/internal/platform/actor"
)
// ActorKindPlayer 是玩家 actor 在 actor.Manager 中使用的类型标识。
const ActorKindPlayer = "player"
// ActorBackedManager 使用通用 actor 管理器承载玩家事件串行处理。
type ActorBackedManager struct {
name string
handler Handler
actors *actor.Manager
logger *slog.Logger
mu sync.RWMutex
started bool
}
// NewActorBackedManager 创建基于 actor mailbox 的玩家循环管理器。
func NewActorBackedManager(name string, queue int, handler Handler, logger *slog.Logger, options Options) *ActorBackedManager {
if queue <= 0 {
queue = 64
}
name = strings.TrimSpace(name)
if name == "" {
name = "player-loop"
}
return &ActorBackedManager{
name: name,
handler: handler,
actors: actor.NewManager(name+"-actors", logger, actor.Options{
MailboxSize: queue,
HandlerTimeout: options.HandlerTimeout,
}),
logger: logger,
}
}
// Name 返回 actor 版玩家循环管理器名称。
func (m *ActorBackedManager) Name() string {
if m == nil {
return ""
}
return m.name
}
// Start 标记 actor 版玩家循环管理器可接收事件。
func (m *ActorBackedManager) Start(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
if m == nil || m.actors == nil {
return ErrStopped
}
m.mu.Lock()
m.started = true
m.mu.Unlock()
return nil
}
// Stop 停止 actor 版玩家循环管理器并关闭底层 actor。
func (m *ActorBackedManager) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
if m == nil || m.actors == nil {
return nil
}
m.mu.Lock()
m.started = false
m.mu.Unlock()
return m.actors.Stop(ctx)
}
// Submit 将事件提交到账号对应的玩家 actor。
func (m *ActorBackedManager) Submit(ctx context.Context, accountID string, payload any) error {
if ctx == nil {
ctx = context.Background()
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return ErrMissingAccount
}
if err := ctx.Err(); err != nil {
return err
}
if m == nil || m.actors == nil || !m.isStarted() {
return ErrStopped
}
act, err := m.actorFor(accountID)
if err != nil {
return err
}
event := Event{AccountID: accountID, Payload: payload, ReceivedAt: time.Now().UTC()}
err = act.Tell(ctx, actor.Message{Route: "player.event", UserID: accountID, Payload: event})
if errors.Is(err, actor.ErrMailboxFull) {
return ErrQueueFull
}
if errors.Is(err, actor.ErrActorStopped) || errors.Is(err, actor.ErrActorDraining) {
return ErrStopped
}
return err
}
// Snapshot 返回所有玩家 actor 的队列快照。
func (m *ActorBackedManager) Snapshot() []Snapshot {
if m == nil || m.actors == nil {
return nil
}
actorSnapshot := m.actors.Snapshot()
out := make([]Snapshot, 0, len(actorSnapshot.Actors))
for _, item := range actorSnapshot.Actors {
if item.Kind != ActorKindPlayer {
continue
}
out = append(out, Snapshot{AccountID: item.ID, Queued: item.MailboxLen})
}
return out
}
// ActorSnapshot 返回底层 actor 管理器的完整快照。
func (m *ActorBackedManager) ActorSnapshot() actor.Snapshot {
if m == nil || m.actors == nil {
return actor.Snapshot{}
}
return m.actors.Snapshot()
}
// DrainAccount 请求指定账号 actor 进入排空状态。
func (m *ActorBackedManager) DrainAccount(ctx context.Context, accountID string) error {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return ErrMissingAccount
}
if m == nil || m.actors == nil {
return nil
}
act, ok := m.actors.Get(ActorKindPlayer, accountID)
if !ok {
return nil
}
return act.Drain(ctx)
}
func (m *ActorBackedManager) actorFor(accountID string) (*actor.Actor, error) {
if act, ok := m.actors.Get(ActorKindPlayer, accountID); ok {
return act, nil
}
act, err := m.actors.Spawn(ActorKindPlayer, accountID, m.handleActorMessage)
if errors.Is(err, actor.ErrActorExists) {
if existing, ok := m.actors.Get(ActorKindPlayer, accountID); ok {
return existing, nil
}
}
return act, err
}
func (m *ActorBackedManager) handleActorMessage(ctx context.Context, _ *actor.Actor, msg actor.Message) error {
if m == nil || m.handler == nil {
return nil
}
event, ok := msg.Payload.(Event)
if !ok {
event = Event{AccountID: msg.UserID, Payload: msg.Payload, ReceivedAt: msg.SentAt}
}
return m.handler(ctx, event)
}
func (m *ActorBackedManager) isStarted() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.started
}
@@ -0,0 +1,5 @@
// Package playerloop 将单个玩家的工作串行化到账号维度队列。
//
// 这样可以保护 Profile 和玩法状态不被并发修改,同时允许不同账号并行处理。Handler
// 不能无限阻塞,否则会卡住该玩家自己的队列。
package playerloop
@@ -0,0 +1,85 @@
package playerloop
import (
"strings"
"longheng.io/server/internal/platform/metrics"
)
const (
// MetricPlayerLoopQueueLen 记录所有账号循环的待处理事件总数。
MetricPlayerLoopQueueLen = "playerloop_queue_len"
// MetricPlayerLoopLoopCount 记录当前活跃账号循环数量。
MetricPlayerLoopLoopCount = "playerloop_loop_count"
// MetricPlayerLoopSweepsTotal 记录空闲循环扫描累计次数。
MetricPlayerLoopSweepsTotal = "playerloop_sweeps_total"
// MetricPlayerLoopHandlerErrorsTotal 记录玩家事件 handler 错误累计数。
MetricPlayerLoopHandlerErrorsTotal = "playerloop_handler_errors_total"
)
// MetricsObserver 是可写入平台指标注册表的玩家循环观测接口。
type MetricsObserver interface {
ObserveMetrics(reg *metrics.Registry, labels map[string]string)
}
// ObserveMetrics 把队列长度、循环数量和错误计数写入指标注册表。
func (m *Manager) ObserveMetrics(reg *metrics.Registry, labels map[string]string) {
if m == nil || reg == nil {
return
}
var queued int64
m.mu.RLock()
loops := int64(len(m.loops))
for _, playerLoop := range m.loops {
if playerLoop != nil {
queued += int64(len(playerLoop.ch))
}
}
m.mu.RUnlock()
tags := normMetricLabels(labels)
if m.name != "" {
tags = withMetricLabel(tags, "manager", m.name)
}
reg.SetGauge(MetricPlayerLoopQueueLen, tags, queued)
reg.SetGauge(MetricPlayerLoopLoopCount, tags, loops)
reg.SetCounter(MetricPlayerLoopSweepsTotal, tags, metrics.Int64FromUint64(m.sweepCount.Load()))
reg.SetCounter(MetricPlayerLoopHandlerErrorsTotal, tags, metrics.Int64FromUint64(m.handlerErrorCount.Load()))
}
func normMetricLabels(labels map[string]string) map[string]string {
if len(labels) == 0 {
return nil
}
out := make(map[string]string, len(labels))
for key, value := range labels {
key = strings.TrimSpace(key)
if key == "" {
continue
}
out[key] = value
}
if len(out) == 0 {
return nil
}
return out
}
func withMetricLabel(labels map[string]string, key, value string) map[string]string {
key = strings.TrimSpace(key)
if key == "" {
return labels
}
if len(labels) == 0 {
return map[string]string{key: value}
}
out := make(map[string]string, len(labels)+1)
for existingKey, existingValue := range labels {
out[existingKey] = existingValue
}
out[key] = value
return out
}
@@ -0,0 +1,84 @@
package playerloop
import (
"strings"
"sync"
"time"
)
// OnlineRecord 记录账号在线状态和最近变更时间。
type OnlineRecord struct {
AccountID string `json:"account_id"`
Online bool `json:"online"`
UpdatedAt time.Time `json:"updated_at"`
}
// OnlineRegistry 在内存中维护账号在线状态。
type OnlineRegistry struct {
mu sync.RWMutex
records map[string]OnlineRecord
now func() time.Time
}
// NewOnlineRegistry 使用系统时钟创建在线状态表。
func NewOnlineRegistry() *OnlineRegistry {
return NewOnlineRegistryWithClock(time.Now)
}
// NewOnlineRegistryWithClock 使用指定时钟创建在线状态表,便于测试和回放。
func NewOnlineRegistryWithClock(now func() time.Time) *OnlineRegistry {
if now == nil {
now = time.Now
}
return &OnlineRegistry{
records: make(map[string]OnlineRecord),
now: now,
}
}
// MarkOnline 标记账号在线并返回新状态。
func (r *OnlineRegistry) MarkOnline(accountID string) OnlineRecord {
return r.set(accountID, true)
}
// MarkOffline 标记账号离线并返回新状态。
func (r *OnlineRegistry) MarkOffline(accountID string) OnlineRecord {
return r.set(accountID, false)
}
// IsOnline 判断账号当前是否在线。
func (r *OnlineRegistry) IsOnline(accountID string) bool {
accountID = strings.TrimSpace(accountID)
if r == nil || accountID == "" {
return false
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.records[accountID].Online
}
// Snapshot 返回在线状态表的当前副本。
func (r *OnlineRegistry) Snapshot() []OnlineRecord {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]OnlineRecord, 0, len(r.records))
for _, record := range r.records {
out = append(out, record)
}
return out
}
func (r *OnlineRegistry) set(accountID string, online bool) OnlineRecord {
accountID = strings.TrimSpace(accountID)
if r == nil || accountID == "" {
return OnlineRecord{}
}
record := OnlineRecord{AccountID: accountID, Online: online, UpdatedAt: r.now().UTC()}
r.mu.Lock()
r.records[accountID] = record
r.mu.Unlock()
return record
}
@@ -0,0 +1,403 @@
package playerloop
import (
"context"
"errors"
"log/slog"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"longheng.io/server/internal/platform/tracing"
)
var (
// ErrStopped 表示玩家循环管理器未启动或已经停止。
ErrStopped = errors.New("player loop manager is stopped")
// ErrMissingAccount 表示提交事件时缺少账号 ID。
ErrMissingAccount = errors.New("account id is required")
// ErrQueueFull 表示目标玩家循环队列已经写满。
ErrQueueFull = errors.New("player loop queue is full")
)
// Event 是按账号串行处理的玩家事件。
type Event struct {
AccountID string
Payload any
ReceivedAt time.Time
}
// Handler 处理单个玩家事件,框架保证同一账号事件串行进入。
type Handler func(context.Context, Event) error
// Options 描述玩家循环的空闲回收和 handler 超时策略。
type Options struct {
IdleTTL time.Duration
SweepInterval time.Duration
HandlerTimeout time.Duration
}
// Snapshot 是单个账号玩家循环的队列快照。
type Snapshot struct {
AccountID string `json:"account_id"`
Queued int `json:"queued"`
}
// Manager 按账号维护独立事件队列,保证同一账号状态修改串行执行。
type Manager struct {
name string
queue int
handler Handler
logger *slog.Logger
idleTTL time.Duration
sweepInterval time.Duration
handlerTimeout time.Duration
mu sync.RWMutex
started bool
loops map[string]*loop
active *managerGeneration
sweepCount atomic.Uint64
handlerErrorCount atomic.Uint64
}
// managerGeneration 持有一次 Start 创建的全部运行资源,避免旧 Stop 干扰新一代循环。
type managerGeneration struct {
ctx context.Context
cancel context.CancelFunc
loops map[string]*loop
wg sync.WaitGroup
sweepCancel context.CancelFunc
sweepDone chan struct{}
}
type loop struct {
accountID string
ch chan Event
closeOnce sync.Once
mu sync.RWMutex
closed bool
// processing 防止 idle sweep 在 handler 已取出事件、但仍在修改账号状态时关闭 loop。
processing bool
lastActive time.Time
}
// New 使用默认选项创建玩家循环管理器。
func New(name string, queue int, handler Handler, logger *slog.Logger) *Manager {
return NewWithOptions(name, queue, handler, logger, Options{})
}
// NewWithOptions 创建带空闲回收和 handler 超时配置的玩家循环管理器。
func NewWithOptions(name string, queue int, handler Handler, logger *slog.Logger, options Options) *Manager {
if queue <= 0 {
queue = 64
}
if options.SweepInterval <= 0 && options.IdleTTL > 0 {
options.SweepInterval = options.IdleTTL / 2
if options.SweepInterval <= 0 {
options.SweepInterval = time.Second
}
}
return &Manager{
name: name,
queue: queue,
handler: handler,
logger: logger,
idleTTL: options.IdleTTL,
sweepInterval: options.SweepInterval,
handlerTimeout: options.HandlerTimeout,
loops: make(map[string]*loop),
}
}
// Name 返回玩家循环管理器名称。
func (m *Manager) Name() string {
return m.name
}
// Start 启动玩家循环管理器和可选空闲回收任务。
func (m *Manager) Start(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
m.mu.Lock()
defer m.mu.Unlock()
if m.started {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
runCtx, runCancel := context.WithCancel(context.Background())
generation := &managerGeneration{
ctx: runCtx,
cancel: runCancel,
loops: make(map[string]*loop),
}
m.started = true
m.active = generation
m.loops = generation.loops
if m.idleTTL > 0 {
sweepCtx, cancel := context.WithCancel(runCtx)
generation.sweepCancel = cancel
generation.sweepDone = make(chan struct{})
go m.sweepIdleLoops(sweepCtx, generation)
}
return nil
}
// Stop 关闭所有账号循环并等待已取出的事件处理完成。
func (m *Manager) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
var stopErr error
recordCtxErr := func() {
if stopErr == nil {
stopErr = ctx.Err()
}
}
m.mu.Lock()
if !m.started || m.active == nil {
m.mu.Unlock()
return nil
}
generation := m.active
loops := make([]*loop, 0, len(generation.loops))
for _, playerLoop := range generation.loops {
loops = append(loops, playerLoop)
}
m.started = false
m.active = nil
m.loops = make(map[string]*loop)
m.mu.Unlock()
if generation.sweepCancel != nil {
generation.sweepCancel()
}
if generation.sweepDone != nil {
select {
case <-generation.sweepDone:
case <-ctx.Done():
recordCtxErr()
}
}
for _, playerLoop := range loops {
playerLoop.close()
}
done := make(chan struct{})
go func() {
generation.wg.Wait()
close(done)
}()
select {
case <-done:
generation.cancel()
return stopErr
case <-ctx.Done():
generation.cancel()
recordCtxErr()
return stopErr
}
}
// Submit 将事件提交到指定账号的串行队列。
func (m *Manager) Submit(ctx context.Context, accountID string, payload any) error {
if ctx == nil {
ctx = context.Background()
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return ErrMissingAccount
}
if err := ctx.Err(); err != nil {
return err
}
m.mu.Lock()
if !m.started || m.active == nil {
m.mu.Unlock()
return ErrStopped
}
generation := m.active
playerLoop := generation.loops[accountID]
if playerLoop == nil {
playerLoop = &loop{
accountID: accountID,
ch: make(chan Event, m.queue),
lastActive: time.Now().UTC(),
}
generation.loops[accountID] = playerLoop
generation.wg.Add(1)
go m.run(generation, playerLoop)
}
m.mu.Unlock()
event := Event{
AccountID: accountID,
Payload: payload,
ReceivedAt: time.Now().UTC(),
}
return playerLoop.submit(ctx, event)
}
// Snapshot 返回所有账号循环的队列长度快照。
func (m *Manager) Snapshot() []Snapshot {
m.mu.RLock()
out := make([]Snapshot, 0, len(m.loops))
for accountID, playerLoop := range m.loops {
out = append(out, Snapshot{
AccountID: accountID,
Queued: len(playerLoop.ch),
})
}
m.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
return out[i].AccountID < out[j].AccountID
})
return out
}
func (m *Manager) run(generation *managerGeneration, playerLoop *loop) {
defer generation.wg.Done()
for event := range playerLoop.ch {
m.handle(generation.ctx, playerLoop, event)
}
}
func (m *Manager) handle(ctx context.Context, playerLoop *loop, event Event) {
playerLoop.markProcessing(true)
defer playerLoop.markProcessing(false)
if m.handler == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
handlerCtx := ctx
var cancel context.CancelFunc
if m.handlerTimeout > 0 {
handlerCtx, cancel = context.WithTimeout(ctx, m.handlerTimeout)
}
if cancel != nil {
defer cancel()
}
defer func() {
if rec := recover(); rec != nil {
m.handlerErrorCount.Add(1)
if m.logger != nil {
tracing.LogPanic(handlerCtx, m.logger, "playerloop.handler", rec, map[string]string{"account_id": event.AccountID})
}
}
}()
if err := m.handler(handlerCtx, event); err != nil {
m.handlerErrorCount.Add(1)
if m.logger != nil && !errors.Is(err, context.Canceled) {
m.logger.Error("player loop handler failed", "account_id", event.AccountID, "error", err)
}
}
}
func (m *Manager) sweepIdleLoops(ctx context.Context, generation *managerGeneration) {
defer close(generation.sweepDone)
ticker := time.NewTicker(m.sweepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.reclaimGenLoops(generation, time.Now().UTC())
case <-ctx.Done():
return
}
}
}
func (m *Manager) reclaimIdleLoops(now time.Time) {
m.mu.RLock()
generation := m.active
m.mu.RUnlock()
m.reclaimGenLoops(generation, now)
}
func (m *Manager) reclaimGenLoops(generation *managerGeneration, now time.Time) {
if m.idleTTL <= 0 {
return
}
m.sweepCount.Add(1)
m.mu.Lock()
defer m.mu.Unlock()
if !m.started || m.active != generation {
return
}
for accountID, playerLoop := range generation.loops {
if !playerLoop.canReclaim(now, m.idleTTL) {
continue
}
delete(generation.loops, accountID)
playerLoop.close()
}
}
func (l *loop) close() {
l.closeOnce.Do(func() {
l.mu.Lock()
l.closed = true
close(l.ch)
l.mu.Unlock()
})
}
func (l *loop) submit(ctx context.Context, event Event) error {
l.mu.Lock()
defer l.mu.Unlock()
if l.closed {
return ErrStopped
}
select {
case l.ch <- event:
if event.ReceivedAt.After(l.lastActive) {
l.lastActive = event.ReceivedAt
}
return nil
case <-ctx.Done():
return ctx.Err()
default:
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return ErrQueueFull
}
}
func (l *loop) markProcessing(processing bool) {
l.mu.Lock()
l.processing = processing
if !processing {
l.lastActive = time.Now().UTC()
}
l.mu.Unlock()
}
func (l *loop) canReclaim(now time.Time, idleTTL time.Duration) bool {
l.mu.RLock()
processing := l.processing
lastActive := l.lastActive
l.mu.RUnlock()
if processing || len(l.ch) > 0 || lastActive.IsZero() {
return false
}
return now.Sub(lastActive) >= idleTTL
}
@@ -0,0 +1,65 @@
package playerloop
import (
"context"
"errors"
"time"
)
// TickPayloadFunc 为每个账号构造一次 tick 事件负载。
type TickPayloadFunc func(accountID string, at time.Time) any
// TickResult 记录一次 tick 广播覆盖账号数、成功提交数和丢弃数。
type TickResult struct {
Accounts int `json:"accounts"`
Submitted int `json:"submitted"`
Dropped int `json:"dropped"`
}
// Tick 向当前所有账号循环提交同一个 tick 负载。
func (m *Manager) Tick(ctx context.Context, payload any) (TickResult, error) {
return m.TickFunc(ctx, func(string, time.Time) any { return payload })
}
// TickFunc 向当前所有账号循环提交由回调构造的 tick 负载。
func (m *Manager) TickFunc(ctx context.Context, build TickPayloadFunc) (TickResult, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return TickResult{}, err
}
if build == nil {
build = func(string, time.Time) any { return nil }
}
now := time.Now().UTC()
m.mu.Lock()
if !m.started {
m.mu.Unlock()
return TickResult{}, ErrStopped
}
loops := make([]*loop, 0, len(m.loops))
for _, playerLoop := range m.loops {
loops = append(loops, playerLoop)
}
m.mu.Unlock()
result := TickResult{Accounts: len(loops)}
for _, playerLoop := range loops {
event := Event{
AccountID: playerLoop.accountID,
Payload: build(playerLoop.accountID, now),
ReceivedAt: now,
}
switch err := playerLoop.submit(ctx, event); {
case err == nil:
result.Submitted++
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return result, ctx.Err()
default:
result.Dropped++
}
}
return result, nil
}