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,103 @@
// 本文件定义时装和称号命令计划。
// 计划只记录客户端意图和 owner 预检所需字段,不直接修改外观、称号簿或物品资产。
package avatartitle
import (
"fmt"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// Command 是 avatartitle handler 交给 avatar/title owner 的最小命令计划。
type Command struct {
Operation string
AccountID string
SelectedCharacterID uint16
ConsumeSlot int16
Slot1 int16
Slot2 int16
RequestItemID int32
TargetSlot int16
TargetItemID int32
MaterialSlot int16
Emblems []EmblemApply
EmblemCount int
ItemSpaceRaw int32
TitleSlot int16
TitleItemID int32
TitleCategory int32
TitleIndex int32
NeedsOwner string
}
// NewCompoundCommand 构造时装合成命令计划。
func NewCompoundCommand(req alignedcmd.Request, parsed CompoundAvatarRequest) Command {
return Command{
Operation: "compound_avatar",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
ConsumeSlot: parsed.ConsumeSlot,
Slot1: parsed.Slot1,
Slot2: parsed.Slot2,
RequestItemID: parsed.RequestItemID,
NeedsOwner: "inventory owner + avatar owner + mutation id + appearance/USERINFO/NOTI order",
}
}
// NewEmblemCommand 构造时装徽章镶嵌命令计划。
func NewEmblemCommand(req alignedcmd.Request, parsed AvatarEmblemRequest) Command {
emblems := append([]EmblemApply(nil), parsed.Emblems...)
return Command{
Operation: "use_emblem",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
TargetSlot: parsed.TargetSlot,
TargetItemID: parsed.TargetItemID,
Emblems: emblems,
EmblemCount: len(emblems),
NeedsOwner: "inventory owner + avatar owner + socket persistence + appearance/USERINFO/NOTI order",
}
}
// NewSocketCommand 构造时装开孔命令计划。
func NewSocketCommand(req alignedcmd.Request, parsed AvatarSocketRequest) Command {
return Command{
Operation: "add_avatar_socket",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
TargetSlot: parsed.TargetSlot,
TargetItemID: parsed.TargetItemID,
MaterialSlot: parsed.MaterialSlot,
NeedsOwner: "inventory owner + avatar owner + socket persistence + appearance/USERINFO/NOTI order",
}
}
// NewTitleBookCommand 构造称号簿放入或取出命令计划。
func NewTitleBookCommand(req alignedcmd.Request, operation string, parsed TitleBookRequest) Command {
return Command{
Operation: operation,
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
ItemSpaceRaw: parsed.ItemSpaceRaw,
TitleSlot: parsed.Slot,
TitleItemID: parsed.ItemID,
TitleCategory: parsed.Category,
TitleIndex: parsed.Index,
NeedsOwner: "inventory owner + title owner + mutation id + title book/USERINFO/NOTI order",
}
}
func (c Command) String() string {
base := fmt.Sprintf("account=%q char=%d", c.AccountID, c.SelectedCharacterID)
switch c.Operation {
case "compound_avatar":
return fmt.Sprintf("%s consume=%d slot1=%d slot2=%d requestItem=%d needs=%s", base, c.ConsumeSlot, c.Slot1, c.Slot2, c.RequestItemID, c.NeedsOwner)
case "use_emblem":
return fmt.Sprintf("%s target=(%d,%d) emblems=%d needs=%s", base, c.TargetSlot, c.TargetItemID, c.EmblemCount, c.NeedsOwner)
case "add_avatar_socket":
return fmt.Sprintf("%s target=(%d,%d) material=%d needs=%s", base, c.TargetSlot, c.TargetItemID, c.MaterialSlot, c.NeedsOwner)
default:
return fmt.Sprintf("%s space=%d slot=%d item=%d category=%d index=%d needs=%s", base, c.ItemSpaceRaw, c.TitleSlot, c.TitleItemID, c.TitleCategory, c.TitleIndex, c.NeedsOwner)
}
}
@@ -0,0 +1,2 @@
// Package avatartitle 承接 DNF 时装、徽章和称号簿类 C2S 协议。
package avatartitle
@@ -0,0 +1,130 @@
// 本文件负责时装和称号命令的协议分发。
// 时装合成、徽章、开孔和称号簿都会改动物品与外观状态,当前只允许 owner 只读预检,不开放成功 ACK。
package avatartitle
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
type handler struct{}
// NewHandler 创建时装称号协议处理器。
func NewHandler() alignedcmd.Handler {
return handler{}
}
// Domain 返回时装称号业务域。
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainAvatarTitle
}
// Handle 解析时装称号请求,并在外观、物品刷新链路闭合前禁止成功 ACK。
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketCompoundAvatar:
parsed, err := DecodeCompoundAvatarRequest(req.Body)
cmd := NewCompoundCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketUseEmblem:
parsed, err := DecodeAvatarEmblemRequest(req.Body)
cmd := NewEmblemCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketAddAvatarSocket:
parsed, err := DecodeAvatarSocketRequest(req.Body)
cmd := NewSocketCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketTitleBookPut:
parsed, err := DecodeTitleBookRequest(req.Body)
cmd := NewTitleBookCommand(req, "title_book_put", parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketTitleBookGet:
parsed, err := DecodeTitleBookRequest(req.Body)
cmd := NewTitleBookCommand(req, "title_book_get", parsed)
return applyOwner(ctx, req, cmd, err), nil
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("avatar_title 模块未注册 opcode %d", req.Opcode),
}, nil
}
}
func applyOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd, err)
}
result, err := owner.Plan(ctx, cmd)
if err != nil {
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
}
return ownerPlannedResult(cmd, result)
}
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("%s 请求体解析失败:%v;禁止回成功包", operation, err),
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("已解析 %s:%s;外观、称号簿和物品刷新顺序未闭合,禁止回包", operation, summary),
}
}
func ownerBlockedResult(cmd Command, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("已解析 %s:%s;avatar/title owner 预检未通过:%v;禁止回成功包", cmd.Operation, cmd.String(), err),
}
}
func ownerPlannedResult(cmd Command, result PlanResult) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"已解析 %s:%s;avatartitle owner verified account=%s char=%s inventory=%t equipmentKnown=%t equipEntries=%d avatarSources=%d/%d targetFound=%t targetItem=%d materialFound=%t materialItem=%d emblemMaterials=%d/%d titleFound=%t title=(list=%d slot=%d item=%d category=%d index=%d) requestedOutput=%d;外观/称号簿/USERINFO 刷新顺序未闭合,禁止成功 ACK",
cmd.Operation,
cmd.String(),
result.AccountID,
result.CharacterID,
result.InventoryKnown,
result.EquipmentKnown,
result.EquipmentEntryCount,
result.AvatarSourceFound,
result.AvatarSourceTotal,
result.TargetFound,
result.TargetItemID,
result.MaterialFound,
result.MaterialItemID,
result.EmblemMaterialFound,
result.EmblemMaterialTotal,
result.TitleInventoryFound,
result.TitleInventoryList,
result.TitleInventorySlot,
result.TitleInventoryItemID,
result.TitleBookCategory,
result.TitleBookIndex,
result.RequestedOutputItemID,
),
}
}
@@ -0,0 +1,138 @@
package avatartitle
import (
"context"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestDecodeTitleBookRequest(t *testing.T) {
got, err := DecodeTitleBookRequest([]byte{
1, 0, 0, 0,
5, 0, 0, 0,
0x44, 0x33, 0x22, 0x11,
2, 0, 0, 0,
3, 0, 0, 0,
})
if err != nil {
t.Fatalf("DecodeTitleBookRequest error = %v", err)
}
if got.ItemSpaceRaw != 1 || got.Slot != 5 || got.ItemID != 0x11223344 || got.Category != 2 || got.Index != 3 {
t.Fatalf("got = %+v", got)
}
}
func TestDecodeCompoundAvatarRequestMatchesCurrentEXEOp99(t *testing.T) {
got, err := DecodeCompoundAvatarRequest([]byte{
0x75, 0x00,
0x15, 0x00, 0x15, 0xec, 0xb4, 0x06,
0x16, 0x00, 0x15, 0xec, 0xb4, 0x06,
0x75, 0xeb, 0xb4, 0x06,
0x02, 0x00,
0x00, 0x00,
})
if err != nil {
t.Fatal(err)
}
if got.ConsumeSlot != 117 || got.Slot1 != 21 || got.ExpectedItemID1 != 112520213 ||
got.Slot2 != 22 || got.ExpectedItemID2 != 112520213 || got.RequestItemID != 112520053 ||
got.AbilityNo != 2 || got.RequestedItemFlag != 0 || got.MaterialClass != 0 {
t.Fatalf("request=%+v", got)
}
}
func TestDecodeAvatarEmblemRequest(t *testing.T) {
got, err := DecodeAvatarEmblemRequest([]byte{
listTypeAvatar,
0x05, 0x00,
0x44, 0x33, 0x22, 0x11,
1,
0x06, 0x00,
0x88, 0x77, 0x66, 0x55,
2,
})
if err != nil {
t.Fatalf("DecodeAvatarEmblemRequest error = %v", err)
}
if got.TargetSlot != 5 || got.TargetItemID != 0x11223344 || len(got.Emblems) != 1 {
t.Fatalf("got = %+v", got)
}
}
func TestHandlerBlocksAvatarTitleAck(t *testing.T) {
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketAddAvatarSocket),
Body: []byte{0x05, 0x00, 0x44, 0x33, 0x22, 0x11, 0x06, 0x00},
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
}
func TestHandlerUsesOwnerPreflightAndStillBlocksAck(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc"}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "88",
Slots: map[string]dnfrepo.ItemStack{
"1:5": {ItemID: 0x11223344, Count: 1},
"0:6": {ItemID: 7001, Count: 1},
},
}); err != nil {
t.Fatalf("Inventory.Save error = %v", err)
}
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "88",
Entries: map[string]dnfrepo.EquipmentEntry{
"0": {SlotIndex: 0, ItemID: 9001},
},
}); err != nil {
t.Fatalf("Equipment.Save error = %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketAddAvatarSocket),
SelectedCharacterID: 88,
Repositories: repos,
Body: []byte{0x05, 0x00, 0x44, 0x33, 0x22, 0x11, 0x06, 0x00},
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
for _, want := range []string{"avatartitle owner verified", "targetFound=true", "materialFound=true", "equipEntries=1"} {
if !strings.Contains(got.Reason, want) {
t.Fatalf("reason %q missing %q", got.Reason, want)
}
}
}
func TestCommandRecordsAvatarOwnerGap(t *testing.T) {
cmd := NewEmblemCommand(alignedcmd.Request{
AccountID: " acc ",
SelectedCharacterID: 88,
}, AvatarEmblemRequest{
TargetSlot: 5,
TargetItemID: 0x11223344,
Emblems: []EmblemApply{{SocketIndex: 2}},
})
summary := cmd.String()
for _, want := range []string{`account="acc"`, "char=88", "target=(5,287454020)", "emblems=1", "avatar owner", "USERINFO"} {
if !strings.Contains(summary, want) {
t.Fatalf("summary %q missing %q", summary, want)
}
}
}
@@ -0,0 +1,329 @@
// 本文件提供时装和称号命令的只读 owner 预检。
// 当前只读取角色、背包和穿戴快照,外观、称号簿和 USERINFO 刷新链路闭合前不会写库或开放成功 ACK。
package avatartitle
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrOwnerUnavailable = errors.New("avatar title owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrCharacterNotFound = errors.New("character record not found")
ErrInventoryNotFound = errors.New("inventory record not found")
ErrSlotNotFound = errors.New("inventory slot not found")
ErrItemMismatch = errors.New("inventory item mismatch")
)
// Owner 是时装/称号命令的读模型边界。
type Owner struct {
characters dnfrepo.CharacterRepository
inventory dnfrepo.InventoryRepository
equipment dnfrepo.EquipmentRepository
}
// PlanResult 描述一次只读预检结果,不代表允许向旧客户端回成功包。
type PlanResult struct {
AccountID string
CharacterID string
Operation string
InventoryKnown bool
EquipmentKnown bool
EquipmentEntryCount int
AvatarSourceFound int
AvatarSourceTotal int
TargetFound bool
TargetItemID int64
MaterialFound bool
MaterialItemID int64
EmblemMaterialFound int
EmblemMaterialTotal int
TitleInventoryFound bool
TitleInventoryList byte
TitleInventorySlot int16
TitleInventoryItemID int64
TitleBookCategory int32
TitleBookIndex int32
RequestedOutputItemID int32
}
// NewOwner 创建时装/称号 owner;缺少角色、背包或穿戴仓储时拒绝预检。
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.Character == nil || repos.Inventory == nil || repos.Equipment == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{characters: repos.Character, inventory: repos.Inventory, equipment: repos.Equipment}, nil
}
// Plan 只读取当前角色相关快照并校验请求槽位,等待 EXE/MCP 回包顺序闭合后再接真实写路径。
func (o *Owner) Plan(ctx context.Context, cmd Command) (PlanResult, error) {
state, err := o.loadState(ctx, cmd)
if err != nil {
return PlanResult{}, err
}
result := PlanResult{
AccountID: state.character.AccountID,
CharacterID: state.characterID,
Operation: cmd.Operation,
InventoryKnown: true,
EquipmentKnown: state.equipmentKnown,
EquipmentEntryCount: len(state.equipment.Entries),
TitleBookCategory: cmd.TitleCategory,
TitleBookIndex: cmd.TitleIndex,
}
switch cmd.Operation {
case "compound_avatar":
result.RequestedOutputItemID = cmd.RequestItemID
found, total, err := state.checkAvatarSources(cmd.ConsumeSlot, cmd.Slot1, cmd.Slot2)
if err != nil {
return PlanResult{}, err
}
result.AvatarSourceFound = found
result.AvatarSourceTotal = total
case "use_emblem":
target, err := state.requireInventorySlot(listTypeAvatar, cmd.TargetSlot, int64(cmd.TargetItemID))
if err != nil {
return PlanResult{}, err
}
result.TargetFound = target.Found
result.TargetItemID = target.ItemID
found, total, err := state.checkEmblems(cmd.Emblems)
if err != nil {
return PlanResult{}, err
}
result.EmblemMaterialFound = found
result.EmblemMaterialTotal = total
case "add_avatar_socket":
target, err := state.requireInventorySlot(listTypeAvatar, cmd.TargetSlot, int64(cmd.TargetItemID))
if err != nil {
return PlanResult{}, err
}
material, err := state.requireInventorySlot(listTypeMain, cmd.MaterialSlot, 0)
if err != nil {
return PlanResult{}, err
}
result.TargetFound = target.Found
result.TargetItemID = target.ItemID
result.MaterialFound = material.Found
result.MaterialItemID = material.ItemID
case "title_book_put":
title, err := state.requireTitleInventory(cmd.ItemSpaceRaw, cmd.TitleSlot, int64(cmd.TitleItemID))
if err != nil {
return PlanResult{}, err
}
result.TitleInventoryFound = title.Found
result.TitleInventoryList = title.ListType
result.TitleInventorySlot = title.SlotIndex
result.TitleInventoryItemID = title.ItemID
case "title_book_get":
title := state.findTitleInventory(cmd.ItemSpaceRaw, cmd.TitleSlot, int64(cmd.TitleItemID))
result.TitleInventoryFound = title.Found
result.TitleInventoryList = title.ListType
result.TitleInventorySlot = title.SlotIndex
result.TitleInventoryItemID = title.ItemID
}
return result, nil
}
type ownerState struct {
characterID string
character dnfrepo.CharacterRecord
inventory dnfrepo.InventoryRecord
equipment dnfrepo.EquipmentRecord
equipmentKnown bool
inventorySlots map[string]dnfrepo.ItemStack
inventoryCargo map[string]dnfrepo.ItemStack
}
type slotCheck struct {
ListType byte
SlotIndex int16
ItemID int64
Found bool
}
func (o *Owner) loadState(ctx context.Context, cmd Command) (ownerState, error) {
if o == nil || o.characters == nil || o.inventory == nil || o.equipment == nil {
return ownerState{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return ownerState{}, ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
character, ok, err := o.characters.Load(ctx, characterID)
if err != nil {
return ownerState{}, err
}
if !ok {
return ownerState{}, ErrCharacterNotFound
}
inventory, ok, err := o.inventory.Load(ctx, characterID)
if err != nil {
return ownerState{}, err
}
if !ok {
return ownerState{}, ErrInventoryNotFound
}
equipment, equipmentKnown, err := o.equipment.Load(ctx, characterID)
if err != nil {
return ownerState{}, err
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
if inventory.Warehouse == nil {
inventory.Warehouse = make(map[string]dnfrepo.ItemStack)
}
equipment = dnfrepo.CloneEquipment(equipment)
if equipment.Entries == nil {
equipment.Entries = make(map[string]dnfrepo.EquipmentEntry)
}
return ownerState{
characterID: characterID,
character: dnfrepo.CloneCharacter(character),
inventory: inventory,
equipment: equipment,
equipmentKnown: equipmentKnown,
inventorySlots: inventory.Slots,
inventoryCargo: inventory.Warehouse,
}, nil
}
func (s ownerState) checkAvatarSources(slots ...int16) (int, int, error) {
found := 0
total := 0
for _, slot := range slots {
if slot < 0 {
continue
}
total++
if _, err := s.requireInventorySlot(listTypeAvatar, slot, 0); err != nil {
return found, total, err
}
found++
}
if total == 0 {
return 0, 0, ErrSlotNotFound
}
return found, total, nil
}
func (s ownerState) checkEmblems(emblems []EmblemApply) (int, int, error) {
found := 0
for _, emblem := range emblems {
if emblem.EmblemSlot < 0 {
continue
}
check, err := s.requireInventorySlot(listTypeMain, emblem.EmblemSlot, int64(emblem.EmblemItemID))
if err != nil {
return found, len(emblems), err
}
if check.Found {
found++
}
}
return found, len(emblems), nil
}
func (s ownerState) requireTitleInventory(spaceRaw int32, slot int16, expectedItemID int64) (slotCheck, error) {
check := s.findTitleInventory(spaceRaw, slot, expectedItemID)
if !check.Found {
return check, fmt.Errorf("%w: title space=%d slot=%d", ErrSlotNotFound, spaceRaw, slot)
}
if expectedItemID != 0 && check.ItemID != expectedItemID {
return check, fmt.Errorf("%w: title space=%d slot=%d want=%d got=%d", ErrItemMismatch, spaceRaw, slot, expectedItemID, check.ItemID)
}
return check, nil
}
func (s ownerState) findTitleInventory(spaceRaw int32, slot int16, expectedItemID int64) slotCheck {
candidates := titleListCandidates(spaceRaw)
firstFound := slotCheck{}
for _, listType := range candidates {
check := s.findInventorySlot(listType, slot)
if !check.Found {
continue
}
if firstFound == (slotCheck{}) {
firstFound = check
}
if expectedItemID == 0 || check.ItemID == expectedItemID {
return check
}
}
return firstFound
}
func (s ownerState) requireInventorySlot(listType byte, slot int16, expectedItemID int64) (slotCheck, error) {
check := s.findInventorySlot(listType, slot)
if !check.Found {
return check, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, listType, slot)
}
if expectedItemID != 0 && check.ItemID != expectedItemID {
return check, fmt.Errorf("%w: list=%d slot=%d want=%d got=%d", ErrItemMismatch, listType, slot, expectedItemID, check.ItemID)
}
return check, nil
}
func (s ownerState) findInventorySlot(listType byte, slot int16) slotCheck {
if slot < 0 {
return slotCheck{ListType: listType, SlotIndex: slot}
}
items := s.inventorySlots
if listType == listTypePersonalCargo {
items = s.inventoryCargo
}
stack, ok := items[inventoryKey(listType, slot)]
if !ok {
return slotCheck{ListType: listType, SlotIndex: slot}
}
return slotCheck{
ListType: listType,
SlotIndex: slot,
ItemID: stack.ItemID,
Found: true,
}
}
func titleListCandidates(spaceRaw int32) []byte {
candidates := make([]byte, 0, 3)
add := func(listType byte) {
for _, existing := range candidates {
if existing == listType {
return
}
}
candidates = append(candidates, listType)
}
if spaceRaw >= 0 && spaceRaw <= 255 {
add(byte(spaceRaw))
}
add(listTypeMain)
add(listTypeAvatar)
return candidates
}
func inventoryKey(listType byte, slotIndex int16) string {
return fmt.Sprintf("%d:%d", listType, slotIndex)
}
func planError(operation string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", strings.TrimSpace(operation), err)
}
const (
listTypeMain byte = 0
listTypePersonalCargo byte = 2
)
@@ -0,0 +1,97 @@
package avatartitle
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerPlansAvatarSocketFromRepositories(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc"}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "88",
Slots: map[string]dnfrepo.ItemStack{
"1:5": {ItemID: 0x11223344, Count: 1},
"0:6": {ItemID: 7001, Count: 1},
},
}); err != nil {
t.Fatalf("Inventory.Save error = %v", err)
}
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "88",
Entries: map[string]dnfrepo.EquipmentEntry{
"0": {SlotIndex: 0, ItemID: 9001},
},
}); err != nil {
t.Fatalf("Equipment.Save error = %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
got, err := owner.Plan(ctx, Command{
Operation: "add_avatar_socket",
SelectedCharacterID: 88,
TargetSlot: 5,
TargetItemID: 0x11223344,
MaterialSlot: 6,
})
if err != nil {
t.Fatalf("Plan error = %v", err)
}
if got.AccountID != "acc" || got.CharacterID != "88" {
t.Fatalf("identity = (%q,%q)", got.AccountID, got.CharacterID)
}
if !got.TargetFound || got.TargetItemID != 0x11223344 {
t.Fatalf("target = found %t item %d", got.TargetFound, got.TargetItemID)
}
if !got.MaterialFound || got.MaterialItemID != 7001 {
t.Fatalf("material = found %t item %d", got.MaterialFound, got.MaterialItemID)
}
if !got.EquipmentKnown || got.EquipmentEntryCount != 1 {
t.Fatalf("equipment = known %t count %d", got.EquipmentKnown, got.EquipmentEntryCount)
}
}
func TestOwnerRejectsMismatchedAvatarTarget(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc"}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "88",
Slots: map[string]dnfrepo.ItemStack{
"1:5": {ItemID: 1001, Count: 1},
"0:6": {ItemID: 7001, Count: 1},
},
}); err != nil {
t.Fatalf("Inventory.Save error = %v", err)
}
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "88"}); err != nil {
t.Fatalf("Equipment.Save error = %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Plan(ctx, Command{
Operation: "add_avatar_socket",
SelectedCharacterID: 88,
TargetSlot: 5,
TargetItemID: 0x11223344,
MaterialSlot: 6,
})
if !errors.Is(err, ErrItemMismatch) {
t.Fatalf("Plan error = %v, want ErrItemMismatch", err)
}
}
@@ -0,0 +1,154 @@
// 本文件解析时装和称号类 C2S 请求体。
// 字段顺序来自当前已整理的 C# 旧实现与 Go 侧现有协议号对齐结果,成功回包仍需 EXE/MCP 闭合。
package avatartitle
import (
"encoding/binary"
"fmt"
)
const listTypeAvatar byte = 1
// CompoundAvatarRequest 描述时装合成请求。
type CompoundAvatarRequest struct {
ConsumeSlot int16
Slot1 int16
ExpectedItemID1 int32
Slot2 int16
ExpectedItemID2 int32
RequestItemID int32
AbilityNo uint16
RequestedItemFlag byte
MaterialClass byte
}
// AvatarSocketRequest 描述时装开孔请求。
type AvatarSocketRequest struct {
TargetSlot int16
TargetItemID int32
MaterialSlot int16
}
// AvatarEmblemRequest 描述徽章镶嵌请求。
type AvatarEmblemRequest struct {
TargetSlot int16
TargetItemID int32
Emblems []EmblemApply
}
// EmblemApply 描述单个徽章应用记录。
type EmblemApply struct {
EmblemSlot int16
EmblemItemID int32
SocketIndex byte
}
// TitleBookRequest 描述称号簿放入或取出请求。
type TitleBookRequest struct {
ItemSpaceRaw int32
Slot int16
ItemID int32
Category int32
Index int32
}
// DecodeCompoundAvatarRequest 解析时装合成请求。
func DecodeCompoundAvatarRequest(body []byte) (CompoundAvatarRequest, error) {
if len(body) < 22 {
return CompoundAvatarRequest{}, fmt.Errorf("body too short: got %d want >= 22", len(body))
}
return CompoundAvatarRequest{
ConsumeSlot: readI16(body, 0),
Slot1: readI16(body, 2),
ExpectedItemID1: readI32(body, 4),
Slot2: readI16(body, 8),
ExpectedItemID2: readI32(body, 10),
RequestItemID: readI32(body, 14),
AbilityNo: binary.LittleEndian.Uint16(body[18:20]),
RequestedItemFlag: body[20],
MaterialClass: body[21],
}, nil
}
// DecodeAvatarSocketRequest 解析时装开孔请求。
func DecodeAvatarSocketRequest(body []byte) (AvatarSocketRequest, error) {
if len(body) < 8 {
return AvatarSocketRequest{}, fmt.Errorf("body too short: got %d want >= 8", len(body))
}
return AvatarSocketRequest{
TargetSlot: readI16(body, 0),
TargetItemID: readI32(body, 2),
MaterialSlot: readI16(body, 6),
}, nil
}
// DecodeAvatarEmblemRequest 解析时装徽章请求,兼容首字节为 Avatar list type 的包体。
func DecodeAvatarEmblemRequest(body []byte) (AvatarEmblemRequest, error) {
if len(body) >= 8 && body[0] == listTypeAvatar {
return decodeEmblemAt(body, 1)
}
return decodeEmblemAt(body, 0)
}
// DecodeTitleBookRequest 解析称号簿请求。
func DecodeTitleBookRequest(body []byte) (TitleBookRequest, error) {
if len(body) < 20 {
return TitleBookRequest{}, fmt.Errorf("body too short: got %d want >= 20", len(body))
}
return TitleBookRequest{
ItemSpaceRaw: readI32(body, 0),
Slot: int16(readI32(body, 4)),
ItemID: readI32(body, 8),
Category: readI32(body, 12),
Index: readI32(body, 16),
}, nil
}
func decodeEmblemAt(body []byte, offset int) (AvatarEmblemRequest, error) {
if len(body) < offset+7 {
return AvatarEmblemRequest{}, fmt.Errorf("body too short: got %d want >= %d", len(body), offset+7)
}
count := int(body[offset+6])
req := AvatarEmblemRequest{
TargetSlot: readI16(body, offset),
TargetItemID: readI32(body, offset+2),
Emblems: make([]EmblemApply, 0, count),
}
entryOffset := offset + 7
for i := 0; i < count; i++ {
if entryOffset+7 > len(body) {
return AvatarEmblemRequest{}, fmt.Errorf("emblem entry too short: index %d", i)
}
req.Emblems = append(req.Emblems, EmblemApply{
EmblemSlot: readI16(body, entryOffset),
EmblemItemID: readI32(body, entryOffset+2),
SocketIndex: body[entryOffset+6],
})
entryOffset += 7
}
return req, nil
}
func (r CompoundAvatarRequest) String() string {
return fmt.Sprintf("consume=%d slot1=(%d,%d) slot2=(%d,%d) requestItem=%d ability=%d materialClass=%d", r.ConsumeSlot, r.Slot1, r.ExpectedItemID1, r.Slot2, r.ExpectedItemID2, r.RequestItemID, r.AbilityNo, r.MaterialClass)
}
func (r AvatarSocketRequest) String() string {
return fmt.Sprintf("target=(%d,%d) material=%d", r.TargetSlot, r.TargetItemID, r.MaterialSlot)
}
func (r AvatarEmblemRequest) String() string {
return fmt.Sprintf("target=(%d,%d) emblems=%d", r.TargetSlot, r.TargetItemID, len(r.Emblems))
}
func (r TitleBookRequest) String() string {
return fmt.Sprintf("space=%d slot=%d item=%d category=%d index=%d", r.ItemSpaceRaw, r.Slot, r.ItemID, r.Category, r.Index)
}
func readI16(body []byte, offset int) int16 {
return int16(binary.LittleEndian.Uint16(body[offset:]))
}
func readI32(body []byte, offset int) int32 {
return int32(binary.LittleEndian.Uint32(body[offset:]))
}