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

502 lines
18 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.
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
}