服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1014 lines
39 KiB
Go
1014 lines
39 KiB
Go
package inventory
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"reflect"
|
||
"testing"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||
)
|
||
|
||
func TestMoveAccountCargoPersistsRowsUnderAccountOwner(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||
AccountID: "dnf:account-cargo",
|
||
Metadata: map[string]string{
|
||
"account_cargo_created": "true",
|
||
"account_cargo_level": "8",
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
"0:21": {ItemID: 100021, Count: 3, Extra: map[string]string{"stack_limit": "100"}},
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
owner, err := NewOwner(repos)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
owner.SetStackMergeResolver(accountCargoTestResolver(map[int64]int64{100021: 100}))
|
||
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
||
AccountID: "dnf:account-cargo",
|
||
SelectedCharacterID: 77,
|
||
}, MoveItemspaceRequest{
|
||
SourceListType: listTypeMain,
|
||
SourceSlotIndex: 21,
|
||
MoveCount: 3,
|
||
DestinationListType: listTypeAccountCargo,
|
||
DestinationSlotIndex: 2,
|
||
}))
|
||
if err != nil {
|
||
t.Fatalf("move to account cargo: %v", err)
|
||
}
|
||
if !result.Changed || result.Mode != "move" || result.MoveCount != 3 {
|
||
t.Fatalf("move result=%+v", result)
|
||
}
|
||
character, found, err := repos.Inventory.Load(ctx, "77")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
if _, exists := character.Slots["0:21"]; exists {
|
||
t.Fatalf("source still belongs to character: %+v", character.Slots)
|
||
}
|
||
cargo, found, err := repos.AccountInventory.Load(ctx, "dnf:account-cargo")
|
||
if err != nil || !found {
|
||
t.Fatalf("load account cargo found=%t err=%v", found, err)
|
||
}
|
||
if got := cargo.Slots["12:2"]; got.ItemID != 100021 || got.Count != 3 {
|
||
t.Fatalf("account cargo row=%+v", got)
|
||
}
|
||
|
||
// A different selected character must see the same account-owned row and
|
||
// can take it back without creating a per-character list-12 duplicate.
|
||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "78", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
result, err = owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
||
AccountID: "dnf:account-cargo",
|
||
SelectedCharacterID: 78,
|
||
}, MoveItemspaceRequest{
|
||
SourceListType: listTypeAccountCargo,
|
||
SourceSlotIndex: 2,
|
||
MoveCount: 3,
|
||
DestinationListType: listTypeMain,
|
||
DestinationSlotIndex: 22,
|
||
}))
|
||
if err != nil {
|
||
t.Fatalf("withdraw from account cargo: %v", err)
|
||
}
|
||
if !result.Changed || result.Mode != "move" {
|
||
t.Fatalf("withdraw result=%+v", result)
|
||
}
|
||
second, found, err := repos.Inventory.Load(ctx, "78")
|
||
if err != nil || !found {
|
||
t.Fatalf("load second character found=%t err=%v", found, err)
|
||
}
|
||
if got := second.Slots["0:22"]; got.ItemID != 100021 || got.Count != 3 {
|
||
t.Fatalf("withdrawn row=%+v", got)
|
||
}
|
||
cargo, found, err = repos.AccountInventory.Load(ctx, "dnf:account-cargo")
|
||
if err != nil || !found {
|
||
t.Fatalf("reload account cargo found=%t err=%v", found, err)
|
||
}
|
||
if _, exists := cargo.Slots["12:2"]; exists {
|
||
t.Fatalf("account cargo row remained after withdraw: %+v", cargo.Slots)
|
||
}
|
||
}
|
||
|
||
func TestHandlerAccountCargoRejectsSecondPVFCharmInQuickSlotWithoutPartialSave(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
const accountID = "dnf:account-cargo-charm"
|
||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||
AccountID: accountID,
|
||
Metadata: map[string]string{
|
||
"account_cargo_created": "true",
|
||
"account_cargo_level": "8",
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeMain, 4): {ItemID: 400360003, Count: 1},
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||
AccountID: accountID,
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeAccountCargo, 6): {ItemID: 400360005, Count: 1},
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resolver := func(itemID int64) (alignedcmd.StackMergeResolution, error) {
|
||
return alignedcmd.StackMergeResolution{ItemID: itemID, Kind: "equipment", EquipmentType: "[charm]"}, nil
|
||
}
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
Body: currentMoveItemspaceBody(listTypeAccountCargo, 6, 0, 1, listTypeMain, 3, 0, 0, -1),
|
||
AccountID: accountID,
|
||
SelectedCharacterID: 77,
|
||
Repositories: repos,
|
||
StackMergeResolver: resolver,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Handle error = %v", err)
|
||
}
|
||
wantBody := []byte{0, moveItemspaceRejectedErrorCode, listTypeAccountCargo, listTypeMain}
|
||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
||
!reflect.DeepEqual(got.UpperResponses[0].Body, wantBody) {
|
||
t.Fatalf("result = %+v, want body % X", got, wantBody)
|
||
}
|
||
character, _, _ := repos.Inventory.Load(ctx, "77")
|
||
if _, found := character.Slots[slotKey(listTypeMain, 3)]; found ||
|
||
character.Slots[slotKey(listTypeMain, 4)].ItemID != 400360003 {
|
||
t.Fatalf("character partially saved: %+v", character.Slots)
|
||
}
|
||
cargo, _, _ := repos.AccountInventory.Load(ctx, accountID)
|
||
if got := cargo.Slots[slotKey(listTypeAccountCargo, 6)].ItemID; got != 400360005 {
|
||
t.Fatalf("account cargo source changed to %d", got)
|
||
}
|
||
}
|
||
|
||
func TestMoveAccountCargoUsesPVFResolverToMergeLegacyMaterial(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "dnf:legacy-cargo", Metadata: map[string]string{
|
||
"account_cargo_created": "true", "account_cargo_level": "8",
|
||
}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
|
||
"0:65": {ItemID: 3244, Count: 5, Extra: map[string]string{"source": "pickup"}},
|
||
}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{AccountID: "dnf:legacy-cargo", Slots: map[string]dnfrepo.ItemStack{
|
||
"12:2": {ItemID: 3244, Count: 7, Extra: map[string]string{"source": "mail"}},
|
||
}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
owner, err := NewOwner(repos)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
owner.SetStackMergeResolver(func(itemID int64) (alignedcmd.StackMergeResolution, error) {
|
||
return alignedcmd.StackMergeResolution{
|
||
ItemID: itemID,
|
||
Kind: "stackable",
|
||
PVFPath: "stackable/material/crystal_pure.stk",
|
||
AttachType: "`[free]`",
|
||
// 这条用例考的是"按 PVF 解析器合并进已有堆",与 attach type 无关;
|
||
// 解析器必须同时声明它可入金库,否则会在入库闸门就被挡掉。
|
||
StackableType: "[material]",
|
||
}, nil
|
||
})
|
||
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{AccountID: "dnf:legacy-cargo", SelectedCharacterID: 77}, MoveItemspaceRequest{
|
||
SourceListType: listTypeMain, SourceSlotIndex: 65, MoveCount: 5,
|
||
DestinationListType: listTypeAccountCargo, DestinationSlotIndex: 6,
|
||
}))
|
||
if err != nil {
|
||
t.Fatalf("Move error = %v", err)
|
||
}
|
||
if result.Mode != "auto_stack" || result.MoveCount != 5 || !result.Changed {
|
||
t.Fatalf("result = %+v", result)
|
||
}
|
||
cargo, found, err := repos.AccountInventory.Load(ctx, "dnf:legacy-cargo")
|
||
if err != nil || !found {
|
||
t.Fatalf("load cargo found=%t err=%v", found, err)
|
||
}
|
||
if _, exists := cargo.Slots["12:6"]; exists {
|
||
t.Fatalf("literal destination was populated: %+v", cargo.Slots)
|
||
}
|
||
if stack := cargo.Slots["12:2"]; stack.Count != 12 || stack.Extra["pvf_path"] != "stackable/material/crystal_pure.stk" {
|
||
t.Fatalf("merged cargo stack = %+v", stack)
|
||
}
|
||
}
|
||
|
||
// TestMoveAccountCargoRedirectsSlotOutsidePersistedCapacityIntoUnlockedSlot 运营口径
|
||
// 2026-09-18:落点在未解锁栏位(这里是只解锁 1 格的金库、客户端却拖到第 1 格)不再报错,
|
||
// 而是顺延到已解锁区第一个空格(第 0 格)。旧行为是把这次拖拽整个拒掉。
|
||
func TestMoveAccountCargoRedirectsSlotOutsidePersistedCapacityIntoUnlockedSlot(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||
AccountID: "dnf:tiny-cargo",
|
||
Metadata: map[string]string{"account_cargo_created": "true", "account_cargo_level": "1"},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{"0:1": {ItemID: 1, Count: 1}}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
owner, err := NewOwner(repos)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
owner.SetStackMergeResolver(accountCargoTestFreeResolver())
|
||
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
||
AccountID: "dnf:tiny-cargo",
|
||
SelectedCharacterID: 77,
|
||
}, MoveItemspaceRequest{
|
||
SourceListType: listTypeMain,
|
||
SourceSlotIndex: 1,
|
||
MoveCount: 1,
|
||
DestinationListType: listTypeAccountCargo,
|
||
DestinationSlotIndex: 1,
|
||
}))
|
||
if err != nil {
|
||
t.Fatalf("Move error = %v", err)
|
||
}
|
||
if !result.Changed || result.DestinationSlotIndex != 0 || !result.RedirectedReservedMainSlot {
|
||
t.Fatalf("result = %+v, 期望顺延到 12:0", result)
|
||
}
|
||
cargo, found, err := repos.AccountInventory.Load(ctx, "dnf:tiny-cargo")
|
||
if err != nil || !found {
|
||
t.Fatalf("load cargo found=%t err=%v", found, err)
|
||
}
|
||
if _, exists := cargo.Slots["12:1"]; exists {
|
||
t.Fatalf("未解锁格不能被写入: %+v", cargo.Slots)
|
||
}
|
||
if stack := cargo.Slots["12:0"]; stack.ItemID != 1 || stack.Count != 1 {
|
||
t.Fatalf("落点应为 12:0: %+v", cargo.Slots)
|
||
}
|
||
}
|
||
|
||
func TestMoveAccountCargoAutoStacksBackpackDepositIntoLowestPVFCompatibleSlot(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||
AccountID: "dnf:auto-deposit",
|
||
Metadata: map[string]string{
|
||
"account_cargo_created": "true",
|
||
"account_cargo_level": "8",
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
source := pvfMoveStack(3227, 2, 10)
|
||
source.RawEntry = make([]byte, currentItemListEntrySize)
|
||
binary.LittleEndian.PutUint32(source.RawEntry[0x06:0x0A], 2)
|
||
target := pvfMoveStack(3227, 3, 10)
|
||
target.RawEntry = make([]byte, currentItemListEntrySize)
|
||
binary.LittleEndian.PutUint32(target.RawEntry[0x06:0x0A], 3)
|
||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
"0:65": source,
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||
AccountID: "dnf:auto-deposit",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
// 同一道具在一个容器里只有一行:3227 只放在 12:0。
|
||
"12:0": target,
|
||
"12:2": pvfMoveStack(3331, 3, 10),
|
||
// A stale row beyond the persisted capacity is never an auto target.
|
||
"12:9": pvfMoveStack(3227, 1, 10),
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
owner, err := NewOwner(repos)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
owner.SetStackMergeResolver(accountCargoTestResolver(map[int64]int64{3227: 10}))
|
||
|
||
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
||
AccountID: "dnf:auto-deposit",
|
||
SelectedCharacterID: 77,
|
||
}, MoveItemspaceRequest{
|
||
SourceListType: listTypeMain,
|
||
SourceSlotIndex: 65,
|
||
MoveCount: 2,
|
||
DestinationListType: listTypeAccountCargo,
|
||
DestinationSlotIndex: 4,
|
||
}))
|
||
if err != nil {
|
||
t.Fatalf("auto-stack deposit: %v", err)
|
||
}
|
||
if result.Mode != "auto_stack" || result.MoveCount != 2 || !result.Changed {
|
||
t.Fatalf("result=%+v", result)
|
||
}
|
||
// 跨容器 auto_stack 的真实落点写在 ACK 里,不能再补角色容器的整 list 快照:
|
||
// list-0 的保留槽 0(金币钱包行)会被一起下发,现场就是"获得了 N 金币"。
|
||
if len(result.RefreshListTypes) != 0 || len(result.Refresh) != 0 {
|
||
t.Fatalf("refresh=%v lists=%v, want none", result.Refresh, result.RefreshListTypes)
|
||
}
|
||
if result.DestinationSlotIndex != 0 || !result.RedirectedReservedMainSlot {
|
||
t.Fatalf("ack destination=%d redirected=%t, want the real merged slot 0",
|
||
result.DestinationSlotIndex, result.RedirectedReservedMainSlot)
|
||
}
|
||
character, found, err := repos.Inventory.Load(ctx, "77")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
if _, exists := character.Slots["0:65"]; exists {
|
||
t.Fatalf("source remained after deposit: %+v", character.Slots)
|
||
}
|
||
cargo, found, err := repos.AccountInventory.Load(ctx, "dnf:auto-deposit")
|
||
if err != nil || !found {
|
||
t.Fatalf("load cargo found=%t err=%v", found, err)
|
||
}
|
||
if _, exists := cargo.Slots["12:4"]; exists {
|
||
t.Fatalf("literal empty destination was populated: %+v", cargo.Slots)
|
||
}
|
||
merged := cargo.Slots["12:0"]
|
||
if merged.Count != 5 || binary.LittleEndian.Uint32(merged.RawEntry[0x06:0x0A]) != 5 {
|
||
t.Fatalf("merged cargo stack=%+v raw=%x", merged, merged.RawEntry)
|
||
}
|
||
if cargo.Slots["12:9"].Count != 1 {
|
||
t.Fatalf("out-of-capacity stale row changed: %+v", cargo.Slots["12:9"])
|
||
}
|
||
}
|
||
|
||
func TestMoveAccountCargoAutoStacksWithdrawalIntoExistingBackpackStack(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||
AccountID: "dnf:auto-withdraw",
|
||
Metadata: map[string]string{
|
||
"account_cargo_created": "true",
|
||
"account_cargo_level": "8",
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
"0:66": pvfMoveStack(3227, 3, 10),
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||
AccountID: "dnf:auto-withdraw",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
"12:6": pvfMoveStack(3227, 2, 10),
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
owner, err := NewOwner(repos)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
||
AccountID: "dnf:auto-withdraw",
|
||
SelectedCharacterID: 77,
|
||
}, MoveItemspaceRequest{
|
||
SourceListType: listTypeAccountCargo,
|
||
SourceSlotIndex: 6,
|
||
MoveCount: 2,
|
||
DestinationListType: listTypeMain,
|
||
DestinationSlotIndex: 70,
|
||
}))
|
||
if err != nil {
|
||
t.Fatalf("auto-stack withdrawal: %v", err)
|
||
}
|
||
if result.Mode != "auto_stack" || result.MoveCount != 2 || !result.Changed {
|
||
t.Fatalf("result=%+v", result)
|
||
}
|
||
// 同上:真实落点(背包里那一堆)写进 ACK,不再补整 list 快照。
|
||
if len(result.RefreshListTypes) != 0 || len(result.Refresh) != 0 {
|
||
t.Fatalf("refresh=%v lists=%v, want none", result.Refresh, result.RefreshListTypes)
|
||
}
|
||
if result.DestinationSlotIndex != 66 || !result.RedirectedReservedMainSlot {
|
||
t.Fatalf("ack destination=%d redirected=%t, want the real merged slot 66",
|
||
result.DestinationSlotIndex, result.RedirectedReservedMainSlot)
|
||
}
|
||
character, found, err := repos.Inventory.Load(ctx, "77")
|
||
if err != nil || !found {
|
||
t.Fatalf("load character found=%t err=%v", found, err)
|
||
}
|
||
if got := character.Slots["0:66"].Count; got != 5 {
|
||
t.Fatalf("backpack merged count=%d want=5", got)
|
||
}
|
||
if _, exists := character.Slots["0:70"]; exists {
|
||
t.Fatalf("literal empty destination was populated: %+v", character.Slots)
|
||
}
|
||
cargo, found, err := repos.AccountInventory.Load(ctx, "dnf:auto-withdraw")
|
||
if err != nil || !found {
|
||
t.Fatalf("load cargo found=%t err=%v", found, err)
|
||
}
|
||
if _, exists := cargo.Slots["12:6"]; exists {
|
||
t.Fatalf("cargo source remained: %+v", cargo.Slots)
|
||
}
|
||
}
|
||
|
||
func TestHandlerAccountCargoAutoStackReturnsAuthoritativeCharacterAndCargoRefreshPlan(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||
AccountID: "dnf:auto-handler",
|
||
Metadata: map[string]string{
|
||
"account_cargo_created": "true",
|
||
"account_cargo_level": "8",
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
"0:66": pvfMoveStack(3227, 3, 10),
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||
AccountID: "dnf:auto-handler",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
"12:6": pvfMoveStack(3227, 2, 10),
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
Body: currentMoveItemspaceBody(
|
||
listTypeAccountCargo,
|
||
6,
|
||
0,
|
||
2,
|
||
listTypeMain,
|
||
70,
|
||
0,
|
||
0,
|
||
-1,
|
||
),
|
||
AccountID: "dnf:auto-handler",
|
||
SelectedCharacterID: 77,
|
||
Repositories: repos,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Handle error=%v", err)
|
||
}
|
||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
||
len(got.ItemSlotRefreshes) != 0 ||
|
||
!reflect.DeepEqual(got.PostActions, []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedAccountCargo}) {
|
||
t.Fatalf("result=%+v", got)
|
||
}
|
||
ack := got.UpperResponses[0]
|
||
if ack.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) || ack.Classification != 1 ||
|
||
binary.LittleEndian.Uint32(ack.Body[4:8]) != 2 {
|
||
t.Fatalf("ACK=%+v body=%x", ack, ack.Body)
|
||
}
|
||
// 真实落点是背包已有的那一堆 0:66,不是客户端拖过去的空格 0:70;ACK 必须报 66,
|
||
// 并且不再补角色容器的整 list 快照(那会把 list-0 的金币钱包行一起下发)。
|
||
wantACK := []byte{1, listTypeAccountCargo, 6, 0, 2, 0, 0, 0, listTypeMain, 66, 0}
|
||
if !reflect.DeepEqual(ack.Body, wantACK) {
|
||
t.Fatalf("ACK body=% X, want % X", ack.Body, wantACK)
|
||
}
|
||
}
|
||
|
||
func TestHandlerAccountCargoExplicitStackRefreshesCharacterEndpointAndCargo(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||
AccountID: "dnf:explicit-stack",
|
||
Metadata: map[string]string{
|
||
"account_cargo_created": "true",
|
||
"account_cargo_level": "8",
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
"0:66": pvfMoveStack(3227, 3, 10),
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||
AccountID: "dnf:explicit-stack",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
"12:6": pvfMoveStack(3227, 2, 10),
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
Body: currentMoveItemspaceBody(
|
||
listTypeAccountCargo,
|
||
6,
|
||
0,
|
||
2,
|
||
listTypeMain,
|
||
66,
|
||
0,
|
||
3,
|
||
-1,
|
||
),
|
||
AccountID: "dnf:explicit-stack",
|
||
SelectedCharacterID: 77,
|
||
Repositories: repos,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Handle error=%v", err)
|
||
}
|
||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
||
!reflect.DeepEqual(got.ItemSlotRefreshes, []alignedcmd.ItemSlotRefresh{{ListType: listTypeMain, SlotIndex: 66}}) ||
|
||
!reflect.DeepEqual(got.PostActions, []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedAccountCargo}) {
|
||
t.Fatalf("result=%+v", got)
|
||
}
|
||
}
|
||
|
||
func TestHandlerAccountCargoAutoStackTransactionFailureRollsBackBothOwners(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||
AccountID: "dnf:auto-rollback",
|
||
Metadata: map[string]string{
|
||
"account_cargo_created": "true",
|
||
"account_cargo_level": "8",
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
"0:65": pvfMoveStack(3227, 2, 10),
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||
AccountID: "dnf:auto-rollback",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
"12:2": pvfMoveStack(3227, 3, 10),
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
wantErr := errors.New("forced account cargo auto-stack rollback")
|
||
repos.AccountItems = rollbackAccountCharacterItemsAfterApply{base: repos.AccountItems, err: wantErr}
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
Body: currentMoveItemspaceBody(
|
||
listTypeMain,
|
||
65,
|
||
0,
|
||
2,
|
||
listTypeAccountCargo,
|
||
4,
|
||
0,
|
||
0,
|
||
-1,
|
||
),
|
||
AccountID: "dnf:auto-rollback",
|
||
SelectedCharacterID: 77,
|
||
Repositories: repos,
|
||
StackMergeResolver: accountCargoTestResolver(map[int64]int64{3227: 10}),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Handle error=%v", err)
|
||
}
|
||
// 2026-09-18:已解析成 move 的请求被 owner 拒绝时必须回 op19 失败帧。静默会让客户端
|
||
// sub_1D2DE80 的 pending 拖拽对象永远不释放——现场就是金库/装备/消耗品窗口全部点不动。
|
||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||
t.Fatalf("failure result=%+v", got)
|
||
}
|
||
wantFailure := []byte{0, moveItemspaceRejectedErrorCode, listTypeMain, listTypeAccountCargo}
|
||
if body := got.UpperResponses[0].Body; string(body) != string(wantFailure) {
|
||
t.Fatalf("failure ack=% X, want % X", body, wantFailure)
|
||
}
|
||
character, _, _ := repos.Inventory.Load(ctx, "77")
|
||
if got := character.Slots["0:65"].Count; got != 2 {
|
||
t.Fatalf("source after rollback=%d want=2", got)
|
||
}
|
||
cargo, _, _ := repos.AccountInventory.Load(ctx, "dnf:auto-rollback")
|
||
if got := cargo.Slots["12:2"].Count; got != 3 {
|
||
t.Fatalf("cargo target after rollback=%d want=3", got)
|
||
}
|
||
}
|
||
|
||
// TestHandlerAccountCargoToPersonalCargoLockedSlotMoveLandsInFirstUnlockedSlot 反方向
|
||
// (账号金库 → 个人仓库)同样按运营口径顺延:拖到未解锁的 2:40 时落到第一个已解锁空格。
|
||
func TestHandlerAccountCargoToPersonalCargoLockedSlotMoveLandsInFirstUnlockedSlot(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
saveLockedSlotAccountCargoFixture(t, ctx, repos, "dnf:locked-slot", map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeAccountCargo, 0): pvfMoveStack(3330, 1, 0),
|
||
})
|
||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Warehouse: map[string]dnfrepo.ItemStack{},
|
||
})
|
||
saveMoveContainerState(t, ctx, repos, "77", 24, 8)
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
Body: currentMoveItemspaceBody(listTypeAccountCargo, 0, 0, 1, listTypePersonalCargo, 40, 0, 0, -1),
|
||
AccountID: "dnf:locked-slot",
|
||
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)
|
||
}
|
||
_, _, dstList, dstSlot := parseMoveSuccessAckEndpoints(t, got.UpperResponses[0].Body)
|
||
if dstList != listTypePersonalCargo || dstSlot != 0 {
|
||
t.Fatalf("ACK 目标 = (%d,%d), 期望 (2,0)", dstList, dstSlot)
|
||
}
|
||
loaded := loadTestInventory(t, ctx, repos, "77")
|
||
if _, exists := loaded.Warehouse[slotKey(listTypePersonalCargo, 40)]; exists {
|
||
t.Fatalf("未解锁格不能被写入: %+v", loaded.Warehouse)
|
||
}
|
||
if landed := loaded.Warehouse[slotKey(listTypePersonalCargo, 0)]; landed.ItemID != 3330 || landed.Count != 1 {
|
||
t.Fatalf("落点应为 2:0 的 3330x1: %+v", landed)
|
||
}
|
||
cargo, _, _ := repos.AccountInventory.Load(ctx, "dnf:locked-slot")
|
||
if _, exists := cargo.Slots[slotKey(listTypeAccountCargo, 0)]; exists {
|
||
t.Fatalf("金库源行应被搬空: %+v", cargo.Slots)
|
||
}
|
||
}
|
||
|
||
// TestHandlerAccountCargoSameContainerMoveToLockedSlotSnapsBack 金库内把一堆拖到未解锁栏位:
|
||
// 按运营口径 2026-09-18 **不适用**"顺延到第一个空格",直接回原位(回 op19 失败帧)。
|
||
func TestHandlerAccountCargoSameContainerMoveToLockedSlotSnapsBack(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
saveLockedSlotAccountCargoFixture(t, ctx, repos, "dnf:locked-slot", map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeAccountCargo, 2): pvfMoveStack(3330, 10, 1000),
|
||
})
|
||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77"})
|
||
|
||
move := NewMoveCommand(alignedcmd.Request{
|
||
AccountID: "dnf:locked-slot",
|
||
SelectedCharacterID: 77,
|
||
}, MoveItemspaceRequest{
|
||
SourceListType: listTypeAccountCargo,
|
||
SourceSlotIndex: 2,
|
||
MoveCount: 10,
|
||
DestinationListType: listTypeAccountCargo,
|
||
DestinationSlotIndex: 28,
|
||
})
|
||
owner, err := NewOwner(repos)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := owner.Move(ctx, move); !errors.Is(err, ErrMoveDestinationFull) {
|
||
t.Fatalf("Move error = %v, 期望 ErrMoveDestinationFull(回原位)", err)
|
||
}
|
||
cargo, _, _ := repos.AccountInventory.Load(ctx, "dnf:locked-slot")
|
||
if _, exists := cargo.Slots[slotKey(listTypeAccountCargo, 28)]; exists {
|
||
t.Fatalf("未解锁格不能被写入: %+v", cargo.Slots)
|
||
}
|
||
if stayed := cargo.Slots[slotKey(listTypeAccountCargo, 2)]; stayed.Count != 10 {
|
||
t.Fatalf("物品应停在原格 12:2: %+v", cargo.Slots)
|
||
}
|
||
}
|
||
|
||
// TestHandlerAccountCargoSameContainerMoveToLockedSlotAnswersWithFailureFrame 同一个现场走
|
||
// handler:客户端必须收到失败帧,物品才会弹回原位置(静默会让拖拽 pending 卡住)。
|
||
func TestHandlerAccountCargoSameContainerMoveToLockedSlotAnswersWithFailureFrame(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
saveLockedSlotAccountCargoFixture(t, ctx, repos, "dnf:locked-slot", map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeAccountCargo, 2): pvfMoveStack(3330, 10, 1000),
|
||
})
|
||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77"})
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
Body: currentMoveItemspaceBody(listTypeAccountCargo, 2, 0, 10, listTypeAccountCargo, 28, 0, 0, -1),
|
||
AccountID: "dnf:locked-slot",
|
||
SelectedCharacterID: 77,
|
||
Repositories: repos,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Handle error = %v", err)
|
||
}
|
||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||
t.Fatalf("必须回失败帧让物品弹回: %+v", got)
|
||
}
|
||
wantFailure := []byte{0, moveItemspaceDestinationFullErrorCode, listTypeAccountCargo, listTypeAccountCargo}
|
||
if body := got.UpperResponses[0].Body; !reflect.DeepEqual(body, wantFailure) {
|
||
t.Fatalf("failure ack=% X, want % X", body, wantFailure)
|
||
}
|
||
cargo, _, _ := repos.AccountInventory.Load(ctx, "dnf:locked-slot")
|
||
if len(cargo.Slots) != 1 || cargo.Slots[slotKey(listTypeAccountCargo, 2)].Count != 10 {
|
||
t.Fatalf("数据不应变化: %+v", cargo.Slots)
|
||
}
|
||
}
|
||
|
||
// TestHandlerAccountCargoInternalReverseMoveToLockedSlotSnapsBack 复现现场 2026-09-18
|
||
// (金库内部):客户端把**已有物品**拖到未解锁栏位时,把落点写在 source 端——服务端看到的是
|
||
// src=(12,27) 空格 + dst=(12,3) 物品,走 reverse_move 分支。旧代码直接把物品放进 12:27。
|
||
// 按运营口径:金库内拖动落未解锁栏位 → 直接回原位(失败帧),不顺延。
|
||
func TestHandlerAccountCargoInternalReverseMoveToLockedSlotSnapsBack(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
saveLockedSlotAccountCargoFixture(t, ctx, repos, "dnf:locked-slot", map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeAccountCargo, 3): pvfMoveStack(3330, 4000, 0),
|
||
})
|
||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77"})
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
// 与线上抓包同形:src 是拖到的未解锁格(服务端那里是空的),dst 是物品所在格。
|
||
Body: currentMoveItemspaceBody(listTypeAccountCargo, 27, 0, 0, listTypeAccountCargo, 3, 0x1D34E451, 0, -1),
|
||
AccountID: "dnf:locked-slot",
|
||
SelectedCharacterID: 77,
|
||
Repositories: repos,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Handle error = %v", err)
|
||
}
|
||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||
t.Fatalf("必须回失败帧让物品弹回: %+v", got)
|
||
}
|
||
wantFailure := []byte{0, moveItemspaceDestinationFullErrorCode, listTypeAccountCargo, listTypeAccountCargo}
|
||
if body := got.UpperResponses[0].Body; !reflect.DeepEqual(body, wantFailure) {
|
||
t.Fatalf("failure ack=% X, want % X", body, wantFailure)
|
||
}
|
||
cargo, _, _ := repos.AccountInventory.Load(ctx, "dnf:locked-slot")
|
||
if _, exists := cargo.Slots[slotKey(listTypeAccountCargo, 27)]; exists {
|
||
t.Fatalf("未解锁格不能被写入: %+v", cargo.Slots)
|
||
}
|
||
if stayed := cargo.Slots[slotKey(listTypeAccountCargo, 3)]; stayed.ItemID != 3330 || stayed.Count != 4000 {
|
||
t.Fatalf("物品应停在原格 12:3: %+v", cargo.Slots)
|
||
}
|
||
if len(cargo.Slots) != 1 {
|
||
t.Fatalf("不应产生新行: %+v", cargo.Slots)
|
||
}
|
||
}
|
||
|
||
// TestHandlerAccountCargoInternalReverseMoveToLockedSlotStacksOntoExistingRow 反向 + 已解锁区
|
||
// 已有同 ID 行:同样按"金库内拖动"口径直接回原位,不叠加、不新开行。
|
||
func TestHandlerAccountCargoInternalReverseMoveToLockedSlotStacksOntoExistingRow(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
saveLockedSlotAccountCargoFixture(t, ctx, repos, "dnf:locked-slot", map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeAccountCargo, 5): pvfMoveStack(3330, 3, 1000),
|
||
slotKey(listTypeAccountCargo, 3): pvfMoveStack(3330, 5, 1000),
|
||
})
|
||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77"})
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
Body: currentMoveItemspaceBody(listTypeAccountCargo, 27, 0, 0, listTypeAccountCargo, 3, 0x1D34E451, 0, -1),
|
||
AccountID: "dnf:locked-slot",
|
||
SelectedCharacterID: 77,
|
||
Repositories: repos,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Handle error = %v", err)
|
||
}
|
||
wantFailure := []byte{0, moveItemspaceDestinationFullErrorCode, listTypeAccountCargo, listTypeAccountCargo}
|
||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
||
!reflect.DeepEqual(got.UpperResponses[0].Body, wantFailure) {
|
||
t.Fatalf("必须回失败帧: %+v", got)
|
||
}
|
||
cargo, _, _ := repos.AccountInventory.Load(ctx, "dnf:locked-slot")
|
||
if _, exists := cargo.Slots[slotKey(listTypeAccountCargo, 27)]; exists {
|
||
t.Fatalf("未解锁格不能被写入: %+v", cargo.Slots)
|
||
}
|
||
if stacked := cargo.Slots[slotKey(listTypeAccountCargo, 5)]; stacked.Count != 3 {
|
||
t.Fatalf("12:5 不应变化: %+v", cargo.Slots)
|
||
}
|
||
if stayed := cargo.Slots[slotKey(listTypeAccountCargo, 3)]; stayed.Count != 5 {
|
||
t.Fatalf("物品应停在原格 12:3: %+v", cargo.Slots)
|
||
}
|
||
}
|
||
|
||
// saveLockedSlotAccountCargoFixture 建一个"账号金库已创建、解锁 8 格"的夹具。
|
||
func saveLockedSlotAccountCargoFixture(
|
||
t *testing.T,
|
||
ctx context.Context,
|
||
repos dnfrepo.Group,
|
||
accountID string,
|
||
cargoSlots map[string]dnfrepo.ItemStack,
|
||
) {
|
||
t.Helper()
|
||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||
AccountID: accountID,
|
||
Metadata: map[string]string{"account_cargo_created": "true", "account_cargo_level": "8"},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if cargoSlots != nil {
|
||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||
AccountID: accountID,
|
||
Slots: cargoSlots,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// accountCargoTestResolver 是"落点 / 容量 / 叠加 / 满仓"这些用例共用的运行时 PVF 桩。
|
||
// 它们考的是搬到哪一格、叠不叠、满不满,**不是**物品资格,所以这里统一声明成可入金库的
|
||
// [attach type](free)——与 0.2026-09-21 起的入库白名单(free / account / sealing)一致。
|
||
// limits 按 item id 给出 [stack limit],与夹具 pvfMoveStack 写进 Extra 的取值同义;不在表里
|
||
// 的 item 回"不是可叠加类别"(装备一类的形状)。资格判定本身由
|
||
// account_cargo_eligibility_test.go 的用例单独覆盖,不要拿这个桩去断言放行/拒绝。
|
||
func accountCargoTestResolver(limits map[int64]int64) alignedcmd.StackMergeResolver {
|
||
return func(itemID int64) (alignedcmd.StackMergeResolution, error) {
|
||
resolution := alignedcmd.StackMergeResolution{ItemID: itemID, AttachType: "`[free]`"}
|
||
if limit := limits[itemID]; limit > 0 {
|
||
resolution.Kind = "stackable"
|
||
resolution.PVFPath = "stackable/material/test_3227.stk"
|
||
resolution.StackableType = "[material]"
|
||
resolution.StackLimit = limit
|
||
}
|
||
return resolution, nil
|
||
}
|
||
}
|
||
|
||
// accountCargoTestFreeResolver 给只需要"这件道具能进金库"、不关心堆叠契约的用例用。
|
||
func accountCargoTestFreeResolver() alignedcmd.StackMergeResolver {
|
||
return accountCargoTestResolver(nil)
|
||
}
|
||
|
||
// TestHandlerAccountCargoLockedSlotMoveLandsInFirstUnlockedSlot 运营口径 2026-09-18:
|
||
// 把道具拖进账号金库**未解锁**的 12:28 时不再拒绝,而是顺延到已解锁区第一个空格
|
||
// (第 0 格被别的道具占着 → 落到第 1 格),ACK 必须回显真实落点。
|
||
func TestHandlerAccountCargoLockedSlotMoveLandsInFirstUnlockedSlot(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
saveLockedSlotAccountCargoFixture(t, ctx, repos, "dnf:locked-slot", map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeAccountCargo, 0): pvfMoveStack(3331, 1, 0),
|
||
})
|
||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeMain, 69): pvfMoveStack(3330, 1, 0),
|
||
},
|
||
})
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
Body: currentMoveItemspaceBody(listTypeMain, 69, 0, 1, listTypeAccountCargo, 28, 0, 0, -1),
|
||
AccountID: "dnf:locked-slot",
|
||
SelectedCharacterID: 77,
|
||
Repositories: repos,
|
||
StackMergeResolver: accountCargoTestFreeResolver(),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Handle error = %v", err)
|
||
}
|
||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||
t.Fatalf("result = %+v", got)
|
||
}
|
||
srcList, srcSlot, dstList, dstSlot := parseMoveSuccessAckEndpoints(t, got.UpperResponses[0].Body)
|
||
if srcList != listTypeMain || srcSlot != 69 || dstList != listTypeAccountCargo || dstSlot != 1 {
|
||
t.Fatalf("ACK 端点 = (%d,%d)->(%d,%d), 期望 (0,69)->(12,1)", srcList, srcSlot, dstList, dstSlot)
|
||
}
|
||
cargo, found, err := repos.AccountInventory.Load(ctx, "dnf:locked-slot")
|
||
if err != nil || !found {
|
||
t.Fatalf("load cargo found=%t err=%v", found, err)
|
||
}
|
||
if _, exists := cargo.Slots[slotKey(listTypeAccountCargo, 28)]; exists {
|
||
t.Fatalf("未解锁格不能被写入: %+v", cargo.Slots)
|
||
}
|
||
if landed := cargo.Slots[slotKey(listTypeAccountCargo, 1)]; landed.ItemID != 3330 || landed.Count != 1 {
|
||
t.Fatalf("落点应为 12:1 的 3330x1: %+v", landed)
|
||
}
|
||
loaded := loadTestInventory(t, ctx, repos, "77")
|
||
if _, exists := loaded.Slots[slotKey(listTypeMain, 69)]; exists {
|
||
t.Fatalf("源格应被搬空: %+v", loaded.Slots)
|
||
}
|
||
}
|
||
|
||
// TestHandlerAccountCargoLockedSlotMoveStacksOntoExistingRow 金库里已有同 ID 行 → 叠加进那一行,
|
||
// 不新开第二行、也不落到未解锁格。
|
||
func TestHandlerAccountCargoLockedSlotMoveStacksOntoExistingRow(t *testing.T) {
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
saveLockedSlotAccountCargoFixture(t, ctx, repos, "dnf:locked-slot", map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeAccountCargo, 3): pvfMoveStack(3330, 3, 1000),
|
||
})
|
||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeMain, 69): pvfMoveStack(3330, 5, 1000),
|
||
},
|
||
})
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
Body: currentMoveItemspaceBody(listTypeMain, 69, 0, 5, listTypeAccountCargo, 28, 0, 0, -1),
|
||
AccountID: "dnf:locked-slot",
|
||
SelectedCharacterID: 77,
|
||
Repositories: repos,
|
||
StackMergeResolver: accountCargoTestResolver(map[int64]int64{3330: 1000}),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Handle error = %v", err)
|
||
}
|
||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||
t.Fatalf("result = %+v", got)
|
||
}
|
||
_, _, _, dstSlot := parseMoveSuccessAckEndpoints(t, got.UpperResponses[0].Body)
|
||
if dstSlot != 3 {
|
||
t.Fatalf("ACK 落点 = %d, 期望叠加进 12:3", dstSlot)
|
||
}
|
||
cargo, _, _ := repos.AccountInventory.Load(ctx, "dnf:locked-slot")
|
||
if stacked := cargo.Slots[slotKey(listTypeAccountCargo, 3)]; stacked.Count != 8 {
|
||
t.Fatalf("12:3 应叠加到 8: %+v", stacked)
|
||
}
|
||
if len(cargo.Slots) != 1 {
|
||
t.Fatalf("同 ID 只能有一行: %+v", cargo.Slots)
|
||
}
|
||
}
|
||
|
||
// TestHandlerAccountCargoLockedSlotMoveRejectsWhenCarryLimitReached 金库里同 ID 已经堆到
|
||
// PVF [stack limit] 上限 → 必须回拒绝提示(不是把新的一堆塞进未解锁格)。
|
||
func TestHandlerAccountCargoLockedSlotMoveRejectsWhenCarryLimitReached(t *testing.T) {
|
||
const limitOneTicket int64 = 10008067
|
||
ctx := context.Background()
|
||
repos := dnfrepomemory.NewMemoryGroup()
|
||
saveLockedSlotAccountCargoFixture(t, ctx, repos, "dnf:locked-slot", map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeAccountCargo, 3): pvfMoveStack(limitOneTicket, 1, 1),
|
||
})
|
||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
||
CharacterID: "77",
|
||
Slots: map[string]dnfrepo.ItemStack{
|
||
slotKey(listTypeMain, 69): pvfMoveStack(limitOneTicket, 1, 1),
|
||
},
|
||
})
|
||
|
||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||
Body: currentMoveItemspaceBody(listTypeMain, 69, 0, 1, listTypeAccountCargo, 28, 0, 0, -1),
|
||
AccountID: "dnf:locked-slot",
|
||
SelectedCharacterID: 77,
|
||
Repositories: repos,
|
||
StackMergeResolver: accountCargoTestResolver(map[int64]int64{limitOneTicket: 1}),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Handle error = %v", err)
|
||
}
|
||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||
t.Fatalf("超上限必须回失败帧: %+v", got)
|
||
}
|
||
wantFailure := []byte{0, moveItemspaceDestinationFullErrorCode, listTypeMain, listTypeAccountCargo}
|
||
if body := got.UpperResponses[0].Body; !reflect.DeepEqual(body, wantFailure) {
|
||
t.Fatalf("failure ack=% X, want % X", body, wantFailure)
|
||
}
|
||
cargo, _, _ := repos.AccountInventory.Load(ctx, "dnf:locked-slot")
|
||
if len(cargo.Slots) != 1 || cargo.Slots[slotKey(listTypeAccountCargo, 3)].Count != 1 {
|
||
t.Fatalf("拒绝后金库不应变化: %+v", cargo.Slots)
|
||
}
|
||
if _, exists := cargo.Slots[slotKey(listTypeAccountCargo, 28)]; exists {
|
||
t.Fatalf("未解锁格不能被写入: %+v", cargo.Slots)
|
||
}
|
||
loaded := loadTestInventory(t, ctx, repos, "77")
|
||
if source := loaded.Slots[slotKey(listTypeMain, 69)]; source.Count != 1 {
|
||
t.Fatalf("拒绝后源行不应变化: %+v", source)
|
||
}
|
||
}
|