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,362 @@
package dnfbridge
import (
"context"
"errors"
"fmt"
"sync/atomic"
"time"
"longheng.io/server/internal/platform/eventloop"
)
const (
gameSessionEventQueueSize = 512
gameSessionShutdownWait = 2 * createWriteTimeout
// gameSessionIdlePriorityQueueSize 是"本包不会用到"的高/低优先级队列容量。
//
// 本包全部事件提交点(postGameSessionEvent / callGameSession /
// postGameSessionCharacterEvent)都只用 eventloop.PriorityMedium —— 全仓 grep
// PriorityHigh|PriorityLow 在 dnfbridge 下零匹配。而每条循环都会按 QueueSize
// 预分配三条优先级通道,于是这两条 512 深的通道从创建到销毁一次都不会被写入。
// 按 amd64 上 Event 约 96 字节算:2 × 512 × 96 ≈ 96 KiB/会话,1000 会话约 96 MB。
//
// 取 8 而不是 0:仍然保底容纳一次小突发;将来真要用高/低优先级时,也只是更早
// 触发 SubmitBlocking 的 1ms 重试退避(不会死锁、不会丢事件),把这里调回
// gameSessionEventQueueSize 即可恢复原容量。
gameSessionIdlePriorityQueueSize = 8
// slowEventProbeThreshold 是 2026-09-26「处处有延迟感」排查用的临时慢事件阈值。
//
// 原本该处用 createWriteTimeout(3s)。3s 只能抓到秒级以上的单次操作,而玩家
// 感知的「处处卡」是 200ms~1s 量级的高频现象:同一份 packet_log.txt 里 200ms
// 门限的 game-slow-upper-request 已经抓到选角耗时 1342ms,而 3s 门限一条都不报
// (两个 server-*.stdout.log 全量 grep "slow threshold" 零命中)。
//
// 临时下调到 200ms 只为把这一段点名,纯诊断:只多打 WARN、只多加 slow 计数,
// 不改变任何行为分支、不改变超时、不影响发包顺序。
// 排查结束后把下面 Options 里的 SlowThreshold 改回 createWriteTimeout 即可。
slowEventProbeThreshold = 200 * time.Millisecond
)
var errInvalidGameSessionEvent = errors.New("dnf game session event is invalid")
// errGameClientInputRejected marks a handler failure caused by the client's own
// packet - an unrecognised or unproven shape inside an already well-framed
// request - rather than by a server-side write, repository or transport failure.
//
// Inbound game packets are submitted as fatal events because a handler that
// failed in the middle of an ordered mutation may leave the client unable to
// follow the stream any more. A rejected input shape is not that case: nothing
// was mutated and the frame boundary came from the stream splitter, so the only
// effect of the fatal boundary is that one unproven request costs the player the
// entire connection. That is exactly the live 2026-09-12 17:40:04Z incident
// recorded in runtime/logs/packet_log.txt, where the party teleport's 8-byte
// legacy op434 reached the 18-byte native path-report parser, the returned
// validation error crossed this boundary and the game socket was closed - the
// player saw the "网络连接失败" popup plus "无法前往" and had to re-enter the game.
//
// Handlers therefore wrap input-shape rejections with this sentinel: the request
// stays visible in the packet log, the session survives it, and genuine write or
// repository failures keep the original fatal behaviour.
var errGameClientInputRejected = errors.New("game client input rejected")
type gameSessionEvents struct {
loop *eventloop.Loop
active atomic.Bool
}
type gameSessionEventTask struct {
source string
characterID uint16
characterGeneration uint64
async bool
fatal bool
run func() error
}
func (s *Service) startGameSessionEvents(ctx context.Context, session *gameSession) error {
if s == nil || session == nil {
return errInvalidGameSessionEvent
}
if session.events != nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
if session.characterGeneration == 0 {
session.characterGeneration = 1
}
events := &gameSessionEvents{}
events.loop = eventloop.New(
"dnf-game-session-"+session.connID,
eventloop.ProcessorFunc(func(_ context.Context, event eventloop.Event) eventloop.Result {
task, ok := event.Data.(gameSessionEventTask)
if !ok || task.run == nil {
return eventloop.Result{Err: errInvalidGameSessionEvent}
}
if task.async {
if !events.active.Load() {
return eventloop.Result{}
}
if task.characterGeneration != 0 &&
(task.characterGeneration != session.characterGeneration ||
task.characterID == 0 ||
task.characterID != session.selectedCharacterID) {
s.logGameEvent(session, "game-session-event-stale",
"source", task.source,
"event_char_id", task.characterID,
"selected_char_id", session.selectedCharacterID,
"event_generation", task.characterGeneration,
"current_generation", session.characterGeneration,
"reason", "selected_character_or_generation_changed")
return eventloop.Result{}
}
}
err := task.run()
if err != nil && task.fatal {
if errors.Is(err, errGameClientInputRejected) {
// The client's own packet was refused before anything was
// mutated and the frame boundary is intact, so the ordered
// session stays usable. Later queued packets are still
// applied: they were framed independently and this rejection
// did not consume part of their payload.
s.logGameEvent(session, "game-session-client-input-rejected",
"source", task.source,
"char_id", session.selectedCharacterID,
"character_generation", session.characterGeneration,
"session_kept_alive", true,
"error", err)
return eventloop.Result{}
}
events.active.Store(false)
s.logGameEvent(session, "game-session-async-event-failed",
"source", task.source,
"error", err)
// The synchronous reader used to return the same handler error and
// close the socket immediately. Preserve that failure boundary after
// moving inbound packets to asynchronous submission: once one ordered
// packet fails, later queued packets are stale and the blocked Read
// call must be released so connection cleanup can run.
if session.conn != nil {
_ = session.conn.Close()
}
}
return eventloop.Result{Err: err}
}),
eventloop.Options{
QueueSize: gameSessionEventQueueSize,
HighQueueSize: gameSessionIdlePriorityQueueSize,
LowQueueSize: gameSessionIdlePriorityQueueSize,
CallbackQueueSize: gameSessionEventQueueSize,
CallbackTimeout: gameSessionShutdownWait,
SlowThreshold: slowEventProbeThreshold,
Logger: s.logger,
},
)
if err := events.loop.Start(ctx); err != nil {
return err
}
events.active.Store(true)
session.events = events
// The writer is what keeps a client that stopped reading from parking this
// loop: every frame below is submitted to it instead of written inline.
s.startGameSessionWriter(ctx, session)
s.logGameEvent(session, "game-session-events-started",
"character_generation", session.characterGeneration,
"queue_size", gameSessionEventQueueSize)
return nil
}
// postGameSessionEvent submits inbound network work without waiting for its
// repository I/O or response writes to complete. The per-session loop remains
// the single owner, so requests from one character stay ordered while other
// character sessions can execute concurrently. SubmitBlocking is intentional:
// the bounded queue provides TCP backpressure instead of dropping commands.
func (s *Service) postGameSessionEvent(
ctx context.Context,
session *gameSession,
source string,
run func() error,
) error {
if run == nil || session == nil {
return errInvalidGameSessionEvent
}
if session.events == nil || session.events.loop == nil {
return run()
}
if ctx == nil {
ctx = context.Background()
}
return session.events.loop.SubmitBlocking(ctx, eventloop.Event{
Type: source,
Data: gameSessionEventTask{
source: source,
async: true,
fatal: true,
run: run,
},
Priority: eventloop.PriorityMedium,
Context: ctx,
})
}
func (s *Service) beginGameSessionClose(session *gameSession) {
if session == nil || session.events == nil {
return
}
session.events.active.Store(false)
}
func (s *Service) stopGameSessionEvents(ctx context.Context, session *gameSession) error {
if session == nil || session.events == nil || session.events.loop == nil {
return nil
}
session.events.active.Store(false)
if ctx == nil {
ctx = context.Background()
}
return session.events.loop.Stop(ctx)
}
func (s *Service) callGameSession(
ctx context.Context,
session *gameSession,
source string,
run func() error,
) error {
if run == nil {
return errInvalidGameSessionEvent
}
if session == nil {
return errInvalidGameSessionEvent
}
if session.events == nil || session.events.loop == nil {
return run()
}
if ctx == nil {
ctx = context.Background()
}
callback := make(chan eventloop.Result, 1)
event := eventloop.Event{
Type: source,
Data: gameSessionEventTask{source: source, run: run},
Priority: eventloop.PriorityMedium,
Callback: callback,
Context: ctx,
}
if err := session.events.loop.SubmitBlocking(ctx, event); err != nil {
return err
}
select {
case result := <-callback:
return result.Err
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) postGameSessionCharacterEvent(
session *gameSession,
source string,
characterID uint16,
characterGeneration uint64,
run func() error,
) error {
if run == nil {
return errInvalidGameSessionEvent
}
if session == nil {
return errInvalidGameSessionEvent
}
if session.events == nil || session.events.loop == nil {
return run()
}
ctx := session.ctx
if ctx == nil {
ctx = context.Background()
}
return session.events.loop.SubmitBlocking(ctx, eventloop.Event{
Type: source,
Data: gameSessionEventTask{
source: source,
characterID: characterID,
characterGeneration: characterGeneration,
async: true,
run: run,
},
Priority: eventloop.PriorityMedium,
Context: ctx,
})
}
func (s *Service) shutdownGameSessionEvents(session *gameSession, closeDproto bool) {
if session == nil {
return
}
// The reader has stopped submitting new packet events at this boundary.
// Drain acknowledged dungeon quest writes while their completion callbacks
// can still be delivered to the session event loop.
questCtx, questCancel := context.WithTimeout(context.Background(), gameSessionShutdownWait)
questErr := waitGameSessionQuestProgress(questCtx, session)
questCancel()
if questErr != nil {
s.logGameEvent(session, "game-session-quest-progress-drain-failed", "error", questErr)
}
s.beginGameSessionClose(session)
ctx, cancel := context.WithTimeout(context.Background(), gameSessionShutdownWait)
defer cancel()
var cleanupCompleted atomic.Bool
cleanup := func() error {
if closeDproto {
s.closeGameDprotoSession(session)
}
s.cleanupOnlinePlayer(session)
s.unbindGameSession(session)
cleanupCompleted.Store(true)
return nil
}
cleanupErr := s.callGameSession(ctx, session, "game-session-shutdown", cleanup)
stopErr := s.stopGameSessionEvents(ctx, session)
// The loop has stopped, so nothing can submit another frame. Drain what the
// client is still owed (its last notices) inside the same bounded ctx, then
// retire the goroutine instead of leaking it.
s.stopGameSessionWriter(ctx, session)
if !cleanupCompleted.Load() && stopErr == nil {
cleanupErr = errors.Join(cleanupErr, cleanup())
}
if cleanupErr != nil || stopErr != nil {
s.logGameEvent(session, "game-session-events-shutdown-failed",
"cleanup_error", cleanupErr,
"stop_error", stopErr)
return
}
s.logGameEvent(session, "game-session-events-stopped",
"character_generation", session.characterGeneration)
}
func advanceGameSessionCharacterGeneration(session *gameSession) uint64 {
if session == nil {
return 0
}
session.characterGeneration++
if session.characterGeneration == 0 {
session.characterGeneration++
}
return session.characterGeneration
}
func gameSessionCharacterEventIdentity(session *gameSession, characterID uint16) (uint16, uint64, error) {
if session == nil || characterID == 0 || characterID != session.selectedCharacterID {
return 0, 0, fmt.Errorf("%w: selected character changed", errInvalidGameSessionEvent)
}
return characterID, session.characterGeneration, nil
}
func isClosedGameSessionEventError(err error) bool {
return errors.Is(err, context.Canceled) ||
errors.Is(err, eventloop.ErrClosed) ||
errors.Is(err, eventloop.ErrDraining) ||
errors.Is(err, eventloop.ErrNotStarted)
}