服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
4462 lines
158 KiB
Go
4462 lines
158 KiB
Go
package inventory
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/premium"
|
|
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"
|
|
)
|
|
|
|
func TestOwnerDeleteSimpleSlotPersists(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 5},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Delete(ctx, NewDeleteCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, DeleteRequest{
|
|
ListType: listTypeMain,
|
|
SlotIndex: 2,
|
|
Count: 3,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Delete error = %v", err)
|
|
}
|
|
if !result.Changed || len(result.Removed) != 1 || result.Removed[0].RemainingCount != 2 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
got := loaded.Slots[slotKey(listTypeMain, 2)]
|
|
if got.ItemID != 100 || got.Count != 2 {
|
|
t.Fatalf("persisted stack = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerDeleteExtendedEntriesPersistsTogether(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 5},
|
|
slotKey(listTypeMain, 3): {ItemID: 200, Count: 7},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.Delete(ctx, NewDeleteCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, DeleteRequest{
|
|
Extended: true,
|
|
ListType: listTypeMain,
|
|
Entries: []DeleteEntry{
|
|
{SlotIndex: 2, ItemID: 100, DeleteCount: 2},
|
|
{SlotIndex: 3, ItemID: 200, DeleteCount: 0},
|
|
},
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Delete error = %v", err)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 2)].Count; got != 3 {
|
|
t.Fatalf("slot 2 count = %d, want 3", got)
|
|
}
|
|
if _, ok := loaded.Slots[slotKey(listTypeMain, 3)]; ok {
|
|
t.Fatalf("slot 3 should be deleted: %+v", loaded.Slots)
|
|
}
|
|
}
|
|
|
|
func TestOwnerDeleteExtendedMismatchDoesNotPersistPartialMutation(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 5},
|
|
slotKey(listTypeMain, 3): {ItemID: 200, Count: 7},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.Delete(ctx, NewDeleteCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, DeleteRequest{
|
|
Extended: true,
|
|
ListType: listTypeMain,
|
|
Entries: []DeleteEntry{
|
|
{SlotIndex: 2, ItemID: 100, DeleteCount: 2},
|
|
{SlotIndex: 3, ItemID: 999, DeleteCount: 1},
|
|
},
|
|
}))
|
|
if !errors.Is(err, ErrItemMismatch) {
|
|
t.Fatalf("Delete error = %v, want ErrItemMismatch", err)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 2)].Count; got != 5 {
|
|
t.Fatalf("slot 2 count = %d, want unchanged 5", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 3)].Count; got != 7 {
|
|
t.Fatalf("slot 3 count = %d, want unchanged 7", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDeleteUsesOwnerAndReturnsAck(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 5},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketDeleteItem),
|
|
Body: []byte{14, 0, 0, 0, 16, 0, 26, 8, 8, 1, 16, 2, 24, 100, 32, 3, 32, 0, 0},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "delete_item" {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
for _, want := range []string{"inventory owner applied", "0x0012"} {
|
|
if !strings.Contains(got.Reason, want) {
|
|
t.Fatalf("reason %q missing %q", got.Reason, want)
|
|
}
|
|
}
|
|
if len(got.UpperResponses) != 1 {
|
|
t.Fatalf("response count = %d, want 1", len(got.UpperResponses))
|
|
}
|
|
ack := got.UpperResponses[0]
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketDeleteItem) || ack.Classification != dnfproto.DefaultChannelClassification || !ack.AllowCodec {
|
|
t.Fatalf("ack = %+v", ack)
|
|
}
|
|
if want := []byte{1, 12, 0, 0, 0, 8, listTypeMain, 18, 6, 8, 2, 16, 3, 24, 0, 24, 0}; string(ack.Body) != string(want) {
|
|
t.Fatalf("ack body = % X, want % X", ack.Body, want)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 2)].Count; got != 2 {
|
|
t.Fatalf("persisted count = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDeleteSkillMaterialUsesAccountSharedCrystalWarehouse(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 121): {ItemID: 3021, Count: 1},
|
|
},
|
|
})
|
|
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
|
AccountID: "dnf:1",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
dnfrepo.AccountSharedInventorySlotKey(358): {ItemID: 3037, Count: 602},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body := []byte{
|
|
0x10, 0x00, 0x00, 0x00,
|
|
0x10, 0x00,
|
|
0x1A, 0x0A,
|
|
0x08, 0x02,
|
|
0x10, 0xE6, 0x02,
|
|
0x18, 0xDD, 0x17,
|
|
0x20, 0x01,
|
|
0x20, 0x00,
|
|
0x01,
|
|
}
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketDeleteItem),
|
|
Body: body,
|
|
AccountID: "dnf:1",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if want := []byte{1, 4, 0, 0, 0, 0x08, 0, 0x18, 0}; !bytes.Equal(got.UpperResponses[0].Body, want) {
|
|
t.Fatalf("skill material ACK=%x want=%x", got.UpperResponses[0].Body, want)
|
|
}
|
|
if len(got.ItemSlotRefreshes) != 1 || got.ItemSlotRefreshes[0] != (alignedcmd.ItemSlotRefresh{ListType: listTypeMain, SlotIndex: 358}) {
|
|
t.Fatalf("skill material refreshes=%+v", got.ItemSlotRefreshes)
|
|
}
|
|
|
|
account, found, err := repos.AccountInventory.Load(ctx, "dnf:1")
|
|
if err != nil || !found {
|
|
t.Fatalf("account inventory found=%t err=%v", found, err)
|
|
}
|
|
if stack := account.Slots[dnfrepo.AccountSharedInventorySlotKey(358)]; stack.ItemID != 3037 || stack.Count != 601 {
|
|
t.Fatalf("account crystal slot = %+v, want 3037x601", stack)
|
|
}
|
|
character := loadTestInventory(t, ctx, repos, "77")
|
|
if _, exists := character.Slots[dnfrepo.AccountSharedInventorySlotKey(358)]; exists {
|
|
t.Fatalf("account crystal was written into character inventory: %+v", character.Slots)
|
|
}
|
|
if stack := character.Slots[slotKey(listTypeMain, 121)]; stack.ItemID != 3021 || stack.Count != 1 {
|
|
t.Fatalf("ordinary character stack changed: %+v", stack)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDeleteAccountMaterialRequestsCompletionPostAction(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
|
|
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
|
AccountID: "dnf:1",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
dnfrepo.AccountSharedInventorySlotKey(358): {ItemID: 3037, Count: 5},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 副本内技能材料删除(state trailer 形态)扣账号共享晶块。
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketDeleteItem),
|
|
Body: []byte{
|
|
0x10, 0x00, 0x00, 0x00,
|
|
0x10, 0x00,
|
|
0x1A, 0x0A,
|
|
0x08, 0x02,
|
|
0x10, 0xE6, 0x02,
|
|
0x18, 0xDD, 0x17,
|
|
0x20, 0x01,
|
|
0x20, 0x00,
|
|
0x01,
|
|
},
|
|
AccountID: "dnf:1",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if len(got.PostActions) != 1 || got.PostActions[0] != alignedcmd.PostActionSendDeleteItemAccountMaterialCompletion {
|
|
t.Fatalf("post actions = %v, want exactly the account-material completion", got.PostActions)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDeleteOrdinaryRowDoesNotRequestCompletionPostAction(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 3): {ItemID: 500, Count: 5, Extra: map[string]string{"item_kind": "stackable", "pvf_path": "stackable/tutorial/tutorial_0500.stk", "stackable_type": "[waste] 4"}},
|
|
},
|
|
})
|
|
|
|
// 普通 Main 槽删除不请求账号材料完成帧。
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketDeleteItem),
|
|
Body: []byte{
|
|
0x0F, 0x00, 0x00, 0x00,
|
|
0x10, 0x00,
|
|
0x1A, 0x09,
|
|
0x08, 0x02,
|
|
0x10, 0x03,
|
|
0x18, 0xF4, 0x03,
|
|
0x20, 0x01,
|
|
0x20, 0x00,
|
|
0x01,
|
|
},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if len(got.PostActions) != 0 {
|
|
t.Fatalf("ordinary delete must not request post actions, got %v", got.PostActions)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDeleteMalformedRequestReturnsEmptyFailureAck(t *testing.T) {
|
|
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketDeleteItem),
|
|
Body: []byte{1, 2, 3},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: dnfrepomemory.NewMemoryGroup(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if want := []byte{0, 4, 8, 0, 0, 0, 0x08, 0, 0x18, 0}; !bytes.Equal(got.UpperResponses[0].Body, want) {
|
|
t.Fatalf("malformed delete ACK=%x want=%x", got.UpperResponses[0].Body, want)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDeleteEquipmentListBlocksBeforeMutation(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeEquipment, 11): {ItemID: 999, Count: 1},
|
|
},
|
|
})
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
|
CharacterID: "77",
|
|
Entries: map[string]dnfrepo.EquipmentEntry{
|
|
"11": {SlotIndex: 11, ItemID: 700, RawEntry: []byte{1, 2, 3}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("Save equipment error = %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketDeleteItem),
|
|
Body: []byte{listTypeEquipment, 0x0B, 0x00, 0x01, 0x00},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "delete_item" || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if want := []byte{0, 4, 8, 0, 0, 0, 0x08, 0, 0x18, 0}; !bytes.Equal(got.UpperResponses[0].Body, want) {
|
|
t.Fatalf("delete failure ACK=%x want=%x", got.UpperResponses[0].Body, want)
|
|
}
|
|
if !strings.Contains(got.Reason, "equipment delete requires a typed equipment owner") {
|
|
t.Fatalf("reason = %q", got.Reason)
|
|
}
|
|
|
|
inventory := loadTestInventory(t, ctx, repos, "77")
|
|
if stack := inventory.Slots[slotKey(listTypeEquipment, 11)]; stack.ItemID != 999 || stack.Count != 1 {
|
|
t.Fatalf("inventory equipment-shaped decoy mutated: %+v", stack)
|
|
}
|
|
equipment, ok, err := repos.Equipment.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Load equipment ok=%t err=%v", ok, err)
|
|
}
|
|
if entry := equipment.Entries["11"]; entry.ItemID != 700 || entry.SlotIndex != 11 {
|
|
t.Fatalf("equipment mutated: %+v", entry)
|
|
}
|
|
}
|
|
|
|
func TestOwnerDeleteEquipmentListFailsClosedForDirectCaller(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeEquipment, 11): {ItemID: 999, Count: 1},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
|
|
_, err = owner.Delete(ctx, NewDeleteCommand(alignedcmd.Request{SelectedCharacterID: 77}, DeleteRequest{
|
|
ListType: listTypeEquipment,
|
|
SlotIndex: 11,
|
|
Count: 1,
|
|
}))
|
|
if !errors.Is(err, ErrDeleteRequiresEquipmentOwner) {
|
|
t.Fatalf("Delete error = %v, want ErrDeleteRequiresEquipmentOwner", err)
|
|
}
|
|
if stack := loadTestInventory(t, ctx, repos, "77").Slots[slotKey(listTypeEquipment, 11)]; stack.ItemID != 999 || stack.Count != 1 {
|
|
t.Fatalf("inventory mutated: %+v", stack)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDeleteExtendedReturnsOneProtobufAck(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 5},
|
|
slotKey(listTypeMain, 3): {ItemID: 200, Count: 7},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketDeleteItem),
|
|
Body: []byte{
|
|
listTypeMain, 2,
|
|
2, 0, 2, 0, 100, 0, 0, 0, 1, 0, 0, 0,
|
|
2, 0, 3, 0, 200, 0, 0, 0, 2, 0, 0, 0,
|
|
},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if want := []byte{1, 20, 0, 0, 0, 8, listTypeMain, 18, 6, 8, 2, 16, 1, 24, 0, 18, 6, 8, 3, 16, 2, 24, 0, 24, 0}; string(got.UpperResponses[0].Body) != string(want) {
|
|
t.Fatalf("ack = % X, want % X", got.UpperResponses[0].Body, want)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 2)].Count; got != 4 {
|
|
t.Fatalf("slot 2 count = %d, want 4", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 3)].Count; got != 5 {
|
|
t.Fatalf("slot 3 count = %d, want 5", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerSellZeroPricePersistsInventoryOnly(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 12345}}); err != nil {
|
|
t.Fatalf("Character.Save error = %v", err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 300, Count: 6, Extra: map[string]string{"sell_gold": "0"}},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Sell(ctx, NewSellCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, DeleteOrSellRequest{
|
|
HasListType: true,
|
|
ListType: listTypeMain,
|
|
SlotIndex: 4,
|
|
Count: 2,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Sell error = %v", err)
|
|
}
|
|
if !result.Changed || result.Sold.AppliedCount != 2 || result.GoldDelta != 0 || result.UpdatedGold != 12345 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].Count; got != 4 {
|
|
t.Fatalf("persisted count = %d, want 4", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerSellRequiresPriceEvidence(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 300, Count: 6},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.Sell(ctx, NewSellCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, DeleteOrSellRequest{
|
|
HasListType: true,
|
|
ListType: listTypeMain,
|
|
SlotIndex: 4,
|
|
Count: 2,
|
|
}))
|
|
if !errors.Is(err, ErrSellPriceMissing) {
|
|
t.Fatalf("Sell error = %v, want ErrSellPriceMissing", err)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].Count; got != 6 {
|
|
t.Fatalf("persisted count = %d, want unchanged 6", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerSellPositivePriceRequiresWalletTransaction(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 300, Count: 6, Extra: map[string]string{"sell_gold": "10"}},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.Sell(ctx, NewSellCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, DeleteOrSellRequest{
|
|
HasListType: true,
|
|
ListType: listTypeMain,
|
|
SlotIndex: 4,
|
|
Count: 2,
|
|
}))
|
|
if !errors.Is(err, ErrWalletTxnRequired) {
|
|
t.Fatalf("Sell error = %v, want ErrWalletTxnRequired", err)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].Count; got != 6 {
|
|
t.Fatalf("persisted count = %d, want unchanged 6", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerSellUsesOwnerAndReturnsAck(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 12345}}); err != nil {
|
|
t.Fatalf("Character.Save error = %v", err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 300, Count: 6, Extra: map[string]string{"sell_gold": "0"}},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketSellItem),
|
|
Body: []byte{listTypeMain, 0x04, 0x00, 0x02, 0x00},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "sell_item" {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
for _, want := range []string{"inventory owner applied", "0x0016", "updatedGold=12345"} {
|
|
if !strings.Contains(got.Reason, want) {
|
|
t.Fatalf("reason %q missing %q", got.Reason, want)
|
|
}
|
|
}
|
|
if len(got.UpperResponses) != 1 {
|
|
t.Fatalf("responses = %+v", got.UpperResponses)
|
|
}
|
|
ack := got.UpperResponses[0]
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketSellItem) || ack.Classification != 1 || !ack.AllowCodec {
|
|
t.Fatalf("ack header = %+v", ack)
|
|
}
|
|
wantBody := []byte{1, 0x39, 0x30, 0x00, 0x00, listTypeMain, 0x04, 0x00, 0x02, 0x00}
|
|
if string(ack.Body) != string(wantBody) {
|
|
t.Fatalf("ack body = % X, want % X", ack.Body, wantBody)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].Count; got != 4 {
|
|
t.Fatalf("persisted count = %d, want 4", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerMoveToEmptySlotPersists(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 1},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, MoveItemspaceRequest{
|
|
SourceListType: listTypeMain,
|
|
SourceSlotIndex: 2,
|
|
MoveCount: 1,
|
|
DestinationListType: listTypeMain,
|
|
DestinationSlotIndex: 8,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Move error = %v", err)
|
|
}
|
|
if !result.Changed || result.Mode != "move" || result.MoveCount != 1 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if _, ok := loaded.Slots[slotKey(listTypeMain, 2)]; ok {
|
|
t.Fatalf("source slot should be empty: %+v", loaded.Slots)
|
|
}
|
|
got := loaded.Slots[slotKey(listTypeMain, 8)]
|
|
if got.ItemID != 100 || got.Count != 1 {
|
|
t.Fatalf("destination stack = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerMoveRejectsSecondPVFCharmInQuickSlots(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 400360003, Count: 1}, // [charm] earthquake
|
|
slotKey(listTypeMain, 9): {ItemID: 400360005, Count: 1}, // [charm] hurricane
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
owner.SetStackMergeResolver(func(itemID int64) (alignedcmd.StackMergeResolution, error) {
|
|
return alignedcmd.StackMergeResolution{
|
|
ItemID: itemID,
|
|
Kind: "equipment",
|
|
PVFPath: "equipment/character/common/charm/test.equ",
|
|
EquipmentType: "[charm]",
|
|
}, nil
|
|
})
|
|
|
|
_, err = owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, MoveItemspaceRequest{
|
|
SourceListType: listTypeMain,
|
|
SourceSlotIndex: 9,
|
|
MoveCount: 1,
|
|
DestinationListType: listTypeMain,
|
|
DestinationSlotIndex: 3,
|
|
}))
|
|
if !errors.Is(err, ErrQuickSlotCharmAlreadyEquipped) {
|
|
t.Fatalf("Move error = %v, want %v", err, ErrQuickSlotCharmAlreadyEquipped)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if _, found := loaded.Slots[slotKey(listTypeMain, 3)]; found {
|
|
t.Fatalf("rejected second charm persisted in quick slot: %+v", loaded.Slots)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].ItemID; got != 400360003 {
|
|
t.Fatalf("existing charm changed to %d", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 9)].ItemID; got != 400360005 {
|
|
t.Fatalf("source charm changed to %d", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerMoveRejectsSecondPVFCharmWithNativeFailureAck(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 400360003, Count: 1},
|
|
slotKey(listTypeMain, 9): {ItemID: 400360005, Count: 1},
|
|
},
|
|
})
|
|
resolver := func(itemID int64) (alignedcmd.StackMergeResolution, error) {
|
|
return alignedcmd.StackMergeResolution{
|
|
ItemID: itemID,
|
|
Kind: "equipment",
|
|
PVFPath: "equipment/character/common/charm/test.equ",
|
|
EquipmentType: "[charm]",
|
|
}, nil
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
|
Body: currentMoveItemspaceBody(listTypeMain, 9, 0, 1, listTypeMain, 3, 0, 0, -1),
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
StackMergeResolver: resolver,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "move_itemspace" || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
ack := got.UpperResponses[0]
|
|
wantBody := []byte{0, moveItemspaceRejectedErrorCode, listTypeMain, listTypeMain}
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) || ack.Classification != 1 || !ack.AllowCodec ||
|
|
string(ack.Body) != string(wantBody) {
|
|
t.Fatalf("failure ACK = %+v body=% X, want % X", ack, ack.Body, wantBody)
|
|
}
|
|
if !strings.Contains(got.Reason, "release pending drag state") {
|
|
t.Fatalf("reason = %q", got.Reason)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if _, found := loaded.Slots[slotKey(listTypeMain, 3)]; found {
|
|
t.Fatalf("rejected second charm persisted in quick slot: %+v", loaded.Slots)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].ItemID; got != 400360003 {
|
|
t.Fatalf("existing charm changed to %d", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 9)].ItemID; got != 400360005 {
|
|
t.Fatalf("source charm changed to %d", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerMoveTouchesQuickSlotFailsClosedWhenPVFCatalogUnavailable(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {ItemID: 400360005, Count: 1},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
|
Body: currentMoveItemspaceBody(listTypeMain, 9, 0, 1, listTypeMain, 3, 0, 0, -1),
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
StackMergeResolverError: errors.New("test PVF catalog unavailable"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
wantBody := []byte{0, moveItemspaceRejectedErrorCode, listTypeMain, listTypeMain}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
|
!bytes.Equal(got.UpperResponses[0].Body, wantBody) ||
|
|
!strings.Contains(got.Reason, ErrQuickSlotCharmValidationUnavailable.Error()) {
|
|
t.Fatalf("catalog failure result = %+v, want body % X", got, wantBody)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if _, found := loaded.Slots[slotKey(listTypeMain, 3)]; found {
|
|
t.Fatalf("catalog failure populated quick slot: %+v", loaded.Slots)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 9)].ItemID; got != 400360005 {
|
|
t.Fatalf("catalog failure changed source item to %d", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerMoveAllowsRemovingLegacySecondPVFCharmFromQuickSlots(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 3): {ItemID: 400360003, Count: 1},
|
|
slotKey(listTypeMain, 4): {ItemID: 400360005, Count: 1},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
owner.SetStackMergeResolver(func(itemID int64) (alignedcmd.StackMergeResolution, error) {
|
|
return alignedcmd.StackMergeResolution{
|
|
ItemID: itemID,
|
|
Kind: "equipment",
|
|
PVFPath: "equipment/character/common/charm/test.equ",
|
|
EquipmentType: "[charm]",
|
|
}, nil
|
|
})
|
|
|
|
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, MoveItemspaceRequest{
|
|
SourceListType: listTypeMain,
|
|
SourceSlotIndex: 3,
|
|
MoveCount: 1,
|
|
DestinationListType: listTypeMain,
|
|
DestinationSlotIndex: 9,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Move error = %v", err)
|
|
}
|
|
if !result.Changed || result.Mode != "move" {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if _, found := loaded.Slots[slotKey(listTypeMain, 3)]; found {
|
|
t.Fatalf("removed charm still in quick slot: %+v", loaded.Slots)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].ItemID; got != 400360005 {
|
|
t.Fatalf("remaining charm = %d, want 400360005", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 9)].ItemID; got != 400360003 {
|
|
t.Fatalf("removed charm destination = %d, want 400360003", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerMoveSplitStackPersists(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
extra := map[string]string{
|
|
"item_kind": "stackable",
|
|
"pvf_path": "stackable/material/test_0100.stk",
|
|
"stack_limit": "20",
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 10, Extra: extra},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, MoveItemspaceRequest{
|
|
SourceListType: listTypeMain,
|
|
SourceSlotIndex: 2,
|
|
MoveCount: 3,
|
|
DestinationListType: listTypeMain,
|
|
DestinationSlotIndex: 8,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Move error = %v", err)
|
|
}
|
|
if result.Mode != "split" || result.MoveCount != 3 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 2)].Count; got != 7 {
|
|
t.Fatalf("source count = %d, want 7", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 8)].Count; got != 3 {
|
|
t.Fatalf("destination count = %d, want 3", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerMoveStacksSameItemPersists(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
extra := map[string]string{
|
|
"item_kind": "stackable",
|
|
"pvf_path": "stackable/material/test_0100.stk",
|
|
"stack_limit": "10",
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 3, Extra: extra},
|
|
slotKey(listTypeMain, 8): {ItemID: 100, Count: 4, Extra: extra},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, MoveItemspaceRequest{
|
|
SourceListType: listTypeMain,
|
|
SourceSlotIndex: 2,
|
|
MoveCount: 3,
|
|
DestinationListType: listTypeMain,
|
|
DestinationSlotIndex: 8,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Move error = %v", err)
|
|
}
|
|
if result.Mode != "stack" || result.MoveCount != 3 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if _, ok := loaded.Slots[slotKey(listTypeMain, 2)]; ok {
|
|
t.Fatalf("source slot should be empty: %+v", loaded.Slots)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 8)].Count; got != 7 {
|
|
t.Fatalf("destination count = %d, want 7", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerMoveSwapsDistinctItems(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 1},
|
|
slotKey(listTypeMain, 8): {ItemID: 200, Count: 1},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, MoveItemspaceRequest{
|
|
SourceListType: listTypeMain,
|
|
SourceSlotIndex: 2,
|
|
MoveCount: 1,
|
|
DestinationListType: listTypeMain,
|
|
DestinationSlotIndex: 8,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Move error = %v", err)
|
|
}
|
|
if result.Mode != "swap" {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 2)].ItemID; got != 200 {
|
|
t.Fatalf("source slot item = %d, want 200", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 8)].ItemID; got != 100 {
|
|
t.Fatalf("destination slot item = %d, want 100", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerMoveRejectsEquipmentOwner(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeAvatar, 2): {ItemID: 100, Count: 1},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, MoveItemspaceRequest{
|
|
SourceListType: listTypeEquipment,
|
|
SourceSlotIndex: 2,
|
|
MoveCount: 1,
|
|
DestinationListType: listTypeMain,
|
|
DestinationSlotIndex: 8,
|
|
}))
|
|
if !errors.Is(err, ErrMoveRequiresEquipmentOwner) {
|
|
t.Fatalf("Move error = %v, want ErrMoveRequiresEquipmentOwner", err)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeAvatar, 2)].ItemID; got != 100 {
|
|
t.Fatalf("persisted item = %d, want unchanged 100", got)
|
|
}
|
|
}
|
|
|
|
func TestSlotKeyKeepsEquipmentAndAvatarContainersDistinct(t *testing.T) {
|
|
if equipment, avatar := slotKey(listTypeEquipment, 11), slotKey(listTypeAvatar, 11); equipment == avatar {
|
|
t.Fatalf("equipment key %q aliases avatar key %q", equipment, avatar)
|
|
}
|
|
}
|
|
|
|
func TestHandlerMoveUsesOwnerAndReturnsAck(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 1},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
|
Body: currentMoveItemspaceBody(listTypeMain, 2, 0, 1, listTypeMain, 8, 0, 0, -1),
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "move_itemspace" {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
for _, want := range []string{"inventory owner applied", "0x0013"} {
|
|
if !strings.Contains(got.Reason, want) {
|
|
t.Fatalf("reason %q missing %q", got.Reason, want)
|
|
}
|
|
}
|
|
if len(got.UpperResponses) != 1 {
|
|
t.Fatalf("responses = %+v", got.UpperResponses)
|
|
}
|
|
ack := got.UpperResponses[0]
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) || ack.Classification != 1 || !ack.AllowCodec {
|
|
t.Fatalf("ack header = %+v", ack)
|
|
}
|
|
wantBody := []byte{1, listTypeMain, 0x02, 0x00, 1, 0, 0, 0, listTypeMain, 0x08, 0x00}
|
|
if string(ack.Body) != string(wantBody) {
|
|
t.Fatalf("ack body = % X, want % X", ack.Body, wantBody)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if _, ok := loaded.Slots[slotKey(listTypeMain, 2)]; ok {
|
|
t.Fatalf("source slot should be empty: %+v", loaded.Slots)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 8)].ItemID; got != 100 {
|
|
t.Fatalf("destination item = %d, want 100", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerMoveDirectGrantedPetCreatesTypedState(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypePet, 0): {
|
|
ItemID: 400990199,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"source": "booster_item",
|
|
"item_kind": "equipment",
|
|
"equipment_type": "[creature]",
|
|
"pvf_path": "equipment/creature/2018Summer/chn_2018_summer_400990199.equ",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
|
Body: currentMoveItemspaceBody(listTypePet, 0, 0, 1, listTypePet, 24, 0, 0, -1),
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "move_itemspace" {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if len(got.PostActions) != 1 || got.PostActions[0] != alignedcmd.PostActionRefreshSelectedItemContainers {
|
|
t.Fatalf("post actions = %v", got.PostActions)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if _, ok := loaded.Slots[slotKey(listTypePet, 0)]; ok {
|
|
t.Fatalf("pet source slot should be empty: %+v", loaded.Slots)
|
|
}
|
|
moved := loaded.Slots[slotKey(listTypePet, 24)]
|
|
serial := moved.Extra["creature_serial_or_handle"]
|
|
if moved.ItemID != 400990199 || serial == "" || moved.Extra["creature_key"] != serial {
|
|
t.Fatalf("moved pet = %+v", moved)
|
|
}
|
|
petRecord, ok, err := repos.Pet.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load pet ok=%t err=%v", ok, err)
|
|
}
|
|
entry := petRecord.Entries[serial]
|
|
if entry.ItemID != moved.ItemID || entry.SourceListType != listTypePet || entry.SourceSlotIndex != 24 || entry.Level != 1 || entry.Satiety != 100 {
|
|
t.Fatalf("pet entry = %+v", entry)
|
|
}
|
|
}
|
|
|
|
func TestHandlerMovePetToActorEndpoint17ReturnsAckThenAuthoritativeRefreshes(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypePet, 48): {ItemID: 9001, Count: 1, Extra: map[string]string{"creature_serial_or_handle": "37"}},
|
|
},
|
|
})
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}}); err != nil {
|
|
t.Fatalf("Save equipment error = %v", err)
|
|
}
|
|
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
|
CharacterID: "77",
|
|
Entries: map[string]dnfrepo.PetEntry{
|
|
"37": {PetKey: "37", CreatureKey: 37, ItemID: 9001, SourceListType: listTypePet, SourceSlotIndex: 48, Level: 1, Satiety: 100},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("Save pet error = %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
|
Body: currentMoveItemspaceBody(listTypePet, 48, 37, 1, listTypeActorWornAlt, 26, 0, 0, -1),
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "move_itemspace" {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if len(got.UpperResponses) != 1 {
|
|
t.Fatalf("responses = %+v", got.UpperResponses)
|
|
}
|
|
if len(got.ItemSlotRefreshes) != 0 {
|
|
t.Fatalf("pet slot26 equip emitted duplicate item slot refreshes=%v", got.ItemSlotRefreshes)
|
|
}
|
|
wantPetActions := []alignedcmd.PostAction{
|
|
alignedcmd.PostActionRefreshSelectedWornCreatureInventory,
|
|
alignedcmd.PostActionRefreshSelectedCreatureState,
|
|
alignedcmd.PostActionRefreshSelectedActorAppearance,
|
|
}
|
|
if !reflect.DeepEqual(got.PostActions, wantPetActions) {
|
|
t.Fatalf("post actions = %v, want %v", got.PostActions, wantPetActions)
|
|
}
|
|
if ack := got.UpperResponses[0]; ack.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) || string(ack.Body) != string([]byte{1, listTypePet, 0x30, 0x00, 1, 0, 0, 0, listTypeActorWornAlt, 0x1A, 0x00}) {
|
|
t.Fatalf("ack = %+v body=% X", ack, ack.Body)
|
|
}
|
|
|
|
inventory := loadTestInventory(t, ctx, repos, "77")
|
|
if _, ok := inventory.Slots[slotKey(listTypePet, 48)]; ok {
|
|
t.Fatalf("pet source slot should be empty: %+v", inventory.Slots)
|
|
}
|
|
equipment, ok, err := repos.Equipment.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load equipment ok=%v err=%v", ok, err)
|
|
}
|
|
if entry := equipment.Entries["26"]; entry.ItemID != 9001 || len(entry.RawEntry) < 28 {
|
|
t.Fatalf("equipment pet entry = %+v", entry)
|
|
}
|
|
pet, ok, err := repos.Pet.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load pet ok=%v err=%v", ok, err)
|
|
}
|
|
if pet.EquippedKey != "37" || pet.Entries["37"].SourceListType != listTypeEquipment || pet.Entries["37"].SourceSlotIndex != 26 {
|
|
t.Fatalf("equipped pet state = %+v", pet)
|
|
}
|
|
}
|
|
|
|
func TestHandlerMovePetUnequipKeepsCurrentEXEEndpointOrder(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
|
CharacterID: "77",
|
|
Entries: map[string]dnfrepo.EquipmentEntry{
|
|
"26": {SlotIndex: 26, ItemID: 9001, RawEntry: testPetEquipRaw(26, 9001, 37), Extra: map[string]string{"source": "equipped"}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("Save equipment error = %v", err)
|
|
}
|
|
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
|
CharacterID: "77",
|
|
Entries: map[string]dnfrepo.PetEntry{
|
|
"37": {PetKey: "37", CreatureKey: 37, ItemID: 9001, SourceListType: listTypeEquipment, SourceSlotIndex: 26, Level: 1, Satiety: 100},
|
|
},
|
|
EquippedKey: "37",
|
|
}); err != nil {
|
|
t.Fatalf("Save pet error = %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
|
Body: currentMoveItemspaceBody(listTypePet, 48, 0, 1, listTypeActorWornAlt, 26, 37, 0, -1),
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if len(got.UpperResponses) != 1 {
|
|
t.Fatalf("responses = %+v", got.UpperResponses)
|
|
}
|
|
if len(got.ItemSlotRefreshes) != 0 {
|
|
t.Fatalf("pet slot26 unequip emitted duplicate item slot refreshes=%v", got.ItemSlotRefreshes)
|
|
}
|
|
wantPetActions := []alignedcmd.PostAction{
|
|
alignedcmd.PostActionRefreshSelectedWornCreatureInventory,
|
|
alignedcmd.PostActionRefreshSelectedCreatureState,
|
|
alignedcmd.PostActionRefreshSelectedActorAppearance,
|
|
}
|
|
if !reflect.DeepEqual(got.PostActions, wantPetActions) {
|
|
t.Fatalf("post actions = %v, want %v", got.PostActions, wantPetActions)
|
|
}
|
|
if ack := got.UpperResponses[0]; ack.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) || string(ack.Body) != string([]byte{1, listTypePet, 0x30, 0x00, 1, 0, 0, 0, listTypeActorWornAlt, 0x1A, 0x00}) {
|
|
t.Fatalf("ack = %+v body=% X", ack, ack.Body)
|
|
}
|
|
|
|
equipment, ok, err := repos.Equipment.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load equipment ok=%v err=%v", ok, err)
|
|
}
|
|
if _, ok := equipment.Entries["26"]; ok {
|
|
t.Fatalf("equipment slot should be empty: %+v", equipment.Entries)
|
|
}
|
|
inventory := loadTestInventory(t, ctx, repos, "77")
|
|
if stack := inventory.Slots[slotKey(listTypePet, 48)]; stack.ItemID != 9001 || stack.Extra["creature_serial_or_handle"] != "37" {
|
|
t.Fatalf("pet stack = %+v", stack)
|
|
}
|
|
pet, ok, err := repos.Pet.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load pet ok=%v err=%v", ok, err)
|
|
}
|
|
if pet.EquippedKey != "" || pet.Entries["37"].SourceListType != listTypePet || pet.Entries["37"].SourceSlotIndex != 48 {
|
|
t.Fatalf("unequipped pet state = %+v", pet)
|
|
}
|
|
}
|
|
|
|
func TestHandlerMoveEquipmentBlocksBeforeOwnerAndMutation(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {ItemID: 700, Count: 1, Extra: map[string]string{"raw_entry_hex": "000102030405060708090c000d0e"}},
|
|
},
|
|
})
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}}); err != nil {
|
|
t.Fatalf("Save equipment error = %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
|
Body: currentMoveItemspaceBody(listTypeMain, 5, 0, 1, listTypeEquipment, 11, 0, 0, -1),
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || got.ResponseAllowed || got.Operation != "move_itemspace" || len(got.UpperResponses) != 0 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if !strings.Contains(got.Reason, "normal equipment move requires the current PVF placement validator") {
|
|
t.Fatalf("reason = %q", got.Reason)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if stack := loaded.Slots[slotKey(listTypeMain, 5)]; stack.ItemID != 700 || stack.Count != 1 {
|
|
t.Fatalf("source slot mutated: %+v", stack)
|
|
}
|
|
equipment, ok, err := repos.Equipment.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Load equipment ok=%t err=%v", ok, err)
|
|
}
|
|
if len(equipment.Entries) != 0 {
|
|
t.Fatalf("equipment mutated: %+v", equipment.Entries)
|
|
}
|
|
}
|
|
|
|
func TestHandlerMoveEquipmentUsesPVFValidatorAndReturnsAckThenActorRefresh(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
raw := make([]byte, currentItemListEntrySize)
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {ItemID: 700, Count: 1, RawEntry: raw},
|
|
},
|
|
})
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
placements := make([]alignedcmd.EquipmentPlacementRequest, 0, 1)
|
|
request := alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
|
Body: currentMoveItemspaceBody(listTypeMain, 5, 0, 1, listTypeEquipment, 11, 0, 0, -1),
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
EquipmentPlacement: func(_ context.Context, placement alignedcmd.EquipmentPlacementRequest) error {
|
|
placements = append(placements, placement)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
equipped, err := NewHandler().Handle(ctx, request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantWearActions := []alignedcmd.PostAction{
|
|
alignedcmd.PostActionRefreshSelectedActorAppearance,
|
|
}
|
|
if !equipped.Handled || !equipped.ResponseAllowed || len(equipped.UpperResponses) != 1 ||
|
|
!reflect.DeepEqual(equipped.PostActions, wantWearActions) {
|
|
t.Fatalf("equip result = %+v", equipped)
|
|
}
|
|
if len(equipped.ItemSlotRefreshes) != 0 {
|
|
t.Fatalf("equipment equip emitted duplicate item slot refreshes=%v", equipped.ItemSlotRefreshes)
|
|
}
|
|
wantAck := []byte{1, listTypeMain, 5, 0, 1, 0, 0, 0, listTypeEquipment, 11, 0}
|
|
if got := equipped.UpperResponses[0].Body; string(got) != string(wantAck) {
|
|
t.Fatalf("equip ACK = %x, want %x", got, wantAck)
|
|
}
|
|
if len(placements) != 1 || placements[0].CharacterID != "77" || placements[0].ItemID != 700 || placements[0].SourceListType != listTypeMain || placements[0].TargetSlotIndex != 11 {
|
|
t.Fatalf("placements = %+v", placements)
|
|
}
|
|
if _, ok := loadTestInventory(t, ctx, repos, "77").Slots[slotKey(listTypeMain, 5)]; ok {
|
|
t.Fatal("equipped source slot still occupied")
|
|
}
|
|
storedEquipment, ok, err := repos.Equipment.Load(ctx, "77")
|
|
if err != nil || !ok || storedEquipment.Entries["11"].ItemID != 700 {
|
|
t.Fatalf("stored equipment ok=%t err=%v record=%+v", ok, err, storedEquipment)
|
|
}
|
|
|
|
unequipped, err := NewHandler().Handle(ctx, request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !unequipped.ResponseAllowed || len(unequipped.UpperResponses) != 1 ||
|
|
!reflect.DeepEqual(unequipped.PostActions, wantWearActions) {
|
|
t.Fatalf("unequip result = %+v", unequipped)
|
|
}
|
|
if len(unequipped.ItemSlotRefreshes) != 0 {
|
|
t.Fatalf("equipment unequip emitted duplicate item slot refreshes=%v", unequipped.ItemSlotRefreshes)
|
|
}
|
|
if got := unequipped.UpperResponses[0].Body; string(got) != string(wantAck) {
|
|
t.Fatalf("unequip ACK changed endpoint order: %x", got)
|
|
}
|
|
if stack := loadTestInventory(t, ctx, repos, "77").Slots[slotKey(listTypeMain, 5)]; stack.ItemID != 700 {
|
|
t.Fatalf("unequipped stack = %+v", stack)
|
|
}
|
|
}
|
|
|
|
func TestHandlerMoveEquipmentAcceptsDualWeaponToggleCountZero(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
|
CharacterID: "77",
|
|
Entries: map[string]dnfrepo.EquipmentEntry{
|
|
"12": {SlotIndex: 12, ItemID: 712, RawEntry: []byte{0x12}},
|
|
"24": {SlotIndex: 24, ItemID: 724, RawEntry: []byte{0x24}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
placements := make([]alignedcmd.EquipmentPlacementRequest, 0, 2)
|
|
result, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
|
Body: currentMoveItemspaceBody(listTypeEquipment, 12, 0x6E68, 0, listTypeEquipment, 24, 0x0605C03F, 0, -1),
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
EquipmentPlacement: func(_ context.Context, placement alignedcmd.EquipmentPlacementRequest) error {
|
|
placements = append(placements, placement)
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !result.Handled || !result.ResponseAllowed || result.Operation != "move_itemspace" || len(result.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
// The swap touches durable slot 24, so this tree also refreshes the
|
|
// expanded (op342) equipment group after the mode0 appearance snapshot.
|
|
wantActions := []alignedcmd.PostAction{
|
|
alignedcmd.PostActionRefreshSelectedActorAppearance,
|
|
alignedcmd.PostActionRefreshSelectedActorExpandedEquipment,
|
|
}
|
|
if !reflect.DeepEqual(result.PostActions, wantActions) {
|
|
t.Fatalf("post actions = %v, want %v", result.PostActions, wantActions)
|
|
}
|
|
wantACK := []byte{1, listTypeEquipment, 12, 0, 0, 0, 0, 0, listTypeEquipment, 24, 0}
|
|
if got := result.UpperResponses[0].Body; !bytes.Equal(got, wantACK) {
|
|
t.Fatalf("dual-weapon toggle ACK = %x, want %x", got, wantACK)
|
|
}
|
|
entries, ok, err := repos.Equipment.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Load equipment ok=%t err=%v", ok, err)
|
|
}
|
|
if entries.Entries["12"].ItemID != 724 || entries.Entries["24"].ItemID != 712 {
|
|
t.Fatalf("equipment not swapped: %+v", entries.Entries)
|
|
}
|
|
if len(placements) != 2 || placements[0].TargetSlotIndex != 24 || placements[1].TargetSlotIndex != 12 {
|
|
t.Fatalf("placements = %+v", placements)
|
|
}
|
|
}
|
|
|
|
func TestHandlerMoveEquipmentRedirectsStaleUnequipDestinationAndReportsCommittedSourceInAck(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 10): {ItemID: 900, Count: 1, RawEntry: []byte{0x90}},
|
|
},
|
|
})
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
|
CharacterID: "77",
|
|
Entries: map[string]dnfrepo.EquipmentEntry{
|
|
"17": {SlotIndex: 17, ItemID: 700, RawEntry: []byte{0x70}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
placements := make([]alignedcmd.EquipmentPlacementRequest, 0, 1)
|
|
result, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
|
Body: currentMoveItemspaceBody(listTypeMain, 10, 0, 1, listTypeEquipment, 17, 0x47E0, 0, -1),
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
EquipmentPlacement: func(_ context.Context, placement alignedcmd.EquipmentPlacementRequest) error {
|
|
placements = append(placements, placement)
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantWearActions := []alignedcmd.PostAction{
|
|
alignedcmd.PostActionRefreshSelectedActorAppearance,
|
|
}
|
|
if !result.ResponseAllowed || len(result.UpperResponses) != 1 ||
|
|
!reflect.DeepEqual(result.PostActions, wantWearActions) {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
if len(result.ItemSlotRefreshes) != 0 {
|
|
t.Fatalf("redirected equipment unequip emitted duplicate item slot refreshes=%v", result.ItemSlotRefreshes)
|
|
}
|
|
wantAck := []byte{1, listTypeMain, 11, 0, 1, 0, 0, 0, listTypeEquipment, 17, 0}
|
|
if got := result.UpperResponses[0].Body; !bytes.Equal(got, wantAck) {
|
|
t.Fatalf("redirected ACK = %x, want %x", got, wantAck)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if loaded.Slots[slotKey(listTypeMain, 10)].ItemID != 900 || loaded.Slots[slotKey(listTypeMain, 11)].ItemID != 700 {
|
|
t.Fatalf("redirected inventory = %+v", loaded.Slots)
|
|
}
|
|
storedEquipment, ok, err := repos.Equipment.Load(ctx, "77")
|
|
if err != nil || !ok || len(storedEquipment.Entries) != 0 {
|
|
t.Fatalf("equipment after unequip ok=%t err=%v entries=%+v", ok, err, storedEquipment.Entries)
|
|
}
|
|
if len(placements) != 0 {
|
|
t.Fatalf("redirected unequip unexpectedly invoked PVF swap validator: %+v", placements)
|
|
}
|
|
}
|
|
|
|
func TestOwnerSortMainSegmentPersists(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {ItemID: 300, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
|
|
slotKey(listTypeMain, 12): {ItemID: 200, Count: 1, Extra: map[string]string{"item_kind": "equipment"}},
|
|
slotKey(listTypeMain, 20): {ItemID: 100, Count: 1, Extra: map[string]string{"item_kind": "avatar"}},
|
|
slotKey(listTypeMain, 65): {ItemID: 999, Count: 1, Extra: map[string]string{"item_kind": "equipment"}},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Sort(ctx, NewSortCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, SortItemRequest{
|
|
ListType: listTypeMain,
|
|
Category: 1,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Sort error = %v", err)
|
|
}
|
|
if !result.Changed || result.Mode != "sort" || result.MovedCount != 3 || result.StartSlot != 9 || result.EndSlot != 64 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 9)].ItemID; got != 100 {
|
|
t.Fatalf("slot 9 item = %d, want 100", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 10)].ItemID; got != 200 {
|
|
t.Fatalf("slot 10 item = %d, want 200", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 11)].ItemID; got != 300 {
|
|
t.Fatalf("slot 11 item = %d, want 300", got)
|
|
}
|
|
if _, ok := loaded.Slots[slotKey(listTypeMain, 12)]; ok {
|
|
t.Fatalf("slot 12 should be empty after compaction: %+v", loaded.Slots)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 65)].ItemID; got != 999 {
|
|
t.Fatalf("outside segment item = %d, want 999", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerSortMainSegmentKeepsSortLockedItemAtItsCurrentSlot(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {ItemID: 300, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
|
|
slotKey(listTypeMain, 12): {ItemID: 200, Count: 1, Extra: map[string]string{"item_kind": "equipment", "item_sort_lock_state": "1"}},
|
|
slotKey(listTypeMain, 20): {ItemID: 100, Count: 1, Extra: map[string]string{"item_kind": "avatar"}},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Sort(ctx, NewSortCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, SortItemRequest{ListType: listTypeMain, Category: 1}))
|
|
if err != nil {
|
|
t.Fatalf("Sort error = %v", err)
|
|
}
|
|
if !result.Changed || result.MovedCount != 2 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 9)].ItemID; got != 100 {
|
|
t.Fatalf("slot 9 item=%d want sorted item 100", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 10)].ItemID; got != 300 {
|
|
t.Fatalf("slot 10 item=%d want sorted item 300", got)
|
|
}
|
|
locked := loaded.Slots[slotKey(listTypeMain, 12)]
|
|
if locked.ItemID != 200 || locked.Extra["item_sort_lock_state"] != "1" {
|
|
t.Fatalf("sort lock anchor was moved or cleared: %+v", locked)
|
|
}
|
|
}
|
|
|
|
func TestOwnerSortUnknownCategoryIsNoop(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 20): {ItemID: 100, Count: 1},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Sort(ctx, NewSortCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, SortItemRequest{
|
|
ListType: listTypeMain,
|
|
Category: 99,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Sort error = %v", err)
|
|
}
|
|
if result.Changed || result.Mode != "noop" || result.MovedCount != 0 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 20)].ItemID; got != 100 {
|
|
t.Fatalf("slot 20 item = %d, want unchanged 100", got)
|
|
}
|
|
}
|
|
|
|
// 实况日志曾见金库整理按钮以 list=12 category=11 到达并被 "account cargo sort is
|
|
// unsupported" 拒掉。list 12 只存在 AccountInventory,整理必须走账号 owner 的事务,
|
|
// 用与角色容器完全相同的 kind/itemID/slot 规则并钉住整理锁定行;角色记录不受影响。
|
|
func TestOwnerSortRepacksAccountCargoUnderAccountOwner(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
|
AccountID: "acc",
|
|
Metadata: map[string]string{"account_cargo_created": "true", "account_cargo_level": "8"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
|
AccountID: "acc",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeAccountCargo, 5): {ItemID: 300, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
|
|
slotKey(listTypeAccountCargo, 2): {ItemID: 200, Count: 1, Extra: map[string]string{"item_kind": "equipment", "item_sort_lock_state": "1"}},
|
|
slotKey(listTypeAccountCargo, 7): {ItemID: 100, Count: 1, Extra: map[string]string{"item_kind": "avatar"}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Warehouse: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypePersonalCargo, 1): {ItemID: 100, Count: 1},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Sort(ctx, NewSortCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, SortItemRequest{
|
|
ListType: listTypeAccountCargo,
|
|
Category: 11,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Sort error = %v", err)
|
|
}
|
|
if !result.Changed || result.ListType != listTypeAccountCargo || result.MovedCount != 2 || result.StartSlot != 0 || result.EndSlot != 7 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
cargo, found, err := repos.AccountInventory.Load(ctx, "acc")
|
|
if err != nil || !found {
|
|
t.Fatalf("load account cargo found=%t err=%v", found, err)
|
|
}
|
|
if got := cargo.Slots[slotKey(listTypeAccountCargo, 0)].ItemID; got != 100 {
|
|
t.Fatalf("slot 0 item = %d, want avatar 100 first", got)
|
|
}
|
|
if got := cargo.Slots[slotKey(listTypeAccountCargo, 1)].ItemID; got != 300 {
|
|
t.Fatalf("slot 1 item = %d, want stackable 300 next", got)
|
|
}
|
|
if got := cargo.Slots[slotKey(listTypeAccountCargo, 2)].ItemID; got != 200 {
|
|
t.Fatalf("slot 2 item = %d, want the sort-locked 200 pinned", got)
|
|
}
|
|
if _, stale := cargo.Slots[slotKey(listTypeAccountCargo, 5)]; stale {
|
|
t.Fatalf("slot 5 still occupied after repack: %+v", cargo.Slots)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Warehouse[slotKey(listTypePersonalCargo, 1)].ItemID; got != 100 {
|
|
t.Fatalf("warehouse item = %d, want unchanged 100", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerSortAccountCargoRejectsUncreatedCargo(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc", Metadata: map[string]string{}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77"})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.Sort(ctx, NewSortCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, SortItemRequest{ListType: listTypeAccountCargo, Category: 11}))
|
|
if !errors.Is(err, ErrAccountCargoNotCreated) {
|
|
t.Fatalf("Sort error = %v, want ErrAccountCargoNotCreated", err)
|
|
}
|
|
}
|
|
|
|
// list-12 整理的 ACK 之后刷新交给账号所有者的 post action,不能用按角色的容器刷新。
|
|
func TestOwnerAppliedSortResultRoutesAccountCargoToAccountRefresh(t *testing.T) {
|
|
cmd := NewSortCommand(alignedcmd.Request{AccountID: "acc", SelectedCharacterID: 77}, SortItemRequest{ListType: listTypeAccountCargo, Category: 11})
|
|
got := ownerAppliedSortResult(20, cmd, SortResult{ListType: listTypeAccountCargo, Mode: "sort", Changed: true})
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if len(got.PostActions) != 1 || got.PostActions[0] != alignedcmd.PostActionRefreshSelectedAccountCargo {
|
|
t.Fatalf("post actions = %v, want the account cargo refresh", got.PostActions)
|
|
}
|
|
}
|
|
|
|
func TestHandlerSortPersonalCargoKeepsReinforceAndAmplifyTicketsInWarehouse(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
reinforceRaw := make([]byte, currentItemListEntrySize)
|
|
reinforceRaw[0x59] = 0xA1
|
|
amplifyRaw := make([]byte, currentItemListEntrySize)
|
|
amplifyRaw[0x59] = 0xB2
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{},
|
|
Warehouse: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypePersonalCargo, 7): {
|
|
ItemID: 10008121, Count: 1, RawEntry: amplifyRaw,
|
|
Extra: map[string]string{"item_kind": "stackable", "pvf_path": "stackable/cash/chn_20150827_Blind_Purchase/deed_8equipmentamplify_10008121.stk"},
|
|
},
|
|
slotKey(listTypePersonalCargo, 5): {
|
|
ItemID: 7273, Count: 1, RawEntry: reinforceRaw,
|
|
Extra: map[string]string{"item_kind": "stackable", "pvf_path": "stackable/event/tcg/deed_7equipmentstrengthening.stk"},
|
|
},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketSortItem), Body: []byte{listTypePersonalCargo, 11, 0},
|
|
AccountID: "acc", SelectedCharacterID: 77, Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle: %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.PostActions) != 1 || got.PostActions[0] != alignedcmd.PostActionRefreshSelectedPersonalCargo {
|
|
t.Fatalf("result=%+v", got)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if len(loaded.Slots) != 0 {
|
|
t.Fatalf("tickets returned to backpack: %+v", loaded.Slots)
|
|
}
|
|
reinforce := loaded.Warehouse[slotKey(listTypePersonalCargo, 0)]
|
|
amplify := loaded.Warehouse[slotKey(listTypePersonalCargo, 1)]
|
|
if reinforce.ItemID != 7273 || len(reinforce.RawEntry) != currentItemListEntrySize || reinforce.RawEntry[0x59] != 0xA1 {
|
|
t.Fatalf("reinforce ticket=%+v", reinforce)
|
|
}
|
|
if amplify.ItemID != 10008121 || len(amplify.RawEntry) != currentItemListEntrySize || amplify.RawEntry[0x59] != 0xB2 {
|
|
t.Fatalf("amplify ticket=%+v", amplify)
|
|
}
|
|
}
|
|
|
|
func TestHandlerSortMainReturnsAckThenRepositoryRefreshPostAction(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 12): {ItemID: 200, Count: 1, Extra: map[string]string{"item_kind": "equipment"}},
|
|
slotKey(listTypeMain, 20): {ItemID: 100, Count: 1, Extra: map[string]string{"item_kind": "avatar"}},
|
|
},
|
|
})
|
|
if err := repos.Settings.Save(ctx, dnfrepo.SettingsRecord{
|
|
Scope: dnfrepo.CharacterContainerStateScope("77"),
|
|
Values: map[string]string{
|
|
"main_list_param16": "24",
|
|
"avatar_list_param16": "0",
|
|
"personal_cargo_list_param16": "8",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save container state: %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketSortItem),
|
|
Body: []byte{6, 0, 0, 0, 16, listTypeMain, 24, 1, 32, 0},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "sort_item" {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if len(got.UpperResponses) != 1 {
|
|
t.Fatalf("responses = %+v", got.UpperResponses)
|
|
}
|
|
ack := got.UpperResponses[0]
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketSortItem) || ack.Classification != dnfproto.DefaultChannelClassification || !ack.AllowCodec {
|
|
t.Fatalf("ack = %+v", ack)
|
|
}
|
|
if string(ack.Body) != string([]byte{1, 4, 0, 0, 0, 8, listTypeMain, 16, 0}) {
|
|
t.Fatalf("ack body = % X", ack.Body)
|
|
}
|
|
if len(got.PostActions) != 1 || got.PostActions[0] != alignedcmd.PostActionRefreshSelectedItemContainers {
|
|
t.Fatalf("post actions = %v, want repository-backed item-container refresh", got.PostActions)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 9)].ItemID; got != 100 {
|
|
t.Fatalf("slot 9 item = %d, want 100", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 10)].ItemID; got != 200 {
|
|
t.Fatalf("slot 10 item = %d, want 200", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerSortPetFailsClosedWithoutRefresh(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypePet, 5): {ItemID: 900, Count: 1, Extra: map[string]string{"creature_serial_or_handle": "1234"}},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketSortItem),
|
|
Body: []byte{listTypePet, 0x05, 0x00},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || got.ResponseAllowed || got.Operation != "sort_item" || len(got.UpperResponses) != 0 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if !strings.Contains(got.Reason, ErrPetInventoryOwnerRequired.Error()) {
|
|
t.Fatalf("reason = %q", got.Reason)
|
|
}
|
|
stack := loadTestInventory(t, ctx, repos, "77").Slots[slotKey(listTypePet, 5)]
|
|
if stack.ItemID != 900 || stack.Count != 1 || stack.Extra["creature_serial_or_handle"] != "1234" {
|
|
t.Fatalf("pet inventory mutated: %+v", stack)
|
|
}
|
|
}
|
|
|
|
func TestHandlerSortAvatarReturnsAckThenRepositoryRefreshPostAction(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeAvatar, 8): {ItemID: 300, Count: 1, Extra: map[string]string{"item_kind": "avatar"}},
|
|
slotKey(listTypeAvatar, 10): {ItemID: 200, Count: 1, Extra: map[string]string{"item_kind": "avatar"}},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketSortItem),
|
|
Body: []byte{listTypeAvatar, 0x08, 0x00},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "sort_item" || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
ack := got.UpperResponses[0]
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketSortItem) || string(ack.Body) != string([]byte{1, 4, 0, 0, 0, 8, listTypeAvatar, 16, 0}) {
|
|
t.Fatalf("ack = %+v", ack)
|
|
}
|
|
if len(got.PostActions) != 1 || got.PostActions[0] != alignedcmd.PostActionRefreshSelectedItemContainers {
|
|
t.Fatalf("post actions = %v, want repository-backed item-container refresh", got.PostActions)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if loaded.Slots[slotKey(listTypeAvatar, 0)].ItemID != 200 || loaded.Slots[slotKey(listTypeAvatar, 1)].ItemID != 300 {
|
|
t.Fatalf("sorted avatar slots = %+v", loaded.Slots)
|
|
}
|
|
}
|
|
|
|
func TestOwnerRepairZeroCostPersistsDurability(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 43210}})
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "12",
|
|
"max_durability": "20",
|
|
"repair_gold": "0",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Repair(ctx, NewRepairCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, RepairEquipmentRequest{
|
|
InvenType: listTypeMain,
|
|
SlotIndex: 5,
|
|
}), inventoryRepairTestResolver(20, 0, 20))
|
|
if err != nil {
|
|
t.Fatalf("Repair error = %v", err)
|
|
}
|
|
if !result.Changed || result.OldDurability != 12 || result.NewDurability != 20 || result.Cost != 0 || result.UpdatedGold != 43210 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 5)].Extra["durability"]; got != "20" {
|
|
t.Fatalf("durability = %q, want 20", got)
|
|
}
|
|
}
|
|
|
|
// TestOwnerRepairKeepsCurrentRowPackedUpgradeByte 复现现场 2026-09-18:脱下→修理→调整品级
|
|
// 之后装备凭空多出强化等级。当前 0x77 行的耐久在 +0x0B,+0x0A 是打包的强化/属性字节;
|
|
// 旧代码对所有 len>=12 的行都写 [10:12],等于把"最大耐久"写成了强化等级(腰带 +25、
|
|
// 上衣 +33),同时耐久只写了一半。修理必须只改耐久字段。
|
|
func TestOwnerRepairKeepsCurrentRowPackedUpgradeByte(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 43210}})
|
|
|
|
// 当前 0x77 行:+0x02 物品 id、+0x06 品级种子、+0x0A 打包强化字节(0)、+0x0B 耐久。
|
|
raw := make([]byte, 0x77)
|
|
binary.LittleEndian.PutUint16(raw[0:2], uint16(5))
|
|
binary.LittleEndian.PutUint32(raw[2:6], 100220519)
|
|
binary.LittleEndian.PutUint32(raw[6:10], 836078919)
|
|
raw[0x0A] = 0
|
|
binary.LittleEndian.PutUint16(raw[0x0B:0x0D], 19)
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {
|
|
ItemID: 100220519,
|
|
Count: 1,
|
|
RawEntry: raw,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "19",
|
|
"max_durability": "25",
|
|
"repair_gold": "0",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Repair(ctx, NewRepairCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, RepairEquipmentRequest{
|
|
InvenType: listTypeMain,
|
|
SlotIndex: 5,
|
|
}), inventoryRepairTestResolver(25, 0, 25))
|
|
if err != nil {
|
|
t.Fatalf("Repair error = %v", err)
|
|
}
|
|
if result.OldDurability != 19 || result.NewDurability != 25 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
stack := loaded.Slots[slotKey(listTypeMain, 5)]
|
|
if stack.RawEntry[0x0A] != 0 {
|
|
t.Fatalf("修理把最大耐久写进了强化字节: raw[0x0A]=%d", stack.RawEntry[0x0A])
|
|
}
|
|
if got := binary.LittleEndian.Uint32(stack.RawEntry[6:10]); got != 836078919 {
|
|
t.Fatalf("品级种子被改动: %d", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(stack.RawEntry[0x0B:0x0D]); got != 25 {
|
|
t.Fatalf("当前行耐久应写在 +0x0B: %d", got)
|
|
}
|
|
if got := upgradeLevelOf(stack); got != 0 {
|
|
t.Fatalf("修理后不应出现强化等级: %d", got)
|
|
}
|
|
}
|
|
|
|
// TestOwnerRepairKeepsLegacyRowDurabilityOffset 旧构造行(物品 id 在 +1、强化在 +9、
|
|
// 耐久在 +10)必须保持原偏移,不能被新分支改坏。
|
|
func TestOwnerRepairKeepsLegacyRowDurabilityOffset(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 43210}})
|
|
|
|
legacy := make([]byte, 15)
|
|
legacy[0] = 5
|
|
binary.LittleEndian.PutUint32(legacy[1:5], 700)
|
|
binary.LittleEndian.PutUint32(legacy[5:9], 1234)
|
|
legacy[9] = 7 // 打包强化
|
|
binary.LittleEndian.PutUint16(legacy[10:12], 11)
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
RawEntry: legacy,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "11",
|
|
"max_durability": "20",
|
|
"repair_gold": "0",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
if _, err := owner.Repair(ctx, NewRepairCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, RepairEquipmentRequest{
|
|
InvenType: listTypeMain,
|
|
SlotIndex: 5,
|
|
}), inventoryRepairTestResolver(20, 0, 20)); err != nil {
|
|
t.Fatalf("Repair error = %v", err)
|
|
}
|
|
|
|
stack := loadTestInventory(t, ctx, repos, "77").Slots[slotKey(listTypeMain, 5)]
|
|
if got := binary.LittleEndian.Uint16(stack.RawEntry[10:12]); got != 20 {
|
|
t.Fatalf("旧行耐久应写在 +10: %d", got)
|
|
}
|
|
if stack.RawEntry[9] != 7 {
|
|
t.Fatalf("旧行打包强化字节被改动: %d", stack.RawEntry[9])
|
|
}
|
|
}
|
|
|
|
func inventoryRepairTestResolver(maxDurability int64, repairPrice int64, grade int64) alignedcmd.RepairCostResolver {
|
|
return func(itemID int64) (alignedcmd.RepairCostEvidence, error) {
|
|
return alignedcmd.RepairCostEvidence{
|
|
EquipmentType: "[weapon]",
|
|
MaxDurability: maxDurability,
|
|
RepairPrice: repairPrice,
|
|
Grade: grade,
|
|
RepairCostRate: 0.08,
|
|
QuickRepairRate: 1.5,
|
|
UpgradeRates: []float64{1, 1, 1},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func TestOwnerRepairDeductsFormulaCostAndGoldAtomically(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 1000}})
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "12",
|
|
"max_durability": "20",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
// 86JP formula: 6400*(20+5)/10=16000; 16000*0.08/20*8 = 512.
|
|
result, err := owner.Repair(ctx, NewRepairCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, RepairEquipmentRequest{
|
|
InvenType: listTypeMain,
|
|
SlotIndex: 5,
|
|
}), inventoryRepairTestResolver(20, 6400, 20))
|
|
if err != nil {
|
|
t.Fatalf("Repair error = %v", err)
|
|
}
|
|
if !result.Changed || result.Cost != 512 || result.UpdatedGold != 488 || result.FreeRepair {
|
|
t.Fatalf("result = %+v, want cost=512 gold=488", result)
|
|
}
|
|
character, _, _ := repos.Character.Load(ctx, "77")
|
|
if character.Stats["gold"] != 488 {
|
|
t.Fatalf("persisted gold = %d, want 488", character.Stats["gold"])
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 5)].Extra["durability"]; got != "20" {
|
|
t.Fatalf("durability = %q, want 20", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerRepairInsufficientGoldRollsBack(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 100}})
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "12",
|
|
"max_durability": "20",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.Repair(ctx, NewRepairCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, RepairEquipmentRequest{
|
|
InvenType: listTypeMain,
|
|
SlotIndex: 5,
|
|
}), inventoryRepairTestResolver(20, 6400, 20))
|
|
if !errors.Is(err, ErrRepairGoldInsufficient) {
|
|
t.Fatalf("Repair error = %v, want ErrRepairGoldInsufficient", err)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 5)].Extra["durability"]; got != "12" {
|
|
t.Fatalf("durability = %q, want unchanged 12", got)
|
|
}
|
|
character, _, _ := repos.Character.Load(ctx, "77")
|
|
if character.Stats["gold"] != 100 {
|
|
t.Fatalf("gold mutated = %d", character.Stats["gold"])
|
|
}
|
|
}
|
|
|
|
func TestOwnerRepairPersonalCargoPersistsWarehouseDurability(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 7654}})
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Warehouse: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypePersonalCargo, 9): {
|
|
ItemID: 701,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "3",
|
|
"max_durability": "15",
|
|
"repair_gold": "0",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
result, err := owner.Repair(ctx, NewRepairCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, RepairEquipmentRequest{
|
|
InvenType: listTypePersonalCargo,
|
|
SlotIndex: 9,
|
|
}), inventoryRepairTestResolver(15, 0, 0))
|
|
if err != nil {
|
|
t.Fatalf("Repair error = %v", err)
|
|
}
|
|
if !result.Changed || result.ListType != listTypePersonalCargo || result.OldDurability != 3 || result.NewDurability != 15 || result.UpdatedGold != 7654 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Warehouse[slotKey(listTypePersonalCargo, 9)].Extra["durability"]; got != "15" {
|
|
t.Fatalf("warehouse durability = %q, want 15", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerRepairRejectsEquipmentListUntilEquipmentOwnerExists(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeAvatar, 11): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"durability": "12",
|
|
"max_durability": "20",
|
|
"repair_gold": "0",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.Repair(ctx, NewRepairCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, RepairEquipmentRequest{
|
|
InvenType: listTypeEquipment,
|
|
SlotIndex: 11,
|
|
}), nil)
|
|
if !errors.Is(err, ErrRepairRequiresEquipmentOwner) {
|
|
t.Fatalf("Repair error = %v, want ErrRepairRequiresEquipmentOwner", err)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeAvatar, 11)].Extra["durability"]; got != "12" {
|
|
t.Fatalf("durability = %q, want unchanged 12", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRepairUsesOwnerAndReturnsAck(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 12345}})
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "12",
|
|
"max_durability": "20",
|
|
"repair_gold": "0",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketRepairEquipment),
|
|
Body: []byte{
|
|
listTypeMain,
|
|
0x05, 0x00,
|
|
0xFF, 0xFF,
|
|
0x00, 0x00, 0x01,
|
|
},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
RepairCostResolver: inventoryRepairTestResolver(20, 0, 20),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "repair_equipment" {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if len(got.UpperResponses) != 1 {
|
|
t.Fatalf("responses = %+v", got.UpperResponses)
|
|
}
|
|
ack := got.UpperResponses[0]
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketRepairEquipment) || ack.Classification != dnfproto.DefaultChannelClassification || !ack.AllowCodec {
|
|
t.Fatalf("ack = %+v", ack)
|
|
}
|
|
wantBody := []byte{1, 0x39, 0x30, 0x00, 0x00, listTypeMain, 0x05, 0x00, 0x00, 0x00}
|
|
if string(ack.Body) != string(wantBody) {
|
|
t.Fatalf("ack body = % X, want % X", ack.Body, wantBody)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 5)].Extra["durability"]; got != "20" {
|
|
t.Fatalf("durability = %q, want 20", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRepairEquippedUsesEquipOwnerAndReturnsAck(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
raw := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0, 13, 14}
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 888}})
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
|
CharacterID: "77",
|
|
Entries: map[string]dnfrepo.EquipmentEntry{
|
|
"11": {
|
|
SlotIndex: 11,
|
|
ItemID: 700,
|
|
RawEntry: raw,
|
|
Extra: map[string]string{
|
|
"max_durability": "20",
|
|
"repair_gold": "0",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("Save equipment error = %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketRepairEquipment),
|
|
Body: []byte{
|
|
listTypeEquipment,
|
|
0x0B, 0x00,
|
|
0xFF, 0xFF,
|
|
0x00, 0x00, 0x00,
|
|
},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
RepairCostResolver: inventoryRepairTestResolver(20, 0, 20),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "repair_equipment" {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if len(got.UpperResponses) != 1 {
|
|
t.Fatalf("responses = %+v", got.UpperResponses)
|
|
}
|
|
ack := got.UpperResponses[0]
|
|
wantBody := []byte{1, 0x78, 0x03, 0x00, 0x00, listTypeEquipment, 0x0B, 0x00, 0x00, 0x00}
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketRepairEquipment) || string(ack.Body) != string(wantBody) {
|
|
t.Fatalf("ack = %+v body=% X want=% X", ack, ack.Body, wantBody)
|
|
}
|
|
|
|
loaded, ok, err := repos.Equipment.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Load equipment ok=%t err=%v", ok, err)
|
|
}
|
|
entry := loaded.Entries["11"]
|
|
if got := uint16(entry.RawEntry[10]) | uint16(entry.RawEntry[11])<<8; got != 20 {
|
|
t.Fatalf("raw durability = %d, want 20", got)
|
|
}
|
|
if got := entry.Extra["durability"]; got != "20" {
|
|
t.Fatalf("extra durability = %q, want 20", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUpgradeReinforcePersistsPackedLevelAndConsumesMaterial(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 12345}})
|
|
raw := make([]byte, currentItemListEntrySize)
|
|
raw[0x0A] = 2
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
RawEntry: raw,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "45",
|
|
"max_durability": "45",
|
|
},
|
|
},
|
|
slotKey(listTypeMain, 121): {
|
|
ItemID: 5000,
|
|
Count: 3,
|
|
Extra: map[string]string{
|
|
"item_kind": "stackable",
|
|
"pvf_path": "stackable/material/upgrade.mat",
|
|
"stack_limit": "999",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := owner.Upgrade(ctx, NewUpgradeCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UpgradeItemRequest{
|
|
Mode: "reinforce",
|
|
TargetSlotIndex: 9,
|
|
TargetItemTemplateID: 700,
|
|
MaterialSlotIndex: 121,
|
|
OptionalTicketSlotIndex: -1,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Upgrade error = %v", err)
|
|
}
|
|
if !result.Success || !result.Changed || result.OldLevel != 2 || result.NewLevel != 3 || result.MaterialRemainingStackCount != 2 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
target := loaded.Slots[slotKey(listTypeMain, 9)]
|
|
if got := target.Extra["reinforce"]; got != "3" {
|
|
t.Fatalf("reinforce extra = %q, want 3", got)
|
|
}
|
|
if got := target.RawEntry[0x0A]; got != 3 {
|
|
t.Fatalf("raw upgrade byte = %d, want 3", got)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 121)].Count; got != 2 {
|
|
t.Fatalf("material count = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUpgradeReinforceConsumesAccountSharedColorlessCubes(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 12345}})
|
|
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
|
AccountID: "acc",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
dnfrepo.AccountSharedInventorySlotKey(358): {ItemID: 3037, Count: 12},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "45",
|
|
"max_durability": "45",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := NewUpgradeCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UpgradeItemRequest{
|
|
Mode: "reinforce",
|
|
TargetSlotIndex: 9,
|
|
TargetItemTemplateID: 700,
|
|
MaterialSlotIndex: -1,
|
|
OptionalTicketSlotIndex: -1,
|
|
})
|
|
cmd.UpgradeMaterialItemID = 3037
|
|
cmd.UpgradeMaterialCount = 10
|
|
result, err := owner.Upgrade(ctx, cmd)
|
|
if err != nil {
|
|
t.Fatalf("Upgrade error = %v", err)
|
|
}
|
|
if !result.Success || !result.Changed || result.NewLevel != 1 || result.MaterialRemainingStackCount != 2 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
account, ok, err := repos.AccountInventory.Load(ctx, "acc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("account inventory ok=%t err=%v", ok, err)
|
|
}
|
|
if got := account.Slots[dnfrepo.AccountSharedInventorySlotKey(358)].Count; got != 2 {
|
|
t.Fatalf("colorless cube count = %d, want 2", got)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 9)].Extra["reinforce"]; got != "1" {
|
|
t.Fatalf("reinforce extra = %q, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUpgradeReinforceRejectsInsufficientAccountSharedCubes(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 12345}})
|
|
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
|
AccountID: "acc",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
dnfrepo.AccountSharedInventorySlotKey(358): {ItemID: 3037, Count: 9},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "45",
|
|
"max_durability": "45",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := NewUpgradeCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UpgradeItemRequest{
|
|
Mode: "reinforce",
|
|
TargetSlotIndex: 9,
|
|
TargetItemTemplateID: 700,
|
|
MaterialSlotIndex: -1,
|
|
OptionalTicketSlotIndex: -1,
|
|
})
|
|
cmd.UpgradeMaterialItemID = 3037
|
|
cmd.UpgradeMaterialCount = 10
|
|
result, err := owner.Upgrade(ctx, cmd)
|
|
if err != nil {
|
|
t.Fatalf("Upgrade error = %v", err)
|
|
}
|
|
if result.Success || result.Changed || result.ErrorCode != upgradeErrorInvalidMaterial {
|
|
t.Fatalf("result = %+v, want material failure", result)
|
|
}
|
|
account, ok, err := repos.AccountInventory.Load(ctx, "acc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("account inventory ok=%t err=%v", ok, err)
|
|
}
|
|
if got := account.Slots[dnfrepo.AccountSharedInventorySlotKey(358)].Count; got != 9 {
|
|
t.Fatalf("colorless cube mutated to %d, want 9", got)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 9)].Extra["reinforce"]; got != "" {
|
|
t.Fatalf("reinforce extra mutated to %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUpgradeAmplifyRequiresIdentifiedAmplifyState(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 1}})
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "45",
|
|
"max_durability": "45",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := owner.Upgrade(ctx, NewUpgradeCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UpgradeItemRequest{
|
|
Mode: "amplify",
|
|
TargetSlotIndex: 9,
|
|
TargetItemTemplateID: 700,
|
|
MaterialSlotIndex: -1,
|
|
OptionalTicketSlotIndex: -1,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Upgrade error = %v", err)
|
|
}
|
|
if result.Success || result.ErrorCode != upgradeErrorWrongMode {
|
|
t.Fatalf("result = %+v, want wrong-mode failure", result)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
stack := loaded.Slots[slotKey(listTypeMain, 9)]
|
|
stack.Extra["amplify_type"] = "3"
|
|
stack.Extra["amplify_value"] = "12"
|
|
loaded.Slots[slotKey(listTypeMain, 9)] = stack
|
|
saveTestInventory(t, ctx, repos, loaded)
|
|
|
|
result, err = owner.Upgrade(ctx, NewUpgradeCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UpgradeItemRequest{
|
|
Mode: "amplify",
|
|
TargetSlotIndex: 9,
|
|
TargetItemTemplateID: 700,
|
|
MaterialSlotIndex: -1,
|
|
OptionalTicketSlotIndex: -1,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Upgrade amplify error = %v", err)
|
|
}
|
|
if !result.Success || result.NewLevel != 1 {
|
|
t.Fatalf("amplify result = %+v", result)
|
|
}
|
|
loaded = loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 9)].Extra["reinforce"]; got != "1" {
|
|
t.Fatalf("amplify upgrade level = %q, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerUpgradeReturnsOp50AckAndSlotUpdate(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 999}})
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "45",
|
|
"max_durability": "45",
|
|
},
|
|
},
|
|
slotKey(listTypeMain, 121): {ItemID: 5000, Count: 2, Extra: map[string]string{"item_kind": "stackable", "pvf_path": "stackable/material/upgrade.mat", "stack_limit": "999"}},
|
|
},
|
|
})
|
|
|
|
body := make([]byte, 16)
|
|
binary.LittleEndian.PutUint16(body[0:2], 0)
|
|
binary.LittleEndian.PutUint16(body[2:4], 9)
|
|
binary.LittleEndian.PutUint32(body[4:8], 700)
|
|
binary.LittleEndian.PutUint16(body[8:10], 121)
|
|
binary.LittleEndian.PutUint16(body[10:12], 0xFFFF)
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUpgradeItem),
|
|
Body: body,
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "upgrade_item" || len(got.UpperResponses) < 2 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
ack := got.UpperResponses[0]
|
|
wantAck := []byte{1, 0, 121, 0, 1, 0, 0, 0, 0xFF, 0xFF, 0, 0, 0, 1, 0, 9, 0, 0xFF, 0xFF}
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketUpgradeItem) || ack.Classification != dnfproto.DefaultChannelClassification || !ack.AllowCodec || !bytes.Equal(ack.Body, wantAck) {
|
|
t.Fatalf("ack = %+v body=% X want=% X", ack, ack.Body, wantAck)
|
|
}
|
|
refresh := got.UpperResponses[1]
|
|
if refresh.MsgID != msgItemListUpdate || refresh.Classification != 0 || len(refresh.Body) != 3+2*currentItemListEntrySize {
|
|
t.Fatalf("refresh = %+v bodyLen=%d", refresh, len(refresh.Body))
|
|
}
|
|
if refresh.Body[0] != listTypeMain || binary.LittleEndian.Uint16(refresh.Body[1:3]) != 2 || refresh.Body[3+0x0A] != 1 {
|
|
t.Fatalf("refresh body = % X", refresh.Body[:3+currentItemListEntrySize])
|
|
}
|
|
}
|
|
|
|
func TestHandlerUpgradeUsesPolicyAccountSharedMaterial(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 999}})
|
|
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
|
AccountID: "acc",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
dnfrepo.AccountSharedInventorySlotKey(358): {ItemID: 3037, Count: 12},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "45",
|
|
"max_durability": "45",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
body := make([]byte, 16)
|
|
binary.LittleEndian.PutUint16(body[0:2], 0)
|
|
binary.LittleEndian.PutUint16(body[2:4], 9)
|
|
binary.LittleEndian.PutUint32(body[4:8], 700)
|
|
binary.LittleEndian.PutUint16(body[8:10], 0xFFFF)
|
|
binary.LittleEndian.PutUint16(body[10:12], 0xFFFF)
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUpgradeItem),
|
|
Body: body,
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
UpgradePolicyResolver: func(mode string, currentLevel int, targetItemID int64, amplifyType int) (alignedcmd.UpgradePolicyResolution, error) {
|
|
if mode != "reinforce" || currentLevel != 0 || targetItemID != 700 {
|
|
t.Fatalf("policy args mode=%s level=%d target=%d", mode, currentLevel, targetItemID)
|
|
}
|
|
return alignedcmd.UpgradePolicyResolution{
|
|
SuccessWeight: currentUpgradeSuccessBase,
|
|
MaterialItemID: 3037,
|
|
MaterialCount: 10,
|
|
GoldCost: 100,
|
|
}, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "upgrade_item" || len(got.UpperResponses) < 2 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
account, ok, err := repos.AccountInventory.Load(ctx, "acc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("account inventory ok=%t err=%v", ok, err)
|
|
}
|
|
if got := account.Slots[dnfrepo.AccountSharedInventorySlotKey(358)].Count; got != 2 {
|
|
t.Fatalf("colorless cube count = %d, want 2", got)
|
|
}
|
|
character, ok, err := repos.Character.Load(ctx, "77")
|
|
if err != nil || !ok || character.Stats["gold"] != 899 {
|
|
t.Fatalf("character gold=%+v found=%t err=%v", character, ok, err)
|
|
}
|
|
}
|
|
|
|
func TestHandlerUpgradeUsesPolicyAccountSharedMaterialWhenClientSendsSharedSlot(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 999}})
|
|
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
|
AccountID: "acc",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
dnfrepo.AccountSharedInventorySlotKey(358): {ItemID: 3037, Count: 12},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "45",
|
|
"max_durability": "45",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
body := make([]byte, 16)
|
|
binary.LittleEndian.PutUint16(body[0:2], 0)
|
|
binary.LittleEndian.PutUint16(body[2:4], 9)
|
|
binary.LittleEndian.PutUint32(body[4:8], 700)
|
|
binary.LittleEndian.PutUint16(body[8:10], 358)
|
|
binary.LittleEndian.PutUint16(body[10:12], 0xFFFF)
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUpgradeItem),
|
|
Body: body,
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
UpgradePolicyResolver: func(mode string, currentLevel int, targetItemID int64, amplifyType int) (alignedcmd.UpgradePolicyResolution, error) {
|
|
if mode != "reinforce" || currentLevel != 0 {
|
|
t.Fatalf("policy args mode=%s level=%d", mode, currentLevel)
|
|
}
|
|
return alignedcmd.UpgradePolicyResolution{
|
|
SuccessWeight: currentUpgradeSuccessBase,
|
|
MaterialItemID: 3037,
|
|
MaterialCount: 10,
|
|
}, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "upgrade_item" || len(got.UpperResponses) < 2 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
account, ok, err := repos.AccountInventory.Load(ctx, "acc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("account inventory ok=%t err=%v", ok, err)
|
|
}
|
|
if got := account.Slots[dnfrepo.AccountSharedInventorySlotKey(358)].Count; got != 2 {
|
|
t.Fatalf("colorless cube count = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerUpgradeSkipsTicketResolverForAccountSharedMaterialSlot(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 999}})
|
|
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
|
AccountID: "acc",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
dnfrepo.AccountSharedInventorySlotKey(358): {ItemID: 3037, Count: 12},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "45",
|
|
"max_durability": "45",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
body := make([]byte, 16)
|
|
binary.LittleEndian.PutUint16(body[0:2], 0)
|
|
binary.LittleEndian.PutUint16(body[2:4], 9)
|
|
binary.LittleEndian.PutUint32(body[4:8], 700)
|
|
binary.LittleEndian.PutUint16(body[8:10], 358)
|
|
binary.LittleEndian.PutUint16(body[10:12], 0xFFFF)
|
|
ticketCalled := false
|
|
policyCalled := false
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUpgradeItem),
|
|
Body: body,
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
UpgradeTicketResolver: func(materialItemID int64, targetItemID int64) (alignedcmd.UpgradeTicketResolution, error) {
|
|
ticketCalled = true
|
|
return alignedcmd.UpgradeTicketResolution{}, nil
|
|
},
|
|
UpgradePolicyResolver: func(mode string, currentLevel int, targetItemID int64, amplifyType int) (alignedcmd.UpgradePolicyResolution, error) {
|
|
policyCalled = true
|
|
if mode != "reinforce" || currentLevel != 0 {
|
|
t.Fatalf("policy args mode=%s level=%d", mode, currentLevel)
|
|
}
|
|
return alignedcmd.UpgradePolicyResolution{
|
|
SuccessWeight: currentUpgradeSuccessBase,
|
|
MaterialItemID: 3037,
|
|
MaterialCount: 10,
|
|
}, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if ticketCalled {
|
|
t.Fatalf("ticket resolver was called for account-shared material slot")
|
|
}
|
|
if !policyCalled {
|
|
t.Fatalf("policy resolver was not called")
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "upgrade_item" || len(got.UpperResponses) < 2 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
account, ok, err := repos.AccountInventory.Load(ctx, "acc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("account inventory ok=%t err=%v", ok, err)
|
|
}
|
|
if got := account.Slots[dnfrepo.AccountSharedInventorySlotKey(358)].Count; got != 2 {
|
|
t.Fatalf("colorless cube count = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerUpgradeLogsPolicyResolverError(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 999}})
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "45",
|
|
"max_durability": "45",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
body := make([]byte, 16)
|
|
binary.LittleEndian.PutUint16(body[0:2], 0)
|
|
binary.LittleEndian.PutUint16(body[2:4], 9)
|
|
binary.LittleEndian.PutUint32(body[4:8], 700)
|
|
binary.LittleEndian.PutUint16(body[8:10], 358)
|
|
binary.LittleEndian.PutUint16(body[10:12], 0xFFFF)
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUpgradeItem),
|
|
Body: body,
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
UpgradePolicyResolver: func(mode string, currentLevel int, targetItemID int64, amplifyType int) (alignedcmd.UpgradePolicyResolution, error) {
|
|
return alignedcmd.UpgradePolicyResolution{}, errors.New("upgrade table smoke failure")
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "upgrade_item" {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if !strings.Contains(got.Reason, `policyErr="upgrade table smoke failure"`) {
|
|
t.Fatalf("reason = %q, want policyErr", got.Reason)
|
|
}
|
|
if len(got.UpperResponses) != 1 || got.UpperResponses[0].MsgID != uint16(dnfenum.CmdPacketUpgradeItem) {
|
|
t.Fatalf("responses = %+v", got.UpperResponses)
|
|
}
|
|
}
|
|
|
|
func TestHandlerUpgradeMissingPolicyResolverFailsClosedBeforeSharedSlotFallback(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 999}})
|
|
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
|
AccountID: "acc",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
dnfrepo.AccountSharedInventorySlotKey(358): {ItemID: 3037, Count: 12},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 9): {
|
|
ItemID: 700,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "equipment",
|
|
"durability": "45",
|
|
"max_durability": "45",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
body := make([]byte, 16)
|
|
binary.LittleEndian.PutUint16(body[0:2], 0)
|
|
binary.LittleEndian.PutUint16(body[2:4], 9)
|
|
binary.LittleEndian.PutUint32(body[4:8], 700)
|
|
binary.LittleEndian.PutUint16(body[8:10], 358)
|
|
binary.LittleEndian.PutUint16(body[10:12], 0xFFFF)
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUpgradeItem),
|
|
Body: body,
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "upgrade_item" {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if !strings.Contains(got.Reason, `policyErr="upgrade policy resolver missing"`) {
|
|
t.Fatalf("reason = %q, want missing policy resolver", got.Reason)
|
|
}
|
|
account, ok, err := repos.AccountInventory.Load(ctx, "acc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("account inventory ok=%t err=%v", ok, err)
|
|
}
|
|
if got := account.Slots[dnfrepo.AccountSharedInventorySlotKey(358)].Count; got != 12 {
|
|
t.Fatalf("colorless cube count mutated to %d, want 12", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackableRequiresPVFProvenWasteBeforeMutation(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 500, Count: 2, Extra: map[string]string{"stackable": "true"}},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.UseStackable(ctx, NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UseStackableRequest{
|
|
SlotIndex: 4,
|
|
ListType: listTypeMain,
|
|
InstanceValue: 0x11223344,
|
|
ItemCode: 500,
|
|
}), nil)
|
|
if !errors.Is(err, ErrUseStackableContractRequired) {
|
|
t.Fatalf("UseStackable error = %v, want ErrUseStackableContractRequired", err)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if stack := loaded.Slots[slotKey(listTypeMain, 4)]; stack.ItemID != 500 || stack.Count != 2 {
|
|
t.Fatalf("stack mutated: %+v", stack)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackableUsesCurrentPVFAuthorityWhenLegacyRowOmitsType(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
const itemID = int64(490701712)
|
|
const pvfPath = "stackable/cash/chn_490701712.stk"
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {
|
|
ItemID: itemID,
|
|
Count: 2,
|
|
Extra: map[string]string{
|
|
"item_kind": "stackable",
|
|
"pvf_path": pvfPath,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
resolver := func(gotItemID int64) (alignedcmd.StackableUseResolution, error) {
|
|
return alignedcmd.StackableUseResolution{
|
|
ItemID: gotItemID,
|
|
PVFPath: pvfPath,
|
|
StackableType: "[waste]",
|
|
}, nil
|
|
}
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UseStackableRequest{
|
|
SlotIndex: 4,
|
|
ListType: listTypeMain,
|
|
InstanceValue: 0x11223344,
|
|
ItemCode: int32(itemID),
|
|
})
|
|
for wantRemaining := int64(1); wantRemaining >= 0; wantRemaining-- {
|
|
result, useErr := owner.UseStackableWithResolvers(ctx, cmd, nil, nil, resolver)
|
|
if useErr != nil {
|
|
t.Fatalf("UseStackableWithResolvers error = %v", useErr)
|
|
}
|
|
if !result.Changed || result.RemainingCount != wantRemaining || result.PVFPath != pvfPath || result.StackableType != "[waste]" {
|
|
t.Fatalf("result = %+v, want remaining=%d", result, wantRemaining)
|
|
}
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if _, exists := loaded.Slots[slotKey(listTypeMain, 4)]; exists {
|
|
t.Fatalf("legacy consumable row survived its second use: %+v", loaded.Slots)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackableForgottenLandWasteUsesDynamicPVFIdentityAndScene(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
inDungeon bool
|
|
dungeonID int64
|
|
wantAllowed bool
|
|
}{
|
|
{name: "forgotten land", inDungeon: true, dungeonID: 4000, wantAllowed: true},
|
|
{name: "town", wantAllowed: false},
|
|
{name: "different dungeon", inDungeon: true, dungeonID: 4001, wantAllowed: false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
const itemID = int64(10093469)
|
|
const pvfPath = "stackable/lostland/lostland_hppotion.stk"
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: itemID, Count: 2},
|
|
}})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc", SelectedCharacterID: 77, InDungeon: tc.inDungeon, DungeonID: tc.dungeonID,
|
|
}, UseStackableRequest{SlotIndex: 4, ListType: listTypeMain, InstanceValue: 0x11223344, ItemCode: int32(itemID)})
|
|
resolver := func(int64) (alignedcmd.StackableUseResolution, error) {
|
|
return alignedcmd.StackableUseResolution{
|
|
ItemID: itemID, PVFPath: pvfPath, StackableType: "[waste]",
|
|
UsePlace: &alignedcmd.StackableUsePlaceResolution{
|
|
DungeonAllowed: true, ActionUsableDungeonIDs: []int64{4000},
|
|
},
|
|
}, nil
|
|
}
|
|
result, useErr := owner.UseStackableWithResolvers(ctx, cmd, nil, nil, resolver)
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if tc.wantAllowed {
|
|
if useErr != nil || !result.Changed || result.RemainingCount != 1 || result.PVFPath != pvfPath {
|
|
t.Fatalf("result=%+v err=%v", result, useErr)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].Count; got != 1 {
|
|
t.Fatalf("remaining=%d want=1", got)
|
|
}
|
|
return
|
|
}
|
|
if !errors.Is(useErr, ErrStackableUsePlaceContract) {
|
|
t.Fatalf("error=%v want ErrStackableUsePlaceContract", useErr)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].Count; got != 2 {
|
|
t.Fatalf("rejected use consumed stack: remaining=%d", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackableTransformationValidatesSceneBeforeAtomicConsume(t *testing.T) {
|
|
type testCase struct {
|
|
name string
|
|
stackableType string
|
|
transformation alignedcmd.TransformationUseResolution
|
|
inDungeon bool
|
|
dungeonID int64
|
|
wantAllowed bool
|
|
}
|
|
tests := []testCase{
|
|
{name: "town disguise", stackableType: "[disguise]", transformation: alignedcmd.TransformationUseResolution{ActionType: "[disguise]", FormIDs: []int64{61584}, DurationSeconds: 300, VillageAllowed: true, SeriaRoomAllowed: true}, wantAllowed: true},
|
|
{name: "town random disguise", stackableType: "[random disguise]", transformation: alignedcmd.TransformationUseResolution{ActionType: "[random disguise]", FormIDs: []int64{64703, 191}, DurationSeconds: 300, VillageAllowed: true, SeriaRoomAllowed: true}, wantAllowed: true},
|
|
{name: "town celebration", stackableType: "[etc]", transformation: alignedcmd.TransformationUseResolution{ActionType: "[celebrate big transition]", FormIDs: []int64{1, 2}, DurationSeconds: 300, VillageAllowed: true, SeriaRoomAllowed: true}, wantAllowed: true},
|
|
{name: "dungeon riding", stackableType: "[waste]", transformation: alignedcmd.TransformationUseResolution{ActionType: "[riding]", FormIDs: []int64{107000902}, DurationSeconds: 300, DungeonAllowed: true, ActionUsableDungeonIDs: []int64{100, 101}}, inDungeon: true, dungeonID: 100, wantAllowed: true},
|
|
{name: "dungeon ridable", stackableType: "[waste]", transformation: alignedcmd.TransformationUseResolution{ActionType: "[ridable item]", FormIDs: []int64{64734}, DungeonAllowed: true, LimitDungeonIDs: []int64{200, 201}}, inDungeon: true, dungeonID: 200, wantAllowed: true},
|
|
{name: "dungeon card rejected in town", stackableType: "[waste]", transformation: alignedcmd.TransformationUseResolution{ActionType: "[riding]", FormIDs: []int64{107000902}, DungeonAllowed: true}, wantAllowed: false},
|
|
{name: "town card rejected in dungeon", stackableType: "[disguise]", transformation: alignedcmd.TransformationUseResolution{ActionType: "[disguise]", FormIDs: []int64{61584}, VillageAllowed: true}, inDungeon: true, dungeonID: 100, wantAllowed: false},
|
|
{name: "action dungeon whitelist rejected", stackableType: "[waste]", transformation: alignedcmd.TransformationUseResolution{ActionType: "[riding]", FormIDs: []int64{107000902}, DungeonAllowed: true, ActionUsableDungeonIDs: []int64{100}}, inDungeon: true, dungeonID: 999, wantAllowed: false},
|
|
{name: "limit dungeon whitelist rejected", stackableType: "[waste]", transformation: alignedcmd.TransformationUseResolution{ActionType: "[ridable item]", FormIDs: []int64{64734}, DungeonAllowed: true, LimitDungeonIDs: []int64{200}}, inDungeon: true, dungeonID: 999, wantAllowed: false},
|
|
}
|
|
for index, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
itemID := int64(7000 + index)
|
|
pvfPath := fmt.Sprintf("stackable/transformation/%d.stk", itemID)
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {
|
|
ItemID: itemID,
|
|
Count: 2,
|
|
Extra: map[string]string{
|
|
"item_kind": "stackable",
|
|
"pvf_path": pvfPath,
|
|
"stackable_type": tc.stackableType,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
InDungeon: tc.inDungeon,
|
|
DungeonID: tc.dungeonID,
|
|
}, UseStackableRequest{SlotIndex: 4, ListType: listTypeMain, InstanceValue: 0x11223344, ItemCode: int32(itemID)})
|
|
resolver := func(int64) (alignedcmd.StackableUseResolution, error) {
|
|
transformation := tc.transformation
|
|
return alignedcmd.StackableUseResolution{
|
|
ItemID: itemID,
|
|
PVFPath: pvfPath,
|
|
StackableType: tc.stackableType,
|
|
Transformation: &transformation,
|
|
}, nil
|
|
}
|
|
result, useErr := owner.UseStackableWithResolvers(ctx, cmd, nil, nil, resolver)
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if tc.wantAllowed {
|
|
if useErr != nil || !result.TransformationApplied || result.TransformationAction != tc.transformation.ActionType || result.RemainingCount != 1 {
|
|
t.Fatalf("result=%+v err=%v", result, useErr)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].Count; got != 1 {
|
|
t.Fatalf("remaining=%d want=1", got)
|
|
}
|
|
return
|
|
}
|
|
if !errors.Is(useErr, ErrTransformationContract) {
|
|
t.Fatalf("error=%v want ErrTransformationContract", useErr)
|
|
}
|
|
if got := loaded.Slots[slotKey(listTypeMain, 4)].Count; got != 2 {
|
|
t.Fatalf("rejected transformation consumed stack: remaining=%d", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerUseStackableTransformationKeepsNativeOp44SuccessPath(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
const itemID = int64(490004179)
|
|
const pvfPath = "stackable/490004001/chn_490004179.stk"
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: itemID, Count: 2, Extra: map[string]string{
|
|
"item_kind": "stackable", "pvf_path": pvfPath, "stackable_type": "[waste]",
|
|
}},
|
|
},
|
|
})
|
|
body := []byte{0x04, 0x00, listTypeMain, 0x11, 0x22, 0x33, 0x44, 0xD3, 0xDE, 0x34, 0x1D, 0, 0, 0, 0}
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUseStackable),
|
|
Body: body,
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
InDungeon: true,
|
|
DungeonID: 100,
|
|
StackableUseResolver: func(int64) (alignedcmd.StackableUseResolution, error) {
|
|
return alignedcmd.StackableUseResolution{
|
|
ItemID: itemID, PVFPath: pvfPath, StackableType: "[waste]",
|
|
Transformation: &alignedcmd.TransformationUseResolution{
|
|
ActionType: "[riding]", FormIDs: []int64{107000902}, DurationSeconds: 300,
|
|
DungeonAllowed: true, ActionUsableDungeonIDs: []int64{100},
|
|
},
|
|
}, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 || len(got.ItemSlotRefreshes) != 1 || len(got.PostActions) != 0 ||
|
|
!strings.Contains(got.Reason, "native transformation") {
|
|
t.Fatalf("result=%+v", got)
|
|
}
|
|
wantBody := []byte{1, 4, 0, listTypeMain, 0x11, 0x22, 0x33, 0x44, 0xD3, 0xDE, 0x34, 0x1D}
|
|
response := got.UpperResponses[0]
|
|
if response.MsgID != uint16(dnfenum.CmdPacketUseStackable) || !response.AllowCodec || !bytes.Equal(response.Body, wantBody) {
|
|
t.Fatalf("op44 response=%+v body=%x want=%x", response, response.Body, wantBody)
|
|
}
|
|
if refresh := got.ItemSlotRefreshes[0]; refresh != (alignedcmd.ItemSlotRefresh{ListType: listTypeMain, SlotIndex: 4}) {
|
|
t.Fatalf("refresh=%+v", refresh)
|
|
}
|
|
if remaining := loadTestInventory(t, ctx, repos, "77").Slots[slotKey(listTypeMain, 4)].Count; remaining != 1 {
|
|
t.Fatalf("remaining=%d want=1", remaining)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackableProjectileValidatesDungeonAndPersistsRawCount(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
inDungeon bool
|
|
dungeonID int64
|
|
whitelist []int64
|
|
wantAllowed bool
|
|
}{
|
|
{name: "dungeon throw", inDungeon: true, dungeonID: 100, wantAllowed: true},
|
|
{name: "town rejected", wantAllowed: false},
|
|
{name: "wrong dungeon rejected", inDungeon: true, dungeonID: 999, whitelist: []int64{100}, wantAllowed: false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
rawEntry := make([]byte, currentItemListEntrySize)
|
|
binary.LittleEndian.PutUint32(rawEntry[0x06:0x0A], 2)
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {
|
|
ItemID: 6009,
|
|
Count: 2,
|
|
RawEntry: rawEntry,
|
|
Extra: map[string]string{
|
|
"item_kind": "stackable", "pvf_path": "stackable/projectile/shuriken.stk",
|
|
"stackable_type": "[throw]", "amount_or_count": "2",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc", SelectedCharacterID: 77, InDungeon: tc.inDungeon, DungeonID: tc.dungeonID,
|
|
}, UseStackableRequest{SlotIndex: 4, ListType: listTypeMain, InstanceValue: 0x11223344, ItemCode: 6009})
|
|
resolver := func(int64) (alignedcmd.StackableUseResolution, error) {
|
|
return alignedcmd.StackableUseResolution{
|
|
ItemID: 6009, PVFPath: "stackable/projectile/shuriken.stk", StackableType: "[throw]",
|
|
Projectile: &alignedcmd.ProjectileUseResolution{
|
|
StackableType: "[throw]", EffectID: 102, IntData: []int64{102, 2, 3, 4, 5, 6},
|
|
DungeonAllowed: true, ActionUsableDungeonIDs: tc.whitelist,
|
|
},
|
|
}, nil
|
|
}
|
|
result, useErr := owner.UseStackableWithResolvers(ctx, cmd, nil, nil, resolver)
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
stack := loaded.Slots[slotKey(listTypeMain, 4)]
|
|
if tc.wantAllowed {
|
|
if useErr != nil || !result.ProjectileApplied || result.ProjectileEffectID != 102 || result.RemainingCount != 1 {
|
|
t.Fatalf("result=%+v err=%v", result, useErr)
|
|
}
|
|
if stack.Count != 1 || binary.LittleEndian.Uint32(stack.RawEntry[0x06:0x0A]) != 1 || stack.Extra["amount_or_count"] != "1" {
|
|
t.Fatalf("durable projectile count was not synchronized: %+v raw=%x", stack, stack.RawEntry)
|
|
}
|
|
return
|
|
}
|
|
if !errors.Is(useErr, ErrProjectileContract) || stack.Count != 2 || binary.LittleEndian.Uint32(stack.RawEntry[0x06:0x0A]) != 2 {
|
|
t.Fatalf("rejected projectile result=%+v err=%v stack=%+v", result, useErr, stack)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerUseStackableProjectileKeepsNativeOp44SuccessPath(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
const itemID = int64(6012)
|
|
const pvfPath = "stackable/projectile/bomb.stk"
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: itemID, Count: 2, Extra: map[string]string{
|
|
"item_kind": "stackable", "pvf_path": pvfPath, "stackable_type": "[set]",
|
|
}},
|
|
}})
|
|
body := []byte{0x04, 0x00, listTypeMain, 0x11, 0x22, 0x33, 0x44, 0x7C, 0x17, 0x00, 0x00, 0, 0, 0, 0}
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUseStackable), Body: body, AccountID: "acc", SelectedCharacterID: 77,
|
|
Repositories: repos, InDungeon: true, DungeonID: 100,
|
|
StackableUseResolver: func(int64) (alignedcmd.StackableUseResolution, error) {
|
|
return alignedcmd.StackableUseResolution{
|
|
ItemID: itemID, PVFPath: pvfPath, StackableType: "[set]",
|
|
Projectile: &alignedcmd.ProjectileUseResolution{
|
|
StackableType: "[set]", EffectID: 103, IntData: []int64{103, 2, 3, 4, 5, 6}, DungeonAllowed: true,
|
|
},
|
|
}, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantBody := []byte{1, 4, 0, listTypeMain, 0x11, 0x22, 0x33, 0x44, 0x7C, 0x17, 0x00, 0x00}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 || len(got.ItemSlotRefreshes) != 1 ||
|
|
!strings.Contains(got.Reason, "native projectile") || !bytes.Equal(got.UpperResponses[0].Body, wantBody) {
|
|
t.Fatalf("result=%+v want_body=%x", got, wantBody)
|
|
}
|
|
if remaining := loadTestInventory(t, ctx, repos, "77").Slots[slotKey(listTypeMain, 4)].Count; remaining != 1 {
|
|
t.Fatalf("remaining=%d want=1", remaining)
|
|
}
|
|
}
|
|
|
|
func TestHandlerUseStackablePremiumMetadataReturnsFailureAckBeforeMutation(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {
|
|
ItemID: 600,
|
|
Count: 1,
|
|
Extra: map[string]string{"premium_type": "2", "premium_days": "7"},
|
|
},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUseStackable),
|
|
Body: []byte{
|
|
0x04, 0x00,
|
|
listTypeMain,
|
|
0x11, 0x22, 0x33, 0x44,
|
|
0x58, 0x02, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00,
|
|
},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "use_stackable" || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if !strings.Contains(got.Reason, "PVF-proven [waste] provenance and stable item identity") {
|
|
t.Fatalf("reason = %q", got.Reason)
|
|
}
|
|
response := got.UpperResponses[0]
|
|
wantBody := []byte{0, useStackableSilentFailureCode, listTypeMain, 0, 0, 0, 0, 0, 0, 0, 0}
|
|
if response.MsgID != uint16(dnfenum.CmdPacketUseStackable) ||
|
|
response.Classification != dnfproto.DefaultChannelClassification ||
|
|
!response.AllowCodec || string(response.Body) != string(wantBody) {
|
|
t.Fatalf("response = %+v body=%x want=%x", response, response.Body, wantBody)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if stack := loaded.Slots[slotKey(listTypeMain, 4)]; stack.ItemID != 600 || stack.Count != 1 {
|
|
t.Fatalf("premium stack mutated: %+v", stack)
|
|
}
|
|
if _, ok, err := repos.Account.Load(ctx, "acc"); err != nil || ok {
|
|
t.Fatalf("premium account mutation ok=%t err=%v", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestHandlerUseStackablePVFProvenWasteDecrementsAndReturnsExactAck(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "78",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {
|
|
ItemID: 500,
|
|
Count: 2,
|
|
Extra: map[string]string{
|
|
"item_kind": "stackable",
|
|
"pvf_path": "stackable/tutorial/tutorial_0500.stk",
|
|
"stackable_type": "[waste] 4",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUseStackable),
|
|
Body: []byte{
|
|
0x05, 0x00,
|
|
listTypeMain,
|
|
0x11, 0x22, 0x33, 0x44,
|
|
0xF4, 0x01, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00,
|
|
},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 78,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "use_stackable" || len(got.UpperResponses) != 1 ||
|
|
len(got.ItemSlotRefreshes) != 1 ||
|
|
got.ItemSlotRefreshes[0] != (alignedcmd.ItemSlotRefresh{ListType: listTypeMain, SlotIndex: 5}) {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
response := got.UpperResponses[0]
|
|
wantBody := []byte{1, 5, 0, listTypeMain, 0x11, 0x22, 0x33, 0x44, 0xF4, 0x01, 0, 0}
|
|
if response.MsgID != uint16(dnfenum.CmdPacketUseStackable) ||
|
|
response.Classification != dnfproto.DefaultChannelClassification ||
|
|
!response.AllowCodec || string(response.Body) != string(wantBody) {
|
|
t.Fatalf("response = %+v body=%x want=%x", response, response.Body, wantBody)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "78")
|
|
if stack := loaded.Slots[slotKey(listTypeMain, 5)]; stack.ItemID != 500 || stack.Count != 1 {
|
|
t.Fatalf("stack mutated: %+v", stack)
|
|
}
|
|
}
|
|
|
|
func TestHandlerUseReviveCoinConsumableAtomicallyIncrementsWallet(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "78",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {
|
|
ItemID: 42,
|
|
Count: 2,
|
|
Extra: map[string]string{
|
|
"item_kind": "stackable",
|
|
"pvf_path": "stackable/cash/coin_general.stk",
|
|
"stackable_type": "[waste]",
|
|
},
|
|
},
|
|
slotKey(listTypeMain, 1): {
|
|
ItemID: 1,
|
|
Count: 3,
|
|
Extra: map[string]string{
|
|
"amount_or_count": "3",
|
|
"count": "3",
|
|
"value_a": "3",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUseStackable),
|
|
Body: []byte{
|
|
0x05, 0x00,
|
|
listTypeMain,
|
|
0x11, 0x22, 0x33, 0x44,
|
|
0x2A, 0x00, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00,
|
|
},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 78,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
|
len(got.PostActions) != 0 ||
|
|
len(got.ItemSlotRefreshes) != 2 ||
|
|
got.ItemSlotRefreshes[0] != (alignedcmd.ItemSlotRefresh{ListType: listTypeMain, SlotIndex: 5}) ||
|
|
got.ItemSlotRefreshes[1] != (alignedcmd.ItemSlotRefresh{ListType: listTypeMain, SlotIndex: 1}) {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
wantBody := []byte{1, 5, 0, listTypeMain, 0x11, 0x22, 0x33, 0x44, 0x2A, 0, 0, 0}
|
|
if response := got.UpperResponses[0]; response.MsgID != uint16(dnfenum.CmdPacketUseStackable) ||
|
|
string(response.Body) != string(wantBody) {
|
|
t.Fatalf("response=%+v body=%x want=%x", response, response.Body, wantBody)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "78")
|
|
if stack := loaded.Slots[slotKey(listTypeMain, 5)]; stack.ItemID != 42 || stack.Count != 1 {
|
|
t.Fatalf("consumable after=%+v", stack)
|
|
}
|
|
if wallet := loaded.Slots[slotKey(listTypeMain, 1)]; wallet.ItemID != 1 ||
|
|
wallet.Count != 4 ||
|
|
wallet.Extra["amount_or_count"] != "4" ||
|
|
wallet.Extra["value_a"] != "4" {
|
|
t.Fatalf("wallet after=%+v", wallet)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackableDeletesLastPVFProvenWasteAtomically(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "79",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 3): {
|
|
ItemID: 8474,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"item_kind": "stackable",
|
|
"pvf_path": "stackable/tutorial/tutorial_8474.stk",
|
|
"stackable_type": "`[waste]` 4",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := owner.UseStackable(ctx, NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 79,
|
|
}, UseStackableRequest{
|
|
SlotIndex: 3,
|
|
ListType: listTypeMain,
|
|
InstanceValue: 0x11223344,
|
|
ItemCode: 8474,
|
|
}), nil)
|
|
if err != nil {
|
|
t.Fatalf("UseStackable error = %v", err)
|
|
}
|
|
if !result.Changed || result.ItemID != 8474 || result.RemainingCount != 0 || result.PVFPath == "" {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "79")
|
|
if _, exists := loaded.Slots[slotKey(listTypeMain, 3)]; exists {
|
|
t.Fatalf("consumed last stack still exists: %+v", loaded.Slots)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseSharedEffectItemConsumesAndGrantsAtomically(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "79",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 65): {ItemID: 490701730, Count: 2, Extra: map[string]string{"item_kind": "stackable", "value_a": "287454020"}},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := owner.UseSharedEffectItem(ctx, NewUseSharedEffectItemCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 79,
|
|
}, UseSharedEffectItemRequest{
|
|
SlotIndex: 65,
|
|
ListType: listTypeMain,
|
|
ItemTemplateID: 490701730,
|
|
}), randomRewardItemTestResolver(490701730, 490701733))
|
|
if err != nil {
|
|
t.Fatalf("UseStackable error = %v", err)
|
|
}
|
|
if !result.Changed || result.RemainingCount != 1 || result.RandomRewardItemID != 490701733 || len(result.RandomRewardSlots) != 1 || result.RandomRewardSlots[0] != 66 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "79")
|
|
if source := loaded.Slots[slotKey(listTypeMain, 65)]; source.ItemID != 490701730 || source.Count != 1 {
|
|
t.Fatalf("source = %+v", source)
|
|
}
|
|
if reward := loaded.Slots[slotKey(listTypeMain, 66)]; reward.ItemID != 490701733 || reward.Count != 1 || reward.Extra["pvf_path"] != "stackable/cash/chn_490701733.stk" {
|
|
t.Fatalf("reward = %+v", reward)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseSharedEffectItemRollsBackWhenNoResultSlot(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "79",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 65): {ItemID: 490701730, Count: 2, Extra: map[string]string{"item_kind": "stackable", "value_a": "287454020"}},
|
|
slotKey(listTypeMain, 66): {ItemID: 1, Count: 1},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = owner.UseSharedEffectItem(ctx, NewUseSharedEffectItemCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 79,
|
|
}, UseSharedEffectItemRequest{SlotIndex: 65, ListType: listTypeMain, ItemTemplateID: 490701730}), randomRewardItemTestResolverInSlots(490701730, 490701733, 65, 66))
|
|
if !errors.Is(err, ErrRandomRewardInventoryFull) {
|
|
t.Fatalf("UseStackable error = %v", err)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "79")
|
|
if source := loaded.Slots[slotKey(listTypeMain, 65)]; source.ItemID != 490701730 || source.Count != 2 {
|
|
t.Fatalf("source mutated after rollback = %+v", source)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseSharedEffectItemRejectsTemplateMismatchWithoutMutation(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "79",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 65): {ItemID: 490701730, Count: 2, Extra: map[string]string{"item_kind": "stackable", "value_a": "287454020"}},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = owner.UseSharedEffectItem(ctx, NewUseSharedEffectItemCommand(alignedcmd.Request{
|
|
AccountID: "acc", SelectedCharacterID: 79,
|
|
}, UseSharedEffectItemRequest{
|
|
SlotIndex: 65, ListType: listTypeMain, ItemTemplateID: 490701731,
|
|
}), randomRewardItemTestResolver(490701730, 490701733))
|
|
if !errors.Is(err, ErrItemMismatch) {
|
|
t.Fatalf("UseSharedEffectItem error = %v, want ErrItemMismatch", err)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "79")
|
|
if source := loaded.Slots[slotKey(listTypeMain, 65)]; source.Count != 2 {
|
|
t.Fatalf("source mutated after template mismatch = %+v", source)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseSharedEffectItemVisualOnlyConsumesWithoutReward(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "79",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 65): {ItemID: 490701730, Count: 2, Extra: map[string]string{"item_kind": "stackable", "value_a": "287454020"}},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
visualOnly := func(candidate int64) (alignedcmd.RandomRewardItemResolution, error) {
|
|
if candidate != 490701730 {
|
|
return alignedcmd.RandomRewardItemResolution{}, nil
|
|
}
|
|
return alignedcmd.RandomRewardItemResolution{
|
|
SourceItemID: 490701730, SourcePVFPath: "stackable/event/firework.stk", StackableType: "random reward item",
|
|
Outcomes: []alignedcmd.RandomRewardItemOutcome{{Weight: 1}},
|
|
}, nil
|
|
}
|
|
result, err := owner.UseSharedEffectItem(ctx, NewUseSharedEffectItemCommand(alignedcmd.Request{
|
|
AccountID: "acc", SelectedCharacterID: 79,
|
|
}, UseSharedEffectItemRequest{SlotIndex: 65, ListType: listTypeMain, ItemTemplateID: 490701730}), visualOnly)
|
|
if err != nil {
|
|
t.Fatalf("visual-only type251 error = %v", err)
|
|
}
|
|
if result.RandomRewardItemID != 0 || len(result.RandomRewardSlots) != 0 || result.RemainingCount != 1 {
|
|
t.Fatalf("visual-only result = %+v", result)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "79")
|
|
if source := loaded.Slots[slotKey(listTypeMain, 65)]; source.Count != 1 {
|
|
t.Fatalf("visual-only source = %+v", source)
|
|
}
|
|
if len(loaded.Slots) != 1 {
|
|
t.Fatalf("visual-only unexpectedly granted item: %+v", loaded.Slots)
|
|
}
|
|
}
|
|
|
|
func TestOwnerAppliedSharedEffectItemResultUsesType251AckAndRefreshOrder(t *testing.T) {
|
|
result := ownerAppliedSharedEffectItemResult(251, Command{Operation: "use_shared_effect_item"}, UseStackableResult{
|
|
ListType: listTypeMain, SlotIndex: 65, ItemID: 490701730, RemainingCount: 1,
|
|
RandomRewardItemID: 490701733, RandomRewardSlots: []int16{66}, Changed: true,
|
|
})
|
|
if !result.ResponseAllowed || len(result.UpperResponses) != 1 || !bytes.Equal(result.UpperResponses[0].Body, []byte{1}) {
|
|
t.Fatalf("type251 response = %+v", result)
|
|
}
|
|
if len(result.ItemSlotRefreshes) != 2 || result.ItemSlotRefreshes[0].SlotIndex != 65 || result.ItemSlotRefreshes[1].SlotIndex != 66 {
|
|
t.Fatalf("type251 refresh order = %+v", result.ItemSlotRefreshes)
|
|
}
|
|
empty := ownerAppliedSharedEffectItemResult(251, Command{Operation: "use_shared_effect_item"}, UseStackableResult{
|
|
ListType: listTypeMain, SlotIndex: 65, ItemID: 490701730, RemainingCount: 0, Changed: true,
|
|
})
|
|
if len(empty.ItemSlotRefreshes) != 1 || empty.ItemSlotRefreshes[0].SlotIndex != 65 {
|
|
t.Fatalf("empty type251 refreshes = %+v", empty.ItemSlotRefreshes)
|
|
}
|
|
}
|
|
|
|
func TestHandlerUseSharedEffectItemCommitsAndReturnsType251Ack(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "79",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 93): {ItemID: 490005593, Count: 2, Extra: map[string]string{"item_kind": "stackable"}},
|
|
},
|
|
})
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUseSharedEffectItem),
|
|
Body: []byte{0x59, 0xE4, 0x34, 0x1D, 93, listTypeMain},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 79,
|
|
Repositories: repos,
|
|
RandomRewardItemResolver: randomRewardItemTestResolverInSlots(
|
|
490005593, 490005595, 93, 120,
|
|
),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("type251 handler error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 || !bytes.Equal(got.UpperResponses[0].Body, []byte{1}) {
|
|
t.Fatalf("type251 handler result = %+v", got)
|
|
}
|
|
if len(got.ItemSlotRefreshes) != 2 || got.ItemSlotRefreshes[0].SlotIndex != 93 || got.ItemSlotRefreshes[1].SlotIndex != 94 {
|
|
t.Fatalf("type251 handler refreshes = %+v", got.ItemSlotRefreshes)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "79")
|
|
if source := loaded.Slots[slotKey(listTypeMain, 93)]; source.Count != 1 {
|
|
t.Fatalf("type251 source = %+v", source)
|
|
}
|
|
if reward := loaded.Slots[slotKey(listTypeMain, 94)]; reward.ItemID != 490005595 || reward.Count != 1 {
|
|
t.Fatalf("type251 reward = %+v", reward)
|
|
}
|
|
}
|
|
|
|
func randomRewardItemTestResolver(sourceID int64, rewardID int64) alignedcmd.RandomRewardItemResolver {
|
|
return randomRewardItemTestResolverInSlots(sourceID, rewardID, 65, 120)
|
|
}
|
|
|
|
func randomRewardItemTestResolverInSlots(sourceID int64, rewardID int64, slotStart, slotEnd int16) alignedcmd.RandomRewardItemResolver {
|
|
return func(candidate int64) (alignedcmd.RandomRewardItemResolution, error) {
|
|
if candidate != sourceID {
|
|
return alignedcmd.RandomRewardItemResolution{}, nil
|
|
}
|
|
return alignedcmd.RandomRewardItemResolution{
|
|
SourceItemID: sourceID,
|
|
SourcePVFPath: "stackable/cash/chn_490701730.stk",
|
|
StackableType: "random reward item",
|
|
Outcomes: []alignedcmd.RandomRewardItemOutcome{{
|
|
Weight: 1,
|
|
Reward: alignedcmd.MagicBoxRewardItem{
|
|
ItemID: rewardID, Kind: "stackable", TargetListType: listTypeMain, StackLimit: 1000,
|
|
SlotStart: slotStart, SlotEnd: slotEnd, PVFPath: "stackable/cash/chn_490701733.stk",
|
|
},
|
|
}},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func TestOwnerDeleteRejectsEquipmentLockedItem(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 5, Extra: map[string]string{"equipment_lock_state": "1"}},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.Delete(ctx, NewDeleteCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, DeleteRequest{
|
|
ListType: listTypeMain,
|
|
SlotIndex: 2,
|
|
Count: 1,
|
|
}))
|
|
if !errors.Is(err, ErrItemLocked) {
|
|
t.Fatalf("Delete error = %v, want ErrItemLocked", err)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 2)].Count; got != 5 {
|
|
t.Fatalf("count = %d, want unchanged 5", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerSellRejectsEquipmentLockedItem(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 2): {ItemID: 100, Count: 5, Extra: map[string]string{"equipment_lock_state": "1", "sell_gold": "0"}},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
_, err = owner.Sell(ctx, NewSellCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, DeleteOrSellRequest{
|
|
HasListType: true,
|
|
ListType: listTypeMain,
|
|
SlotIndex: 2,
|
|
Count: 1,
|
|
}))
|
|
if !errors.Is(err, ErrItemLocked) {
|
|
t.Fatalf("Sell error = %v, want ErrItemLocked", err)
|
|
}
|
|
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if got := loaded.Slots[slotKey(listTypeMain, 2)].Count; got != 5 {
|
|
t.Fatalf("count = %d, want unchanged 5", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerPetInventoryDeleteSellSortFailClosedWithoutStateMutation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
run func(context.Context, *Owner) error
|
|
}{
|
|
{
|
|
name: "delete",
|
|
run: func(ctx context.Context, owner *Owner) error {
|
|
_, err := owner.Delete(ctx, NewDeleteCommand(alignedcmd.Request{SelectedCharacterID: 91}, DeleteRequest{
|
|
ListType: listTypePet,
|
|
SlotIndex: 5,
|
|
Count: 1,
|
|
}))
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "sell",
|
|
run: func(ctx context.Context, owner *Owner) error {
|
|
_, err := owner.Sell(ctx, NewSellCommand(alignedcmd.Request{SelectedCharacterID: 91}, DeleteOrSellRequest{
|
|
HasListType: true,
|
|
ListType: listTypePet,
|
|
SlotIndex: 5,
|
|
Count: 1,
|
|
}))
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "sort",
|
|
run: func(ctx context.Context, owner *Owner) error {
|
|
_, err := owner.Sort(ctx, NewSortCommand(alignedcmd.Request{SelectedCharacterID: 91}, SortItemRequest{
|
|
ListType: listTypePet,
|
|
Category: 5,
|
|
}))
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
savePetInventoryGuardFixture(t, ctx, repos)
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner error = %v", err)
|
|
}
|
|
|
|
if err := test.run(ctx, owner); !errors.Is(err, ErrPetInventoryOwnerRequired) {
|
|
t.Fatalf("%s error = %v, want ErrPetInventoryOwnerRequired", test.name, err)
|
|
}
|
|
assertPetInventoryGuardFixtureUnchanged(t, ctx, repos)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerPetInventoryDeleteSellSortFailClosedWithoutStateMutation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opcode dnfenum.CmdPacket
|
|
body []byte
|
|
operation string
|
|
}{
|
|
{name: "delete", opcode: dnfenum.CmdPacketDeleteItem, body: []byte{listTypePet, 5, 0, 1, 0}, operation: "delete_item"},
|
|
{name: "sell", opcode: dnfenum.CmdPacketSellItem, body: []byte{listTypePet, 5, 0, 1, 0}, operation: "sell_item"},
|
|
{name: "sort", opcode: dnfenum.CmdPacketSortItem, body: []byte{listTypePet, 5, 0}, operation: "sort_item"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
savePetInventoryGuardFixture(t, ctx, repos)
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(test.opcode),
|
|
Body: test.body,
|
|
AccountID: "acc-pet-guard",
|
|
SelectedCharacterID: 91,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if test.opcode == dnfenum.CmdPacketDeleteItem {
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != test.operation || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if want := []byte{0, 4, 8, 0, 0, 0, 0x08, 0, 0x18, 0}; !bytes.Equal(got.UpperResponses[0].Body, want) {
|
|
t.Fatalf("delete failure ACK=%x want=%x", got.UpperResponses[0].Body, want)
|
|
}
|
|
} else if !got.Handled || got.ResponseAllowed || got.Operation != test.operation || len(got.UpperResponses) != 0 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if !strings.Contains(got.Reason, ErrPetInventoryOwnerRequired.Error()) {
|
|
t.Fatalf("reason = %q, want %q", got.Reason, ErrPetInventoryOwnerRequired.Error())
|
|
}
|
|
assertPetInventoryGuardFixtureUnchanged(t, ctx, repos)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 现场 2026-09-18:「宠物蛋无法丢弃 正常应可以丢弃」。宠物蛋是宠物栏(list 7)里
|
|
// 一行**没有绑定宠物实体**的道具,丢弃它不涉及图鉴/装备状态,必须放行;而已经孵化
|
|
// 的宠物(带 serial/handle)仍然由 typed pet owner 管,直接删会留下孤儿记录。
|
|
func TestHandlerPetEggDeleteSucceedsWhileBoundPetStaysFailClosed(t *testing.T) {
|
|
ctx := context.Background()
|
|
const characterID = "92"
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: characterID,
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypePet, 2): {ItemID: 63022, Count: 1, Extra: map[string]string{"item_kind": "creature"}},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketDeleteItem),
|
|
Body: []byte{listTypePet, 2, 0, 1, 0},
|
|
AccountID: "acc-pet-egg",
|
|
SelectedCharacterID: 92,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed {
|
|
t.Fatalf("宠物蛋删除被拒: %+v", got)
|
|
}
|
|
if strings.Contains(got.Reason, ErrPetInventoryOwnerRequired.Error()) {
|
|
t.Fatalf("宠物蛋删除仍被 typed pet owner 拦下: %q", got.Reason)
|
|
}
|
|
inventory := loadTestInventory(t, ctx, repos, characterID)
|
|
if stack, found := inventory.Slots[slotKey(listTypePet, 2)]; found {
|
|
t.Fatalf("宠物蛋没有被删掉: %+v", stack)
|
|
}
|
|
}
|
|
|
|
func savePetInventoryGuardFixture(t *testing.T, ctx context.Context, repos dnfrepo.Group) {
|
|
t.Helper()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "91",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypePet, 5): {
|
|
ItemID: 9001,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"creature_serial_or_handle": "37",
|
|
"sell_gold": "0",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
|
CharacterID: "91",
|
|
Entries: map[string]dnfrepo.PetEntry{
|
|
"37": {PetKey: "37", ItemID: 9001, SourceListType: listTypePet, SourceSlotIndex: 5, Name: "guard-pet", Level: 4, Exp: 12},
|
|
},
|
|
EquippedKey: "",
|
|
TownDisplay: false,
|
|
}); err != nil {
|
|
t.Fatalf("Save pet error = %v", err)
|
|
}
|
|
}
|
|
|
|
func assertPetInventoryGuardFixtureUnchanged(t *testing.T, ctx context.Context, repos dnfrepo.Group) {
|
|
t.Helper()
|
|
inventory := loadTestInventory(t, ctx, repos, "91")
|
|
stack, found := inventory.Slots[slotKey(listTypePet, 5)]
|
|
if !found || stack.ItemID != 9001 || stack.Count != 1 || stack.Extra["creature_serial_or_handle"] != "37" {
|
|
t.Fatalf("pet inventory mutated: found=%t stack=%+v", found, stack)
|
|
}
|
|
petRecord, found, err := repos.Pet.Load(ctx, "91")
|
|
if err != nil || !found {
|
|
t.Fatalf("Load pet found=%t err=%v", found, err)
|
|
}
|
|
entry, found := petRecord.Entries["37"]
|
|
if !found || entry.PetKey != "37" || entry.ItemID != 9001 || entry.SourceListType != listTypePet || entry.SourceSlotIndex != 5 || entry.Name != "guard-pet" || entry.Level != 4 || entry.Exp != 12 || petRecord.EquippedKey != "" || petRecord.TownDisplay {
|
|
t.Fatalf("pet record mutated: record=%+v entry=%+v found=%t", petRecord, entry, found)
|
|
}
|
|
}
|
|
|
|
func saveTestInventory(t *testing.T, ctx context.Context, repos dnfrepo.Group, record dnfrepo.InventoryRecord) {
|
|
t.Helper()
|
|
if err := repos.Inventory.Save(ctx, record); err != nil {
|
|
t.Fatalf("Save inventory error = %v", err)
|
|
}
|
|
}
|
|
|
|
func saveTestCharacter(t *testing.T, ctx context.Context, repos dnfrepo.Group, record dnfrepo.CharacterRecord) {
|
|
t.Helper()
|
|
if err := repos.Character.Save(ctx, record); err != nil {
|
|
t.Fatalf("Save character error = %v", err)
|
|
}
|
|
}
|
|
|
|
func loadTestInventory(t *testing.T, ctx context.Context, repos dnfrepo.Group, characterID string) dnfrepo.InventoryRecord {
|
|
t.Helper()
|
|
record, ok, err := repos.Inventory.Load(ctx, characterID)
|
|
if err != nil {
|
|
t.Fatalf("Load inventory error = %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatalf("inventory %s not found", characterID)
|
|
}
|
|
return record
|
|
}
|
|
|
|
func currentMoveItemspaceBody(sourceList byte, sourceSlot int16, sourceInstance int32, moveCount int32, destinationList byte, destinationSlot int16, destinationInstance int32, destinationStack int32, actorIndex int32) []byte {
|
|
body := make([]byte, 28)
|
|
body[0] = sourceList
|
|
binary.LittleEndian.PutUint16(body[1:3], uint16(sourceSlot))
|
|
binary.LittleEndian.PutUint32(body[3:7], uint32(sourceInstance))
|
|
binary.LittleEndian.PutUint32(body[7:11], uint32(moveCount))
|
|
body[11] = destinationList
|
|
binary.LittleEndian.PutUint16(body[12:14], uint16(destinationSlot))
|
|
binary.LittleEndian.PutUint32(body[14:18], uint32(destinationInstance))
|
|
binary.LittleEndian.PutUint32(body[18:22], uint32(destinationStack))
|
|
binary.LittleEndian.PutUint32(body[22:26], uint32(actorIndex))
|
|
return body
|
|
}
|
|
|
|
func testPetEquipRaw(slot int16, itemID int32, serial int32) []byte {
|
|
raw := make([]byte, 47)
|
|
raw[0] = byte(slot)
|
|
binary.LittleEndian.PutUint32(raw[1:], uint32(itemID))
|
|
binary.LittleEndian.PutUint32(raw[5:], uint32(serial))
|
|
binary.LittleEndian.PutUint32(raw[24:], uint32(serial))
|
|
return raw
|
|
}
|
|
|
|
func premiumContractTestResolver(itemID int64, premiumType int64, durationSeconds int64) alignedcmd.PremiumContractResolver {
|
|
return func(candidate int64) (alignedcmd.PremiumContractResolution, error) {
|
|
if candidate != itemID {
|
|
return alignedcmd.PremiumContractResolution{}, nil
|
|
}
|
|
return alignedcmd.PremiumContractResolution{
|
|
ItemID: itemID,
|
|
PremiumType: premiumType,
|
|
DurationSeconds: durationSeconds,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackablePremiumContractActivatesAccountPremium(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 30, Count: 2, Extra: map[string]string{"item_kind": "etc"}},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := owner.UseStackable(ctx, NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UseStackableRequest{
|
|
SlotIndex: 4,
|
|
ListType: listTypeMain,
|
|
InstanceValue: 0x11223344,
|
|
ItemCode: 30,
|
|
}), premiumContractTestResolver(30, 22, 86400))
|
|
if err != nil {
|
|
t.Fatalf("UseStackable error = %v", err)
|
|
}
|
|
if !result.Changed || !result.PremiumActivated || result.PremiumType != 22 || result.RemainingCount != 1 {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
if result.PremiumRemainingSeconds < 86390 || result.PremiumRemainingSeconds > 86400 {
|
|
t.Fatalf("remaining seconds = %d, want ~86400", result.PremiumRemainingSeconds)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if stack := loaded.Slots[slotKey(listTypeMain, 4)]; stack.ItemID != 30 || stack.Count != 1 {
|
|
t.Fatalf("stack = %+v, want item 30 count 1", stack)
|
|
}
|
|
account, ok, err := repos.Account.Load(ctx, "acc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("account load ok=%t err=%v", ok, err)
|
|
}
|
|
if raw := account.Metadata["premium_expire_22"]; raw == "" {
|
|
t.Fatalf("premium_expire_22 missing: %+v", account.Metadata)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackableAuctionCouponPersistsBundledPrivateStoreLicense(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 193, Count: 1},
|
|
},
|
|
})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := owner.UseStackable(ctx, NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc", SelectedCharacterID: 77,
|
|
}, UseStackableRequest{SlotIndex: 4, ListType: listTypeMain, InstanceValue: 0x11223344, ItemCode: 193}),
|
|
premiumContractTestResolver(193, premium.TypeAuction, 30*86400))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(result.PremiumActivations, []PremiumActivation{
|
|
{PremiumType: premium.TypeAuction, RemainingSeconds: result.PremiumRemainingSeconds},
|
|
{PremiumType: premium.TypePrivateStore, RemainingSeconds: result.PremiumRemainingSeconds},
|
|
}) {
|
|
t.Fatalf("premium activations=%+v", result.PremiumActivations)
|
|
}
|
|
account, found, err := repos.Account.Load(ctx, "acc")
|
|
if err != nil || !found || !premium.Active(account, premium.TypeAuction, time.Now()) ||
|
|
!premium.Active(account, premium.TypePrivateStore, time.Now()) {
|
|
t.Fatalf("bundled premiums account=%+v found=%t err=%v", account.Metadata, found, err)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackablePremiumContractRenewalStacksOnExpiry(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
future := time.Now().Add(48 * time.Hour).Unix()
|
|
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
|
AccountID: "acc",
|
|
Metadata: map[string]string{"premium_expire_27": strconv.FormatInt(future, 10)},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 43, Count: 1},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := owner.UseStackable(ctx, NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UseStackableRequest{
|
|
SlotIndex: 4,
|
|
ListType: listTypeMain,
|
|
InstanceValue: 0x11223344,
|
|
ItemCode: 43,
|
|
}), premiumContractTestResolver(43, 27, 86400))
|
|
if err != nil {
|
|
t.Fatalf("UseStackable error = %v", err)
|
|
}
|
|
account, ok, err := repos.Account.Load(ctx, "acc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("account load ok=%t err=%v", ok, err)
|
|
}
|
|
got, err := strconv.ParseInt(account.Metadata["premium_expire_27"], 10, 64)
|
|
if err != nil {
|
|
t.Fatalf("premium_expire_27 = %q: %v", account.Metadata["premium_expire_27"], err)
|
|
}
|
|
if want := future + 86400; got != want {
|
|
t.Fatalf("premium_expire_27 = %d, want %d (stacked on existing expiry)", got, want)
|
|
}
|
|
if result.PremiumRemainingSeconds <= 0 {
|
|
t.Fatalf("remaining = %d", result.PremiumRemainingSeconds)
|
|
}
|
|
if _, exists := loadTestInventory(t, ctx, repos, "77").Slots[slotKey(listTypeMain, 4)]; exists {
|
|
t.Fatal("consumed last contract item still exists")
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackablePremiumContractMismatchKeepsItemAndAccount(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 31, Count: 1},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = owner.UseStackable(ctx, NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UseStackableRequest{
|
|
SlotIndex: 4,
|
|
ListType: listTypeMain,
|
|
InstanceValue: 0x11223344,
|
|
ItemCode: 30,
|
|
}), premiumContractTestResolver(30, 22, 86400))
|
|
if !errors.Is(err, ErrItemMismatch) {
|
|
t.Fatalf("UseStackable error = %v, want ErrItemMismatch", err)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if stack := loaded.Slots[slotKey(listTypeMain, 4)]; stack.ItemID != 31 || stack.Count != 1 {
|
|
t.Fatalf("stack mutated: %+v", stack)
|
|
}
|
|
account, ok, err := repos.Account.Load(ctx, "acc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("account load ok=%t err=%v", ok, err)
|
|
}
|
|
if _, exists := account.Metadata["premium_expire_22"]; exists {
|
|
t.Fatalf("premium metadata written on mismatch: %+v", account.Metadata)
|
|
}
|
|
}
|
|
|
|
func TestOwnerUseStackablePremiumContractResolverErrorFailsClosed(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 4): {ItemID: 30, Count: 1},
|
|
},
|
|
})
|
|
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = owner.UseStackable(ctx, NewUseStackableCommand(alignedcmd.Request{
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
}, UseStackableRequest{
|
|
SlotIndex: 4,
|
|
ListType: listTypeMain,
|
|
InstanceValue: 0x11223344,
|
|
ItemCode: 30,
|
|
}), func(int64) (alignedcmd.PremiumContractResolution, error) {
|
|
return alignedcmd.PremiumContractResolution{}, errors.New("PVF unavailable")
|
|
})
|
|
if !errors.Is(err, ErrPremiumContractResolveFailed) {
|
|
t.Fatalf("UseStackable error = %v, want ErrPremiumContractResolveFailed", err)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if stack := loaded.Slots[slotKey(listTypeMain, 4)]; stack.ItemID != 30 || stack.Count != 1 {
|
|
t.Fatalf("stack mutated: %+v", stack)
|
|
}
|
|
}
|
|
|
|
func TestHandlerUseStackablePremiumContractReturnsAckAndPremiumNoti(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "78",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
slotKey(listTypeMain, 5): {ItemID: 30, Count: 2},
|
|
},
|
|
})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketUseStackable),
|
|
Body: []byte{
|
|
0x05, 0x00,
|
|
listTypeMain,
|
|
0x11, 0x22, 0x33, 0x44,
|
|
0x1E, 0x00, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00,
|
|
},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 78,
|
|
Repositories: repos,
|
|
PremiumContractResolver: premiumContractTestResolver(30, 22, 86400),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "use_stackable" || len(got.UpperResponses) != 2 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
ack := got.UpperResponses[0]
|
|
wantAck := []byte{1, 5, 0, listTypeMain, 0x11, 0x22, 0x33, 0x44, 0x1E, 0, 0, 0}
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketUseStackable) || !bytes.Equal(ack.Body, wantAck) {
|
|
t.Fatalf("ack = msgID=%d body=% X want=% X", ack.MsgID, ack.Body, wantAck)
|
|
}
|
|
noti := got.UpperResponses[1]
|
|
wantNoti := []byte{2, 0, 22, 0x80, 0x51, 0x01, 0, 0, 0, 0, 0}
|
|
if noti.MsgID != 0x0042 || noti.Classification != 0 || !bytes.Equal(noti.Body, wantNoti) {
|
|
t.Fatalf("noti = msgID=%d class=%d body=% X want class0 % X", noti.MsgID, noti.Classification, noti.Body, wantNoti)
|
|
}
|
|
account, ok, err := repos.Account.Load(ctx, "acc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("account load ok=%t err=%v", ok, err)
|
|
}
|
|
if raw := account.Metadata["premium_expire_22"]; raw == "" {
|
|
t.Fatalf("premium_expire_22 missing: %+v", account.Metadata)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRepairAllReturnsAckWithSlotFFFF(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 5000}})
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
|
CharacterID: "77",
|
|
Entries: map[string]dnfrepo.EquipmentEntry{
|
|
"12": {SlotIndex: 12, ItemID: 700, RawEntry: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"0:4": {ItemID: 701, Count: 1, Extra: map[string]string{"durability": "10"}},
|
|
},
|
|
})
|
|
resolver := func(itemID int64) (alignedcmd.RepairCostEvidence, error) {
|
|
equipmentType := "[amulet]"
|
|
if itemID == 700 {
|
|
equipmentType = "[weapon]"
|
|
}
|
|
return alignedcmd.RepairCostEvidence{
|
|
EquipmentType: equipmentType,
|
|
MaxDurability: 20,
|
|
RepairPrice: 6400,
|
|
Grade: 20,
|
|
RepairCostRate: 0.08,
|
|
QuickRepairRate: 1.5,
|
|
UpgradeRates: []float64{1, 1, 1},
|
|
}, nil
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketRepairEquipment),
|
|
Body: []byte{
|
|
listTypeEquipment,
|
|
0xFF, 0xFF,
|
|
0xFF, 0xFF,
|
|
0x00, 0x00, 0x00,
|
|
},
|
|
AccountID: "acc",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
RepairCostResolver: resolver,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || !got.ResponseAllowed || got.Operation != "repair_equipment" || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
if !reflect.DeepEqual(got.EquipmentSlotRefreshes, []int16{12}) {
|
|
t.Fatalf("equipment refreshes = %+v, want slot 12", got.EquipmentSlotRefreshes)
|
|
}
|
|
if !reflect.DeepEqual(got.ItemSlotRefreshes, []alignedcmd.ItemSlotRefresh{{ListType: listTypeMain, SlotIndex: 4}}) {
|
|
t.Fatalf("inventory refreshes = %+v, want main slot 4", got.ItemSlotRefreshes)
|
|
}
|
|
// weapon 12 lost 8 -> 512; amulet slot4 lost 10 -> 640; total 1152,
|
|
// gold 5000-1152 = 3848 = 0x0F08 little-endian.
|
|
ack := got.UpperResponses[0]
|
|
wantBody := []byte{1, 0x08, 0x0F, 0x00, 0x00, listTypeEquipment, 0xFF, 0xFF, 0x00, 0x00}
|
|
if ack.MsgID != uint16(dnfenum.CmdPacketRepairEquipment) || !bytes.Equal(ack.Body, wantBody) {
|
|
t.Fatalf("ack = %+v body=% X want=% X", ack, ack.Body, wantBody)
|
|
}
|
|
equipment, _, _ := repos.Equipment.Load(ctx, "77")
|
|
if got := uint16(equipment.Entries["12"].RawEntry[10]) | uint16(equipment.Entries["12"].RawEntry[11])<<8; got != 20 {
|
|
t.Fatalf("equipped durability = %d, want 20", got)
|
|
}
|
|
loaded := loadTestInventory(t, ctx, repos, "77")
|
|
if loaded.Slots["0:4"].Extra["durability"] != "20" {
|
|
t.Fatalf("quickbar durability = %q, want 20", loaded.Slots["0:4"].Extra["durability"])
|
|
}
|
|
character, _, _ := repos.Character.Load(ctx, "77")
|
|
if character.Stats["gold"] != 3848 {
|
|
t.Fatalf("gold = %d, want 3848", character.Stats["gold"])
|
|
}
|
|
}
|
|
|
|
func TestSortSegmentUsesCurrentEXEPetConsumableRange(t *testing.T) {
|
|
start, end, ok := sortSegment(listTypePet, 7)
|
|
if !ok || start != 189 || end != 238 {
|
|
t.Fatalf("pet-consumable segment=(%d,%d,%t), want (189,238,true)", start, end, ok)
|
|
}
|
|
}
|