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,410 @@
package dnfbridge
import (
"context"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 「已装备道具」对强化/增幅的助力:费用折扣与成功率加成。
//
// 现场 2026-10-03(服主报,附强化确认框截图):
//
// 未佩戴赫尔德 273960 金币;佩戴赫尔德 263002 金币
// 273960 × (100-4) / 100 = 263001.6 → 263002
//
// 也就是说**客户端自己按 PVF 算好了折扣并显示**,而服务端按原价扣,玩家白掏那 4%。
//
// 两个字段都来自 PVF,**不需要硬编码**(服主一开始以为要硬编码,因为字段名不直观):
//
// [upgrade cost discount] 4 ← 强化费用降低 4%
// [increase upgrade compound rate] 400 ← 强化成功率 +400(单位 0.01% ⇒ +4.00%)
//
// 谁能带这两个字段(同一份 PVF 里数出来的):`[increase upgrade compound rate]` 在 7 件上
// ——赫尔德 hilda.equ / riz.equ / riz_jin.equ / 新年赫尔德 / 3 个称号(craftsmanship.equ 等);
// `[upgrade cost discount]` 主要在布甲腰带上(belt_cl_2choro41x / belt_cl_3choro2x…)。
// 所以这里按"**所有已装备道具求和**"实现,而不是只认宠物 —— 传承腰带那批同理生效。
//
// 客户端把折扣算在**费用展示**上;成功率那一项客户端算不了(成功与否是服务端掷的),
// 所以服务端必须自己把它加进成功权重里(PVF 单位 0.01%,400 ⇒ ×1.04)。
const (
currentUpgradeCostDiscountField = "upgrade cost discount"
currentUpgradeCompoundRateField = "increase upgrade compound rate"
currentUpgradeCompoundRateDivisor = 10000 // PVF 的 0.01% 单位 ⇒ 百分比
)
var (
currentUpgradeCostDiscountPattern = regexp.MustCompile(
`(?m)^\s*\[upgrade cost discount\]\s*\n\s*(-?\d+)`)
currentUpgradeCompoundRatePattern = regexp.MustCompile(
`(?m)^\s*\[increase upgrade compound rate\]\s*\n\s*(-?\d+)`)
)
// currentWornUpgradeAssistResult 是聚合结果 + 排查用的证据(哪张记录读到了什么)。
type currentWornUpgradeAssistResult struct {
DiscountPercent int64
CompoundRate int64
// WornEquipment 是装备记录里的穿戴条目数(腰带/称号等)。
WornEquipment int
// PetEquipped / PetItemID 说明宠物记录里有没有"出战"的那只、它是哪件道具。
PetEquipped bool
PetItemID int64
// Items 是**每件已装备道具**这一次解析的完整证据链(id → 路径 → 原文 → 两个字段)。
Items []currentWornUpgradeAssistItem
}
// currentWornUpgradeAssistItem 是一件道具这一轮解析的断点证据。
//
// 为什么要留这么细:现场 2026-10-03 第一版日志只打了**聚合值** 0/0,看到"都读到 0"
// 却分不清是"id 不在 lst 里"、"路径读不到文本"、还是"读到文本但正则没中",
// 白跑了一轮部署。现在每件道具都把 ListedPath(lst 原文)与 ArchivePath(真正读到的
// 归档路径)分开打,两者不一致就说明走了相对路径兜底,一眼定位。
type currentWornUpgradeAssistItem struct {
ItemID int64
// ListedPath 是 equipment/equipment.lst 里记的那条路径。
ListedPath string
// ArchivePath 是**真正读到文本**的归档路径。
ArchivePath string
TextBytes int
Discount int64
Compound int64
// Err 非空表示这件道具没读成(值已压成无空格的短串,便于当成日志字段)。
Err string
}
// petEvidence 取出"出战宠物"那一件的证据;没佩戴宠物时返回零值。
func (r currentWornUpgradeAssistResult) petEvidence() currentWornUpgradeAssistItem {
if !r.PetEquipped || r.PetItemID <= 0 {
return currentWornUpgradeAssistItem{}
}
for _, item := range r.Items {
if item.ItemID == r.PetItemID {
return item
}
}
return currentWornUpgradeAssistItem{ItemID: r.PetItemID}
}
// describeItems 把每件道具的证据压成**一个没有空格**的日志字段,形如
//
// 400990076|creature/hilda/hilda.equ|equipment/creature/hilda/hilda.equ|743|4|400
//
// 字段依次是 id | lst 原文路径 | 实际归档路径 | 文本字节数 | 折扣 | 成功率;
// 读失败时最后一栏是原因(例:...|0|0|read-text-failed),多件之间用 ";" 隔开。
func (r currentWornUpgradeAssistResult) describeItems() string {
if len(r.Items) == 0 {
return "-"
}
var builder strings.Builder
for index, item := range r.Items {
if index > 0 {
builder.WriteString(";")
}
fmt.Fprintf(&builder, "%d|%s|%s|%d|%d|%d|%s",
item.ItemID, item.ListedPath, item.ArchivePath,
item.TextBytes, item.Discount, item.Compound, item.Err)
}
return builder.String()
}
// currentWornUpgradeAssist 汇总角色**已装备**道具上的两项强化助力。
//
// 两个来源都要看(现场 2026-10-03 第一次只看了前者,结果宠物没被算进去):
//
// 装备记录 dnfrepo.EquipmentRecord —— 腰带、称号这些普通穿戴槽;
// 宠物记录 dnfrepo.PetRecord —— **穿戴中的宠物在 PetRecord.EquippedKey 上**,
// 它不在装备记录里(宠物是独立的 list 7 + 独立记录)。
//
// 每件道具按 PVF 原文里的字段求和。任何一件读不到都只是**少算那一件**,不会让强化失败。
func (s *Service) currentWornUpgradeAssist(
ctx context.Context,
repositories dnfrepo.Group,
characterID string,
) currentWornUpgradeAssistResult {
var result currentWornUpgradeAssistResult
if s == nil || strings.TrimSpace(characterID) == "" {
return result
}
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil {
return result
}
itemIDs := make([]int64, 0, 16)
seen := make(map[int64]struct{}, 16)
appendItem := func(itemID int64) {
if itemID <= 0 {
return
}
if _, duplicate := seen[itemID]; duplicate {
return
}
seen[itemID] = struct{}{}
itemIDs = append(itemIDs, itemID)
}
if repositories.Equipment != nil {
if record, found, loadErr := repositories.Equipment.Load(ctx, characterID); loadErr == nil && found {
for _, entry := range record.Entries {
if entry.ItemID > 0 {
result.WornEquipment++
}
appendItem(entry.ItemID)
}
}
}
if repositories.Pet != nil {
if record, found, loadErr := repositories.Pet.Load(ctx, characterID); loadErr == nil && found {
// **只有出战(佩戴)的那只才算。** `PetRecord.EquippedKey` 就是"穿戴栏"在服务端的
// 口径:equip/pet_sync.go 在宠物进/出穿戴槽时写它,卸下会清空。
//
// 服主 2026-10-03:"这个宠物只有佩戴以后才生效 不佩戴是不生效的 直接扫穿戴栏不就行了"
// —— 所以这里**不做**"key 为空就扫全部宠物"的兜底:那会把背包里的宠物也算进去,
// 等于没佩戴也享受折扣。key 为空 = 没佩戴 = 不加成(fail-closed)。
if key := strings.TrimSpace(record.EquippedKey); key != "" {
if entry, ok := record.Entries[key]; ok {
result.PetEquipped = true
result.PetItemID = entry.ItemID
appendItem(entry.ItemID)
}
}
}
}
for _, itemID := range itemIDs {
item := s.currentPVFUpgradeAssist(catalog, itemID)
result.DiscountPercent += item.Discount
result.CompoundRate += item.Compound
result.Items = append(result.Items, item)
}
if result.DiscountPercent > 100 {
result.DiscountPercent = 100
}
if result.DiscountPercent < 0 {
result.DiscountPercent = 0
}
if result.CompoundRate < 0 {
result.CompoundRate = 0
}
return result
}
// currentPVFUpgradeAssist 从一件道具的 PVF 原文里读出这两个字段,并留下断点证据。
//
// 为什么直接读原文而不是加进 dungeonDropItemDefinition:这两个字段只有强化这一条路径用,
// 定义结构里已经有 40+ 个字段;直接读原文的改动面最小,也不会影响别的解析结果。
//
// 现场 2026-10-03 的断点(第二轮)就死在下面这行读文本的方式上,见注释。
func (s *Service) currentPVFUpgradeAssist(
catalog *pvfDungeonDropCatalog,
itemID int64,
) currentWornUpgradeAssistItem {
item := currentWornUpgradeAssistItem{ItemID: itemID}
if catalog == nil || catalog.source == nil || itemID <= 0 {
item.Err = "no-pvf-source"
return item
}
item.ListedPath = s.upgradeAssistPathForItem(catalog, itemID)
if item.ListedPath == "" {
item.Err = "item-id-not-in-equipment-lst"
return item
}
// **必须走 readDungeonDropText,不能直接 catalog.source.ReadText(ListedPath)。**
//
// equipment.lst 里的路径是**相对 equipment/ 目录**记的 —— 而且这不只是宠物那一类:
//
// equipment.lst 原文 : 400990076 `creature/hilda/hilda.equ`
// `character/priest/weapon/axe/108040415.equ`
// 归档里的真名 : equipment/creature/hilda/hilda.equ
// equipment/character/priest/weapon/axe/108040415.equ
//
// 于是直接 ReadText 一定 not found、两个字段永远取到 0:实测线上 equipment.lst 里
// 相对写法的 .equ 抽 20000 条,裸 ReadText 读到 **0** 条,走解析链读到 19370 条。
// 这也解释了为什么测试夹具(当时手写成带 equipment/ 前缀)全绿、线上却无效。
// readDungeonDropText 是 ResolveItem 一直在用的同一条解析链(raw → root 拼接 → (r) 变体),
// 这里复用它而不是自己拼路径,避免两套口径漂移。
archivePath, text, err := readDungeonDropText(
catalog.source, string(dungeonDropItemEquipment), item.ListedPath)
if err != nil || text == "" {
item.Err = "read-text-failed"
if err != nil {
item.Err = currentUpgradeAssistErrText(err)
}
return item
}
item.ArchivePath = archivePath
item.TextBytes = len(text)
item.Discount, item.Compound = currentUpgradeAssistFromText(text)
return item
}
// currentUpgradeAssistErrText 把错误压成无空格的短串,保证它当一个日志字段用。
func currentUpgradeAssistErrText(err error) string {
if err == nil {
return ""
}
message := strings.Join(strings.Fields(err.Error()), "_")
if len(message) > 120 {
message = message[:120]
}
return message
}
// upgradeAssistPathForItem 解析"道具 id → equipment.lst 里记的那条路径"(**原文,未做归档解析**)。
//
// 先查目录自带的索引,查不到再**直接从 equipment/equipment.lst 现查一次**(结果缓存)。
//
// 2026-10-03 那次排查一度以为"目录索引里没有这条 id"就是根因,于是加了这条兜底;
// 结果线上字段依旧是 0 —— 因为**真正的断点在读到路径之后的 ReadText**(见
// currentPVFUpgradeAssist 那段注释:lst 记的是相对 equipment/ 的路径)。
// 兜底本身留着无害(目录索引确实按 kind 过滤,宠物这种 .equ 不一定在里面),
// 但**它当时不是根因**,别再被这行注释带偏。
func (s *Service) upgradeAssistPathForItem(catalog *pvfDungeonDropCatalog, itemID int64) string {
if catalog == nil || itemID <= 0 || itemID > math.MaxUint32 {
return ""
}
if reference, found := catalog.itemRefs[uint32(itemID)]; found {
if path := strings.TrimSpace(reference.path); path != "" {
return path
}
}
index, err := s.currentEquipmentPathIndex(catalog)
if err != nil {
return ""
}
return index[uint32(itemID)]
}
// currentEquipmentPathIndex 惰性解析 equipment/equipment.lst 的 "id → 路径" 全表。
//
// equipment.lst 是一座大表,全库上万条;这里只在"目录索引没查到"时才解析一次并缓存,
// 之后每次强化都只是查 map。
func (s *Service) currentEquipmentPathIndex(catalog *pvfDungeonDropCatalog) (map[uint32]string, error) {
if s == nil || catalog == nil || catalog.source == nil {
return nil, errDungeonDropSourceRequired
}
catalog.upgradeAssistPathMu.Lock()
defer catalog.upgradeAssistPathMu.Unlock()
if catalog.upgradeAssistPathIndex != nil {
return catalog.upgradeAssistPathIndex, nil
}
if catalog.upgradeAssistPathErr != nil {
return nil, catalog.upgradeAssistPathErr
}
text, err := catalog.source.ReadText(dungeonDropEquipmentList)
if err != nil {
catalog.upgradeAssistPathErr = err
return nil, err
}
index := make(map[uint32]string, 8192)
for _, match := range currentEquipmentListEntryPattern.FindAllStringSubmatch(text, -1) {
id, parseErr := strconv.ParseUint(strings.TrimSpace(match[1]), 10, 32)
if parseErr != nil {
continue
}
path := strings.TrimSpace(match[2])
if path == "" {
continue
}
index[uint32(id)] = path
}
if len(index) == 0 {
err := fmt.Errorf("equipment list %s parsed to an empty index", dungeonDropEquipmentList)
catalog.upgradeAssistPathErr = err
return nil, err
}
catalog.upgradeAssistPathIndex = index
return index, nil
}
func currentUpgradeAssistFromText(text string) (discountPercent int64, compoundRate int64) {
if match := currentUpgradeCostDiscountPattern.FindStringSubmatch(text); match != nil {
if value, err := strconv.ParseInt(strings.TrimSpace(match[1]), 10, 64); err == nil {
discountPercent = value
}
}
if match := currentUpgradeCompoundRatePattern.FindStringSubmatch(text); match != nil {
if value, err := strconv.ParseInt(strings.TrimSpace(match[1]), 10, 64); err == nil {
compoundRate = value
}
}
return discountPercent, compoundRate
}
// withCurrentWornUpgradeAssist 把上面两项叠加到 PVF 策略上。
//
// - 费用:`cost - cost × 折扣 / 100`(与客户端展示同口径:273960 ⇒ 263002);
// - 成功率:`weight × (10000 + compoundRate) / 10000`(PVF 单位 0.01%),上限封在必成。
//
// 策略本身(`SuccessWeight` / `GoldCost`)来自 PVF,这里只做"装备加成",不动别的字段。
func (s *Service) withCurrentWornUpgradeAssist(
base func(mode string, currentLevel int, targetItemID int64, amplifyType int) (alignedcmd.UpgradePolicyResolution, error),
session *gameSession,
repositories dnfrepo.Group,
) func(mode string, currentLevel int, targetItemID int64, amplifyType int) (alignedcmd.UpgradePolicyResolution, error) {
if base == nil {
return nil
}
return func(mode string, currentLevel int, targetItemID int64, amplifyType int) (alignedcmd.UpgradePolicyResolution, error) {
policy, err := base(mode, currentLevel, targetItemID, amplifyType)
if err != nil {
return policy, err
}
if s == nil || session == nil || session.selectedCharacterID == 0 {
return policy, nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
assist := s.currentWornUpgradeAssist(ctx,
repositories, strconv.FormatUint(uint64(session.selectedCharacterID), 10))
discount, compoundRate := assist.DiscountPercent, assist.CompoundRate
// 无论有没有加成都要留痕:第一次上线时因为"没命中就不打日志",排查时看不到
// 任何线索(服主 2026-10-03 报"费用还是不对"那一轮)。日志里带上聚合结果与
// 生效后的策略值,下一次直接从日志就能判断是哪一环没取到。
//
// 第二轮又补了**每件道具**的证据(pet_listed_path / pet_archive_path /
// pet_text_bytes / pet_discount_raw / pet_compound_raw / worn_assist_items):
// 上一版只有聚合值 0/0,看到"读到了但字段为 0"仍旧分不清是"id 不在 lst"、
// "路径读不到文本"还是"正则没中"。现在这几个字段各自对应一截断点。
pet := assist.petEvidence()
s.logPacketEvent("dnf-upgrade-worn-assist",
"conn_id", session.connID,
"char_id", session.selectedCharacterID,
"mode", mode,
"target_item_id", targetItemID,
"cost_discount_percent", discount,
"compound_rate_permille", compoundRate,
"worn_equipment_entries", assist.WornEquipment,
"pet_equipped", assist.PetEquipped,
"pet_item_id", assist.PetItemID,
"pet_listed_path", pet.ListedPath,
"pet_archive_path", pet.ArchivePath,
"pet_text_bytes", pet.TextBytes,
"pet_discount_raw", pet.Discount,
"pet_compound_raw", pet.Compound,
"pet_read_error", pet.Err,
"worn_assist_items", assist.describeItems(),
"gold_cost_before", policy.GoldCost,
"success_weight_before", policy.SuccessWeight)
if discount > 0 && policy.GoldCost > 0 {
policy.GoldCost -= policy.GoldCost * discount / 100
}
if compoundRate > 0 && policy.SuccessWeight > 0 {
scaled := int64(policy.SuccessWeight) * (int64(currentUpgradeCompoundRateDivisor) + compoundRate) /
int64(currentUpgradeCompoundRateDivisor)
if scaled > int64(currentUpgradeSuccessBase) {
scaled = int64(currentUpgradeSuccessBase)
}
policy.SuccessWeight = int(scaled)
}
return policy, nil
}
}
// equipment.lst 里的 "id `path`" 行。(索引本身缓存在目录对象上,见
// pvfDungeonDropCatalog.upgradeAssistPathIndex —— 不放在包级,避免测试之间互相污染。)
var currentEquipmentListEntryPattern = regexp.MustCompile("(\\d+)\\s+`([^`]+)`")