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,229 @@
package dnfbridge
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
)
// partyFanoutBudget 是「一次多人扇出」的总预算。
//
// 扇出跑在发起者(通常是队长)【唯一】的 event loop 上,所以这个值必须同时满足两件事:
//
// - 足够长,让每个成员真正做完。实测 party-dungeon-loading-release 单成员最长
// 5.95s(2026-09-25 00:27 慢事件日志),改前那个 per-member 的
// createWriteTimeout(3s) 根本不够,于是发起者在 3s 处超时、中止后续流程。
// - 有界。不能无限等:客户端约 20s 收不到响应就判「网络连接中断」。
//
// 10s 给实测最坏值留了余量,又远低于客户端超时阈值。
const partyFanoutBudget = 10 * time.Second
// sessionFanoutTarget 是一次扇出里的单个目标会话。
//
// characterID 允许 run 在执行时(而不是收集时)用
// onlineGameSessionCharacterSnapshot 重新校验绑定,避免拿一个在等待期间
// 已经过期的快照去操作会话。
type sessionFanoutTarget struct {
label string
characterID uint16
session *gameSession
}
// fanOutToGameSessions 并发地把同一段工作投递给多个会话。
//
// 改前的写法是 for 循环里给每个成员现开一个 createWriteTimeout 再同步等:
// N 个成员就是 3s × (N-1) 的【串行累加】,而这段代码跑在队长的 event loop 上,
// 于是队长的循环被卡住数秒。2026-09-24 23:24 与 2026-09-25 00:27 两次多人进副本
// 集体掉线,日志里的 game-stream-packet 慢事件就是卡在这里。
//
// 并发执行把总耗时从 Σ 变成 max,同时整次扇出共用一个总预算。每个目标会话仍然
// 在【它自己的】event loop 上执行,所以这里不引入跨会话的并发数据访问:与逐个
// 执行的唯一差别是它们在时间上重叠,而不是在数据上重叠。
//
// 错误按声明顺序取第一个返回,保持调用方「出错即中止后续流程」的原有语义;
// 区别只在于出错时其余成员已经各自尝试过,不会像改前那样被整段跳过。
func (s *Service) fanOutToGameSessions(
ctx context.Context,
targets []sessionFanoutTarget,
source string,
run func(target sessionFanoutTarget) error,
) error {
if s == nil || run == nil || len(targets) == 0 {
return nil
}
if ctx == nil {
ctx = context.Background()
}
fanoutCtx, cancel := context.WithTimeout(ctx, partyFanoutBudget)
defer cancel()
errs := make([]error, len(targets))
var wg sync.WaitGroup
for i := range targets {
target := targets[i]
if target.session == nil {
continue
}
wg.Add(1)
go func(index int, target sessionFanoutTarget) {
defer wg.Done()
errs[index] = s.callGameSession(fanoutCtx, target.session, source, func() error {
return run(target)
})
}(i, target)
}
wg.Wait()
for i, err := range errs {
if err != nil {
return fmt.Errorf("%s: member %s: %w", source, targets[i].label, err)
}
}
return nil
}
// fanOutToGameSessionsAsync 是 fanOutToGameSessions 的**不等待**版本。
//
// 为什么需要它
//
// 同步版本等的是「别的会话的 event loop」:每个目标的工作跑在它自己的 loop 上,
// callGameSession 会一直把调用方停在原地。多人同时进图时,每个人的 loop 都停在
// 同一个等待里,谁都推进不了,只能等共用预算(partyFanoutBudget,10s)到点。
// 2026-09-28 用 20 客户端夹具实测:发起者的 loop 被停住 10.0004 秒,那 10 秒里
// 它一个包都处理不了(repro_20raid_test.go,TestRepro20RaidSyncFanoutParksInitiator)。
//
// 它保证了什么(同步版本被依赖的那些性质)
//
// 1. 每个目标的工作**仍然跑在该目标自己的 loop 上**——单归属规则不变,
// 这是"投递方式"的改变,不是并发模型的改变。
// 2. `then` 只在**所有目标都完成之后**才被调用,因此"必须先做完的人先做、
// 必须最后做的人最后做"这类线序契约由**构造**保证,而不是靠计时。
// `then` 里只允许投递,不允许直接改别的会话的状态。
// 3. 没有任何 loop 等待另一个 loop:调用方立刻返回。
// 4. 失败仍然看得见:每个目标的错误都单独记录,`then` 照样执行 ——
// 一个坏成员绝不能拖住全队。
//
// 恰有一次调用 `then`:最后一个完成的目标、预算到点、或投递失败。
func (s *Service) fanOutToGameSessionsAsync(
ctx context.Context,
targets []sessionFanoutTarget,
source string,
run func(target sessionFanoutTarget) error,
then func(),
) {
if s == nil {
if then != nil {
then()
}
return
}
if ctx == nil {
ctx = context.Background()
}
var once sync.Once
finish := func() {
once.Do(func() {
if then != nil {
then()
}
})
}
if run == nil || len(targets) == 0 {
finish()
return
}
// 只要有一个目标会话真的拥有 event loop,"等别人的 loop"才可能存在 —— 也正是
// 必须避免的那件事。反过来,如果一个都没有(测试夹具、或还没 startGameSessionEvents
// 的会话),等待是廉价且确定的,就保持同步语义:调用方返回时全部已完成。
//
// 这条分界不是为了让测试通过,而是"死锁的成立条件":卡住的前提是有另一个 loop
// 正在停在自己的等待里;没有 loop 就没有这个前提。
hasEventLoop := false
for i := range targets {
if session := targets[i].session; session != nil &&
session.events != nil && session.events.loop != nil {
hasEventLoop = true
break
}
}
if !hasEventLoop {
var wg sync.WaitGroup
for i := range targets {
target := targets[i]
if target.session == nil {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
if runErr := run(target); runErr != nil {
s.logWarn("dnf session fanout target failed",
"source", source, "member", target.label, "error", runErr)
}
}()
}
wg.Wait()
finish()
return
}
var pending atomic.Int64
pending.Store(int64(len(targets)))
remaining := func() {
if pending.Add(-1) == 0 {
finish()
}
}
// 预算是保险丝,不是机制:只有在某个目标始终不完成(loop 已销毁)时才用得上。
budget := time.AfterFunc(partyFanoutBudget, func() {
s.logWarn("dnf session fanout budget expired",
"source", source,
"targets", len(targets),
"pending", pending.Load())
finish()
})
defer budget.Stop()
for i := range targets {
target := targets[i]
if target.session == nil {
remaining()
continue
}
work := func() error {
defer remaining()
if runErr := run(target); runErr != nil {
// 逐目标可见的失败(等于参照实现的 RAID_NOTIFY_FAILED),
// 且绝不允许它中止其他成员。
s.logWarn("dnf session fanout target failed",
"source", source, "member", target.label, "error", runErr)
return runErr
}
return nil
}
// 没有事件循环的会话(测试夹具、或尚未 startGameSessionEvents 的会话)没有
// 邮箱可用;这种情况下必须**每个目标一个 goroutine**,否则这些目标会串行
// 执行 —— 这正是 stress_dungeon_entry_test.go 断言的那条性质
//("同一次扇出的两个 follower 必须时间上重叠")。
if target.session.events == nil || target.session.events.loop == nil {
go func() { _ = work() }()
continue
}
err := s.postGameSessionCharacterEvent(
target.session,
source,
target.characterID,
target.session.characterGeneration,
work,
)
if err != nil {
s.logWarn("dnf session fanout submit failed",
"source", source, "member", target.label, "error", err)
remaining()
}
}
}