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

757 lines
31 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"os"
"reflect"
"sort"
"strconv"
"strings"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestParseCurrentCeraPackageOpenRequestUsesExactCurrentEXEBody(t *testing.T) {
fixedRewards, err := parseCurrentCeraPackageOpenRequest([]byte{69, 0, 0})
if err != nil || fixedRewards.SourceSlot != 69 || len(fixedRewards.Choices) != 0 {
t.Fatalf("fixed-reward request=%+v err=%v", fixedRewards, err)
}
body := make([]byte, 13)
binary.LittleEndian.PutUint16(body[0:2], 75)
body[2] = 2
binary.LittleEndian.PutUint32(body[3:7], 700)
body[7] = 9
binary.LittleEndian.PutUint32(body[8:12], 701)
body[12] = 3
request, err := parseCurrentCeraPackageOpenRequest(body)
if err != nil {
t.Fatal(err)
}
want := currentCeraPackageOpenRequest{
SourceSlot: 75,
Choices: []currentCeraPackageChoice{
{ItemID: 700, Option: 9},
{ItemID: 701, Option: 3},
},
}
if !reflect.DeepEqual(request, want) {
t.Fatalf("request=%+v want=%+v", request, want)
}
for _, malformed := range [][]byte{
nil,
append(append([]byte(nil), body...), 0),
{75, 0, 1, 0, 0, 0, 0, 9},
{75, 0, 2, 188, 2, 0, 0, 9, 188, 2, 0, 0, 3},
} {
if _, err := parseCurrentCeraPackageOpenRequest(malformed); err == nil {
t.Fatalf("malformed body accepted: %x", malformed)
}
}
}
func TestResolveCurrentCeraPackageAllowsFixedRewardsWithoutAvatarChoices(t *testing.T) {
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "500 `cash/fixed_package.stk`\n600 `material/fixed_reward.stk`\n",
"equipment/equipment.lst": "",
"stackable/cash/fixed_package.stk": "[stackable type]\n`[usable cera package]`\n[stack limit]\n1\n[expiration date]\n`2030-12-31 23:59:59`\n[package data]\n600 3\n[/package data]\n",
"stackable/material/fixed_reward.stk": "[stackable type]\n`[material]`\n[stack limit]\n999\n",
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
definition, err := resolveCurrentCeraPackageDefinition(catalog, 500)
if err != nil {
t.Fatal(err)
}
if len(definition.Rewards) != 1 || definition.Rewards[0].Definition.ItemID != 600 ||
definition.Rewards[0].Count != 3 || len(definition.AvatarItemIDs) != 0 {
t.Fatalf("fixed package=%+v", definition)
}
if options, err := validateCurrentCeraPackageChoices(definition, nil); err != nil || len(options) != 0 {
t.Fatalf("fixed package options=%v err=%v", options, err)
}
if _, err := validateCurrentCeraPackageChoices(definition, []currentCeraPackageChoice{{ItemID: 600}}); !errors.Is(err, errCurrentCeraPackageChoiceInvalid) {
t.Fatalf("unexpected fixed-package choice err=%v", err)
}
}
func TestCurrentCeraPackageLegacyRouteAtomicallyConsumesAndGrantsPVFRewards(t *testing.T) {
catalog := mustCurrentCeraPackageTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
sourceStack := dnfrepo.ItemStack{ItemID: 500, Count: 2}
sourceEntry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, 75, sourceStack)
sourceStack.RawEntry = append([]byte(nil), sourceEntry.data[:]...)
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:75": sourceStack},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-1",
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
pvfItemCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
session := &gameSession{conn: connection, connID: "cera-package-test", selectedCharacterID: 19}
requestBody := currentCeraPackageTestRequestBody(75, currentCeraPackageChoice{ItemID: 700, Option: 9})
before := time.Now().UTC()
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketOpenCerapackage),
requestBody,
); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification || ack.Header.MsgID != uint16(dnfenum.CmdPacketOpenCerapackage) ||
!bytes.Equal(ack.Body, []byte{1, 75, 0}) {
t.Fatalf("op518 ACK header=%+v body=%x", ack.Header, ack.Body)
}
rewardNotice, rest := splitGameServerUpperPacket(t, rest)
wantRewardNotice := append([]byte{1}, buildCurrentBoosterSuccessBody(currentBoosterCommitResult{
SourceItemID: 500,
SourceSlot: 75,
SourceRemaining: 1,
Rewards: []currentBoosterGrantedReward{
{ItemID: 700, Count: 1},
{ItemID: 600, Count: 3},
},
})...)
if rewardNotice.Header.Classification != dnfproto.DefaultChannelClassification ||
rewardNotice.Header.MsgID != uint16(dnfenum.CmdPacketUseBoosterItem) ||
!bytes.Equal(rewardNotice.Body, wantRewardNotice) {
t.Fatalf("op160 reward notice header=%+v body=%x want=%x", rewardNotice.Header, rewardNotice.Body, wantRewardNotice)
}
list0, rest := splitCurrentSceneItemListPacket(t, rest)
if len(list0.Body) == 0 || list0.Body[0] != dnfrepo.MainInventoryListType {
t.Fatalf("list0 body=%x", list0.Body)
}
list1, trailing := splitCurrentSceneItemListPacket(t, rest)
if len(trailing) != 0 || len(list1.Body) == 0 || list1.Body[0] != 1 {
t.Fatalf("list1 body=%x trailing=%d", list1.Body, len(trailing))
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if source := inventory.Slots["0:75"]; source.ItemID != 500 || source.Count != 1 || binary.LittleEndian.Uint32(source.RawEntry[6:10]) != 1 {
t.Fatalf("source after=%+v raw=%x", source, source.RawEntry)
}
stackable, found := inventory.Slots["0:121"]
minExpire := time.Unix(before.Unix()+7*86400, 0).UTC()
maxExpire := time.Unix(time.Now().UTC().Unix()+7*86400, 0).UTC()
if !found || stackable.ItemID != 600 || stackable.Count != 3 || stackable.Extra["source"] != "cera_package" ||
stackable.Extra["usable_period_days"] != "7" || stackable.Extra["expiration_source"] != "runtime_pvf_usable_period_grant" ||
stackable.ExpireAt.Before(minExpire) || stackable.ExpireAt.After(maxExpire) ||
binary.LittleEndian.Uint32(stackable.RawEntry[currentItemListExpireTimeOffset:currentItemListExpireTimeOffset+4]) != uint32(stackable.ExpireAt.Unix()) {
t.Fatalf("stackable reward=%+v found=%t", stackable, found)
}
if _, exists := inventory.Slots["0:3"]; exists {
t.Fatalf("automatic package material entered quick slot: %+v", inventory.Slots)
}
avatar, found := inventory.Slots["1:0"]
if !found || avatar.ItemID != 700 || avatar.Count != 1 || avatar.Extra["amount_or_count"] != "0" || avatar.Extra["avatar_ability_no"] != "9" || avatar.Extra["durability"] != "9" || avatar.Extra["source"] != "cera_package" {
t.Fatalf("avatar reward=%+v found=%t", avatar, found)
}
if scheduled, _, active := queue.counts(); scheduled != 2 || active != 2 {
t.Fatalf("cera package reward highlight schedule=(%d,%d) want=(2,2)", scheduled, active)
}
}
func TestCurrentCeraPackageRoutesCreatureAndFeedRewardsToPetSegments(t *testing.T) {
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "502 `cash/pet_package.stk`\n24 `cash/creature/creature_food.stk`\n",
"equipment/equipment.lst": "700 `avatar/test_hat.equ`\n800 `creature/test_pet.equ`\n",
"stackable/cash/pet_package.stk": "[stackable type]\n`[usable cera package]`\n[stack limit]\n1\n[package data]\n700 1\n800 1\n24 300\n[/package data]\n",
"stackable/cash/creature/creature_food.stk": "[stackable type]\n`[feed]`\n[stack limit]\n1000\n",
"equipment/avatar/test_hat.equ": "[equipment type]\n`[hat avatar]`\n",
"equipment/creature/test_pet.equ": "[equipment type]\n`[creature]`\n",
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
definition, err := resolveCurrentCeraPackageDefinition(catalog, 502)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:75": {ItemID: 502, Count: 1}},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1"},
pvfItemCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
result, err := service.commitCurrentCeraPackage(
ctx,
&gameSession{selectedCharacterID: 19},
catalog,
definition,
currentCeraPackageOpenRequest{SourceSlot: 75, Choices: []currentCeraPackageChoice{{ItemID: 700, Option: 9}}},
)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result.ChangedListTypes, []byte{0, 1, currentPetInventoryListType}) {
t.Fatalf("changed lists=%v", result.ChangedListTypes)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if _, found := inventory.Slots["0:75"]; found {
t.Fatal("source package was not consumed")
}
for key, stack := range inventory.Slots {
if strings.HasPrefix(key, "0:") && (stack.ItemID == 24 || stack.ItemID == 800) {
t.Fatalf("pet reward leaked into main bag key=%s stack=%+v", key, stack)
}
}
creature, creatureFound := inventory.Slots["7:0"]
if !creatureFound || creature.ItemID != 800 || creature.Count != 1 ||
creature.Extra["source"] != "cera_package" ||
binary.LittleEndian.Uint32(creature.RawEntry[0x0E:0x12]) != 0 {
t.Fatalf("creature reward=%+v found=%t", creature, creatureFound)
}
feed, feedFound := inventory.Slots["7:189"]
if !feedFound || feed.ItemID != 24 || feed.Count != 300 ||
feed.Extra["stackable_type"] != "[feed]" || feed.Extra["source"] != "cera_package" {
t.Fatalf("feed reward=%+v found=%t", feed, feedFound)
}
}
func TestCurrentCeraPackageAllowsFutureInstanceExpirationWhenPVFStaticDateExpired(t *testing.T) {
catalog := mustCurrentCeraPackageTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
instanceExpire := time.Date(2037, time.December, 31, 15, 59, 59, 0, time.UTC)
sourceStack := dnfrepo.ItemStack{
ItemID: 501,
Count: 1,
ExpireAt: instanceExpire,
Extra: map[string]string{
"expire_time": strconv.FormatInt(instanceExpire.Unix(), 10),
"expire_unix": strconv.FormatInt(instanceExpire.Unix(), 10),
},
}
sourceEntry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, 75, sourceStack)
sourceStack.RawEntry = append([]byte(nil), sourceEntry.data[:]...)
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:75": sourceStack},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-1",
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
pvfItemCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "cera-package-expired-static-test", selectedCharacterID: 19}
requestBody := currentCeraPackageTestRequestBody(75, currentCeraPackageChoice{ItemID: 700, Option: 9})
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketOpenCerapackage),
requestBody,
); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketOpenCerapackage) || !bytes.Equal(ack.Body, []byte{1, 75, 0}) {
t.Fatalf("op518 ACK header=%+v body=%x", ack.Header, ack.Body)
}
rewardNotice, rest := splitGameServerUpperPacket(t, rest)
wantRewardNotice := append([]byte{1}, buildCurrentBoosterSuccessBody(currentBoosterCommitResult{
SourceItemID: 501,
SourceSlot: 75,
SourceRemaining: 0,
Rewards: []currentBoosterGrantedReward{
{ItemID: 700, Count: 1},
{ItemID: 600, Count: 3},
{ItemID: 602, Count: 1},
},
})...)
if rewardNotice.Header.MsgID != uint16(dnfenum.CmdPacketUseBoosterItem) ||
!bytes.Equal(rewardNotice.Body, wantRewardNotice) {
t.Fatalf("op160 reward notice header=%+v body=%x want=%x", rewardNotice.Header, rewardNotice.Body, wantRewardNotice)
}
list0, rest := splitCurrentSceneItemListPacket(t, rest)
list1, trailing := splitCurrentSceneItemListPacket(t, rest)
if len(trailing) != 0 || len(list0.Body) == 0 || list0.Body[0] != dnfrepo.MainInventoryListType || len(list1.Body) == 0 || list1.Body[0] != 1 {
t.Fatalf("list0=%x list1=%x trailing=%d", list0.Body, list1.Body, len(trailing))
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if _, stillPresent := inventory.Slots["0:75"]; stillPresent {
t.Fatalf("future instance package source was not consumed: %+v", inventory.Slots["0:75"])
}
if avatar, found := inventory.Slots["1:0"]; !found || avatar.ItemID != 700 || avatar.Extra["source"] != "cera_package" {
t.Fatalf("avatar reward=%+v found=%t", avatar, found)
}
var inheritedReward dnfrepo.ItemStack
inheritedFound := false
for _, stack := range inventory.Slots {
if stack.ItemID == 602 {
inheritedReward = stack
inheritedFound = true
break
}
}
if !inheritedFound || !inheritedReward.ExpireAt.Equal(instanceExpire) ||
inheritedReward.Extra["expire_unix"] != strconv.FormatInt(instanceExpire.Unix(), 10) ||
binary.LittleEndian.Uint32(inheritedReward.RawEntry[currentItemListExpireTimeOffset:currentItemListExpireTimeOffset+4]) != uint32(instanceExpire.Unix()) {
t.Fatalf("inherited reward=%+v found=%t want_expire=%s", inheritedReward, inheritedFound, instanceExpire)
}
}
func TestCurrentCeraPackageRejectsExpiredPVFStaticDateWithoutInstanceExpiration(t *testing.T) {
catalog := mustCurrentCeraPackageTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:75": {ItemID: 501, Count: 1}},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1"},
pvfItemCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{selectedCharacterID: 19}
request := currentCeraPackageOpenRequest{SourceSlot: 75, Choices: []currentCeraPackageChoice{{ItemID: 700, Option: 9}}}
if _, err := service.prepareCurrentCeraPackage(ctx, session, catalog, request); !errors.Is(err, errCurrentCeraPackageExpired) {
t.Fatalf("prepare expired static package error=%v", err)
}
}
func TestCurrentCeraPackageForgedChoiceRollsBackSourceAndRewards(t *testing.T) {
catalog := mustCurrentCeraPackageTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
before := dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:75": {ItemID: 500, Count: 1}},
}
if err := repositories.Inventory.Save(ctx, before); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1"},
pvfItemCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{selectedCharacterID: 19}
request := currentCeraPackageOpenRequest{SourceSlot: 75, Choices: []currentCeraPackageChoice{{ItemID: 701, Option: 9}}}
definition, err := resolveCurrentCeraPackageDefinition(catalog, 500)
if err != nil {
t.Fatal(err)
}
if _, err := service.commitCurrentCeraPackage(ctx, session, catalog, definition, request); !errors.Is(err, errCurrentCeraPackageChoiceInvalid) {
t.Fatalf("commit forged choice error=%v", err)
}
after, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found || !reflect.DeepEqual(after.Slots, before.Slots) {
t.Fatalf("rollback inventory=%+v found=%t err=%v", after, found, err)
}
}
func TestCurrentCeraPackageInventoryFullRollsBackConsumedSource(t *testing.T) {
catalog := mustCurrentCeraPackageTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
slots := map[string]dnfrepo.ItemStack{"0:75": {ItemID: 500, Count: 1}}
for slot := int16(0); slot <= 500; slot++ {
slots[currentCeraShopInventorySlotKey(1, slot)] = dnfrepo.ItemStack{ItemID: 9000 + int64(slot), Count: 0}
}
before := dnfrepo.InventoryRecord{CharacterID: "19", Slots: slots}
if err := repositories.Inventory.Save(ctx, before); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1"},
pvfItemCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
definition, err := resolveCurrentCeraPackageDefinition(catalog, 500)
if err != nil {
t.Fatal(err)
}
request := currentCeraPackageOpenRequest{SourceSlot: 75, Choices: []currentCeraPackageChoice{{ItemID: 700, Option: 9}}}
if _, err := service.commitCurrentCeraPackage(ctx, &gameSession{selectedCharacterID: 19}, catalog, definition, request); !errors.Is(err, errDungeonPickupInventoryFull) {
t.Fatalf("commit full inventory error=%v", err)
}
after, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found || !reflect.DeepEqual(after.Slots, before.Slots) {
t.Fatalf("rollback inventory changed found=%t err=%v", found, err)
}
}
func TestCurrentCeraPackageMailsMainInventoryOverflowAtomically(t *testing.T) {
catalog := mustCurrentCeraPackageTestCatalog(t)
definition, err := resolveCurrentCeraPackageDefinition(catalog, 500)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
slots := make(map[string]dnfrepo.ItemStack)
var material dungeonDropItemDefinition
for _, reward := range definition.Rewards {
if reward.Definition.ItemID == 600 {
material = reward.Definition
break
}
}
if material.ItemID == 0 {
t.Fatal("test package material definition is missing")
}
for slot := material.SlotStart; slot <= material.SlotEnd; slot++ {
slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)] = dnfrepo.ItemStack{ItemID: 90000 + int64(slot), Count: 1}
}
for slot := int16(3); slot <= 8; slot++ {
slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)] = dnfrepo.ItemStack{ItemID: 91000 + int64(slot), Count: 1}
}
// Keep the source outside the reward's category range: removing the
// package must not manufacture a free material slot for this test.
slots["0:500"] = dnfrepo.ItemStack{ItemID: 500, Count: 1}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: slots}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1"},
pvfItemCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
result, err := service.commitCurrentCeraPackage(
ctx,
&gameSession{selectedCharacterID: 19},
catalog,
definition,
currentCeraPackageOpenRequest{SourceSlot: 500, Choices: []currentCeraPackageChoice{{ItemID: 700, Option: 9}}},
)
if err != nil {
t.Fatal(err)
}
if result.OverflowMailID == "" || result.OverflowItems != 1 || !reflect.DeepEqual(result.ChangedListTypes, []byte{0, 1}) {
t.Fatalf("commit result=%+v", result)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if _, found := inventory.Slots["0:500"]; found {
t.Fatal("source package was not consumed")
}
if avatar, found := inventory.Slots["1:0"]; !found || avatar.ItemID != 700 || avatar.Extra["source"] != "cera_package" {
t.Fatalf("avatar after overflow=%+v found=%t", avatar, found)
}
mailbox, found, err := repositories.Mailbox.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load mailbox found=%t err=%v", found, err)
}
mail, found := mailbox.Mails[result.OverflowMailID]
if !found || mail.Title != "背包已满:礼包奖励" || len(mail.Attachments) != 1 {
t.Fatalf("overflow mail=%+v found=%t", mail, found)
}
attachment := mail.Attachments[0]
if attachment.ItemID != 600 || attachment.Count != 3 || attachment.Extra["source"] != "cera_package" ||
attachment.Extra["cera_package_source_item"] != "500" || attachment.Extra["cera_package_overflow_mail"] != "true" {
t.Fatalf("overflow attachment=%+v", attachment)
}
if attachment.ExpireAt.IsZero() || attachment.RawEntry == nil {
t.Fatalf("overflow attachment lost its item-instance fields: %+v", attachment)
}
}
func TestRealPVFSummerCeraPackageResolvesAllRewardsAndAvatarChoices(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the real 2018 summer Cera package")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
definition, err := resolveCurrentCeraPackageDefinition(catalog, 490702418)
if err != nil {
t.Fatal(err)
}
if definition.Source.PVFPath != "stackable/cash/chn_490702418.stk" || len(definition.Rewards) != 13 || len(definition.AvatarItemIDs) != 9 {
t.Fatalf("real package source=%+v reward_rows=%d avatar_choices=%v", definition.Source, len(definition.Rewards), definition.AvatarItemIDs)
}
wantAvatarIDs := []uint32{412550012, 112560230, 112570187, 112520228, 412500009, 112510244, 112530222, 112540258, 112580139}
if !reflect.DeepEqual(definition.AvatarItemIDs, wantAvatarIDs) {
t.Fatalf("avatar choices=%v want=%v", definition.AvatarItemIDs, wantAvatarIDs)
}
}
func TestRealPVFGunbladerPetCeraPackageUsesZeroAvatarChoiceBody(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the fixed-reward Gunblader pet package")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
definition, err := resolveCurrentCeraPackageDefinition(catalog, 490702494)
if err != nil {
t.Fatal(err)
}
if definition.Source.PVFPath != "stackable/cash/chn_490702494.stk" ||
len(definition.Rewards) != 4 || len(definition.AvatarItemIDs) != 0 {
t.Fatalf("real fixed package source=%+v reward_rows=%d avatar_choices=%v",
definition.Source, len(definition.Rewards), definition.AvatarItemIDs)
}
request, err := parseCurrentCeraPackageOpenRequest([]byte{69, 0, 0})
if err != nil {
t.Fatal(err)
}
if request.SourceSlot != 69 || len(request.Choices) != 0 {
t.Fatalf("request=%+v", request)
}
if _, err := validateCurrentCeraPackageChoices(definition, request.Choices); err != nil {
t.Fatal(err)
}
}
func TestRealPVFBeastGuardianCeraPackageKeepsItsOriginalRewardLayout(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the real 2018 beast-guardian Cera package")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
definition, err := resolveCurrentCeraPackageDefinition(catalog, 490701964)
if err != nil {
t.Fatal(err)
}
if definition.Source.PVFPath != "stackable/cash/chn_490701964.stk" || len(definition.Rewards) != 28 || len(definition.AvatarItemIDs) != 8 {
t.Fatalf("real package source=%+v reward_rows=%d avatar_choices=%v", definition.Source, len(definition.Rewards), definition.AvatarItemIDs)
}
var total uint64
counts := make(map[uint32]uint32, len(definition.Rewards))
for _, reward := range definition.Rewards {
total += uint64(reward.Count)
counts[reward.Definition.ItemID] = reward.Count
}
if total != 996 || counts[490701742] != 1 || counts[490701730] != 200 || counts[24] != 300 {
t.Fatalf("real package reward total=%d counts=%v", total, counts)
}
}
func TestRealPVFSummerCeraPackageFeedRewardUsesPetConsumableType(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the real 2018 summer Cera package feed")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
definition, err := resolveCurrentCeraPackageDefinition(catalog, 490702439)
if err != nil {
t.Fatal(err)
}
for _, reward := range definition.Rewards {
if reward.Definition.ItemID != 24 {
continue
}
if reward.Count != 300 || !isCurrentCeraShopPetConsumable(reward.Definition) {
t.Fatalf("real feed reward=%+v", reward)
}
return
}
t.Fatal("real package 490702439 has no item 24 feed reward")
}
func TestRealPVFSummerCeraPackageThreeCoversAllSixteenJobs(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify all real 2018 summer Cera packages")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
type match struct {
itemID uint32
name string
jobs []string
rewardRows int
avatarChoices int
}
matches := make([]match, 0, 16)
for itemID, reference := range catalog.itemRefs {
if itemID < 490702000 || itemID > 490703000 || reference.kind != dungeonDropItemStackable {
continue
}
definition, resolveErr := catalog.ResolveItem(itemID)
if resolveErr != nil {
t.Fatalf("resolve candidate item=%d: %v", itemID, resolveErr)
}
document, readErr := parseDungeonCardPVFDocument(catalog.source, definition.PVFPath)
if readErr != nil {
t.Fatalf("read candidate item=%d: %v", itemID, readErr)
}
name, _ := document.Text("name")
if !strings.Contains(name, "2018热舞一夏乐享礼包 3 (") {
continue
}
resolved, packageErr := resolveCurrentCeraPackageDefinition(catalog, itemID)
if packageErr != nil {
t.Fatalf("resolve package item=%d name=%q: %v", itemID, name, packageErr)
}
jobList := strings.TrimSuffix(strings.TrimPrefix(name[strings.LastIndex(name, " ("):], " ("), ")")
matches = append(matches, match{
itemID: itemID,
name: name,
jobs: strings.Split(jobList, "/"),
rewardRows: len(resolved.Rewards),
avatarChoices: len(resolved.AvatarItemIDs),
})
}
sort.Slice(matches, func(left, right int) bool { return matches[left].itemID < matches[right].itemID })
if len(matches) != 14 {
t.Fatalf("2018 summer package-3 item count=%d want=14 matches=%+v", len(matches), matches)
}
coveredJobs := make(map[string]struct{}, 16)
for _, packageMatch := range matches {
if packageMatch.rewardRows == 0 || packageMatch.avatarChoices == 0 {
t.Fatalf("package=%+v has no real rewards/avatar choices", packageMatch)
}
for _, job := range packageMatch.jobs {
coveredJobs[job] = struct{}{}
}
t.Logf("item=%d name=%s reward_rows=%d avatar_choices=%d", packageMatch.itemID, packageMatch.name, packageMatch.rewardRows, packageMatch.avatarChoices)
}
if len(coveredJobs) != 16 {
t.Fatalf("2018 summer package-3 job coverage=%d want=16 jobs=%v", len(coveredJobs), coveredJobs)
}
}
func mustCurrentCeraPackageTestCatalog(t *testing.T) *pvfDungeonDropCatalog {
t.Helper()
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "500 `cash/test_package.stk`\n501 `cash/expired_static_package.stk`\n600 `material/test_reward.stk`\n602 `cash/expired_child_booster.stk`\n",
"equipment/equipment.lst": "700 `avatar/test_hat.equ`\n",
"stackable/cash/test_package.stk": "[stackable type]\n`[usable cera package]`\n[stack limit]\n100\n[expiration date]\n`2028-08-16 06:00:00`\n[package data]\n700 1\n600 3\n[/package data]\n",
"stackable/cash/expired_static_package.stk": "[stackable type]\n`[usable cera package]`\n[stack limit]\n1\n[expiration date]\n`2017-11-01 22:00:00`\n[package data]\n700 1\n600 3\n602 1\n[/package data]\n",
"stackable/cash/expired_child_booster.stk": "[stackable type]\n`[booster]`\n[stack limit]\n1\n[expiration date]\n`2017-11-01 22:00:00`\n[booster info]\n[etc]\n1 600 10000 1\n[/etc]\n[/booster info]\n",
"stackable/material/test_reward.stk": "[stackable type]\n`[material]`\n[stack limit]\n999\n[usable period]\n7\n",
"equipment/avatar/test_hat.equ": "[equipment type]\n`[hat avatar]`\n",
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
definition, err := resolveCurrentCeraPackageDefinition(catalog, 500)
if err != nil {
t.Fatal(err)
}
if len(definition.Rewards) != 2 || !reflect.DeepEqual(definition.AvatarItemIDs, []uint32{700}) {
t.Fatalf("fixture package=%+v", definition)
}
return catalog
}
func currentCeraPackageTestRequestBody(slot uint16, choices ...currentCeraPackageChoice) []byte {
body := make([]byte, currentCeraPackageRequestHeaderSize+len(choices)*currentCeraPackageChoiceStride)
binary.LittleEndian.PutUint16(body[0:2], slot)
body[2] = byte(len(choices))
for index, choice := range choices {
offset := currentCeraPackageRequestHeaderSize + index*currentCeraPackageChoiceStride
binary.LittleEndian.PutUint32(body[offset:offset+4], choice.ItemID)
body[offset+4] = choice.Option
}
return body
}