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

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

1014 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}