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

411 lines
17 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"
"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+`([^`]+)`")