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,153 @@
package dnfbridge
import (
"context"
"time"
)
// warmOptionalPVFCatalogs 在监听端口打开前,把「否则会拖到玩家第一个请求里才建」的
// PVF 目录先建好。
//
// 背景(2026-09-26 本机测试服实测)
//
// 服务重启后第一次进城镇:op143 ENUM_CMDPACKET_CHANGE_TUTORIAL_FLAG = 1589ms
// 同一客户端 30 秒后第二次进城: 同一 op143 = 491ms
//
// 同一份 packet_log.txt 显示差额来自三个目录被懒加载在那一次进城请求里:
// cera 商店目录(前后空档 308ms)、消费时间目录、称号簿映射。
// 也就是说 ~1100ms 是「解析 + 建索引」的账,而且它发生在玩家自己的会话事件循环上
// ——那 1.59 秒里该会话的事件循环完全堵死,客户端收不到任何东西。
//
// 参照官方端 df_game_r:它对每个子系统都有一个**显式命名的加载入口**
// (importCashShopItemList / importAttendanceScript / importTitleBookEtcScript …),
// 由 main -> Init(int,char**) -> ScriptManager::LoadScript() 在启动期统一跑完,
// 而不是留一个懒单例等玩家去触发。
//
// 为什么不加进 pvfPreloads 表
//
// 那张表(见 Start)把 loader 返回的 error 当作**致命错误**:会取消 ctx、关闭
// packet log 并拒绝启动。把这三个**可选功能**加进去,等于让「PVF 里没有商店数据」
// 从「商城不可用」升级成「整台服务器起不来」——那是比卡顿严重得多的回归。
//
// 所以这里只做**尽力而为**的预热,并且比懒路径更保守:
// - 返回 error → 只记一条 WARN,不中断启动;
// - panic → 由 warmOptionalCatalog 兜住,只记一条 WARN,不中断启动。
//
// 三个懒访问器保持原语义(首次使用时重试,或返回同一个已经记下的错误),
// 改动前后它们的对外行为逐字节等价。
//
// 调用位置的两个硬约束
//
// 1. 必须在 pvfPreloads 之后。currentCeraShopCatalog 与消费时间目录都要读
// s.initialEquipmentArchive,而它由该表第 1 项 preloadInitialEquipmentIndex 打开。
// 提前调用会让 LoadArchive 的失败被永久缓存进 initialEquipmentLoadErr
// (见 initialEquipmentArchiveLocked),那才会真的修 A 坏 B。
// 2. 必须在 FreeOSMemory 边界之前。那条既有注释写明该边界就是用来回收启动期这些
// 「短命 PVF 文档索引」的,预热属于同一类工作,不该落在边界之外。
func (s *Service) warmOptionalPVFCatalogs(ctx context.Context) {
if s == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return
}
// cera 商店目录。成功时它自己会打 dnf-cera-shop-pvf-catalog-loaded。
// 失败时不做补偿:错误已被 currentCeraShopCatalog 记进 ceraShopCatalogLoadErr,
// 与玩家首次打开商城时失败的结果完全相同——只是提前到了启动期。
s.warmOptionalCatalog("cera shop catalog", func() {
if _, err := s.currentCeraShopCatalog(); err != nil {
s.logWarn("dnfbridge cera shop catalog warmup failed", "error", err)
}
})
// 商城限购扫描:把 cera 商店目录**自带的那一份**道具解析器的缓存填满。
//
// 背景(2026-09-26 卡顿栈采样器抓到,conn=game-000364,本地 13:36:15.678):
//
// readDungeonDropText
// → (*pvfDungeonDropCatalog).ResolveItem
// → (*pvfCeraShopCatalog).BuyRestrictPolicies
// → (*Service).currentCeraShopSoldOutEntries
// → (*Service).sendCurrentCeraShopBuyRestrictProjection
//
// 这一段跑在会话事件循环上,实占 445ms,是「重启后第一次进城」窗口里最大的一笔。
//
// 为什么既有的全量道具预热盖不住它:newPVFCeraShopCatalog 在 cera_shop_catalog.go:78
// **自己**调了一次 newPVFDungeonDropCatalog,于是进程里存在**两份互相独立的
// itemRefs / itemCache**——服务级那份见 currentPVFItemCatalog,商城那份挂在
// pvfCeraShopCatalog.items。preloadDungeonDropItemDefinitions 只填服务级那份,
// 所以商城这条路径照旧逐条冷读 PVF 文本 + dnfpvf.Parse,一字不减。
//
// 这里把同一段扫描在启动期**空跑一遍**(空角色/账号数据,结果直接丢弃),
// 目的只是让那三万余个商品各自的道具定义落进私有 catalog 的 itemCache。
// 它只读不写:currentCeraShopSoldOutEntries 不碰任何持久化状态,
// ResolveItem 的副作用也只有它自己的缓存。
//
// 代价 ≈ 该扫描本身的耗时(本机实测 445ms 启动时间),换来的是重启后第一个
// 进城的玩家不再承担它(同一段在低配 VPS 上实测 2.4 秒)。
s.warmOptionalCatalog("cera shop buy restrict sweep", func() {
catalog, err := s.currentCeraShopCatalog()
if err != nil || catalog == nil {
// 目录本身没建起来:上一步已经记过 WARN,这里不重复报。
return
}
started := time.Now()
entries := s.currentCeraShopSoldOutEntries(catalog, nil, nil, s.currentPurchaseLimitTime())
// 这一行是**验收依据**:没有它,本次改动在日志上完全不可见,
// 只能靠"第一次进城少了 445ms"间接推断。elapsed_ms 应当与
// 玩家侧原本承担的那一段同量级(本机 ≈445ms)。
// entries 用空角色/账号数据跑,正常应为 0——非 0 说明有人拿
// "已买满"判定依赖了空 map 的零值,那是个需要单独看的信号。
s.logPacketEvent("dnf-cera-shop-buy-restrict-sweep-preloaded",
"products", len(catalog.products),
"entries_with_empty_stats", len(entries),
"elapsed_ms", time.Since(started).Milliseconds())
})
// 消费时间目录,三态返回 (state, available, err):
// available == false 表示「运行时 PVF 归档尚未打开」,是**延后**而不是失败,
// 调用方会 defer 到下一次、且不会缓存任何东西。预热阶段本不该出现它
// (pvfPreloads 已打开归档),真出现也只记一条。
//
// 刻意传 runCtx 而**不**像懒路径那样给超时 ctx:LoadAttendancePVFCatalog 一旦
// 因 ctx 取消而返回错误,该错误会被永久写进 spendTimeCatalogLoadErr,
// 等于把一个本可重试的失败变成永久失败。runCtx 只在服务关闭时取消,
// 不会引入这种风险。
s.warmOptionalCatalog("spend time catalog", func() {
if _, available, err := s.currentSpendTimeRuntimeCatalogIfAvailable(ctx); err != nil {
s.logWarn("dnfbridge spend time catalog warmup failed", "error", err)
} else if !available {
s.logWarn("dnfbridge spend time catalog warmup deferred",
"reason", "runtime_pvf_archive_not_loaded")
}
})
// 称号簿映射:无 error 返回。失败时返回 nil 且**不缓存**(titleBookMappingCache
// 只在成功解析后才赋值),下一次调用会重试,因此这里不需要也不应该做任何补偿。
s.warmOptionalCatalog("title book mapping", func() {
s.currentTitleBookMapping()
})
}
// warmOptionalCatalog 跑一次预热,把 panic 降级成一条 WARN。
//
// 为什么必须自己 recover:这三个访问器在正常服务里只在会话事件循环内被调用,
// 而事件循环自带 recover(见 eventloop 的 recordProcessed 收 panicked 参数)——
// 也就是说解析器即使 panic,今天最坏结果也只是毁掉那一个请求。Start 里没有这层
// 保护,直接调用会把同一个 panic 升级成「服务器起不来」:那是比卡顿严重得多的回归。
//
// 兜住之后最坏情况退化成「这一次预热被跳过」,懒访问器会在玩家首次使用时重试,
// 与改动前的行为一致。
func (s *Service) warmOptionalCatalog(name string, run func()) {
defer func() {
if recovered := recover(); recovered != nil {
s.logWarn("dnfbridge optional catalog warmup panicked",
"catalog", name, "panic", recovered)
}
}()
run()
}