DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,644 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"maps"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 2026-09-21 现场(玩家 1111,蓝色小晶块 3036×69):
|
||||
//
|
||||
// 晶体仓库 0:357 → 个人仓库 2:117(正常)
|
||||
// 个人仓库 2:117 → 背包,客户端声明落点 0:156 → 服务端按固定格送回 0:357,ACK 也如实
|
||||
// 回执了……但客户端发包前已经把那件物品**乐观地画在 0:156** 上,而服务端从没提过 156;
|
||||
// 0:156 于是留下一格点不动的幽灵行,**再往外拖时客户端连 op19 都不发**(日志:13:20:08
|
||||
// 之后该连接零报文,DB 里 0:156 是空的、0:357 才是那 69 个)。
|
||||
//
|
||||
// 这条用例锁住修复:ACK 之后必须补一条"客户端声明格"的精确刷新行;那一格在持久化记录里是
|
||||
// 空的,bridge 会把它写成背包空格的删除行 (0xFFFFFFFF),客户端据此清掉幽灵行。
|
||||
func TestHandlerMoveAccountSharedRedirectClearsClientDeclaredBagSlot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const accountID = "dnf:redirect-clears-declared"
|
||||
clearCube, clearSlot := int64(3037), dnfrepo.CrystalWarehouseFirstSlot+4 // 0:358
|
||||
const declaredSlot = int16(156)
|
||||
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
||||
CharacterID: "77",
|
||||
Slots: map[string]dnfrepo.ItemStack{},
|
||||
Warehouse: map[string]dnfrepo.ItemStack{
|
||||
slotKey(listTypePersonalCargo, 117): pvfMoveStack(clearCube, 69, 99999),
|
||||
},
|
||||
})
|
||||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||||
AccountID: accountID,
|
||||
Slots: map[string]dnfrepo.ItemStack{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saveMoveContainerState(t, ctx, repos, "77", 24, 8)
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||||
Body: currentMoveItemspaceBody(
|
||||
listTypePersonalCargo, 117, 0, 69,
|
||||
listTypeMain, declaredSlot, 0, 0, -1,
|
||||
),
|
||||
AccountID: accountID,
|
||||
SelectedCharacterID: 77,
|
||||
Repositories: repos,
|
||||
StackMergeResolver: func(itemID int64) (alignedcmd.StackMergeResolution, error) {
|
||||
return alignedcmd.StackMergeResolution{
|
||||
ItemID: itemID, Kind: "stackable",
|
||||
PVFPath: "stackable/material/cube.stk", StackLimit: 99_999,
|
||||
SlotStart: 120, SlotEnd: 151,
|
||||
}, nil
|
||||
},
|
||||
AccountWarehouseSlotResolver: func(itemID int64) (int16, bool) {
|
||||
if itemID == clearCube {
|
||||
return clearSlot, true
|
||||
}
|
||||
return 0, false
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
body := got.UpperResponses[0].Body
|
||||
if len(body) != 11 || body[0] != 1 {
|
||||
t.Fatalf("move ACK body = % X", body)
|
||||
}
|
||||
if body[1] != listTypePersonalCargo || body[8] != listTypeMain {
|
||||
t.Fatalf("move ACK endpoints = list %d -> list %d, want %d -> %d",
|
||||
body[1], body[8], listTypePersonalCargo, listTypeMain)
|
||||
}
|
||||
if srcSlot := int16(binary.LittleEndian.Uint16(body[2:4])); srcSlot != 117 {
|
||||
t.Fatalf("move ACK source slot = %d, want 117", srcSlot)
|
||||
}
|
||||
if dstSlot := int16(binary.LittleEndian.Uint16(body[9:11])); dstSlot != clearSlot {
|
||||
t.Fatalf("move ACK destination slot = %d, want the fixed crystal cell %d", dstSlot, clearSlot)
|
||||
}
|
||||
|
||||
wantRefresh := alignedcmd.ItemSlotRefresh{ListType: listTypeMain, SlotIndex: declaredSlot}
|
||||
cleared := false
|
||||
for _, refresh := range got.ItemSlotRefreshes {
|
||||
if refresh == wantRefresh {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
if !cleared {
|
||||
t.Fatalf("ItemSlotRefreshes = %+v, want the client-declared bag slot %d cleared",
|
||||
got.ItemSlotRefreshes, declaredSlot)
|
||||
}
|
||||
// 光补删除行还不够:那一格在客户端的物品表里可能已经被 (itemID=0, count=0) 的"空格"行
|
||||
// 变成了金币对象(itemID=0 在这个客户端就是金币),增量行治不了它 —— 现场症状是
|
||||
// 面板看着正常、拖出来却是金币、客户端连 op19 都不发,小退(整份 list-0 快照重建)
|
||||
// 才恢复。所以落点被改写这一步必须补一次 list-0 整表重建,让权威快照最后落地。
|
||||
if !reflect.DeepEqual(got.ItemListRebuilds, []byte{listTypeMain}) {
|
||||
t.Fatalf("ItemListRebuilds = %v, want a list-0 rebuild after a redirected account-shared landing",
|
||||
got.ItemListRebuilds)
|
||||
}
|
||||
|
||||
account, _, err := repos.AccountInventory.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := account.Slots[slotKey(listTypeMain, clearSlot)].Count; got != 69 {
|
||||
t.Fatalf("account crystal cell count = %d, want 69 (slots=%+v)", got, account.Slots)
|
||||
}
|
||||
record := loadTestInventory(t, ctx, repos, "77")
|
||||
if stack, occupied := record.Slots[slotKey(listTypeMain, declaredSlot)]; occupied && stack.ItemID != 0 && stack.Count > 0 {
|
||||
t.Fatalf("declared bag slot must stay empty durably: %+v", stack)
|
||||
}
|
||||
if _, occupied := record.Warehouse[slotKey(listTypePersonalCargo, 117)]; occupied {
|
||||
t.Fatalf("warehouse source row must be gone: %+v", record.Warehouse)
|
||||
}
|
||||
}
|
||||
|
||||
// 反向守卫:客户端声明的落点上压着**别的**物品时(互换形态),落点照样改写到固定格,
|
||||
// 但绝不能补"清格"刷新 —— 那一格在服务端有真实物品,删除行会把它从客户端抹掉。
|
||||
func TestHandlerMoveAccountSharedRedirectKeepsOccupiedDeclaredSlot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const accountID = "dnf:redirect-keeps-occupied"
|
||||
clearCube, clearSlot := int64(3037), dnfrepo.CrystalWarehouseFirstSlot+4
|
||||
const declaredSlot = int16(156)
|
||||
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
||||
CharacterID: "77",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
slotKey(listTypeMain, declaredSlot): pvfMoveStack(3331, 4, 99999),
|
||||
},
|
||||
Warehouse: map[string]dnfrepo.ItemStack{
|
||||
slotKey(listTypePersonalCargo, 117): pvfMoveStack(clearCube, 69, 99999),
|
||||
},
|
||||
})
|
||||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||||
AccountID: accountID,
|
||||
Slots: map[string]dnfrepo.ItemStack{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saveMoveContainerState(t, ctx, repos, "77", 24, 8)
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
|
||||
Body: currentMoveItemspaceBody(
|
||||
listTypePersonalCargo, 117, 0, 69,
|
||||
listTypeMain, declaredSlot, 0, 0, -1,
|
||||
),
|
||||
AccountID: accountID,
|
||||
SelectedCharacterID: 77,
|
||||
Repositories: repos,
|
||||
StackMergeResolver: func(itemID int64) (alignedcmd.StackMergeResolution, error) {
|
||||
return alignedcmd.StackMergeResolution{
|
||||
ItemID: itemID, Kind: "stackable",
|
||||
PVFPath: "stackable/material/cube.stk", StackLimit: 99_999,
|
||||
SlotStart: 120, SlotEnd: 151,
|
||||
}, nil
|
||||
},
|
||||
AccountWarehouseSlotResolver: func(itemID int64) (int16, bool) {
|
||||
if itemID == clearCube {
|
||||
return clearSlot, true
|
||||
}
|
||||
return 0, false
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
for _, refresh := range got.ItemSlotRefreshes {
|
||||
if refresh.ListType == listTypeMain && refresh.SlotIndex == declaredSlot {
|
||||
t.Fatalf("occupied declared slot %d must not be cleared: %+v", declaredSlot, got.ItemSlotRefreshes)
|
||||
}
|
||||
}
|
||||
record := loadTestInventory(t, ctx, repos, "77")
|
||||
if stack := record.Slots[slotKey(listTypeMain, declaredSlot)]; stack.ItemID != 3331 || stack.Count != 4 {
|
||||
t.Fatalf("the occupying item must be untouched: %+v", stack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveAccountSharedRejectsSecondPVFCharmInQuickSlot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
const accountID = "dnf:account-shared-charm"
|
||||
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)
|
||||
}
|
||||
sharedSlot := dnfrepo.CrystalWarehouseFirstSlot
|
||||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||||
AccountID: accountID,
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
slotKey(listTypeMain, sharedSlot): {ItemID: 400360005, Count: 1},
|
||||
},
|
||||
}); 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: "equipment", EquipmentType: "[charm]"}, nil
|
||||
})
|
||||
|
||||
_, err = owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
||||
AccountID: accountID, SelectedCharacterID: 77,
|
||||
}, MoveItemspaceRequest{
|
||||
SourceListType: listTypeMain, SourceSlotIndex: sharedSlot, MoveCount: 1,
|
||||
DestinationListType: listTypeMain, DestinationSlotIndex: 3,
|
||||
}))
|
||||
if !errors.Is(err, ErrQuickSlotCharmAlreadyEquipped) {
|
||||
t.Fatalf("Move error = %v, want %v", err, ErrQuickSlotCharmAlreadyEquipped)
|
||||
}
|
||||
character, _, _ := repos.Inventory.Load(ctx, "77")
|
||||
if _, found := character.Slots[slotKey(listTypeMain, 3)]; found {
|
||||
t.Fatalf("character quick slot partially saved: %+v", character.Slots)
|
||||
}
|
||||
account, _, _ := repos.AccountInventory.Load(ctx, accountID)
|
||||
if got := account.Slots[slotKey(listTypeMain, sharedSlot)].ItemID; got != 400360005 {
|
||||
t.Fatalf("account-shared source changed to %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAccountSharedSlotsMovesLegacyStackWithoutLosingState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
expires := time.Date(2027, time.March, 4, 5, 6, 7, 0, time.FixedZone("CST", 8*60*60))
|
||||
legacy := dnfrepo.ItemStack{
|
||||
ItemID: 3033,
|
||||
Count: 25,
|
||||
Bind: true,
|
||||
ExpireAt: expires,
|
||||
RawEntry: []byte{0x77, 0x35, 0x40},
|
||||
Extra: map[string]string{"kind": "crystal", "serial": "991"},
|
||||
}
|
||||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
||||
CharacterID: "77",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:9": {ItemID: 700, Count: 1},
|
||||
"0:354": legacy,
|
||||
},
|
||||
})
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner error = %v", err)
|
||||
}
|
||||
|
||||
if err := owner.MigrateAccountSharedSlots(ctx, "dnf:1", "77"); err != nil {
|
||||
t.Fatalf("MigrateAccountSharedSlots error = %v", err)
|
||||
}
|
||||
|
||||
character := loadTestInventory(t, ctx, repos, "77")
|
||||
if _, exists := character.Slots["0:354"]; exists {
|
||||
t.Fatalf("legacy account-shared slot remains on character: %+v", character.Slots)
|
||||
}
|
||||
if got := character.Slots["0:9"]; got.ItemID != 700 || got.Count != 1 {
|
||||
t.Fatalf("ordinary character slot changed: %+v", got)
|
||||
}
|
||||
account, exists, err := repos.AccountInventory.Load(ctx, "dnf:1")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("Load account inventory exists=%t err=%v", exists, err)
|
||||
}
|
||||
got := account.Slots["0:354"]
|
||||
if got.ItemID != legacy.ItemID || got.Count != legacy.Count || got.Bind != legacy.Bind ||
|
||||
!got.ExpireAt.Equal(legacy.ExpireAt) || !bytes.Equal(got.RawEntry, legacy.RawEntry) || !maps.Equal(got.Extra, legacy.Extra) {
|
||||
t.Fatalf("migrated stack = %+v, want complete legacy state %+v", got, legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAccountSharedSlotsRemovesIdenticalCharacterDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
stack := dnfrepo.ItemStack{
|
||||
ItemID: 4012,
|
||||
Count: 9,
|
||||
Bind: true,
|
||||
ExpireAt: time.Date(2028, time.January, 2, 3, 4, 5, 0, time.UTC),
|
||||
RawEntry: []byte{4, 0, 1, 2},
|
||||
Extra: map[string]string{"kind": "soul"},
|
||||
}
|
||||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
||||
CharacterID: "77",
|
||||
Slots: map[string]dnfrepo.ItemStack{"0:360": stack},
|
||||
})
|
||||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||||
AccountID: "dnf:1",
|
||||
Slots: map[string]dnfrepo.ItemStack{"0:360": stack},
|
||||
}); err != nil {
|
||||
t.Fatalf("Save account inventory error = %v", err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner error = %v", err)
|
||||
}
|
||||
|
||||
if err := owner.MigrateAccountSharedSlots(ctx, "dnf:1", "77"); err != nil {
|
||||
t.Fatalf("MigrateAccountSharedSlots error = %v", err)
|
||||
}
|
||||
if _, exists := loadTestInventory(t, ctx, repos, "77").Slots["0:360"]; exists {
|
||||
t.Fatal("identical character duplicate was not removed")
|
||||
}
|
||||
account, exists, err := repos.AccountInventory.Load(ctx, "dnf:1")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("Load account inventory exists=%t err=%v", exists, err)
|
||||
}
|
||||
if got := account.Slots["0:360"]; !accountSharedStacksEqual(got, stack) {
|
||||
t.Fatalf("account owner changed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAccountSharedSlotsConflictRollsBackEverySlot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
first := dnfrepo.ItemStack{ItemID: 3033, Count: 5, RawEntry: []byte{3, 5, 4}, Extra: map[string]string{"origin": "legacy"}}
|
||||
conflictingLegacy := dnfrepo.ItemStack{ItemID: 3034, Count: 6, RawEntry: []byte{3, 5, 5}}
|
||||
conflictingAccount := dnfrepo.ItemStack{ItemID: 9999, Count: 1, RawEntry: []byte{9}}
|
||||
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
|
||||
CharacterID: "77",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:354": first,
|
||||
"0:355": conflictingLegacy,
|
||||
},
|
||||
})
|
||||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||||
AccountID: "dnf:1",
|
||||
Slots: map[string]dnfrepo.ItemStack{"0:355": conflictingAccount},
|
||||
}); err != nil {
|
||||
t.Fatalf("Save account inventory error = %v", err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner error = %v", err)
|
||||
}
|
||||
|
||||
err = owner.MigrateAccountSharedSlots(ctx, "dnf:1", "77")
|
||||
if !errors.Is(err, ErrAccountSharedMigrationConflict) {
|
||||
t.Fatalf("MigrateAccountSharedSlots error = %v, want ErrAccountSharedMigrationConflict", err)
|
||||
}
|
||||
|
||||
character := loadTestInventory(t, ctx, repos, "77")
|
||||
if !accountSharedStacksEqual(character.Slots["0:354"], first) ||
|
||||
!accountSharedStacksEqual(character.Slots["0:355"], conflictingLegacy) {
|
||||
t.Fatalf("character slots changed after conflict: %+v", character.Slots)
|
||||
}
|
||||
account, exists, loadErr := repos.AccountInventory.Load(ctx, "dnf:1")
|
||||
if loadErr != nil || !exists {
|
||||
t.Fatalf("Load account inventory exists=%t err=%v", exists, loadErr)
|
||||
}
|
||||
if _, exists := account.Slots["0:354"]; exists {
|
||||
t.Fatalf("earlier slot partially migrated despite later conflict: %+v", account.Slots)
|
||||
}
|
||||
if !accountSharedStacksEqual(account.Slots["0:355"], conflictingAccount) {
|
||||
t.Fatalf("conflicting account slot changed: %+v", account.Slots["0:355"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAccountSharedSlotsValidatesOwnersAndKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
owner, err := NewOwner(dnfrepomemory.NewMemoryGroup())
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner error = %v", err)
|
||||
}
|
||||
if err := owner.MigrateAccountSharedSlots(ctx, "", "77"); !errors.Is(err, ErrAccountRequired) {
|
||||
t.Fatalf("empty account error = %v", err)
|
||||
}
|
||||
if err := owner.MigrateAccountSharedSlots(ctx, "dnf:1", ""); !errors.Is(err, ErrCharacterRequired) {
|
||||
t.Fatalf("empty character error = %v", err)
|
||||
}
|
||||
if err := owner.MigrateAccountSharedSlots(ctx, "dnf:1", "77"); !errors.Is(err, ErrInventoryNotFound) {
|
||||
t.Fatalf("missing character error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 运营口径 2026-09-17:晶体/灵魂类道具(六个晶块 + 六个灵魂,各有唯一固定仓库格)只能
|
||||
// 住在账号的晶体/灵魂仓库里。客户端把"从个人仓库/账号金库取回背包"的落点声明成材料背包
|
||||
// 格(现场 0:156 / 0:121 / 0:150),服务端必须把它改回固定格;固定格被别的道具占着、或者
|
||||
// 并进去会超上限时保持原落点,绝不做跨容器互换。
|
||||
func TestMoveAccountSharedRedirectsWarehouseItemAwayFromBackpack(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const accountID = "dnf:warehouse-redirect"
|
||||
clearCube, clearSlot := int64(3037), dnfrepo.CrystalWarehouseFirstSlot+4 // 0:358
|
||||
otherCube, otherSlot := int64(3033), dnfrepo.CrystalWarehouseFirstSlot // 0:354
|
||||
cases := []struct {
|
||||
name string
|
||||
sourceList byte
|
||||
sourceSlot int16
|
||||
sourceCount int64
|
||||
destinationSlot int16
|
||||
// declaredSlotOccupant 非 0 时,在"客户端声明的那一格"上预置一件别的物品(互换形态)。
|
||||
declaredSlotOccupant int64
|
||||
accountSlots map[string]dnfrepo.ItemStack
|
||||
wantDestination int16
|
||||
wantAccount map[int16]int64
|
||||
wantRedirected bool
|
||||
// wantClearedDeclaredSlot 是"客户端声明的那一格":落点被改写到固定格之后,它必须被
|
||||
// 显式清掉,否则客户端会留下点不动的幽灵行(2026-09-21 现场 0:156)。只有该格在
|
||||
// 持久化记录里确实是空的才回报 —— 声明格被别的物品占着时必须是 0,不能删掉真实物品。
|
||||
wantClearedDeclaredSlot int16
|
||||
}{
|
||||
{
|
||||
name: "storage withdrawal lands in the fixed crystal slot",
|
||||
sourceList: listTypePersonalCargo, sourceSlot: 43, sourceCount: 40, destinationSlot: 121,
|
||||
wantDestination: clearSlot, wantRedirected: true,
|
||||
wantAccount: map[int16]int64{clearSlot: 40},
|
||||
wantClearedDeclaredSlot: 121,
|
||||
},
|
||||
{
|
||||
name: "bag to bag move also lands in the fixed crystal slot",
|
||||
sourceList: listTypeMain, sourceSlot: 150, sourceCount: 7, destinationSlot: 151,
|
||||
wantDestination: clearSlot, wantRedirected: true,
|
||||
wantAccount: map[int16]int64{clearSlot: 7},
|
||||
wantClearedDeclaredSlot: 151,
|
||||
},
|
||||
{
|
||||
name: "existing warehouse stack is merged into",
|
||||
sourceList: listTypePersonalCargo, sourceSlot: 43, sourceCount: 40, destinationSlot: 121,
|
||||
accountSlots: map[string]dnfrepo.ItemStack{slotKey(listTypeMain, clearSlot): pvfMoveStack(clearCube, 100, 99999)},
|
||||
wantDestination: clearSlot, wantRedirected: true,
|
||||
wantAccount: map[int16]int64{clearSlot: 140},
|
||||
wantClearedDeclaredSlot: 121,
|
||||
},
|
||||
{
|
||||
name: "the client's own warehouse destination is left alone",
|
||||
sourceList: listTypePersonalCargo, sourceSlot: 43, sourceCount: 40, destinationSlot: clearSlot,
|
||||
wantDestination: clearSlot, wantRedirected: false,
|
||||
wantAccount: map[int16]int64{clearSlot: 40},
|
||||
},
|
||||
{
|
||||
name: "a foreign stack squatting on the fixed slot keeps the declared landing",
|
||||
sourceList: listTypePersonalCargo, sourceSlot: 43, sourceCount: 40, destinationSlot: 121,
|
||||
accountSlots: map[string]dnfrepo.ItemStack{slotKey(listTypeMain, clearSlot): pvfMoveStack(otherCube, 5, 99999)},
|
||||
wantDestination: 121, wantRedirected: false,
|
||||
wantAccount: map[int16]int64{clearSlot: 5},
|
||||
},
|
||||
{
|
||||
// 声明格上压着别的物品(客户端想做互换):落点照样改写到固定格,但**绝不能**
|
||||
// 回报清格 —— 那一格在服务端有真实物品,删除行会把它从客户端抹掉。
|
||||
name: "declared landing occupied by another item is not cleared",
|
||||
sourceList: listTypePersonalCargo, sourceSlot: 43, sourceCount: 40, destinationSlot: 121,
|
||||
declaredSlotOccupant: 3331,
|
||||
accountSlots: map[string]dnfrepo.ItemStack{},
|
||||
wantDestination: clearSlot, wantRedirected: true,
|
||||
wantAccount: map[int16]int64{clearSlot: 40},
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
record := dnfrepo.InventoryRecord{
|
||||
CharacterID: "77",
|
||||
Slots: map[string]dnfrepo.ItemStack{},
|
||||
Warehouse: map[string]dnfrepo.ItemStack{
|
||||
slotKey(listTypePersonalCargo, 43): pvfMoveStack(clearCube, testCase.sourceCount, 99999),
|
||||
},
|
||||
}
|
||||
if testCase.declaredSlotOccupant != 0 {
|
||||
record.Slots[slotKey(listTypeMain, testCase.destinationSlot)] =
|
||||
pvfMoveStack(testCase.declaredSlotOccupant, 1, 99999)
|
||||
}
|
||||
if testCase.sourceList == listTypeMain {
|
||||
record.Warehouse = map[string]dnfrepo.ItemStack{}
|
||||
record.Slots = map[string]dnfrepo.ItemStack{
|
||||
slotKey(listTypeMain, testCase.sourceSlot): pvfMoveStack(clearCube, testCase.sourceCount, 99999),
|
||||
}
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accountSlots := testCase.accountSlots
|
||||
if accountSlots == nil {
|
||||
accountSlots = map[string]dnfrepo.ItemStack{}
|
||||
}
|
||||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||||
AccountID: accountID, Slots: accountSlots,
|
||||
}); 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/cube.stk", StackLimit: 99_999,
|
||||
SlotStart: 120, SlotEnd: 151,
|
||||
}, nil
|
||||
})
|
||||
owner.SetAccountWarehouseSlotResolver(func(itemID int64) (int16, bool) {
|
||||
switch itemID {
|
||||
case clearCube:
|
||||
return clearSlot, true
|
||||
case otherCube:
|
||||
return otherSlot, true
|
||||
}
|
||||
return 0, false
|
||||
})
|
||||
|
||||
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
||||
AccountID: accountID, SelectedCharacterID: 77,
|
||||
}, MoveItemspaceRequest{
|
||||
SourceListType: testCase.sourceList, SourceSlotIndex: testCase.sourceSlot,
|
||||
MoveCount: int32(testCase.sourceCount),
|
||||
DestinationListType: listTypeMain, DestinationSlotIndex: testCase.destinationSlot,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("Move error = %v", err)
|
||||
}
|
||||
if result.DestinationSlotIndex != testCase.wantDestination {
|
||||
t.Fatalf("committed destination slot = %d, want %d (redirected=%t)",
|
||||
result.DestinationSlotIndex, testCase.wantDestination, result.RedirectedReservedMainSlot)
|
||||
}
|
||||
if result.RedirectedReservedMainSlot != testCase.wantRedirected {
|
||||
t.Fatalf("RedirectedReservedMainSlot = %t, want %t",
|
||||
result.RedirectedReservedMainSlot, testCase.wantRedirected)
|
||||
}
|
||||
if result.ClearedRedirectedMainSlot != testCase.wantClearedDeclaredSlot {
|
||||
t.Fatalf("ClearedRedirectedMainSlot = %d, want %d (redirected=%t declared=%d)",
|
||||
result.ClearedRedirectedMainSlot, testCase.wantClearedDeclaredSlot,
|
||||
result.RedirectedReservedMainSlot, testCase.destinationSlot)
|
||||
}
|
||||
account, _, err := repos.AccountInventory.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for slot, want := range testCase.wantAccount {
|
||||
if got := account.Slots[slotKey(listTypeMain, slot)].Count; got != want {
|
||||
t.Fatalf("account slot %d count = %d, want %d (slots=%+v)", slot, got, want, account.Slots)
|
||||
}
|
||||
}
|
||||
if !testCase.wantRedirected &&
|
||||
!dnfrepo.IsAccountSharedInventorySlot(listTypeMain, testCase.destinationSlot) {
|
||||
// 落点没被改:客户端声明的背包格必须真的收到那一行。
|
||||
record, _, _ := repos.Inventory.Load(ctx, "77")
|
||||
if got := record.Slots[slotKey(listTypeMain, testCase.destinationSlot)].Count; got != testCase.sourceCount {
|
||||
t.Fatalf("declared backpack slot %d count = %d, want %d (slots=%+v)",
|
||||
testCase.destinationSlot, got, testCase.sourceCount, record.Slots)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestMoveAccountSharedReportsEmptiedCrystalSlots(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const accountID = "dnf:account-shared-emptied"
|
||||
crystalSlot := dnfrepo.CrystalWarehouseFirstSlot + 4 // 358 = 无色小晶块
|
||||
cases := []struct {
|
||||
name string
|
||||
request MoveItemspaceRequest
|
||||
wantMode string
|
||||
wantEmptied []int16
|
||||
wantAccountN int64
|
||||
}{
|
||||
{
|
||||
name: "withdrawal to the backpack empties the crystal cell",
|
||||
request: MoveItemspaceRequest{
|
||||
SourceListType: listTypeMain, SourceSlotIndex: crystalSlot, MoveCount: 40,
|
||||
DestinationListType: listTypeMain, DestinationSlotIndex: 150,
|
||||
},
|
||||
wantMode: "move",
|
||||
wantEmptied: []int16{crystalSlot},
|
||||
wantAccountN: 0,
|
||||
},
|
||||
{
|
||||
name: "partial withdrawal keeps the cell occupied",
|
||||
request: MoveItemspaceRequest{
|
||||
SourceListType: listTypeMain, SourceSlotIndex: crystalSlot, MoveCount: 10,
|
||||
DestinationListType: listTypeMain, DestinationSlotIndex: 150,
|
||||
},
|
||||
wantMode: "split",
|
||||
wantEmptied: nil,
|
||||
wantAccountN: 30,
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "77",
|
||||
Slots: map[string]dnfrepo.ItemStack{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
|
||||
AccountID: accountID,
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
slotKey(listTypeMain, crystalSlot): {ItemID: 3037, Count: 40},
|
||||
},
|
||||
}); 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/cube_clear.stk", StackLimit: 100000,
|
||||
SlotStart: 120, SlotEnd: 151,
|
||||
}, nil
|
||||
})
|
||||
result, err := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{
|
||||
AccountID: accountID, SelectedCharacterID: 77,
|
||||
}, testCase.request))
|
||||
if err != nil {
|
||||
t.Fatalf("Move error = %v", err)
|
||||
}
|
||||
if result.Mode != testCase.wantMode {
|
||||
t.Fatalf("move mode = %q, want %q", result.Mode, testCase.wantMode)
|
||||
}
|
||||
if !reflect.DeepEqual(result.EmptiedAccountSharedSlots, testCase.wantEmptied) {
|
||||
t.Fatalf("emptied account-shared slots = %v, want %v",
|
||||
result.EmptiedAccountSharedSlots, testCase.wantEmptied)
|
||||
}
|
||||
account, exists, loadErr := repos.AccountInventory.Load(ctx, accountID)
|
||||
if loadErr != nil || !exists {
|
||||
t.Fatalf("load account inventory exists=%t err=%v", exists, loadErr)
|
||||
}
|
||||
if got := account.Slots[slotKey(listTypeMain, crystalSlot)].Count; got != testCase.wantAccountN {
|
||||
t.Fatalf("account crystal count = %d, want %d", got, testCase.wantAccountN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user