Files
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

2787 lines
100 KiB
Go
Raw Permalink 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.
// 本文件覆盖装备 owner 的修理、穿戴、卸下和宠物装备 raw 构造。
package equip
import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"reflect"
"strconv"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerRepairPatchesEquippedRawDurability(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0, 13, 14}
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 9999}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: raw, Extra: map[string]string{"max_durability": "20", "repair_gold": "0"}},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: 11}, repairTestResolver(20, 0, 20))
if err != nil {
t.Fatalf("Repair error = %v", err)
}
if !result.Changed || result.OldDurability != 12 || result.NewDurability != 20 || result.ItemID != 700 || result.UpdatedGold != 9999 {
t.Fatalf("result = %+v", result)
}
loaded := loadTestEquipment(t, ctx, repos, "77")
entry := loaded.Entries["11"]
if got := uint16(entry.RawEntry[10]) | uint16(entry.RawEntry[11])<<8; got != 20 {
t.Fatalf("raw durability = %d, want 20", got)
}
if raw[10] != 12 {
t.Fatalf("caller raw was mutated: %v", raw)
}
}
func TestBuildCurrentEquipmentRawEntryFromMoveEvidenceRecoversZeroDurabilityDungeonCardAccessory(t *testing.T) {
stack := dnfrepo.ItemStack{
ItemID: 20053,
Count: 1,
Extra: map[string]string{
"source": "dungeon_card_reward",
"item_kind": "equipment",
"pvf_path": "equipment/character/common/amulet/neck_darknesstouch.equ",
"equipment_type": "[amulet]",
"quality_seed": "998506240",
},
}
raw, ok := buildCurrentEquipmentRawEntryFromMoveEvidence(stack, wireListMain, 19, 20053)
if !ok {
t.Fatal("zero-durability dungeon-card accessory was not recovered")
}
if len(raw) != currentEquipmentRawEntryWireSize ||
binary.LittleEndian.Uint16(raw[0:2]) != 19 ||
binary.LittleEndian.Uint32(raw[2:6]) != 20053 ||
binary.LittleEndian.Uint32(raw[6:10]) != 998506240 ||
binary.LittleEndian.Uint16(raw[0x0B:0x0D]) != 0 ||
binary.LittleEndian.Uint32(raw[0x0E:0x12]) != 20053 {
t.Fatalf("recovered raw = %x", raw)
}
if _, ok := buildCurrentEquipmentRawEntryFromMoveEvidence(stack, wireListMain, 19, 1); ok {
t.Fatal("mismatched client item-id echo recovered a dungeon-card accessory")
}
}
func repairTestResolver(maxDurability int64, repairPrice int64, grade int64) alignedcmd.RepairCostResolver {
return func(itemID int64) (alignedcmd.RepairCostEvidence, error) {
return alignedcmd.RepairCostEvidence{
EquipmentType: "[weapon]",
MaxDurability: maxDurability,
RepairPrice: repairPrice,
Grade: grade,
RepairCostRate: 0.08,
QuickRepairRate: 1.5,
UpgradeRates: []float64{1, 1, 1},
}, nil
}
}
func TestOwnerRepairDeductsFormulaCostAndGoldAtomically(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0, 13, 14}
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 1000}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: raw},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
// 86JP formula: 6400*(20+5)/10=16000; 16000*0.08/20*8 = 512.
result, err := owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: 11}, repairTestResolver(20, 6400, 20))
if err != nil {
t.Fatalf("Repair error = %v", err)
}
if !result.Changed || result.Cost != 512 || result.UpdatedGold != 488 || result.FreeRepair {
t.Fatalf("result = %+v, want cost=512 gold=488", result)
}
character, _, _ := repos.Character.Load(ctx, "77")
if character.Stats["gold"] != 488 {
t.Fatalf("persisted gold = %d, want 488", character.Stats["gold"])
}
loaded := loadTestEquipment(t, ctx, repos, "77")
if got := uint16(loaded.Entries["11"].RawEntry[10]) | uint16(loaded.Entries["11"].RawEntry[11])<<8; got != 20 {
t.Fatalf("raw durability = %d, want 20", got)
}
}
func TestOwnerRepairQuickRepairPaysQuickRate(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0, 13, 14}
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 1000}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: raw},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
// 512 * 1.5 = 768.
result, err := owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: 11, QuickRepair: true}, repairTestResolver(20, 6400, 20))
if err != nil {
t.Fatalf("Repair error = %v", err)
}
if result.Cost != 768 || result.UpdatedGold != 232 {
t.Fatalf("result = %+v, want cost=768 gold=232", result)
}
}
func TestOwnerRepairInsufficientGoldRollsBack(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0, 13, 14}
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 100}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: raw},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
if _, err := owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: 11}, repairTestResolver(20, 6400, 20)); !errors.Is(err, ErrRepairGoldInsufficient) {
t.Fatalf("Repair error = %v, want ErrRepairGoldInsufficient", err)
}
loaded := loadTestEquipment(t, ctx, repos, "77")
if got := uint16(loaded.Entries["11"].RawEntry[10]) | uint16(loaded.Entries["11"].RawEntry[11])<<8; got != 12 {
t.Fatalf("raw durability = %d, want unchanged 12", got)
}
character, _, _ := repos.Character.Load(ctx, "77")
if character.Stats["gold"] != 100 {
t.Fatalf("gold mutated = %d, want 100", character.Stats["gold"])
}
}
func TestOwnerRepairRejectsShortRawWithoutMutation(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 9999}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: []byte{1, 2, 3}, Extra: map[string]string{"max_durability": "20", "repair_gold": "0"}},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: 11}, repairTestResolver(20, 0, 20))
if !errors.Is(err, ErrRawEntryTooShort) {
t.Fatalf("Repair error = %v, want ErrRawEntryTooShort", err)
}
}
func TestOwnerRepairRejectsNotRepairableItem(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 9999}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0}},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: 11}, repairTestResolver(-1, 0, 0))
if !errors.Is(err, ErrRepairNotRepairable) {
t.Fatalf("Repair error = %v, want ErrRepairNotRepairable", err)
}
}
func TestOwnerRepairNilResolverFailsClosed(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 9999}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0}},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: 11}, nil)
if !errors.Is(err, ErrRepairCostMissing) {
t.Fatalf("Repair error = %v, want ErrRepairCostMissing", err)
}
}
func TestOwnerRepairFullDurabilityIsNoOp(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 4321}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 0}},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: 11}, repairTestResolver(20, 6400, 20))
if err != nil {
t.Fatalf("Repair error = %v", err)
}
if result.Changed || result.Cost != 0 || result.UpdatedGold != 4321 || result.NewDurability != 20 {
t.Fatalf("result = %+v, want unchanged no-cost", result)
}
character, _, _ := repos.Character.Load(ctx, "77")
if character.Stats["gold"] != 4321 {
t.Fatalf("gold mutated = %d", character.Stats["gold"])
}
}
func TestOwnerMoveEquipsInventoryStackToEmptySlot(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := "000102030405060708090c000d0e"
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 700, Count: 1, Extra: map[string]string{"raw_entry_hex": raw}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 11,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if !result.Changed || result.Mode != "equip" || result.ItemID != 700 {
t.Fatalf("result = %+v", result)
}
inventory := loadTestInventory(t, ctx, repos, "77")
if _, ok := inventory.Slots["0:5"]; ok {
t.Fatalf("inventory source slot should be empty: %+v", inventory.Slots)
}
equipment := loadTestEquipment(t, ctx, repos, "77")
entry := equipment.Entries["11"]
if entry.ItemID != 700 || entry.SlotIndex != 11 || len(entry.RawEntry) != 14 {
t.Fatalf("equipment entry = %+v", entry)
}
}
func TestOwnerMoveRequiresPlacementValidatorBeforeMutation(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 700, Count: 1, RawEntry: []byte{1, 2, 3}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 11,
})
if !errors.Is(err, ErrMoveValidatorRequired) {
t.Fatalf("Move error = %v, want ErrMoveValidatorRequired", err)
}
if stack := loadTestInventory(t, ctx, repos, "77").Slots["0:5"]; stack.ItemID != 700 || stack.Count != 1 {
t.Fatalf("inventory mutated: %+v", stack)
}
if entries := loadTestEquipment(t, ctx, repos, "77").Entries; len(entries) != 0 {
t.Fatalf("equipment mutated: %+v", entries)
}
}
func TestOwnerMoveRejectsOutOfRangeEquipmentSlotBeforeValidation(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 700, Count: 1, RawEntry: []byte{1, 2, 3}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
validator := &recordingPlacementValidator{}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: currentEquipmentSlotCount,
})
if !errors.Is(err, ErrMoveSlotOutOfRange) {
t.Fatalf("Move error = %v, want ErrMoveSlotOutOfRange", err)
}
if len(validator.placements) != 0 {
t.Fatalf("validator called for structurally invalid slot: %+v", validator.placements)
}
if stack := loadTestInventory(t, ctx, repos, "77").Slots["0:5"]; stack.ItemID != 700 || stack.Count != 1 {
t.Fatalf("inventory mutated: %+v", stack)
}
}
func TestOwnerMoveValidatorRejectionDoesNotMutate(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 700, Count: 1, RawEntry: []byte{1, 2, 3}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
rejected := errors.New("placement rejected")
validator := &recordingPlacementValidator{err: rejected}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 11,
})
if !errors.Is(err, rejected) {
t.Fatalf("Move error = %v, want validator rejection", err)
}
if len(validator.placements) != 1 || validator.placements[0].ItemID != 700 || validator.placements[0].TargetSlotIndex != 11 {
t.Fatalf("placements = %+v", validator.placements)
}
if stack := loadTestInventory(t, ctx, repos, "77").Slots["0:5"]; stack.ItemID != 700 || stack.Count != 1 {
t.Fatalf("inventory mutated: %+v", stack)
}
if entries := loadTestEquipment(t, ctx, repos, "77").Entries; len(entries) != 0 {
t.Fatalf("equipment mutated: %+v", entries)
}
}
func TestOwnerMoveValidatesEveryEquipmentSwapTarget(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: []byte{1}},
"13": {SlotIndex: 13, ItemID: 800, RawEntry: []byte{2}},
},
})
validator := &recordingPlacementValidator{}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 11,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 13,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if !result.Changed || len(validator.placements) != 2 {
t.Fatalf("result=%+v placements=%+v", result, validator.placements)
}
if first, second := validator.placements[0], validator.placements[1]; first.ItemID != 700 || first.TargetSlotIndex != 13 || second.ItemID != 800 || second.TargetSlotIndex != 11 {
t.Fatalf("placements = %+v", validator.placements)
}
entries := loadTestEquipment(t, ctx, repos, "77").Entries
if entries["11"].ItemID != 800 || entries["13"].ItemID != 700 {
t.Fatalf("equipment swap = %+v", entries)
}
}
func TestOwnerMoveEquipsTypedRawEntryWithoutLegacyHex(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0, 13, 14}
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 700, Count: 1, RawEntry: raw},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
if _, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 11,
}); err != nil {
t.Fatalf("Move error = %v", err)
}
entry := loadTestEquipment(t, ctx, repos, "77").Entries["11"]
if !bytes.Equal(entry.RawEntry, raw) {
t.Fatalf("equipment raw = % X, want % X", entry.RawEntry, raw)
}
raw[0] = 0xFF
if entry.RawEntry[0] == 0xFF {
t.Fatal("equipment raw aliases caller memory")
}
}
func TestOwnerMoveEquipsTitleBookRewardWithoutDuplicatingInventoryItem(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:12": {
ItemID: 26691,
Count: 1,
Extra: map[string]string{
"source": "title_book_get",
"title_book_category": "1",
"title_book_index": "73",
},
},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 12,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 13,
})
if err != nil {
t.Fatal(err)
}
if !result.Changed || result.Mode != "equip" || result.ItemID != 26691 {
t.Fatalf("move result = %+v", result)
}
if _, found := loadTestInventory(t, ctx, repos, "77").Slots["0:12"]; found {
t.Fatal("title remained in inventory after successful equip")
}
entry, found := loadTestEquipment(t, ctx, repos, "77").Entries["13"]
if !found || entry.ItemID != 26691 {
t.Fatalf("equipped title = %+v found=%t", entry, found)
}
if len(entry.RawEntry) != 43 ||
entry.RawEntry[0] != 13 ||
binary.LittleEndian.Uint32(entry.RawEntry[1:5]) != 26691 ||
binary.LittleEndian.Uint32(entry.RawEntry[5:9]) != 1 {
t.Fatalf("equipped title raw = %x", entry.RawEntry)
}
}
// 普通装备(没有任何 title_book_* 标记)摘下来仍然进背包 —— 重定向必须只对簿内资产生效。
func TestOwnerMoveUnequipKeepsOrdinaryEquipmentLandingInTheBackpack(t *testing.T) {
t.Skip("2026-09-28 线上回退:被测行为已停用,待诊断后重开")
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"13": {
SlotIndex: 13,
ItemID: 26691,
RawEntry: titleBookTestEquipmentRaw(13, 26691),
Extra: map[string]string{
"source": "equipped",
"title_book_category": "1",
"title_book_index": "135",
},
},
},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 13,
MoveCount: 1,
DestinationListType: wireListMain,
DestinationSlotIndex: 8,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.DestinationListType != wireListMain || result.DestinationSlotIndex != 8 {
t.Fatalf("ordinary unequip was redirected: destination = (%d,%d)",
result.DestinationListType, result.DestinationSlotIndex)
}
if _, occupied := loadTestInventory(t, ctx, repos, "77").Slots["0:8"]; !occupied {
t.Fatal("ordinary equipment did not land in the requested backpack slot")
}
}
// 称号簿原来那一格被别人占了:必须在同一个 category 内回退到第一个空格,而不是掉背包。
func TestOwnerMoveUnequipFallsBackToFirstFreeSlotInTheSameTitleBookCategory(t *testing.T) {
t.Skip("2026-09-28 线上回退:被测行为已停用,待诊断后重开")
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
// 原格 1*1000+135 已被别的称号占住。
"100:1135": {ItemID: 26691, Count: 1},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"13": {
SlotIndex: 13,
ItemID: 100331415,
RawEntry: titleBookTestEquipmentRaw(13, 100331415),
Extra: map[string]string{
"source": "title_book_equip",
"title_book_category": "1",
"title_book_index": "135",
},
},
},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 13,
MoveCount: 1,
DestinationListType: wireListMain,
DestinationSlotIndex: 8,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
// category 1 的第 0 格 = 1*1000+0 = 1000 是空的。
if result.DestinationListType != 100 || result.DestinationSlotIndex != 1000 {
t.Fatalf("ack destination = (%d,%d), want (100,1000)",
result.DestinationListType, result.DestinationSlotIndex)
}
inventory := loadTestInventory(t, ctx, repos, "77")
if _, occupied := inventory.Slots["0:8"]; occupied {
t.Fatalf("achievement title fell through into the backpack: %+v", inventory.Slots)
}
if got := inventory.Slots["100:1000"]; got.ItemID != 100331415 {
t.Fatalf("fallback book slot = %+v", got)
}
// 被占的原格不能被覆盖。
if got := inventory.Slots["100:1135"]; got.ItemID != 26691 {
t.Fatalf("occupied book slot was overwritten: %+v", got)
}
}
// 礼白666 型卡死:achievement.grantTitle 写进称号簿时不写 RawEntry,titlebook.Equip
// 又把空 raw 原样搬进装备行,于是 unequip 开头那道 `len(RawEntry)==0` 的门把这个称号
// 永久锁死(玩家:称号展示栏卡了一个成就称号,摘不下来)。簿内资产必须就地补行放行。
func TestOwnerMoveReleasesStuckTitleBookTitleWithoutRawEntry(t *testing.T) {
t.Skip("2026-09-28 线上回退:被测行为已停用,待诊断后重开")
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"13": {
SlotIndex: 13,
ItemID: 100331415,
// 故意没有 RawEntry:这正是 grantTitle + Equip 留下的卡死态。
Extra: map[string]string{
"source": "title_book_equip",
"title_book_category": "1",
"title_book_index": "135",
},
},
},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 13,
MoveCount: 1,
DestinationListType: wireListMain,
DestinationSlotIndex: 8,
})
if err != nil {
t.Fatalf("stuck title must be releasable, Move error = %v", err)
}
if result.DestinationListType != 100 || result.DestinationSlotIndex != 1135 {
t.Fatalf("ack destination = (%d,%d), want (100,1135)",
result.DestinationListType, result.DestinationSlotIndex)
}
inventory := loadTestInventory(t, ctx, repos, "77")
if _, occupied := inventory.Slots["0:8"]; occupied {
t.Fatalf("released achievement title fell into the backpack: %+v", inventory.Slots)
}
got, found := inventory.Slots["100:1135"]
if !found || got.ItemID != 100331415 {
t.Fatalf("released achievement title did not return to the book: found=%t stack=%+v", found, got)
}
// 放行时必须补出合法的 43 字节行,而不是把空 raw 一路带回去。
wantRawHex := hex.EncodeToString(titleBookTestEquipmentRaw(13, 100331415))
if got.Extra["raw_entry_hex"] != wantRawHex {
t.Fatalf("released title raw = %q, want %q", got.Extra["raw_entry_hex"], wantRawHex)
}
}
func TestOwnerMoveStillRejectsUnprovenMissingRawEntry(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:12": {ItemID: 26691, Count: 1},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatal(err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 12,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 13,
})
if !errors.Is(err, ErrMoveRawEntryMissing) {
t.Fatalf("missing unproven raw error = %v, want %v", err, ErrMoveRawEntryMissing)
}
}
func TestOwnerMoveMaterializesRawFromCurrentEquipmentMoveEvidence(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:9": {
ItemID: 100180108,
Count: 1,
Extra: map[string]string{
"item_kind": "equipment",
"pvf_path": "equipment/character/common/shoulder/harmor/100180108.equ",
"quality_seed": "999999998",
"durability": "32",
"seal_flag": "1",
},
},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 9,
SourceInstanceValue: 0x05F8A08C,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 15,
DestinationInstanceValue: 0,
})
if err != nil {
t.Fatal(err)
}
if !result.Changed || result.Mode != "equip" || result.ItemID != 100180108 {
t.Fatalf("result=%+v", result)
}
entry, found := loadTestEquipment(t, ctx, repos, "77").Entries["15"]
if !found || len(entry.RawEntry) != currentEquipmentRawEntryWireSize {
t.Fatalf("entry=%+v found=%t", entry, found)
}
if got := binary.LittleEndian.Uint16(entry.RawEntry[0:2]); got != 15 {
t.Fatalf("raw slot=%d", got)
}
if got := binary.LittleEndian.Uint32(entry.RawEntry[2:6]); got != 100180108 {
t.Fatalf("raw item=%d", got)
}
if got := binary.LittleEndian.Uint32(entry.RawEntry[6:10]); got != 999999998 {
t.Fatalf("raw quality=%d", got)
}
if got := binary.LittleEndian.Uint16(entry.RawEntry[0x0B:0x0D]); got != 32 {
t.Fatalf("raw durability=%d", got)
}
if entry.RawEntry[0x0D] != 0 || entry.Extra["seal_removed_by_first_equip"] != "1" {
t.Fatalf("first-equip seal was not consumed: raw=%x extra=%+v", entry.RawEntry[0x0D], entry.Extra)
}
if got := binary.LittleEndian.Uint32(entry.RawEntry[0x0E:0x12]); got != 0x05F8A08C {
t.Fatalf("raw source identity=%#x", got)
}
}
func TestOwnerMoveMaterializesZeroDurabilityAccessoryFromCurrentMoveEvidence(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:14": {
ItemID: 24000,
Count: 1,
Extra: map[string]string{
"source": "dungeon_card_reward",
"item_kind": "equipment",
"pvf_path": "equipment/character/common/ring/ring_rstraw.equ",
"equipment_type": "[ring]",
"quality_seed": "877178772",
},
},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 14,
SourceInstanceValue: 24000,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 21,
DestinationInstanceValue: 0,
})
if err != nil {
t.Fatal(err)
}
if !result.Changed || result.Mode != "equip" || result.ItemID != 24000 {
t.Fatalf("result=%+v", result)
}
entry, found := loadTestEquipment(t, ctx, repos, "77").Entries["21"]
if !found || len(entry.RawEntry) != currentEquipmentRawEntryWireSize {
t.Fatalf("entry=%+v found=%t", entry, found)
}
if got := binary.LittleEndian.Uint16(entry.RawEntry[0x0B:0x0D]); got != 0 {
t.Fatalf("raw durability=%d, want 0", got)
}
if got := binary.LittleEndian.Uint32(entry.RawEntry[0x0E:0x12]); got != 24000 {
t.Fatalf("raw source identity=%d, want 24000", got)
}
}
func TestCurrentMoveEvidenceStillRejectsMissingDurabilityForWeapon(t *testing.T) {
_, ok := buildCurrentEquipmentRawEntryFromMoveEvidence(dnfrepo.ItemStack{
ItemID: 116000011,
Count: 1,
Extra: map[string]string{
"item_kind": "equipment",
"pvf_path": "equipment/character/gunblader/weapon/lblade/116000011.equ",
"equipment_type": "[weapon]",
"quality_seed": "877178772",
},
}, wireListMain, 12, 116000011)
if ok {
t.Fatal("weapon without durability evidence was accepted")
}
}
func TestOwnerMoveEquipsStackWithTailDataAliasPreservesRaw(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := make([]byte, 0x77)
tail := make([]byte, equipmentTailDataBytes)
tail[0] = 2
binary.LittleEndian.PutUint32(tail[1:5], 0xFFFFFFFF)
binary.LittleEndian.PutUint32(tail[5:9], 0xFFFFFFFF)
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 700, Count: 1, RawEntry: raw, Extra: map[string]string{"tailData2F": hex.EncodeToString(tail)}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
if _, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 11,
}); err != nil {
t.Fatalf("Move error = %v", err)
}
entry := loadTestEquipment(t, ctx, repos, "77").Entries["11"]
if !bytes.Equal(entry.RawEntry, raw) {
t.Fatalf("equipment raw = %x, want preserved %x", entry.RawEntry, raw)
}
}
func TestOwnerMoveEquipsAndSwapsPreviousEntryBack(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 700, Count: 1, Extra: map[string]string{"raw_entry_hex": "000102030405060708090c000d0e"}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 800, RawEntry: []byte{9, 8, 7, 6}, Extra: map[string]string{"source": "old"}},
},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 11,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "equip_swap" || result.SwappedItemID != 800 {
t.Fatalf("result = %+v", result)
}
inventory := loadTestInventory(t, ctx, repos, "77")
if got := inventory.Slots["0:5"]; got.ItemID != 800 || got.Extra["raw_entry_hex"] != "09080706" {
t.Fatalf("swapped inventory stack = %+v", got)
}
equipment := loadTestEquipment(t, ctx, repos, "77")
if got := equipment.Entries["11"].ItemID; got != 700 {
t.Fatalf("equipped item = %d, want 700", got)
}
}
func TestOwnerMoveFirstEquipPermanentlyRemovesExplicitSeal(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := make([]byte, 0x77)
raw[0x0D] = 1
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:10": {
ItemID: 400330119,
Count: 1,
RawEntry: raw,
Extra: map[string]string{"seal_flag": "1", "source": "booster_item"},
},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatal(err)
}
if _, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77, SourceListType: wireListMain, SourceSlotIndex: 10, MoveCount: 1,
DestinationListType: wireListEquipment, DestinationSlotIndex: 13,
}); err != nil {
t.Fatal(err)
}
equipped := loadTestEquipment(t, ctx, repos, "77").Entries["13"]
if len(equipped.RawEntry) <= 0x0D || equipped.RawEntry[0x0D] != 0 || equipped.Extra["seal_flag"] != "" ||
equipped.Extra["seal_removed_by_first_equip"] != "1" || equipped.Extra["trade_locked_by_first_equip"] != "1" {
t.Fatalf("equipped seal survived: raw=%x extra=%+v", equipped.RawEntry, equipped.Extra)
}
if _, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77, SourceListType: wireListEquipment, SourceSlotIndex: 13, MoveCount: 1,
DestinationListType: wireListMain, DestinationSlotIndex: 10,
}); err != nil {
t.Fatal(err)
}
unequipped := loadTestInventory(t, ctx, repos, "77").Slots["0:10"]
if len(unequipped.RawEntry) <= 0x0D || unequipped.RawEntry[0x0D] != 0 || unequipped.Extra["seal_flag"] != "" ||
unequipped.Extra["seal_removed_by_first_equip"] != "1" {
t.Fatalf("unequipped item was resealed: raw=%x extra=%+v", unequipped.RawEntry, unequipped.Extra)
}
}
func TestOwnerMoveEquipsPetCreatureToSlot26(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
before := richTestPetEntry("37", 0x17E69F80, wireListPet, 48)
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"7:48": {ItemID: 0x17E69F80, Count: 1, Extra: map[string]string{"creature_serial_or_handle": "37"}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
saveTestPet(t, ctx, repos, dnfrepo.PetRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.PetEntry{"37": before},
TownDisplay: true,
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListPet,
SourceSlotIndex: 48,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 26,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "equip" || result.ItemID != 0x17E69F80 || !result.Changed {
t.Fatalf("result = %+v", result)
}
inventory := loadTestInventory(t, ctx, repos, "77")
if _, ok := inventory.Slots["7:48"]; ok {
t.Fatalf("pet source slot should be empty: %+v", inventory.Slots)
}
equipment := loadTestEquipment(t, ctx, repos, "77")
entry := equipment.Entries["26"]
if entry.ItemID != 0x17E69F80 || len(entry.RawEntry) < 28 {
t.Fatalf("pet equipment entry = %+v raw=% X", entry, entry.RawEntry)
}
if got := binary.LittleEndian.Uint32(entry.RawEntry[5:]); got != 37 {
t.Fatalf("raw instance serial = %d, want 37", got)
}
if got := binary.LittleEndian.Uint32(entry.RawEntry[24:]); got != 37 {
t.Fatalf("raw creature extra = %d, want 37", got)
}
pet := loadTestPet(t, ctx, repos, "77")
if pet.EquippedKey != "37" || !pet.TownDisplay {
t.Fatalf("pet equipped state = %+v", pet)
}
want := before
want.SourceListType = wireListEquipment
want.SourceSlotIndex = 26
if got := pet.Entries["37"]; !reflect.DeepEqual(got, want) {
t.Fatalf("pet growth/location = %+v, want %+v", got, want)
}
}
func TestOwnerMoveEquipsPetCreatureAndPreservesEnchant(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"7:48": {
ItemID: 400990168,
Count: 1,
Extra: map[string]string{
"creature_serial_or_handle": "41",
// Historical unequip rows could retain a stale zero in the
// pet-specific alias while the generic field held the card.
"pet_enchant_card_item_id": "0",
"enchant_card_id": "10008705",
"enchant_upgrade_count": "3",
},
},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
saveTestPet(t, ctx, repos, dnfrepo.PetRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.PetEntry{
"41": richTestPetEntry("41", 400990168, wireListPet, 48),
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
if _, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListPet,
SourceSlotIndex: 48,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 26,
}); err != nil {
t.Fatal(err)
}
entry := loadTestEquipment(t, ctx, repos, "77").Entries["26"]
if len(entry.RawEntry) < 0x15 {
t.Fatalf("pet equipment raw too short: %x", entry.RawEntry)
}
if got := binary.LittleEndian.Uint32(entry.RawEntry[0x10:0x14]); got != 10008705 {
t.Fatalf("raw enchant card=%d want 10008705 raw=%x", got, entry.RawEntry)
}
if got := entry.RawEntry[0x14]; got != 3 {
t.Fatalf("raw enchant upgrade=%d want 3 raw=%x", got, entry.RawEntry)
}
if entry.Extra["pet_enchant_card_item_id"] != "10008705" ||
entry.Extra["enchant_card_id"] != "10008705" ||
entry.Extra["value_a"] != "10008705" ||
entry.Extra["enchant_upgrade_count"] != "3" ||
entry.Extra["byte_12"] != "3" {
t.Fatalf("pet enchant extras not canonicalized: %+v", entry.Extra)
}
}
func TestOwnerMoveUnequipsPetCreatureToPetInventory(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
before := richTestPetEntry("37", 0x17E69F80, wireListEquipment, 26)
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"26": {SlotIndex: 26, ItemID: 0x17E69F80, RawEntry: buildPetCreatureEquipEntry(26, 0x17E69F80, 37), Extra: map[string]string{"source": "equipped-pet"}},
},
})
saveTestPet(t, ctx, repos, dnfrepo.PetRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.PetEntry{"37": before},
EquippedKey: "37",
TownDisplay: true,
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 26,
MoveCount: 1,
DestinationListType: wireListPet,
DestinationSlotIndex: 48,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "unequip" || !result.Changed {
t.Fatalf("result = %+v", result)
}
equipment := loadTestEquipment(t, ctx, repos, "77")
if _, ok := equipment.Entries["26"]; ok {
t.Fatalf("equipment slot should be empty: %+v", equipment.Entries)
}
inventory := loadTestInventory(t, ctx, repos, "77")
got := inventory.Slots["7:48"]
if got.ItemID != 0x17E69F80 || got.Extra["creature_serial_or_handle"] != "37" || got.Extra["raw_entry_hex"] == "" {
t.Fatalf("pet inventory stack = %+v", got)
}
pet := loadTestPet(t, ctx, repos, "77")
if pet.EquippedKey != "" || !pet.TownDisplay {
t.Fatalf("pet equipped state = %+v", pet)
}
want := before
want.SourceListType = wireListPet
want.SourceSlotIndex = 48
if got := pet.Entries["37"]; !reflect.DeepEqual(got, want) {
t.Fatalf("pet growth/location = %+v, want %+v", got, want)
}
}
func TestOwnerMoveSwapsPetCreaturesAndPreservesGrowth(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
equippedBefore := richTestPetEntry("37", 9001, wireListEquipment, 26)
inventoryBefore := richTestPetEntry("38", 9002, wireListPet, 48)
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"7:48": {ItemID: 9002, Count: 1, Extra: map[string]string{"creature_serial_or_handle": "38"}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"26": {SlotIndex: 26, ItemID: 9001, RawEntry: buildPetCreatureEquipEntry(26, 9001, 37), Extra: map[string]string{"source": "equipped-pet"}},
},
})
saveTestPet(t, ctx, repos, dnfrepo.PetRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.PetEntry{
"37": equippedBefore,
"38": inventoryBefore,
},
EquippedKey: "37",
TownDisplay: true,
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListPet,
SourceSlotIndex: 48,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 26,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "equip_swap" || result.ItemID != 9002 || result.SwappedItemID != 9001 {
t.Fatalf("result = %+v", result)
}
if got := loadTestInventory(t, ctx, repos, "77").Slots["7:48"]; got.ItemID != 9001 || got.Extra["creature_serial_or_handle"] != "37" {
t.Fatalf("swapped inventory pet = %+v", got)
}
if got := loadTestEquipment(t, ctx, repos, "77").Entries["26"]; got.ItemID != 9002 || petEquipmentSerial(got) != 38 {
t.Fatalf("swapped equipped pet = %+v", got)
}
pet := loadTestPet(t, ctx, repos, "77")
if pet.EquippedKey != "38" || !pet.TownDisplay {
t.Fatalf("pet equipped state = %+v", pet)
}
wantEquipped := equippedBefore
wantEquipped.SourceListType = wireListPet
wantEquipped.SourceSlotIndex = 48
wantInventory := inventoryBefore
wantInventory.SourceListType = wireListEquipment
wantInventory.SourceSlotIndex = 26
if got := pet.Entries["37"]; !reflect.DeepEqual(got, wantEquipped) {
t.Fatalf("old equipped pet = %+v, want %+v", got, wantEquipped)
}
if got := pet.Entries["38"]; !reflect.DeepEqual(got, wantInventory) {
t.Fatalf("new equipped pet = %+v, want %+v", got, wantInventory)
}
}
func TestOwnerMovePetMismatchRollsBackInventoryEquipmentAndPet(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
inventoryBefore := dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"7:48": {ItemID: 9001, Count: 1, Extra: map[string]string{"creature_serial_or_handle": "37"}},
},
}
equipmentBefore := dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}}
petBefore := dnfrepo.PetRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.PetEntry{
"38": richTestPetEntry("38", 9002, wireListPet, 49),
},
TownDisplay: true,
}
saveTestInventory(t, ctx, repos, inventoryBefore)
saveTestEquipment(t, ctx, repos, equipmentBefore)
saveTestPet(t, ctx, repos, petBefore)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListPet,
SourceSlotIndex: 48,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 26,
})
if !errors.Is(err, ErrPetEntryNotFound) {
t.Fatalf("Move error = %v, want ErrPetEntryNotFound", err)
}
if got := loadTestInventory(t, ctx, repos, "77"); !reflect.DeepEqual(got.Slots, inventoryBefore.Slots) {
t.Fatalf("inventory mutated after rollback: %+v", got.Slots)
}
if got := loadTestEquipment(t, ctx, repos, "77"); len(got.Entries) != 0 {
t.Fatalf("equipment mutated after rollback: %+v", got.Entries)
}
if got := loadTestPet(t, ctx, repos, "77"); !reflect.DeepEqual(got.Entries, petBefore.Entries) || got.EquippedKey != petBefore.EquippedKey || got.TownDisplay != petBefore.TownDisplay {
t.Fatalf("pet mutated after rollback: %+v", got)
}
}
func TestOwnerMoveUnequipsEntryToInventory(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: []byte{0, 1, 2, 3}, Extra: map[string]string{"source": "equipped"}},
},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 11,
MoveCount: 1,
DestinationListType: wireListMain,
DestinationSlotIndex: 8,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "unequip" || !result.Changed {
t.Fatalf("result = %+v", result)
}
equipment := loadTestEquipment(t, ctx, repos, "77")
if _, ok := equipment.Entries["11"]; ok {
t.Fatalf("equipment slot should be empty: %+v", equipment.Entries)
}
inventory := loadTestInventory(t, ctx, repos, "77")
if got := inventory.Slots["0:8"]; got.ItemID != 700 || got.Count != 1 || got.Extra["raw_entry_hex"] != "00010203" {
t.Fatalf("inventory stack = %+v", got)
}
}
func titleBookTestEquipmentRaw(slot int16, itemID int64) []byte {
raw := make([]byte, 43)
raw[0] = byte(slot)
binary.LittleEndian.PutUint32(raw[1:5], uint32(itemID))
binary.LittleEndian.PutUint32(raw[5:9], 1)
return raw
}
// 成就称号是称号簿资产:从簿里「右键使用」穿上之后,摘下来必须回簿,不能进背包。
// 客户端字符串 48178:「通过成就任务获得的称号,无法移动到物品栏或金库。」
func TestOwnerMoveUnequipsTitleBookTitleBackIntoTheBook(t *testing.T) {
t.Skip("2026-09-28 线上回退:被测行为已停用,待诊断后重开")
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"13": {
SlotIndex: 13,
ItemID: 100331415,
RawEntry: titleBookTestEquipmentRaw(13, 100331415),
Extra: map[string]string{
"source": "title_book_equip",
"title_book_category": "1",
"title_book_index": "135",
},
},
},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 13,
MoveCount: 1,
DestinationListType: wireListMain,
DestinationSlotIndex: 8,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "unequip" || !result.Changed {
t.Fatalf("result = %+v", result)
}
// ACK 必须回执真实落点:称号簿(list 100)第 1 类第 135 格 = 1*1000+135。
if result.DestinationListType != 100 || result.DestinationSlotIndex != 1135 {
t.Fatalf("ack destination = (%d,%d), want (100,1135)",
result.DestinationListType, result.DestinationSlotIndex)
}
equipment := loadTestEquipment(t, ctx, repos, "77")
if _, ok := equipment.Entries["13"]; ok {
t.Fatalf("equipment slot 13 should be empty: %+v", equipment.Entries)
}
inventory := loadTestInventory(t, ctx, repos, "77")
if _, occupied := inventory.Slots["0:8"]; occupied {
t.Fatalf("achievement title landed in the backpack: %+v", inventory.Slots)
}
got, found := inventory.Slots["100:1135"]
if !found || got.ItemID != 100331415 {
t.Fatalf("achievement title did not return to the title book: found=%t stack=%+v", found, got)
}
}
// 玩家不摘、而是直接穿背包里的另一件称号,把成就称号顶下来:成就称号同样必须回簿,
// 新称号原来占的背包格要清空,不能留下一个幽灵行。
func TestOwnerMoveEquipSwapReturnsDisplacedTitleBookTitleToTheBook(t *testing.T) {
t.Skip("2026-09-28 线上回退:被测行为已停用,待诊断后重开")
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:12": {ItemID: 26691, Count: 1, RawEntry: titleBookTestEquipmentRaw(13, 26691)},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"13": {
SlotIndex: 13,
ItemID: 100331415,
RawEntry: titleBookTestEquipmentRaw(13, 100331415),
Extra: map[string]string{
"source": "title_book_equip",
"title_book_category": "1",
"title_book_index": "135",
},
},
},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 12,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 13,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "equip_swap" || result.SwappedItemID != 100331415 {
t.Fatalf("result = %+v", result)
}
inventory := loadTestInventory(t, ctx, repos, "77")
if _, occupied := inventory.Slots["0:12"]; occupied {
t.Fatalf("displaced achievement title landed in the backpack: %+v", inventory.Slots)
}
got, found := inventory.Slots["100:1135"]
if !found || got.ItemID != 100331415 {
t.Fatalf("displaced achievement title did not return to the title book: found=%t stack=%+v", found, got)
}
if entry, ok := loadTestEquipment(t, ctx, repos, "77").Entries["13"]; !ok || entry.ItemID != 26691 {
t.Fatalf("new title was not equipped: entry=%+v ok=%t", entry, ok)
}
}
func TestOwnerMoveUnequipsEntryWithTailDataAliasPreservesRaw(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := make([]byte, 0x77)
tail := make([]byte, equipmentTailDataBytes)
tail[0] = 2
binary.LittleEndian.PutUint32(tail[1:5], 0xFFFFFFFF)
binary.LittleEndian.PutUint32(tail[5:9], 0xFFFFFFFF)
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: raw, Extra: map[string]string{"tailData2F": hex.EncodeToString(tail)}},
},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
if _, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 11,
MoveCount: 1,
DestinationListType: wireListMain,
DestinationSlotIndex: 8,
}); err != nil {
t.Fatalf("Move error = %v", err)
}
stack := loadTestInventory(t, ctx, repos, "77").Slots["0:8"]
if !bytes.Equal(stack.RawEntry, raw) {
t.Fatalf("inventory raw = %x, want preserved %x", stack.RawEntry, raw)
}
}
func TestOwnerMoveUnequipUsesNextEmptyMainSlotFromStaleClientDestination(t *testing.T) {
for occupiedThrough := int16(10); occupiedThrough <= 12; occupiedThrough++ {
t.Run(fmt.Sprintf("occupied_through_%d", occupiedThrough), func(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
slots := make(map[string]dnfrepo.ItemStack)
for slot := int16(10); slot <= occupiedThrough; slot++ {
slots[inventoryKey(wireListMain, slot)] = dnfrepo.ItemStack{
ItemID: int64(800 + slot),
Count: 1,
RawEntry: []byte{byte(slot), 0xA5},
}
}
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: slots})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"17": {SlotIndex: 17, ItemID: 700, RawEntry: []byte{0x11, 0x22}, Extra: map[string]string{"source": "equipped"}},
},
})
validator := &recordingPlacementValidator{}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatal(err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 10,
SourceInstanceValue: 0,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 17,
DestinationInstanceValue: 0x47E0,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
wantSlot := occupiedThrough + 1
if result.Mode != "unequip" || !result.Changed || result.SourceListType != wireListMain || result.SourceSlotIndex != wantSlot || result.DestinationListType != wireListEquipment || result.DestinationSlotIndex != 17 {
t.Fatalf("result = %+v, want redirected main slot %d", result, wantSlot)
}
loadedInventory := loadTestInventory(t, ctx, repos, "77")
for slot := int16(10); slot <= occupiedThrough; slot++ {
if got := loadedInventory.Slots[inventoryKey(wireListMain, slot)].ItemID; got != int64(800+slot) {
t.Fatalf("occupied slot %d changed to item %d", slot, got)
}
}
if got := loadedInventory.Slots[inventoryKey(wireListMain, wantSlot)]; got.ItemID != 700 || !bytes.Equal(got.RawEntry, []byte{0x11, 0x22}) {
t.Fatalf("redirected stack = %+v", got)
}
if entries := loadTestEquipment(t, ctx, repos, "77").Entries; len(entries) != 0 {
t.Fatalf("equipment after redirected unequip = %+v", entries)
}
if len(validator.placements) != 0 {
t.Fatalf("unequip relocation must not validate a fake swap: %+v", validator.placements)
}
})
}
}
func TestNextEmptyUnequipGuildMedalNeverCrossesIntoGuardianGemPage(t *testing.T) {
const guardianGemSlot int16 = 49
items := make(map[string]dnfrepo.ItemStack)
for slot := guildMedalInventorySlotStart; slot < guildMedalInventorySlotEnd; slot++ {
items[inventoryKey(wireListGuildMedal, slot)] = dnfrepo.ItemStack{ItemID: int64(1000 + slot), Count: 1}
}
items[inventoryKey(wireListGuildMedal, guardianGemSlot)] = dnfrepo.ItemStack{ItemID: 90003, Count: 1}
if got, ok := nextEmptyUnequipInventorySlot(items, wireListGuildMedal, guildMedalInventorySlotEnd); !ok || got != guildMedalInventorySlotEnd {
t.Fatalf("last medal-page empty slot=(%d,%v), want=(%d,true)", got, ok, guildMedalInventorySlotEnd)
}
items[inventoryKey(wireListGuildMedal, guildMedalInventorySlotEnd)] = dnfrepo.ItemStack{ItemID: 2000, Count: 1}
if got, ok := nextEmptyUnequipInventorySlot(items, wireListGuildMedal, guildMedalInventorySlotEnd); ok {
t.Fatalf("full medal page returned guardian-page slot=(%d,true)", got)
}
if got, ok := nextEmptyUnequipInventorySlot(items, wireListGuildMedal, guardianGemSlot); ok {
t.Fatalf("guardian-page requested slot=(%d,true), want rejected", got)
}
}
func TestNextEmptyUnequipInventorySlotStaysInsideCurrentEXEContainerPage(t *testing.T) {
tests := []struct {
name string
listType byte
requested int16
occupiedEnd int16
want int16
}{
{
name: "avatar",
listType: wireListAvatar,
requested: 207,
occupiedEnd: 208,
want: 209,
},
{
name: "pet_body",
listType: wireListPet,
requested: 137,
occupiedEnd: 138,
want: 139,
},
{
name: "pet_artifact",
listType: wireListPet,
requested: 186,
occupiedEnd: 187,
want: 188,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
items := make(map[string]dnfrepo.ItemStack)
for slot := test.requested; slot <= test.occupiedEnd; slot++ {
items[inventoryKey(test.listType, slot)] = dnfrepo.ItemStack{
ItemID: int64(1000 + slot),
Count: 1,
}
}
got, ok := nextEmptyUnequipInventorySlot(items, test.listType, test.requested)
if !ok || got != test.want {
t.Fatalf("next empty slot=(%d,%t), want=(%d,true)", got, ok, test.want)
}
})
}
// A full creature-body page must not spill into the artifact page, and a
// full artifact page must not spill into pet consumables.
for _, page := range []struct {
name string
requested int16
start int16
end int16
}{
{name: "pet_body_full", requested: 139, start: petBodyInventorySlotStart, end: petBodyInventorySlotEnd},
{name: "pet_artifact_full", requested: 188, start: petArtifactInventorySlotStart, end: petArtifactInventorySlotEnd},
} {
t.Run(page.name, func(t *testing.T) {
items := make(map[string]dnfrepo.ItemStack)
for slot := page.start; slot <= page.end; slot++ {
items[inventoryKey(wireListPet, slot)] = dnfrepo.ItemStack{
ItemID: int64(2000 + slot),
Count: 1,
}
}
if got, ok := nextEmptyUnequipInventorySlot(items, wireListPet, page.requested); ok {
t.Fatalf("full page returned cross-page slot=(%d,true)", got)
}
})
}
}
func TestOwnerMoveRejectsGuildMedalEndpointOnGuardianGemPage(t *testing.T) {
ctx := context.Background()
for _, test := range []struct {
name string
inventory dnfrepo.InventoryRecord
equipment dnfrepo.EquipmentRecord
command MoveCommand
}{
{
name: "equip_from_guardian_page",
inventory: dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
"38:49": {ItemID: 100380060, Count: 1, RawEntry: []byte{0x31, 0xA5}},
}},
equipment: dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}},
command: MoveCommand{SelectedCharacterID: 77, SourceListType: wireListGuildMedal, SourceSlotIndex: 49, MoveCount: 1, DestinationListType: wireListEquipment, DestinationSlotIndex: 32},
},
{
name: "unequip_to_guardian_page",
inventory: dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}},
equipment: dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380060, RawEntry: []byte{0x20, 0xA5}},
}},
command: MoveCommand{SelectedCharacterID: 77, SourceListType: wireListGuildMedal, SourceSlotIndex: 49, MoveCount: 1, DestinationListType: wireListEquipment, DestinationSlotIndex: 32},
},
} {
t.Run(test.name, func(t *testing.T) {
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, test.inventory)
saveTestEquipment(t, ctx, repos, test.equipment)
owner, err := NewOwnerWithPlacementValidator(repos, &recordingPlacementValidator{})
if err != nil {
t.Fatal(err)
}
if _, err := owner.Move(ctx, test.command); !errors.Is(err, ErrMoveUnsupported) {
t.Fatalf("Move error=%v, want %v", err, ErrMoveUnsupported)
}
})
}
}
func TestOwnerMoveRejectsEquipWithoutRawEntryEvidence(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 700, Count: 1},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 11,
})
if !errors.Is(err, ErrMoveRawEntryMissing) {
t.Fatalf("Move error = %v, want ErrMoveRawEntryMissing", err)
}
}
func TestOwnerMoveRejectsEquipmentStackCountGreaterThanOne(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 700, Count: 1, Extra: map[string]string{"raw_entry_hex": "00010203"}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 2,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 11,
})
if !errors.Is(err, ErrMoveStackCountInvalid) {
t.Fatalf("Move error = %v, want ErrMoveStackCountInvalid", err)
}
inventory := loadTestInventory(t, ctx, repos, "77")
if got := inventory.Slots["0:5"].ItemID; got != 700 {
t.Fatalf("inventory item = %d, want unchanged 700", got)
}
}
func TestOwnerMoveResolvesFixedEndpointsForEquipAndUnequip(t *testing.T) {
tests := []struct {
name string
listType byte
sourceSlot int16
targetSlot int16
}{
{name: "main_weapon", listType: wireListMain, sourceSlot: 9, targetSlot: 12},
{name: "avatar", listType: wireListAvatar, sourceSlot: 19, targetSlot: 0},
{name: "personal_cargo_title", listType: wireListPersonalCargo, sourceSlot: 29, targetSlot: 13},
{name: "guild_medal", listType: wireListGuildMedal, sourceSlot: 7, targetSlot: 32},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
itemID := int64(700 + tt.targetSlot)
stack := dnfrepo.ItemStack{ItemID: itemID, Count: 1, RawEntry: []byte{byte(tt.targetSlot + 1), 0xA5}}
inventory := dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}, Warehouse: map[string]dnfrepo.ItemStack{}}
if tt.listType == wireListPersonalCargo {
inventory.Warehouse[inventoryKey(tt.listType, tt.sourceSlot)] = stack
} else {
inventory.Slots[inventoryKey(tt.listType, tt.sourceSlot)] = stack
}
saveTestInventory(t, ctx, repos, inventory)
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
validator := &recordingPlacementValidator{}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
cmd := MoveCommand{
SelectedCharacterID: 77,
SourceListType: tt.listType,
SourceSlotIndex: tt.sourceSlot,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: tt.targetSlot,
}
equipped, err := owner.Move(ctx, cmd)
if err != nil {
t.Fatalf("equip Move error = %v", err)
}
if equipped.Mode != "equip" || equipped.ItemID != itemID {
t.Fatalf("equip result = %+v", equipped)
}
assertMoveResultEndpoints(t, equipped, cmd)
if entry := loadTestEquipment(t, ctx, repos, "77").Entries[entryKey(tt.targetSlot)]; entry.ItemID != itemID || entry.SlotIndex != tt.targetSlot || !bytes.Equal(entry.RawEntry, stack.RawEntry) {
t.Fatalf("equipped entry = %+v", entry)
} else if entry.Extra["current_exe_equipment_type"] != strconv.Itoa(int(tt.targetSlot)) || entry.Extra["current_exe_runtime_move"] != "1" {
t.Fatalf("equipped runtime slot metadata = %+v", entry.Extra)
}
unequipped, err := owner.Move(ctx, cmd)
if err != nil {
t.Fatalf("unequip fixed-endpoint Move error = %v", err)
}
if unequipped.Mode != "unequip" || unequipped.ItemID != itemID {
t.Fatalf("unequip result = %+v", unequipped)
}
assertMoveResultEndpoints(t, unequipped, cmd)
if entries := loadTestEquipment(t, ctx, repos, "77").Entries; len(entries) != 0 {
t.Fatalf("equipment after unequip = %+v", entries)
}
loadedInventory := loadTestInventory(t, ctx, repos, "77")
items, _, ok := inventoryMap(&loadedInventory, tt.listType)
if !ok {
t.Fatalf("inventoryMap list=%d unsupported", tt.listType)
}
if got := items[inventoryKey(tt.listType, tt.sourceSlot)]; got.ItemID != itemID || !bytes.Equal(got.RawEntry, stack.RawEntry) {
t.Fatalf("restored stack = %+v", got)
}
if len(validator.placements) != 1 || validator.placements[0].TargetSlotIndex != tt.targetSlot {
t.Fatalf("placements = %+v", validator.placements)
}
})
}
}
func TestOwnerMovePermanentAvatarPreservesZeroContainerCount(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
avatar := dnfrepo.ItemStack{
ItemID: 710,
Count: 0, // current-EXE permanent-avatar amount
RawEntry: []byte{0xCA, 0xFE},
Extra: map[string]string{
"item_kind": "avatar",
"amount_or_count": "0",
},
}
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
inventoryKey(wireListAvatar, 19): avatar,
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
validator := &recordingPlacementValidator{}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
cmd := MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListAvatar,
SourceSlotIndex: 19,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 0,
}
if result, err := owner.Move(ctx, cmd); err != nil || result.Mode != "equip" {
t.Fatalf("equip permanent avatar result=%+v err=%v", result, err)
}
entry := loadTestEquipment(t, ctx, repos, "77").Entries[entryKey(0)]
if entry.ItemID != avatar.ItemID || entry.Extra["current_exe_inventory_count"] != "0" {
t.Fatalf("equipped permanent avatar=%+v", entry)
}
if len(validator.placements) != 1 || validator.placements[0].SourceListType != wireListAvatar || validator.placements[0].TargetSlotIndex != 0 {
t.Fatalf("placements=%+v", validator.placements)
}
if result, err := owner.Move(ctx, cmd); err != nil || result.Mode != "unequip" {
t.Fatalf("unequip permanent avatar result=%+v err=%v", result, err)
}
restored := loadTestInventory(t, ctx, repos, "77").Slots[inventoryKey(wireListAvatar, 19)]
if restored.ItemID != avatar.ItemID || restored.Count != 0 || !bytes.Equal(restored.RawEntry, avatar.RawEntry) || restored.Extra["amount_or_count"] != "0" {
t.Fatalf("restored permanent avatar=%+v", restored)
}
}
func TestOwnerMoveMigratesLegacyPVFStarterSlotsForCurrentEndpointRoundTrip(t *testing.T) {
tests := []struct {
name string
legacySlot int16
currentSlot int16
itemID int64
}{
{name: "weapon", legacySlot: 11, currentSlot: 12, itemID: 101010912},
{name: "coat", legacySlot: 13, currentSlot: 14, itemID: 10400},
{name: "pants", legacySlot: 15, currentSlot: 16, itemID: 12400},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := []byte{byte(tt.legacySlot), 0xA5, 0x5A}
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
entryKey(tt.legacySlot): {
SlotIndex: tt.legacySlot,
ItemID: tt.itemID,
RawEntry: raw,
Extra: map[string]string{
"source": "pvf_create_equipment_list",
"preserve_test": "yes",
},
},
},
})
validator := &recordingPlacementValidator{}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
cmd := MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 9,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: tt.currentSlot,
}
unequipped, err := owner.Move(ctx, cmd)
if err != nil {
t.Fatalf("legacy unequip Move error = %v", err)
}
if unequipped.Mode != "unequip" || unequipped.ItemID != tt.itemID {
t.Fatalf("legacy unequip result = %+v", unequipped)
}
assertMoveResultEndpoints(t, unequipped, cmd)
if entries := loadTestEquipment(t, ctx, repos, "77").Entries; len(entries) != 0 {
t.Fatalf("equipment after legacy unequip = %+v", entries)
}
stack := loadTestInventory(t, ctx, repos, "77").Slots[inventoryKey(wireListMain, 9)]
if stack.ItemID != tt.itemID || !bytes.Equal(stack.RawEntry, raw) || stack.Extra["preserve_test"] != "yes" {
t.Fatalf("legacy unequip stack = %+v", stack)
}
if stack.Extra["current_exe_equipment_type"] != strconv.Itoa(int(tt.currentSlot)) || stack.Extra["current_exe_runtime_move"] != "1" {
t.Fatalf("legacy migration stack metadata = %+v", stack.Extra)
}
equipped, err := owner.Move(ctx, cmd)
if err != nil {
t.Fatalf("legacy re-equip Move error = %v", err)
}
if equipped.Mode != "equip" || equipped.ItemID != tt.itemID {
t.Fatalf("legacy re-equip result = %+v", equipped)
}
assertMoveResultEndpoints(t, equipped, cmd)
entries := loadTestEquipment(t, ctx, repos, "77").Entries
if _, exists := entries[entryKey(tt.legacySlot)]; exists {
t.Fatalf("legacy alias still exists after round trip: %+v", entries)
}
entry := entries[entryKey(tt.currentSlot)]
if entry.ItemID != tt.itemID || entry.SlotIndex != tt.currentSlot || !bytes.Equal(entry.RawEntry, raw) {
t.Fatalf("current slot entry = %+v", entry)
}
if entry.Extra["current_exe_equipment_type"] != strconv.Itoa(int(tt.currentSlot)) || entry.Extra["current_exe_runtime_move"] != "1" {
t.Fatalf("current slot metadata = %+v", entry.Extra)
}
if len(validator.placements) != 1 || validator.placements[0].TargetSlotIndex != tt.currentSlot {
t.Fatalf("placements = %+v", validator.placements)
}
// Replaying the same fixed-endpoint request must see the persisted
// current slot, not reinterpret it as another legacy category.
replayed, err := owner.Move(ctx, cmd)
if err != nil || replayed.Mode != "unequip" || replayed.ItemID != tt.itemID {
t.Fatalf("replayed current-slot unequip = %+v err=%v", replayed, err)
}
})
}
}
func TestOwnerMoveLegacyPVFSlotCollisionRollsBackEveryAlias(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
})
before := dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 701, RawEntry: []byte{11}, Extra: map[string]string{"source": "pvf_create_equipment_list"}},
"13": {SlotIndex: 13, ItemID: 702, RawEntry: []byte{13}, Extra: map[string]string{"source": "pvf_create_equipment_list"}},
"15": {SlotIndex: 15, ItemID: 703, RawEntry: []byte{15}, Extra: map[string]string{"source": "pvf_create_equipment_list"}},
"16": {SlotIndex: 16, ItemID: 999, RawEntry: []byte{16}, Extra: map[string]string{"source": "runtime"}},
},
}
saveTestEquipment(t, ctx, repos, before)
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 9,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 12,
})
if !errors.Is(err, ErrLegacyPVFSlotCollision) {
t.Fatalf("Move error = %v, want ErrLegacyPVFSlotCollision", err)
}
after := loadTestEquipment(t, ctx, repos, "77")
if !reflect.DeepEqual(after.Entries, before.Entries) {
t.Fatalf("collision partially migrated aliases: before=%+v after=%+v", before.Entries, after.Entries)
}
if slots := loadTestInventory(t, ctx, repos, "77").Slots; len(slots) != 0 {
t.Fatalf("collision mutated inventory: %+v", slots)
}
}
func TestOwnerMovePlacementFailureRollsBackLegacyPVFNormalization(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
inventoryBefore := dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 812, Count: 1, RawEntry: []byte{0x81, 0x02}},
},
}
equipmentBefore := dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 712, RawEntry: []byte{0x71, 0x02}, Extra: map[string]string{"source": "pvf_create_equipment_list"}},
},
}
saveTestInventory(t, ctx, repos, inventoryBefore)
saveTestEquipment(t, ctx, repos, equipmentBefore)
rejected := errors.New("placement rejected after legacy normalization")
owner, err := NewOwnerWithPlacementValidator(repos, &recordingPlacementValidator{err: rejected})
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 9,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 12,
})
if !errors.Is(err, rejected) {
t.Fatalf("Move error = %v, want placement rejection", err)
}
if after := loadTestEquipment(t, ctx, repos, "77"); !reflect.DeepEqual(after.Entries, equipmentBefore.Entries) {
t.Fatalf("placement failure committed legacy migration: before=%+v after=%+v", equipmentBefore.Entries, after.Entries)
}
if after := loadTestInventory(t, ctx, repos, "77"); !reflect.DeepEqual(after.Slots, inventoryBefore.Slots) {
t.Fatalf("placement failure mutated inventory: before=%+v after=%+v", inventoryBefore.Slots, after.Slots)
}
}
func TestOwnerMoveDoesNotMigrateRuntimeSlotsThatAliasLegacyPVFSlots(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
before := dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 811, RawEntry: []byte{11}, Extra: map[string]string{"source": "runtime"}},
"13": {SlotIndex: 13, ItemID: 813, RawEntry: []byte{13}, Extra: map[string]string{
"source": "pvf_create_equipment_list",
"current_exe_runtime_move": "1",
}},
},
}
saveTestEquipment(t, ctx, repos, before)
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 9,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 12,
})
if !errors.Is(err, ErrMoveSlotNotFound) {
t.Fatalf("Move error = %v, want empty current endpoint", err)
}
after := loadTestEquipment(t, ctx, repos, "77")
if !reflect.DeepEqual(after.Entries, before.Entries) {
t.Fatalf("runtime aliases were migrated: before=%+v after=%+v", before.Entries, after.Entries)
}
}
func TestOwnerMoveSupportsActorAppearanceSlotsZeroThroughThirteen(t *testing.T) {
for targetSlot := int16(0); targetSlot <= 13; targetSlot++ {
name := "avatar"
listType := wireListAvatar
if targetSlot == 12 {
name = "weapon"
listType = wireListMain
} else if targetSlot == 13 {
name = "title"
listType = wireListMain
}
t.Run(fmt.Sprintf("%s_slot_%d", name, targetSlot), func(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
sourceSlot := int16(40 + targetSlot)
itemID := int64(9000 + targetSlot)
raw := []byte{0x5A, byte(targetSlot)}
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
inventoryKey(listType, sourceSlot): {ItemID: itemID, Count: 1, RawEntry: raw},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
validator := &recordingPlacementValidator{}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: listType,
SourceSlotIndex: sourceSlot,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: targetSlot,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
entry := loadTestEquipment(t, ctx, repos, "77").Entries[entryKey(targetSlot)]
if entry.ItemID != itemID || entry.SlotIndex != targetSlot || !bytes.Equal(entry.RawEntry, raw) {
t.Fatalf("entry = %+v", entry)
}
if len(validator.placements) != 1 || validator.placements[0].TargetSlotIndex != targetSlot {
t.Fatalf("placements = %+v", validator.placements)
}
})
}
}
func TestOwnerMoveResolvesOccupiedSecondEquipmentEndpoint(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"13": {SlotIndex: 13, ItemID: 813, RawEntry: []byte{0x13}},
},
})
validator := &recordingPlacementValidator{}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
cmd := MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 12,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 13,
}
result, err := owner.Move(ctx, cmd)
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "equip_slot_swap" || result.ItemID != 813 {
t.Fatalf("result = %+v", result)
}
assertMoveResultEndpoints(t, result, cmd)
entries := loadTestEquipment(t, ctx, repos, "77").Entries
if entries["12"].ItemID != 813 || entries["12"].SlotIndex != 12 {
t.Fatalf("destination entry = %+v", entries["12"])
}
if _, ok := entries["13"]; ok {
t.Fatalf("source endpoint still occupied: %+v", entries)
}
if len(validator.placements) != 1 || validator.placements[0].TargetSlotIndex != 12 {
t.Fatalf("placements = %+v", validator.placements)
}
}
func TestOwnerMoveAllowsDualWeaponToggleCountZero(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {SlotIndex: 12, ItemID: 712, RawEntry: []byte{0x12}},
"24": {SlotIndex: 24, ItemID: 724, RawEntry: []byte{0x24}},
},
})
validator := &recordingPlacementValidator{}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 12,
MoveCount: 0,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 24,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "equip_slot_swap" || !result.Changed || result.ItemID != 712 || result.SwappedItemID != 724 {
t.Fatalf("result = %+v", result)
}
entries := loadTestEquipment(t, ctx, repos, "77").Entries
if got := entries["12"]; got.ItemID != 724 || got.SlotIndex != 12 || !bytes.Equal(got.RawEntry, []byte{0x24}) ||
got.Extra["equipment_slot"] != "12" || got.Extra["equipped_slot"] != "12" ||
got.Extra["current_exe_equipment_type"] != "12" || got.Extra["current_exe_runtime_move"] != "1" {
t.Fatalf("main weapon = %+v", got)
}
if got := entries["24"]; got.ItemID != 712 || got.SlotIndex != 24 || !bytes.Equal(got.RawEntry, []byte{0x12}) ||
got.Extra["equipment_slot"] != "24" || got.Extra["equipped_slot"] != "24" ||
got.Extra["current_exe_equipment_type"] != "24" || got.Extra["current_exe_runtime_move"] != "1" {
t.Fatalf("sub weapon = %+v", got)
}
if len(validator.placements) != 2 || validator.placements[0].TargetSlotIndex != 24 || validator.placements[1].TargetSlotIndex != 12 {
t.Fatalf("placements = %+v", validator.placements)
}
}
func TestOwnerMoveRejectsCountZeroForUnrelatedEquipmentSlots(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {SlotIndex: 12, ItemID: 712, RawEntry: []byte{0x12}},
"13": {SlotIndex: 13, ItemID: 713, RawEntry: []byte{0x13}},
},
})
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("newValidatedTestOwner error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 12,
MoveCount: 0,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 13,
})
if !errors.Is(err, ErrMoveStackCountInvalid) {
t.Fatalf("Move error = %v, want ErrMoveStackCountInvalid", err)
}
entries := loadTestEquipment(t, ctx, repos, "77").Entries
if entries["12"].ItemID != 712 || entries["13"].ItemID != 713 {
t.Fatalf("equipment mutated: %+v", entries)
}
}
func TestOwnerMoveRejectsPVFWrongSlotWithoutMutation(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := []byte{0x12, 0x34}
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 712, Count: 1, RawEntry: raw},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
wrongSlot := errors.New("pvf wrong equipment slot")
owner, err := NewOwnerWithPlacementValidator(repos, PlacementValidatorFunc(func(_ context.Context, placement Placement) error {
if placement.TargetSlotIndex != 12 {
return wrongSlot
}
return nil
}))
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 13,
})
if !errors.Is(err, wrongSlot) {
t.Fatalf("Move error = %v, want PVF wrong-slot rejection", err)
}
if got := loadTestInventory(t, ctx, repos, "77").Slots["0:5"]; got.ItemID != 712 || !bytes.Equal(got.RawEntry, raw) {
t.Fatalf("inventory mutated: %+v", got)
}
if entries := loadTestEquipment(t, ctx, repos, "77").Entries; len(entries) != 0 {
t.Fatalf("equipment mutated: %+v", entries)
}
}
func TestOwnerMoveRollsBackInventoryWhenEquipmentSaveFails(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := []byte{0xAA, 0x55}
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 700, Count: 1, RawEntry: raw},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
commitErr := errors.New("equipment commit failed")
repos.CharacterItems = rollbackTestCharacterItems{
inventory: repos.Inventory,
equipment: repos.Equipment,
fail: commitErr,
}
owner, err := newValidatedTestOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListMain,
SourceSlotIndex: 5,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 12,
})
if !errors.Is(err, commitErr) {
t.Fatalf("Move error = %v, want equipment commit failure", err)
}
if got := loadTestInventory(t, ctx, repos, "77").Slots["0:5"]; got.ItemID != 700 || got.Count != 1 || !bytes.Equal(got.RawEntry, raw) {
t.Fatalf("inventory was not rolled back: %+v", got)
}
if entries := loadTestEquipment(t, ctx, repos, "77").Entries; len(entries) != 0 {
t.Fatalf("equipment mutated after rollback: %+v", entries)
}
}
func assertMoveResultEndpoints(t *testing.T, result MoveResult, cmd MoveCommand) {
t.Helper()
if result.SourceListType != cmd.SourceListType || result.SourceSlotIndex != cmd.SourceSlotIndex || result.DestinationListType != cmd.DestinationListType || result.DestinationSlotIndex != cmd.DestinationSlotIndex {
t.Fatalf("result endpoints = (%d,%d)->(%d,%d), want request (%d,%d)->(%d,%d)", result.SourceListType, result.SourceSlotIndex, result.DestinationListType, result.DestinationSlotIndex, cmd.SourceListType, cmd.SourceSlotIndex, cmd.DestinationListType, cmd.DestinationSlotIndex)
}
}
type failingEquipmentRepository struct {
dnfrepo.EquipmentRepository
err error
}
func (r failingEquipmentRepository) Save(context.Context, dnfrepo.EquipmentRecord) error {
return r.err
}
type rollbackTestCharacterItems struct {
inventory dnfrepo.InventoryRepository
equipment dnfrepo.EquipmentRepository
fail error
}
func (u rollbackTestCharacterItems) WithinCharacterItems(ctx context.Context, characterID string, apply func(dnfrepo.InventoryRepository, dnfrepo.EquipmentRepository) error) error {
inventoryBefore, inventoryFound, err := u.inventory.Load(ctx, characterID)
if err != nil {
return err
}
equipmentBefore, equipmentFound, err := u.equipment.Load(ctx, characterID)
if err != nil {
return err
}
err = apply(u.inventory, failingEquipmentRepository{EquipmentRepository: u.equipment, err: u.fail})
if err == nil {
return nil
}
if inventoryFound {
if restoreErr := u.inventory.Save(ctx, inventoryBefore); restoreErr != nil {
return restoreErr
}
}
if equipmentFound {
if restoreErr := u.equipment.Save(ctx, equipmentBefore); restoreErr != nil {
return restoreErr
}
}
return err
}
func saveTestEquipment(t *testing.T, ctx context.Context, repos dnfrepo.Group, record dnfrepo.EquipmentRecord) {
t.Helper()
if err := repos.Equipment.Save(ctx, record); err != nil {
t.Fatalf("Save equipment error = %v", err)
}
}
func saveTestCharacter(t *testing.T, ctx context.Context, repos dnfrepo.Group, record dnfrepo.CharacterRecord) {
t.Helper()
if err := repos.Character.Save(ctx, record); err != nil {
t.Fatalf("Save character error = %v", err)
}
}
func loadTestEquipment(t *testing.T, ctx context.Context, repos dnfrepo.Group, characterID string) dnfrepo.EquipmentRecord {
t.Helper()
record, ok, err := repos.Equipment.Load(ctx, characterID)
if err != nil {
t.Fatalf("Load equipment error = %v", err)
}
if !ok {
t.Fatalf("equipment %s not found", characterID)
}
return record
}
func saveTestInventory(t *testing.T, ctx context.Context, repos dnfrepo.Group, record dnfrepo.InventoryRecord) {
t.Helper()
if err := repos.Inventory.Save(ctx, record); err != nil {
t.Fatalf("Save inventory error = %v", err)
}
}
func loadTestInventory(t *testing.T, ctx context.Context, repos dnfrepo.Group, characterID string) dnfrepo.InventoryRecord {
t.Helper()
record, ok, err := repos.Inventory.Load(ctx, characterID)
if err != nil {
t.Fatalf("Load inventory error = %v", err)
}
if !ok {
t.Fatalf("inventory %s not found", characterID)
}
return record
}
func saveTestPet(t *testing.T, ctx context.Context, repos dnfrepo.Group, record dnfrepo.PetRecord) {
t.Helper()
if err := repos.Pet.Save(ctx, record); err != nil {
t.Fatalf("Save pet error = %v", err)
}
}
func loadTestPet(t *testing.T, ctx context.Context, repos dnfrepo.Group, characterID string) dnfrepo.PetRecord {
t.Helper()
record, ok, err := repos.Pet.Load(ctx, characterID)
if err != nil {
t.Fatalf("Load pet error = %v", err)
}
if !ok {
t.Fatalf("pet %s not found", characterID)
}
return record
}
func richTestPetEntry(key string, itemID int64, sourceListType byte, sourceSlot int16) dnfrepo.PetEntry {
serial, _ := strconv.ParseUint(key, 10, 32)
return dnfrepo.PetEntry{
PetKey: key,
CreatureKey: uint32(serial),
ItemID: itemID,
SourceListType: sourceListType,
SourceSlotIndex: sourceSlot,
Name: "typed-growth-pet",
NameRaw: []byte{0xC4, 0xE3, 0xD7, 0xD6},
Satiety: 73,
ModeFlag: 1,
Mode1Field0A: 0xA1,
Mode1Field0B: 0xB2,
Level: 19,
Exp: 456789,
TailFlag: 0x5A,
RawEntry: []byte{0x10, 0x20, 0x30},
Extra: map[string]string{"growth_evidence": "keep", "serial": key},
}
}
func newValidatedTestOwner(repos dnfrepo.Group) (*Owner, error) {
return NewOwnerWithPlacementValidator(repos, PlacementValidatorFunc(func(context.Context, Placement) error {
return nil
}))
}
type recordingPlacementValidator struct {
placements []Placement
err error
}
func (v *recordingPlacementValidator) ValidateEquipmentPlacement(_ context.Context, placement Placement) error {
v.placements = append(v.placements, placement)
return v.err
}
func TestOwnerRepairAutoRepairFreeWithDevilContract(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc-1", Stats: map[string]int64{"gold": 9999}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 700, RawEntry: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0}},
},
})
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc-1"}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
// Auto-repair without the contract pays the normal formula cost (512).
paid, err := owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, AccountID: "acc-1", SlotIndex: 11, AutoRepair: true}, repairTestResolver(20, 6400, 20))
if err != nil {
t.Fatalf("no-contract auto repair error = %v", err)
}
if paid.FreeRepair || paid.Cost != 512 || paid.UpdatedGold != 9487 {
t.Fatalf("paid result = %+v, want cost=512 gold=9487", paid)
}
// Damage again, then the contract makes the same request free.
damaged := loadTestEquipment(t, ctx, repos, "77")
damagedEntry := damaged.Entries["11"]
damagedEntry.RawEntry[10] = 12
damagedEntry.RawEntry[11] = 0
damaged.Entries["11"] = damagedEntry
if err := repos.Equipment.Save(ctx, damaged); err != nil {
t.Fatal(err)
}
future := time.Now().Add(24 * time.Hour).Unix()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-1",
Metadata: map[string]string{"premium_expire_586": strconv.FormatInt(future, 10)},
}); err != nil {
t.Fatal(err)
}
result, err := owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, AccountID: "acc-1", SlotIndex: 11, AutoRepair: true}, repairTestResolver(20, 6400, 20))
if err != nil {
t.Fatalf("contract auto repair error = %v", err)
}
if !result.FreeRepair || result.Cost != 0 || !result.Changed || result.NewDurability != 20 || result.UpdatedGold != 9487 {
t.Fatalf("result = %+v", result)
}
loaded := loadTestEquipment(t, ctx, repos, "77")
if got := uint16(loaded.Entries["11"].RawEntry[10]) | uint16(loaded.Entries["11"].RawEntry[11])<<8; got != 20 {
t.Fatalf("raw durability = %d, want 20", got)
}
character, _, _ := repos.Character.Load(ctx, "77")
if character.Stats["gold"] != 9487 {
t.Fatalf("gold = %d, want 9487 (only the paid repair charged)", character.Stats["gold"])
}
// Manual repair (body[5]=0) must not consume the contract's free repair.
damaged = loadTestEquipment(t, ctx, repos, "77")
damagedEntry = damaged.Entries["11"]
damagedEntry.RawEntry[10] = 12
damagedEntry.RawEntry[11] = 0
damaged.Entries["11"] = damagedEntry
if err := repos.Equipment.Save(ctx, damaged); err != nil {
t.Fatal(err)
}
manual, err := owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, AccountID: "acc-1", SlotIndex: 11}, repairTestResolver(20, 6400, 20))
if err != nil {
t.Fatalf("manual repair with contract error = %v", err)
}
if manual.FreeRepair || manual.Cost != 512 || manual.UpdatedGold != 8975 {
t.Fatalf("manual result = %+v, want cost=512 gold=8975", manual)
}
}
func repairAllTestResolver(types map[int64]string) alignedcmd.RepairCostResolver {
return func(itemID int64) (alignedcmd.RepairCostEvidence, error) {
equipmentType, ok := types[itemID]
if !ok {
return alignedcmd.RepairCostEvidence{MaxDurability: -1, RepairCostRate: 0.08, QuickRepairRate: 1.5, UpgradeRates: []float64{1, 1, 1}}, nil
}
return alignedcmd.RepairCostEvidence{
EquipmentType: equipmentType,
MaxDurability: 20,
RepairPrice: 6400,
Grade: 20,
RepairCostRate: 0.08,
QuickRepairRate: 1.5,
UpgradeRates: []float64{1, 1, 1},
}, nil
}
}
func TestOwnerRepairAllRepairsEquippedAndQuickbarWithTypeFilter(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 5000}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {SlotIndex: 12, ItemID: 700, RawEntry: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0}}, // weapon, damaged
"14": {SlotIndex: 14, ItemID: 701, RawEntry: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 0}}, // coat, damaged
"13": {SlotIndex: 13, ItemID: 702, RawEntry: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 0}}, // title, damaged but ineligible
"15": {SlotIndex: 15, ItemID: 703, RawEntry: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 0}}, // shoulder, full
},
})
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:4": {ItemID: 704, Count: 1, Extra: map[string]string{"durability": "10"}},
"0:9": {ItemID: 705, Count: 1, Extra: map[string]string{"durability": "5"}},
},
})
resolver := repairAllTestResolver(map[int64]string{
700: "[weapon]",
701: "[coat]",
702: "[title name]",
703: "[shoulder]",
704: "[amulet]",
705: "[weapon]",
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
// weapon 12 lost 8 -> 512; coat 14 lost 12 -> 768; amulet slot4 lost 10
// -> 640. Title is ineligible, shoulder is full, main slot 9 is outside
// the quickbar scan: total = 1920.
result, err := owner.RepairAll(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: -1}, resolver)
if err != nil {
t.Fatalf("RepairAll error = %v", err)
}
if !result.Changed || result.RepairedCount != 3 || result.Cost != 1920 || result.UpdatedGold != 3080 {
t.Fatalf("result = %+v, want count=3 cost=1920 gold=3080", result)
}
wantRepairedSlots := []RepairSlot{
{Equipped: true, SlotIndex: 12},
{Equipped: true, SlotIndex: 14},
{Equipped: false, SlotIndex: 4},
}
if !reflect.DeepEqual(result.RepairedSlots, wantRepairedSlots) {
t.Fatalf("repaired slots = %+v, want %+v", result.RepairedSlots, wantRepairedSlots)
}
equipment := loadTestEquipment(t, ctx, repos, "77")
for _, key := range []string{"12", "14"} {
entry := equipment.Entries[key]
if got := uint16(entry.RawEntry[10]) | uint16(entry.RawEntry[11])<<8; got != 20 {
t.Fatalf("equipped %s durability = %d, want 20", key, got)
}
}
if got := uint16(equipment.Entries["13"].RawEntry[10]) | uint16(equipment.Entries["13"].RawEntry[11])<<8; got != 1 {
t.Fatalf("title durability = %d, want untouched 1", got)
}
inventory := loadTestInventory(t, ctx, repos, "77")
if inventory.Slots["0:4"].Extra["durability"] != "20" {
t.Fatalf("quickbar slot 4 durability = %q, want 20", inventory.Slots["0:4"].Extra["durability"])
}
if inventory.Slots["0:9"].Extra["durability"] != "5" {
t.Fatalf("main slot 9 durability = %q, want untouched 5", inventory.Slots["0:9"].Extra["durability"])
}
character, _, _ := repos.Character.Load(ctx, "77")
if character.Stats["gold"] != result.UpdatedGold {
t.Fatalf("persisted gold = %d, want %d", character.Stats["gold"], result.UpdatedGold)
}
}
func TestOwnerRepairAllQuickRateAndFreeRepair(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc-1", Stats: map[string]int64{"gold": 1000}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {SlotIndex: 12, ItemID: 700, RawEntry: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 0}},
},
})
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc-1"}); err != nil {
t.Fatal(err)
}
resolver := repairAllTestResolver(map[int64]string{700: "[weapon]"})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
// Quick repair-all: 512 * 1.5 = 768.
quick, err := owner.RepairAll(ctx, RepairCommand{SelectedCharacterID: 77, AccountID: "acc-1", SlotIndex: -1, QuickRepair: true}, resolver)
if err != nil {
t.Fatalf("quick RepairAll error = %v", err)
}
if quick.Cost != 768 || quick.UpdatedGold != 232 || quick.RepairedCount != 1 {
t.Fatalf("quick result = %+v, want cost=768", quick)
}
// Damage again; with the devil contract the auto repair-all is free.
damaged := loadTestEquipment(t, ctx, repos, "77")
entry := damaged.Entries["12"]
entry.RawEntry[10] = 12
entry.RawEntry[11] = 0
damaged.Entries["12"] = entry
if err := repos.Equipment.Save(ctx, damaged); err != nil {
t.Fatal(err)
}
future := time.Now().Add(24 * time.Hour).Unix()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-1",
Metadata: map[string]string{"premium_expire_586": strconv.FormatInt(future, 10)},
}); err != nil {
t.Fatal(err)
}
free, err := owner.RepairAll(ctx, RepairCommand{SelectedCharacterID: 77, AccountID: "acc-1", SlotIndex: -1, AutoRepair: true}, resolver)
if err != nil {
t.Fatalf("free RepairAll error = %v", err)
}
if !free.FreeRepair || free.Cost != 0 || free.UpdatedGold != 232 || free.RepairedCount != 1 {
t.Fatalf("free result = %+v, want cost=0", free)
}
character, _, _ := repos.Character.Load(ctx, "77")
if character.Stats["gold"] != 232 {
t.Fatalf("gold = %d, want 232 (only the quick repair charged)", character.Stats["gold"])
}
}
func TestOwnerRepairAllNothingDamagedIsNoOp(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 777}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.RepairAll(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: -1}, repairAllTestResolver(nil))
if err != nil {
t.Fatalf("RepairAll error = %v", err)
}
if result.Changed || result.RepairedCount != 0 || result.Cost != 0 || result.UpdatedGold != 777 {
t.Fatalf("result = %+v, want no-op", result)
}
if len(result.RepairedSlots) != 0 {
t.Fatalf("no-op repaired slots = %+v, want none", result.RepairedSlots)
}
}