DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,453 @@
|
||||
package drop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const ClearRewardRulePath = "Etc/ItemDropInfo_ClearReward.etc"
|
||||
|
||||
const (
|
||||
// ClearRewardItemRateDenominator is the probability scale used by the
|
||||
// ordinary dungeon-clear equipment roll in the current PVF.
|
||||
ClearRewardItemRateDenominator = uint32(10_000)
|
||||
clearRewardBonusDenominator = float64(1_000)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClearRewardRuleSourceRequired = errors.New("dnf clear-reward rule PVF source is required")
|
||||
ErrClearRewardRuleSectionMissing = errors.New("dnf clear-reward rule PVF section is missing")
|
||||
ErrClearRewardRuleSectionShape = errors.New("dnf clear-reward rule PVF section shape is invalid")
|
||||
ErrClearRewardRuleLookupMissing = errors.New("dnf clear-reward rule lookup is missing")
|
||||
)
|
||||
|
||||
// ClearRewardItemReference preserves one exact three-column
|
||||
// [item drop ref table] row. The two values remain opaque until the current
|
||||
// client confirms their item-level interpretation.
|
||||
type ClearRewardItemReference struct {
|
||||
LookupKey int64
|
||||
ValueA int64
|
||||
ValueB int64
|
||||
}
|
||||
|
||||
// ClearRewardRuleSnapshot reports only loaded PVF source coverage.
|
||||
type ClearRewardRuleSnapshot struct {
|
||||
MapRateRows int
|
||||
DifficultyBonusRows int
|
||||
RarityThresholdRows int
|
||||
PartyBonusRows int
|
||||
GoldDifficultyRows int
|
||||
GoldCardCostRows int
|
||||
ItemReferenceRows int
|
||||
}
|
||||
|
||||
// ClearRewardRawTables is a value-copy of fixed-size clear-reward source
|
||||
// tables.
|
||||
type ClearRewardRawTables struct {
|
||||
ItemRateByMapCount map[int64]int64
|
||||
DifficultyDropBonus [5]int64
|
||||
RarityDecision [7]int64
|
||||
PartyMemberDropBonus [4]float64
|
||||
GoldDifficultyDropBonus [5]float64
|
||||
GoldCardCostByLevel map[int64]int64
|
||||
}
|
||||
|
||||
// ClearRewardSourcePlan is a pure lookup. It neither rolls a card reward nor
|
||||
// charges gold; callers can use it to correlate a real-client packet trace
|
||||
// with the exact rows from the runtime PVF.
|
||||
type ClearRewardSourcePlan struct {
|
||||
Level int64
|
||||
DifficultyIndex int
|
||||
PartyMembers int
|
||||
VisitedRoomCount int
|
||||
ItemRate int64
|
||||
DifficultyBonus int64
|
||||
PartyBonus float64
|
||||
GoldDifficultyRate float64
|
||||
GoldCardCost int64
|
||||
ItemReference ClearRewardItemReference
|
||||
ItemRateFound bool
|
||||
DifficultyFound bool
|
||||
PartyBonusFound bool
|
||||
GoldDifficultyFound bool
|
||||
GoldCardCostFound bool
|
||||
ItemReferenceFound bool
|
||||
}
|
||||
|
||||
// ClearRewardRule is the resolved ordinary end-of-dungeon card rule. The
|
||||
// equipment rate and rarity thresholds are read from the active PVF; it does
|
||||
// not select or grant an item.
|
||||
type ClearRewardRule struct {
|
||||
EquipmentRate uint32
|
||||
GradeDown int64
|
||||
GradeUp int64
|
||||
RarityThresholds [7]uint32
|
||||
RarityScale uint32
|
||||
PaidCardCost uint32
|
||||
GoldDifficultyRate float64
|
||||
MapCount int
|
||||
}
|
||||
|
||||
// ClearRewardRules is a read-only typed view of
|
||||
// Etc/ItemDropInfo_ClearReward.etc. The data can be safely inspected by
|
||||
// card-reward and diagnostics code without owning card selection or rewards.
|
||||
type ClearRewardRules struct {
|
||||
itemRateByMapCount map[int64]int64
|
||||
difficultyDropBonus [5]int64
|
||||
rarityDecision [7]int64
|
||||
partyMemberDropBonus [4]float64
|
||||
goldDifficultyDropBonus [5]float64
|
||||
goldCardCostByLevel map[int64]int64
|
||||
itemReferences map[int64]ClearRewardItemReference
|
||||
}
|
||||
|
||||
func LoadClearRewardRules(source dnfpvf.Source) (*ClearRewardRules, error) {
|
||||
if source == nil {
|
||||
return nil, ErrClearRewardRuleSourceRequired
|
||||
}
|
||||
text, err := source.ReadText(ClearRewardRulePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", ClearRewardRulePath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(ClearRewardRulePath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", ClearRewardRulePath, err)
|
||||
}
|
||||
|
||||
rules := &ClearRewardRules{
|
||||
itemRateByMapCount: make(map[int64]int64, 5),
|
||||
goldCardCostByLevel: make(map[int64]int64),
|
||||
itemReferences: make(map[int64]ClearRewardItemReference),
|
||||
}
|
||||
if err := loadClearRewardMapRates(document, rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fillClearRewardIntArray(document, "dungeon difficulty drop bonusrate", rules.difficultyDropBonus[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index, value := range rules.difficultyDropBonus {
|
||||
if value < 0 {
|
||||
return nil, fmt.Errorf("%w: section=dungeon difficulty drop bonusrate row=%d value=%d", ErrClearRewardRuleSectionShape, index, value)
|
||||
}
|
||||
}
|
||||
if err := fillClearRewardIntArray(document, "basis of rarity dicision", rules.rarityDecision[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index, value := range rules.rarityDecision {
|
||||
if value < 0 || index > 0 && value < rules.rarityDecision[index-1] {
|
||||
return nil, fmt.Errorf("%w: section=basis of rarity dicision row=%d value=%d", ErrClearRewardRuleSectionShape, index, value)
|
||||
}
|
||||
}
|
||||
if rules.rarityDecision[len(rules.rarityDecision)-1] == 0 {
|
||||
return nil, fmt.Errorf("%w: section=basis of rarity dicision final_threshold=0", ErrClearRewardRuleSectionShape)
|
||||
}
|
||||
if err := fillClearRewardNumberArray(document, "party member drop bonusrate", rules.partyMemberDropBonus[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fillClearRewardNumberArray(document, "dungeon difficulty gold drop bonusrate", rules.goldDifficultyDropBonus[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadClearRewardGoldCardCosts(document, rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadClearRewardItemReferences(document, rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func loadClearRewardMapRates(document *dnfpvf.Document, rules *ClearRewardRules) error {
|
||||
values, err := clearRewardIntegers(document, "reward item rate per map max count")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) != 10 {
|
||||
return fmt.Errorf("%w: section=reward item rate per map max count values=%d row_width=2", ErrClearRewardRuleSectionShape, len(values))
|
||||
}
|
||||
for offset := 0; offset < len(values); offset += 2 {
|
||||
mapCount, rate := values[offset], values[offset+1]
|
||||
wantMapCount := int64(offset/2 + 1)
|
||||
if mapCount != wantMapCount || rate < 0 {
|
||||
return fmt.Errorf("%w: section=reward item rate per map max count row=%d count=%d rate=%d", ErrClearRewardRuleSectionShape, offset/2, mapCount, rate)
|
||||
}
|
||||
rules.itemRateByMapCount[mapCount] = rate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadClearRewardGoldCardCosts(document *dnfpvf.Document, rules *ClearRewardRules) error {
|
||||
values, err := clearRewardIntegers(document, "gold card cost table")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) == 0 || len(values)%2 != 0 {
|
||||
return fmt.Errorf("%w: section=gold card cost table values=%d row_width=2", ErrClearRewardRuleSectionShape, len(values))
|
||||
}
|
||||
var previous int64
|
||||
for offset := 0; offset < len(values); offset += 2 {
|
||||
level, cost := values[offset], values[offset+1]
|
||||
if level <= 0 || cost <= 0 || offset != 0 && level <= previous {
|
||||
return fmt.Errorf("%w: section=gold card cost table row=%d level=%d cost=%d", ErrClearRewardRuleSectionShape, offset/2, level, cost)
|
||||
}
|
||||
rules.goldCardCostByLevel[level] = cost
|
||||
previous = level
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadClearRewardItemReferences(document *dnfpvf.Document, rules *ClearRewardRules) error {
|
||||
values, err := clearRewardIntegers(document, "item drop ref table")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) == 0 || len(values)%3 != 0 {
|
||||
return fmt.Errorf("%w: section=item drop ref table values=%d row_width=3", ErrClearRewardRuleSectionShape, len(values))
|
||||
}
|
||||
var previous int64
|
||||
for offset := 0; offset < len(values); offset += 3 {
|
||||
row := ClearRewardItemReference{LookupKey: values[offset], ValueA: values[offset+1], ValueB: values[offset+2]}
|
||||
if row.LookupKey <= 0 || row.ValueA < 0 || row.ValueB < 0 || offset != 0 && row.LookupKey <= previous {
|
||||
return fmt.Errorf("%w: section=item drop ref table row=%d value=%+v", ErrClearRewardRuleSectionShape, offset/3, row)
|
||||
}
|
||||
rules.itemReferences[row.LookupKey] = row
|
||||
previous = row.LookupKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearRewardIntegers(document *dnfpvf.Document, section string) ([]int64, error) {
|
||||
tokens, found := document.Section(section)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: section=%s", ErrClearRewardRuleSectionMissing, section)
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return nil, fmt.Errorf("%w: section=%s empty", ErrClearRewardRuleSectionShape, section)
|
||||
}
|
||||
values := make([]int64, 0, len(tokens))
|
||||
for index, token := range tokens {
|
||||
if token.Kind != dnfpvf.TokenInt {
|
||||
return nil, fmt.Errorf("%w: section=%s token=%d kind=%s", ErrClearRewardRuleSectionShape, section, index, token.Kind)
|
||||
}
|
||||
values = append(values, token.Int)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func clearRewardNumbers(document *dnfpvf.Document, section string) ([]float64, error) {
|
||||
tokens, found := document.Section(section)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: section=%s", ErrClearRewardRuleSectionMissing, section)
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return nil, fmt.Errorf("%w: section=%s empty", ErrClearRewardRuleSectionShape, section)
|
||||
}
|
||||
values := make([]float64, 0, len(tokens))
|
||||
for index, token := range tokens {
|
||||
var value float64
|
||||
switch token.Kind {
|
||||
case dnfpvf.TokenInt:
|
||||
value = float64(token.Int)
|
||||
case dnfpvf.TokenFloat:
|
||||
value = token.Float
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: section=%s token=%d kind=%s", ErrClearRewardRuleSectionShape, section, index, token.Kind)
|
||||
}
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
|
||||
return nil, fmt.Errorf("%w: section=%s token=%d value=%v", ErrClearRewardRuleSectionShape, section, index, value)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func fillClearRewardIntArray(document *dnfpvf.Document, section string, destination []int64) error {
|
||||
values, err := clearRewardIntegers(document, section)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) != len(destination) {
|
||||
return fmt.Errorf("%w: section=%s values=%d want=%d", ErrClearRewardRuleSectionShape, section, len(values), len(destination))
|
||||
}
|
||||
copy(destination, values)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fillClearRewardNumberArray(document *dnfpvf.Document, section string, destination []float64) error {
|
||||
values, err := clearRewardNumbers(document, section)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) != len(destination) {
|
||||
return fmt.Errorf("%w: section=%s values=%d want=%d", ErrClearRewardRuleSectionShape, section, len(values), len(destination))
|
||||
}
|
||||
copy(destination, values)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rules *ClearRewardRules) ItemReference(level int64) (ClearRewardItemReference, bool) {
|
||||
if rules == nil {
|
||||
return ClearRewardItemReference{}, false
|
||||
}
|
||||
reference, found := rules.itemReferences[level]
|
||||
return reference, found
|
||||
}
|
||||
|
||||
// PartyMemberRate is the PVF [party member drop bonusrate] multiplier for a
|
||||
// party size, clamped to the four rows the table defines. The clear-card gold
|
||||
// formula applies it alongside the difficulty rate; a size outside the table
|
||||
// contributes no bonus rather than zeroing the reward.
|
||||
func (rules *ClearRewardRules) PartyMemberRate(partyMembers int) float64 {
|
||||
if rules == nil {
|
||||
return 1
|
||||
}
|
||||
if partyMembers < 1 {
|
||||
partyMembers = 1
|
||||
} else if partyMembers > len(rules.partyMemberDropBonus) {
|
||||
partyMembers = len(rules.partyMemberDropBonus)
|
||||
}
|
||||
rate := rules.partyMemberDropBonus[partyMembers-1]
|
||||
if rate <= 0 {
|
||||
return 1
|
||||
}
|
||||
return rate
|
||||
}
|
||||
|
||||
func (rules *ClearRewardRules) PlanSource(level int64, difficultyIndex, partyMembers, visitedRoomCount int) ClearRewardSourcePlan {
|
||||
plan := ClearRewardSourcePlan{
|
||||
Level: level,
|
||||
DifficultyIndex: difficultyIndex,
|
||||
PartyMembers: partyMembers,
|
||||
VisitedRoomCount: visitedRoomCount,
|
||||
}
|
||||
if rules == nil {
|
||||
return plan
|
||||
}
|
||||
plan.ItemRate, plan.ItemRateFound = rules.itemRateByMapCount[int64(visitedRoomCount)]
|
||||
if difficultyIndex >= 0 && difficultyIndex < len(rules.difficultyDropBonus) {
|
||||
plan.DifficultyBonus = rules.difficultyDropBonus[difficultyIndex]
|
||||
plan.DifficultyFound = true
|
||||
plan.GoldDifficultyRate = rules.goldDifficultyDropBonus[difficultyIndex]
|
||||
plan.GoldDifficultyFound = true
|
||||
}
|
||||
if partyMembers >= 1 && partyMembers <= len(rules.partyMemberDropBonus) {
|
||||
plan.PartyBonus = rules.partyMemberDropBonus[partyMembers-1]
|
||||
plan.PartyBonusFound = true
|
||||
}
|
||||
plan.GoldCardCost, plan.GoldCardCostFound = rules.goldCardCostByLevel[level]
|
||||
plan.ItemReference, plan.ItemReferenceFound = rules.ItemReference(level)
|
||||
return plan
|
||||
}
|
||||
|
||||
// Resolve applies the formula used by the current server/client clear-card
|
||||
// flow: room rate multiplied by the additive difficulty bonus (1/1000 scale)
|
||||
// and the party-size multiplier, capped to the PVF's 1/10000 probability
|
||||
// denominator. Normal difficulty has a zero additive bonus, not a zero
|
||||
// multiplier.
|
||||
func (rules *ClearRewardRules) Resolve(level int64, difficultyIndex, partyMembers, visitedRoomCount int) (ClearRewardRule, error) {
|
||||
if rules == nil || level <= 0 {
|
||||
return ClearRewardRule{}, ErrClearRewardRuleSourceRequired
|
||||
}
|
||||
if difficultyIndex < 0 || difficultyIndex >= len(rules.difficultyDropBonus) {
|
||||
difficultyIndex = 0
|
||||
}
|
||||
if partyMembers < 1 {
|
||||
partyMembers = 1
|
||||
} else if partyMembers > len(rules.partyMemberDropBonus) {
|
||||
partyMembers = len(rules.partyMemberDropBonus)
|
||||
}
|
||||
if visitedRoomCount < 1 {
|
||||
visitedRoomCount = 1
|
||||
} else if visitedRoomCount > len(rules.itemRateByMapCount) {
|
||||
visitedRoomCount = len(rules.itemRateByMapCount)
|
||||
}
|
||||
plan := rules.PlanSource(level, difficultyIndex, partyMembers, visitedRoomCount)
|
||||
if !plan.ItemRateFound || !plan.DifficultyFound || !plan.PartyBonusFound ||
|
||||
!plan.GoldDifficultyFound || !plan.GoldCardCostFound || !plan.ItemReferenceFound {
|
||||
return ClearRewardRule{}, fmt.Errorf(
|
||||
"%w: level=%d difficulty=%d party=%d rooms=%d coverage=%+v",
|
||||
ErrClearRewardRuleLookupMissing, level, difficultyIndex, partyMembers, visitedRoomCount, plan,
|
||||
)
|
||||
}
|
||||
if plan.ItemRate < 0 || plan.DifficultyBonus < 0 || plan.PartyBonus <= 0 ||
|
||||
math.IsNaN(plan.PartyBonus) || math.IsInf(plan.PartyBonus, 0) ||
|
||||
plan.GoldCardCost <= 0 || plan.GoldCardCost > math.MaxUint32 ||
|
||||
plan.ItemReference.ValueA < 0 || plan.ItemReference.ValueB < 0 {
|
||||
return ClearRewardRule{}, fmt.Errorf("%w: resolved source=%+v", ErrClearRewardRuleSectionShape, plan)
|
||||
}
|
||||
raw := rules.RawTables()
|
||||
var rarityThresholds [7]uint32
|
||||
for index, threshold := range raw.RarityDecision {
|
||||
if threshold < 0 || threshold > math.MaxUint32 {
|
||||
return ClearRewardRule{}, fmt.Errorf(
|
||||
"%w: section=basis of rarity dicision row=%d value=%d",
|
||||
ErrClearRewardRuleSectionShape, index, threshold,
|
||||
)
|
||||
}
|
||||
rarityThresholds[index] = uint32(threshold)
|
||||
}
|
||||
rarityScale := rarityThresholds[len(rarityThresholds)-1]
|
||||
if rarityScale == 0 {
|
||||
return ClearRewardRule{}, fmt.Errorf("%w: section=basis of rarity dicision final_threshold=0", ErrClearRewardRuleSectionShape)
|
||||
}
|
||||
rate := math.Floor(float64(plan.ItemRate) *
|
||||
(clearRewardBonusDenominator + float64(plan.DifficultyBonus)) /
|
||||
clearRewardBonusDenominator * plan.PartyBonus)
|
||||
if rate < 0 {
|
||||
rate = 0
|
||||
} else if rate > float64(ClearRewardItemRateDenominator) {
|
||||
rate = float64(ClearRewardItemRateDenominator)
|
||||
}
|
||||
return ClearRewardRule{
|
||||
EquipmentRate: uint32(rate),
|
||||
GradeDown: plan.ItemReference.ValueA,
|
||||
GradeUp: plan.ItemReference.ValueB,
|
||||
RarityThresholds: rarityThresholds,
|
||||
RarityScale: rarityScale,
|
||||
PaidCardCost: uint32(plan.GoldCardCost),
|
||||
GoldDifficultyRate: plan.GoldDifficultyRate,
|
||||
MapCount: visitedRoomCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (rules *ClearRewardRules) Snapshot() ClearRewardRuleSnapshot {
|
||||
if rules == nil {
|
||||
return ClearRewardRuleSnapshot{}
|
||||
}
|
||||
return ClearRewardRuleSnapshot{
|
||||
MapRateRows: len(rules.itemRateByMapCount),
|
||||
DifficultyBonusRows: len(rules.difficultyDropBonus),
|
||||
RarityThresholdRows: len(rules.rarityDecision),
|
||||
PartyBonusRows: len(rules.partyMemberDropBonus),
|
||||
GoldDifficultyRows: len(rules.goldDifficultyDropBonus),
|
||||
GoldCardCostRows: len(rules.goldCardCostByLevel),
|
||||
ItemReferenceRows: len(rules.itemReferences),
|
||||
}
|
||||
}
|
||||
|
||||
func (rules *ClearRewardRules) RawTables() ClearRewardRawTables {
|
||||
if rules == nil {
|
||||
return ClearRewardRawTables{}
|
||||
}
|
||||
return ClearRewardRawTables{
|
||||
ItemRateByMapCount: cloneClearRewardIntMap(rules.itemRateByMapCount),
|
||||
DifficultyDropBonus: rules.difficultyDropBonus,
|
||||
RarityDecision: rules.rarityDecision,
|
||||
PartyMemberDropBonus: rules.partyMemberDropBonus,
|
||||
GoldDifficultyDropBonus: rules.goldDifficultyDropBonus,
|
||||
GoldCardCostByLevel: cloneClearRewardIntMap(rules.goldCardCostByLevel),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneClearRewardIntMap(source map[int64]int64) map[int64]int64 {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[int64]int64, len(source))
|
||||
for key, value := range source {
|
||||
clone[key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package drop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestLoadClearRewardRulesPreservesPVFRowsAndResolvesCurrentFormula(t *testing.T) {
|
||||
rules, err := LoadClearRewardRules(clearRewardRuleTestSource{ClearRewardRulePath: validClearRewardRuleText()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot := rules.Snapshot(); snapshot != (ClearRewardRuleSnapshot{
|
||||
MapRateRows: 5, DifficultyBonusRows: 5, RarityThresholdRows: 7,
|
||||
PartyBonusRows: 4, GoldDifficultyRows: 5, GoldCardCostRows: 2, ItemReferenceRows: 2,
|
||||
}) {
|
||||
t.Fatalf("snapshot=%+v", snapshot)
|
||||
}
|
||||
plan := rules.PlanSource(60, 1, 2, 5)
|
||||
if !plan.ItemRateFound || !plan.DifficultyFound || !plan.PartyBonusFound ||
|
||||
!plan.GoldDifficultyFound || !plan.GoldCardCostFound || !plan.ItemReferenceFound ||
|
||||
plan.ItemRate != 6000 || plan.DifficultyBonus != 600 || plan.PartyBonus != 1.2 ||
|
||||
plan.GoldDifficultyRate != 1.38 || plan.GoldCardCost != 7180 ||
|
||||
plan.ItemReference != (ClearRewardItemReference{LookupKey: 60, ValueA: 7, ValueB: 3}) {
|
||||
t.Fatalf("plan=%+v", plan)
|
||||
}
|
||||
missing := rules.PlanSource(59, 5, 5, 6)
|
||||
if missing.ItemRateFound || missing.DifficultyFound || missing.PartyBonusFound ||
|
||||
missing.GoldDifficultyFound || missing.GoldCardCostFound || missing.ItemReferenceFound {
|
||||
t.Fatalf("missing lookup fabricated rows: %+v", missing)
|
||||
}
|
||||
raw := rules.RawTables()
|
||||
if raw.ItemRateByMapCount[5] != 6000 || raw.DifficultyDropBonus != ([5]int64{0, 600, 1000, 1400, 1200}) ||
|
||||
raw.RarityDecision != ([7]int64{500000, 799900, 999900, 1000000, 1000001, 1000002, 1000003}) ||
|
||||
raw.PartyMemberDropBonus != ([4]float64{1, 1.2, 1.4, 1.6}) ||
|
||||
raw.GoldDifficultyDropBonus != ([5]float64{1.02, 1.38, 1.6, 1.9, 2}) || raw.GoldCardCostByLevel[60] != 7180 {
|
||||
t.Fatalf("raw=%+v", raw)
|
||||
}
|
||||
raw.ItemRateByMapCount[5] = 1
|
||||
raw.GoldCardCostByLevel[60] = 1
|
||||
again := rules.RawTables()
|
||||
if again.ItemRateByMapCount[5] != 6000 || again.GoldCardCostByLevel[60] != 7180 {
|
||||
t.Fatalf("rules leaked mutable tables: %+v", again)
|
||||
}
|
||||
resolved, err := rules.Resolve(60, 1, 2, 9)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.EquipmentRate != 10_000 || resolved.GradeDown != 7 || resolved.GradeUp != 3 ||
|
||||
resolved.RarityThresholds != ([7]uint32{500000, 799900, 999900, 1000000, 1000001, 1000002, 1000003}) ||
|
||||
resolved.RarityScale != 1000003 || resolved.PaidCardCost != 7180 ||
|
||||
resolved.GoldDifficultyRate != 1.38 || resolved.MapCount != 5 {
|
||||
t.Fatalf("resolved=%+v", resolved)
|
||||
}
|
||||
if _, err := rules.Resolve(59, 0, 1, 1); !errors.Is(err, ErrClearRewardRuleLookupMissing) {
|
||||
t.Fatalf("missing resolved lookup error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClearRewardRulesRejectsMalformedPVFShapes(t *testing.T) {
|
||||
if _, err := LoadClearRewardRules(nil); !errors.Is(err, ErrClearRewardRuleSourceRequired) {
|
||||
t.Fatalf("nil source error=%v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(string) string
|
||||
}{
|
||||
{
|
||||
name: "map counts must be complete and ordered",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "1 2000 2 3000", "1 2000 3 3000")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "difficulty rows must remain five",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "0 600 1000 1400 1200", "0 600 1000 1400")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rarity thresholds cannot decrease",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "500000 799900 999900", "500000 400000 999900")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "party bonus must remain positive",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "1.00 1.20 1.40 1.60", "1.00 0 1.40 1.60")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gold card level keys must increase",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "1 100 60 7180", "60 7180 1 100")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "item references require nonnegative values",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "60 7 3", "60 -1 3")
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := LoadClearRewardRules(clearRewardRuleTestSource{ClearRewardRulePath: test.mutate(validClearRewardRuleText())})
|
||||
if !errors.Is(err, ErrClearRewardRuleSectionShape) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealScriptPVFClearRewardRulesPreserveCurrentSourceTables(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
pvfPath = os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
|
||||
}
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to run real clear-reward PVF smoke")
|
||||
}
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules, err := LoadClearRewardRules(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := rules.Snapshot()
|
||||
if snapshot.MapRateRows != 5 || snapshot.DifficultyBonusRows != 5 || snapshot.RarityThresholdRows != 7 ||
|
||||
snapshot.PartyBonusRows != 4 || snapshot.GoldDifficultyRows != 5 || snapshot.GoldCardCostRows == 0 || snapshot.ItemReferenceRows == 0 {
|
||||
t.Fatalf("real snapshot=%+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func validClearRewardRuleText() string {
|
||||
return `[reward item rate per map max count]
|
||||
1 2000 2 3000 3 4000 4 5000 5 6000
|
||||
[dungeon difficulty drop bonusrate]
|
||||
0 600 1000 1400 1200
|
||||
[basis of rarity dicision]
|
||||
500000 799900 999900 1000000 1000001 1000002 1000003
|
||||
[party member drop bonusrate]
|
||||
1.00 1.20 1.40 1.60
|
||||
[dungeon difficulty gold drop bonusrate]
|
||||
1.02 1.38 1.60 1.90 2.00
|
||||
[gold card cost table]
|
||||
1 100 60 7180
|
||||
[item drop ref table]
|
||||
1 0 3 60 7 3
|
||||
`
|
||||
}
|
||||
|
||||
type clearRewardRuleTestSource map[string]string
|
||||
|
||||
func (source clearRewardRuleTestSource) ReadText(path string) (string, error) {
|
||||
if text, found := source[path]; found {
|
||||
return text, nil
|
||||
}
|
||||
return "", errors.New("missing test PVF path: " + path)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package drop 把 DNF PVF 掉落文本索引加载成只读内存表。
|
||||
package drop
|
||||
@@ -0,0 +1,53 @@
|
||||
// 本文件负责从 DNF PVF 内存索引加载掉落强类型表。
|
||||
package drop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
// Load 从已构建的 DNF PVF 内存索引加载掉落表。
|
||||
// 这里不做随机、不结算、不发奖,只把 PVF 静态文本转成可查询的只读表。
|
||||
func Load(ctx context.Context, index *dnfpvf.Index, options Options) (*Table, error) {
|
||||
if index == nil {
|
||||
return nil, ErrIndexRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
listPath := options.ListPath
|
||||
if listPath == "" {
|
||||
listPath = DefaultList
|
||||
}
|
||||
entries := index.List(listPath)
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
|
||||
}
|
||||
table := &Table{
|
||||
entries: make([]Entry, 0, len(entries)),
|
||||
byID: make(map[int64]int, len(entries)),
|
||||
byPath: make(map[string]int, len(entries)),
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, ok := index.Document(entry.Path)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrDocMissing, entry.Path)
|
||||
}
|
||||
dropEntry := parse(entry.ID, entry.Path, doc)
|
||||
idx := len(table.entries)
|
||||
table.entries = append(table.entries, dropEntry)
|
||||
if _, exists := table.byID[dropEntry.ID]; !exists {
|
||||
table.byID[dropEntry.ID] = idx
|
||||
}
|
||||
key := pathKey(dropEntry.Path)
|
||||
if key != "" {
|
||||
table.byPath[key] = idx
|
||||
}
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 本文件验证掉落表加载只依赖内存 PVF 索引。
|
||||
package drop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
func TestLoadBuildsDropTable(t *testing.T) {
|
||||
table, err := Load(context.Background(), testIndex(t), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("load drop: %v", err)
|
||||
}
|
||||
if snapshot := table.Snapshot(); snapshot.Entries != 1 {
|
||||
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
||||
}
|
||||
entry, ok := table.Find(5001)
|
||||
if !ok {
|
||||
t.Fatalf("expected drop entry")
|
||||
}
|
||||
if entry.Name != "training drop" || entry.Kind != "normal" || entry.Gold != 120 || entry.MinCount != 1 {
|
||||
t.Fatalf("unexpected entry: %+v", entry)
|
||||
}
|
||||
if len(entry.ItemIDs) != 2 || entry.ItemIDs[0] != 1001 {
|
||||
t.Fatalf("unexpected item ids: %+v", entry.ItemIDs)
|
||||
}
|
||||
if len(entry.Items) != 2 || entry.Items[0].ID != 1001 || entry.Items[0].Weight != 0.75 {
|
||||
t.Fatalf("unexpected weighted items: %+v", entry.Items)
|
||||
}
|
||||
entry.ItemIDs[0] = 1
|
||||
entry.Items[0].Weight = 1
|
||||
entry.Scalars["gold"] = 1
|
||||
again, _ := table.FindPath("./DROP/training.drop")
|
||||
if again.ItemIDs[0] != 1001 || again.Items[0].Weight != 0.75 || again.Scalars["gold"] != 120 {
|
||||
t.Fatalf("table returned mutable entry: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresIndex(t *testing.T) {
|
||||
_, err := Load(context.Background(), nil, Options{})
|
||||
if !errors.Is(err, ErrIndexRequired) {
|
||||
t.Fatalf("expected ErrIndexRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReportsEmptyList(t *testing.T) {
|
||||
index, err := dnfpvf.Build(context.Background(), textSource{
|
||||
"drop/drop.lst": "5001 `drop/missing.drop`\n",
|
||||
}, dnfpvf.BuildOptions{Paths: []string{"drop/drop.lst"}})
|
||||
if err != nil {
|
||||
t.Fatalf("build index: %v", err)
|
||||
}
|
||||
_, err = Load(context.Background(), index, Options{})
|
||||
if !errors.Is(err, ErrListEmpty) {
|
||||
t.Fatalf("expected ErrListEmpty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testIndex(t *testing.T) *dnfpvf.Index {
|
||||
t.Helper()
|
||||
index, err := dnfpvf.Build(context.Background(), textSource{
|
||||
"drop/drop.lst": "5001 `drop/training.drop`\n",
|
||||
"drop/training.drop": `
|
||||
[name]
|
||||
` + "`training drop`" + `
|
||||
[drop type]
|
||||
` + "`normal`" + `
|
||||
[items]
|
||||
1001 1002
|
||||
[item paths]
|
||||
` + "`equipment/training_sword.equ`" + `
|
||||
[drop rate]
|
||||
1001 0.75 ` + "`equipment/training_sword.equ`" + ` 0.25
|
||||
[gold]
|
||||
120
|
||||
[min count]
|
||||
1
|
||||
[max count]
|
||||
2
|
||||
`,
|
||||
}, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
|
||||
if err != nil {
|
||||
t.Fatalf("build index: %v", err)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
type textSource map[string]string
|
||||
|
||||
func (s textSource) ReadText(relativePath string) (string, error) {
|
||||
for key, value := range s {
|
||||
if pathKey(key) == pathKey(relativePath) {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return "", dnfpvf.ErrDocNotFound
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package drop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const MonsterRulePath = "Etc/ItemDropInfo_Monseter.etc"
|
||||
|
||||
var (
|
||||
ErrMonsterRuleSourceRequired = errors.New("dnf monster drop rule PVF source is required")
|
||||
ErrMonsterRuleSectionMissing = errors.New("dnf monster drop rule PVF section is missing")
|
||||
ErrMonsterRuleSectionShape = errors.New("dnf monster drop rule PVF section shape is invalid")
|
||||
)
|
||||
|
||||
// MonsterProbabilityRow preserves one exact seven-column [drop prob] row.
|
||||
// The first two values form the lookup range. The five remaining columns stay
|
||||
// opaque until their category mapping is proved from the current server/EXE;
|
||||
// the PVF section alone does not safely name them as gold/equipment/material.
|
||||
type MonsterProbabilityRow struct {
|
||||
KeyMin int64
|
||||
KeyMax int64
|
||||
Rates [5]int64
|
||||
}
|
||||
|
||||
// MonsterItemReference preserves one exact three-column [item drop ref table]
|
||||
// row. ValueA and ValueB are intentionally not named as grade offsets because
|
||||
// that business meaning is not established by the PVF document itself.
|
||||
type MonsterItemReference struct {
|
||||
LookupKey int64
|
||||
ValueA int64
|
||||
ValueB int64
|
||||
}
|
||||
|
||||
type MonsterRuleSnapshot struct {
|
||||
ProbabilityRows int
|
||||
RarityRows int
|
||||
PartyBonusRows int
|
||||
DifficultyBonusRows int
|
||||
MonsterTypeRows int
|
||||
ItemReferences int
|
||||
ConditionRows int
|
||||
}
|
||||
|
||||
// MonsterRawTables is a value-copy of the remaining fixed-size PVF sections.
|
||||
// The names describe source sections and shape only; row/column category
|
||||
// semantics are not promoted to business rules without separate evidence.
|
||||
type MonsterRawTables struct {
|
||||
RarityDecision [4][7]int64
|
||||
PartyBonus [5][4]float64
|
||||
DifficultyBonus [5][5]float64
|
||||
MonsterTypeBonus [5][4]float64
|
||||
FirstBossNamed [2]int64
|
||||
ConditionRates [4][2]int64
|
||||
GoldQuantity [4]int64
|
||||
GoldVolume [2]int64
|
||||
RarityControl [2][5]int64
|
||||
}
|
||||
|
||||
// MonsterSourcePlan is a pure lookup result. It does not roll a probability,
|
||||
// choose an item, allocate a scene object, or mutate an inventory.
|
||||
type MonsterSourcePlan struct {
|
||||
LookupKey int64
|
||||
Probability MonsterProbabilityRow
|
||||
ItemReference MonsterItemReference
|
||||
ProbabilityFound bool
|
||||
ItemReferenceFound bool
|
||||
}
|
||||
|
||||
// MonsterRules is a read-only typed view of every numeric section currently
|
||||
// present in ItemDropInfo_Monseter.etc. Matrices preserve PVF row/column order;
|
||||
// their axes remain deliberately opaque where the current runtime has not
|
||||
// proved a category mapping.
|
||||
type MonsterRules struct {
|
||||
probabilityRows []MonsterProbabilityRow
|
||||
rarityDecision [4][7]int64
|
||||
partyBonus [5][4]float64
|
||||
difficultyBonus [5][5]float64
|
||||
monsterTypeBonus [5][4]float64
|
||||
itemReferences map[int64]MonsterItemReference
|
||||
firstBossNamed [2]int64
|
||||
conditionRates [4][2]int64
|
||||
goldQuantity [4]int64
|
||||
goldVolume [2]int64
|
||||
rarityControl [2][5]int64
|
||||
}
|
||||
|
||||
func LoadMonsterRules(source dnfpvf.Source) (*MonsterRules, error) {
|
||||
if source == nil {
|
||||
return nil, ErrMonsterRuleSourceRequired
|
||||
}
|
||||
text, err := source.ReadText(MonsterRulePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", MonsterRulePath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(MonsterRulePath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", MonsterRulePath, err)
|
||||
}
|
||||
|
||||
probabilityRows, err := parseMonsterProbabilityRows(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
itemReferences, err := parseMonsterItemReferences(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := &MonsterRules{
|
||||
probabilityRows: probabilityRows,
|
||||
itemReferences: itemReferences,
|
||||
}
|
||||
if err := fillMonsterRuleIntMatrix(document, "basis of rarity dicision", 4, 7, func(row, column int, value int64) {
|
||||
rules.rarityDecision[row][column] = value
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for row := range rules.rarityDecision {
|
||||
for column := 1; column < len(rules.rarityDecision[row]); column++ {
|
||||
if rules.rarityDecision[row][column] < rules.rarityDecision[row][column-1] {
|
||||
return nil, fmt.Errorf("%w: section=basis of rarity dicision row=%d column=%d value=%d previous=%d",
|
||||
ErrMonsterRuleSectionShape, row, column, rules.rarityDecision[row][column], rules.rarityDecision[row][column-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := fillMonsterRuleNumberMatrix(document, "party member drop bonusrate", 5, 4, func(row, column int, value float64) {
|
||||
rules.partyBonus[row][column] = value
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fillMonsterRuleNumberMatrix(document, "dungeon difficulty drop bonusrate", 5, 5, func(row, column int, value float64) {
|
||||
rules.difficultyBonus[row][column] = value
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fillMonsterRuleNumberMatrix(document, "monster type drop bonusrate", 5, 4, func(row, column int, value float64) {
|
||||
rules.monsterTypeBonus[row][column] = value
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fillMonsterRuleIntArray(document, "first boss/named mob hunting", rules.firstBossNamed[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fillMonsterRuleIntMatrix(document, "condition rate", 4, 2, func(row, column int, value int64) {
|
||||
rules.conditionRates[row][column] = value
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fillMonsterRuleIntArray(document, "gold quantity", rules.goldQuantity[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fillMonsterRuleIntArray(document, "gold volume", rules.goldVolume[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fillMonsterRuleIntMatrix(document, "item drop rarity control", 2, 5, func(row, column int, value int64) {
|
||||
rules.rarityControl[row][column] = value
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func parseMonsterProbabilityRows(document *dnfpvf.Document) ([]MonsterProbabilityRow, error) {
|
||||
declared, found := document.Int("drop prob count")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: section=drop prob count", ErrMonsterRuleSectionMissing)
|
||||
}
|
||||
values, err := monsterRuleIntegers(document, "drop prob")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if declared <= 0 || len(values)%7 != 0 || int64(len(values)/7) != declared {
|
||||
return nil, fmt.Errorf("%w: section=drop prob declared=%d values=%d row_width=7",
|
||||
ErrMonsterRuleSectionShape, declared, len(values))
|
||||
}
|
||||
rows := make([]MonsterProbabilityRow, 0, len(values)/7)
|
||||
for offset := 0; offset < len(values); offset += 7 {
|
||||
row := MonsterProbabilityRow{
|
||||
KeyMin: values[offset],
|
||||
KeyMax: values[offset+1],
|
||||
Rates: [5]int64{
|
||||
values[offset+2], values[offset+3], values[offset+4], values[offset+5], values[offset+6],
|
||||
},
|
||||
}
|
||||
if row.KeyMin < 0 || row.KeyMax < row.KeyMin {
|
||||
return nil, fmt.Errorf("%w: section=drop prob row=%+v", ErrMonsterRuleSectionShape, row)
|
||||
}
|
||||
for _, value := range row.Rates {
|
||||
if value < 0 {
|
||||
return nil, fmt.Errorf("%w: section=drop prob row=%+v negative_rate=%d", ErrMonsterRuleSectionShape, row, value)
|
||||
}
|
||||
}
|
||||
if len(rows) != 0 && row.KeyMin <= rows[len(rows)-1].KeyMax {
|
||||
return nil, fmt.Errorf("%w: section=drop prob overlapping_or_unsorted previous=%+v current=%+v",
|
||||
ErrMonsterRuleSectionShape, rows[len(rows)-1], row)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func parseMonsterItemReferences(document *dnfpvf.Document) (map[int64]MonsterItemReference, error) {
|
||||
values, err := monsterRuleIntegers(document, "item drop ref table")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(values)%3 != 0 {
|
||||
return nil, fmt.Errorf("%w: section=item drop ref table values=%d row_width=3", ErrMonsterRuleSectionShape, len(values))
|
||||
}
|
||||
rows := make(map[int64]MonsterItemReference, len(values)/3)
|
||||
var previous int64
|
||||
for offset := 0; offset < len(values); offset += 3 {
|
||||
row := MonsterItemReference{LookupKey: values[offset], ValueA: values[offset+1], ValueB: values[offset+2]}
|
||||
if row.LookupKey <= 0 || (offset != 0 && row.LookupKey <= previous) {
|
||||
return nil, fmt.Errorf("%w: section=item drop ref table previous_key=%d row=%+v", ErrMonsterRuleSectionShape, previous, row)
|
||||
}
|
||||
rows[row.LookupKey] = row
|
||||
previous = row.LookupKey
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func monsterRuleIntegers(document *dnfpvf.Document, section string) ([]int64, error) {
|
||||
tokens, found := document.Section(section)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: section=%s", ErrMonsterRuleSectionMissing, section)
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return nil, fmt.Errorf("%w: section=%s empty", ErrMonsterRuleSectionShape, section)
|
||||
}
|
||||
values := make([]int64, 0, len(tokens))
|
||||
for index, token := range tokens {
|
||||
if token.Kind != dnfpvf.TokenInt {
|
||||
return nil, fmt.Errorf("%w: section=%s token=%d kind=%s", ErrMonsterRuleSectionShape, section, index, token.Kind)
|
||||
}
|
||||
values = append(values, token.Int)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func monsterRuleNumbers(document *dnfpvf.Document, section string) ([]float64, error) {
|
||||
tokens, found := document.Section(section)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: section=%s", ErrMonsterRuleSectionMissing, section)
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return nil, fmt.Errorf("%w: section=%s empty", ErrMonsterRuleSectionShape, section)
|
||||
}
|
||||
values := make([]float64, 0, len(tokens))
|
||||
for index, token := range tokens {
|
||||
var value float64
|
||||
switch token.Kind {
|
||||
case dnfpvf.TokenInt:
|
||||
value = float64(token.Int)
|
||||
case dnfpvf.TokenFloat:
|
||||
value = token.Float
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: section=%s token=%d kind=%s", ErrMonsterRuleSectionShape, section, index, token.Kind)
|
||||
}
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
|
||||
return nil, fmt.Errorf("%w: section=%s token=%d value=%v", ErrMonsterRuleSectionShape, section, index, value)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func fillMonsterRuleIntArray(document *dnfpvf.Document, section string, destination []int64) error {
|
||||
values, err := monsterRuleIntegers(document, section)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) != len(destination) {
|
||||
return fmt.Errorf("%w: section=%s values=%d want=%d", ErrMonsterRuleSectionShape, section, len(values), len(destination))
|
||||
}
|
||||
copy(destination, values)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fillMonsterRuleIntMatrix(
|
||||
document *dnfpvf.Document,
|
||||
section string,
|
||||
rows int,
|
||||
columns int,
|
||||
set func(int, int, int64),
|
||||
) error {
|
||||
values, err := monsterRuleIntegers(document, section)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) != rows*columns {
|
||||
return fmt.Errorf("%w: section=%s values=%d matrix=%dx%d", ErrMonsterRuleSectionShape, section, len(values), rows, columns)
|
||||
}
|
||||
for index, value := range values {
|
||||
set(index/columns, index%columns, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fillMonsterRuleNumberMatrix(
|
||||
document *dnfpvf.Document,
|
||||
section string,
|
||||
rows int,
|
||||
columns int,
|
||||
set func(int, int, float64),
|
||||
) error {
|
||||
values, err := monsterRuleNumbers(document, section)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) != rows*columns {
|
||||
return fmt.Errorf("%w: section=%s values=%d matrix=%dx%d", ErrMonsterRuleSectionShape, section, len(values), rows, columns)
|
||||
}
|
||||
for index, value := range values {
|
||||
set(index/columns, index%columns, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rules *MonsterRules) Probability(key int64) (MonsterProbabilityRow, bool) {
|
||||
if rules == nil {
|
||||
return MonsterProbabilityRow{}, false
|
||||
}
|
||||
for _, row := range rules.probabilityRows {
|
||||
if key >= row.KeyMin && key <= row.KeyMax {
|
||||
return row, true
|
||||
}
|
||||
}
|
||||
return MonsterProbabilityRow{}, false
|
||||
}
|
||||
|
||||
func (rules *MonsterRules) ItemReference(key int64) (MonsterItemReference, bool) {
|
||||
if rules == nil {
|
||||
return MonsterItemReference{}, false
|
||||
}
|
||||
row, found := rules.itemReferences[key]
|
||||
return row, found
|
||||
}
|
||||
|
||||
func (rules *MonsterRules) PlanSource(key int64) MonsterSourcePlan {
|
||||
plan := MonsterSourcePlan{LookupKey: key}
|
||||
plan.Probability, plan.ProbabilityFound = rules.Probability(key)
|
||||
plan.ItemReference, plan.ItemReferenceFound = rules.ItemReference(key)
|
||||
return plan
|
||||
}
|
||||
|
||||
func (rules *MonsterRules) Snapshot() MonsterRuleSnapshot {
|
||||
if rules == nil {
|
||||
return MonsterRuleSnapshot{}
|
||||
}
|
||||
return MonsterRuleSnapshot{
|
||||
ProbabilityRows: len(rules.probabilityRows),
|
||||
RarityRows: len(rules.rarityDecision),
|
||||
PartyBonusRows: len(rules.partyBonus),
|
||||
DifficultyBonusRows: len(rules.difficultyBonus),
|
||||
MonsterTypeRows: len(rules.monsterTypeBonus),
|
||||
ItemReferences: len(rules.itemReferences),
|
||||
ConditionRows: len(rules.conditionRates),
|
||||
}
|
||||
}
|
||||
|
||||
func (rules *MonsterRules) RawTables() MonsterRawTables {
|
||||
if rules == nil {
|
||||
return MonsterRawTables{}
|
||||
}
|
||||
return MonsterRawTables{
|
||||
RarityDecision: rules.rarityDecision,
|
||||
PartyBonus: rules.partyBonus,
|
||||
DifficultyBonus: rules.difficultyBonus,
|
||||
MonsterTypeBonus: rules.monsterTypeBonus,
|
||||
FirstBossNamed: rules.firstBossNamed,
|
||||
ConditionRates: rules.conditionRates,
|
||||
GoldQuantity: rules.goldQuantity,
|
||||
GoldVolume: rules.goldVolume,
|
||||
RarityControl: rules.rarityControl,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package drop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestLoadMonsterRulesPreservesEveryCurrentPVFMatrixWithoutInventingRateNames(t *testing.T) {
|
||||
rules, err := LoadMonsterRules(monsterRuleTestSource{MonsterRulePath: validMonsterRuleText()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot := rules.Snapshot(); snapshot != (MonsterRuleSnapshot{
|
||||
ProbabilityRows: 2, RarityRows: 4, PartyBonusRows: 5,
|
||||
DifficultyBonusRows: 5, MonsterTypeRows: 5, ItemReferences: 2, ConditionRows: 4,
|
||||
}) {
|
||||
t.Fatalf("snapshot=%+v", snapshot)
|
||||
}
|
||||
plan := rules.PlanSource(16)
|
||||
if !plan.ProbabilityFound || !plan.ItemReferenceFound ||
|
||||
plan.Probability != (MonsterProbabilityRow{KeyMin: 16, KeyMax: 23, Rates: [5]int64{750, 30, 210, 17, 15}}) ||
|
||||
plan.ItemReference != (MonsterItemReference{LookupKey: 16, ValueA: 7, ValueB: 3}) {
|
||||
t.Fatalf("plan=%+v", plan)
|
||||
}
|
||||
missing := rules.PlanSource(24)
|
||||
if missing.ProbabilityFound || missing.ItemReferenceFound {
|
||||
t.Fatalf("missing lookup fabricated source rows: %+v", missing)
|
||||
}
|
||||
|
||||
tables := rules.RawTables()
|
||||
if tables.RarityDecision[0] != ([7]int64{700000, 964900, 990000, 1000000, 1000001, 1000002, 1000003}) ||
|
||||
tables.PartyBonus[1] != ([4]float64{1, 1.3, 1.6, 2}) ||
|
||||
tables.DifficultyBonus[3] != ([5]float64{1, 1.4, 1.6, 2.2, 2}) ||
|
||||
tables.MonsterTypeBonus[4] != ([4]float64{1, 2, 3, 4}) ||
|
||||
tables.FirstBossNamed != ([2]int64{1, 1}) ||
|
||||
tables.ConditionRates[3] != ([2]int64{85, 15}) ||
|
||||
tables.GoldQuantity != ([4]int64{1, 2, 4, 6}) ||
|
||||
tables.GoldVolume != ([2]int64{100, 110}) ||
|
||||
tables.RarityControl != ([2][5]int64{{2, 13, 17, 0, -5}, {3, 13, 17, 0, -5}}) {
|
||||
t.Fatalf("raw matrices changed: %+v", tables)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMonsterRulesRejectsPartialOrReinterpretedPVFShapes(t *testing.T) {
|
||||
if _, err := LoadMonsterRules(nil); !errors.Is(err, ErrMonsterRuleSourceRequired) {
|
||||
t.Fatalf("nil source error=%v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(string) string
|
||||
}{
|
||||
{
|
||||
name: "declared row count mismatch",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "[drop prob count]\n2", "[drop prob count]\n3")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "overlapping lookup ranges",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "16 23 750", "15 23 750")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "party matrix short",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text,
|
||||
"1 1 1 1 1 1.3 1.6 2 1 1.2 1.4 1.6 1 1.2 1.4 1.6 1 1.2 1.4 1.6",
|
||||
"1 1 1 1")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nonnumeric rate token",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "1.00 1.00 1.00 1.40", "1.00 unknown 1.00 1.40")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rarity threshold decreases",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "700000 964900 990000", "700000 600000 990000")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "item reference keys unsorted",
|
||||
mutate: func(text string) string {
|
||||
return replaceOnce(t, text, "1 0 3 16 7 3", "16 7 3 1 0 3")
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := LoadMonsterRules(monsterRuleTestSource{MonsterRulePath: test.mutate(validMonsterRuleText())})
|
||||
if !errors.Is(err, ErrMonsterRuleSectionShape) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealScriptPVFMonsterRulesPreserveExactRuntimeRows(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
pvfPath = os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
|
||||
}
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to run real monster-rule PVF smoke")
|
||||
}
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules, err := LoadMonsterRules(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot := rules.Snapshot(); snapshot != (MonsterRuleSnapshot{
|
||||
ProbabilityRows: 10, RarityRows: 4, PartyBonusRows: 5,
|
||||
DifficultyBonusRows: 5, MonsterTypeRows: 5, ItemReferences: 200, ConditionRows: 4,
|
||||
}) {
|
||||
t.Fatalf("real snapshot=%+v", snapshot)
|
||||
}
|
||||
if plan := rules.PlanSource(1); !plan.ProbabilityFound || !plan.ItemReferenceFound ||
|
||||
plan.Probability.Rates != ([5]int64{986, 60, 250, 0, 0}) ||
|
||||
plan.ItemReference != (MonsterItemReference{LookupKey: 1, ValueA: 0, ValueB: 3}) {
|
||||
t.Fatalf("real lookup 1=%+v", plan)
|
||||
}
|
||||
if plan := rules.PlanSource(86); !plan.ProbabilityFound || !plan.ItemReferenceFound ||
|
||||
plan.Probability.Rates != ([5]int64{750, 10, 135, 8, 5}) ||
|
||||
plan.ItemReference != (MonsterItemReference{LookupKey: 86, ValueA: 7, ValueB: 3}) {
|
||||
t.Fatalf("real lookup 86=%+v", plan)
|
||||
}
|
||||
if tables := rules.RawTables(); !reflect.DeepEqual(tables.DifficultyBonus[0], [5]float64{1, 1, 1, 1.4, 1.1}) ||
|
||||
!reflect.DeepEqual(tables.MonsterTypeBonus[0], [4]float64{1, 2, 3, 6.6}) {
|
||||
t.Fatalf("real raw modifiers changed: %+v", tables)
|
||||
}
|
||||
}
|
||||
|
||||
func validMonsterRuleText() string {
|
||||
return `[drop prob count]
|
||||
2
|
||||
[drop prob]
|
||||
1 15 986 60 250 0 0 16 23 750 30 210 17 15
|
||||
[basis of rarity dicision]
|
||||
700000 964900 990000 1000000 1000001 1000002 1000003
|
||||
545500 899500 999500 1000000 1000001 1000002 1000003
|
||||
500000 944900 999900 1000000 1000001 1000002 1000003
|
||||
700000 944900 999000 1000000 1000001 1000002 1000003
|
||||
[party member drop bonusrate]
|
||||
1 1 1 1 1 1.3 1.6 2 1 1.2 1.4 1.6 1 1.2 1.4 1.6 1 1.2 1.4 1.6
|
||||
[dungeon difficulty drop bonusrate]
|
||||
1.00 1.00 1.00 1.40 1.10 1.00 1.40 1.60 2.20 2.00 1.00 1.40 1.60 2.20 2.00 1.00 1.40 1.60 2.20 2.00 1.00 1.40 1.60 2.20 2.00
|
||||
[monster type drop bonusrate]
|
||||
1.00 2.00 3.00 6.60 1.00 2.00 3.00 4.00 0.20 2.00 7.00 16.80 1.00 2.00 3.00 4.00 1.00 2.00 3.00 4.00
|
||||
[item drop ref table]
|
||||
1 0 3 16 7 3
|
||||
[first boss/named mob hunting]
|
||||
1 1
|
||||
[condition rate]
|
||||
95 5 93 7 90 10 85 15
|
||||
[gold quantity]
|
||||
1 2 4 6
|
||||
[gold volume]
|
||||
100 110
|
||||
[item drop rarity control]
|
||||
2 13 17 0 -5 3 13 17 0 -5
|
||||
[/item drop rarity control]
|
||||
`
|
||||
}
|
||||
|
||||
func replaceOnce(t *testing.T, source, old, replacement string) string {
|
||||
t.Helper()
|
||||
if source == "" || old == "" || !containsOnce(source, old) {
|
||||
t.Fatalf("test replacement source does not contain exactly one %q", old)
|
||||
}
|
||||
return stringReplaceOnce(source, old, replacement)
|
||||
}
|
||||
|
||||
func containsOnce(source, value string) bool {
|
||||
first := indexString(source, value)
|
||||
return first >= 0 && indexString(source[first+len(value):], value) < 0
|
||||
}
|
||||
|
||||
func stringReplaceOnce(source, old, replacement string) string {
|
||||
index := indexString(source, old)
|
||||
return source[:index] + replacement + source[index+len(old):]
|
||||
}
|
||||
|
||||
func indexString(source, value string) int {
|
||||
for index := 0; index+len(value) <= len(source); index++ {
|
||||
if source[index:index+len(value)] == value {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
type monsterRuleTestSource map[string]string
|
||||
|
||||
func (source monsterRuleTestSource) ReadText(path string) (string, error) {
|
||||
if text, found := source[path]; found {
|
||||
return text, nil
|
||||
}
|
||||
return "", errors.New("missing test PVF path: " + path)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// 本文件负责把通用 PVF Document 转成掉落领域字段。
|
||||
package drop
|
||||
|
||||
import dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
|
||||
// parse 把单个掉落 PVF 文档收敛成稳定字段。
|
||||
// 没识别出的项目私有字段仍保留在通用 Document 里,后续可以按真实 PVF 样本继续扩展。
|
||||
func parse(id int64, dropPath string, doc *dnfpvf.Document) Entry {
|
||||
entry := Entry{
|
||||
ID: id,
|
||||
Path: dropPath,
|
||||
Name: firstText(doc, "name", "display name"),
|
||||
Kind: firstText(doc, "drop type", "type", "kind"),
|
||||
ItemIDs: firstInts(doc, "items", "item ids", "item list", "drops", "drop items"),
|
||||
ItemPaths: firstTexts(doc, "item paths", "item path", "drop paths", "drop path"),
|
||||
Items: weightedItems(doc),
|
||||
Gold: firstInt(doc, "gold", "money", "coin"),
|
||||
MinCount: firstInt(doc, "min count", "minimum count"),
|
||||
MaxCount: firstInt(doc, "max count", "maximum count"),
|
||||
Scalars: scalars(doc),
|
||||
}
|
||||
if entry.Name == "" {
|
||||
entry.Name = entry.Path
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// weightedItems 解析常见的“物品 id/路径 + 权重”段落。
|
||||
// 不在这里执行概率随机,掉落随机应由 battle/dungeon 结算后的 reward owner 处理。
|
||||
func weightedItems(doc *dnfpvf.Document) []WeightedItem {
|
||||
for _, name := range []string{"weighted items", "item weights", "drop rate", "drop rates", "probability"} {
|
||||
tokens, ok := doc.Section(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items := parseWeighted(tokens)
|
||||
if len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseWeighted 兼容 `1001 0.75` 和 `item/path 0.25` 两类引用写法。
|
||||
func parseWeighted(tokens []dnfpvf.Token) []WeightedItem {
|
||||
out := make([]WeightedItem, 0)
|
||||
for idx := 0; idx < len(tokens); idx++ {
|
||||
item, ok := itemRef(tokens[idx])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
weight, next, ok := numberAfter(tokens, idx+1)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item.Weight = weight
|
||||
out = append(out, item)
|
||||
idx = next
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func itemRef(token dnfpvf.Token) (WeightedItem, bool) {
|
||||
switch token.Kind {
|
||||
case dnfpvf.TokenInt:
|
||||
return WeightedItem{ID: token.Int}, true
|
||||
case dnfpvf.TokenString, dnfpvf.TokenIdent:
|
||||
if token.Value == "" {
|
||||
return WeightedItem{}, false
|
||||
}
|
||||
return WeightedItem{Path: token.Value}, true
|
||||
default:
|
||||
return WeightedItem{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func numberAfter(tokens []dnfpvf.Token, start int) (float64, int, bool) {
|
||||
for idx := start; idx < len(tokens); idx++ {
|
||||
token := tokens[idx]
|
||||
if token.Kind == dnfpvf.TokenSymbol {
|
||||
continue
|
||||
}
|
||||
if value, ok := tokenNumber(token); ok {
|
||||
return value, idx, true
|
||||
}
|
||||
return 0, start, false
|
||||
}
|
||||
return 0, start, false
|
||||
}
|
||||
|
||||
func tokenNumber(token dnfpvf.Token) (float64, bool) {
|
||||
switch token.Kind {
|
||||
case dnfpvf.TokenInt:
|
||||
return float64(token.Int), true
|
||||
case dnfpvf.TokenFloat:
|
||||
return token.Float, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// scalars 提取结算可能用到的数值字段,方便启动后直接走内存查询。
|
||||
func scalars(doc *dnfpvf.Document) map[string]float64 {
|
||||
names := map[string][]string{
|
||||
"gold": {"gold", "money", "coin"},
|
||||
"min_count": {"min count", "minimum count"},
|
||||
"max_count": {"max count", "maximum count"},
|
||||
"drop_rate": {"drop rate", "drop rates", "probability"},
|
||||
"party_bonus": {"party bonus"},
|
||||
}
|
||||
out := make(map[string]float64)
|
||||
for key, aliases := range names {
|
||||
if value, ok := firstNumber(doc, aliases...); ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstText(doc *dnfpvf.Document, names ...string) string {
|
||||
for _, name := range names {
|
||||
if value, ok := doc.Text(name); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstTexts(doc *dnfpvf.Document, names ...string) []string {
|
||||
for _, name := range names {
|
||||
values := doc.Texts(name)
|
||||
if len(values) > 0 {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstInt(doc *dnfpvf.Document, names ...string) int64 {
|
||||
for _, name := range names {
|
||||
if value, ok := doc.Int(name); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstInts(doc *dnfpvf.Document, names ...string) []int64 {
|
||||
for _, name := range names {
|
||||
values := doc.Ints(name)
|
||||
if len(values) > 0 {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNumber(doc *dnfpvf.Document, names ...string) (float64, bool) {
|
||||
for _, name := range names {
|
||||
if value, ok := doc.Number(name); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 本文件负责掉落表路径 key 的归一化。
|
||||
package drop
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func pathKey(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
for strings.HasPrefix(value, "./") || strings.HasPrefix(value, "/") {
|
||||
value = strings.TrimPrefix(value, "./")
|
||||
value = strings.TrimPrefix(value, "/")
|
||||
}
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
clean := strings.TrimSuffix(path.Clean(value), "/")
|
||||
if clean == "." {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(clean)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 本文件定义 DNF 掉落表的内存视图和查询接口。
|
||||
package drop
|
||||
|
||||
import "errors"
|
||||
|
||||
const DefaultList = "drop/drop.lst"
|
||||
|
||||
var (
|
||||
ErrIndexRequired = errors.New("dnf drop index is required")
|
||||
ErrListEmpty = errors.New("dnf drop list is empty")
|
||||
ErrDocMissing = errors.New("dnf drop document is missing")
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
ListPath string
|
||||
}
|
||||
|
||||
type WeightedItem struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
ItemIDs []int64 `json:"item_ids,omitempty"`
|
||||
ItemPaths []string `json:"item_paths,omitempty"`
|
||||
Items []WeightedItem `json:"items,omitempty"`
|
||||
Gold int64 `json:"gold,omitempty"`
|
||||
MinCount int64 `json:"min_count,omitempty"`
|
||||
MaxCount int64 `json:"max_count,omitempty"`
|
||||
Scalars map[string]float64 `json:"scalars,omitempty"`
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
entries []Entry
|
||||
byID map[int64]int
|
||||
byPath map[string]int
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Entries int `json:"entries"`
|
||||
}
|
||||
|
||||
// Entries 返回掉落表副本,避免调用方改坏内存索引。
|
||||
func (t *Table) Entries() []Entry {
|
||||
if t == nil || len(t.entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Entry, len(t.entries))
|
||||
for idx, entry := range t.entries {
|
||||
out[idx] = cloneEntry(entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Find 按 `.lst` 中的掉落 id 查询内存表。
|
||||
func (t *Table) Find(id int64) (Entry, bool) {
|
||||
if t == nil {
|
||||
return Entry{}, false
|
||||
}
|
||||
idx, ok := t.byID[id]
|
||||
if !ok {
|
||||
return Entry{}, false
|
||||
}
|
||||
return cloneEntry(t.entries[idx]), true
|
||||
}
|
||||
|
||||
// FindPath 按 PVF 相对路径查询内存表。
|
||||
func (t *Table) FindPath(dropPath string) (Entry, bool) {
|
||||
if t == nil {
|
||||
return Entry{}, false
|
||||
}
|
||||
idx, ok := t.byPath[pathKey(dropPath)]
|
||||
if !ok {
|
||||
return Entry{}, false
|
||||
}
|
||||
return cloneEntry(t.entries[idx]), true
|
||||
}
|
||||
|
||||
// Snapshot 返回掉落表当前规模,用于启动日志和 debug 面板。
|
||||
func (t *Table) Snapshot() Snapshot {
|
||||
if t == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
return Snapshot{Entries: len(t.entries)}
|
||||
}
|
||||
|
||||
// cloneEntry 复制切片和 map,保证加载后的表只读。
|
||||
func cloneEntry(entry Entry) Entry {
|
||||
entry.ItemIDs = append([]int64(nil), entry.ItemIDs...)
|
||||
entry.ItemPaths = append([]string(nil), entry.ItemPaths...)
|
||||
entry.Items = append([]WeightedItem(nil), entry.Items...)
|
||||
if len(entry.Scalars) > 0 {
|
||||
scalars := make(map[string]float64, len(entry.Scalars))
|
||||
for key, value := range entry.Scalars {
|
||||
scalars[key] = value
|
||||
}
|
||||
entry.Scalars = scalars
|
||||
}
|
||||
return entry
|
||||
}
|
||||
Reference in new issue
Block a user