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,225 @@
package dnfbridge
import (
"context"
"sync"
"time"
)
// 本文件为 syncRuntimeRaidMateActorsAllAtSceneBoundary 的 M×(M−1) 双循环提供
// 「按演员复用投影 body」的缓存。
//
// 背景(2026-09-25 测试服实测,8 人攻坚):
//
// 一次 loaded 边界产生 56 个有序对,每个对都要建两份 body
// (mode0 场景对象表 + mode1 角色绑定),每份都带一次 PVF 属性重算。
// 一次边界 6.0 秒,全部串行。
//
// 但两份 body **只由演员决定,除一个字节外**:
//
// character_scene_object.go:85-90 writeByte(mode0) writeUint16(1)
// writeByte(currentSceneObjectRoute) writeByte(ownerChannel)
// userinfo_current_modes.go:32-35 writeByte(mode1) writeUint16(1)
// writeByte(currentSceneObjectRoute) writeByte(ownerChannel)
//
// 即两份 body 的**字节 4** 就是 ownerChannel,其余全部来自演员。
// 而 ownerChannel = currentTownRemoteActorOwnerContext(target, _) 显式忽略
// charID(online_player_manager.go:978-983,第二个参数就是 `_ uint16`),
// 只取决于**接收者**。
//
// ⇒ 缓存键里**不能**含 ownerChannel:那会让每个接收者各建一遍、命中率为零。
// 第一版就是这么写的,实测一次边界只从 6.0s 降到 4.5s(那点收益全部来自
// mode1 那处 includeEquipment 修复)。正确做法是按演员缓存一份「字节 4 归零」
// 的 body,发给每个接收者前把字节 4 补成该接收者的 owner。
//
// 这样 56 次构建退化成约 M 次,且**最终字节与改动前逐字相同**。
//
// 缓存生命周期 = 一次扇出(挂在 ctx 上),与 projectionReadCache 同一约定:
// 函数返回即失效,绝不跨请求留存演员状态。
// actorProjectionOwnerChannelOffset 是两份 body 里 ownerChannel 的字节偏移,
// 由上面两个 writer 的逐行核实得出。
const actorProjectionOwnerChannelOffset = 4
// actorProjectionBodies 是一个键对应的两份 body(字节 4 已归零)。
type actorProjectionBodies struct {
mode0 []byte
mode1 []byte
}
// actorProjectionBodiesKey 是缓存键:只含**演员维度**的输入。
//
// charID —— 演员角色 ID(body 的决定性输入)
// binding —— withBinding(决定 mode1 是否带 ~31 行装备行,见
//
// sendTownRemoteActorProjectionWithContext)
type actorProjectionBodiesKey struct {
charID uint16
withBinding bool
}
type actorProjectionBodiesCache struct {
entries map[actorProjectionBodiesKey]actorProjectionBodies
// 诊断计数:回答「键为什么没复用」。
//
// 按 withBinding 分开统计,因为 withBinding 由**接收者**场景决定
// (runtimeRaidPeerActorInstallModeFor(state, target))。如果它随接收者变化,
// 同一个演员就会被拆成多个键、命中率暴跌 —— 这正是要证伪的假设。
mu sync.Mutex
hitNoBinding int
hitBinding int
missNoBinding int
missBinding int
buildNanos int64
sendNanos int64
}
func (cache *actorProjectionBodiesCache) noteHit(withBinding bool) {
cache.mu.Lock()
if withBinding {
cache.hitBinding++
} else {
cache.hitNoBinding++
}
cache.mu.Unlock()
}
func (cache *actorProjectionBodiesCache) noteMiss(withBinding bool) {
cache.mu.Lock()
if withBinding {
cache.missBinding++
} else {
cache.missNoBinding++
}
cache.mu.Unlock()
}
// logSummary 在一次扇出结束时打一行计数,用于判定缓存是否真的复用上了。
func (cache *actorProjectionBodiesCache) logSummary(s *Service, source string) {
if cache == nil || s == nil {
return
}
cache.mu.Lock()
hitNo, hitYes := cache.hitNoBinding, cache.hitBinding
missNo, missYes := cache.missNoBinding, cache.missBinding
entries := len(cache.entries)
buildNanos, sendNanos := cache.buildNanos, cache.sendNanos
cache.mu.Unlock()
s.logPacketEvent("game-actor-body-cache-summary",
"source", source,
"hit_nobind", hitNo,
"hit_bind", hitYes,
"miss_nobind", missNo,
"miss_bind", missYes,
"distinct_keys", entries,
"build_ms", buildNanos/1e6,
"send_ms", sendNanos/1e6)
}
func newActorProjectionBodiesCache() *actorProjectionBodiesCache {
return &actorProjectionBodiesCache{
entries: make(map[actorProjectionBodiesKey]actorProjectionBodies),
}
}
type actorProjectionBodiesCtxKey struct{}
// withActorProjectionBodiesCache 为一次扇出挂上 body 缓存。
func withActorProjectionBodiesCache(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, actorProjectionBodiesCtxKey{}, newActorProjectionBodiesCache())
}
// actorProjectionBodiesCacheFromContext 取当前扇出的缓存;没有则返回 nil,
// 此时 lookup 一律未命中、store 是空操作 —— 也就是**改动前的原始行为**。
func actorProjectionBodiesCacheFromContext(ctx context.Context) *actorProjectionBodiesCache {
if ctx == nil {
return nil
}
cache, _ := ctx.Value(actorProjectionBodiesCtxKey{}).(*actorProjectionBodiesCache)
return cache
}
// lookup 命中时返回**缓存内容的副本**。
//
// 副本是刻意的:调用方会把切片交给发包路径,缓存自己必须留一份不受影响的
// 权威拷贝,否则任何下游对切片的写入都会污染后续所有接收者。一次约 500 字节
// 的 memcpy 相比重新构建(含 PVF 重算与仓储读)可以忽略。
func (cache *actorProjectionBodiesCache) lookup(
key actorProjectionBodiesKey,
) ([]byte, []byte, bool) {
if cache == nil {
return nil, nil, false
}
cache.mu.Lock()
entry, ok := cache.entries[key]
cache.mu.Unlock()
if !ok {
cache.noteMiss(key.withBinding)
return nil, nil, false
}
cache.noteHit(key.withBinding)
return append([]byte(nil), entry.mode0...), append([]byte(nil), entry.mode1...), true
}
// store 保存两份 body 的副本,并把 ownerChannel 字节归零。
//
// 归零是刻意的:缓存代表**演员维度**的内容,接收者维度由
// applyActorProjectionOwnerChannel 在取出后补上。
func (cache *actorProjectionBodiesCache) store(
key actorProjectionBodiesKey, mode0, mode1 []byte,
) {
if cache == nil {
return
}
cache.mu.Lock()
cache.entries[key] = actorProjectionBodies{
mode0: zeroActorProjectionOwnerChannel(mode0),
mode1: zeroActorProjectionOwnerChannel(mode1),
}
cache.mu.Unlock()
}
// noteBuild / noteSend 累加「建 body」与「发包」各自花掉的时间。
//
// 这是为了回答「4.3 秒到底在构建还是在投递」。缓存已经把构建从 56 次降到 8 次
// (hit 48 / miss 8),如果 noteSend 仍占绝大部分,那剩下的就是同步写进每个
// 接收者 writer 的等待(gameWriterSyncBudget=250ms),与构建无关。
func (cache *actorProjectionBodiesCache) noteBuild(d time.Duration) {
if cache == nil || d <= 0 {
return
}
cache.mu.Lock()
cache.buildNanos += d.Nanoseconds()
cache.mu.Unlock()
}
func (cache *actorProjectionBodiesCache) noteSend(d time.Duration) {
if cache == nil || d <= 0 {
return
}
cache.mu.Lock()
cache.sendNanos += d.Nanoseconds()
cache.mu.Unlock()
}
func zeroActorProjectionOwnerChannel(body []byte) []byte {
stored := append([]byte(nil), body...)
if len(stored) > actorProjectionOwnerChannelOffset {
stored[actorProjectionOwnerChannelOffset] = 0
}
return stored
}
// applyActorProjectionOwnerChannel 把 body 的 ownerChannel 字节补成接收者的值。
//
// 该字节是两份 body 里**唯一**与接收者相关的值(已逐行核实两个 writer)。
// body 短于该偏移时不改,与改动前保持一致。
func applyActorProjectionOwnerChannel(body []byte, ownerChannel byte) {
if len(body) > actorProjectionOwnerChannelOffset {
body[actorProjectionOwnerChannelOffset] = ownerChannel
}
}