Files
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

154 lines
7.9 KiB
Go
Raw Permalink 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"
"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()
}