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

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

530 lines
22 KiB
Go

package dnfbridge
import (
"fmt"
"os"
"sort"
"strings"
"testing"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func newMagicBoxTestCatalog(t *testing.T) (*pvfDungeonDropCatalog, dungeonDropCatalogTestSource) {
t.Helper()
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"equipment/equipment.lst": "101600024 `avatar/weapon.equ`\n",
"stackable/stackable.lst": "24 `cash/creature/creature_food.stk`\n10007368 `ect/chn_random/amazingbox.stk`\n10007367 `ect/chn_random/hammer.stk`\n2001 `booster/boosterbox.stk`\n2002 `other/plain.stk`\n2003 `booster/legacy_avatar_box.stk`\n2004 `booster/upgradable_legacy_box.stk`\n2600014 `potion/ptn.stk`\n2682272 `other/reward2682272.stk`\n3000 `other/reward3000.stk`\n3001 `other/reward3001.stk`\n3002 `other/reward3002.stk`\n",
"stackable/ect/chn_random/amazingbox.stk": "[name] `Amazing Box`\n[stackable type] `[random upgradable legacy]`\n[RANDOMBOX]\n[int data]\n2600014 2 0 2600014 18000 2 0 2600014 5000 2 0 2600014 500 2 0 2600014 2 2 0\n[/int data]\n[int data]\n2682272 1 0 2682272 50000 1 0\n[/int data]\n[sealing removal item]\n2 10007367 1 10007368 1\n[/sealing removal item]\n[/RANDOMBOX]\n",
"stackable/ect/chn_random/hammer.stk": "[name] `Hammer`\n[stackable type] `[material]`\n",
"stackable/booster/boosterbox.stk": "[name] `Booster Box`\n[stackable type] `[booster]`\n[booster info]\n[etc]\n2 3001 100 1 3002 50 2\n[/etc]\n[/booster info]\n[need material] `10007367 1`\n",
"stackable/booster/legacy_avatar_box.stk": "[name] `Legacy Avatar Box`\n[stackable type] `[booster]`\n[booster info]\n[avatar]\n1 101600024 100000 1 3 2\n[/avatar]\n[/booster info]\n",
"stackable/booster/upgradable_legacy_box.stk": "[name] `Old Event Box`\n[stackable type] `[upgradable legacy]`\n[int data]\n3000 0 1 3001 217 1 3002 1430 2\n[/int data]\n[need material]\n10007367 1\n[/need material]\n",
"stackable/other/plain.stk": "[name] `Plain`\n[stackable type] `[material]`\n",
"stackable/other/reward2682272.stk": "[name] `Reward 2682272`\n[stackable type] `[material]`\n",
"stackable/other/reward3000.stk": "[name] `Reward 3000`\n[stackable type] `[material]`\n",
"stackable/other/reward3001.stk": "[name] `Reward 3001`\n[stackable type] `[material]`\n",
"stackable/other/reward3002.stk": "[name] `Reward 3002`\n[stackable type] `[material]`\n",
"stackable/potion/ptn.stk": "[name] `Potion`\n[stackable type] `[consumption]`\n[stack limit] 100\n[usable period] 30\n[attach type] `[sealing]`\n",
"stackable/cash/creature/creature_food.stk": "[name] `Creature Food`\n[stackable type] `[feed]`\n[stack limit] 1000\n",
"equipment/avatar/weapon.equ": "[name] `Legacy Weapon Avatar`\n[equipment type] `[weapon avatar]` 0\n[attach type] `[trade]`\n",
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
return catalog, source
}
// [booster info] 里的 `[charactor]` 是按职业分组的(首 token 是职业标签,
// 后面是 minLevel/maxLevel/drawCount,再接普通的 (item, weight, count) 三元组)。
// 解析器必须把职业门读出来,否则开箱时无从过滤 —— 现场就是鬼剑士开豪华婚礼
// 化妆箱把全职业的时装一起领走(2026-10-04)。
func TestParseMagicBoxBoosterCharactorGroupsCarryJobGate(t *testing.T) {
// 直接解析一段 PVF 文本,避免改动共享 fixture 影响其它用例。
doc, err := dnfpvf.Parse("box.stk", "[booster info]\n"+
"[charactor]\n`[swordman]` 1 99 1 3001 100000 1\n[/charactor]\n"+
"[charactor]\n`[at swordman]` 30 99 2 3002 100000 1\n[/charactor]\n"+
"[etc]\n1 3000 100000 2\n[/etc]\n"+
"[/booster info]")
if err != nil {
t.Fatal(err)
}
groups := parseMagicBoxBoosterGroups(doc)
if len(groups) != 3 {
t.Fatalf("分组数=%d,应为 3: %+v", len(groups), groups)
}
if groups[0].Job != "[swordman]" || groups[0].MinLevel != 1 || groups[0].MaxLevel != 99 || groups[0].DrawCount != 1 {
t.Fatalf("第一组职业门=%+v", groups[0])
}
if groups[1].Job != "[at swordman]" || groups[1].MinLevel != 30 || groups[1].DrawCount != 2 {
t.Fatalf("第二组职业门=%+v", groups[1])
}
// 普通 [etc] 组不带职业门。
if groups[2].Job != "" || groups[2].MinLevel != 0 || groups[2].MaxLevel != 0 {
t.Fatalf("etc 组不应带职业门: %+v", groups[2])
}
}
func TestResolveCurrentMagicBoxHistoricalAvatarFiveFieldRow(t *testing.T) {
catalog, source := newMagicBoxTestCatalog(t)
definition, err := resolveCurrentBoosterDefinition(catalog, 2003, currentBoosterRequestRandom)
if err != nil {
t.Fatalf("resolve historical avatar box: %v", err)
}
amounts, options, gold, err := resolveCurrentBoosterRewardAmounts(
definition,
currentBoosterOpenRequest{Kind: currentBoosterRequestRandom},
func(limit int64) (int64, error) { return 0, nil },
)
if err != nil {
t.Fatalf("roll historical avatar box: %v", err)
}
if len(amounts) != 1 || amounts[101600024] != 1 || amounts[1] != 0 || options[101600024] != 2 || gold != 0 {
t.Fatalf("historical avatar amounts=%v options=%v gold=%d", amounts, options, gold)
}
rewards, err := resolveCurrentBoosterRewards(
catalog,
amounts,
options,
dnfrepo.ItemStack{ItemID: 2003, Count: 1},
time.Now().UTC(),
)
if err != nil {
t.Fatalf("resolve historical avatar reward: %v", err)
}
if len(rewards) != 1 || rewards[0].Definition.ItemID != 101600024 || rewards[0].Count != 1 || rewards[0].Option != 2 || rewards[0].Placement != currentBoosterRewardAvatar {
t.Fatalf("historical avatar rewards=%+v", rewards)
}
inventory := dnfrepo.InventoryRecord{Slots: make(map[string]dnfrepo.ItemStack)}
// 24 = 最大扩展,使落点范围不被夹取,等价于本用例原本的落点语义。
keys, err := grantCurrentBoosterReward(&inventory, source, rewards[0], 24)
if err != nil {
t.Fatalf("grant historical avatar reward: %v", err)
}
if len(keys) != 1 || keys[0] != "1:0" {
t.Fatalf("historical avatar keys=%v", keys)
}
avatar := inventory.Slots["1:0"]
if avatar.ItemID != 101600024 || avatar.Count != 1 {
t.Fatalf("historical avatar stack=%+v", avatar)
}
}
func TestRealPVFHistoricalAvatarBoosterUsesFiveFieldRow(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the historical avatar booster")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatalf("load real item catalog: %v", err)
}
definition, err := resolveCurrentBoosterDefinition(catalog, 10007317, currentBoosterRequestRandom)
if err != nil {
t.Fatalf("resolve historical avatar booster 10007317: %v", err)
}
if len(definition.Random.Groups) != 1 || definition.Random.Groups[0].DrawCount != 1 || len(definition.Random.Groups[0].Entries) != 1 {
t.Fatalf("historical avatar booster groups=%+v", definition.Random.Groups)
}
entry := definition.Random.Groups[0].Entries[0]
if entry.ItemID != 101600024 || entry.Weight != 100000 || entry.Count != 1 || entry.Option != 0 {
t.Fatalf("historical avatar booster entry=%+v", entry)
}
}
func TestRealPVFUpgradableLegacyGiftBoxUsesNativeMagicBoxPool(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the historical upgradable-legacy gift box")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
resolution, err := resolveCurrentMagicBox(catalog, archive, 10002952)
if err != nil {
t.Fatal(err)
}
if resolution.Kind != "random" || len(resolution.Groups) != 1 || len(resolution.Groups[0].Entries) < 100 {
t.Fatalf("rainbow gift box resolution=%+v", resolution)
}
if resolution.MaterialItemID != 10002941 || resolution.MaterialCountPerUse != 1 {
t.Fatalf("rainbow gift box material=%d x%d", resolution.MaterialItemID, resolution.MaterialCountPerUse)
}
}
func TestRealPVFAllNativeLotteryJarPoolsResolve(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to audit every native lottery jar")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
jarCount := 0
rewardCount := 0
for _, itemID := range catalog.stackableIDs {
source, resolveErr := catalog.ResolveItem(itemID)
if resolveErr != nil {
t.Fatalf("resolve source item=%d: %v", itemID, resolveErr)
}
if !isCurrentLotteryJarType(normalizeDungeonDropStackableType(source.StackableType)) {
continue
}
jarCount++
definition, resolveErr := resolveCurrentBoosterDefinition(catalog, itemID, currentBoosterRequestLottery)
if resolveErr != nil {
t.Fatalf("resolve lottery jar item=%d name=%q path=%s: %v", itemID, source.Name, source.PVFPath, resolveErr)
}
document, documentErr := parseDungeonCardPVFDocument(catalog.source, source.PVFPath)
if documentErr != nil {
t.Fatalf("read lottery jar item=%d path=%s: %v", itemID, source.PVFPath, documentErr)
}
openingItemID, openingCount := resolveMagicBoxSealingRemovalMaterial(document, int64(itemID))
if definition.MaterialItemID != 0 || definition.MaterialCount != 0 ||
definition.Random.MaterialItemID != openingItemID || definition.Random.MaterialCountPerUse != openingCount {
t.Fatalf(
"lottery jar item=%d name=%q mixed shop/opening material: definition=%d x%d random=%d x%d want sealing=%d x%d",
itemID,
source.Name,
definition.MaterialItemID,
definition.MaterialCount,
definition.Random.MaterialItemID,
definition.Random.MaterialCountPerUse,
openingItemID,
openingCount,
)
}
for groupIndex, group := range definition.Random.Groups {
if len(group.Entries) == 0 {
t.Fatalf("lottery jar item=%d group=%d is empty", itemID, groupIndex)
}
for _, entry := range group.Entries {
if entry.Gold {
if entry.ItemID != 0 || entry.Count <= 0 {
t.Fatalf("lottery jar item=%d has invalid gold reward=%+v", itemID, entry)
}
rewardCount++
continue
}
if entry.ItemID <= 0 || entry.ItemID > int64(^uint32(0)) {
t.Fatalf("lottery jar item=%d has invalid reward=%d", itemID, entry.ItemID)
}
if _, rewardErr := catalog.ResolveItem(uint32(entry.ItemID)); rewardErr != nil {
t.Fatalf("lottery jar item=%d reward=%d is not mapped: %v", itemID, entry.ItemID, rewardErr)
}
rewardCount++
}
}
}
if jarCount == 0 || rewardCount == 0 {
t.Fatalf("native lottery jar audit jars=%d rewards=%d", jarCount, rewardCount)
}
t.Logf("native lottery jar audit jars=%d mapped_reward_rows=%d", jarCount, rewardCount)
}
func TestRealPVFAllNamedPocketJarRoutesResolve(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to audit every named pocket jar route")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
typeCounts := make(map[string]int)
unsupported := make([]string, 0)
routeErrors := make([]string, 0)
jarCount := 0
for _, itemID := range catalog.stackableIDs {
source, resolveErr := catalog.ResolveItem(itemID)
if resolveErr != nil {
t.Fatalf("resolve source item=%d: %v", itemID, resolveErr)
}
if !strings.Contains(source.Name, "袖珍罐") {
continue
}
jarCount++
stackableType := normalizeDungeonDropStackableType(source.StackableType)
typeCounts[stackableType]++
switch stackableType {
case "[upgradable legacy]", "[random upgradable legacy]":
_, resolveErr = resolveCurrentBoosterDefinition(catalog, itemID, currentBoosterRequestLottery)
case "[booster]", "[cera booster]", "[booster random]":
_, resolveErr = resolveCurrentBoosterDefinition(catalog, itemID, currentBoosterRequestRandom)
case "[booster selection]":
_, resolveErr = resolveCurrentBoosterDefinition(catalog, itemID, currentBoosterRequestSelection)
default:
unsupported = append(unsupported, fmt.Sprintf("%d %q type=%q path=%s", itemID, source.Name, stackableType, source.PVFPath))
continue
}
if resolveErr != nil {
routeErrors = append(routeErrors, fmt.Sprintf("%d %q type=%q path=%s: %v", itemID, source.Name, stackableType, source.PVFPath, resolveErr))
}
}
sort.Strings(unsupported)
sort.Strings(routeErrors)
if len(routeErrors) != 0 {
t.Fatalf("named pocket jars with a broken current route (%d):\n%s", len(routeErrors), strings.Join(routeErrors, "\n"))
}
if len(unsupported) != 0 {
// Name matching also finds fragments, keys, services and twelve
// retired 2010-2014 [etc]/[legacy] definitions. They have no current
// opener type and must not be promoted into op27 based on their name.
t.Logf("named pocket-jar terms without a current opener (%d):\n%s", len(unsupported), strings.Join(unsupported, "\n"))
}
if jarCount == 0 {
t.Fatal("named pocket jar audit found no items")
}
t.Logf("named pocket jar audit jars=%d routes=%v", jarCount, typeCounts)
}
func TestResolveCurrentBoosterLotteryIgnoresLegacyShopNeedMaterial(t *testing.T) {
catalog, _ := newMagicBoxTestCatalog(t)
lottery, err := resolveCurrentBoosterDefinition(catalog, 2004, currentBoosterRequestLottery)
if err != nil {
t.Fatal(err)
}
if lottery.MaterialItemID != 0 || lottery.MaterialCount != 0 ||
lottery.Random.MaterialItemID != 0 || lottery.Random.MaterialCountPerUse != 0 {
t.Fatalf("lottery shop material leaked into opening cost: %+v", lottery)
}
magicBox, err := resolveCurrentMagicBox(catalog, catalog.source, 2004)
if err != nil {
t.Fatal(err)
}
if magicBox.MaterialItemID != 10007367 || magicBox.MaterialCountPerUse != 1 {
t.Fatalf("non-lottery magic-box material=%d x%d", magicBox.MaterialItemID, magicBox.MaterialCountPerUse)
}
randomLegacy, err := resolveCurrentBoosterDefinition(catalog, 10007368, currentBoosterRequestLottery)
if err != nil {
t.Fatal(err)
}
if randomLegacy.MaterialItemID != 0 || randomLegacy.MaterialCount != 0 ||
randomLegacy.Random.MaterialItemID != 10007367 || randomLegacy.Random.MaterialCountPerUse != 1 {
t.Fatalf("lottery opening key was not preserved: %+v", randomLegacy)
}
}
func TestResolveCurrentBoosterRandomIgnoresShopNeedMaterial(t *testing.T) {
catalog, _ := newMagicBoxTestCatalog(t)
booster, err := resolveCurrentBoosterDefinition(catalog, 2001, currentBoosterRequestRandom)
if err != nil {
t.Fatal(err)
}
if booster.MaterialItemID != 0 || booster.MaterialCount != 0 ||
booster.Random.MaterialItemID != 0 || booster.Random.MaterialCountPerUse != 0 {
t.Fatalf("booster shop material leaked into opening cost: %+v", booster)
}
}
func TestResolveCurrentMagicBoxPrunesUnlistedLegacyRewardRows(t *testing.T) {
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"equipment/equipment.lst": "",
"stackable/stackable.lst": "7000 `legacy/jar.stk`\n7001 `legacy/reward.stk`\n",
"stackable/legacy/jar.stk": "[name] `Legacy Jar`\n[stackable type] `[upgradable legacy]`\n[int data]\n7001 10 1 7999 1000 1\n[/int data]\n",
"stackable/legacy/reward.stk": "[name] `Mapped Reward`\n[stackable type] `[material]`\n",
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
resolution, err := resolveCurrentMagicBox(catalog, source, 7000)
if err != nil {
t.Fatal(err)
}
if len(resolution.Groups) != 1 || len(resolution.Groups[0].Entries) != 1 || resolution.Groups[0].Entries[0].ItemID != 7001 {
t.Fatalf("filtered legacy jar=%+v", resolution.Groups)
}
}
func TestResolveCurrentMagicBoxUpgradableLegacyGoldPseudoReward(t *testing.T) {
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"equipment/equipment.lst": "",
"stackable/stackable.lst": "7614 `event/gold_jar.stk`\n",
"stackable/event/gold_jar.stk": "[name] `Gold Jar`\n[stackable type] `[upgradable legacy]`\n[int data]\n0 1 1 0 2 10000\n[/int data]\n",
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
definition, err := resolveCurrentBoosterDefinition(catalog, 7614, currentBoosterRequestLottery)
if err != nil {
t.Fatal(err)
}
if len(definition.Random.Groups) != 1 || len(definition.Random.Groups[0].Entries) != 2 || !definition.Random.Groups[0].Entries[0].Gold {
t.Fatalf("gold jar groups=%+v", definition.Random.Groups)
}
amounts, options, gold, err := resolveCurrentBoosterRewardAmounts(
definition,
currentBoosterOpenRequest{Kind: currentBoosterRequestLottery},
func(limit int64) (int64, error) {
if limit != 3 {
t.Fatalf("roll limit=%d", limit)
}
return 2, nil
},
)
if err != nil || len(amounts) != 0 || len(options) != 0 || gold != 10000 {
t.Fatalf("gold jar amounts=%v options=%v gold=%d err=%v", amounts, options, gold, err)
}
}
func TestResolveCurrentMagicBoxPetFeedTargetsPetConsumableSegment(t *testing.T) {
catalog, source := newMagicBoxTestCatalog(t)
item, err := resolveCurrentMagicBoxRewardItem(catalog, source, 24)
if err != nil {
t.Fatal(err)
}
if item.ItemID != 24 || item.TargetListType != currentPetInventoryListType ||
item.SlotStart != currentCeraShopPetConsumableSlotStart ||
item.SlotEnd != currentCeraShopPetConsumableSlotEnd ||
item.StackLimit != 1000 {
t.Fatalf("pet feed reward=%+v", item)
}
}
func TestResolveCurrentMagicBoxRandomLegacy(t *testing.T) {
catalog, source := newMagicBoxTestCatalog(t)
resolution, err := resolveCurrentMagicBox(catalog, source, 10007368)
if err != nil {
t.Fatalf("resolve error = %v", err)
}
if resolution.Kind != "random" {
t.Fatalf("kind = %q, want random", resolution.Kind)
}
if len(resolution.Groups) != 2 {
t.Fatalf("groups = %d, want 2", len(resolution.Groups))
}
first := resolution.Groups[0]
if first.DrawCount != 1 || len(first.Entries) != 4 {
t.Fatalf("group[0] = %+v, want 4 entries DrawCount 1", first)
}
if first.Entries[0].ItemID != 2600014 || first.Entries[0].Weight != 18000 || first.Entries[0].Count != 2 {
t.Fatalf("group[0] entry[0] = %+v", first.Entries[0])
}
if first.Entries[3].Weight != 2 {
t.Fatalf("group[0] entry[3] = %+v, want weight 2 preserved", first.Entries[3])
}
second := resolution.Groups[1]
// Seven ints: exactly one quad entry after the skipped leading triple.
if len(second.Entries) != 1 || second.Entries[0].ItemID != 2682272 || second.Entries[0].Weight != 50000 || second.Entries[0].Count != 1 {
t.Fatalf("group[1] = %+v", second)
}
if resolution.MaterialItemID != 10007367 || resolution.MaterialCountPerUse != 1 {
t.Fatalf("material = %d x%d, want 10007367 x1", resolution.MaterialItemID, resolution.MaterialCountPerUse)
}
if resolution.BoxPVFPath != "stackable/ect/chn_random/amazingbox.stk" {
t.Fatalf("path = %q", resolution.BoxPVFPath)
}
}
func TestResolveCurrentMagicBoxUpgradableLegacyTopLevelTriples(t *testing.T) {
catalog, source := newMagicBoxTestCatalog(t)
resolution, err := resolveCurrentMagicBox(catalog, source, 2004)
if err != nil {
t.Fatal(err)
}
if resolution.Kind != "random" || len(resolution.Groups) != 1 || resolution.Groups[0].DrawCount != 1 {
t.Fatalf("resolution=%+v", resolution)
}
entries := resolution.Groups[0].Entries
if len(entries) != 2 || entries[0].ItemID != 3001 || entries[0].Weight != 217 || entries[0].Count != 1 ||
entries[1].ItemID != 3002 || entries[1].Weight != 1430 || entries[1].Count != 2 {
t.Fatalf("entries=%+v", entries)
}
if resolution.MaterialItemID != 10007367 || resolution.MaterialCountPerUse != 1 {
t.Fatalf("material=%d x%d", resolution.MaterialItemID, resolution.MaterialCountPerUse)
}
}
func TestResolveCurrentMagicBoxBoosterFamily(t *testing.T) {
catalog, source := newMagicBoxTestCatalog(t)
resolution, err := resolveCurrentMagicBox(catalog, source, 2001)
if err != nil {
t.Fatalf("resolve error = %v", err)
}
if resolution.Kind != "random" || len(resolution.Groups) != 1 {
t.Fatalf("resolution = %+v", resolution)
}
group := resolution.Groups[0]
if group.DrawCount != 2 {
t.Fatalf("draw count = %d, want leading DrawCount 2", group.DrawCount)
}
if len(group.Entries) != 2 || group.Entries[0].ItemID != 3001 || group.Entries[0].Count != 1 || group.Entries[1].ItemID != 3002 || group.Entries[1].Count != 2 {
t.Fatalf("entries = %+v", group.Entries)
}
if resolution.MaterialItemID != 10007367 || resolution.MaterialCountPerUse != 1 {
t.Fatalf("material = %d x%d, want [need material] fallback", resolution.MaterialItemID, resolution.MaterialCountPerUse)
}
}
func TestResolveCurrentMagicBoxUnsupportedAndUnknown(t *testing.T) {
catalog, source := newMagicBoxTestCatalog(t)
resolution, err := resolveCurrentMagicBox(catalog, source, 2002)
if err != nil {
t.Fatalf("resolve error = %v", err)
}
if resolution.Kind != "" {
t.Fatalf("plain stackable kind = %q, want empty", resolution.Kind)
}
resolution, err = resolveCurrentMagicBox(catalog, source, 9999)
if err != nil {
t.Fatalf("resolve error = %v", err)
}
if resolution.Kind != "" {
t.Fatalf("unknown item kind = %q, want empty", resolution.Kind)
}
}
func TestResolveCurrentMagicBoxRewardItemMetadata(t *testing.T) {
catalog, source := newMagicBoxTestCatalog(t)
before := time.Now().UTC()
item, err := resolveCurrentMagicBoxRewardItem(catalog, source, 2600014)
if err != nil {
t.Fatalf("resolve error = %v", err)
}
if item.ItemID != 2600014 || item.Kind != "stackable" || item.StackLimit != 100 || !item.Seal || item.UsablePeriodDays != 30 {
t.Fatalf("reward item = %+v", item)
}
minExpire := time.Unix(before.Unix()+30*86400, 0).UTC()
maxExpire := time.Unix(time.Now().UTC().Unix()+30*86400, 0).UTC()
if item.ExpireAt.Before(minExpire) || item.ExpireAt.After(maxExpire) {
t.Fatalf("reward expiration=%s outside %s..%s", item.ExpireAt, minExpire, maxExpire)
}
if item.SlotStart != 65 || item.SlotEnd != 120 {
t.Fatalf("slot range = %d-%d, want 65-120", item.SlotStart, item.SlotEnd)
}
if item.PVFPath != "stackable/potion/ptn.stk" {
t.Fatalf("path = %q", item.PVFPath)
}
}