服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1158 lines
57 KiB
Go
1158 lines
57 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
|
||
"longheng.io/server/internal/modules/dnf/progression"
|
||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||
)
|
||
|
||
func TestAlignedStackableUseResolverAuthorizesPVFWasteAndCompletePetFeedOnly(t *testing.T) {
|
||
source := dungeonDropCatalogTestSource{
|
||
"monster/monster.lst": "",
|
||
"equipment/equipment.lst": "",
|
||
"stackable/stackable.lst": "24 `cash/creature/creature_food.stk`\n25 `cash/creature/broken_food.stk`\n26 `event/percent_capsule.stk`\n27 `storybook/chapter.stk`\n30 `transform/town_monster.stk`\n31 `transform/random_town.stk`\n32 `transform/dungeon_riding.stk`\n33 `transform/dungeon_ridable.stk`\n34 `transform/town_celebrate.stk`\n35 `forgotten_land/potion.stk`\n1031 `book_skill1.stk`\n1038 `book_skill2.stk`\n1204 `book_fskill1.stk`\n1205 `book_fskill2.stk`\n1206 `book_fskillreset.stk`\n1253 `book_fskillreset_event.stk`\n6002 `projectile/boomerang.stk`\n6009 `projectile/shuriken.stk`\n6012 `projectile/bomb.stk`\n80003 `extention/test_book_skill1.stk`\n2683638 `event/random_level_up_ticket.stk`\n10003634 `event/level_up_ticket.stk`\n10164630 `10164001/10164630.stk`\n490701712 `cash/chn_490701712.stk`\n490701713 `cash/chn_490701713.stk`\n",
|
||
"stackable/cash/creature/creature_food.stk": "[stackable type] `[feed]` 1\n[int data]\n300 -20 30\n[/int data]\n",
|
||
"stackable/cash/creature/broken_food.stk": "[stackable type] `[feed]` 1\n[int data]\n300 -20\n[/int data]\n",
|
||
"stackable/event/percent_capsule.stk": "[stackable type] `[etc]`\n[increase status type] `[expUpByPercent]` 15\n",
|
||
"stackable/storybook/chapter.stk": "[stackable type] `[waste]`\n[minimum level] 85\n[maximum level] 99\n[action type] `[story book]` 3142\n[/action type]\n",
|
||
"stackable/transform/town_monster.stk": "[stackable type] `[disguise]`\n[string data] `monster`\n[int data] 61584 300\n",
|
||
"stackable/transform/random_town.stk": "[stackable type] `[random disguise]`\n[string data] `monster` `creature`\n[int data] 64703 300 191 300\n",
|
||
"stackable/transform/dungeon_riding.stk": "[stackable type] `[waste]`\n[action type] `[riding]` 107000902 300000\n[action usable place] `[dungeon]`\n[action usable dungeon] 100 101\n",
|
||
"stackable/transform/dungeon_ridable.stk": "[stackable type] `[waste]`\n[action type] `[ridable item]` 64734\n[action usable place] `[dungeon]` `[dont use assult]`\n[limit dungeon] 200 201\n",
|
||
"stackable/transform/town_celebrate.stk": "[stackable type] `[etc]`\n[action type] `[celebrate big transition]` 1 2 3\n[action usable place] `[village]` `[seria room]`\n[int data] 300\n",
|
||
"stackable/forgotten_land/potion.stk": "[stackable type] `[waste]`\n[action type] `[hp recovery]` `%` 50 500 `myself`\n[action usable place] `[dungeon]`\n[action usable dungeon] 4000\n",
|
||
"stackable/book_skill1.stk": "[name] `SP+5 skill book`\n[minimum level] 1\n[stackable type] `[etc]` 8\n",
|
||
"stackable/book_skill2.stk": "[name] `SP+20 skill book`\n[minimum level] 1\n[stackable type] `[etc]` 8\n",
|
||
"stackable/book_fskill1.stk": "[name] `TP+1 skill book`\n[minimum level] 50\n[stackable type] `[etc]` 8\n",
|
||
"stackable/book_fskill2.stk": "[name] `TP+5 skill book`\n[minimum level] 50\n[stackable type] `[etc]` 8\n",
|
||
"stackable/book_fskillreset.stk": "[name] `TP reset book`\n[minimum level] 50\n[stackable type] `[etc]` 8\n",
|
||
"stackable/book_fskillreset_event.stk": "[name] `event TP reset book`\n[minimum level] 50\n[stackable type] `[etc]` 8\n",
|
||
"stackable/projectile/boomerang.stk": "[stackable type] `[throw]`\n[int data] 101 2 3 4 5 6\n[string data] `particle/a.ptl`\n",
|
||
"stackable/projectile/shuriken.stk": "[stackable type] `[throw]`\n[int data] 102 2 3 4 5 6\n",
|
||
"stackable/projectile/bomb.stk": "[stackable type] `[set]`\n[int data] 103 2 3 4 5 6\n[action usable place] `[dungeon]`\n",
|
||
"stackable/extention/test_book_skill1.stk": "[name] `extension SP+5 skill book`\n[minimum level] 1\n[stackable type] `[etc]` 8\n",
|
||
"stackable/10164001/10164630.stk": "[stackable type] `[etc]` 1\n[minimum level] 90\n[maximum level] 90\n[action type] `[gm quest clear]`\n[/action type]\n[action usable place] `[village]`\n[/action usable place]\n",
|
||
"stackable/event/level_up_ticket.stk": "[stackable type] `[etc]` 1\n[minimum level] 1\n[maximum level] 79\n[levelup range] 1\n[action type] `[level up ticket]`\n[/action type]\n",
|
||
"stackable/event/random_level_up_ticket.stk": "[stackable type] `[etc]` 1\n[minimum level] 55\n[maximum level] 82\n[levelup range] 3\n[action type] `[level up ticket]`\n[/action type]\n[booster info]\n[etc]\n1 1 50000 1 1 30000 2 1 10000 3\n[/etc]\n[/booster info]\n",
|
||
"stackable/cash/chn_490701712.stk": "[stackable type] `[waste]`\n",
|
||
"stackable/cash/chn_490701713.stk": "[stackable type] `[material]`\n",
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := &Service{pvfItemCatalog: catalog}
|
||
resolver, err := service.alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
waste, err := resolver(490701712)
|
||
if err != nil || waste.ItemID != 490701712 || waste.PVFPath != "stackable/cash/chn_490701712.stk" || waste.StackableType != "[waste]" {
|
||
t.Fatalf("waste resolution=%+v err=%v", waste, err)
|
||
}
|
||
for itemID, wantAction := range map[int64]string{
|
||
30: "[disguise]",
|
||
31: "[random disguise]",
|
||
32: "[riding]",
|
||
33: "[ridable item]",
|
||
34: "[celebrate big transition]",
|
||
} {
|
||
got, resolveErr := resolver(itemID)
|
||
if resolveErr != nil || got.Transformation == nil || got.Transformation.ActionType != wantAction || len(got.Transformation.FormIDs) == 0 {
|
||
t.Fatalf("transformation item=%d resolution=%+v err=%v", itemID, got, resolveErr)
|
||
}
|
||
}
|
||
if town, _ := resolver(30); !town.Transformation.VillageAllowed || !town.Transformation.SeriaRoomAllowed || town.Transformation.DungeonAllowed || town.Transformation.DurationSeconds != 300 {
|
||
t.Fatalf("town disguise contract=%+v", town.Transformation)
|
||
}
|
||
if riding, _ := resolver(32); !riding.Transformation.DungeonAllowed || riding.Transformation.VillageAllowed || riding.Transformation.DurationSeconds != 300 || fmt.Sprint(riding.Transformation.ActionUsableDungeonIDs) != "[100 101]" {
|
||
t.Fatalf("dungeon riding contract=%+v", riding.Transformation)
|
||
}
|
||
if ridable, _ := resolver(33); !ridable.Transformation.DungeonAllowed || !ridable.Transformation.DisallowAssault || fmt.Sprint(ridable.Transformation.LimitDungeonIDs) != "[200 201]" {
|
||
t.Fatalf("dungeon ridable contract=%+v", ridable.Transformation)
|
||
}
|
||
forgottenPotion, err := resolver(35)
|
||
if err != nil || forgottenPotion.UsePlace == nil || !forgottenPotion.UsePlace.DungeonAllowed ||
|
||
forgottenPotion.UsePlace.VillageAllowed || fmt.Sprint(forgottenPotion.UsePlace.ActionUsableDungeonIDs) != "[4000]" {
|
||
t.Fatalf("forgotten-land potion resolution=%+v err=%v", forgottenPotion, err)
|
||
}
|
||
feed, err := resolver(24)
|
||
if err != nil || feed.ItemID != 24 || feed.PVFPath != "stackable/cash/creature/creature_food.stk" || feed.StackableType != "[feed]" ||
|
||
feed.PetFeedEffectDurationSeconds != 300 || feed.PetFeedSkillChargePercent != -20 || feed.PetFeedSatietyDelta != 30 {
|
||
t.Fatalf("feed resolution=%+v err=%v", feed, err)
|
||
}
|
||
if broken, err := resolver(25); err == nil || broken != (alignedcmd.StackableUseResolution{}) {
|
||
t.Fatalf("broken feed resolution=%+v err=%v", broken, err)
|
||
}
|
||
percentCapsule, err := resolver(26)
|
||
if err != nil || percentCapsule.DirectExperience == nil || percentCapsule.DirectExperience.EffectType != "[exppercentup]" ||
|
||
len(percentCapsule.DirectExperience.EffectValues) != 1 || percentCapsule.DirectExperience.EffectValues[0] != 15 {
|
||
t.Fatalf("percent capsule resolution=%+v err=%v", percentCapsule, err)
|
||
}
|
||
storyBook, err := resolver(27)
|
||
if err != nil || storyBook.StoryBook == nil || storyBook.StoryBook.ActionType != "[story book]" ||
|
||
storyBook.StoryBook.QuestID != 3142 || storyBook.StoryBook.MinimumLevel != 85 || storyBook.StoryBook.MaximumLevel != 99 {
|
||
t.Fatalf("story-book resolution=%+v err=%v", storyBook, err)
|
||
}
|
||
bookCases := map[int64]alignedcmd.TPBookResolution{
|
||
1031: {Kind: alignedcmd.SkillPointBookAddSP, Delta: 5, MinimumLevel: 1},
|
||
1038: {Kind: alignedcmd.SkillPointBookAddSP, Delta: 20, MinimumLevel: 1},
|
||
1204: {Kind: alignedcmd.SkillPointBookAddTP, Delta: 1, MinimumLevel: 50},
|
||
1205: {Kind: alignedcmd.SkillPointBookAddTP, Delta: 5, MinimumLevel: 50},
|
||
1206: {Kind: alignedcmd.SkillPointBookResetTP, MinimumLevel: 50},
|
||
1253: {Kind: alignedcmd.SkillPointBookResetTP, MinimumLevel: 50},
|
||
80003: {Kind: alignedcmd.SkillPointBookAddSP, Delta: 5, MinimumLevel: 1},
|
||
}
|
||
for itemID, want := range bookCases {
|
||
book, resolveErr := resolver(itemID)
|
||
if resolveErr != nil || book.ItemID != itemID || book.StackableType != "[etc]" ||
|
||
book.TPBook == nil || *book.TPBook != want {
|
||
t.Fatalf("SP/TP book %d resolution=%+v want=%+v err=%v", itemID, book, want, resolveErr)
|
||
}
|
||
}
|
||
for itemID, wantType := range map[int64]string{6002: "[throw]", 6009: "[throw]", 6012: "[set]"} {
|
||
projectile, resolveErr := resolver(itemID)
|
||
if resolveErr != nil || projectile.Projectile == nil || projectile.Projectile.StackableType != wantType ||
|
||
projectile.Projectile.EffectID <= 0 || len(projectile.Projectile.IntData) != 6 || !projectile.Projectile.DungeonAllowed {
|
||
t.Fatalf("projectile %d resolution=%+v err=%v", itemID, projectile, resolveErr)
|
||
}
|
||
}
|
||
material, err := resolver(490701713)
|
||
if err != nil || material != (alignedcmd.StackableUseResolution{}) {
|
||
t.Fatalf("material resolution=%+v err=%v", material, err)
|
||
}
|
||
actionResolver, err := service.alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackableAction)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ticket, err := actionResolver(10164630)
|
||
if err != nil || ticket.ItemID != 10164630 || ticket.QuestCompletionTicket == nil ||
|
||
ticket.QuestCompletionTicket.ActionType != "[gm quest clear]" || !ticket.QuestCompletionTicket.VillageOnly {
|
||
t.Fatalf("op515 quest ticket resolution=%+v err=%v", ticket, err)
|
||
}
|
||
eventLevelUpResolver, err := service.alignedStackableUseResolverForCommand(dnfenum.CmdPacketRequestEventServerLevelUp)
|
||
if err != nil || eventLevelUpResolver == nil {
|
||
t.Fatalf("op418 resolver=%v err=%v", eventLevelUpResolver, err)
|
||
}
|
||
eventLevelUpTicket, err := eventLevelUpResolver(10003634)
|
||
if err != nil || eventLevelUpTicket.DirectExperience == nil || eventLevelUpTicket.QuestCompletionTicket == nil ||
|
||
eventLevelUpTicket.DirectExperience.EffectType != "[level up ticket]" {
|
||
t.Fatalf("op418 level-up ticket resolution=%+v err=%v", eventLevelUpTicket, err)
|
||
}
|
||
levelUpTicket, err := resolver(10003634)
|
||
if err != nil || levelUpTicket.ItemID != 10003634 || levelUpTicket.DirectExperience == nil ||
|
||
levelUpTicket.QuestCompletionTicket == nil || levelUpTicket.DirectExperience.EffectType != "[level up ticket]" ||
|
||
len(levelUpTicket.DirectExperience.EffectValues) != 1 || levelUpTicket.DirectExperience.EffectValues[0] != 1 ||
|
||
levelUpTicket.DirectExperience.MinimumLevel != 1 || levelUpTicket.DirectExperience.MaximumLevel != 79 ||
|
||
levelUpTicket.QuestCompletionTicket.ActionType != "[quest clear]" {
|
||
t.Fatalf("level-up ticket resolution=%+v err=%v", levelUpTicket, err)
|
||
}
|
||
randomDefinition, err := catalog.ResolveItem(2683638)
|
||
if err != nil || len(randomDefinition.LevelUpOutcomes) != 3 ||
|
||
randomDefinition.LevelUpOutcomes[0] != (levelUpTicketOutcome{Range: 1, Weight: 50000}) ||
|
||
randomDefinition.LevelUpOutcomes[1] != (levelUpTicketOutcome{Range: 2, Weight: 30000}) ||
|
||
randomDefinition.LevelUpOutcomes[2] != (levelUpTicketOutcome{Range: 3, Weight: 10000}) {
|
||
t.Fatalf("random level-up definition=%+v err=%v", randomDefinition, err)
|
||
}
|
||
randomLevelUpTicket, err := resolver(2683638)
|
||
if err != nil || randomLevelUpTicket.DirectExperience == nil || len(randomLevelUpTicket.DirectExperience.EffectValues) != 1 ||
|
||
randomLevelUpTicket.DirectExperience.EffectValues[0] < 1 || randomLevelUpTicket.DirectExperience.EffectValues[0] > 3 {
|
||
t.Fatalf("random level-up ticket resolution=%+v err=%v", randomLevelUpTicket, err)
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRecognizesFatiguePotionPVFContract(t *testing.T) {
|
||
source := dungeonDropCatalogTestSource{
|
||
"monster/monster.lst": "",
|
||
"equipment/equipment.lst": "",
|
||
"stackable/stackable.lst": "9001 `cash/fatigue.stk`\n9002 `event/fatigue_one.stk`\n",
|
||
"stackable/cash/fatigue.stk": "[name] `fatigue 50`\n[explain] `疲劳值低于106时才能使用;每日限用3个`\n[minimum level] 1\n[stackable type] `[expert town potion]` 0\n[action type] `[add fatigue]` 50\n[/action type]\n[total usable count] 3\n[action usable place] `[village]` `[seria room]` `[dont use assult]`\n[/action usable place]\n",
|
||
"stackable/event/fatigue_one.stk": "[name] `fatigue 1`\n[explain] `fatigue below 155, daily 3`\n[minimum level] 1\n[stackable type] `[expert town potion]` 0\n[action type] `[add fatigue]` 1\n[/action type]\n[total usable count] 3\n[action usable place] `[village]`\n[/action usable place]\n",
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackableAction)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first, err := resolver(9001)
|
||
if err != nil || first.ItemID != 9001 || first.FatigueRecovery != 50 || first.FatigueMaximumRemaining != 106 || first.DailyUseLimit != 3 {
|
||
t.Fatalf("50-point fatigue resolution=%+v err=%v", first, err)
|
||
}
|
||
second, err := resolver(9002)
|
||
if err != nil || second.FatigueRecovery != 1 || second.FatigueMaximumRemaining != 155 || second.DailyUseLimit != 3 {
|
||
t.Fatalf("1-point fatigue resolution=%+v err=%v", second, err)
|
||
}
|
||
}
|
||
|
||
// [total usable count] 不是疲劳药专属:任何 stackable(例如 [waste] 成长秘药)
|
||
// 声明它都必须解析出每日上限,否则服务端永远不会拒绝第 N+1 次使用;没有该字段的
|
||
// 道具必须保持 0(= 无上限、也不多做任何事)。
|
||
func TestAlignedStackableUseResolverReadsTotalUsableCountForAnyStackable(t *testing.T) {
|
||
source := dungeonDropCatalogTestSource{
|
||
"monster/monster.lst": "",
|
||
"equipment/equipment.lst": "",
|
||
"stackable/stackable.lst": "10153501 `10153001/10153501.stk`\n10153502 `10153001/10153502.stk`\n",
|
||
"stackable/10153001/10153501.stk": "[name] `远古精灵的秘药`\n[stackable type] `[waste]`\n[minimum level] 1\n" +
|
||
"[total usable count] 10\n",
|
||
"stackable/10153001/10153502.stk": "[name] `无上限材料`\n[stackable type] `[waste]`\n[minimum level] 1\n",
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
capped, err := resolver(10153501)
|
||
if err != nil || capped.ItemID != 10153501 || capped.DailyUseLimit != 10 {
|
||
t.Fatalf("capped waste resolution=%+v err=%v", capped, err)
|
||
}
|
||
uncapped, err := resolver(10153502)
|
||
if err != nil || uncapped.ItemID != 10153502 || uncapped.DailyUseLimit != 0 {
|
||
t.Fatalf("uncapped waste resolution=%+v err=%v", uncapped, err)
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRecognizesDungeonAdmissionTickets(t *testing.T) {
|
||
source := dungeonDropCatalogTestSource{
|
||
"monster/monster.lst": "",
|
||
"equipment/equipment.lst": "",
|
||
"stackable/stackable.lst": "10158692 `10158001/10158692.stk`\n490701703 `cash/chn_490701703.stk`\n",
|
||
"stackable/10158001/10158692.stk": "[stackable type] `[waste]`\n[minimum level] 70\n[action type] `[add dimension dungeon enter count]` 5\n" +
|
||
"[action usable place] `[village]` `[seria room]`\n",
|
||
"stackable/cash/chn_490701703.stk": "[stackable type] `[waste]`\n[minimum level] 90\n[action type] `[up layer despair dungeon]` 5115\n" +
|
||
"[action usable place] `[village]`\n[total usable count] 2\n[use action packet] 1\n",
|
||
}
|
||
itemCatalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
admissionCatalog, err := dnfdungeonadmission.LoadCatalog(dungeonAdmissionBridgeCatalogSource())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := &Service{pvfItemCatalog: itemCatalog, dungeonAdmissionCatalog: admissionCatalog}
|
||
resolver, err := service.alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
dimension, err := resolver(10158692)
|
||
if err != nil || dimension.DungeonAdmissionTicket == nil || len(dimension.DungeonAdmissionTicket.Grants) != 1 ||
|
||
dimension.DungeonAdmissionTicket.Grants[0].Policy.Key != "dimension_gate.character.daily" ||
|
||
dimension.DungeonAdmissionTicket.Grants[0].Amount != 5 || dimension.DungeonAdmissionTicket.Grants[0].MaximumBonus != 5 {
|
||
t.Fatalf("dimension ticket=%+v err=%v", dimension, err)
|
||
}
|
||
tower, err := resolver(490701703)
|
||
if err != nil || tower.DungeonAdmissionTicket == nil || tower.DungeonAdmissionTicket.TargetDungeonID != 5115 ||
|
||
tower.DungeonAdmissionTicket.ActionPacket != 1 || len(tower.DungeonAdmissionTicket.Grants) != 2 || tower.DungeonAdmissionTicket.UseLimit.Limit != 2 ||
|
||
tower.DungeonAdmissionTicket.Grants[0].Amount != 1 || tower.DungeonAdmissionTicket.Grants[1].Amount != 1 {
|
||
t.Fatalf("tower ticket=%+v err=%v", tower, err)
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFRecognizesDungeonAdmissionTickets(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify dungeon admission tickets")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
itemCatalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
admissionCatalog, err := dnfdungeonadmission.LoadCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: itemCatalog, dungeonAdmissionCatalog: admissionCatalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for itemID, action := range map[int64]string{
|
||
10158692: "[add dimension dungeon enter count]",
|
||
490701703: "[up layer despair dungeon]",
|
||
10099882: "[up layer despair dungeon]",
|
||
10158917: "[up layer despair dungeon]",
|
||
10302670: "[up layer despair dungeon]",
|
||
10161291: "[up layer despair dungeon]",
|
||
} {
|
||
resolution, resolveErr := resolver(itemID)
|
||
if resolveErr != nil || resolution.DungeonAdmissionTicket == nil || resolution.DungeonAdmissionTicket.ActionType != action ||
|
||
(action == "[up layer despair dungeon]" && resolution.DungeonAdmissionTicket.ActionPacket != 1) ||
|
||
(action == "[add dimension dungeon enter count]" && resolution.DungeonAdmissionTicket.ActionPacket != 0) {
|
||
t.Fatalf("item=%d resolution=%+v err=%v", itemID, resolution, resolveErr)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFCoversPeaceWingStoryBooks(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify runtime story books")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for itemID, questID := range map[int64]int64{
|
||
10149084: 3140,
|
||
10149085: 3141,
|
||
10149086: 3142,
|
||
10149087: 3143,
|
||
10149088: 3144,
|
||
} {
|
||
resolution, resolveErr := resolver(itemID)
|
||
if resolveErr != nil || resolution.StoryBook == nil || resolution.StoryBook.QuestID != questID ||
|
||
resolution.StoryBook.ActionType != "[story book]" || resolution.StackableType != "[waste]" {
|
||
t.Fatalf("story-book item=%d quest=%d resolution=%+v err=%v", itemID, questID, resolution, resolveErr)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFCoversForgottenLandWaste(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify Forgotten Land consumables")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, itemID := range []int64{
|
||
10092913, 10092914, 10092915, 10092916, 10092917, 10092918,
|
||
10093345, 10093346, 10093469, 10093470, 10093471, 10093472,
|
||
} {
|
||
resolution, resolveErr := resolver(itemID)
|
||
if resolveErr != nil || resolution.ItemID != itemID || resolution.StackableType != "[waste]" ||
|
||
resolution.UsePlace == nil || !resolution.UsePlace.DungeonAllowed || resolution.UsePlace.VillageAllowed ||
|
||
fmt.Sprint(resolution.UsePlace.ActionUsableDungeonIDs) != "[4000]" {
|
||
t.Fatalf("Forgotten Land item=%d resolution=%+v err=%v", itemID, resolution, resolveErr)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFCoversEveryTransformationConsumable(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to audit every runtime transformation consumable")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
counts := map[string]int{}
|
||
for _, itemID := range catalog.stackableIDs {
|
||
definition, resolveErr := catalog.ResolveItem(itemID)
|
||
if resolveErr != nil {
|
||
t.Fatalf("resolve transformation candidate item=%d: %v", itemID, resolveErr)
|
||
}
|
||
transformation, transformErr := currentPVFTransformationUseResolution(definition)
|
||
if transformErr != nil {
|
||
t.Fatalf("parse transformation item=%d path=%s: %v", itemID, definition.PVFPath, transformErr)
|
||
}
|
||
if transformation == nil {
|
||
continue
|
||
}
|
||
resolution, resolveErr := resolver(int64(itemID))
|
||
if resolveErr != nil || resolution.Transformation == nil || resolution.Transformation.ActionType != transformation.ActionType {
|
||
t.Fatalf("resolve transformation item=%d path=%s resolution=%+v err=%v", itemID, definition.PVFPath, resolution, resolveErr)
|
||
}
|
||
counts[transformation.ActionType]++
|
||
}
|
||
want := map[string]int{
|
||
"[disguise]": 267,
|
||
"[random disguise]": 1,
|
||
"[riding]": 53,
|
||
"[ridable item]": 27,
|
||
"[celebrate big transition]": 1,
|
||
}
|
||
if fmt.Sprint(counts) != fmt.Sprint(want) {
|
||
t.Fatalf("transformation census=%v want=%v", counts, want)
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFRecognizesEverySkillPointBook(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify runtime SP/TP point books")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for itemID, want := range map[int64]struct {
|
||
path string
|
||
kind alignedcmd.SkillPointBookKind
|
||
delta int64
|
||
minimum int64
|
||
}{
|
||
1031: {path: "stackable/book_skill1.stk", kind: alignedcmd.SkillPointBookAddSP, delta: 5, minimum: 1},
|
||
1038: {path: "stackable/book_skill2.stk", kind: alignedcmd.SkillPointBookAddSP, delta: 20, minimum: 1},
|
||
1204: {path: "stackable/book_fskill1.stk", kind: alignedcmd.SkillPointBookAddTP, delta: 1, minimum: 50},
|
||
1205: {path: "stackable/book_fskill2.stk", kind: alignedcmd.SkillPointBookAddTP, delta: 5, minimum: 50},
|
||
1206: {path: "stackable/book_fskillreset.stk", kind: alignedcmd.SkillPointBookResetTP, minimum: 50},
|
||
1253: {path: "stackable/book_fskillreset_event.stk", kind: alignedcmd.SkillPointBookResetTP, minimum: 50},
|
||
80003: {path: "stackable/extention/test_book_skill1.stk", kind: alignedcmd.SkillPointBookAddSP, delta: 5, minimum: 1},
|
||
} {
|
||
resolution, resolveErr := resolver(itemID)
|
||
if resolveErr != nil || resolution.PVFPath != want.path || resolution.TPBook == nil ||
|
||
resolution.TPBook.Kind != want.kind || resolution.TPBook.Delta != want.delta ||
|
||
resolution.TPBook.MinimumLevel != want.minimum {
|
||
t.Fatalf("runtime SP/TP book %d resolution=%+v err=%v", itemID, resolution, resolveErr)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFCoversEveryProjectile(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to audit every runtime throw/set item")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
counts := map[string]int{}
|
||
invalid := make([]string, 0)
|
||
skippedNoop := make([]string, 0)
|
||
for _, itemID := range catalog.stackableIDs {
|
||
definition, resolveErr := catalog.ResolveItem(itemID)
|
||
if resolveErr != nil {
|
||
t.Fatalf("resolve projectile candidate item=%d: %v", itemID, resolveErr)
|
||
}
|
||
stackableType := normalizeDungeonDropStackableType(definition.StackableType)
|
||
if stackableType != "[throw]" && stackableType != "[set]" {
|
||
continue
|
||
}
|
||
projectile, projectileErr := currentPVFProjectileUseResolution(definition)
|
||
if projectileErr != nil {
|
||
invalid = append(invalid, fmt.Sprintf("item=%d path=%s type=%s ints=%v err=%v", itemID, definition.PVFPath, stackableType, definition.ActionIntData, projectileErr))
|
||
continue
|
||
}
|
||
if projectile == nil {
|
||
skippedNoop = append(skippedNoop, definition.PVFPath)
|
||
continue
|
||
}
|
||
resolution, resolveErr := resolver(int64(itemID))
|
||
if resolveErr != nil || resolution.Projectile == nil || resolution.Projectile.EffectID != projectile.EffectID ||
|
||
resolution.Projectile.StackableType != stackableType || !resolution.Projectile.DungeonAllowed {
|
||
t.Fatalf("resolve projectile item=%d path=%s resolution=%+v err=%v", itemID, definition.PVFPath, resolution, resolveErr)
|
||
}
|
||
counts[stackableType]++
|
||
}
|
||
if len(invalid) != 0 {
|
||
t.Fatalf("invalid runtime projectile contracts (%d): %v", len(invalid), invalid)
|
||
}
|
||
if fmt.Sprint(skippedNoop) != "[stackable/throw/dummy_throwing.stk]" {
|
||
t.Fatalf("unexpected no-op projectiles=%v", skippedNoop)
|
||
}
|
||
want := map[string]int{"[throw]": 134, "[set]": 35}
|
||
if fmt.Sprint(counts) != fmt.Sprint(want) {
|
||
t.Fatalf("projectile census=%v want=%v", counts, want)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeDirectExperienceTypeKeepsLevelUpTicketAction(t *testing.T) {
|
||
if got := normalizeDirectExperienceType("`[LEVEL UP TICKET]`"); got != "[level up ticket]" {
|
||
t.Fatalf("normalized type=%q", got)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeDirectExperienceTypeCanonicalizesPercentCapsuleAlias(t *testing.T) {
|
||
if got := normalizeDirectExperienceType("`[ExpUpByPercent]`"); got != "[exppercentup]" {
|
||
t.Fatalf("normalized type=%q", got)
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFCoversEveryDirectExperienceConsumable(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to audit every runtime direct-experience consumable")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
counts := make(map[string]int)
|
||
for _, itemID := range catalog.stackableIDs {
|
||
definition, resolveErr := catalog.ResolveItem(itemID)
|
||
if resolveErr != nil {
|
||
t.Fatalf("resolve stackable definition %d: %v", itemID, resolveErr)
|
||
}
|
||
effectType := normalizeDirectExperienceType(definition.DirectExperienceType)
|
||
if effectType == "" {
|
||
continue
|
||
}
|
||
resolution, resolveErr := resolver(int64(itemID))
|
||
if resolveErr != nil {
|
||
t.Fatalf("resolve direct-experience item=%d path=%s type=%q: %v", itemID, definition.PVFPath, definition.DirectExperienceType, resolveErr)
|
||
}
|
||
if resolution.DirectExperience == nil || resolution.DirectExperience.EffectType != effectType ||
|
||
resolution.ItemID != int64(itemID) || resolution.PVFPath != definition.PVFPath ||
|
||
resolution.DirectExperience.MinimumLevel != definition.MinimumLevel ||
|
||
resolution.DirectExperience.MaximumLevel != definition.MaximumLevel ||
|
||
fmt.Sprint(resolution.DirectExperience.EffectValues) != fmt.Sprint(definition.DirectExperienceValues) {
|
||
t.Fatalf("direct-experience item=%d definition=%+v resolution=%+v", itemID, definition, resolution)
|
||
}
|
||
counts[effectType]++
|
||
}
|
||
for _, effectType := range []string{"[expup]", "[exppercentup]", "[expupbytable]", "[levelupbytable]"} {
|
||
if counts[effectType] == 0 {
|
||
t.Fatalf("runtime PVF type %q has no covered item: counts=%v", effectType, counts)
|
||
}
|
||
}
|
||
if total := counts["[expup]"] + counts["[exppercentup]"] + counts["[expupbytable]"] + counts["[levelupbytable]"]; total != 522 {
|
||
t.Fatalf("covered direct-experience total=%d want=522 counts=%v", total, counts)
|
||
}
|
||
for _, itemID := range []int64{690001552, 690001553, 690001554, 690001555} {
|
||
resolution, resolveErr := resolver(itemID)
|
||
if resolveErr != nil || resolution.DirectExperience == nil {
|
||
t.Fatalf("experience book item=%d resolution=%+v err=%v", itemID, resolution, resolveErr)
|
||
}
|
||
}
|
||
t.Logf("covered runtime direct-experience consumables: %v", counts)
|
||
}
|
||
|
||
func TestDirectExperienceGainRealPVFCoversCapsulesAndExperienceBooks(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify runtime direct-experience formulas")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tables, err := progression.Load(context.Background(), source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
for _, test := range []struct {
|
||
name string
|
||
itemID int64
|
||
level int
|
||
wantGain uint32
|
||
}{
|
||
{name: "fixed experience book", itemID: 690001552, level: 20, wantGain: 1000},
|
||
{name: "table experience capsule", itemID: 10158716, level: 50, wantGain: 422400},
|
||
} {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
resolution, resolveErr := resolver(test.itemID)
|
||
if resolveErr != nil || resolution.DirectExperience == nil {
|
||
t.Fatalf("resolution=%+v err=%v", resolution, resolveErr)
|
||
}
|
||
gain, gainErr := directExperienceGain(tables, test.level, 0, *resolution.DirectExperience)
|
||
if gainErr != nil || gain != test.wantGain {
|
||
t.Fatalf("gain=%d want=%d err=%v resolution=%+v", gain, test.wantGain, gainErr, resolution)
|
||
}
|
||
})
|
||
}
|
||
|
||
percent, err := resolver(690001555)
|
||
if err != nil || percent.DirectExperience == nil {
|
||
t.Fatalf("percentage experience book resolution=%+v err=%v", percent, err)
|
||
}
|
||
upper, err := tables.ThresholdToNext(20)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
lower, err := tables.ThresholdToNext(19)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
percentGain, err := directExperienceGain(tables, 20, lower, *percent.DirectExperience)
|
||
if err != nil || percentGain != (upper-lower)/100 {
|
||
t.Fatalf("percentage gain=%d want=%d err=%v", percentGain, (upper-lower)/100, err)
|
||
}
|
||
|
||
levelTable, err := resolver(10158963)
|
||
if err != nil || levelTable.DirectExperience == nil {
|
||
t.Fatalf("target-level capsule resolution=%+v err=%v", levelTable, err)
|
||
}
|
||
current, err := tables.ThresholdToNext(49)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
target, err := tables.ThresholdToNext(81)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
levelGain, err := directExperienceGain(tables, 50, current, *levelTable.DirectExperience)
|
||
if err != nil || levelGain != target-current {
|
||
t.Fatalf("target-level gain=%d want=%d err=%v", levelGain, target-current, err)
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFCoversEveryLevelUpTicket(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to audit every runtime level-up ticket")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketRequestEventServerLevelUp)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
count := 0
|
||
ranges := make(map[int64]int)
|
||
for _, itemID := range catalog.stackableIDs {
|
||
definition, resolveErr := catalog.ResolveItem(itemID)
|
||
if resolveErr != nil {
|
||
t.Fatalf("resolve stackable definition %d: %v", itemID, resolveErr)
|
||
}
|
||
if normalizeLevelUpTicketAction(definition.ActionType) == "" {
|
||
continue
|
||
}
|
||
count++
|
||
ranges[definition.LevelUpRange]++
|
||
if definition.LevelUpRange > 1 {
|
||
t.Logf("multi-level ticket item=%d range=%d name=%q path=%s", itemID, definition.LevelUpRange, definition.Name, definition.PVFPath)
|
||
}
|
||
resolution, resolveErr := resolver(int64(itemID))
|
||
if resolveErr != nil {
|
||
t.Fatalf("resolve level-up ticket %d path=%s: %v", itemID, definition.PVFPath, resolveErr)
|
||
}
|
||
if resolution.DirectExperience == nil || resolution.QuestCompletionTicket == nil ||
|
||
resolution.DirectExperience.EffectType != "[level up ticket]" ||
|
||
len(resolution.DirectExperience.EffectValues) != 1 || resolution.DirectExperience.EffectValues[0] <= 0 ||
|
||
resolution.DirectExperience.EffectValues[0] > definition.LevelUpRange ||
|
||
definition.LevelUpRange <= 0 {
|
||
t.Fatalf("level-up ticket %d definition=%+v resolution=%+v", itemID, definition, resolution)
|
||
}
|
||
}
|
||
if count == 0 {
|
||
t.Fatal("runtime PVF has no [level up ticket] documents")
|
||
}
|
||
t.Logf("covered runtime level-up tickets: %d ranges=%v", count, ranges)
|
||
}
|
||
|
||
func TestDirectExperienceGainRealPVFLevelUpTicketTargetsExactLevels(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify level-up ticket progression thresholds")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tables, err := progression.Load(context.Background(), source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, test := range []struct {
|
||
name string
|
||
level int
|
||
rangeValue int64
|
||
currentTotal uint32
|
||
}{
|
||
{name: "one level", level: 1, rangeValue: 1},
|
||
{name: "random maximum", level: 55, rangeValue: 3},
|
||
{name: "ten levels", level: 1, rangeValue: 10},
|
||
} {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
targetTotal, thresholdErr := tables.ThresholdToNext(test.level + int(test.rangeValue) - 1)
|
||
if thresholdErr != nil {
|
||
t.Fatal(thresholdErr)
|
||
}
|
||
gain, gainErr := directExperienceGain(tables, test.level, test.currentTotal, alignedcmd.DirectExperienceConsumableResolution{
|
||
EffectType: "[level up ticket]",
|
||
EffectValues: []int64{test.rangeValue},
|
||
})
|
||
if gainErr != nil {
|
||
t.Fatal(gainErr)
|
||
}
|
||
if gain != targetTotal-test.currentTotal {
|
||
t.Fatalf("level=%d range=%d gain=%d want=%d", test.level, test.rangeValue, gain, targetTotal-test.currentTotal)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFAuthorizesBothPetFeeds(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify runtime pet feeds")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, itemID := range []int64{24, 490701301} {
|
||
resolution, err := resolver(itemID)
|
||
if err != nil {
|
||
t.Fatalf("resolve feed %d: %v", itemID, err)
|
||
}
|
||
if resolution.ItemID != itemID || resolution.StackableType != "[feed]" ||
|
||
resolution.PetFeedEffectDurationSeconds != 300 ||
|
||
resolution.PetFeedSkillChargePercent != -20 ||
|
||
resolution.PetFeedSatietyDelta != 30 {
|
||
t.Fatalf("feed %d resolution = %+v", itemID, resolution)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFRecognizesQuestTicketFamilies(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify runtime quest tickets")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
wants := map[int64]string{
|
||
10164630: "[gm quest clear]",
|
||
10301410: "[lv clear]",
|
||
10303917: "[any quest clear]",
|
||
10158730: "[achievement quest clear]",
|
||
10162220: "[side quest clear]",
|
||
10006007: "[quest clear]",
|
||
10003197: "[quest clear coupon]",
|
||
10164042: "[first awaken clear]",
|
||
10303235: "[second awaken clear]",
|
||
}
|
||
for itemID, wantAction := range wants {
|
||
resolution, resolveErr := resolver(itemID)
|
||
if resolveErr != nil {
|
||
t.Fatalf("resolve ticket %d: %v", itemID, resolveErr)
|
||
}
|
||
if resolution.ItemID != itemID || resolution.QuestCompletionTicket == nil ||
|
||
resolution.QuestCompletionTicket.ActionType != wantAction {
|
||
t.Fatalf("ticket %d resolution=%+v want_action=%q", itemID, resolution, wantAction)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRealPVFCoversEveryQuestTicketActionDocument(t *testing.T) {
|
||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||
if pvfPath == "" {
|
||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to audit every runtime quest ticket")
|
||
}
|
||
source, err := platformpvf.Open(pvfPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
counts := make(map[string]int)
|
||
for _, itemID := range catalog.stackableIDs {
|
||
definition, resolveErr := catalog.ResolveItem(itemID)
|
||
if resolveErr != nil {
|
||
t.Fatalf("resolve stackable definition %d: %v", itemID, resolveErr)
|
||
}
|
||
action := normalizeQuestCompletionTicketAction(definition.ActionType)
|
||
if action == "" {
|
||
continue
|
||
}
|
||
resolution, resolveErr := resolver(int64(itemID))
|
||
if resolveErr != nil {
|
||
t.Fatalf("resolve quest ticket %d action=%q: %v", itemID, action, resolveErr)
|
||
}
|
||
if resolution.QuestCompletionTicket == nil || resolution.QuestCompletionTicket.ActionType != action {
|
||
t.Fatalf("quest ticket %d action=%q resolution=%+v", itemID, action, resolution)
|
||
}
|
||
counts[action]++
|
||
}
|
||
for _, action := range []string{
|
||
"[any quest clear]", "[achievement quest clear]", "[first awaken clear]",
|
||
"[second awaken clear]", "[quest clear]", "[quest clear coupon]",
|
||
"[lv clear]", "[gm quest clear]", "[side quest clear]",
|
||
} {
|
||
if counts[action] == 0 {
|
||
t.Fatalf("runtime PVF action %q has no covered document: counts=%v", action, counts)
|
||
}
|
||
}
|
||
t.Logf("covered runtime quest ticket documents: %v", counts)
|
||
}
|
||
|
||
func TestAlignedStackableUseResolverRecognizesEveryQuestCompletionAction(t *testing.T) {
|
||
actions := []struct {
|
||
tag string
|
||
ints string
|
||
}{
|
||
{tag: "[any quest clear]", ints: "674 649"},
|
||
{tag: "[achievement quest clear]"},
|
||
{tag: "[first awaken clear]", ints: "1 1"},
|
||
{tag: "[second awaken clear]", ints: "0 1"},
|
||
{tag: "[quest clear]"},
|
||
{tag: "[quest clear coupon]"},
|
||
{tag: "[lv clear]", ints: "90"},
|
||
{tag: "[gm quest clear]"},
|
||
{tag: "[side quest clear]"},
|
||
}
|
||
source := dungeonDropCatalogTestSource{
|
||
"monster/monster.lst": "",
|
||
"equipment/equipment.lst": "",
|
||
}
|
||
list := ""
|
||
for index, action := range actions {
|
||
itemID := 1000 + index
|
||
path := fmt.Sprintf("stackable/ticket_%d.stk", itemID)
|
||
list += fmt.Sprintf("%d `ticket_%d.stk`\n", itemID, itemID)
|
||
source[path] = "[name] `ticket`\n[stackable type] `[etc]` 1\n[minimum level] 50\n[maximum level] 90\n[action type] `" +
|
||
action.tag + "` " + action.ints +
|
||
"\n[/action type]\n[action usable place] `[village]`\n[/action usable place]\n"
|
||
}
|
||
source["stackable/stackable.lst"] = list
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for index, want := range actions {
|
||
resolution, resolveErr := resolver(int64(1000 + index))
|
||
if resolveErr != nil {
|
||
t.Fatalf("resolve %q: %v", want.tag, resolveErr)
|
||
}
|
||
if resolution.QuestCompletionTicket == nil || resolution.QuestCompletionTicket.ActionType != strings.ToLower(want.tag) ||
|
||
resolution.QuestCompletionTicket.MinimumLevel != 50 || resolution.QuestCompletionTicket.MaximumLevel != 90 ||
|
||
!resolution.QuestCompletionTicket.VillageOnly {
|
||
t.Fatalf("action %q resolution=%+v", want.tag, resolution)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 扩容券三兄弟([upgrade inventory] / [upgrade cargo] / [upgrade account cargo])。
|
||
//
|
||
// 现场 2026-10-03(服主报,测试x = char 11):490701690/490701691/490701692 使用后
|
||
// 没效果。抓包证明客户端其实发了 op430(物品栏券)、op408(金库券)和 op515 action 146
|
||
// (帐号金库工具),而服务端这三条路一条都没接。这个用例锁住解析侧的判据:种类只看 PVF
|
||
// 自己的 [action type],所以同族的 100 件道具(40/47/13)都适用,换一件券也能用。
|
||
func TestAlignedStackableUseResolverRecognizesContainerUpgradeVouchers(t *testing.T) {
|
||
source := dungeonDropCatalogTestSource{
|
||
"monster/monster.lst": "",
|
||
"equipment/equipment.lst": "",
|
||
"stackable/stackable.lst": "490701690 `etc/upgrade_inventory.stk`\n490701691 `etc/upgrade_cargo.stk`\n490701692 `etc/upgrade_account_cargo.stk`\n490701693 `etc/upgrade_no_place.stk`\n490701694 `etc/upgrade_wrong_type.stk`\n",
|
||
"stackable/etc/upgrade_inventory.stk": "[stackable type] `[etc]`\n[action type] `[upgrade inventory]`\n[/action type]\n[action usable place] `[village]`\n[/action usable place]\n",
|
||
"stackable/etc/upgrade_cargo.stk": "[stackable type] `[etc]`\n[action type] `[upgrade cargo]`\n[/action type]\n[action usable place] `[village]`\n[/action usable place]\n",
|
||
"stackable/etc/upgrade_account_cargo.stk": "[stackable type] `[etc]`\n[action type] `[upgrade account cargo]`\n[/action type]\n[action usable place] `[village]`\n[/action usable place]\n",
|
||
// 反证一:声明了 [upgrade inventory] 却没有 [action usable place] [village]。
|
||
"stackable/etc/upgrade_no_place.stk": "[stackable type] `[etc]`\n[action type] `[upgrade inventory]`\n[/action type]\n[action usable place] `[dungeon]`\n[/action usable place]\n",
|
||
// 反证二:只有 action type、stackable type 不是 [etc]。
|
||
"stackable/etc/upgrade_wrong_type.stk": "[stackable type] `[waste]`\n[action type] `[upgrade cargo]`\n[/action type]\n[action usable place] `[village]`\n[/action usable place]\n",
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := &Service{pvfItemCatalog: catalog}
|
||
want := map[int64]alignedcmd.ContainerUpgradeKind{
|
||
490701690: alignedcmd.ContainerUpgradeInventory,
|
||
490701691: alignedcmd.ContainerUpgradeCargo,
|
||
490701692: alignedcmd.ContainerUpgradeAccountCargo,
|
||
}
|
||
// 三个 opcode 都必须拿到解析器:op430/op408 的请求体里只有槽位,没有 item id,
|
||
// owner 只能靠这个解析器判"槽上那件东西是不是扩容券、是哪一种"。
|
||
for _, opcode := range []dnfenum.CmdPacket{
|
||
dnfenum.CmdPacketUseStackableAction,
|
||
dnfenum.CmdPacketUpgradeInventory,
|
||
dnfenum.CmdPacketUpgradeCargo,
|
||
} {
|
||
resolver, resolverErr := service.alignedStackableUseResolverForCommand(opcode)
|
||
if resolverErr != nil || resolver == nil {
|
||
t.Fatalf("opcode %d resolver=%v err=%v", opcode, resolver, resolverErr)
|
||
}
|
||
for itemID, wantKind := range want {
|
||
resolution, resolveErr := resolver(itemID)
|
||
upgrade := resolution.ContainerUpgrade
|
||
if resolveErr != nil || upgrade == nil || upgrade.Kind != wantKind || upgrade.ItemID != itemID ||
|
||
strings.TrimSpace(resolution.StackableType) != "[etc]" {
|
||
t.Fatalf("opcode %d item %d resolution=%+v err=%v", opcode, itemID, resolution, resolveErr)
|
||
}
|
||
}
|
||
if _, resolveErr := resolver(490701693); resolveErr == nil {
|
||
t.Fatalf("opcode %d: [upgrade inventory] without [action usable place] [village] must fail closed", opcode)
|
||
}
|
||
if _, resolveErr := resolver(490701694); resolveErr == nil {
|
||
t.Fatalf("opcode %d: [upgrade cargo] without a bare [etc] stackable must fail closed", opcode)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 城镇自施放增益药剂(史诗Buff药剂一族):`[stackable type] [expert town potion]`
|
||
// + **没有** `[action type]` + `[stat change duration]`。
|
||
//
|
||
// 现场 2026-10-03(服主报,测试x = char 11):490002458 连点 15 次全是 op44 error 17,
|
||
// 因为 useStackableWithResolvers 只认 [waste],这一族整族用不了(全库 710 件
|
||
// [expert town potion],其中约 30 件是这种"说明里写了限时增益"的)。
|
||
func TestAlignedStackableUseResolverRecognizesStatChangePotions(t *testing.T) {
|
||
source := dungeonDropCatalogTestSource{
|
||
"monster/monster.lst": "",
|
||
"equipment/equipment.lst": "",
|
||
"stackable/stackable.lst": "490002458 `event/epic_buff.stk`\n490006963 `event/ouhuang.stk`\n490005358 `event/lantern_big.stk`\n10008220 `cash/reinforce.stk`\n490000999 `event/fatigue.stk`\n490000998 `event/dungeon_only.stk`\n490000997 `event/no_place.stk`\n",
|
||
// 史诗Buff药剂:说明里写了"掉落率变为2倍" ⇒ 200%/600s。
|
||
"stackable/event/epic_buff.stk": "[name] `epic buff potion`\n[explain] `使用后, 深渊派对里的史诗装备掉落率变为2倍, 效果持续10分钟。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 600000 `myself`\n[action usable place] `[village]` `[seria room]`\n",
|
||
// 欧皇药水:说明里有"掉落率"但没有倍数 ⇒ 同样按家族基准 200。
|
||
"stackable/event/ouhuang.stk": "[name] `lucky potion`\n[explain] `使用后, 可以增加深渊派对里的史诗装备掉落率, 效果持续10分钟。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 600000 `myself`\n[action usable place] `[village]` `[seria room]`\n",
|
||
// 小灯笼的大瓶:说明里同样只有"增加…掉落率"。
|
||
"stackable/event/lantern_big.stk": "[name] `lantern big`\n[explain] `使用后, 增加在深渊派对中史诗道具掉落率, 效果持续10分钟。 效果不与其他史诗Buff药剂叠加。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 600000 `myself`\n[action usable place] `[village]` `[seria room]`\n",
|
||
// 反证一:同族但效果不是掉落率的(强化秘药一类)⇒ 能正常用,但不给倍率。
|
||
"stackable/cash/reinforce.stk": "[name] `reinforce potion`\n[explain] `使用后, 强化成功率提升。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 600000 `myself`\n[action usable place] `[village]`\n",
|
||
// 反证二:有 [action type] 的同族道具必须继续走各自的 action 通道(这里是疲劳药)。
|
||
"stackable/event/fatigue.stk": "[name] `fatigue 30`\n[explain] `疲劳值低于106时才能使用;每日限用3个`\n[stackable type] `[expert town potion]` 0\n[action type] `[add fatigue]` 30\n[/action type]\n[total usable count] 3\n[action usable place] `[village]`\n",
|
||
// 反证三:同族里没写 [action usable place] 的(全库 9 件:赛丽亚的幸运*药剂、
|
||
// 强化秘药、和平的呐喊…)也必须能正常使用 —— 地点字段是"限制",不是"证据"。
|
||
"stackable/event/no_place.stk": "[name] `seria lucky drop`\n[explain] `使用后, 可以获得深渊派对史诗道具掉落率提升Buff, 效果持续10分钟。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 600000 `myself`\n",
|
||
// 反证四:只能在地下图用的同类增益同样是这一族(客户端按同一份 PVF 判地点)。
|
||
"stackable/event/dungeon_only.stk": "[name] `dungeon only buff`\n[explain] `使用后, 增加深渊派对里的史诗装备掉落率。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 600000 `myself`\n[action usable place] `[dungeon]`\n",
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := &Service{pvfItemCatalog: catalog}
|
||
resolver, err := service.alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil || resolver == nil {
|
||
t.Fatalf("op44 resolver=%v err=%v", resolver, err)
|
||
}
|
||
|
||
// 这些夹具在 PVF 里都写 `[stat change duration] 600000`(600 秒),窗口长度一律按
|
||
// PVF 口径(2026-10-03 那次"硬编码 1 分钟"的过期验证已经按服主要求撤掉了)。
|
||
const wantSeconds = int64(600)
|
||
for _, tc := range []struct {
|
||
itemID int64
|
||
wantPercent int64
|
||
wantSeconds int64
|
||
}{
|
||
{itemID: 490002458, wantPercent: 200, wantSeconds: wantSeconds},
|
||
{itemID: 490006963, wantPercent: 200, wantSeconds: wantSeconds},
|
||
{itemID: 490005358, wantPercent: 200, wantSeconds: wantSeconds},
|
||
{itemID: 10008220, wantPercent: 0, wantSeconds: wantSeconds},
|
||
} {
|
||
resolution, resolveErr := resolver(tc.itemID)
|
||
potion := resolution.StatChangePotion
|
||
if resolveErr != nil || potion == nil ||
|
||
potion.EpicDropRatePercent != tc.wantPercent ||
|
||
potion.EffectDurationSeconds != tc.wantSeconds {
|
||
t.Fatalf("item %d resolution=%+v err=%v", tc.itemID, resolution, resolveErr)
|
||
}
|
||
}
|
||
|
||
// 反证二:有 [action type] 的道具不能被这条分支抢走(疲劳药必须仍然是 op515 的活)。
|
||
fatigue, err := resolver(490000999)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if fatigue.StatChangePotion != nil {
|
||
t.Fatalf("an [add fatigue] item must keep its own action path: %+v", fatigue.StatChangePotion)
|
||
}
|
||
// 反证三:只声明了 [action usable place] 缺字段或 [dungeon] 的同类增益**也要放行** ——
|
||
// 地点字段是"使用地点的限制",不是"这是增益药剂"的证据;客户端按同一份 PVF
|
||
// 自己判地点。全库有 9 件同族连这个字段都没写(赛丽亚的幸运*药剂、强化秘药…),
|
||
// 把它们拒掉就是服主报的"没反应"。
|
||
for _, itemID := range []int64{490000998, 490000997} {
|
||
admitted, resolveErr := resolver(itemID)
|
||
if resolveErr != nil || admitted.StatChangePotion == nil ||
|
||
admitted.StatChangePotion.EpicDropRatePercent != 200 ||
|
||
admitted.StatChangePotion.EffectDurationSeconds != wantSeconds {
|
||
t.Fatalf("item %d must still be admitted: %+v err=%v", itemID, admitted, resolveErr)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 强化/增幅成功率药剂(服主 2026-10-03:"看看增幅的秘密 强化秘药这类增加强化增幅成功率
|
||
// 的道具 也一并修复了")。判据全部来自 PVF,不看 id 不看名字:
|
||
//
|
||
// 说明文字写"强化成功率"(强化秘药那一支) → reinforce + 固定常量 10
|
||
// [stackable type] `[item upgrad buff usual upgrade]` → reinforce(强化的奥秘)
|
||
// [stackable type] `[item upgrad buff amplify upgrade]` → amplify(永恒的增幅的秘密)
|
||
// buff 表里有 `item_usual_upgrade_success_rate` 候选 → 按权重掷点(赛丽亚 10/20/30)
|
||
//
|
||
// 同时钉住"两族互不串味":史诗掉落率药剂不能被判成强化药剂,反之亦然。
|
||
func TestAlignedStackableUseResolverRecognizesUpgradeRatePotions(t *testing.T) {
|
||
source := dungeonDropCatalogTestSource{
|
||
"monster/monster.lst": "",
|
||
"equipment/equipment.lst": "",
|
||
"stackable/stackable.lst": "490002458 `event/epic_buff.stk`\n10008220 `cash/seria_reinforce.stk`\n" +
|
||
"490007878 `event/mystery_upgrade.stk`\n490001534 `event/eternal_amplify.stk`\n" +
|
||
"490004594 `professional/secret_reinforcement.stk`\n",
|
||
// 史诗Buff药剂:只影响掉落率,不该被判成强化药剂。
|
||
"stackable/event/epic_buff.stk": "[name] `epic buff potion`\n[explain] `使用后, 深渊派对里的史诗装备掉落率变为2倍, 效果持续10分钟。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 600000 `myself`\n",
|
||
// 赛丽亚的幸运强化药剂:概率表在 etc 里,服务端掷点。
|
||
"stackable/cash/seria_reinforce.stk": "[name] `seria lucky reinforce`\n[explain] `使用后, 可以获得强化成功率提升Buff, 效果持续10分钟。 将会从下级、 中级、 上级Buff中随机发放1种。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 600000 `myself`\n",
|
||
// 强化的奥秘:专用 stackable type,PVF 里没有数字。
|
||
"stackable/event/mystery_upgrade.stk": "[name] `mystery of upgrade`\n[explain] `使用后, 可以提升强化成功率, 效果持续30分钟。`\n[stackable type] `[item upgrad buff usual upgrade]` 1000\n[stat change duration] 1800000 `myself`\n",
|
||
// 永恒的增幅的秘密:增幅版,同样没有数字。
|
||
"stackable/event/eternal_amplify.stk": "[name] `eternal secret amplify`\n[explain] `使用后, 可以增加增幅成功几率, 效果持续30分钟。`\n[stackable type] `[item upgrad buff amplify upgrade]` 1000\n[stat change duration] 1800000 `myself`\n",
|
||
// 强化秘药:副职业 [expert town potion],说明里只有"提升强化成功率"。
|
||
"stackable/professional/secret_reinforcement.stk": "[name] `secret reinforcement`\n[explain] `使用后, 可以提升强化成功率, 效果持续30分钟。`\n[stackable type] `[expert town potion]` 0\n[stat change duration] 1800000 `myself`\n",
|
||
"etc/variousbuffstackablelist.etc": "[index]\n10008220 \n[probability buff list]\n1097 60 1098 30 1099 10 \n[/index]\n" +
|
||
"[index]\n490002458 \n[probability buff list]\n1136 100 \n[/index]\n",
|
||
"etc/variousbufflist.etc": "[index]\n1097 \n[icon index]\n3085 \n[tooltip text]\n`强化成功率提升 (下级)` \n[effect]\n[name]\n`item_usual_upgrade_success_rate` \n[param]\n10 \n[/param]\n[/effect]\n[/index]\n" +
|
||
"[index]\n1098 \n[icon index]\n3080 \n[tooltip text]\n`强化成功率提升 (中级)` \n[effect]\n[name]\n`item_usual_upgrade_success_rate` \n[param]\n20 \n[/param]\n[/effect]\n[/index]\n" +
|
||
"[index]\n1099 \n[icon index]\n3075 \n[tooltip text]\n`强化成功率提升 (上级)` \n[effect]\n[name]\n`item_usual_upgrade_success_rate` \n[param]\n30 \n[/param]\n[/effect]\n[/index]\n" +
|
||
"[index]\n1136 \n[icon index]\n183 \n[tooltip text]\n`史诗道具掉落率提升` \n[effect]\n[name]\n`epic_rarity_item_drop_rate` \n[param]\n100 \n[/param]\n[/effect]\n[/index]\n",
|
||
}
|
||
catalog, err := newPVFDungeonDropCatalog(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := &Service{pvfItemCatalog: catalog}
|
||
resolver, err := service.alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
|
||
if err != nil || resolver == nil {
|
||
t.Fatalf("op44 resolver=%v err=%v", resolver, err)
|
||
}
|
||
|
||
for _, tc := range []struct {
|
||
itemID int64
|
||
wantKind string
|
||
wantPercent int64
|
||
wantRolls int
|
||
wantDropRate int64
|
||
}{
|
||
{itemID: 490004594, wantKind: currentUpgradeRatePotionReinforce, wantPercent: 10},
|
||
{itemID: 490007878, wantKind: currentUpgradeRatePotionReinforce, wantPercent: 10},
|
||
{itemID: 490001534, wantKind: currentUpgradeRatePotionAmplify, wantPercent: 10},
|
||
{itemID: 10008220, wantKind: currentUpgradeRatePotionReinforce, wantRolls: 3},
|
||
{itemID: 490002458, wantKind: "", wantDropRate: 0},
|
||
} {
|
||
resolution, resolveErr := resolver(tc.itemID)
|
||
potion := resolution.StatChangePotion
|
||
if resolveErr != nil || potion == nil {
|
||
t.Fatalf("item %d resolution=%+v err=%v", tc.itemID, resolution, resolveErr)
|
||
}
|
||
if potion.UpgradeKind != tc.wantKind || potion.UpgradeBonusPercent != tc.wantPercent ||
|
||
len(potion.UpgradeRolls) != tc.wantRolls {
|
||
t.Fatalf("item %d upgrade kind=%q bonus=%d rolls=%+v (want kind=%q bonus=%d rolls=%d)",
|
||
tc.itemID, potion.UpgradeKind, potion.UpgradeBonusPercent, potion.UpgradeRolls,
|
||
tc.wantKind, tc.wantPercent, tc.wantRolls)
|
||
}
|
||
// 掉落率那一族不能被强化药剂污染:反过来也一样(490002458 只有 BuffRolls)。
|
||
if tc.itemID == 490002458 {
|
||
if len(potion.BuffRolls) != 1 || potion.BuffRolls[0].BuffID != 1136 ||
|
||
potion.BuffRolls[0].BonusPercent != 100 {
|
||
t.Fatalf("epic drop potion rolls=%+v", potion.BuffRolls)
|
||
}
|
||
if len(potion.UpgradeRolls) != 0 || potion.UpgradeBonusPercent != 0 {
|
||
t.Fatalf("epic drop potion must not look like an upgrade potion: %+v", potion)
|
||
}
|
||
continue
|
||
}
|
||
if len(potion.BuffRolls) != 0 || potion.EpicDropRatePercent != tc.wantDropRate {
|
||
t.Fatalf("item %d must not carry drop-rate data: %+v", tc.itemID, potion)
|
||
}
|
||
}
|
||
|
||
// 概率表逐档核对:赛丽亚的幸运强化药剂 = 1097(60,+10) / 1098(30,+20) / 1099(10,+30)。
|
||
resolution, err := resolver(10008220)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := []alignedcmd.StatChangePotionWeightedRoll{
|
||
{BuffID: 1097, Weight: 60, BonusPercent: 10},
|
||
{BuffID: 1098, Weight: 30, BonusPercent: 20},
|
||
{BuffID: 1099, Weight: 10, BonusPercent: 30},
|
||
}
|
||
if len(resolution.StatChangePotion.UpgradeRolls) != len(want) {
|
||
t.Fatalf("seria rolls=%+v", resolution.StatChangePotion.UpgradeRolls)
|
||
}
|
||
for i := range want {
|
||
if resolution.StatChangePotion.UpgradeRolls[i] != want[i] {
|
||
t.Fatalf("seria roll %d=%+v want %+v", i, resolution.StatChangePotion.UpgradeRolls[i], want[i])
|
||
}
|
||
}
|
||
}
|