Files
90CNServer/server/internal/services/dnfbridge/dungeon_drop_catalog.go
T
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

1438 lines
54 KiB
Go
Raw 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"
"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...)
}