DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,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)
}
+2
View File
@@ -0,0 +1,2 @@
// Package drop 把 DNF PVF 掉落文本索引加载成只读内存表。
package drop
+53
View File
@@ -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)
}
+171
View File
@@ -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
}
+23
View File
@@ -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)
}
+105
View File
@@ -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
}