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

+479
View File
@@ -0,0 +1,479 @@
package logic
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"longheng.io/server/internal/modules/samples/world"
"longheng.io/server/internal/platform/admincmdqueue"
appkit "longheng.io/server/internal/platform/app"
"longheng.io/server/internal/platform/bus"
"longheng.io/server/internal/platform/db"
"longheng.io/server/internal/platform/dispatch"
"longheng.io/server/internal/platform/eventlog"
"longheng.io/server/internal/platform/eventloop"
"longheng.io/server/internal/platform/health"
"longheng.io/server/internal/platform/idempotency"
"longheng.io/server/internal/platform/metrics"
modulemgr "longheng.io/server/internal/platform/module"
"longheng.io/server/internal/platform/playerloop"
"longheng.io/server/internal/platform/readmodel"
"longheng.io/server/internal/platform/runtimeguard"
"longheng.io/server/internal/platform/workerpool"
"longheng.io/server/internal/reference/player"
"longheng.io/server/internal/runtime/leaderboard"
"longheng.io/server/internal/runtime/moderation"
"longheng.io/server/internal/runtime/notice"
"longheng.io/server/internal/runtime/redeem"
"longheng.io/server/pkg/contracts"
)
// Service 是 logic 服务入口,负责调度、玩家队列、响应缓存和运行时模块装配。
type Service struct {
nodeID string
componentName string
adminPrefix string
metricServiceLabel string
startedService string
environment string
logger *slog.Logger
bus bus.Bus
workers *workerpool.Pool
health *health.Service
metrics *metrics.Registry
modules *modulemgr.Manager
players *player.Module
readModels *readmodel.AdminRegistry
accounts *AccountManager
world *world.Module
leaderboards leaderboard.Runtime
sanctions moderation.SanctionStore
notices notice.Service
redeems redeem.Service
extensions ExtensionPoints
runtimeProfile runtimeguard.Profile
eventLog *eventlog.Log
adminCommands *admincmdqueue.Executor
dispatcher *dispatch.Mux
metricEventLoop *eventloop.Loop
adminRoutes []AdminRouteRegistrar
registerHandlers func(*dispatch.Mux) error
idempotency *idempotency.Guard
responses logicResponseCache
responsesOut *respPublisher
eventOutMu sync.Mutex
eventOut *eventPub
playerLoops *playerloop.Manager
subsMu sync.Mutex
subs []bus.Subscription
admin func(http.HandlerFunc) http.HandlerFunc
adminDangerous func(string, http.HandlerFunc) http.HandlerFunc
initErr error
sessionMu sync.Mutex
activeSessions map[string]string
closedSessions map[string]struct{}
handlerMetricMu sync.Mutex
handlerLatencyMax map[string]int64
}
// Options 是 logic 服务创建时的可选覆盖项。
type Options struct {
ComponentName string
AdminPrefix string
MetricServiceLabel string
StartedService string
Game GameManifestProvider
}
type runtimeStoreCloser interface {
Close() error
}
// New 使用默认配置创建 logic 服务。
func New(env *appkit.Env) *Service {
return NewWithOptions(env, Options{})
}
// NewPlayerD 创建 playerd 服务实例。
func NewPlayerD(env *appkit.Env) *Service {
return NewPlayerDWithManifest(env, DefaultPlayerDManifest)
}
// NewPlayerDWithManifest 使用指定 manifest 创建 playerd 服务实例。
func NewPlayerDWithManifest(env *appkit.Env, game GameManifestProvider) *Service {
if game == nil {
game = DefaultPlayerDManifest
}
return NewWithOptions(env, Options{
ComponentName: "playerd-service",
AdminPrefix: "/playerd",
MetricServiceLabel: "playerd",
StartedService: "playerd",
Game: game,
})
}
// NewWithOptions 使用指定 manifest 和选项创建 logic 服务。
func NewWithOptions(env *appkit.Env, options Options) *Service {
if env == nil {
env = &appkit.Env{}
}
options = normalizeOptions(options)
// NewWithOptions 是 logic/playerd 的统一装配入口:平台依赖来自 Env,玩法侧能力来自 GameManifest。
// 默认 logic 使用空玩法,真实项目应显式传入自己的 manifest,避免把示例模块误认为平台核心。
wiring := newLogicWiring(env, options)
game := wiring.game
core := env.Core()
realtime := env.Realtime()
persistence := env.Persistence()
admin := env.AdminRuntime()
s := &Service{
nodeID: core.Config.Service.NodeID,
componentName: options.ComponentName,
adminPrefix: options.AdminPrefix,
metricServiceLabel: options.MetricServiceLabel,
startedService: options.StartedService,
environment: core.Config.Service.Environment,
logger: core.Logger,
bus: realtime.Bus,
workers: realtime.Workers,
health: core.Health,
metrics: wiring.metrics,
modules: wiring.modules,
players: game.Players,
readModels: newLogicReadModels(),
accounts: wiring.accounts,
world: game.World,
leaderboards: game.Leaderboards,
sanctions: game.Sanctions,
notices: game.Notices,
redeems: game.Redeems,
extensions: game.Extensions,
runtimeProfile: wiring.runtimeProfile,
eventLog: persistence.EventLog,
adminCommands: admin.Commands,
adminRoutes: append([]AdminRouteRegistrar(nil), game.AdminRoutes...),
registerHandlers: game.RegisterHandlers,
idempotency: newIdempotencyGuard(core.Config.Idempotency),
responses: newLogicRespCache(core.Config.Idempotency),
responsesOut: newRespPublisher(realtime.Bus, core.Logger),
eventOut: newEventPub(realtime.Bus, core.Logger),
activeSessions: make(map[string]string),
closedSessions: make(map[string]struct{}),
admin: logicAdminWrapper(env, options.MetricServiceLabel),
adminDangerous: logicAdminGuard(env, options.MetricServiceLabel),
initErr: wiring.gameErr,
}
s.wireLogicRuntime(env, game)
s.registerRoutes(env.AdminMux)
return s
}
// ExtensionPoints 返回项目侧可挂接的平台扩展点。
func (s *Service) ExtensionPoints() ExtensionPoints {
if s == nil {
return ExtensionPoints{}
}
return s.extensions
}
func normalizeOptions(options Options) Options {
if options.ComponentName == "" {
options.ComponentName = "logic-service"
}
if options.AdminPrefix == "" {
options.AdminPrefix = "/logic"
}
options.AdminPrefix = "/" + strings.Trim(strings.TrimSpace(options.AdminPrefix), "/")
if options.MetricServiceLabel == "" {
options.MetricServiceLabel = "logic"
}
if options.StartedService == "" {
options.StartedService = "logic"
}
return options
}
// Name 返回 logic 服务生命周期组件名。
func (s *Service) Name() string {
return s.componentName
}
// Preflight 校验 logic 服务启动前所需依赖。
func (s *Service) Preflight(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
if s.initErr != nil {
return s.initErr
}
if err := runtimeguard.Verify(s.runtimeProfile); err != nil {
return err
}
if err := s.checkStoreBackend("notice", s.notices.Store); err != nil {
return err
}
if err := s.checkStoreBackend("redeem", s.redeems.Store); err != nil {
return err
}
return s.modules.Preflight(ctx)
}
func (s *Service) checkStoreBackend(name string, store any) error {
if store == nil {
return nil
}
describer, ok := store.(runtimeguard.BackendDescriber)
if !ok {
return nil
}
return runtimeguard.CheckDescriber(name, describer, s.runtimeProfile)
}
func (s *Service) gatewayPacketTopic() string {
if topic := contracts.LogicNodePacketTopic(s.nodeID); topic != "" {
return topic
}
return contracts.TopicGatewayClientPacket
}
func (s *Service) sessionOnTopic() string {
if topic := contracts.LogicSessConnTopic(s.nodeID); topic != "" {
return topic
}
return contracts.TopicSessionConnected
}
func (s *Service) sessionOffTopic() string {
if topic := contracts.LogicSessDiscTopic(s.nodeID); topic != "" {
return topic
}
return contracts.TopicSessionDisconnected
}
func (s *Service) acceptsLogicTarget(target string) bool {
target = strings.TrimSpace(target)
return target == "" || s.nodeID == "" || target == s.nodeID
}
// Start 启动 logic 订阅、玩家队列、运行时模块和后台组件。
func (s *Service) Start(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
if err := s.Preflight(ctx); err != nil {
return err
}
s.resetSessionTracking()
if err := s.modules.Start(ctx); err != nil {
return err
}
if err := s.playerLoops.Start(ctx); err != nil {
_ = s.modules.Stop(ctx)
return err
}
if s.accounts != nil {
if err := s.accounts.Start(ctx); err != nil {
_ = s.playerLoops.Stop(ctx)
_ = s.modules.Stop(ctx)
return err
}
}
// Start 的订阅按依赖顺序建立,任一订阅失败都会回滚已启动的模块和玩家队列,避免半启动服务接收流量。
stateSub, err := s.bus.Subscribe(contracts.TopicPlayerStateChange, func(_ context.Context, env bus.Envelope) error {
if evt, ok := env.Payload.(contracts.PlayerStateChanged); ok {
if s.world != nil {
s.world.ObservePlayer(evt.AccountID, evt.State)
}
}
return nil
})
if err != nil {
if s.accounts != nil {
_ = s.accounts.Stop(ctx)
}
_ = s.playerLoops.Stop(ctx)
_ = s.modules.Stop(ctx)
return err
}
s.addSubscription(stateSub)
connectSub, err := s.bus.Subscribe(s.sessionOnTopic(), func(ctx context.Context, env bus.Envelope) error {
evt, ok := env.Payload.(contracts.SessionConnected)
if !ok {
return nil
}
if !s.acceptsLogicTarget(evt.TargetLogicNodeID) {
return nil
}
evt.AccountID = strings.TrimSpace(evt.AccountID)
evt.SessionID = strings.TrimSpace(evt.SessionID)
// 先更新 session 投影再投递玩家队列;如果投递失败必须回滚投影,避免后续包被错误放行。
s.markSessionConnected(evt.AccountID, evt.SessionID)
if err := s.playerLoops.Submit(ctx, evt.AccountID, evt); err != nil {
s.clearCurSession(evt.AccountID, evt.SessionID)
return playerLoopErr(err)
}
return nil
})
if err != nil {
s.closeSubscriptions()
if s.accounts != nil {
_ = s.accounts.Stop(ctx)
}
_ = s.playerLoops.Stop(ctx)
_ = s.modules.Stop(ctx)
return err
}
s.addSubscription(connectSub)
disconnectSub, err := s.bus.Subscribe(s.sessionOffTopic(), func(ctx context.Context, env bus.Envelope) error {
evt, ok := env.Payload.(contracts.SessionDisconnected)
if !ok {
return nil
}
if !s.acceptsLogicTarget(evt.TargetLogicNodeID) {
return nil
}
evt.AccountID = strings.TrimSpace(evt.AccountID)
evt.SessionID = strings.TrimSpace(evt.SessionID)
// 断开事件允许乱序到达,markSessGone 会拒绝覆盖更晚的新 session。
accepted, hadCurrent := s.markSessGone(evt.AccountID, evt.SessionID)
if !accepted {
return nil
}
if err := s.playerLoops.Submit(ctx, evt.AccountID, evt); err != nil {
s.restoreSessGone(evt.AccountID, evt.SessionID, hadCurrent)
return playerLoopErr(err)
}
return nil
})
if err != nil {
s.closeSubscriptions()
if s.accounts != nil {
_ = s.accounts.Stop(ctx)
}
_ = s.playerLoops.Stop(ctx)
_ = s.modules.Stop(ctx)
return err
}
s.addSubscription(disconnectSub)
packetSub, err := s.bus.Subscribe(s.gatewayPacketTopic(), func(ctx context.Context, env bus.Envelope) error {
evt, ok := env.Payload.(contracts.GatewayClientPacket)
if !ok {
return nil
}
if !s.acceptsLogicTarget(evt.TargetLogicNodeID) {
return nil
}
evt.AccountID = strings.TrimSpace(evt.AccountID)
evt.SessionID = strings.TrimSpace(evt.SessionID)
// 所有客户端包先进入玩家队列串行化,真正的 route policy、幂等和 handler 执行在 dispatch flow 里完成。
if err := s.playerLoops.Submit(ctx, evt.AccountID, evt); err != nil {
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "submit_playerloop"})
return s.publishProtocolError(ctx, evt, playerLoopErr(err))
}
return nil
})
if err != nil {
s.closeSubscriptions()
if s.accounts != nil {
_ = s.accounts.Stop(ctx)
}
_ = s.playerLoops.Stop(ctx)
_ = s.modules.Stop(ctx)
return err
}
s.addSubscription(packetSub)
if err := s.startMetricEventLoop(ctx); err != nil {
_ = s.Stop(context.Background())
return err
}
if s.health != nil {
s.health.SetComponent(s.Name(), health.StateReady, "logic modules running")
}
if err := s.bus.Publish(ctx, contracts.TopicServiceStarted, contracts.ServiceStarted{
Service: s.startedService,
NodeID: s.nodeID,
StartedAt: time.Now().UTC(),
}); err != nil {
_ = s.Stop(context.Background())
return err
}
return nil
}
func (s *Service) addSubscription(sub bus.Subscription) {
if s == nil || sub == nil {
return
}
s.subsMu.Lock()
s.subs = append(s.subs, sub)
s.subsMu.Unlock()
}
func (s *Service) closeSubscriptions() {
if s == nil {
return
}
s.subsMu.Lock()
subs := append([]bus.Subscription(nil), s.subs...)
s.subs = nil
s.subsMu.Unlock()
for _, sub := range subs {
if sub != nil {
_ = sub.Close()
}
}
}
// Stop 停止 logic 服务并释放订阅、玩家队列和缓存资源。
func (s *Service) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
s.closeSubscriptions()
var stopErr error
stopErr = errors.Join(stopErr, s.drainMetricEventLoop(ctx))
if s.playerLoops != nil {
stopErr = errors.Join(stopErr, s.playerLoops.Stop(ctx))
}
if pub := s.eventPublisher(); pub != nil {
stopErr = errors.Join(stopErr, pub.Close(ctx))
}
if s.responsesOut != nil {
stopErr = errors.Join(stopErr, s.responsesOut.Close(ctx))
}
if s.accounts != nil {
stopErr = errors.Join(stopErr, s.accounts.Stop(ctx))
}
if s.idempotency != nil {
stopErr = errors.Join(stopErr, s.idempotency.Close(ctx))
}
if s.responses != nil {
stopErr = errors.Join(stopErr, s.responses.Close(ctx))
}
if closer, ok := s.notices.Store.(runtimeStoreCloser); ok {
stopErr = errors.Join(stopErr, closer.Close())
}
stopErr = errors.Join(stopErr, db.CloseOrFlush(ctx, s.notices.Publisher))
if closer, ok := s.redeems.Store.(runtimeStoreCloser); ok {
stopErr = errors.Join(stopErr, closer.Close())
}
stopErr = errors.Join(stopErr, db.CloseOrFlush(ctx, s.leaderboards))
stopErr = errors.Join(stopErr, s.modules.Stop(ctx))
s.resetSessionTracking()
return stopErr
}