DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user