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