DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,501 @@
|
||||
package titlebook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
ListType byte = 100
|
||||
SlotBase int16 = 1000
|
||||
MaxPerCategory = 200
|
||||
|
||||
// TitleEquipmentSlot 是当前客户端 list-3(穿戴栏)里的称号槽位号。
|
||||
//
|
||||
// 依据 2026-09-18 玩家实测:从背包正常穿戴称号时客户端发的是
|
||||
// op19 MOVE_ITEMSPACE「源 list0:11 → 目标 list3:13」(packet_log 15:36:07),
|
||||
// 服务端按该槽位落库后小退重登依然在。称号簿的「右键使用」客户端只会本地穿上
|
||||
// (同一次操作里没有再发任何装备请求),所以服务端必须自己落到这个槽位。
|
||||
TitleEquipmentSlot int16 = 13
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("title book owner unavailable")
|
||||
ErrCharacterRequired = errors.New("selected character id required")
|
||||
ErrInventoryNotFound = errors.New("title book inventory not found")
|
||||
ErrSourceNotFound = errors.New("title book source item not found")
|
||||
ErrCategoryInvalid = errors.New("title book category or index is invalid")
|
||||
ErrCategoryFull = errors.New("title book category is full")
|
||||
ErrTargetFull = errors.New("title book target inventory is full")
|
||||
ErrEquipmentNotFound = errors.New("title book character equipment not found")
|
||||
)
|
||||
|
||||
// EquipResult 是「从称号簿直接使用(装备)」的结果。
|
||||
// 称号按官方语义从簿里移出(见 Equip 注释),所以调用方要刷新称号簿页。
|
||||
type EquipResult struct {
|
||||
CharacterID string
|
||||
ItemSpace int32
|
||||
ItemID int32
|
||||
Category int32
|
||||
BookIndex int32
|
||||
EquipmentSlot int16
|
||||
EquipmentEntry dnfrepo.EquipmentEntry
|
||||
// Replaced 为真表示原来穿着另一个称号,它被放回背包 ReturnSlot。
|
||||
Replaced bool
|
||||
ReturnSlot int16
|
||||
ReturnItem dnfrepo.ItemStack
|
||||
}
|
||||
|
||||
type PutCommand struct {
|
||||
SelectedCharacterID uint16
|
||||
ItemSpace int32
|
||||
SourceSlot int16
|
||||
ItemID int32
|
||||
Category int32
|
||||
BookIndex int32
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PutResult struct {
|
||||
CharacterID string
|
||||
ItemSpace int32
|
||||
SourceList byte
|
||||
SourceSlot int16
|
||||
ItemID int32
|
||||
Category int32
|
||||
BookIndex int32
|
||||
TargetSlot int16
|
||||
}
|
||||
|
||||
type GetCommand struct {
|
||||
SelectedCharacterID uint16
|
||||
ItemSpace int32
|
||||
SourceSlot int16
|
||||
// PreferredSlot 是客户端在 op413 里指定的背包落点:客户端会按回包里的槽位把物品
|
||||
// 画在那个格子上,所以能放下就必须用这个槽(放不下再退回第一个空格)。
|
||||
PreferredSlot int16
|
||||
ItemID int32
|
||||
Category int32
|
||||
BookIndex int32
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type GetResult struct {
|
||||
CharacterID string
|
||||
ItemSpace int32
|
||||
SourceSlot int16
|
||||
ItemID int32
|
||||
Category int32
|
||||
BookIndex int32
|
||||
TargetSlot int16
|
||||
TargetStack dnfrepo.ItemStack
|
||||
}
|
||||
|
||||
type Owner struct {
|
||||
settlements dnfrepo.CharacterSettlementUnitOfWork
|
||||
}
|
||||
|
||||
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
|
||||
if repos.Inventory == nil || repos.CharacterSettlement == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{settlements: repos.CharacterSettlement}, nil
|
||||
}
|
||||
|
||||
func (o *Owner) Put(ctx context.Context, cmd PutCommand) (PutResult, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return PutResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
if err := validateBookLocation(cmd.Category, cmd.BookIndex); err != nil {
|
||||
return PutResult{}, err
|
||||
}
|
||||
characterID, err := selectedCharacterKey(cmd.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return PutResult{}, err
|
||||
}
|
||||
var result PutResult
|
||||
err = o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
inventory, loadErr := loadInventory(ctx, tx.Inventory, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
sourceList := byte(cmd.ItemSpace)
|
||||
sourceKey := inventoryKey(sourceList, cmd.SourceSlot)
|
||||
sourceStack, found := inventory.Slots[sourceKey]
|
||||
if !found || sourceStack.ItemID != int64(cmd.ItemID) || sourceStack.ItemID <= 0 {
|
||||
return fmt.Errorf(
|
||||
"%w: list=%d slot=%d item=%d",
|
||||
ErrSourceNotFound,
|
||||
sourceList,
|
||||
cmd.SourceSlot,
|
||||
cmd.ItemID,
|
||||
)
|
||||
}
|
||||
targetSlot, bookIndex, found := firstBookSlot(inventory.Slots, cmd.Category, cmd.BookIndex)
|
||||
if !found {
|
||||
return fmt.Errorf("%w: category=%d", ErrCategoryFull, cmd.Category)
|
||||
}
|
||||
|
||||
delete(inventory.Slots, sourceKey)
|
||||
titleStack := sourceStack
|
||||
titleStack.Count = 1
|
||||
if titleStack.Extra == nil {
|
||||
titleStack.Extra = make(map[string]string, 4)
|
||||
}
|
||||
titleStack.Extra["title_book_category"] = strconv.FormatInt(int64(cmd.Category), 10)
|
||||
titleStack.Extra["title_book_index"] = strconv.FormatInt(int64(bookIndex), 10)
|
||||
titleStack.Extra["source"] = "title_book_put"
|
||||
inventory.Slots[inventoryKey(ListType, targetSlot)] = titleStack
|
||||
inventory.UpdatedAt = mutationTime(cmd.UpdatedAt)
|
||||
if saveErr := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
result = PutResult{
|
||||
CharacterID: characterID,
|
||||
ItemSpace: cmd.ItemSpace,
|
||||
SourceList: sourceList,
|
||||
SourceSlot: cmd.SourceSlot,
|
||||
ItemID: cmd.ItemID,
|
||||
Category: cmd.Category,
|
||||
BookIndex: bookIndex,
|
||||
TargetSlot: targetSlot,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (o *Owner) Get(ctx context.Context, cmd GetCommand) (GetResult, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return GetResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
if err := validateBookLocation(cmd.Category, cmd.BookIndex); err != nil {
|
||||
return GetResult{}, err
|
||||
}
|
||||
characterID, err := selectedCharacterKey(cmd.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return GetResult{}, err
|
||||
}
|
||||
var result GetResult
|
||||
err = o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
inventory, loadErr := loadInventory(ctx, tx.Inventory, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
sourceKey := BookSlotKey(cmd.Category, cmd.BookIndex)
|
||||
titleStack, found := inventory.Slots[sourceKey]
|
||||
if !found || titleStack.ItemID <= 0 ||
|
||||
(cmd.ItemID > 0 && titleStack.ItemID != int64(cmd.ItemID)) {
|
||||
return fmt.Errorf(
|
||||
"%w: category=%d index=%d item=%d",
|
||||
ErrSourceNotFound,
|
||||
cmd.Category,
|
||||
cmd.BookIndex,
|
||||
cmd.ItemID,
|
||||
)
|
||||
}
|
||||
targetSlot, found := preferredMainInventorySlot(inventory.Slots, cmd.PreferredSlot)
|
||||
if !found {
|
||||
return ErrTargetFull
|
||||
}
|
||||
|
||||
delete(inventory.Slots, sourceKey)
|
||||
targetStack := titleStack
|
||||
targetStack.Count = 1
|
||||
if targetStack.Extra == nil {
|
||||
targetStack.Extra = make(map[string]string, 2)
|
||||
}
|
||||
targetStack.Extra["source"] = "title_book_get"
|
||||
inventory.Slots[inventoryKey(dnfrepo.MainInventoryListType, targetSlot)] = targetStack
|
||||
inventory.UpdatedAt = mutationTime(cmd.UpdatedAt)
|
||||
if saveErr := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
result = GetResult{
|
||||
CharacterID: characterID,
|
||||
ItemSpace: cmd.ItemSpace,
|
||||
SourceSlot: cmd.SourceSlot,
|
||||
ItemID: int32(titleStack.ItemID),
|
||||
Category: cmd.Category,
|
||||
BookIndex: cmd.BookIndex,
|
||||
TargetSlot: targetSlot,
|
||||
TargetStack: targetStack,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func BookSlotKey(category, index int32) string {
|
||||
return inventoryKey(ListType, int16(category)*SlotBase+int16(index))
|
||||
}
|
||||
|
||||
// Equip 实现「称号簿右键直接使用」:把簿里登记的称号**穿到身上**并落库,
|
||||
// 而不是像 Get 那样塞回背包。
|
||||
//
|
||||
// 背景(2026-09-18 玩家报障):客户端在 op413 之后会自己把称号穿上,但服务端
|
||||
// 只把物品放回背包,于是出现「背包一个、穿戴栏一个」,而穿戴栏那个因为服务端
|
||||
// 从未落库,小退重登就消失。这里改为:直接写 list-3 称号槽(槽位号见
|
||||
// TitleEquipmentSlot),登录时随装备一起回放,所以重登后仍在。
|
||||
//
|
||||
// 称号簿条目按官方语义**移动**(穿到身上就不再留在簿里),不是复制。依据官方
|
||||
// 参考服务端 df_game_r(1):CTitleBook::getTitle(0x086425C4) 在目标物品空间是
|
||||
// 装备(space=3)时依次调用 CInventory::tryInsertItemIntoEquipment(0x8501AFA)
|
||||
// → CTitleBook::_deleteTitle(0x8641D50) → CInventory::insertItemIntoEquipment
|
||||
// (0x8502344),也就是「先穿上、再把条目从簿里删掉」,最后只调
|
||||
// CUser::send_equip(0x865DD14)——而 send_equip 对槽位 13(称号)直接 return
|
||||
// (它只广播槽位 0..0xB 与 0x16 的外观变化)。所以客户端是凭 413 回包里的
|
||||
// (space=3, slot=13) 自己完成本地穿戴的,服务端不需要也不应该再补发物品列表;
|
||||
// 条目留在簿里只会让玩家同时看到「簿里一个、身上一个」。
|
||||
//
|
||||
// 若身上已经穿着**另一个**称号,则把它放回背包第一个空位(不这么做就会凭空
|
||||
// 丢东西),并把这件事报给调用方用于刷新。
|
||||
func (o *Owner) Equip(ctx context.Context, cmd GetCommand) (EquipResult, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return EquipResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
if err := validateBookLocation(cmd.Category, cmd.BookIndex); err != nil {
|
||||
return EquipResult{}, err
|
||||
}
|
||||
characterID, err := selectedCharacterKey(cmd.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return EquipResult{}, err
|
||||
}
|
||||
var result EquipResult
|
||||
err = o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
inventory, loadErr := loadInventory(ctx, tx.Inventory, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
sourceKey := BookSlotKey(cmd.Category, cmd.BookIndex)
|
||||
titleStack, found := inventory.Slots[sourceKey]
|
||||
if !found || titleStack.ItemID <= 0 ||
|
||||
(cmd.ItemID > 0 && titleStack.ItemID != int64(cmd.ItemID)) {
|
||||
return fmt.Errorf(
|
||||
"%w: category=%d index=%d item=%d",
|
||||
ErrSourceNotFound,
|
||||
cmd.Category,
|
||||
cmd.BookIndex,
|
||||
cmd.ItemID,
|
||||
)
|
||||
}
|
||||
equipment, equipFound, loadErr := tx.Equipment.Load(ctx, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !equipFound {
|
||||
equipment = dnfrepo.EquipmentRecord{CharacterID: characterID}
|
||||
}
|
||||
if equipment.Entries == nil {
|
||||
equipment.Entries = make(map[string]dnfrepo.EquipmentEntry, 1)
|
||||
}
|
||||
slotKey := strconv.FormatInt(int64(TitleEquipmentSlot), 10)
|
||||
|
||||
// 移动语义:称号离开称号簿,同一件东西不会同时存在于簿里和身上。
|
||||
delete(inventory.Slots, sourceKey)
|
||||
|
||||
previous, occupied := equipment.Entries[slotKey]
|
||||
if occupied && previous.ItemID != 0 && previous.ItemID != titleStack.ItemID {
|
||||
returnSlot, slotFound := firstMainInventorySlot(inventory.Slots)
|
||||
if !slotFound {
|
||||
return ErrTargetFull
|
||||
}
|
||||
returned := dnfrepo.ItemStack{
|
||||
ItemID: previous.ItemID,
|
||||
Count: 1,
|
||||
Bind: previous.Bind,
|
||||
ExpireAt: previous.ExpireAt,
|
||||
RawEntry: rewriteEquipmentRowSlot(previous.RawEntry, returnSlot),
|
||||
Extra: cloneTitleBookExtra(previous.Extra),
|
||||
}
|
||||
if returned.Extra == nil {
|
||||
returned.Extra = make(map[string]string, 2)
|
||||
}
|
||||
delete(returned.Extra, "equipment_slot")
|
||||
delete(returned.Extra, "equipped_slot")
|
||||
returned.Extra["source"] = "title_book_equip_replaced"
|
||||
inventory.Slots[inventoryKey(dnfrepo.MainInventoryListType, returnSlot)] = returned
|
||||
result.Replaced = true
|
||||
result.ReturnSlot = returnSlot
|
||||
result.ReturnItem = returned
|
||||
}
|
||||
|
||||
// 【2026-09-28 修复:礼白666 型卡死】achievement.grantTitle 把称号写进称号簿时
|
||||
// **不写 RawEntry**。这里如果原样把空 raw 搬进装备行,装备行的 RawEntry 就是空的,
|
||||
// 而 equip 的 unequip() 开头就有一道 `len(source.RawEntry) == 0 →
|
||||
// ErrMoveRawEntryMissing`,于是这个称号从此摘不下来(玩家报障:称号展示栏卡了
|
||||
// 一个成就称号)。簿里没有行数据时,按称号的构造规则补一行 43 字节的。
|
||||
raw := rewriteEquipmentRowSlot(titleStack.RawEntry, TitleEquipmentSlot)
|
||||
// 2026-09-28 线上回退:补行本身没被证伪,但它和上面那三行一起上线时出现了回归,
|
||||
// 先关掉保持行为与改动前一致。
|
||||
if false && len(raw) == 0 && titleStack.ItemID > 0 && titleStack.ItemID <= int64(^uint32(0)) {
|
||||
raw = EquipmentRawEntry(titleStack.ItemID, TitleEquipmentSlot)
|
||||
}
|
||||
extra := cloneTitleBookExtra(titleStack.Extra)
|
||||
if extra == nil {
|
||||
extra = make(map[string]string, 8)
|
||||
}
|
||||
extra["equipment_slot"] = slotKey
|
||||
extra["equipped_slot"] = slotKey
|
||||
extra["item_kind"] = "equipment"
|
||||
extra["source"] = "title_book_equip"
|
||||
extra["title_book_category"] = strconv.FormatInt(int64(cmd.Category), 10)
|
||||
extra["title_book_index"] = strconv.FormatInt(int64(cmd.BookIndex), 10)
|
||||
entry := dnfrepo.EquipmentEntry{
|
||||
SlotIndex: TitleEquipmentSlot,
|
||||
ItemID: titleStack.ItemID,
|
||||
Bind: titleStack.Bind,
|
||||
ExpireAt: titleStack.ExpireAt,
|
||||
RawEntry: raw,
|
||||
Extra: extra,
|
||||
}
|
||||
equipment.Entries[slotKey] = entry
|
||||
equipment.UpdatedAt = mutationTime(cmd.UpdatedAt)
|
||||
|
||||
// 称号簿条目被删除(或旧称号被换回背包)都属于背包变更,必须落库。
|
||||
inventory.UpdatedAt = mutationTime(cmd.UpdatedAt)
|
||||
if saveErr := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
if saveErr := dnfrepo.SaveEquipmentFields(ctx, tx.Equipment, equipment, dnfrepo.EquipmentFieldEntries); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
result.CharacterID = characterID
|
||||
result.ItemSpace = cmd.ItemSpace
|
||||
result.ItemID = int32(titleStack.ItemID)
|
||||
result.Category = cmd.Category
|
||||
result.BookIndex = cmd.BookIndex
|
||||
result.EquipmentSlot = TitleEquipmentSlot
|
||||
result.EquipmentEntry = entry
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
// rewriteEquipmentRowSlot 把一行 0x77 物品行的槽位字段改成目标槽,并返回副本。
|
||||
// 称号在背包里存的是 list-0 行,穿到 list-3 后行首的槽位号必须跟着变,客户端才
|
||||
// 会把它画在穿戴栏的称号格上(实测落库行首是 0D00 = 13)。
|
||||
func rewriteEquipmentRowSlot(raw []byte, slot int16) []byte {
|
||||
out := append([]byte(nil), raw...)
|
||||
if len(out) >= 2 {
|
||||
out[0] = byte(slot)
|
||||
out[1] = byte(uint16(slot) >> 8)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EquipmentRawEntry 按当前客户端的 43 字节装备行构造规则造一行称号装备数据:
|
||||
// 字节 0 是角色装备槽,+1 是 PVF 物品号,+5 是构造器要求的 create 标量 1,其余为 0。
|
||||
//
|
||||
// 用途:achievement.grantTitle 入簿时不写 RawEntry,穿到装备栏时若不补行,装备行
|
||||
// 就会是空的,equip 的 unequip() 会以 ErrMoveRawEntryMissing 把它锁死(玩家报障
|
||||
// "称号摘不下来")。equip 包的 buildCurrentTitleEquipmentRawEntry 现在直接转调这里,
|
||||
// 两份实现不会再漂移。
|
||||
func EquipmentRawEntry(itemID int64, slot int16) []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
|
||||
}
|
||||
|
||||
func cloneTitleBookExtra(extra map[string]string) map[string]string {
|
||||
if len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(extra)+4)
|
||||
for key, value := range extra {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstBookSlot(items map[string]dnfrepo.ItemStack, category, preferred int32) (int16, int32, bool) {
|
||||
if preferred >= 0 && preferred < MaxPerCategory {
|
||||
slot := int16(category)*SlotBase + int16(preferred)
|
||||
if _, occupied := items[inventoryKey(ListType, slot)]; !occupied {
|
||||
return slot, preferred, true
|
||||
}
|
||||
}
|
||||
for index := int32(0); index < MaxPerCategory; index++ {
|
||||
slot := int16(category)*SlotBase + int16(index)
|
||||
if _, occupied := items[inventoryKey(ListType, slot)]; !occupied {
|
||||
return slot, index, true
|
||||
}
|
||||
}
|
||||
return -1, -1, false
|
||||
}
|
||||
|
||||
func firstMainInventorySlot(items map[string]dnfrepo.ItemStack) (int16, bool) {
|
||||
for slot := int16(9); slot <= 64; slot++ {
|
||||
if _, occupied := items[inventoryKey(dnfrepo.MainInventoryListType, slot)]; !occupied {
|
||||
return slot, true
|
||||
}
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
|
||||
// preferredMainInventorySlot 优先用客户端指定的落点,占用时退回第一个空格。
|
||||
//
|
||||
// 依据 2026-09-18 玩家报障:称号从簿里「拿出来」时客户端会发
|
||||
// op413 (space=0, slot=<期望格子>),并且**按回包里的槽位**把物品画在背包那个格子上。
|
||||
// 旧实现忽略这个槽位、ack 还回错了空间(把装备槽 13 报给背包请求),于是玩家看到
|
||||
// 「背包里有一个」,但服务端实际把它穿上了,小退重登就变成「自动穿戴」。
|
||||
func preferredMainInventorySlot(items map[string]dnfrepo.ItemStack, preferred int16) (int16, bool) {
|
||||
if preferred >= 9 && preferred <= 64 {
|
||||
if _, occupied := items[inventoryKey(dnfrepo.MainInventoryListType, preferred)]; !occupied {
|
||||
return preferred, true
|
||||
}
|
||||
}
|
||||
return firstMainInventorySlot(items)
|
||||
}
|
||||
|
||||
func inventoryKey(listType byte, slot int16) string {
|
||||
return fmt.Sprintf("%d:%d", listType, slot)
|
||||
}
|
||||
|
||||
func mutationTime(value time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
|
||||
func selectedCharacterKey(selectedCharacterID uint16) (string, error) {
|
||||
if selectedCharacterID == 0 {
|
||||
return "", ErrCharacterRequired
|
||||
}
|
||||
return strconv.FormatUint(uint64(selectedCharacterID), 10), nil
|
||||
}
|
||||
|
||||
func loadInventory(ctx context.Context, repository dnfrepo.InventoryRepository, characterID string) (dnfrepo.InventoryRecord, error) {
|
||||
if repository == nil {
|
||||
return dnfrepo.InventoryRecord{}, ErrOwnerUnavailable
|
||||
}
|
||||
inventory, found, err := repository.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return dnfrepo.InventoryRecord{}, err
|
||||
}
|
||||
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return dnfrepo.InventoryRecord{}, fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
if inventory.Slots == nil {
|
||||
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
return inventory, nil
|
||||
}
|
||||
|
||||
func validateBookLocation(category, index int32) error {
|
||||
if category < 0 || category >= 5 || index < 0 || index >= MaxPerCategory {
|
||||
return fmt.Errorf("%w: category=%d index=%d", ErrCategoryInvalid, category, index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user