DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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
}