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

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

230 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}
}