服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1438 lines
54 KiB
Go
1438 lines
54 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"path"
|
||
"runtime"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/purchaselimit"
|
||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||
)
|
||
|
||
const (
|
||
dungeonDropStackableList = "stackable/stackable.lst"
|
||
dungeonDropEquipmentList = "equipment/equipment.lst"
|
||
currentPVFExpirationDateLayout = "2006-01-02 15:04:05"
|
||
// dungeonDropEquipmentPoolMaxWorkers 是通用装备池建池时的扫描并发上限。
|
||
// 全量 14 万件 .equ 单线程约 5 秒,并发到 16 路后是秒级;上限 16 是为了不在
|
||
// 小机器上把启动期的 CPU 抢满。
|
||
dungeonDropEquipmentPoolMaxWorkers = 16
|
||
)
|
||
|
||
var currentPVFExpirationDateLocation = time.FixedZone("CST", 8*60*60)
|
||
|
||
var (
|
||
errDungeonDropSourceRequired = errors.New("dnf dungeon drop PVF source is required")
|
||
errDungeonDropItemUnresolved = errors.New("dnf dungeon drop item is not present in a supported PVF item list")
|
||
)
|
||
|
||
type dungeonDropItemKind string
|
||
|
||
const (
|
||
dungeonDropItemStackable dungeonDropItemKind = "stackable"
|
||
dungeonDropItemEquipment dungeonDropItemKind = "equipment"
|
||
)
|
||
|
||
// dungeonMonsterDropPoolEntry is one exact (item id, weight) pair from a
|
||
// monster document's [item] section. Weight selects an item within the pool;
|
||
// it is not treated as a drop probability.
|
||
type dungeonMonsterDropPoolEntry struct {
|
||
ItemID uint32
|
||
Weight uint32
|
||
}
|
||
|
||
type dungeonDropItemDefinition struct {
|
||
ItemID uint32
|
||
Kind dungeonDropItemKind
|
||
PVFPath string
|
||
Name string
|
||
IconPath string
|
||
IconIndex int64
|
||
UsableJobs []string
|
||
PackageRewardIDs []uint32
|
||
Explain string
|
||
AttachType string
|
||
AuctionRuleFound bool
|
||
AuctionAllowed bool
|
||
EquipmentType string
|
||
CustomAbilityType string
|
||
CustomAbilityCount int64
|
||
Grade int64
|
||
Rarity int64
|
||
MinimumLevel int64
|
||
MaximumLevel int64
|
||
CreationRate int64
|
||
StackableType string
|
||
EquipmentEffectID uint16
|
||
StackLimit int64
|
||
SlotStart int16
|
||
SlotEnd int16
|
||
Durability uint16
|
||
ExpirationDate time.Time
|
||
UsablePeriodDays int64
|
||
// DailyDeleteItem 是 PVF 的 `[daily delete item] 1`:该物品在每日重置时删除。
|
||
// 与 ExpirationDate / UsablePeriodDays 无关,见 daily_delete_item.go。
|
||
DailyDeleteItem bool
|
||
Price int64
|
||
PriceFound bool
|
||
ActionType string
|
||
// LotteryUseCost 是 PVF `[lottery use cost]`:使用这类"抽奖消耗品"时要扣的资源量。
|
||
// LotteryEntries 是它的 `[int data]` 抽奖表,按仓库既有的 (item, weight, count)
|
||
// 三元组解析 —— 与 [booster info] 的奖励项同一个形状(见本文件 line 510 的注释)。
|
||
// 目前只有 `[monster tarot card]`(卢克塔罗牌,例:stackable/10159001/10159697.stk)
|
||
// 用到;字段是**只读解析**,还没有任何使用路径消费它们。
|
||
LotteryUseCost int64
|
||
LotteryEntries []dungeonDropLotteryEntry
|
||
ActionValues *dungeonDropActionValues
|
||
ActionVillageOnly bool
|
||
ActionUsableVillage bool
|
||
ActionUsableDungeon bool
|
||
// InventoryListType is the list a reward of this item is granted into. The
|
||
// current PVF has no per-item list, so it stays at the main inventory (0)
|
||
// and callers assert that before granting.
|
||
InventoryListType byte
|
||
ActionUsableSeriaRoom bool
|
||
ActionDisallowAssault bool
|
||
ActionUsableDungeonIDs []int64
|
||
LimitDungeonIDs []int64
|
||
ActionStringData []string
|
||
ActionIntData []int64
|
||
DamageFontIndex uint16
|
||
DamageFontExpirationMode alignedcmd.DamageFontExpirationMode
|
||
DamageFontPeriodDays int64
|
||
DamageFontFixedExpiration time.Time
|
||
// HP/MP recovery mirrors the runtime PVF `[hp recovery]` / `[mp recovery]`
|
||
// sections as `<operator> <amount> <duration>`. Operator is `+` for a flat
|
||
// amount and `%` for a percentage of the drinker's maximum. See
|
||
// parseCurrentConsumableRecovery for why the second value is not a maximum.
|
||
HasHPRecovery bool
|
||
HPRecoveryOperator string
|
||
HPRecoveryAmount int64
|
||
HPRecoveryDurationMS int64
|
||
HasMPRecovery bool
|
||
MPRecoveryOperator string
|
||
MPRecoveryAmount int64
|
||
MPRecoveryDurationMS int64
|
||
ExpBonusPercent int64
|
||
EffectDurationMS int64
|
||
ExperienceSourceKey uint32
|
||
DirectExperienceType string
|
||
DirectExperienceValues []int64
|
||
LevelUpRange int64
|
||
LevelUpOutcomes []levelUpTicketOutcome
|
||
TotalUsableCount int64
|
||
// 冷却(PVF `[cool time]` 毫秒 + `[cooltime group]` + `[cooltime maintenance]`)。
|
||
// 只有声明了 `[cooltime maintenance]`(且值非零)的道具才由服务端**持久保留**冷却:
|
||
// 客户端的冷却门是进程内内存(cmd/scratch/potionpatch 已记"no packet is sent while
|
||
// the client blocks the action"),所以小退重上就没了。现场 2026-09-17 运营报
|
||
// "冷却时间为1天的消耗品 小退重上 冷却时间就消失了"(首日度假礼盒 490702504:
|
||
// [cool time] 86400000、[cooltime group] 66、[cooltime maintenance] 1 1 1)。
|
||
// 86 参考服务端同口径:HasCooltimeMaintenance + CoolTime + ResolveCooltimeGroup。
|
||
CooltimeMS int64
|
||
CooltimeGroup int64
|
||
HasCooltimeMaintenance bool
|
||
UseActionPacket int64
|
||
PetFeedEffectDurationSeconds int64
|
||
PetFeedSkillChargePercent int64
|
||
PetFeedSatietyDelta int64
|
||
CargoTransportStone bool
|
||
CargoTransportStoneType int64
|
||
CreatureTransportStone bool
|
||
CreatureTransportStoneType int64
|
||
ResealLevelMin int64
|
||
ResealLevelMax int64
|
||
ResealOneShot bool
|
||
HasGrowthType bool
|
||
PeriodEquipment bool
|
||
// 限购:PVF `[purchase limit]`(`` `scope` `period` count ``)或旧字段
|
||
// `[daily purchase limit]`(`` `scope` count ``,周期=每天)。
|
||
PurchaseLimitFound bool
|
||
PurchaseLimit purchaselimit.Policy
|
||
// PurchaseLimitError 记录字段存在但形状不认识的情况;此时按“无策略”处理,
|
||
// 以免因为一个畸形字段把整件道具判为不可解析。
|
||
PurchaseLimitError string
|
||
}
|
||
|
||
type dungeonDropActionValues struct {
|
||
Values []int64
|
||
}
|
||
|
||
type levelUpTicketOutcome struct {
|
||
Range int64
|
||
Weight int64
|
||
}
|
||
|
||
type dungeonDropItemReference struct {
|
||
kind dungeonDropItemKind
|
||
path string
|
||
}
|
||
|
||
// dungeonDropGradeRarityKey mirrors the C# domain pool key. It is not a
|
||
// protocol value: grade and rarity are read from the runtime PVF and only
|
||
// select a real item definition before the existing op38 item wire is built.
|
||
type dungeonDropGradeRarityKey struct {
|
||
Grade int64
|
||
Rarity int64
|
||
}
|
||
|
||
type dungeonDropWeightedItem struct {
|
||
ItemID uint32
|
||
Weight uint32
|
||
}
|
||
|
||
// dungeonDropGenericPoolAudit 是通用装备池的构成快照。它存在的理由是:
|
||
// 「某个稀有度在某个等级段一件候选都没有」这种洞以前完全不可见——建池失败只会
|
||
// 让 SelectGenericEquipment 静默降级成 rarity 0,产出照旧、日志干净。预载阶段把
|
||
// 这份快照打进服务器日志,同类问题下次会自己冒出来。
|
||
type dungeonDropGenericPoolAudit struct {
|
||
Scanned int
|
||
EquipmentMissing int
|
||
ReadFailed int
|
||
RarityMissing int
|
||
CreationRateAbsent int
|
||
LevelOutOfRange int
|
||
ExcludedEquipmentType int
|
||
Buckets int
|
||
Items int
|
||
ByRarity [7]int
|
||
LowestLevel [7]int
|
||
HighestLevel [7]int
|
||
Fallbacks uint64
|
||
}
|
||
|
||
// pvfDungeonDropCatalog owns only static PVF mapping. It deliberately does
|
||
// not decide how often a monster drops, allocate a scene slot, or mutate the
|
||
// player's inventory. Those are separate runtime/asset transactions.
|
||
type pvfDungeonDropCatalog struct {
|
||
mu sync.Mutex
|
||
gmMailOptionMu sync.Mutex
|
||
gmAvatarSetMu sync.Mutex
|
||
gmAvatarIndexMu sync.Mutex
|
||
|
||
source dnfpvf.Source
|
||
monsterPaths map[int64]string
|
||
itemRefs map[uint32]dungeonDropItemReference
|
||
// upgradeAssistPath* 是 equipment.lst 的 "id -> 路径" 全表缓存,只给"已装备道具的
|
||
// 强化助力"那条路用(upgrade_worn_assist.go):目录索引 itemRefs 只收它关心的 kind,
|
||
// 宠物那类 .equ 可能不在里面,所以那边需要一份不按 kind 过滤的索引。
|
||
upgradeAssistPathMu sync.Mutex
|
||
upgradeAssistPathIndex map[uint32]string
|
||
upgradeAssistPathErr error
|
||
stackableIDs []uint32
|
||
equipmentIDs []uint32
|
||
poolCache map[int64][]dungeonMonsterDropPoolEntry
|
||
itemCache map[uint32]dungeonDropItemDefinition
|
||
experienceSourceKeys map[uint32]uint32
|
||
gmEmblemOptions map[uint32][]GMItemSummary
|
||
gmEnchantOptions map[uint32][]GMItemSummary
|
||
gmAvatarSetIDs []uint32
|
||
gmAvatarSetReady bool
|
||
gmAvatarSetsByJob map[string][]GMAvatarSetSummary
|
||
|
||
// The generic pools deliberately stay separate from itemCache. They are
|
||
// built once, either by the service's "dungeon drop catalog" preload stage
|
||
// or lazily on the first actual generic drop roll, and have no effect on
|
||
// packet layout or persistence.
|
||
genericLoaded bool
|
||
genericErr error
|
||
genericStackables map[dungeonDropGradeRarityKey][]uint32
|
||
genericEquipment map[dungeonDropGradeRarityKey][]dungeonDropWeightedItem
|
||
genericAudit dungeonDropGenericPoolAudit
|
||
// 「城镇增益药剂」的 buff 表(etc/variousbuffstackablelist.etc +
|
||
// etc/variousbufflist.etc)。惰性加载:合成测试源里没有这两张表时必须能优雅降级到
|
||
// `[explain]` 文字回退,不能让整条 op44 因为读不到表而失败。
|
||
variousBuffOnce sync.Once
|
||
variousBuffs currentVariousBuffTables
|
||
variousBuffErr error
|
||
// genericFallbacks counts every request whose rarity had no candidate and
|
||
// was degraded to rarity 0. The degradation itself predates this field
|
||
// (the reference implementation also passes allowFallback), but it used to
|
||
// be invisible; callers now diff this map and log the delta.
|
||
genericFallbacks map[dungeonDropGradeRarityKey]uint64
|
||
}
|
||
|
||
func newPVFDungeonDropCatalog(source dnfpvf.Source) (*pvfDungeonDropCatalog, error) {
|
||
if source == nil {
|
||
return nil, errDungeonDropSourceRequired
|
||
}
|
||
monsterPaths, _, err := loadDungeonDropList(source, "monster/monster.lst")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load dungeon drop monster list: %w", err)
|
||
}
|
||
stackablePaths, stackableIDs, err := loadDungeonDropList(source, dungeonDropStackableList)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load dungeon drop stackable list: %w", err)
|
||
}
|
||
equipmentPaths, equipmentIDs, err := loadDungeonDropList(source, dungeonDropEquipmentList)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load dungeon drop equipment list: %w", err)
|
||
}
|
||
itemRefs := make(map[uint32]dungeonDropItemReference, len(stackablePaths)+len(equipmentPaths))
|
||
for itemID, itemPath := range stackablePaths {
|
||
if itemID <= 0 || itemID > math.MaxUint32 {
|
||
continue
|
||
}
|
||
itemRefs[uint32(itemID)] = dungeonDropItemReference{kind: dungeonDropItemStackable, path: itemPath}
|
||
}
|
||
for itemID, itemPath := range equipmentPaths {
|
||
if itemID <= 0 || itemID > math.MaxUint32 {
|
||
continue
|
||
}
|
||
if _, exists := itemRefs[uint32(itemID)]; !exists {
|
||
itemRefs[uint32(itemID)] = dungeonDropItemReference{kind: dungeonDropItemEquipment, path: itemPath}
|
||
}
|
||
}
|
||
experienceSourceKeys, err := loadExperienceSourceKeys(source)
|
||
if err != nil {
|
||
// Older and focused test PVFs may omit this optional table. The item
|
||
// effect remains usable; only the legacy source-key hint is unavailable.
|
||
experienceSourceKeys = make(map[uint32]uint32)
|
||
}
|
||
return &pvfDungeonDropCatalog{
|
||
source: source,
|
||
monsterPaths: monsterPaths,
|
||
itemRefs: itemRefs,
|
||
stackableIDs: append([]uint32(nil), stackableIDs...),
|
||
equipmentIDs: append([]uint32(nil), equipmentIDs...),
|
||
poolCache: make(map[int64][]dungeonMonsterDropPoolEntry),
|
||
itemCache: make(map[uint32]dungeonDropItemDefinition),
|
||
genericFallbacks: make(map[dungeonDropGradeRarityKey]uint64),
|
||
experienceSourceKeys: experienceSourceKeys,
|
||
gmEmblemOptions: make(map[uint32][]GMItemSummary),
|
||
gmEnchantOptions: make(map[uint32][]GMItemSummary),
|
||
gmAvatarSetsByJob: make(map[string][]GMAvatarSetSummary),
|
||
}, nil
|
||
}
|
||
|
||
const dungeonExperienceSourceStackableList = "etc/variousbuffstackablelist.etc"
|
||
|
||
func loadExperienceSourceKeys(source dnfpvf.Source) (map[uint32]uint32, error) {
|
||
text, err := source.ReadText(dungeonExperienceSourceStackableList)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
document, err := dnfpvf.Parse(dungeonExperienceSourceStackableList, text)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
keys := make(map[uint32]uint32)
|
||
tokens := document.Tokens
|
||
for i := 0; i < len(tokens); i++ {
|
||
if !isPVFSectionToken(tokens[i], "index") {
|
||
continue
|
||
}
|
||
var itemID, sourceKey int64
|
||
for j := i + 1; j < len(tokens); j++ {
|
||
if isPVFSectionToken(tokens[j], "index") || isPVFSectionToken(tokens[j], "/index") {
|
||
break
|
||
}
|
||
if itemID == 0 && tokens[j].Kind == dnfpvf.TokenInt {
|
||
itemID = tokens[j].Int
|
||
continue
|
||
}
|
||
if isPVFSectionToken(tokens[j], "buff list") {
|
||
for k := j + 1; k < len(tokens); k++ {
|
||
if tokens[k].Kind == dnfpvf.TokenInt {
|
||
sourceKey = tokens[k].Int
|
||
break
|
||
}
|
||
if isPVFSectionToken(tokens[k], "index") || isPVFSectionToken(tokens[k], "/index") {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if itemID > 0 && itemID <= math.MaxUint32 && sourceKey > 0 && sourceKey <= math.MaxUint32 {
|
||
keys[uint32(itemID)] = uint32(sourceKey)
|
||
}
|
||
}
|
||
return keys, nil
|
||
}
|
||
|
||
func isPVFSectionToken(token dnfpvf.Token, name string) bool {
|
||
return token.Kind == dnfpvf.TokenSection && strings.EqualFold(strings.TrimSpace(token.Value), name)
|
||
}
|
||
|
||
func loadDungeonDropList(source dnfpvf.Source, listPath string) (map[int64]string, []uint32, error) {
|
||
text, err := source.ReadText(listPath)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("read %s: %w", listPath, err)
|
||
}
|
||
document, err := dnfpvf.Parse(listPath, text)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("parse %s: %w", listPath, err)
|
||
}
|
||
entries := dnfpvf.ParseList(document)
|
||
out := make(map[int64]string, len(entries))
|
||
order := make([]uint32, 0, len(entries))
|
||
for _, entry := range entries {
|
||
clean := cleanDungeonDropPath(entry.Path)
|
||
if entry.ID <= 0 || entry.ID > math.MaxUint32 || clean == "" {
|
||
continue
|
||
}
|
||
if _, exists := out[entry.ID]; !exists {
|
||
out[entry.ID] = clean
|
||
order = append(order, uint32(entry.ID))
|
||
}
|
||
}
|
||
return out, order, nil
|
||
}
|
||
|
||
func (c *pvfDungeonDropCatalog) MonsterPool(monsterID int64) ([]dungeonMonsterDropPoolEntry, error) {
|
||
if c == nil || c.source == nil {
|
||
return nil, errDungeonDropSourceRequired
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if cached, ok := c.poolCache[monsterID]; ok {
|
||
return cloneDungeonMonsterDropPool(cached), nil
|
||
}
|
||
listedPath, ok := c.monsterPaths[monsterID]
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
docPath, text, err := readDungeonDropText(c.source, "monster", listedPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read drop pool monster=%d path=%s: %w", monsterID, listedPath, err)
|
||
}
|
||
document, err := dnfpvf.Parse(docPath, text)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse drop pool monster=%d path=%s: %w", monsterID, docPath, err)
|
||
}
|
||
pool := parseDungeonMonsterDropPool(document)
|
||
c.poolCache[monsterID] = cloneDungeonMonsterDropPool(pool)
|
||
return cloneDungeonMonsterDropPool(pool), nil
|
||
}
|
||
|
||
func (c *pvfDungeonDropCatalog) ResolveItem(itemID uint32) (dungeonDropItemDefinition, error) {
|
||
if c == nil || c.source == nil {
|
||
return dungeonDropItemDefinition{}, errDungeonDropSourceRequired
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if cached, ok := c.itemCache[itemID]; ok {
|
||
return cached, nil
|
||
}
|
||
reference, ok := c.itemRefs[itemID]
|
||
if !ok {
|
||
return dungeonDropItemDefinition{}, fmt.Errorf("%w: item_id=%d", errDungeonDropItemUnresolved, itemID)
|
||
}
|
||
root := string(reference.kind)
|
||
docPath, text, err := readDungeonDropText(c.source, root, reference.path)
|
||
if err != nil {
|
||
return dungeonDropItemDefinition{}, fmt.Errorf("read drop item=%d path=%s: %w", itemID, reference.path, err)
|
||
}
|
||
document, err := dnfpvf.Parse(docPath, text)
|
||
if err != nil {
|
||
return dungeonDropItemDefinition{}, fmt.Errorf("parse drop item=%d path=%s: %w", itemID, docPath, err)
|
||
}
|
||
definition := dungeonDropItemDefinition{ItemID: itemID, Kind: reference.kind, PVFPath: docPath}
|
||
definition.Name, _ = document.Text("name")
|
||
definition.IconPath, _ = document.Text("icon")
|
||
definition.IconIndex, _ = document.Int("icon")
|
||
definition.UsableJobs = append([]string(nil), document.Texts("usable job")...)
|
||
if packageValues := document.Ints("package data"); len(packageValues) >= 2 {
|
||
definition.PackageRewardIDs = make([]uint32, 0, len(packageValues)/2)
|
||
for index := 0; index+1 < len(packageValues); index += 2 {
|
||
itemID := packageValues[index]
|
||
if itemID > 0 && itemID <= math.MaxUint32 {
|
||
definition.PackageRewardIDs = append(definition.PackageRewardIDs, uint32(itemID))
|
||
}
|
||
}
|
||
}
|
||
definition.Explain = strings.Join(document.Texts("explain"), "\n")
|
||
definition.Rarity, _ = document.Int("rarity")
|
||
definition.MinimumLevel, _ = document.Int("minimum level")
|
||
definition.CreationRate, _ = document.Int("creation rate")
|
||
definition.ExperienceSourceKey = c.experienceSourceKeys[itemID]
|
||
definition.AttachType, _ = document.Text("attach type")
|
||
if policy, found, err := parseCurrentPurchaseLimitSection(itemID, document); err != nil {
|
||
definition.PurchaseLimitError = err.Error()
|
||
} else if found {
|
||
definition.PurchaseLimitFound = true
|
||
definition.PurchaseLimit = policy
|
||
}
|
||
if available, found := document.Int("available auction"); found {
|
||
definition.AuctionRuleFound = true
|
||
definition.AuctionAllowed = available != 0
|
||
}
|
||
if price, found := document.Int("price"); found && price >= 0 {
|
||
definition.Price = price
|
||
definition.PriceFound = true
|
||
}
|
||
expirationDate, err := parseCurrentPVFExpirationDate(document)
|
||
if err != nil {
|
||
return dungeonDropItemDefinition{}, fmt.Errorf("parse drop item=%d path=%s expiration date: %w", itemID, docPath, err)
|
||
}
|
||
definition.ExpirationDate = expirationDate
|
||
if usablePeriod, found := document.Int("usable period"); found && usablePeriod > 0 {
|
||
definition.UsablePeriodDays = usablePeriod
|
||
}
|
||
// `[daily delete item] 1` 是和上面那套期限字段**完全独立**的一条规则:物品在
|
||
// 每日重置(服务日边界,北京 06:00)时被删除,客户端把它渲染成"期限 1 天"。
|
||
//
|
||
// 它必须单独读,原因见 currentDailyDeleteItemStateKey 的注释:剥离期限的两个
|
||
// 工具(cmd/scratch/pvfremoveitemexpiry、removeitemexpirydb)摘的是
|
||
// [expiration date] / [usable datetime] / [usable period] /
|
||
// [usable period after unsealing],**不含这一条**,所以它在本服的运行时 PVF
|
||
// 里仍然有效,而上面那几个字段已经被有意屏蔽了。
|
||
if daily, found := document.Int("daily delete item"); found && daily != 0 {
|
||
definition.DailyDeleteItem = true
|
||
}
|
||
if reference.kind == dungeonDropItemEquipment {
|
||
definition.SlotStart, definition.SlotEnd = 9, 64
|
||
definition.EquipmentType, _ = document.Text("equipment type")
|
||
definition.CustomAbilityType, _ = document.Text("custom ability type")
|
||
definition.CustomAbilityCount, _ = document.Int("custom ability count")
|
||
definition.Grade, _ = document.Int("grade")
|
||
_, definition.HasGrowthType = document.Section("growth type")
|
||
_, definition.PeriodEquipment = document.Section("period equipment")
|
||
for _, name := range []string{"durability", "maximum durability", "max durability"} {
|
||
value, found := document.Int(name)
|
||
if !found || value <= 0 {
|
||
continue
|
||
}
|
||
if value > math.MaxUint16 {
|
||
value = math.MaxUint16
|
||
}
|
||
definition.Durability = uint16(value)
|
||
break
|
||
}
|
||
} else {
|
||
definition.StackableType, _ = document.Text("stackable type")
|
||
parseCurrentTransportStoneDefinition(document, &definition)
|
||
if resealLevels := document.Ints("reseal lv"); len(resealLevels) >= 2 {
|
||
definition.ResealLevelMin = resealLevels[0]
|
||
definition.ResealLevelMax = resealLevels[1]
|
||
}
|
||
_, definition.ResealOneShot = document.Section("reseal oneshot")
|
||
if strings.TrimSpace(definition.StackableType) == "" {
|
||
actionType, _ := document.Text("action type")
|
||
definition.StackableType = currentPVFInferredStackableType(actionType)
|
||
}
|
||
if effectType, found := document.Text("increase status type"); found {
|
||
definition.DirectExperienceType = strings.TrimSpace(effectType)
|
||
definition.DirectExperienceValues = append([]int64(nil), document.Ints("increase status type")...)
|
||
}
|
||
if levelUpRange, found := document.Int("levelup range"); found && levelUpRange > 0 {
|
||
definition.LevelUpRange = levelUpRange
|
||
for _, group := range parseMagicBoxBoosterGroups(document) {
|
||
for _, entry := range group.Entries {
|
||
// Level-up ticket [booster info]/[etc] reuses the
|
||
// ordinary (item, weight, count) triple as
|
||
// (sentinel=1, probability, gained levels).
|
||
if entry.ItemID == 1 && entry.Weight > 0 && entry.Count > 0 && entry.Count <= levelUpRange {
|
||
definition.LevelUpOutcomes = append(definition.LevelUpOutcomes, levelUpTicketOutcome{
|
||
Range: entry.Count, Weight: entry.Weight,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if maximumLevel, found := document.Int("maximum level"); found && maximumLevel > 0 {
|
||
definition.MaximumLevel = maximumLevel
|
||
}
|
||
if totalUsableCount, found := document.Int("total usable count"); found && totalUsableCount > 0 {
|
||
definition.TotalUsableCount = totalUsableCount
|
||
}
|
||
// `[lottery use cost]` + `[int data]`:抽奖类消耗品([monster tarot card])的
|
||
// 花费与奖池。两者都只在道具自己声明时才填,缺一个就整条不成立(由使用侧
|
||
// fail closed 判断),这里不做任何语义推断。
|
||
if lotteryUseCost, found := document.Int("lottery use cost"); found && lotteryUseCost > 0 {
|
||
definition.LotteryUseCost = lotteryUseCost
|
||
}
|
||
definition.LotteryEntries = parseDungeonDropLotteryEntries(document.Ints("int data"))
|
||
// 冷却:`[cool time]` 毫秒 + `[cooltime group]` + `[cooltime maintenance]`。
|
||
// maintenance 段存在且至少一个值非零 = 这个冷却要**跨会话保留**,由服务端落库;
|
||
// 只有 [cool time] 而没有 maintenance 的道具(例如 10 秒冷却的红蓝药)保持纯客户端
|
||
// 语义,服务端不记、不下发——否则会多出一堆无意义的行。
|
||
if cooltimeMS, found := document.Int("cool time"); found && cooltimeMS > 0 {
|
||
definition.CooltimeMS = cooltimeMS
|
||
}
|
||
if cooltimeGroup, found := document.Int("cooltime group"); found && cooltimeGroup > 0 {
|
||
definition.CooltimeGroup = cooltimeGroup
|
||
}
|
||
for _, value := range document.Ints("cooltime maintenance") {
|
||
if value != 0 {
|
||
definition.HasCooltimeMaintenance = true
|
||
break
|
||
}
|
||
}
|
||
if useActionPacket, found := document.Int("use action packet"); found && useActionPacket > 0 {
|
||
definition.UseActionPacket = useActionPacket
|
||
}
|
||
if normalizeDungeonDropStackableType(definition.StackableType) == "[feed]" {
|
||
values := document.Ints("int data")
|
||
if len(values) >= 3 {
|
||
definition.PetFeedEffectDurationSeconds = values[0]
|
||
definition.PetFeedSkillChargePercent = values[1]
|
||
definition.PetFeedSatietyDelta = values[2]
|
||
}
|
||
}
|
||
if rate, found := document.Number("exp bonus rate"); found && rate > 0 && !math.IsNaN(rate) && !math.IsInf(rate, 0) {
|
||
percent := math.Round(rate * 100)
|
||
if percent > 0 && percent <= math.MaxInt64 {
|
||
definition.ExpBonusPercent = int64(percent)
|
||
}
|
||
}
|
||
if duration, found := document.Int("stat change duration"); found && duration > 0 {
|
||
definition.EffectDurationMS = duration
|
||
}
|
||
if effectID, found := document.Int("int data"); found && effectID > 0 && effectID <= math.MaxUint16 {
|
||
definition.EquipmentEffectID = uint16(effectID)
|
||
}
|
||
if limit, found := document.Int("stack limit"); found && limit > 0 {
|
||
definition.StackLimit = limit
|
||
}
|
||
definition.SlotStart, definition.SlotEnd = dungeonDropStackableSlotRange(definition.StackableType)
|
||
parseCurrentConsumableRecovery(document, &definition)
|
||
if err := parseCurrentDamageFontDefinition(document, &definition); err != nil {
|
||
return dungeonDropItemDefinition{}, fmt.Errorf("parse drop item=%d path=%s damage font: %w", itemID, docPath, err)
|
||
}
|
||
}
|
||
c.itemCache[itemID] = definition
|
||
return definition, nil
|
||
}
|
||
|
||
// parseCurrentConsumableRecovery reads the top-level `[hp recovery]` and
|
||
// `[mp recovery]` sections of a stackable document.
|
||
//
|
||
// The layout is `<operator> <amount> <duration>`, NOT a `<min> <max>` roll
|
||
// range. The runtime PVF settles this outright: 专家HP药剂 declares
|
||
// `+ 1080 1000` and 达人HP药剂 `+ 1520 1000`, so the second value is smaller
|
||
// than the first and cannot be a maximum; every one of the twelve ordinary
|
||
// HP/MP potions carries exactly 1000 there while their first values climb
|
||
// 70/180/410/710/1080/1520 in tier order, which is the healed amount. Across
|
||
// the whole archive the second value only ever takes a handful of values
|
||
// (500/1000/2000) unrelated to the first.
|
||
//
|
||
// The operator is the section's text token: `+` is a flat amount and `%` is a
|
||
// percentage of the drinker's maximum. Both are real - 646 stackables declare a
|
||
// recovery section, 67 HP and 54 MP of them with `+` and the rest with `%`.
|
||
// The duration is kept only so a later reader is not tempted to re-derive it;
|
||
// nothing on the wire consumes it, because the recovery ACK carries a single
|
||
// scalar.
|
||
func parseCurrentConsumableRecovery(document *dnfpvf.Document, definition *dungeonDropItemDefinition) {
|
||
if document == nil || definition == nil {
|
||
return
|
||
}
|
||
for _, recovery := range []struct {
|
||
section string
|
||
found *bool
|
||
operator *string
|
||
amount *int64
|
||
duration *int64
|
||
}{
|
||
{"hp recovery", &definition.HasHPRecovery, &definition.HPRecoveryOperator, &definition.HPRecoveryAmount, &definition.HPRecoveryDurationMS},
|
||
{"mp recovery", &definition.HasMPRecovery, &definition.MPRecoveryOperator, &definition.MPRecoveryAmount, &definition.MPRecoveryDurationMS},
|
||
} {
|
||
operator, present := document.Text(recovery.section)
|
||
values := document.Ints(recovery.section)
|
||
if !present && len(values) == 0 {
|
||
continue
|
||
}
|
||
*recovery.found = true
|
||
*recovery.operator = strings.TrimSpace(operator)
|
||
if len(values) >= 1 {
|
||
*recovery.amount = values[0]
|
||
}
|
||
if len(values) >= 2 {
|
||
*recovery.duration = values[1]
|
||
}
|
||
}
|
||
}
|
||
|
||
// parseCurrentTransportStoneDefinition accepts both transport-stone layouts
|
||
// present in current and legacy PVFs. Some older documents expose a dedicated
|
||
// [cargo transport stone]/[creature transport stone] section. Current 90CN
|
||
// documents instead encode the same type as the integer payload of
|
||
// [action type], for example `[cargo transport stone] 8`. Treat the action
|
||
// name and its integer as one contract; item names and archive paths are not
|
||
// authoritative enough to infer a transfer rule.
|
||
func parseCurrentTransportStoneDefinition(document *dnfpvf.Document, definition *dungeonDropItemDefinition) {
|
||
if document == nil || definition == nil {
|
||
return
|
||
}
|
||
definition.CargoTransportStoneType, definition.CargoTransportStone = document.Int("cargo transport stone")
|
||
definition.CreatureTransportStoneType, definition.CreatureTransportStone = document.Int("creature transport stone")
|
||
actionType, found := document.Text("action type")
|
||
actionValues := document.Ints("action type")
|
||
if !found || len(actionValues) == 0 {
|
||
return
|
||
}
|
||
switch normalizeDungeonDropStackableType(actionType) {
|
||
case "[cargo transport stone]":
|
||
definition.CargoTransportStoneType = actionValues[0]
|
||
definition.CargoTransportStone = true
|
||
case "[creature transport stone]":
|
||
definition.CreatureTransportStoneType = actionValues[0]
|
||
definition.CreatureTransportStone = true
|
||
}
|
||
}
|
||
|
||
// currentPVFInferredStackableType covers only a proven malformed current-PVF
|
||
// document. Item 490008569 omits [stackable type] but carries the dedicated
|
||
// [gold kaleido box] action, while the other 26 golden-kaleido templates use
|
||
// [gold kaleido]. Keep this exact and fail closed for every unknown omission;
|
||
// the server must not invent a category from an item name or archive path.
|
||
func currentPVFInferredStackableType(actionType string) string {
|
||
switch normalizeDungeonDropStackableType(actionType) {
|
||
case "[gold kaleido box]":
|
||
return "[gold kaleido]"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func parseCurrentDamageFontDefinition(document *dnfpvf.Document, definition *dungeonDropItemDefinition) error {
|
||
if document == nil || definition == nil {
|
||
return nil
|
||
}
|
||
definition.ActionStringData = append([]string(nil), document.Texts("string data")...)
|
||
definition.ActionIntData = append([]int64(nil), document.Ints("int data")...)
|
||
definition.ActionUsableDungeonIDs = append([]int64(nil), document.Ints("action usable dungeon")...)
|
||
definition.LimitDungeonIDs = append([]int64(nil), document.Ints("limit dungeon")...)
|
||
for _, place := range document.Texts("action usable place") {
|
||
switch strings.ToLower(strings.TrimSpace(place)) {
|
||
case "[village]":
|
||
definition.ActionUsableVillage = true
|
||
definition.ActionVillageOnly = true
|
||
case "[dungeon]":
|
||
definition.ActionUsableDungeon = true
|
||
case "[seria room]":
|
||
definition.ActionUsableSeriaRoom = true
|
||
case "[dont use assult]":
|
||
definition.ActionDisallowAssault = true
|
||
}
|
||
}
|
||
actionType, found := document.Text("action type")
|
||
if !found {
|
||
return nil
|
||
}
|
||
definition.ActionType = strings.TrimSpace(actionType)
|
||
if values := document.Ints("action type"); len(values) > 0 {
|
||
definition.ActionValues = &dungeonDropActionValues{Values: append([]int64(nil), values...)}
|
||
}
|
||
if !strings.EqualFold(definition.ActionType, "[add damage font skin]") {
|
||
return nil
|
||
}
|
||
fontIndex, found := document.Int("index")
|
||
if !found || fontIndex <= 0 || fontIndex > math.MaxUint16 {
|
||
return fmt.Errorf("font index %d invalid", fontIndex)
|
||
}
|
||
definition.DamageFontIndex = uint16(fontIndex)
|
||
|
||
texts := document.Texts("expiration info")
|
||
ints := document.Ints("expiration info")
|
||
for index, token := range texts {
|
||
switch {
|
||
case strings.EqualFold(strings.TrimSpace(token), "[period]"):
|
||
if len(ints) == 0 || ints[0] <= 0 {
|
||
return fmt.Errorf("period expiration missing positive days")
|
||
}
|
||
definition.DamageFontExpirationMode = alignedcmd.DamageFontExpirationPeriod
|
||
definition.DamageFontPeriodDays = ints[0]
|
||
return nil
|
||
case strings.EqualFold(strings.TrimSpace(token), "[date]"):
|
||
if index+1 >= len(texts) {
|
||
return fmt.Errorf("fixed expiration missing date")
|
||
}
|
||
expiresAt, err := time.ParseInLocation(currentPVFExpirationDateLayout, strings.TrimSpace(texts[index+1]), currentPVFExpirationDateLocation)
|
||
if err != nil {
|
||
return fmt.Errorf("fixed expiration %q: %w", texts[index+1], err)
|
||
}
|
||
if expiresAt.Unix() <= 0 || expiresAt.Unix() > math.MaxUint32 {
|
||
return fmt.Errorf("fixed expiration unix %d exceeds current u32 wire", expiresAt.Unix())
|
||
}
|
||
definition.DamageFontExpirationMode = alignedcmd.DamageFontExpirationFixed
|
||
definition.DamageFontFixedExpiration = expiresAt.UTC()
|
||
return nil
|
||
case strings.EqualFold(strings.TrimSpace(token), "[unlimit]"):
|
||
definition.DamageFontExpirationMode = alignedcmd.DamageFontExpirationUnlimited
|
||
return nil
|
||
}
|
||
}
|
||
return fmt.Errorf("expiration info is missing a supported period/date/unlimit mode")
|
||
}
|
||
|
||
func parseCurrentPVFExpirationDate(document *dnfpvf.Document) (time.Time, error) {
|
||
if document == nil {
|
||
return time.Time{}, nil
|
||
}
|
||
raw, found := document.Text("expiration date")
|
||
raw = strings.TrimSpace(raw)
|
||
if !found || raw == "" {
|
||
return time.Time{}, nil
|
||
}
|
||
expirationDate, err := time.ParseInLocation(currentPVFExpirationDateLayout, raw, currentPVFExpirationDateLocation)
|
||
if err != nil {
|
||
return time.Time{}, fmt.Errorf("value=%q layout=%q: %w", raw, currentPVFExpirationDateLayout, err)
|
||
}
|
||
unix := expirationDate.Unix()
|
||
if unix <= 0 || unix > math.MaxUint32 {
|
||
return time.Time{}, fmt.Errorf("value=%q unix=%d exceeds current u32 item wire", raw, unix)
|
||
}
|
||
return expirationDate.UTC(), nil
|
||
}
|
||
|
||
// SelectGenericStackable applies the C# MonsterDropConfig candidate rule to
|
||
// the active PVF: candidates are uniform within (grade, rarity), then fall
|
||
// back to rarity zero exactly as the reference implementation does. It does
|
||
// not create a scene object or write an inventory row.
|
||
func (c *pvfDungeonDropCatalog) SelectGenericStackable(
|
||
rng *currentDungeonDropLCG,
|
||
monsterLevel int,
|
||
gradeDown int64,
|
||
gradeUp int64,
|
||
rarity int64,
|
||
) (uint32, bool, error) {
|
||
if rng == nil {
|
||
return 0, false, errDungeonDropSourceRequired
|
||
}
|
||
if err := c.ensureGenericPools(); err != nil {
|
||
return 0, false, err
|
||
}
|
||
candidates := c.genericStackableCandidates(monsterLevel, gradeDown, gradeUp, rarity)
|
||
if len(candidates) == 0 && rarity > 0 {
|
||
candidates = c.genericStackableCandidates(monsterLevel, gradeDown, gradeUp, 0)
|
||
}
|
||
if len(candidates) == 0 {
|
||
return 0, false, nil
|
||
}
|
||
return candidates[rng.Next(len(candidates))], true, nil
|
||
}
|
||
|
||
// SelectGenericEquipment is the weighted counterpart of
|
||
// SelectGenericStackable. The pool's weight is the equipment document's own
|
||
// [creation rate]; it is a domain weight only and is never serialized into the
|
||
// current EXE item wire.
|
||
//
|
||
// When the requested rarity has no candidate at all the call degrades to
|
||
// rarity 0 — the reference implementation does the same (allowFallback). The
|
||
// degradation is recorded so a caller can surface it; before this it was
|
||
// silent, which is exactly how a whole rarity could stop dropping without
|
||
// anyone noticing.
|
||
func (c *pvfDungeonDropCatalog) SelectGenericEquipment(
|
||
rng *currentDungeonDropLCG,
|
||
monsterLevel int,
|
||
gradeDown int64,
|
||
gradeUp int64,
|
||
rarity int64,
|
||
) (uint32, bool, error) {
|
||
if rng == nil {
|
||
return 0, false, errDungeonDropSourceRequired
|
||
}
|
||
if err := c.ensureGenericPools(); err != nil {
|
||
return 0, false, err
|
||
}
|
||
candidates := c.genericEquipmentCandidates(monsterLevel, gradeDown, gradeUp, rarity)
|
||
if len(candidates) == 0 && rarity > 0 {
|
||
c.recordGenericEquipmentFallback(monsterLevel, rarity)
|
||
candidates = c.genericEquipmentCandidates(monsterLevel, gradeDown, gradeUp, 0)
|
||
}
|
||
if len(candidates) == 0 {
|
||
return 0, false, nil
|
||
}
|
||
var total uint64
|
||
for _, candidate := range candidates {
|
||
if candidate.ItemID == 0 || candidate.Weight == 0 || math.MaxUint64-total < uint64(candidate.Weight) {
|
||
continue
|
||
}
|
||
total += uint64(candidate.Weight)
|
||
}
|
||
if total == 0 {
|
||
return 0, false, nil
|
||
}
|
||
want := uint64(rng.NextUint32()) % total
|
||
var cumulative uint64
|
||
for _, candidate := range candidates {
|
||
if candidate.ItemID == 0 || candidate.Weight == 0 || math.MaxUint64-cumulative < uint64(candidate.Weight) {
|
||
continue
|
||
}
|
||
cumulative += uint64(candidate.Weight)
|
||
if want < cumulative {
|
||
return candidate.ItemID, true, nil
|
||
}
|
||
}
|
||
return 0, false, nil
|
||
}
|
||
|
||
func (c *pvfDungeonDropCatalog) genericStackableCandidates(
|
||
monsterLevel int,
|
||
gradeDown int64,
|
||
gradeUp int64,
|
||
rarity int64,
|
||
) []uint32 {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.collectGenericStackablesLocked(monsterLevel, gradeDown, gradeUp, rarity)
|
||
}
|
||
|
||
func (c *pvfDungeonDropCatalog) genericEquipmentCandidates(
|
||
monsterLevel int,
|
||
gradeDown int64,
|
||
gradeUp int64,
|
||
rarity int64,
|
||
) []dungeonDropWeightedItem {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.collectGenericEquipmentLocked(monsterLevel, gradeDown, gradeUp, rarity)
|
||
}
|
||
|
||
func (c *pvfDungeonDropCatalog) collectGenericStackablesLocked(
|
||
monsterLevel int,
|
||
gradeDown int64,
|
||
gradeUp int64,
|
||
rarity int64,
|
||
) []uint32 {
|
||
if c == nil || gradeDown < 0 || gradeUp <= 0 {
|
||
return nil
|
||
}
|
||
result := make([]uint32, 0)
|
||
for offset := -gradeDown; offset < gradeUp; offset++ {
|
||
grade := int64(monsterLevel) + offset
|
||
if grade < 1 || grade > 200 {
|
||
continue
|
||
}
|
||
result = append(result, c.genericStackables[dungeonDropGradeRarityKey{Grade: grade, Rarity: rarity}]...)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (c *pvfDungeonDropCatalog) collectGenericEquipmentLocked(
|
||
monsterLevel int,
|
||
gradeDown int64,
|
||
gradeUp int64,
|
||
rarity int64,
|
||
) []dungeonDropWeightedItem {
|
||
if c == nil || gradeDown < 0 || gradeUp < 0 {
|
||
return nil
|
||
}
|
||
result := make([]dungeonDropWeightedItem, 0)
|
||
for offset := -gradeDown; offset <= gradeUp; offset++ {
|
||
grade := int64(monsterLevel) + offset
|
||
if grade < 1 || grade > 200 {
|
||
continue
|
||
}
|
||
result = append(result, c.genericEquipment[dungeonDropGradeRarityKey{Grade: grade, Rarity: rarity}]...)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (c *pvfDungeonDropCatalog) ensureGenericPools() error {
|
||
if c == nil || c.source == nil {
|
||
return errDungeonDropSourceRequired
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if c.genericLoaded {
|
||
return c.genericErr
|
||
}
|
||
c.genericLoaded = true
|
||
c.genericStackables = make(map[dungeonDropGradeRarityKey][]uint32)
|
||
c.genericEquipment = make(map[dungeonDropGradeRarityKey][]dungeonDropWeightedItem)
|
||
if err := c.loadGenericStackablesLocked(); err != nil {
|
||
c.genericErr = err
|
||
return err
|
||
}
|
||
if err := c.loadGenericEquipmentLocked(); err != nil {
|
||
c.genericErr = err
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c *pvfDungeonDropCatalog) loadGenericStackablesLocked() error {
|
||
for _, itemID := range c.stackableIDs {
|
||
reference, found := c.itemRefs[itemID]
|
||
if !found || reference.kind != dungeonDropItemStackable || dungeonDropExcludedStackablePath(reference.path) {
|
||
continue
|
||
}
|
||
docPath, text, err := readDungeonDropText(c.source, string(dungeonDropItemStackable), reference.path)
|
||
if err != nil {
|
||
// The C# loader skips one unreadable stackable rather than replacing the
|
||
// whole table with a synthetic fallback.
|
||
continue
|
||
}
|
||
document, err := dnfpvf.Parse(docPath, text)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
creationRate, found := document.Int("creation rate")
|
||
if !found || creationRate <= 0 {
|
||
continue
|
||
}
|
||
grade, found := document.Int("grade")
|
||
if !found || grade <= 0 || grade > 200 {
|
||
continue
|
||
}
|
||
rarity, found := document.Int("rarity")
|
||
if !found {
|
||
rarity = 0
|
||
}
|
||
if rarity < 0 || rarity > 6 {
|
||
continue
|
||
}
|
||
key := dungeonDropGradeRarityKey{Grade: grade, Rarity: rarity}
|
||
c.genericStackables[key] = append(c.genericStackables[key], itemID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// dungeonDropGenericPoolSkip 说明一件 .equ 为什么没进通用装备池。审计按它分类,
|
||
// 免得"池子里没有某稀有度"变成只能靠猜的事。
|
||
type dungeonDropGenericPoolSkip int
|
||
|
||
const (
|
||
dungeonDropGenericPoolAccepted dungeonDropGenericPoolSkip = iota
|
||
dungeonDropGenericPoolNotEquipment
|
||
dungeonDropGenericPoolUnreadable
|
||
dungeonDropGenericPoolRarityMissing
|
||
dungeonDropGenericPoolCreationRateAbsent
|
||
dungeonDropGenericPoolLevelOutOfRange
|
||
dungeonDropGenericPoolEquipmentTypeExcluded
|
||
)
|
||
|
||
// dungeonDropGenericEquipmentCandidate 是一次 .equ 扫描的结果。
|
||
type dungeonDropGenericEquipmentCandidate struct {
|
||
ItemID uint32
|
||
Key dungeonDropGradeRarityKey
|
||
Weight uint32
|
||
Skip dungeonDropGenericPoolSkip
|
||
}
|
||
|
||
// loadGenericEquipmentLocked 直接用 equipment/equipment.lst 的每个 .equ 构建通用装备
|
||
// 池:键是 PVF 自己的 ([rarity], [minimum level]),权重是 [creation rate]。这三项
|
||
// 都是掉落语义的权威值,深渊的史诗分支(dungeon_hell_party_drop.go)一直这么建池。
|
||
//
|
||
// 这里曾经读 Etc/ItemDictionary*.etc 的 (column1, column2)。实测 column2 与
|
||
// [minimum level] 100% 相等,键没错;但 column1 **不是** [rarity],而是同一稀有度
|
||
// 的**多套别名编码**:regional 表用 9 表示稀有、5/10 表示神器、7/8 表示史诗/传说,
|
||
// base 表用 3 表示稀有、6/7 表示神器/史诗。代码只接受 0..6,于是
|
||
// - 高等级稀有(别名 9)和神器(别名 10)一条候选都进不了池;
|
||
// - 请求 rarity 3 时命中的反而是 base 表别名 3 的**稀有**装备。
|
||
//
|
||
// 候选为空时 SelectGenericEquipment 会降级成 rarity 0,所以 Lv90 深渊/时空裂缝/
|
||
// 魔界裂缝变成"一局三件白装、一件紫装都没有"。而 [basis of rarity dicision] 里
|
||
// 白装的权重是 0 —— 白装本来不可能被摇出来,它出现本身就是降级的指纹。
|
||
func (c *pvfDungeonDropCatalog) loadGenericEquipmentLocked() error {
|
||
if c == nil || c.source == nil {
|
||
return errDungeonDropSourceRequired
|
||
}
|
||
ids := c.equipmentIDs
|
||
audit := dungeonDropGenericPoolAudit{Scanned: len(ids)}
|
||
results := make([]dungeonDropGenericEquipmentCandidate, len(ids))
|
||
workers := runtime.GOMAXPROCS(0)
|
||
if workers > dungeonDropEquipmentPoolMaxWorkers {
|
||
workers = dungeonDropEquipmentPoolMaxWorkers
|
||
}
|
||
if workers > len(ids) {
|
||
workers = len(ids)
|
||
}
|
||
if workers > 0 {
|
||
jobs := make(chan int)
|
||
var wait sync.WaitGroup
|
||
for worker := 0; worker < workers; worker++ {
|
||
wait.Add(1)
|
||
go func() {
|
||
defer wait.Done()
|
||
for index := range jobs {
|
||
results[index] = c.scanGenericEquipmentCandidate(ids[index])
|
||
}
|
||
}()
|
||
}
|
||
for index := range ids {
|
||
jobs <- index
|
||
}
|
||
close(jobs)
|
||
wait.Wait()
|
||
}
|
||
// 合并必须按 equipment.lst 的原始顺序:SelectGenericEquipment 对候选做前缀和取
|
||
// 件,所以并发完成顺序一旦泄漏进切片顺序,"同一个 roll 抽到哪一件"就不再可复现。
|
||
for _, result := range results {
|
||
switch result.Skip {
|
||
case dungeonDropGenericPoolAccepted:
|
||
c.genericEquipment[result.Key] = append(c.genericEquipment[result.Key], dungeonDropWeightedItem{
|
||
ItemID: result.ItemID,
|
||
Weight: result.Weight,
|
||
})
|
||
case dungeonDropGenericPoolNotEquipment:
|
||
audit.EquipmentMissing++
|
||
case dungeonDropGenericPoolUnreadable:
|
||
audit.ReadFailed++
|
||
case dungeonDropGenericPoolRarityMissing:
|
||
audit.RarityMissing++
|
||
case dungeonDropGenericPoolCreationRateAbsent:
|
||
audit.CreationRateAbsent++
|
||
case dungeonDropGenericPoolLevelOutOfRange:
|
||
audit.LevelOutOfRange++
|
||
case dungeonDropGenericPoolEquipmentTypeExcluded:
|
||
audit.ExcludedEquipmentType++
|
||
}
|
||
}
|
||
audit.Buckets = len(c.genericEquipment)
|
||
for key, candidates := range c.genericEquipment {
|
||
if key.Rarity < 0 || key.Rarity >= int64(len(audit.ByRarity)) {
|
||
continue
|
||
}
|
||
audit.Items += len(candidates)
|
||
audit.ByRarity[key.Rarity] += len(candidates)
|
||
if audit.LowestLevel[key.Rarity] == 0 || key.Grade < int64(audit.LowestLevel[key.Rarity]) {
|
||
audit.LowestLevel[key.Rarity] = int(key.Grade)
|
||
}
|
||
if key.Grade > int64(audit.HighestLevel[key.Rarity]) {
|
||
audit.HighestLevel[key.Rarity] = int(key.Grade)
|
||
}
|
||
}
|
||
c.genericAudit = audit
|
||
return nil
|
||
}
|
||
|
||
// scanGenericEquipmentCandidate 读一件 .equ 并判定它是否属于通用掉落池。
|
||
// 不读 itemCache、也不碰 c.mu:调用方持有 c.mu,走 ResolveItem 会自锁。
|
||
func (c *pvfDungeonDropCatalog) scanGenericEquipmentCandidate(itemID uint32) dungeonDropGenericEquipmentCandidate {
|
||
reference, found := c.itemRefs[itemID]
|
||
if !found || reference.kind != dungeonDropItemEquipment {
|
||
return dungeonDropGenericEquipmentCandidate{ItemID: itemID, Skip: dungeonDropGenericPoolNotEquipment}
|
||
}
|
||
docPath, text, err := readDungeonDropText(c.source, string(dungeonDropItemEquipment), reference.path)
|
||
if err != nil {
|
||
return dungeonDropGenericEquipmentCandidate{ItemID: itemID, Skip: dungeonDropGenericPoolUnreadable}
|
||
}
|
||
document, err := dnfpvf.Parse(docPath, text)
|
||
if err != nil {
|
||
return dungeonDropGenericEquipmentCandidate{ItemID: itemID, Skip: dungeonDropGenericPoolUnreadable}
|
||
}
|
||
rarity, found := document.Int("rarity")
|
||
if !found || rarity < 0 || rarity > 6 {
|
||
return dungeonDropGenericEquipmentCandidate{ItemID: itemID, Skip: dungeonDropGenericPoolRarityMissing}
|
||
}
|
||
creationRate, found := document.Int("creation rate")
|
||
if !found || creationRate <= 0 || creationRate > math.MaxUint32 {
|
||
return dungeonDropGenericEquipmentCandidate{ItemID: itemID, Skip: dungeonDropGenericPoolCreationRateAbsent}
|
||
}
|
||
level, found := document.Int("minimum level")
|
||
if !found || level <= 0 || level > 200 {
|
||
return dungeonDropGenericEquipmentCandidate{ItemID: itemID, Skip: dungeonDropGenericPoolLevelOutOfRange}
|
||
}
|
||
if equipmentType, found := document.Text("equipment type"); found && dungeonDropEquipmentPoolExcludedType(equipmentType) {
|
||
return dungeonDropGenericEquipmentCandidate{ItemID: itemID, Skip: dungeonDropGenericPoolEquipmentTypeExcluded}
|
||
}
|
||
return dungeonDropGenericEquipmentCandidate{
|
||
ItemID: itemID,
|
||
Key: dungeonDropGradeRarityKey{Grade: level, Rarity: rarity},
|
||
Weight: uint32(creationRate),
|
||
}
|
||
}
|
||
|
||
// dungeonDropEquipmentPoolExcludedType 挡掉不属于副本怪物掉落的装备类型。参考实现
|
||
// 把时装单独分池、不让普通掉落池碰到它们;这里沿用同一判断,并顺带挡住宠物、
|
||
// 称号、光环、勋章/守护珠这类由各自系统发放的类型。
|
||
func dungeonDropEquipmentPoolExcludedType(equipmentType string) bool {
|
||
normalized := strings.ToLower(strings.TrimSpace(equipmentType))
|
||
if normalized == "" {
|
||
return false
|
||
}
|
||
for _, excluded := range []string{"avatar", "[creature]", "[title name]", "[aura", "[flag"} {
|
||
if strings.Contains(normalized, excluded) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// recordGenericEquipmentFallback 记一次"请求的稀有度没有候选、降级成 rarity 0"。
|
||
func (c *pvfDungeonDropCatalog) recordGenericEquipmentFallback(monsterLevel int, rarity int64) {
|
||
if c == nil {
|
||
return
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if c.genericFallbacks == nil {
|
||
c.genericFallbacks = make(map[dungeonDropGradeRarityKey]uint64)
|
||
}
|
||
c.genericFallbacks[dungeonDropGradeRarityKey{Grade: int64(monsterLevel), Rarity: rarity}]++
|
||
}
|
||
|
||
// GenericFallbackMarks 返回"到目前为止发生过的降级"快照。调用方取 before/after 差分
|
||
// 就能在日志里点名是哪个等级、哪个稀有度的池子是空的。
|
||
func (c *pvfDungeonDropCatalog) GenericFallbackMarks() map[dungeonDropGradeRarityKey]uint64 {
|
||
if c == nil {
|
||
return nil
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
marks := make(map[dungeonDropGradeRarityKey]uint64, len(c.genericFallbacks))
|
||
for key, count := range c.genericFallbacks {
|
||
marks[key] = count
|
||
}
|
||
return marks
|
||
}
|
||
|
||
// GenericPoolAudit 返回建池时的构成快照。
|
||
func (c *pvfDungeonDropCatalog) GenericPoolAudit() dungeonDropGenericPoolAudit {
|
||
if c == nil {
|
||
return dungeonDropGenericPoolAudit{}
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
audit := c.genericAudit
|
||
audit.Fallbacks = 0
|
||
for _, count := range c.genericFallbacks {
|
||
audit.Fallbacks += count
|
||
}
|
||
return audit
|
||
}
|
||
|
||
// WarmGenericPools 在建池之外什么都不做,供服务的 PVF 预载阶段调用:把一次约
|
||
// 秒级的全量 .equ 扫描放在监听端口打开之前,而不是放在第一只怪死亡的时候。
|
||
func (c *pvfDungeonDropCatalog) WarmGenericPools() error {
|
||
return c.ensureGenericPools()
|
||
}
|
||
|
||
// preloadDungeonDropCatalog 在监听端口打开前把通用掉落池建好,并把池子构成写进
|
||
// 日志。"某个稀有度在某个等级段一件候选都没有"必须一眼可见——这次的洞就是靠静默
|
||
// 降级藏住的。
|
||
func (s *Service) preloadDungeonDropCatalog(ctx context.Context) error {
|
||
if s == nil {
|
||
return nil
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
started := time.Now()
|
||
catalog, err := s.dungeonMonsterCatalog()
|
||
if err != nil {
|
||
return fmt.Errorf("preload dungeon drop catalog: %w", err)
|
||
}
|
||
drops, err := catalog.DropCatalog()
|
||
if err != nil {
|
||
return fmt.Errorf("preload dungeon drop catalog: %w", err)
|
||
}
|
||
if err := drops.WarmGenericPools(); err != nil {
|
||
return fmt.Errorf("preload dungeon drop catalog: %w", err)
|
||
}
|
||
audit := drops.GenericPoolAudit()
|
||
s.logPacketEvent("dnf-dungeon-drop-equipment-pool-loaded",
|
||
"scanned", audit.Scanned,
|
||
"buckets", audit.Buckets,
|
||
"items", audit.Items,
|
||
"by_rarity", audit.ByRarity,
|
||
"lowest_level", audit.LowestLevel,
|
||
"highest_level", audit.HighestLevel,
|
||
"rarity_missing", audit.RarityMissing,
|
||
"creation_rate_absent", audit.CreationRateAbsent,
|
||
"level_out_of_range", audit.LevelOutOfRange,
|
||
"excluded_equipment_type", audit.ExcludedEquipmentType,
|
||
"equipment_missing", audit.EquipmentMissing,
|
||
"read_failed", audit.ReadFailed,
|
||
"elapsed_ms", time.Since(started).Milliseconds())
|
||
return nil
|
||
}
|
||
|
||
// reportCurrentDungeonGenericDropFallbacks 在掉落规划结束后对比降级标记,把**新出现**
|
||
// 的空池点名写进日志。降级本身保留(参考实现也 allowFallback),但不再无声。
|
||
func (s *Service) reportCurrentDungeonGenericDropFallbacks(
|
||
catalog *pvfDungeonDropCatalog,
|
||
before map[dungeonDropGradeRarityKey]uint64,
|
||
event string,
|
||
fields ...any,
|
||
) {
|
||
if s == nil || catalog == nil {
|
||
return
|
||
}
|
||
after := catalog.GenericFallbackMarks()
|
||
report := make([]any, 0, len(fields)+8)
|
||
reported := false
|
||
for key, count := range after {
|
||
if before[key] == count {
|
||
continue
|
||
}
|
||
if !reported {
|
||
report = append(report, fields...)
|
||
reported = true
|
||
}
|
||
report = append(report,
|
||
"empty_pool_level", key.Grade,
|
||
"empty_pool_rarity", key.Rarity,
|
||
"fallback_rolls", count)
|
||
}
|
||
if !reported {
|
||
return
|
||
}
|
||
s.logGameEvent(nil, event, report...)
|
||
}
|
||
|
||
func dungeonDropExcludedStackablePath(value string) bool {
|
||
value = strings.ToLower(cleanDungeonDropPath(value))
|
||
for _, prefix := range []string{
|
||
"cash/", "quest/", "recipe/", "temp/", "event/", "emblem/", "monstercard/",
|
||
} {
|
||
if strings.HasPrefix(value, prefix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 徽章页([avatar emblem])的格子:289..351,**9 行 × 7 格 = 63**。
|
||
//
|
||
// 这里过去写的是 289..344(8 行 56 格),少了最后一行 345..351。服主现场报的正是这一条:
|
||
// 「徽章栏最后一行不给用,一行 7 格、总共 9 行」。取证(2026-10-03):
|
||
// - 该 PVF 里 [avatar emblem] 的 stk 文档本身**不声明页范围**,页几何只在服务端常量里,
|
||
// 所以这个数字就是唯一权威,写错多少就少放多少;
|
||
// - 345..353 这一段全库从未被任何系统写入过一行(逐角色普查),也不属于账号共享固定仓
|
||
// (晶体 354..359 / 灵魂 360..365 从 354 起),即整段是空的、可用的;
|
||
// - 后果不是"少几格"这么轻:char=1 的 56 格被 56 种徽章占满后,新徽章只能进溢出邮件,
|
||
// 而邮件领取又只能往同一页放 → 邮件永久卡住(mail 110 的 灿烂的绿色徽章[物理暴击]
|
||
// 就是被这条卡死的)。
|
||
//
|
||
// 与 repository 侧的口径一致:345..353 那一段在 auction.allocateSlot 的候选范围里本就
|
||
// 被当作普通主背包格。
|
||
const (
|
||
currentAvatarEmblemPageSlotStart int16 = 289
|
||
currentAvatarEmblemPageSlotEnd int16 = 351
|
||
)
|
||
|
||
// dungeonDropStackableSlotRange follows the repository's established main
|
||
// inventory category ranges and uses the real PVF [stackable type] value.
|
||
// It is a domain placement rule, not a claim about the current pickup wire.
|
||
func dungeonDropStackableSlotRange(stackableType string) (int16, int16) {
|
||
normalized := normalizeDungeonDropStackableType(stackableType)
|
||
switch {
|
||
case strings.HasPrefix(normalized, "[material]") && strings.HasSuffix(normalized, "4"):
|
||
return 345, 359
|
||
case strings.HasPrefix(normalized, "[material]"):
|
||
return 121, 176
|
||
case strings.HasPrefix(normalized, "[quest]"):
|
||
return 177, 232
|
||
case strings.HasPrefix(normalized, "[material expert job]"):
|
||
return 233, 288
|
||
case strings.HasPrefix(normalized, "[avatar emblem]"):
|
||
return currentAvatarEmblemPageSlotStart, currentAvatarEmblemPageSlotEnd
|
||
default:
|
||
return 65, 120
|
||
}
|
||
}
|
||
|
||
func dungeonDropStackablePrefersItemQuickSlots(stackableType string) bool {
|
||
normalized := normalizeDungeonDropStackableType(stackableType)
|
||
return strings.HasPrefix(normalized, "[waste]")
|
||
}
|
||
|
||
func normalizeDungeonDropStackableType(stackableType string) string {
|
||
return strings.ToLower(strings.TrimSpace(strings.ReplaceAll(stackableType, "`", "")))
|
||
}
|
||
|
||
// DropCatalog lazily derives the item/drop catalog from the same authoritative
|
||
// PVF source as the monster catalog. Synthetic tests that do not provide item
|
||
// lists therefore keep the existing zero-drop death path until a drop is
|
||
// actually requested.
|
||
func (c *pvfDungeonMonsterCatalog) DropCatalog() (*pvfDungeonDropCatalog, error) {
|
||
if c == nil || c.source == nil {
|
||
return nil, errDungeonDropSourceRequired
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if !c.dropCatalogLoaded {
|
||
c.dropCatalog, c.dropCatalogErr = newPVFDungeonDropCatalog(c.source)
|
||
c.dropCatalogLoaded = true
|
||
}
|
||
return c.dropCatalog, c.dropCatalogErr
|
||
}
|
||
|
||
func parseDungeonMonsterDropPool(document *dnfpvf.Document) []dungeonMonsterDropPoolEntry {
|
||
tokens, ok := document.Section("item")
|
||
if !ok || len(tokens) == 0 {
|
||
return nil
|
||
}
|
||
values := make([]int64, 0, len(tokens))
|
||
for _, token := range tokens {
|
||
if token.Kind == dnfpvf.TokenInt {
|
||
values = append(values, token.Int)
|
||
}
|
||
}
|
||
out := make([]dungeonMonsterDropPoolEntry, 0, len(values)/2)
|
||
for index := 0; index+1 < len(values); index += 2 {
|
||
itemID, weight := values[index], values[index+1]
|
||
if itemID <= 0 || itemID > math.MaxUint32 || weight <= 0 || weight > math.MaxUint32 {
|
||
continue
|
||
}
|
||
out = append(out, dungeonMonsterDropPoolEntry{ItemID: uint32(itemID), Weight: uint32(weight)})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func selectDungeonMonsterDrop(pool []dungeonMonsterDropPoolEntry, roll uint64) (dungeonMonsterDropPoolEntry, bool) {
|
||
var total uint64
|
||
for _, entry := range pool {
|
||
if entry.ItemID == 0 || entry.Weight == 0 || math.MaxUint64-total < uint64(entry.Weight) {
|
||
continue
|
||
}
|
||
total += uint64(entry.Weight)
|
||
}
|
||
if total == 0 {
|
||
return dungeonMonsterDropPoolEntry{}, false
|
||
}
|
||
want := roll % total
|
||
var cumulative uint64
|
||
for _, entry := range pool {
|
||
if entry.ItemID == 0 || entry.Weight == 0 || math.MaxUint64-cumulative < uint64(entry.Weight) {
|
||
continue
|
||
}
|
||
cumulative += uint64(entry.Weight)
|
||
if want < cumulative {
|
||
return entry, true
|
||
}
|
||
}
|
||
return dungeonMonsterDropPoolEntry{}, false
|
||
}
|
||
|
||
func readDungeonDropText(source dnfpvf.Source, root, listedPath string) (string, string, error) {
|
||
clean := cleanDungeonDropPath(listedPath)
|
||
candidates := []string{clean}
|
||
root = cleanDungeonDropPath(root)
|
||
if root != "" && !strings.HasPrefix(strings.ToLower(clean), strings.ToLower(root)+"/") {
|
||
candidates = append(candidates, path.Join(root, clean))
|
||
}
|
||
// The 90 PVF marks revised files with a "(r)" basename prefix while list
|
||
// indexes keep naming the original file; fall back to the revised name
|
||
// only when the original is absent.
|
||
for _, candidate := range append([]string(nil), candidates...) {
|
||
dir, base := path.Split(candidate)
|
||
if base == "" || strings.HasPrefix(strings.ToLower(base), "(r)") {
|
||
continue
|
||
}
|
||
candidates = append(candidates, dir+"(r)"+base)
|
||
}
|
||
var lastErr error
|
||
for _, candidate := range candidates {
|
||
text, err := source.ReadText(candidate)
|
||
if err == nil {
|
||
return candidate, text, nil
|
||
}
|
||
lastErr = err
|
||
}
|
||
return "", "", lastErr
|
||
}
|
||
|
||
func cleanDungeonDropPath(value string) string {
|
||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||
value = strings.TrimPrefix(value, "./")
|
||
return strings.TrimPrefix(value, "/")
|
||
}
|
||
|
||
func cloneDungeonMonsterDropPool(values []dungeonMonsterDropPoolEntry) []dungeonMonsterDropPoolEntry {
|
||
return append([]dungeonMonsterDropPoolEntry(nil), values...)
|
||
}
|