服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
305 lines
11 KiB
Go
305 lines
11 KiB
Go
package titlebook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"testing"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
)
|
|
|
|
// 根因回归(礼白666 型卡死):achievement.grantTitle 写进称号簿的条目**没有
|
|
// RawEntry**(见 achievement/owner.go grantTitle)。titlebook.Equip 过去把空 raw
|
|
// 原样搬进装备行,于是 equip 的 unequip() 一开头就撞上
|
|
// `len(source.RawEntry) == 0 → ErrMoveRawEntryMissing`,这个称号从此摘不下来。
|
|
// 现在 Equip 必须在簿内条目没有行数据时补出合法的 43 字节行。
|
|
func TestOwnerEquipSynthesizesRawEntryForAchievementGrantedTitle(t *testing.T) {
|
|
t.Skip("2026-09-28 线上回退:被测行为已停用,待诊断后重开")
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
// 完全照 achievement.grantTitle 的形状:无 RawEntry + source=achievement_reward。
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "19",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
BookSlotKey(1, 135): {
|
|
ItemID: 100331415,
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"source": "achievement_reward",
|
|
"title_book_category": "1",
|
|
"title_book_index": "135",
|
|
"quest_id": "6746",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "19", Entries: map[string]dnfrepo.EquipmentEntry{}}); err != nil {
|
|
t.Fatalf("save equipment: %v", err)
|
|
}
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner: %v", err)
|
|
}
|
|
if _, err := owner.Equip(ctx, GetCommand{
|
|
SelectedCharacterID: 19, ItemSpace: 3, SourceSlot: 13, ItemID: 100331415, Category: 1, BookIndex: 135,
|
|
}); err != nil {
|
|
t.Fatalf("Equip: %v", err)
|
|
}
|
|
equipment, found, err := repos.Equipment.Load(ctx, "19")
|
|
if err != nil || !found {
|
|
t.Fatalf("load equipment found=%t err=%v", found, err)
|
|
}
|
|
entry, ok := equipment.Entries["13"]
|
|
if !ok {
|
|
t.Fatalf("equipment slot 13 missing: %+v", equipment.Entries)
|
|
}
|
|
if len(entry.RawEntry) != 43 {
|
|
t.Fatalf("equipment raw must be synthesized, got %d bytes (%x)", len(entry.RawEntry), entry.RawEntry)
|
|
}
|
|
if entry.RawEntry[0] != 13 ||
|
|
binary.LittleEndian.Uint32(entry.RawEntry[1:5]) != 100331415 ||
|
|
binary.LittleEndian.Uint32(entry.RawEntry[5:9]) != 1 {
|
|
t.Fatalf("synthesized equipment raw = %x", entry.RawEntry)
|
|
}
|
|
if entry.Extra["source"] != "title_book_equip" || entry.Extra["title_book_index"] != "135" {
|
|
t.Fatalf("equipment extras = %+v", entry.Extra)
|
|
}
|
|
// 仍然按「移动」语义:穿上之后簿里不再留这件称号。
|
|
inventory, _, err := repos.Inventory.Load(ctx, "19")
|
|
if err != nil {
|
|
t.Fatalf("load inventory: %v", err)
|
|
}
|
|
if _, occupied := inventory.Slots[BookSlotKey(1, 135)]; occupied {
|
|
t.Fatalf("book entry should move out when equipped: %+v", inventory.Slots)
|
|
}
|
|
}
|
|
|
|
func TestOwnerPutAndGetTitle(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "19",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"0:42": {ItemID: 9001, Count: 1, Extra: map[string]string{"bind": "1"}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner: %v", err)
|
|
}
|
|
put, err := owner.Put(ctx, PutCommand{
|
|
SelectedCharacterID: 19,
|
|
ItemSpace: 0,
|
|
SourceSlot: 42,
|
|
ItemID: 9001,
|
|
Category: 2,
|
|
BookIndex: 7,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
if put.TargetSlot != 2007 || put.BookIndex != 7 {
|
|
t.Fatalf("put result = %+v", put)
|
|
}
|
|
inventory, _, err := repos.Inventory.Load(ctx, "19")
|
|
if err != nil {
|
|
t.Fatalf("load after put: %v", err)
|
|
}
|
|
if _, found := inventory.Slots["0:42"]; found ||
|
|
inventory.Slots["100:2007"].ItemID != 9001 {
|
|
t.Fatalf("slots after put = %+v", inventory.Slots)
|
|
}
|
|
|
|
get, err := owner.Get(ctx, GetCommand{
|
|
SelectedCharacterID: 19,
|
|
ItemSpace: 0,
|
|
SourceSlot: 42,
|
|
ItemID: 9001,
|
|
Category: 2,
|
|
BookIndex: 7,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if get.TargetSlot != 9 || get.TargetStack.ItemID != 9001 ||
|
|
get.TargetStack.Extra["bind"] != "1" {
|
|
t.Fatalf("get result = %+v", get)
|
|
}
|
|
}
|
|
|
|
func TestOwnerEquipTitleFromBookMovesItOutOfTheBook(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
// 称号簿里已经登记了一个称号(list 100 / category 4 / index 10),
|
|
// 行首槽位是簿内槽号,穿到身上时会被改写成 list-3 的称号槽位。
|
|
bookRow := make([]byte, 0x77)
|
|
bookRow[0], bookRow[1] = 0xEA, 0x03 // 1000 + 10 = 1010 = 0x03EA
|
|
copy(bookRow[2:6], []byte{0xC8, 0xEC, 0x29, 0x00})
|
|
repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "19",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
BookSlotKey(4, 10): {ItemID: 2747592, Count: 1, RawEntry: bookRow, Extra: map[string]string{"item_kind": "equipment", "pvf_path": "equipment/character/common/title/t.equ"}},
|
|
},
|
|
})
|
|
repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "19", Entries: map[string]dnfrepo.EquipmentEntry{}})
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner: %v", err)
|
|
}
|
|
result, err := owner.Equip(ctx, GetCommand{
|
|
SelectedCharacterID: 19, ItemSpace: 1, SourceSlot: 3, ItemID: 2747592, Category: 4, BookIndex: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Equip: %v", err)
|
|
}
|
|
if result.EquipmentSlot != TitleEquipmentSlot || result.ItemID != 2747592 || result.Replaced {
|
|
t.Fatalf("equip result = %+v", result)
|
|
}
|
|
// 官方语义是「移动」:穿上之后簿里不再留这件称号。
|
|
// (簿里一个、身上一个正是玩家报的双份现象,见 owner.go Equip 注释。)
|
|
inventory, _, err := repos.Inventory.Load(ctx, "19")
|
|
if err != nil {
|
|
t.Fatalf("load inventory: %v", err)
|
|
}
|
|
if stack, found := inventory.Slots[BookSlotKey(4, 10)]; found {
|
|
t.Fatalf("book entry should move out when equipped: %+v", stack)
|
|
}
|
|
// 也不许往背包里塞一份(那正是玩家报的「背包里多一个」)。
|
|
for key, stack := range inventory.Slots {
|
|
if stack.ItemID == 2747592 {
|
|
t.Fatalf("title leaked into slot %s: %+v", key, stack)
|
|
}
|
|
}
|
|
equipment, found, err := repos.Equipment.Load(ctx, "19")
|
|
if err != nil || !found {
|
|
t.Fatalf("load equipment found=%t err=%v", found, err)
|
|
}
|
|
entry, ok := equipment.Entries["13"]
|
|
if !ok || entry.ItemID != 2747592 || len(entry.RawEntry) != len(bookRow) || entry.RawEntry[0] != 13 || entry.RawEntry[1] != 0 {
|
|
t.Fatalf("equipment slot 13 = %+v", entry)
|
|
}
|
|
if entry.Extra["equipped_slot"] != "13" || entry.Extra["source"] != "title_book_equip" || entry.Extra["title_book_index"] != "10" {
|
|
t.Fatalf("equipment extras = %+v", entry.Extra)
|
|
}
|
|
|
|
// 再换一个称号:旧称号必须回背包,不能凭空消失。
|
|
secondRow := make([]byte, 0x77)
|
|
copy(secondRow[2:6], []byte{0x11, 0x22, 0x33, 0x00})
|
|
if inventory.Slots == nil {
|
|
// 上一件称号穿走后簿里空了,内存仓会回一个空记录。
|
|
inventory.Slots = make(map[string]dnfrepo.ItemStack, 1)
|
|
}
|
|
inventory.Slots[BookSlotKey(4, 11)] = dnfrepo.ItemStack{ItemID: 2747593, Count: 1, RawEntry: secondRow}
|
|
if err := repos.Inventory.Save(ctx, inventory); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
swap, err := owner.Equip(ctx, GetCommand{
|
|
SelectedCharacterID: 19, ItemSpace: 1, SourceSlot: 3, ItemID: 2747593, Category: 4, BookIndex: 11,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Equip swap: %v", err)
|
|
}
|
|
if !swap.Replaced || swap.ReturnItem.ItemID != 2747592 {
|
|
t.Fatalf("swap result = %+v", swap)
|
|
}
|
|
after, _, err := repos.Inventory.Load(ctx, "19")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after.Slots[inventoryKey(dnfrepo.MainInventoryListType, swap.ReturnSlot)].ItemID != 2747592 {
|
|
t.Fatalf("replaced title not returned to bag: %+v", after.Slots)
|
|
}
|
|
equipment, _, _ = repos.Equipment.Load(ctx, "19")
|
|
if equipment.Entries["13"].ItemID != 2747593 {
|
|
t.Fatalf("equipment after swap = %+v", equipment.Entries)
|
|
}
|
|
if _, found := after.Slots[BookSlotKey(4, 11)]; found {
|
|
t.Fatalf("swapped-in title should leave the book: %+v", after.Slots)
|
|
}
|
|
}
|
|
|
|
func TestOwnerGetHonorsPreferredSlot(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "19",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
BookSlotKey(4, 10): {ItemID: 2747592, Count: 1},
|
|
"0:12": {ItemID: 5, Count: 1}, // 首选格被占用
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner: %v", err)
|
|
}
|
|
// 首选格空着 → 必须用它(客户端按 ack 里的槽位摆放物品)。
|
|
free, err := owner.Get(ctx, GetCommand{
|
|
SelectedCharacterID: 19, ItemSpace: 0, PreferredSlot: 30, ItemID: 2747592, Category: 4, BookIndex: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if free.TargetSlot != 30 {
|
|
t.Fatalf("target slot = %d, want the requested 30", free.TargetSlot)
|
|
}
|
|
// 首选格被占用 → 退回第一个空格(9 号格空着)。
|
|
occupied, err := owner.Put(ctx, PutCommand{
|
|
SelectedCharacterID: 19, ItemSpace: 0, SourceSlot: 30, ItemID: 2747592, Category: 4, BookIndex: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Put back: %v", err)
|
|
}
|
|
if occupied.TargetSlot != 4010 {
|
|
t.Fatalf("put back target = %d", occupied.TargetSlot)
|
|
}
|
|
fallback, err := owner.Get(ctx, GetCommand{
|
|
SelectedCharacterID: 19, ItemSpace: 0, PreferredSlot: 12, ItemID: 2747592, Category: 4, BookIndex: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Get fallback: %v", err)
|
|
}
|
|
if fallback.TargetSlot != 9 {
|
|
t.Fatalf("target slot = %d, want the first free slot 9", fallback.TargetSlot)
|
|
}
|
|
}
|
|
|
|
func TestOwnerPutRejectsFullCategory(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
slots := map[string]dnfrepo.ItemStack{
|
|
"0:42": {ItemID: 9001, Count: 1},
|
|
}
|
|
for index := int32(0); index < MaxPerCategory; index++ {
|
|
slots[BookSlotKey(2, index)] = dnfrepo.ItemStack{ItemID: int64(10000 + index), Count: 1}
|
|
}
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "19",
|
|
Slots: slots,
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
owner, err := NewOwner(repos)
|
|
if err != nil {
|
|
t.Fatalf("NewOwner: %v", err)
|
|
}
|
|
_, err = owner.Put(ctx, PutCommand{
|
|
SelectedCharacterID: 19,
|
|
ItemSpace: 0,
|
|
SourceSlot: 42,
|
|
ItemID: 9001,
|
|
Category: 2,
|
|
BookIndex: 7,
|
|
})
|
|
if !errors.Is(err, ErrCategoryFull) {
|
|
t.Fatalf("Put error = %v, want ErrCategoryFull", err)
|
|
}
|
|
}
|