服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
210 lines
6.7 KiB
Go
210 lines
6.7 KiB
Go
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)
|
|
}
|