服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2240 lines
84 KiB
Go
2240 lines
84 KiB
Go
// 本文件负责角色穿戴装备 owner 的状态修改入口。
|
||
// 它只处理装备 raw entry 的可靠写入,不生成旧客户端成功 ACK。
|
||
package equip
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/creaturestate"
|
||
"longheng.io/server/internal/modules/dnf/itemquality"
|
||
"longheng.io/server/internal/modules/dnf/premium"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
dnftitlebook "longheng.io/server/internal/modules/dnf/titlebook"
|
||
)
|
||
|
||
var (
|
||
ErrOwnerUnavailable = errors.New("equipment owner unavailable")
|
||
ErrCharacterRequired = errors.New("selected character id required")
|
||
ErrEquipmentNotFound = errors.New("equipment record not found")
|
||
ErrSlotNotFound = errors.New("equipped slot not found")
|
||
ErrRawEntryTooShort = errors.New("equipped raw entry is too short")
|
||
ErrDurabilityMissing = errors.New("equipped durability evidence is missing")
|
||
ErrDurabilityInvalid = errors.New("equipped durability evidence is invalid")
|
||
ErrRepairCostMissing = errors.New("equipped repair cost evidence is missing")
|
||
ErrRepairCostInvalid = errors.New("equipped repair cost evidence is invalid")
|
||
ErrRepairNotRepairable = errors.New("equipped item is not repairable by PVF durability evidence")
|
||
ErrRepairGoldInsufficient = errors.New("equipped repair gold is insufficient")
|
||
ErrWalletTxnRequired = errors.New("wallet transaction is required")
|
||
ErrRepairAllRequiresOwners = errors.New("repair all requires equipment and wallet owners")
|
||
ErrInventoryUnavailable = errors.New("inventory repository unavailable")
|
||
ErrInventoryNotFound = errors.New("inventory record not found")
|
||
ErrMoveUnsupported = errors.New("equipment move is unsupported")
|
||
ErrMoveSlotNotFound = errors.New("equipment move slot not found")
|
||
ErrMoveRawEntryMissing = errors.New("equipment raw entry evidence is missing")
|
||
ErrMoveRawEntryInvalid = errors.New("equipment raw entry evidence is invalid")
|
||
ErrMoveStackCountInvalid = errors.New("equipment move stack count is invalid")
|
||
ErrMoveTransactionRequired = errors.New("equipment move transaction is required")
|
||
ErrMoveSlotOutOfRange = errors.New("equipment move slot is outside the current EXE range")
|
||
ErrMoveSlotMismatch = errors.New("equipment entry key and slot do not match")
|
||
ErrMoveValidatorRequired = errors.New("equipment placement validator is required")
|
||
ErrEquipmentTxnRequired = errors.New("equipment mutation requires a character item transaction")
|
||
ErrLegacyPVFSlotCollision = errors.New("legacy PVF equipment slot migration collides with current equipment slot")
|
||
ErrPetRepositoryRequired = errors.New("pet repository is required for a creature equipment move")
|
||
ErrPetMoveTxnRequired = errors.New("pet equipment move requires a character pet transaction")
|
||
ErrPetRecordNotFound = errors.New("pet record not found for creature equipment move")
|
||
ErrPetEntryNotFound = errors.New("pet entry not found for creature equipment move")
|
||
ErrPetOwnershipMismatch = errors.New("pet inventory, equipment, and creature record do not match")
|
||
)
|
||
|
||
// Owner 是穿戴装备和耐久的 durable owner 边界。
|
||
type Owner struct {
|
||
equipment dnfrepo.EquipmentRepository
|
||
inventory dnfrepo.InventoryRepository
|
||
character dnfrepo.CharacterRepository
|
||
accounts dnfrepo.AccountRepository
|
||
items dnfrepo.CharacterItemUnitOfWork
|
||
assets dnfrepo.CharacterAssetUnitOfWork
|
||
pets dnfrepo.PetRepository
|
||
petItems dnfrepo.CharacterPetUnitOfWork
|
||
placementValidator PlacementValidator
|
||
avatarClassifier func(itemID uint32) (isAvatar bool, isClone bool)
|
||
// auroraLookReplaceClassifier 判定 转职光环(PVF `[aurora avatar]` 且
|
||
// `[item category]` 为 `clear avatar`)。它和 avatarClassifier 分开是有意的:
|
||
// 转职光环同样属于克隆分类,但语义相反——它替代被覆盖光环的外观,因此不能走
|
||
// 克隆外观继承,只能把被覆盖光环记成 USERINFO 的关联来源。为 nil 时保持旧行为。
|
||
auroraLookReplaceClassifier func(itemID uint32) bool
|
||
nameTagChecker func(itemID uint32) bool
|
||
inItemTransaction bool
|
||
}
|
||
|
||
// RepairCommand 描述修理穿戴装备的最小命令。
|
||
type RepairCommand struct {
|
||
SelectedCharacterID uint16
|
||
AccountID string
|
||
SlotIndex int16
|
||
QuickRepair bool
|
||
AutoRepair bool
|
||
// ContractAutoRepair 标记"服务端自己发起的魔王契约自动修理":某件佩戴装备的耐久
|
||
// 刚被消耗到 0 时由 op48 处理器调用。它与 AutoRepair(客户端带 auto 标志的修理请求)
|
||
// 的区别是**永不收费**:没有生效中的契约、或该角色当天 6 次已经用完时,本命令是空操作
|
||
// (ContractSkipped),而不会退化成玩家没点过的收费修理。调用方必须同时置 AutoRepair。
|
||
ContractAutoRepair bool
|
||
}
|
||
|
||
// RepairResult 描述穿戴装备耐久修理的写库结果;当前只允许零金币单件修理由上层回成功 ACK。
|
||
type RepairResult struct {
|
||
CharacterID string
|
||
SlotIndex int16
|
||
ItemID int64
|
||
OldDurability uint16
|
||
NewDurability uint16
|
||
Cost int64
|
||
UpdatedGold int64
|
||
Changed bool
|
||
// FreeRepair marks the 魔王契约 auto-repair path (premium type 586):
|
||
// body[5]=1 plus an active contract makes the repair cost zero.
|
||
FreeRepair bool
|
||
// ContractSkipped marks a ContractAutoRepair command that was refused
|
||
// because the contract is inactive or the character's six daily uses are
|
||
// already spent. Nothing was repaired and no gold was taken.
|
||
ContractSkipped bool
|
||
// RepairedCount is set by RepairAll: how many entries were restored.
|
||
RepairedCount int
|
||
// RepairedSlots is set by RepairAll after the transaction commits. It lets
|
||
// the transport publish only the changed rows instead of replaying the
|
||
// crash-prone complete equipped table.
|
||
RepairedSlots []RepairSlot
|
||
}
|
||
|
||
type RepairSlot struct {
|
||
Equipped bool
|
||
SlotIndex int16
|
||
}
|
||
|
||
// DecreaseDurabilityCommand describes the native dungeon durability report.
|
||
// The current client sends the worn equipment slot as a one-byte value; the
|
||
// server applies one point of loss to that exact slot.
|
||
type DecreaseDurabilityCommand struct {
|
||
SelectedCharacterID uint16
|
||
SlotIndex int16
|
||
Amount uint16
|
||
Resolve alignedcmd.RepairCostResolver
|
||
}
|
||
|
||
// DecreaseDurabilityResult is the durable result of one dungeon durability
|
||
// report. A non-repairable or already-zero item is returned unchanged.
|
||
type DecreaseDurabilityResult struct {
|
||
CharacterID string
|
||
SlotIndex int16
|
||
ItemID int64
|
||
OldDurability uint16
|
||
NewDurability uint16
|
||
Changed bool
|
||
}
|
||
|
||
// MoveCommand 描述装备槽和物品栏之间移动的最小命令。
|
||
type MoveCommand struct {
|
||
SelectedCharacterID uint16
|
||
SourceListType byte
|
||
SourceSlotIndex int16
|
||
SourceInstanceValue int32
|
||
MoveCount int32
|
||
DestinationListType byte
|
||
DestinationSlotIndex int16
|
||
DestinationInstanceValue int32
|
||
}
|
||
|
||
// Placement is the typed input required to authorize an item entering an
|
||
// equipment slot. The validator owns PVF and character compatibility rules.
|
||
type Placement struct {
|
||
CharacterID string
|
||
ItemID int64
|
||
SourceListType byte
|
||
SourceSlotIndex int16
|
||
TargetSlotIndex int16
|
||
}
|
||
|
||
// PlacementValidator validates PVF item type, character restrictions, and the
|
||
// exact target worn slot. Owner refuses ordinary equipment writes without it.
|
||
type PlacementValidator interface {
|
||
ValidateEquipmentPlacement(context.Context, Placement) error
|
||
}
|
||
|
||
// PlacementValidatorFunc adapts a function to PlacementValidator.
|
||
type PlacementValidatorFunc func(context.Context, Placement) error
|
||
|
||
func (f PlacementValidatorFunc) ValidateEquipmentPlacement(ctx context.Context, placement Placement) error {
|
||
if f == nil {
|
||
return ErrMoveValidatorRequired
|
||
}
|
||
return f(ctx, placement)
|
||
}
|
||
|
||
// MoveResult 描述穿戴/卸下写库结果;它不是旧客户端成功 ACK 许可。
|
||
type MoveResult struct {
|
||
CharacterID string
|
||
SourceListType byte
|
||
SourceSlotIndex int16
|
||
DestinationListType byte
|
||
DestinationSlotIndex int16
|
||
ItemID int64
|
||
SwappedItemID int64
|
||
Mode string
|
||
Changed bool
|
||
InventorySlots map[string]dnfrepo.ItemStack
|
||
}
|
||
|
||
// NewOwner 创建穿戴装备 owner;缺少装备仓储时拒绝处理。
|
||
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
|
||
if repos.Equipment == nil {
|
||
return nil, ErrOwnerUnavailable
|
||
}
|
||
return &Owner{
|
||
equipment: repos.Equipment,
|
||
inventory: repos.Inventory,
|
||
character: repos.Character,
|
||
accounts: repos.Account,
|
||
items: repos.CharacterItems,
|
||
assets: repos.CharacterAssets,
|
||
pets: repos.Pet,
|
||
petItems: repos.CharacterPets,
|
||
}, nil
|
||
}
|
||
|
||
// NewOwnerWithPlacementValidator enables ordinary equipment writes only when
|
||
// the caller supplies the typed PVF/character placement owner.
|
||
func NewOwnerWithPlacementValidator(repos dnfrepo.Group, validator PlacementValidator) (*Owner, error) {
|
||
if validator == nil {
|
||
return nil, ErrMoveValidatorRequired
|
||
}
|
||
owner, err := NewOwner(repos)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
owner.placementValidator = validator
|
||
return owner, nil
|
||
}
|
||
|
||
// DecreaseDurability applies one native dungeon durability report. When the
|
||
// repository group exposes CharacterItems, the equipment write is scoped to
|
||
// that character transaction; the direct repository path is retained for
|
||
// lightweight/local repository groups used by older fixtures.
|
||
func (o *Owner) DecreaseDurability(ctx context.Context, cmd DecreaseDurabilityCommand) (DecreaseDurabilityResult, error) {
|
||
if o == nil || o.equipment == nil {
|
||
return DecreaseDurabilityResult{}, ErrOwnerUnavailable
|
||
}
|
||
if cmd.SelectedCharacterID == 0 {
|
||
return DecreaseDurabilityResult{}, ErrCharacterRequired
|
||
}
|
||
if cmd.SlotIndex < 0 {
|
||
return DecreaseDurabilityResult{}, fmt.Errorf("%w: slot=%d", ErrSlotNotFound, cmd.SlotIndex)
|
||
}
|
||
if cmd.Amount == 0 {
|
||
cmd.Amount = 1
|
||
}
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
var result DecreaseDurabilityResult
|
||
apply := func(equipment dnfrepo.EquipmentRepository) error {
|
||
var err error
|
||
result, err = decreaseDurabilityInRepository(ctx, equipment, characterID, cmd)
|
||
return err
|
||
}
|
||
if o.items != nil {
|
||
if err := o.items.WithinCharacterItems(ctx, characterID, func(_ dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository) error {
|
||
return apply(equipment)
|
||
}); err != nil {
|
||
return DecreaseDurabilityResult{}, err
|
||
}
|
||
return result, nil
|
||
}
|
||
if err := apply(o.equipment); err != nil {
|
||
return DecreaseDurabilityResult{}, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func decreaseDurabilityInRepository(
|
||
ctx context.Context,
|
||
equipment dnfrepo.EquipmentRepository,
|
||
characterID string,
|
||
cmd DecreaseDurabilityCommand,
|
||
) (DecreaseDurabilityResult, error) {
|
||
record, found, err := equipment.Load(ctx, characterID)
|
||
if err != nil {
|
||
return DecreaseDurabilityResult{}, err
|
||
}
|
||
if !found {
|
||
return DecreaseDurabilityResult{}, ErrEquipmentNotFound
|
||
}
|
||
record = dnfrepo.CloneEquipment(record)
|
||
record.CharacterID = characterID
|
||
key := entryKey(cmd.SlotIndex)
|
||
entry, found := record.Entries[key]
|
||
if !found || entry.SlotIndex != cmd.SlotIndex {
|
||
return DecreaseDurabilityResult{CharacterID: characterID, SlotIndex: cmd.SlotIndex}, nil
|
||
}
|
||
result := DecreaseDurabilityResult{
|
||
CharacterID: characterID,
|
||
SlotIndex: cmd.SlotIndex,
|
||
ItemID: entry.ItemID,
|
||
}
|
||
durabilityOffset, ok := equippedDurabilityOffset(entry)
|
||
if !ok {
|
||
return result, nil
|
||
}
|
||
current := binary.LittleEndian.Uint16(entry.RawEntry[durabilityOffset : durabilityOffset+2])
|
||
result.OldDurability = current
|
||
result.NewDurability = current
|
||
if current == 0 {
|
||
return result, nil
|
||
}
|
||
if cmd.Resolve != nil {
|
||
evidence, resolveErr := cmd.Resolve(entry.ItemID)
|
||
if resolveErr != nil {
|
||
return DecreaseDurabilityResult{}, resolveErr
|
||
}
|
||
if evidence.MaxDurability <= 0 {
|
||
return result, nil
|
||
}
|
||
}
|
||
amount := cmd.Amount
|
||
if amount > current {
|
||
amount = current
|
||
}
|
||
updated := current - amount
|
||
entry.RawEntry = append([]byte(nil), entry.RawEntry...)
|
||
binary.LittleEndian.PutUint16(entry.RawEntry[durabilityOffset:durabilityOffset+2], updated)
|
||
if entry.Extra == nil {
|
||
entry.Extra = make(map[string]string, 1)
|
||
}
|
||
entry.Extra["durability"] = strconv.FormatUint(uint64(updated), 10)
|
||
record.Entries[key] = entry
|
||
record.UpdatedAt = time.Now()
|
||
if err := dnfrepo.SaveEquipmentFields(ctx, equipment, record, dnfrepo.EquipmentFieldEntries); err != nil {
|
||
return DecreaseDurabilityResult{}, err
|
||
}
|
||
result.NewDurability = updated
|
||
result.Changed = updated != current
|
||
return result, nil
|
||
}
|
||
|
||
func equippedDurabilityOffset(entry dnfrepo.EquipmentEntry) (int, bool) {
|
||
return durabilityOffsetForRaw(entry.ItemID, entry.RawEntry)
|
||
}
|
||
|
||
func durabilityOffsetForRaw(itemID int64, raw []byte) (int, bool) {
|
||
// Current item-list rows are 0x77 (119) bytes and store durability at
|
||
// +0x0B. The item-id guard prevents a random long raw blob from being
|
||
// mistaken for the current row format. Historical rows use +0x0A.
|
||
if len(raw) == currentEquipmentRawEntryWireSize && itemID > 0 &&
|
||
itemID <= int64(^uint32(0)) && binary.LittleEndian.Uint32(raw[2:6]) == uint32(itemID) {
|
||
return 0x0B, true
|
||
}
|
||
if len(raw) >= 12 {
|
||
return 10, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// SetNameTagChecker registers a PVF-backed predicate that identifies name tag
|
||
// cards. When set, unequip of a name tag card is blocked (PR #239).
|
||
func (o *Owner) SetNameTagChecker(checker func(itemID uint32) bool) {
|
||
if o != nil {
|
||
o.nameTagChecker = checker
|
||
}
|
||
}
|
||
|
||
// SetAvatarClassifier registers the runtime-PVF authority used to distinguish
|
||
// ordinary and clone avatars while an op19 equipment transaction is active.
|
||
func (o *Owner) SetAvatarClassifier(classifier func(itemID uint32) (isAvatar bool, isClone bool)) {
|
||
if o != nil {
|
||
o.avatarClassifier = classifier
|
||
}
|
||
}
|
||
|
||
// SetAuroraLookReplaceClassifier registers the runtime-PVF authority used to
|
||
// distinguish 转职光环 (aura look-replace avatars) from 透明克隆时装. Leaving it
|
||
// unset keeps the legacy clone-only behaviour.
|
||
func (o *Owner) SetAuroraLookReplaceClassifier(classifier func(itemID uint32) bool) {
|
||
if o != nil {
|
||
o.auroraLookReplaceClassifier = classifier
|
||
}
|
||
}
|
||
|
||
// isAuroraLookReplace reports whether itemID is a 转职光环 under the active PVF.
|
||
// A missing classifier (isolated domain tests, pet-only transactions) is not an
|
||
// error: it must fall back to false so the clone path stays byte-for-byte the
|
||
// same as before this classifier existed.
|
||
func (o *Owner) isAuroraLookReplace(itemID uint32) bool {
|
||
return o != nil && o.auroraLookReplaceClassifier != nil && itemID != 0 &&
|
||
o.auroraLookReplaceClassifier(itemID)
|
||
}
|
||
|
||
// Repair 按 86JP TryRepairSingleEquipped 修理穿戴槽:PVF 证据定价(repair
|
||
// price/grade/durability + pricetable 全局费率),金币与耐久在同一角色资产
|
||
// 事务内提交;body[5]=1 且魔王契约(586)生效时免费;body[7]=1 快速修理按
|
||
// [quick repair cost rate] 加价。slot=-1 转 RepairAll。
|
||
func (o *Owner) Repair(ctx context.Context, cmd RepairCommand, costResolver alignedcmd.RepairCostResolver) (RepairResult, error) {
|
||
if o == nil || o.assets == nil {
|
||
return RepairResult{}, ErrOwnerUnavailable
|
||
}
|
||
if cmd.SelectedCharacterID == 0 {
|
||
return RepairResult{}, ErrCharacterRequired
|
||
}
|
||
if cmd.SlotIndex == -1 {
|
||
return o.RepairAll(ctx, cmd, costResolver)
|
||
}
|
||
if costResolver == nil {
|
||
return RepairResult{}, ErrRepairCostMissing
|
||
}
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
var result RepairResult
|
||
err := o.assets.WithinCharacterAssets(ctx, characterID, func(characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository) error {
|
||
var err error
|
||
result, err = o.repairSingleEquipped(ctx, cmd, characterID, characters, equipment, costResolver)
|
||
return err
|
||
})
|
||
return result, err
|
||
}
|
||
|
||
func (o *Owner) repairSingleEquipped(
|
||
ctx context.Context,
|
||
cmd RepairCommand,
|
||
characterID string,
|
||
characters dnfrepo.CharacterRepository,
|
||
equipment dnfrepo.EquipmentRepository,
|
||
costResolver alignedcmd.RepairCostResolver,
|
||
) (RepairResult, error) {
|
||
record, ok, err := equipment.Load(ctx, characterID)
|
||
if err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
if !ok {
|
||
return RepairResult{}, ErrEquipmentNotFound
|
||
}
|
||
record = dnfrepo.CloneEquipment(record)
|
||
record.CharacterID = characterID
|
||
if record.Entries == nil {
|
||
record.Entries = make(map[string]dnfrepo.EquipmentEntry)
|
||
}
|
||
|
||
key := entryKey(cmd.SlotIndex)
|
||
entry, ok := record.Entries[key]
|
||
if !ok {
|
||
return RepairResult{}, fmt.Errorf("%w: slot=%d", ErrSlotNotFound, cmd.SlotIndex)
|
||
}
|
||
durabilityOffset, ok := durabilityOffsetForRaw(entry.ItemID, entry.RawEntry)
|
||
if !ok {
|
||
return RepairResult{}, fmt.Errorf("%w: slot=%d len=%d", ErrRawEntryTooShort, cmd.SlotIndex, len(entry.RawEntry))
|
||
}
|
||
current := int64(binary.LittleEndian.Uint16(entry.RawEntry[durabilityOffset : durabilityOffset+2]))
|
||
evidence, err := costResolver(entry.ItemID)
|
||
if err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
if evidence.MaxDurability <= 0 {
|
||
return RepairResult{}, fmt.Errorf("%w: slot=%d item=%d", ErrRepairNotRepairable, cmd.SlotIndex, entry.ItemID)
|
||
}
|
||
maxDurability := evidence.MaxDurability
|
||
character, found, err := characters.Load(ctx, characterID)
|
||
if err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
if !found {
|
||
return RepairResult{}, fmt.Errorf("%w: character=%s", ErrWalletTxnRequired, characterID)
|
||
}
|
||
character = dnfrepo.CloneCharacter(character)
|
||
if character.Stats == nil {
|
||
return RepairResult{}, fmt.Errorf("%w: character=%s has no stats", ErrWalletTxnRequired, characterID)
|
||
}
|
||
gold := character.Stats["gold"]
|
||
result := RepairResult{
|
||
CharacterID: characterID,
|
||
SlotIndex: cmd.SlotIndex,
|
||
ItemID: entry.ItemID,
|
||
OldDurability: uint16(current),
|
||
NewDurability: uint16(maxDurability),
|
||
}
|
||
if current >= maxDurability {
|
||
result.NewDurability = uint16(current)
|
||
result.UpdatedGold = gold
|
||
return result, nil
|
||
}
|
||
cost := CalcRepairCost(evidence, current, upgradeLevelOfRawEntry(entry.RawEntry), cmd.QuickRepair)
|
||
freeRepair := false
|
||
statsChanged := false
|
||
if cost != 0 && cmd.AutoRepair && o.premiumActive(ctx, cmd.AccountID, premium.DevilSlotType(premium.DevilSlotAutoRepair)) {
|
||
// 魔王契约 auto repair: the client sent body[5]=1 and the account
|
||
// holds an active slot-6 contract, so the repair is free (86JP
|
||
// freeRepair = autoRepair && HasActiveAutoRepairForAccount).
|
||
if premium.TryConsumeDaily(&character, premium.DevilSlotAutoRepair, time.Now().UTC()) {
|
||
cost = 0
|
||
freeRepair = true
|
||
statsChanged = true
|
||
}
|
||
}
|
||
if cost > gold {
|
||
return RepairResult{}, fmt.Errorf("%w: cost=%d gold=%d", ErrRepairGoldInsufficient, cost, gold)
|
||
}
|
||
|
||
if cost > 0 {
|
||
character.Stats["gold"] = gold - cost
|
||
statsChanged = true
|
||
}
|
||
if statsChanged {
|
||
character.UpdatedAt = time.Now()
|
||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
}
|
||
entry.RawEntry = append([]byte(nil), entry.RawEntry...)
|
||
binary.LittleEndian.PutUint16(entry.RawEntry[durabilityOffset:durabilityOffset+2], uint16(maxDurability))
|
||
if entry.Extra == nil {
|
||
entry.Extra = make(map[string]string, 1)
|
||
}
|
||
entry.Extra["durability"] = strconv.FormatInt(maxDurability, 10)
|
||
record.Entries[key] = entry
|
||
record.UpdatedAt = time.Now()
|
||
if err := dnfrepo.SaveEquipmentFields(ctx, equipment, record, dnfrepo.EquipmentFieldEntries); err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
result.Cost = cost
|
||
result.UpdatedGold = gold - cost
|
||
result.Changed = true
|
||
result.FreeRepair = freeRepair
|
||
return result, nil
|
||
}
|
||
|
||
// RepairAll 按 86JP TryRepairAll 全部修理:扫描穿戴装备与主列表快捷栏
|
||
// (slot 3..8),只处理 13 类可修理装备且耐久未满的条目,合计费用一次扣除,
|
||
// ACK 槽位 0xFFFF;上层必须再发布实际变更行,因为当前客户端不会据此本地拉满。
|
||
func (o *Owner) RepairAll(ctx context.Context, cmd RepairCommand, costResolver alignedcmd.RepairCostResolver) (RepairResult, error) {
|
||
if o == nil || o.assets == nil {
|
||
return RepairResult{}, ErrOwnerUnavailable
|
||
}
|
||
if cmd.SelectedCharacterID == 0 {
|
||
return RepairResult{}, ErrCharacterRequired
|
||
}
|
||
if costResolver == nil {
|
||
return RepairResult{}, ErrRepairCostMissing
|
||
}
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
var result RepairResult
|
||
err := o.assets.WithinCharacterAssets(ctx, characterID, func(characters dnfrepo.CharacterRepository, inventory dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository) error {
|
||
var err error
|
||
result, err = o.repairAllInTransaction(ctx, cmd, characterID, characters, inventory, equipment, costResolver)
|
||
return err
|
||
})
|
||
return result, err
|
||
}
|
||
|
||
type repairAllTarget struct {
|
||
equipped bool
|
||
key string
|
||
slot int16
|
||
itemID int64
|
||
maxDura int64
|
||
cost int64
|
||
durabilityOffset int
|
||
}
|
||
|
||
func (o *Owner) repairAllInTransaction(
|
||
ctx context.Context,
|
||
cmd RepairCommand,
|
||
characterID string,
|
||
characters dnfrepo.CharacterRepository,
|
||
inventory dnfrepo.InventoryRepository,
|
||
equipment dnfrepo.EquipmentRepository,
|
||
costResolver alignedcmd.RepairCostResolver,
|
||
) (RepairResult, error) {
|
||
character, found, err := characters.Load(ctx, characterID)
|
||
if err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
if !found {
|
||
return RepairResult{}, fmt.Errorf("%w: character=%s", ErrWalletTxnRequired, characterID)
|
||
}
|
||
character = dnfrepo.CloneCharacter(character)
|
||
if character.Stats == nil {
|
||
return RepairResult{}, fmt.Errorf("%w: character=%s has no stats", ErrWalletTxnRequired, characterID)
|
||
}
|
||
equipRecord, equipFound, err := equipment.Load(ctx, characterID)
|
||
if err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
equipRecord = dnfrepo.CloneEquipment(equipRecord)
|
||
equipRecord.CharacterID = characterID
|
||
if equipRecord.Entries == nil {
|
||
equipRecord.Entries = make(map[string]dnfrepo.EquipmentEntry)
|
||
}
|
||
invRecord, invFound, err := inventory.Load(ctx, characterID)
|
||
if err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
invRecord = dnfrepo.CloneInventory(invRecord)
|
||
invRecord.CharacterID = characterID
|
||
if invRecord.Slots == nil {
|
||
invRecord.Slots = make(map[string]dnfrepo.ItemStack)
|
||
}
|
||
|
||
autoRepairActive := cmd.AutoRepair && o.premiumActive(ctx, cmd.AccountID, premium.DevilSlotType(premium.DevilSlotAutoRepair))
|
||
freeRepair := false
|
||
targets := make([]repairAllTarget, 0, 16)
|
||
total := int64(0)
|
||
if equipFound {
|
||
for key, entry := range equipRecord.Entries {
|
||
durabilityOffset, ok := durabilityOffsetForRaw(entry.ItemID, entry.RawEntry)
|
||
if !ok {
|
||
continue
|
||
}
|
||
slot, err := strconv.Atoi(key)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
evidence, err := costResolver(entry.ItemID)
|
||
if err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
if evidence.MaxDurability <= 0 || !RepairAllEligible(evidence.EquipmentType) {
|
||
continue
|
||
}
|
||
current := int64(binary.LittleEndian.Uint16(entry.RawEntry[durabilityOffset : durabilityOffset+2]))
|
||
if current >= evidence.MaxDurability {
|
||
continue
|
||
}
|
||
cost := CalcRepairCost(evidence, current, upgradeLevelOfRawEntry(entry.RawEntry), cmd.QuickRepair)
|
||
targets = append(targets, repairAllTarget{equipped: true, key: key, slot: int16(slot), itemID: entry.ItemID, maxDura: evidence.MaxDurability, cost: cost, durabilityOffset: durabilityOffset})
|
||
total += cost
|
||
}
|
||
}
|
||
if invFound {
|
||
for slot := int16(3); slot <= 8; slot++ {
|
||
key := "0:" + strconv.Itoa(int(slot))
|
||
stack, ok := invRecord.Slots[key]
|
||
if !ok || stack.ItemID <= 0 {
|
||
continue
|
||
}
|
||
evidence, err := costResolver(stack.ItemID)
|
||
if err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
if evidence.MaxDurability <= 0 || !RepairAllEligible(evidence.EquipmentType) {
|
||
continue
|
||
}
|
||
current, err := repairBagDurabilityOf(stack)
|
||
if err != nil || current >= evidence.MaxDurability {
|
||
continue
|
||
}
|
||
upgradeLevel := 0
|
||
if len(stack.RawEntry) > 0x0A {
|
||
upgradeLevel = int(stack.RawEntry[0x0A] & 0x1F)
|
||
}
|
||
cost := CalcRepairCost(evidence, current, upgradeLevel, cmd.QuickRepair)
|
||
targets = append(targets, repairAllTarget{key: key, slot: slot, itemID: stack.ItemID, maxDura: evidence.MaxDurability, cost: cost})
|
||
total += cost
|
||
}
|
||
}
|
||
sort.Slice(targets, func(i, j int) bool {
|
||
if targets[i].equipped != targets[j].equipped {
|
||
return targets[i].equipped
|
||
}
|
||
return targets[i].slot < targets[j].slot
|
||
})
|
||
|
||
gold := character.Stats["gold"]
|
||
result := RepairResult{
|
||
CharacterID: characterID,
|
||
SlotIndex: -1,
|
||
FreeRepair: freeRepair,
|
||
}
|
||
if len(targets) == 0 {
|
||
result.UpdatedGold = gold
|
||
return result, nil
|
||
}
|
||
if cmd.ContractAutoRepair {
|
||
// 服务端触发的契约自动修理:先花掉角色当天的一次额度,花不出去就什么都不做。
|
||
// 顺序很重要 —— 先扣额度再修,保证"用完 6 次"与"不收费"两条不会互相打架。
|
||
if !autoRepairActive || !premium.TryConsumeDaily(&character, premium.DevilSlotAutoRepair, time.Now().UTC()) {
|
||
result.ContractSkipped = true
|
||
result.UpdatedGold = gold
|
||
return result, nil
|
||
}
|
||
freeRepair = true
|
||
result.FreeRepair = true
|
||
total = 0
|
||
for index := range targets {
|
||
targets[index].cost = 0
|
||
}
|
||
} else if autoRepairActive && premium.TryConsumeDaily(&character, premium.DevilSlotAutoRepair, time.Now().UTC()) {
|
||
freeRepair = true
|
||
result.FreeRepair = true
|
||
total = 0
|
||
for index := range targets {
|
||
targets[index].cost = 0
|
||
}
|
||
}
|
||
if total > gold {
|
||
return RepairResult{}, fmt.Errorf("%w: cost=%d gold=%d", ErrRepairGoldInsufficient, total, gold)
|
||
}
|
||
if total > 0 || freeRepair {
|
||
character.Stats["gold"] = gold - total
|
||
character.UpdatedAt = time.Now()
|
||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
}
|
||
for _, target := range targets {
|
||
if target.equipped {
|
||
entry := equipRecord.Entries[target.key]
|
||
entry.RawEntry = append([]byte(nil), entry.RawEntry...)
|
||
binary.LittleEndian.PutUint16(entry.RawEntry[target.durabilityOffset:target.durabilityOffset+2], uint16(target.maxDura))
|
||
if entry.Extra == nil {
|
||
entry.Extra = make(map[string]string, 1)
|
||
}
|
||
entry.Extra["durability"] = strconv.FormatInt(target.maxDura, 10)
|
||
equipRecord.Entries[target.key] = entry
|
||
continue
|
||
}
|
||
stack := invRecord.Slots[target.key]
|
||
stack = cloneItemStackForRepair(stack)
|
||
if stack.Extra == nil {
|
||
stack.Extra = make(map[string]string, 2)
|
||
}
|
||
stack.Extra["durability"] = strconv.FormatInt(target.maxDura, 10)
|
||
if durabilityOffset, ok := durabilityOffsetForRaw(stack.ItemID, stack.RawEntry); ok {
|
||
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
||
binary.LittleEndian.PutUint16(stack.RawEntry[durabilityOffset:durabilityOffset+2], uint16(target.maxDura))
|
||
}
|
||
invRecord.Slots[target.key] = stack
|
||
}
|
||
if len(targets) > 0 {
|
||
equipRecord.UpdatedAt = time.Now()
|
||
if equipFound {
|
||
if err := dnfrepo.SaveEquipmentFields(ctx, equipment, equipRecord, dnfrepo.EquipmentFieldEntries); err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
}
|
||
invRecord.UpdatedAt = time.Now()
|
||
if invFound {
|
||
if err := dnfrepo.SaveInventoryFields(ctx, inventory, invRecord, dnfrepo.InventoryFieldSlots); err != nil {
|
||
return RepairResult{}, err
|
||
}
|
||
}
|
||
}
|
||
result.Cost = total
|
||
result.UpdatedGold = gold - total
|
||
result.Changed = true
|
||
result.RepairedCount = len(targets)
|
||
result.RepairedSlots = make([]RepairSlot, 0, len(targets))
|
||
for _, target := range targets {
|
||
result.RepairedSlots = append(result.RepairedSlots, RepairSlot{
|
||
Equipped: target.equipped,
|
||
SlotIndex: target.slot,
|
||
})
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func upgradeLevelOfRawEntry(raw []byte) int {
|
||
if len(raw) > 0x0A {
|
||
return int(raw[0x0A] & 0x1F)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func repairBagDurabilityOf(stack dnfrepo.ItemStack) (int64, error) {
|
||
if stack.Extra == nil {
|
||
return 0, ErrRepairCostMissing
|
||
}
|
||
raw := strings.TrimSpace(stack.Extra["durability"])
|
||
if raw == "" {
|
||
return 0, ErrRepairCostMissing
|
||
}
|
||
value, err := strconv.ParseInt(raw, 10, 64)
|
||
if err != nil || value < 0 {
|
||
return 0, fmt.Errorf("%w: %q", ErrRepairCostInvalid, raw)
|
||
}
|
||
return value, nil
|
||
}
|
||
|
||
func cloneItemStackForRepair(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
|
||
if stack.Extra != nil {
|
||
extra := make(map[string]string, len(stack.Extra)+1)
|
||
for key, value := range stack.Extra {
|
||
extra[key] = value
|
||
}
|
||
stack.Extra = extra
|
||
}
|
||
return stack
|
||
}
|
||
|
||
// premiumActive reports whether the account currently holds an active premium
|
||
// contract of the given type. A missing account repository or record reads as
|
||
// inactive, so only the explicit free-repair bonus is gated by it.
|
||
func (o *Owner) premiumActive(ctx context.Context, accountID string, premiumType int64) bool {
|
||
if o == nil || o.accounts == nil || strings.TrimSpace(accountID) == "" {
|
||
return false
|
||
}
|
||
account, ok, err := o.accounts.Load(ctx, accountID)
|
||
if err != nil || !ok {
|
||
return false
|
||
}
|
||
return premium.Active(account, premiumType, time.Now().UTC())
|
||
}
|
||
|
||
// Move 在装备槽和背包/装扮/宠物物品栏之间迁移 raw entry。
|
||
// Ordinary placement requires structural bounds plus a typed PVF/character validator.
|
||
func (o *Owner) Move(ctx context.Context, cmd MoveCommand) (MoveResult, error) {
|
||
if o == nil || o.equipment == nil {
|
||
return MoveResult{}, ErrOwnerUnavailable
|
||
}
|
||
if o.inventory == nil {
|
||
return MoveResult{}, ErrInventoryUnavailable
|
||
}
|
||
if cmd.SelectedCharacterID == 0 {
|
||
return MoveResult{}, ErrCharacterRequired
|
||
}
|
||
kind, err := validateMoveStructure(cmd)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
creatureMove := kind == moveKindPet
|
||
petAggregateMove := creatureMove || kind == moveKindArtifact
|
||
// Current NoPack's Vagabond "change sub weapon" button reuses op19 for
|
||
// the worn weapon endpoints 12 and 24, but encodes the equipment-slot swap
|
||
// with count=0. Ordinary equip/unequip requests still use count=1. Accept
|
||
// only this exact dual-weapon endpoint pair so a zero count cannot broaden
|
||
// any inventory, avatar, pet, or unrelated equipment mutation. The class /
|
||
// grow-type / beamsword gate for slot 24 stays in the placement validator
|
||
// (currentEquipmentPlacementAllowsBladeDancerOffhand).
|
||
if cmd.MoveCount != 1 && !isDualWeaponToggleMove(cmd) {
|
||
return MoveResult{}, fmt.Errorf("%w: count=%d", ErrMoveStackCountInvalid, cmd.MoveCount)
|
||
}
|
||
if !creatureMove && o.placementValidator == nil {
|
||
return MoveResult{}, ErrMoveValidatorRequired
|
||
}
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
if !o.inItemTransaction {
|
||
var result MoveResult
|
||
if petAggregateMove {
|
||
if o.pets == nil {
|
||
return MoveResult{}, ErrPetRepositoryRequired
|
||
}
|
||
if o.petItems == nil {
|
||
return MoveResult{}, ErrPetMoveTxnRequired
|
||
}
|
||
err = o.petItems.WithinCharacterPets(ctx, characterID, func(inventory dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository, pets dnfrepo.PetRepository) error {
|
||
txOwner := &Owner{
|
||
equipment: equipment,
|
||
inventory: inventory,
|
||
character: o.character,
|
||
pets: pets,
|
||
placementValidator: o.placementValidator,
|
||
inItemTransaction: true,
|
||
}
|
||
var err error
|
||
result, err = txOwner.Move(ctx, cmd)
|
||
return err
|
||
})
|
||
} else {
|
||
if o.items == nil {
|
||
return MoveResult{}, ErrMoveTransactionRequired
|
||
}
|
||
err = o.items.WithinCharacterItems(ctx, characterID, func(inventory dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository) error {
|
||
txOwner := &Owner{
|
||
equipment: equipment,
|
||
inventory: inventory,
|
||
character: o.character,
|
||
placementValidator: o.placementValidator,
|
||
avatarClassifier: o.avatarClassifier,
|
||
auroraLookReplaceClassifier: o.auroraLookReplaceClassifier,
|
||
inItemTransaction: true,
|
||
}
|
||
var err error
|
||
result, err = txOwner.Move(ctx, cmd)
|
||
return err
|
||
})
|
||
}
|
||
return result, err
|
||
}
|
||
if !petAggregateMove {
|
||
if _, err := o.normalizeLegacyPVFInitialEquipmentSlots(ctx, characterID); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
} else if creatureMove {
|
||
if _, err := creaturestate.ReconcileInventory(ctx, characterID, o.inventory, o.equipment, o.pets); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
}
|
||
|
||
// Current NoPack op19 carries two endpoint addresses, not a reliable move
|
||
// direction. Equip and unequip requests keep the inventory/avatar/cargo
|
||
// endpoint first and the worn endpoint second; only the endpoint instance
|
||
// values and authoritative server state change. Resolve the direction from
|
||
// the transaction-scoped DB snapshot so the decision and mutation are
|
||
// serialized by CharacterItemUnitOfWork.
|
||
resolved, reversed, err := o.resolveMoveEndpoints(ctx, characterID, cmd)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
var result MoveResult
|
||
if resolved.SourceListType == wireListEquipment && resolved.DestinationListType == wireListEquipment {
|
||
result, err = o.swapEquipSlots(ctx, resolved)
|
||
} else if resolved.SourceListType == wireListEquipment {
|
||
result, err = o.unequip(ctx, resolved)
|
||
} else {
|
||
result, err = o.equip(ctx, resolved)
|
||
}
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
switch kind {
|
||
case moveKindPet:
|
||
if err := o.syncPetEquipmentMove(ctx, characterID, result); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
case moveKindArtifact:
|
||
if err := o.syncPetArtifactEquipmentMove(ctx, characterID, result); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
}
|
||
if reversed {
|
||
// Keep the request's A/B list direction, but return the authoritative
|
||
// inventory slot selected by the owner. Cross-list op19 accepts a
|
||
// redirected final slot; this is required when the client's stale bag
|
||
// view nominates an already occupied unequip destination.
|
||
result.SourceListType = cmd.SourceListType
|
||
result.SourceSlotIndex = result.DestinationSlotIndex
|
||
result.DestinationListType = cmd.DestinationListType
|
||
result.DestinationSlotIndex = cmd.DestinationSlotIndex
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
type legacyPVFInitialEquipmentSlotMove struct {
|
||
sourceKey string
|
||
targetKey string
|
||
target int16
|
||
entry dnfrepo.EquipmentEntry
|
||
}
|
||
|
||
// normalizeLegacyPVFInitialEquipmentSlots converts only untouched
|
||
// source=pvf_create_equipment_list records from the legacy worn-slot keys to
|
||
// the current EXE actor-slot keys used by op19. It runs inside the same
|
||
// CharacterItemUnitOfWork as the requested move, so a later placement/save
|
||
// failure rolls the migration back with the move. Runtime rows and already
|
||
// migrated PVF rows carry current_exe_runtime_move and are never reinterpreted.
|
||
func (o *Owner) normalizeLegacyPVFInitialEquipmentSlots(ctx context.Context, characterID string) (bool, error) {
|
||
record, found, err := o.equipment.Load(ctx, characterID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if !found {
|
||
return false, ErrEquipmentNotFound
|
||
}
|
||
record = ensureEquipmentRecord(dnfrepo.CloneEquipment(record), characterID)
|
||
|
||
plans := make(map[string]legacyPVFInitialEquipmentSlotMove)
|
||
targets := make(map[string]string)
|
||
for key, entry := range record.Entries {
|
||
if !isUntouchedLegacyPVFInitialEquipment(entry) {
|
||
continue
|
||
}
|
||
target, ok := currentActorSlotForLegacyPVFInitialSlot(entry.SlotIndex)
|
||
if !ok {
|
||
continue
|
||
}
|
||
sourceKey := entryKey(entry.SlotIndex)
|
||
if key != sourceKey {
|
||
return false, fmt.Errorf("%w: key=%q entry=%d", ErrMoveSlotMismatch, key, entry.SlotIndex)
|
||
}
|
||
targetKey := entryKey(target)
|
||
if previous, exists := targets[targetKey]; exists {
|
||
return false, fmt.Errorf(
|
||
"%w: source=%s other_source=%s target=%s",
|
||
ErrLegacyPVFSlotCollision,
|
||
sourceKey,
|
||
previous,
|
||
targetKey,
|
||
)
|
||
}
|
||
targets[targetKey] = sourceKey
|
||
plans[sourceKey] = legacyPVFInitialEquipmentSlotMove{
|
||
sourceKey: sourceKey,
|
||
targetKey: targetKey,
|
||
target: target,
|
||
entry: entry,
|
||
}
|
||
}
|
||
if len(plans) == 0 {
|
||
return false, nil
|
||
}
|
||
|
||
// A target occupied by another planned legacy row is safe because every
|
||
// source key is deleted before any target key is inserted. Any other
|
||
// occupant is authoritative current/runtime state and must fail closed.
|
||
for _, plan := range plans {
|
||
if _, occupied := record.Entries[plan.targetKey]; !occupied {
|
||
continue
|
||
}
|
||
if _, movingAway := plans[plan.targetKey]; !movingAway {
|
||
return false, fmt.Errorf(
|
||
"%w: source=%s target=%s",
|
||
ErrLegacyPVFSlotCollision,
|
||
plan.sourceKey,
|
||
plan.targetKey,
|
||
)
|
||
}
|
||
}
|
||
|
||
for sourceKey := range plans {
|
||
delete(record.Entries, sourceKey)
|
||
}
|
||
for _, plan := range plans {
|
||
entry := plan.entry
|
||
entry.SlotIndex = plan.target
|
||
entry.Extra = cloneExtra(entry.Extra)
|
||
entry.Extra["equipped_slot"] = strconv.Itoa(int(plan.target))
|
||
entry.Extra["current_exe_equipment_type"] = strconv.Itoa(int(plan.target))
|
||
entry.Extra["current_exe_runtime_move"] = "1"
|
||
record.Entries[plan.targetKey] = entry
|
||
}
|
||
record.UpdatedAt = time.Now()
|
||
if err := dnfrepo.SaveEquipmentFields(ctx, o.equipment, record, dnfrepo.EquipmentFieldEntries); err != nil {
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
func isUntouchedLegacyPVFInitialEquipment(entry dnfrepo.EquipmentEntry) bool {
|
||
if !strings.EqualFold(strings.TrimSpace(entry.Extra["source"]), "pvf_create_equipment_list") {
|
||
return false
|
||
}
|
||
raw := strings.TrimSpace(strings.ToLower(entry.Extra["current_exe_runtime_move"]))
|
||
switch raw {
|
||
case "", "0", "false", "no", "off":
|
||
return true
|
||
default:
|
||
// Unknown non-empty markers also fail closed: they may represent a
|
||
// newer runtime migration state that this owner must not reinterpret.
|
||
return false
|
||
}
|
||
}
|
||
|
||
func currentActorSlotForLegacyPVFInitialSlot(slot int16) (int16, bool) {
|
||
switch {
|
||
case slot == 11:
|
||
return 12, true
|
||
case slot >= 13 && slot <= 22:
|
||
return slot + 1, true
|
||
case slot == 23:
|
||
return 25, true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func (o *Owner) resolveMoveEndpoints(ctx context.Context, characterID string, cmd MoveCommand) (MoveCommand, bool, error) {
|
||
var inventory dnfrepo.InventoryRecord
|
||
if cmd.SourceListType != wireListEquipment || cmd.DestinationListType != wireListEquipment {
|
||
loaded, ok, err := o.inventory.Load(ctx, characterID)
|
||
if err != nil {
|
||
return MoveCommand{}, false, err
|
||
}
|
||
if !ok {
|
||
return MoveCommand{}, false, ErrInventoryNotFound
|
||
}
|
||
inventory = ensureInventoryRecord(dnfrepo.CloneInventory(loaded), characterID)
|
||
}
|
||
equipment, ok, err := o.equipment.Load(ctx, characterID)
|
||
if err != nil {
|
||
return MoveCommand{}, false, err
|
||
}
|
||
if !ok {
|
||
return MoveCommand{}, false, ErrEquipmentNotFound
|
||
}
|
||
equipment = ensureEquipmentRecord(dnfrepo.CloneEquipment(equipment), characterID)
|
||
aOccupied, err := moveEndpointOccupied(&inventory, &equipment, cmd.SourceListType, cmd.SourceSlotIndex)
|
||
if err != nil {
|
||
return MoveCommand{}, false, err
|
||
}
|
||
bOccupied, err := moveEndpointOccupied(&inventory, &equipment, cmd.DestinationListType, cmd.DestinationSlotIndex)
|
||
if err != nil {
|
||
return MoveCommand{}, false, err
|
||
}
|
||
if !aOccupied && !bOccupied {
|
||
return MoveCommand{}, false, fmt.Errorf(
|
||
"%w: endpoints=(%d,%d),(%d,%d)",
|
||
ErrMoveSlotNotFound,
|
||
cmd.SourceListType,
|
||
cmd.SourceSlotIndex,
|
||
cmd.DestinationListType,
|
||
cmd.DestinationSlotIndex,
|
||
)
|
||
}
|
||
// The current 28-byte op19 body carries the live instance value for both
|
||
// endpoints. A zero A instance and nonzero B instance is the observed
|
||
// unequip form. Prefer that direction even when the server still has an
|
||
// item in A because a missed/early list0 snapshot can leave the client's
|
||
// chosen empty destination stale. The owner will relocate to the next real
|
||
// empty slot in the same proven inventory page instead of corrupting the
|
||
// worn slot with A.
|
||
if cmd.SourceInstanceValue == 0 && cmd.DestinationInstanceValue != 0 && bOccupied {
|
||
return reverseMoveEndpoints(cmd), true, nil
|
||
}
|
||
if cmd.SourceInstanceValue != 0 && cmd.DestinationInstanceValue == 0 && aOccupied {
|
||
return cmd, false, nil
|
||
}
|
||
if aOccupied {
|
||
return cmd, false, nil
|
||
}
|
||
return reverseMoveEndpoints(cmd), true, nil
|
||
}
|
||
|
||
func moveEndpointOccupied(inventory *dnfrepo.InventoryRecord, equipment *dnfrepo.EquipmentRecord, listType byte, slotIndex int16) (bool, error) {
|
||
if listType == wireListEquipment {
|
||
entry, occupied := equipment.Entries[entryKey(slotIndex)]
|
||
if occupied && entry.SlotIndex != slotIndex {
|
||
return false, fmt.Errorf("%w: key=%d entry=%d", ErrMoveSlotMismatch, slotIndex, entry.SlotIndex)
|
||
}
|
||
return occupied, nil
|
||
}
|
||
items, _, ok := inventoryMap(inventory, listType)
|
||
if !ok {
|
||
return false, fmt.Errorf("%w: endpoint list=%d", ErrMoveUnsupported, listType)
|
||
}
|
||
_, occupied := items[inventoryKey(listType, slotIndex)]
|
||
return occupied, nil
|
||
}
|
||
|
||
func reverseMoveEndpoints(cmd MoveCommand) MoveCommand {
|
||
return MoveCommand{
|
||
SelectedCharacterID: cmd.SelectedCharacterID,
|
||
SourceListType: cmd.DestinationListType,
|
||
SourceSlotIndex: cmd.DestinationSlotIndex,
|
||
SourceInstanceValue: cmd.DestinationInstanceValue,
|
||
MoveCount: cmd.MoveCount,
|
||
DestinationListType: cmd.SourceListType,
|
||
DestinationSlotIndex: cmd.SourceSlotIndex,
|
||
DestinationInstanceValue: cmd.SourceInstanceValue,
|
||
}
|
||
}
|
||
|
||
func (o *Owner) equip(ctx context.Context, cmd MoveCommand) (MoveResult, error) {
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
inventory, equipment, err := o.loadMoveRecords(ctx, characterID)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
srcItems, srcField, ok := inventoryMap(&inventory, cmd.SourceListType)
|
||
if !ok {
|
||
return MoveResult{}, fmt.Errorf("%w: source list=%d", ErrMoveUnsupported, cmd.SourceListType)
|
||
}
|
||
if cmd.SourceListType == wireListGuildMedal && !guildMedalInventorySlotContains(cmd.SourceSlotIndex) {
|
||
return MoveResult{}, fmt.Errorf("%w: guild-medal source slot=%d", ErrMoveUnsupported, cmd.SourceSlotIndex)
|
||
}
|
||
srcKey := inventoryKey(cmd.SourceListType, cmd.SourceSlotIndex)
|
||
source, ok := srcItems[srcKey]
|
||
if !ok {
|
||
return MoveResult{}, fmt.Errorf("%w: source list=%d slot=%d", ErrMoveSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
|
||
}
|
||
if !isEquipmentMoveUnit(source, cmd.SourceListType) {
|
||
return MoveResult{}, fmt.Errorf("%w: count=%d", ErrMoveStackCountInvalid, source.Count)
|
||
}
|
||
entry, err := entryFromStack(source, cmd.SourceListType, cmd.DestinationSlotIndex, cmd.SourceInstanceValue)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
entry = removeSealOnFirstEquipmentUse(entry, source)
|
||
if err := o.validatePlacement(ctx, characterID, source.ItemID, cmd.SourceListType, cmd.SourceSlotIndex, cmd.DestinationSlotIndex); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
|
||
result := MoveResult{
|
||
CharacterID: characterID,
|
||
SourceListType: cmd.SourceListType,
|
||
SourceSlotIndex: cmd.SourceSlotIndex,
|
||
DestinationListType: cmd.DestinationListType,
|
||
DestinationSlotIndex: cmd.DestinationSlotIndex,
|
||
ItemID: source.ItemID,
|
||
Mode: "equip",
|
||
}
|
||
dstKey := entryKey(cmd.DestinationSlotIndex)
|
||
previous, occupied := equipment.Entries[dstKey]
|
||
entry = o.withCloneAvatarAppearance(entry, previous, occupied, cmd.DestinationSlotIndex,
|
||
inventoryKey(cmd.SourceListType, cmd.SourceSlotIndex))
|
||
saveFields := []dnfrepo.InventoryField{srcField}
|
||
if occupied {
|
||
if previous.SlotIndex != cmd.DestinationSlotIndex {
|
||
return MoveResult{}, fmt.Errorf("%w: key=%d entry=%d", ErrMoveSlotMismatch, cmd.DestinationSlotIndex, previous.SlotIndex)
|
||
}
|
||
displaced := stackFromEntry(clearCloneAvatarAppearance(previous))
|
||
// 【2026-09-28 修复】被顶下来的若是「从称号簿穿上来的」成就称号,必须回称号簿,
|
||
// 而不是落进玩家请求里的那个背包格。玩家「直接穿背包里的旧称号」时客户端声明的
|
||
// 落点就是那个背包格,成就称号因此掉进背包(客户端字符串 48178:成就称号无法
|
||
// 移动到物品栏或金库)。改写落点后,新称号原来占的背包格要显式清掉。
|
||
if bookList, bookSlot, isBookTitle := titleBookOriginDestination(previous); isBookTitle {
|
||
bookItems, bookField, mapOK := inventoryMap(&inventory, bookList)
|
||
if !mapOK {
|
||
return MoveResult{}, fmt.Errorf("%w: title book destination list=%d", ErrMoveUnsupported, bookList)
|
||
}
|
||
resolved, slotOK := titleBookReturnSlot(bookItems, bookList, bookSlot)
|
||
if !slotOK {
|
||
return MoveResult{}, fmt.Errorf(
|
||
"%w: title book category full for equip slot=%d",
|
||
ErrMoveSlotNotFound, cmd.DestinationSlotIndex,
|
||
)
|
||
}
|
||
bookItems[inventoryKey(bookList, resolved)] = displaced
|
||
delete(srcItems, srcKey)
|
||
if bookField != srcField {
|
||
saveFields = append(saveFields, bookField)
|
||
}
|
||
} else {
|
||
srcItems[srcKey] = displaced
|
||
}
|
||
result.SwappedItemID = previous.ItemID
|
||
result.Mode = "equip_swap"
|
||
} else {
|
||
delete(srcItems, srcKey)
|
||
}
|
||
equipment.Entries[dstKey] = entry
|
||
return o.saveMove(ctx, inventory, equipment, saveFields, result)
|
||
}
|
||
|
||
func removeSealOnFirstEquipmentUse(entry dnfrepo.EquipmentEntry, source dnfrepo.ItemStack) dnfrepo.EquipmentEntry {
|
||
// The current 0x77 item row's byte +0x0D is the visible sealed-state
|
||
// byte. Runtime-PVF [attach type] [sealing] grants persist it as the
|
||
// explicit seal_flag extra. The first successful equip consumes that
|
||
// state permanently; otherwise unequipping copies the old flag back and
|
||
// the client asks to remove the seal every time.
|
||
if firstExtraInt(source.Extra, 0, "seal_flag", "seal") == 0 {
|
||
return entry
|
||
}
|
||
if len(entry.RawEntry) > 0x0D {
|
||
entry.RawEntry[0x0D] = 0
|
||
}
|
||
if entry.Extra == nil {
|
||
entry.Extra = make(map[string]string, 4)
|
||
}
|
||
for _, key := range []string{"seal_flag", "seal", "equipment_update_seal_flag", "update_seal_flag"} {
|
||
delete(entry.Extra, key)
|
||
}
|
||
entry.Extra["seal_removed_by_first_equip"] = "1"
|
||
entry.Extra["trade_locked_by_first_equip"] = "1"
|
||
return entry
|
||
}
|
||
|
||
func (o *Owner) unequip(ctx context.Context, cmd MoveCommand) (MoveResult, error) {
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
inventory, equipment, err := o.loadMoveRecords(ctx, characterID)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
dstItems, dstField, ok := inventoryMap(&inventory, cmd.DestinationListType)
|
||
if !ok {
|
||
return MoveResult{}, fmt.Errorf("%w: destination list=%d", ErrMoveUnsupported, cmd.DestinationListType)
|
||
}
|
||
if cmd.DestinationListType == wireListGuildMedal && !guildMedalInventorySlotContains(cmd.DestinationSlotIndex) {
|
||
return MoveResult{}, fmt.Errorf("%w: guild-medal destination slot=%d", ErrMoveUnsupported, cmd.DestinationSlotIndex)
|
||
}
|
||
srcKey := entryKey(cmd.SourceSlotIndex)
|
||
source, ok := equipment.Entries[srcKey]
|
||
if !ok {
|
||
return MoveResult{}, fmt.Errorf("%w: equip slot=%d", ErrMoveSlotNotFound, cmd.SourceSlotIndex)
|
||
}
|
||
if source.SlotIndex != cmd.SourceSlotIndex {
|
||
return MoveResult{}, fmt.Errorf("%w: key=%d entry=%d", ErrMoveSlotMismatch, cmd.SourceSlotIndex, source.SlotIndex)
|
||
}
|
||
// PR #239: name tag cards can only be replaced by purchase, not manually unequipped.
|
||
if o.nameTagChecker != nil && source.ItemID > 0 && o.nameTagChecker(uint32(source.ItemID)) {
|
||
return MoveResult{}, fmt.Errorf("%w: name tag card cannot be unequipped", ErrMoveUnsupported)
|
||
}
|
||
// 【2026-09-28 修复】簿内资产(成就称号)的两处收口:
|
||
//
|
||
// ① 卡死行放行:achievement.grantTitle 入簿时不写 RawEntry,titlebook.Equip
|
||
// 过去又原样把空 raw 搬进装备行,于是这里第二道门
|
||
// `len(source.RawEntry) == 0 → ErrMoveRawEntryMissing` 会把"摘称号"永久
|
||
// 挡掉 —— 玩家看到的就是"称号展示栏卡了一个成就称号,摘不下来"(礼白666)。
|
||
// 簿内资产我们知道它的物品号和槽位,就地补一行合法行数据放行,玩家重登后
|
||
// 正常摘一次即自愈,不需要改库。
|
||
// ② 落点改写:成就称号只能在称号簿 ↔ 装备栏之间移动(客户端字符串 48178
|
||
// 「通过成就任务获得的称号,无法移动到物品栏或金库。」),摘下来必须回簿,
|
||
// 绝不能落进客户端声明的那个背包格。
|
||
bookList, bookSlot, isBookTitle := titleBookOriginDestination(source)
|
||
if len(source.RawEntry) == 0 {
|
||
if !isBookTitle {
|
||
return MoveResult{}, ErrMoveRawEntryMissing
|
||
}
|
||
source.RawEntry = buildCurrentTitleEquipmentRawEntry(source.ItemID, cmd.SourceSlotIndex)
|
||
}
|
||
if isBookTitle {
|
||
bookItems, bookField, mapOK := inventoryMap(&inventory, bookList)
|
||
if !mapOK {
|
||
return MoveResult{}, fmt.Errorf("%w: title book destination list=%d", ErrMoveUnsupported, bookList)
|
||
}
|
||
resolved, slotOK := titleBookReturnSlot(bookItems, bookList, bookSlot)
|
||
if !slotOK {
|
||
return MoveResult{}, fmt.Errorf(
|
||
"%w: title book category full for equip slot=%d",
|
||
ErrMoveSlotNotFound, cmd.SourceSlotIndex,
|
||
)
|
||
}
|
||
cmd.DestinationListType = bookList
|
||
cmd.DestinationSlotIndex = resolved
|
||
dstItems, dstField = bookItems, bookField
|
||
}
|
||
|
||
result := MoveResult{
|
||
CharacterID: characterID,
|
||
SourceListType: cmd.SourceListType,
|
||
SourceSlotIndex: cmd.SourceSlotIndex,
|
||
DestinationListType: cmd.DestinationListType,
|
||
DestinationSlotIndex: cmd.DestinationSlotIndex,
|
||
ItemID: source.ItemID,
|
||
Mode: "unequip",
|
||
}
|
||
dstKey := inventoryKey(cmd.DestinationListType, cmd.DestinationSlotIndex)
|
||
if _, occupied := dstItems[dstKey]; occupied && cmd.DestinationInstanceValue == 0 {
|
||
actualSlot, found := nextEmptyUnequipInventorySlot(dstItems, cmd.DestinationListType, cmd.DestinationSlotIndex)
|
||
if !found {
|
||
return MoveResult{}, fmt.Errorf("%w: no empty unequip destination after list=%d slot=%d", ErrMoveSlotNotFound, cmd.DestinationListType, cmd.DestinationSlotIndex)
|
||
}
|
||
cmd.DestinationSlotIndex = actualSlot
|
||
result.DestinationSlotIndex = actualSlot
|
||
dstKey = inventoryKey(cmd.DestinationListType, actualSlot)
|
||
}
|
||
if previous, occupied := dstItems[dstKey]; occupied {
|
||
entry, err := entryFromStack(previous, cmd.DestinationListType, cmd.SourceSlotIndex, cmd.DestinationInstanceValue)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
if err := o.validatePlacement(ctx, characterID, previous.ItemID, cmd.DestinationListType, cmd.DestinationSlotIndex, cmd.SourceSlotIndex); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
entry = o.withCloneAvatarAppearance(entry, source, true, cmd.SourceSlotIndex,
|
||
inventoryKey(cmd.DestinationListType, cmd.DestinationSlotIndex))
|
||
equipment.Entries[srcKey] = entry
|
||
result.SwappedItemID = previous.ItemID
|
||
result.Mode = "unequip_swap"
|
||
} else {
|
||
delete(equipment.Entries, srcKey)
|
||
}
|
||
dstItems[dstKey] = stackFromEntry(clearCloneAvatarAppearance(source))
|
||
return o.saveMove(ctx, inventory, equipment, []dnfrepo.InventoryField{dstField}, result)
|
||
}
|
||
|
||
// currentAuraEquipmentSlot 是客户端唯一消费 USERINFO 关联块的穿戴槽。A21/90CN
|
||
// 客户端的装备 entry 读取器只在 slot == 9 时读 `u32 长度 + 原实例镶嵌`(见
|
||
// tools 里的逆向结论),所以转职光环只在槽 9 记录关联来源;放到其它槽(例如
|
||
// 光环幻化栏 11)时它既不需要也不应该带关联。
|
||
const currentAuraEquipmentSlot int16 = 9
|
||
|
||
func (o *Owner) withCloneAvatarAppearance(entry, previous dnfrepo.EquipmentEntry, occupied bool, slotIndex int16, linkedSource string) dnfrepo.EquipmentEntry {
|
||
entry.Extra = cloneExtra(entry.Extra)
|
||
delete(entry.Extra, dnfrepo.CloneAvatarAppearanceExtraKey)
|
||
delete(entry.Extra, dnfrepo.AuraLinkedTemplateExtraKey)
|
||
delete(entry.Extra, dnfrepo.AuraLinkedSourceExtraKey)
|
||
if entry.ItemID <= 0 || entry.ItemID > int64(^uint32(0)) {
|
||
return entry
|
||
}
|
||
// 转职光环走独立分支,且必须在 avatarClassifier 判空之前判断:它同样属于
|
||
// `clear avatar`,但语义与克隆装扮相反——外观保留自己,被覆盖的光环只作为
|
||
// 关联来源。旧代码把它当克隆装扮处理,于是把被覆盖光环的模板写进外观 key,
|
||
// 客户端渲染回原光环,正是"原光环外观没有被替代"的现象。
|
||
if o.isAuroraLookReplace(uint32(entry.ItemID)) {
|
||
if slotIndex != currentAuraEquipmentSlot {
|
||
return entry
|
||
}
|
||
if !occupied || previous.ItemID <= 0 || previous.ItemID > int64(^uint32(0)) {
|
||
return entry
|
||
}
|
||
// 被覆盖的必须是一件普通光环;转职光环自己不能充当属性来源(参考实现的
|
||
// IsAuroraStatSourceAvatar 同一口径)。绑定失效时什么都不写,绝不猜。
|
||
if o.isAuroraLookReplace(uint32(previous.ItemID)) {
|
||
return entry
|
||
}
|
||
if o.avatarClassifier == nil {
|
||
return entry
|
||
}
|
||
if isAvatar, _ := o.avatarClassifier(uint32(previous.ItemID)); !isAvatar {
|
||
return entry
|
||
}
|
||
entry.Extra[dnfrepo.AuraLinkedTemplateExtraKey] = strconv.FormatInt(previous.ItemID, 10)
|
||
// 记录被覆盖光环的落点,投影时据此实时取它的镶嵌字节(不能穿戴时快照:
|
||
// 玩家完全可能穿好转职光环之后才给那件光环打徽章)。没有落点时只保留
|
||
// 模板,不编造来源。
|
||
if source := strings.TrimSpace(linkedSource); source != "" {
|
||
entry.Extra[dnfrepo.AuraLinkedSourceExtraKey] = source
|
||
}
|
||
return entry
|
||
}
|
||
if o == nil || o.avatarClassifier == nil {
|
||
return entry
|
||
}
|
||
_, isClone := o.avatarClassifier(uint32(entry.ItemID))
|
||
if !isClone || !occupied || previous.ItemID <= 0 || previous.ItemID > int64(^uint32(0)) {
|
||
return entry
|
||
}
|
||
isAvatar, previousIsClone := o.avatarClassifier(uint32(previous.ItemID))
|
||
if !isAvatar {
|
||
return entry
|
||
}
|
||
appearanceID := previous.ItemID
|
||
if previousIsClone {
|
||
appearanceID = firstExtraInt(previous.Extra, 0, dnfrepo.CloneAvatarAppearanceExtraKey)
|
||
}
|
||
if appearanceID <= 0 || appearanceID > int64(^uint32(0)) {
|
||
return entry
|
||
}
|
||
entry.Extra[dnfrepo.CloneAvatarAppearanceExtraKey] = strconv.FormatInt(appearanceID, 10)
|
||
return entry
|
||
}
|
||
|
||
func clearCloneAvatarAppearance(entry dnfrepo.EquipmentEntry) dnfrepo.EquipmentEntry {
|
||
entry.Extra = cloneExtra(entry.Extra)
|
||
delete(entry.Extra, dnfrepo.CloneAvatarAppearanceExtraKey)
|
||
delete(entry.Extra, dnfrepo.AuraLinkedTemplateExtraKey)
|
||
delete(entry.Extra, dnfrepo.AuraLinkedSourceExtraKey)
|
||
return entry
|
||
}
|
||
|
||
const (
|
||
// CloneAvatarDisplayItemIDExtraKey persists the ordinary avatar whose look
|
||
// is borrowed by a physically equipped clear-avatar item.
|
||
CloneAvatarDisplayItemIDExtraKey = "clone_avatar_display_item_id"
|
||
|
||
mainEquipmentInventorySlotStart int16 = 9
|
||
mainEquipmentInventorySlotEnd int16 = 64
|
||
avatarInventorySlotStart int16 = 0
|
||
avatarInventorySlotEnd int16 = 209
|
||
petBodyInventorySlotStart int16 = 0
|
||
petBodyInventorySlotEnd int16 = 139
|
||
petArtifactInventorySlotStart int16 = 140
|
||
petArtifactInventorySlotEnd int16 = 188
|
||
guildMedalInventorySlotStart int16 = 0
|
||
guildMedalInventorySlotEnd int16 = 48
|
||
)
|
||
|
||
func nextEmptyUnequipInventorySlot(items map[string]dnfrepo.ItemStack, listType byte, requested int16) (int16, bool) {
|
||
start, end := mainEquipmentInventorySlotStart, mainEquipmentInventorySlotEnd
|
||
switch listType {
|
||
case wireListMain:
|
||
// Equipment page only. Slots 0..8 are the main-list quick bar and the
|
||
// later PVF pages own consumables, materials, quests, and professions.
|
||
case wireListAvatar:
|
||
// Current NoPack list-1 sort/page reader owns the 0..209 avatar page.
|
||
start, end = avatarInventorySlotStart, avatarInventorySlotEnd
|
||
case wireListPet:
|
||
// Current NoPack splits list 7 into creature bodies 0..139 and
|
||
// creature artifacts 140..188. Never redirect an unequip across those
|
||
// pages; slots 189..238 are pet consumables and are not worn endpoints.
|
||
switch {
|
||
case requested >= petBodyInventorySlotStart && requested <= petBodyInventorySlotEnd:
|
||
start, end = petBodyInventorySlotStart, petBodyInventorySlotEnd
|
||
case requested >= petArtifactInventorySlotStart && requested <= petArtifactInventorySlotEnd:
|
||
start, end = petArtifactInventorySlotStart, petArtifactInventorySlotEnd
|
||
default:
|
||
return 0, false
|
||
}
|
||
case wireListGuildMedal:
|
||
// Current NoPack sub_E9AC00/sub_E9AC30 split the shared 98-slot
|
||
// list-38 container into two 49-slot pages. Medal equip/unequip owns
|
||
// only the first page; slots 49..97 belong to guardian gems.
|
||
start, end = guildMedalInventorySlotStart, guildMedalInventorySlotEnd
|
||
default:
|
||
return 0, false
|
||
}
|
||
if requested < start || requested > end {
|
||
return 0, false
|
||
}
|
||
for slot := requested; slot <= end; slot++ {
|
||
if _, occupied := items[inventoryKey(listType, slot)]; !occupied {
|
||
return slot, true
|
||
}
|
||
}
|
||
for slot := start; slot < requested; slot++ {
|
||
if _, occupied := items[inventoryKey(listType, slot)]; !occupied {
|
||
return slot, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func guildMedalInventorySlotContains(slot int16) bool {
|
||
return slot >= guildMedalInventorySlotStart && slot <= guildMedalInventorySlotEnd
|
||
}
|
||
|
||
func (o *Owner) swapEquipSlots(ctx context.Context, cmd MoveCommand) (MoveResult, error) {
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
record, ok, err := o.equipment.Load(ctx, characterID)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
if !ok {
|
||
return MoveResult{}, ErrEquipmentNotFound
|
||
}
|
||
record = ensureEquipmentRecord(dnfrepo.CloneEquipment(record), characterID)
|
||
srcKey := entryKey(cmd.SourceSlotIndex)
|
||
dstKey := entryKey(cmd.DestinationSlotIndex)
|
||
source, sourceOK := record.Entries[srcKey]
|
||
destination, destinationOK := record.Entries[dstKey]
|
||
if !sourceOK {
|
||
return MoveResult{}, fmt.Errorf("%w: equip slot=%d", ErrMoveSlotNotFound, cmd.SourceSlotIndex)
|
||
}
|
||
if source.SlotIndex != cmd.SourceSlotIndex {
|
||
return MoveResult{}, fmt.Errorf("%w: key=%d entry=%d", ErrMoveSlotMismatch, cmd.SourceSlotIndex, source.SlotIndex)
|
||
}
|
||
if len(source.RawEntry) == 0 {
|
||
return MoveResult{}, ErrMoveRawEntryMissing
|
||
}
|
||
if destinationOK && destination.SlotIndex != cmd.DestinationSlotIndex {
|
||
return MoveResult{}, fmt.Errorf("%w: key=%d entry=%d", ErrMoveSlotMismatch, cmd.DestinationSlotIndex, destination.SlotIndex)
|
||
}
|
||
if destinationOK && len(destination.RawEntry) == 0 {
|
||
return MoveResult{}, ErrMoveRawEntryMissing
|
||
}
|
||
if cmd.SourceSlotIndex == cmd.DestinationSlotIndex {
|
||
return MoveResult{CharacterID: characterID, SourceListType: cmd.SourceListType, SourceSlotIndex: cmd.SourceSlotIndex, DestinationListType: cmd.DestinationListType, DestinationSlotIndex: cmd.DestinationSlotIndex, ItemID: source.ItemID, Mode: "noop"}, nil
|
||
}
|
||
if err := o.validatePlacement(ctx, characterID, source.ItemID, wireListEquipment, cmd.SourceSlotIndex, cmd.DestinationSlotIndex); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
if destinationOK {
|
||
if err := o.validatePlacement(ctx, characterID, destination.ItemID, wireListEquipment, cmd.DestinationSlotIndex, cmd.SourceSlotIndex); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
}
|
||
source = equipmentEntryMovedToSlot(source, cmd.DestinationSlotIndex)
|
||
record.Entries[dstKey] = source
|
||
if destinationOK {
|
||
destination = equipmentEntryMovedToSlot(destination, cmd.SourceSlotIndex)
|
||
record.Entries[srcKey] = destination
|
||
} else {
|
||
delete(record.Entries, srcKey)
|
||
}
|
||
record.UpdatedAt = time.Now()
|
||
if err := dnfrepo.SaveEquipmentFields(ctx, o.equipment, record, dnfrepo.EquipmentFieldEntries); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
result := MoveResult{
|
||
CharacterID: characterID,
|
||
SourceListType: cmd.SourceListType,
|
||
SourceSlotIndex: cmd.SourceSlotIndex,
|
||
DestinationListType: cmd.DestinationListType,
|
||
DestinationSlotIndex: cmd.DestinationSlotIndex,
|
||
ItemID: source.ItemID,
|
||
Mode: "equip_slot_swap",
|
||
Changed: true,
|
||
}
|
||
if destinationOK {
|
||
result.SwappedItemID = destination.ItemID
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// equipmentEntryMovedToSlot keeps the authoritative map key/SlotIndex and the
|
||
// current-client runtime metadata in lockstep. The Vagabond dual-weapon button
|
||
// swaps slots 12 and 24 in-place; leaving the old metadata attached to either
|
||
// item makes later appearance projections see two slot-12 weapons after one of
|
||
// them is replaced from the inventory.
|
||
func equipmentEntryMovedToSlot(entry dnfrepo.EquipmentEntry, slot int16) dnfrepo.EquipmentEntry {
|
||
entry.SlotIndex = slot
|
||
entry.Extra = cloneExtra(entry.Extra)
|
||
slotText := strconv.Itoa(int(slot))
|
||
entry.Extra["equipment_slot"] = slotText
|
||
entry.Extra["equipped_slot"] = slotText
|
||
entry.Extra["current_exe_equipment_type"] = slotText
|
||
entry.Extra["current_exe_runtime_move"] = "1"
|
||
return entry
|
||
}
|
||
|
||
func (o *Owner) loadMoveRecords(ctx context.Context, characterID string) (dnfrepo.InventoryRecord, dnfrepo.EquipmentRecord, error) {
|
||
inventory, ok, err := o.inventory.Load(ctx, characterID)
|
||
if err != nil {
|
||
return dnfrepo.InventoryRecord{}, dnfrepo.EquipmentRecord{}, err
|
||
}
|
||
if !ok {
|
||
return dnfrepo.InventoryRecord{}, dnfrepo.EquipmentRecord{}, ErrInventoryNotFound
|
||
}
|
||
equipment, ok, err := o.equipment.Load(ctx, characterID)
|
||
if err != nil {
|
||
return dnfrepo.InventoryRecord{}, dnfrepo.EquipmentRecord{}, err
|
||
}
|
||
if !ok {
|
||
return dnfrepo.InventoryRecord{}, dnfrepo.EquipmentRecord{}, ErrEquipmentNotFound
|
||
}
|
||
return ensureInventoryRecord(dnfrepo.CloneInventory(inventory), characterID), ensureEquipmentRecord(dnfrepo.CloneEquipment(equipment), characterID), nil
|
||
}
|
||
|
||
func (o *Owner) saveMove(ctx context.Context, inventory dnfrepo.InventoryRecord, equipment dnfrepo.EquipmentRecord, fields []dnfrepo.InventoryField, result MoveResult) (MoveResult, error) {
|
||
now := time.Now()
|
||
inventory.UpdatedAt = now
|
||
equipment.UpdatedAt = now
|
||
if err := dnfrepo.SaveInventoryFields(ctx, o.inventory, inventory, fields...); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
if err := dnfrepo.SaveEquipmentFields(ctx, o.equipment, equipment, dnfrepo.EquipmentFieldEntries); err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
result.Changed = true
|
||
result.InventorySlots = cloneItemMap(inventory.Slots)
|
||
return result, nil
|
||
}
|
||
|
||
func entryKey(slotIndex int16) string {
|
||
return strconv.FormatInt(int64(slotIndex), 10)
|
||
}
|
||
|
||
const currentEquipmentSlotCount int16 = 33
|
||
|
||
func validateMoveStructure(cmd MoveCommand) (moveKind, error) {
|
||
if cmd.SourceListType != wireListEquipment && cmd.DestinationListType != wireListEquipment {
|
||
return moveKindOrdinary, ErrMoveUnsupported
|
||
}
|
||
if cmd.SourceSlotIndex < 0 || cmd.DestinationSlotIndex < 0 {
|
||
return moveKindOrdinary, fmt.Errorf("%w: src=%d dst=%d", ErrMoveSlotOutOfRange, cmd.SourceSlotIndex, cmd.DestinationSlotIndex)
|
||
}
|
||
if cmd.SourceListType == wireListEquipment && cmd.SourceSlotIndex >= currentEquipmentSlotCount {
|
||
return moveKindOrdinary, fmt.Errorf("%w: source=%d max=%d", ErrMoveSlotOutOfRange, cmd.SourceSlotIndex, currentEquipmentSlotCount-1)
|
||
}
|
||
if cmd.DestinationListType == wireListEquipment && cmd.DestinationSlotIndex >= currentEquipmentSlotCount {
|
||
return moveKindOrdinary, fmt.Errorf("%w: destination=%d max=%d", ErrMoveSlotOutOfRange, cmd.DestinationSlotIndex, currentEquipmentSlotCount-1)
|
||
}
|
||
|
||
petMove := (cmd.SourceListType == wireListPet &&
|
||
cmd.DestinationListType == wireListEquipment &&
|
||
cmd.SourceSlotIndex <= 139 &&
|
||
cmd.DestinationSlotIndex == petCreatureWornSlot) ||
|
||
(cmd.SourceListType == wireListEquipment &&
|
||
cmd.SourceSlotIndex == petCreatureWornSlot &&
|
||
cmd.DestinationListType == wireListPet &&
|
||
cmd.DestinationSlotIndex <= 139)
|
||
artifactMove := (cmd.SourceListType == wireListPet &&
|
||
cmd.DestinationListType == wireListEquipment &&
|
||
cmd.SourceSlotIndex >= petArtifactSourceSlotMin && cmd.SourceSlotIndex <= petArtifactSourceSlotMax &&
|
||
isPetArtifactTarget(cmd.DestinationSlotIndex)) ||
|
||
(cmd.SourceListType == wireListEquipment &&
|
||
isPetArtifactTarget(cmd.SourceSlotIndex) &&
|
||
cmd.DestinationListType == wireListPet &&
|
||
cmd.DestinationSlotIndex >= petArtifactSourceSlotMin && cmd.DestinationSlotIndex <= petArtifactSourceSlotMax)
|
||
if cmd.SourceListType == wireListPet || cmd.DestinationListType == wireListPet {
|
||
if petMove {
|
||
return moveKindPet, nil
|
||
}
|
||
if artifactMove {
|
||
return moveKindArtifact, nil
|
||
}
|
||
return moveKindOrdinary, fmt.Errorf("%w: pet move src=(%d,%d) dst=(%d,%d)", ErrMoveUnsupported, cmd.SourceListType, cmd.SourceSlotIndex, cmd.DestinationListType, cmd.DestinationSlotIndex)
|
||
}
|
||
if !isOrdinaryMoveList(cmd.SourceListType) || !isOrdinaryMoveList(cmd.DestinationListType) {
|
||
return moveKindOrdinary, fmt.Errorf("%w: src=%d dst=%d", ErrMoveUnsupported, cmd.SourceListType, cmd.DestinationListType)
|
||
}
|
||
return moveKindOrdinary, nil
|
||
}
|
||
|
||
// isDualWeaponToggleMove recognizes the Vagabond "change sub weapon" op19:
|
||
// worn weapon slots 12 <-> 24 with count=0. Nothing else may bypass the
|
||
// count=1 rule.
|
||
func isDualWeaponToggleMove(cmd MoveCommand) bool {
|
||
if cmd.MoveCount != 0 || cmd.SourceListType != wireListEquipment || cmd.DestinationListType != wireListEquipment {
|
||
return false
|
||
}
|
||
return (cmd.SourceSlotIndex == 12 && cmd.DestinationSlotIndex == 24) ||
|
||
(cmd.SourceSlotIndex == 24 && cmd.DestinationSlotIndex == 12)
|
||
}
|
||
|
||
type moveKind int
|
||
|
||
const (
|
||
moveKindOrdinary moveKind = iota
|
||
moveKindPet
|
||
moveKindArtifact
|
||
)
|
||
|
||
// Current NoPack closes the artifact family as runtime targets 27/28/29
|
||
// (sub_35FC900 -> equip_artifactred/blue/green) and the shared list-7 source
|
||
// range 140..188 (sub_30A4950's unequip search); it is not one slot per color.
|
||
const (
|
||
petArtifactSourceSlotMin int16 = 140
|
||
petArtifactSourceSlotMax int16 = 188
|
||
)
|
||
|
||
func isPetArtifactTarget(slot int16) bool {
|
||
return slot >= 27 && slot <= 29
|
||
}
|
||
|
||
func isOrdinaryMoveList(listType byte) bool {
|
||
switch listType {
|
||
case wireListMain, wireListAvatar, wireListPersonalCargo, wireListEquipment, wireListGuildMedal:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (o *Owner) validatePlacement(ctx context.Context, characterID string, itemID int64, sourceListType byte, sourceSlotIndex int16, targetSlotIndex int16) error {
|
||
// Current NoPack op19 closes the creature body path as list-7 slots
|
||
// 0..139 <-> actor worn target 26.
|
||
if sourceListType == wireListPet && sourceSlotIndex >= 0 && sourceSlotIndex <= 139 && targetSlotIndex == petCreatureWornSlot {
|
||
return nil
|
||
}
|
||
if o == nil || o.placementValidator == nil {
|
||
return ErrMoveValidatorRequired
|
||
}
|
||
return o.placementValidator.ValidateEquipmentPlacement(ctx, Placement{
|
||
CharacterID: characterID,
|
||
ItemID: itemID,
|
||
SourceListType: sourceListType,
|
||
SourceSlotIndex: sourceSlotIndex,
|
||
TargetSlotIndex: targetSlotIndex,
|
||
})
|
||
}
|
||
|
||
const (
|
||
wireListMain byte = 0
|
||
wireListAvatar byte = 1
|
||
wireListPersonalCargo byte = 2
|
||
wireListEquipment byte = 3
|
||
wireListPet byte = 7
|
||
wireListGuildMedal byte = 38
|
||
petCreatureWornSlot int16 = 26
|
||
)
|
||
|
||
func ensureInventoryRecord(record dnfrepo.InventoryRecord, characterID string) dnfrepo.InventoryRecord {
|
||
record.CharacterID = characterID
|
||
if record.Slots == nil {
|
||
record.Slots = make(map[string]dnfrepo.ItemStack)
|
||
}
|
||
if record.Warehouse == nil {
|
||
record.Warehouse = make(map[string]dnfrepo.ItemStack)
|
||
}
|
||
return record
|
||
}
|
||
|
||
func ensureEquipmentRecord(record dnfrepo.EquipmentRecord, characterID string) dnfrepo.EquipmentRecord {
|
||
record.CharacterID = characterID
|
||
if record.Entries == nil {
|
||
record.Entries = make(map[string]dnfrepo.EquipmentEntry)
|
||
}
|
||
return record
|
||
}
|
||
|
||
func inventoryMap(record *dnfrepo.InventoryRecord, listType byte) (map[string]dnfrepo.ItemStack, dnfrepo.InventoryField, bool) {
|
||
switch listType {
|
||
case wireListMain, wireListAvatar, wireListPet, wireListGuildMedal, dnftitlebook.ListType:
|
||
// 称号簿(list 100)的条目和背包/装扮共用 InventoryRecord.Slots,
|
||
// 只是 key 前缀不同("0:<slot>" vs "100:<slot>",见 titlebook.BookSlotKey),
|
||
// 所以它同样落在 InventoryFieldSlots 上。
|
||
return record.Slots, dnfrepo.InventoryFieldSlots, true
|
||
case wireListPersonalCargo:
|
||
return record.Warehouse, dnfrepo.InventoryFieldWarehouse, true
|
||
default:
|
||
return nil, "", false
|
||
}
|
||
}
|
||
|
||
// titleBookOriginDestination 判断一件装备行是不是「从称号簿穿上来」的成就称号,
|
||
// 并给出它该回到的称号簿槽位。
|
||
//
|
||
// 官方语义(客户端字符串 48178 / 0xBC32):「通过成就任务获得的称号,无法移动到
|
||
// 物品栏或金库。」也就是说成就称号是簿内资产,只允许在称号簿 ↔ 装备栏之间移动。
|
||
// titlebook.Owner.Equip 在把称号穿上时打了 source=title_book_equip +
|
||
// title_book_category/title_book_index,这里就是它的消费点。
|
||
//
|
||
// 背景(2026-09-28 玩家报障):从簿里「右键使用」穿上成就称号后,再穿回原来的
|
||
// 称号,成就称号会掉进背包。原因是 equip/unequip 的写回路径从不看这些标记,
|
||
// 一律写到客户端声明的那个背包格。
|
||
func titleBookOriginDestination(entry dnfrepo.EquipmentEntry) (byte, int16, bool) {
|
||
// 2026-09-28 线上回退:本函数一生效就会改写 op19/op413 的落点,而线上实测出现
|
||
// 「称号簿右键报错 / 成就称号仍摘不下 / 成就条件不完成」三个回归。短路后行为与改动前
|
||
// 完全一致;诊断时把这三行删掉即可逐条打开。
|
||
return 0, 0, false
|
||
if entry.Extra == nil {
|
||
return 0, 0, false
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(entry.Extra["source"]), "title_book_equip") &&
|
||
!strings.EqualFold(strings.TrimSpace(entry.Extra["source"]), "title_book_get") {
|
||
return 0, 0, false
|
||
}
|
||
category, categoryErr := strconv.ParseInt(strings.TrimSpace(entry.Extra["title_book_category"]), 10, 32)
|
||
index, indexErr := strconv.ParseInt(strings.TrimSpace(entry.Extra["title_book_index"]), 10, 32)
|
||
// 上界与 currentTitleBookStackNeedsRawEntry 保持同一口径:越界就不改写落点,
|
||
// 退回改动前的行为,绝不猜一个可能不存在的分类。
|
||
if categoryErr != nil || indexErr != nil ||
|
||
category < 0 || category >= 5 ||
|
||
index < 0 || index >= int64(dnftitlebook.MaxPerCategory) {
|
||
return 0, 0, false
|
||
}
|
||
return dnftitlebook.ListType,
|
||
int16(category)*dnftitlebook.SlotBase + int16(index), true
|
||
}
|
||
|
||
// titleBookReturnSlot 给出称号簿里的真实可用落点:优先回原来那一格,被占则在该
|
||
// category 内找第一个空格。找不到就失败——成就称号宁可操作不成功,也不能掉进背包
|
||
// (客户端本来就会拒绝这种移动)。
|
||
func titleBookReturnSlot(items map[string]dnfrepo.ItemStack, listType byte, slot int16) (int16, bool) {
|
||
if _, occupied := items[inventoryKey(listType, slot)]; !occupied {
|
||
return slot, true
|
||
}
|
||
base := (slot / dnftitlebook.SlotBase) * dnftitlebook.SlotBase
|
||
for index := int16(0); index < dnftitlebook.MaxPerCategory; index++ {
|
||
candidate := base + index
|
||
if _, occupied := items[inventoryKey(listType, candidate)]; !occupied {
|
||
return candidate, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func inventoryKey(listType byte, slotIndex int16) string {
|
||
return fmt.Sprintf("%d:%d", listType, slotIndex)
|
||
}
|
||
|
||
func entryFromStack(stack dnfrepo.ItemStack, sourceListType byte, equipSlot int16, sourceInstanceValue int32) (dnfrepo.EquipmentEntry, error) {
|
||
if sourceListType == wireListPet && equipSlot == petCreatureWornSlot {
|
||
return petCreatureEntryFromStack(stack, equipSlot)
|
||
}
|
||
raw := append([]byte(nil), stack.RawEntry...)
|
||
if len(raw) == 0 {
|
||
if currentTitleBookStackNeedsRawEntry(stack, sourceListType, equipSlot) {
|
||
raw = buildCurrentTitleEquipmentRawEntry(stack.ItemID, equipSlot)
|
||
} else if rebuilt, ok := buildCurrentEquipmentRawEntryFromMoveEvidence(stack, sourceListType, equipSlot, sourceInstanceValue); ok {
|
||
raw = rebuilt
|
||
} else {
|
||
var err error
|
||
raw, err = rawEntryFromExtra(stack.Extra)
|
||
if err != nil {
|
||
return dnfrepo.EquipmentEntry{}, err
|
||
}
|
||
}
|
||
}
|
||
raw = overlayEquipmentTailDataFromExtra(raw, stack.Extra)
|
||
extra := cloneExtra(stack.Extra)
|
||
// A permanent avatar is a real, occupied list-1 item whose current-EXE
|
||
// amount field is zero. Count==0 therefore cannot mean "empty slot" for
|
||
// this container. Preserve the repository value explicitly while the item
|
||
// is worn, otherwise an unequip would silently turn it into Count==1.
|
||
//
|
||
// This marker is internal persistence state only; the outward 0x77 amount
|
||
// remains reconstructed from amount_or_count / the real ItemStack when a
|
||
// list snapshot is emitted.
|
||
extra["current_exe_inventory_count"] = strconv.FormatInt(stack.Count, 10)
|
||
extra["equipped_slot"] = strconv.Itoa(int(equipSlot))
|
||
// Persist the current-EXE actor slot chosen by the validated op19 move.
|
||
// Initial PVF records use legacy worn-slot numbering and deliberately do
|
||
// not carry this marker; once such an item is moved by the runtime, the
|
||
// explicit slot must win on later op13/mode1 rebuilds.
|
||
extra["current_exe_equipment_type"] = strconv.Itoa(int(equipSlot))
|
||
extra["current_exe_runtime_move"] = "1"
|
||
return dnfrepo.EquipmentEntry{
|
||
SlotIndex: equipSlot,
|
||
ItemID: stack.ItemID,
|
||
Bind: stack.Bind,
|
||
ExpireAt: stack.ExpireAt,
|
||
RawEntry: raw,
|
||
Extra: extra,
|
||
}, nil
|
||
}
|
||
|
||
const currentEquipmentRawEntryWireSize = 0x77
|
||
|
||
// buildCurrentEquipmentRawEntryFromMoveEvidence repairs only the historical
|
||
// relational rows that already have enough current-client/PVF evidence to be
|
||
// rendered as equipment but lost their opaque 0x77 row during import. The
|
||
// live op19 source instance is required; no identity is invented when the
|
||
// client did not prove one.
|
||
func buildCurrentEquipmentRawEntryFromMoveEvidence(
|
||
stack dnfrepo.ItemStack,
|
||
sourceListType byte,
|
||
equipSlot int16,
|
||
sourceInstanceValue int32,
|
||
) ([]byte, bool) {
|
||
if (sourceListType != wireListMain && sourceListType != wireListAvatar) ||
|
||
stack.ItemID <= 0 || stack.ItemID > int64(^uint32(0)) || stack.Count != 1 ||
|
||
sourceInstanceValue == 0 || stack.Extra == nil ||
|
||
!strings.EqualFold(strings.TrimSpace(stack.Extra["item_kind"]), "equipment") ||
|
||
!strings.HasPrefix(strings.ToLower(strings.TrimSpace(stack.Extra["pvf_path"])), "equipment/") {
|
||
return nil, false
|
||
}
|
||
qualitySeed := uint32(firstExtraInt(stack.Extra, 0, "quality_seed"))
|
||
durability := firstExtraInt(stack.Extra, -1, "durability", "current_durability")
|
||
if durability < 0 && currentDungeonCardZeroDurabilityMoveEvidence(stack, sourceInstanceValue) {
|
||
// Accessory PVFs can have no [durability] entry. A frozen dungeon-card
|
||
// reward still carries the PVF type/path, quality seed, and the live
|
||
// client item-id echo, which is enough to restore its zero word safely.
|
||
durability = 0
|
||
}
|
||
if !itemquality.Valid(qualitySeed) || durability < 0 || durability > int64(^uint16(0)) {
|
||
return nil, false
|
||
}
|
||
raw := make([]byte, currentEquipmentRawEntryWireSize)
|
||
binary.LittleEndian.PutUint16(raw[0:2], uint16(equipSlot))
|
||
binary.LittleEndian.PutUint32(raw[2:6], uint32(stack.ItemID))
|
||
binary.LittleEndian.PutUint32(raw[6:10], qualitySeed)
|
||
raw[0x0A] = byte(firstExtraInt(stack.Extra, 0, "packed_flag_byte", "packed_flag"))
|
||
binary.LittleEndian.PutUint16(raw[0x0B:0x0D], uint16(durability))
|
||
raw[0x0D] = byte(firstExtraInt(stack.Extra, 0, "seal_flag", "seal"))
|
||
binary.LittleEndian.PutUint32(raw[0x0E:0x12], uint32(sourceInstanceValue))
|
||
return raw, true
|
||
}
|
||
|
||
func currentDungeonCardZeroDurabilityMoveEvidence(stack dnfrepo.ItemStack, sourceInstanceValue int32) bool {
|
||
if stack.Extra == nil || sourceInstanceValue <= 0 || int64(sourceInstanceValue) != stack.ItemID {
|
||
return false
|
||
}
|
||
return strings.EqualFold(strings.TrimSpace(stack.Extra["source"]), "dungeon_card_reward") &&
|
||
strings.TrimSpace(stack.Extra["equipment_type"]) != ""
|
||
}
|
||
|
||
func currentTitleBookStackNeedsRawEntry(stack dnfrepo.ItemStack, sourceListType byte, equipSlot int16) bool {
|
||
if sourceListType != wireListMain ||
|
||
equipSlot != 13 ||
|
||
stack.ItemID <= 0 ||
|
||
stack.ItemID > int64(^uint32(0)) ||
|
||
!strings.EqualFold(strings.TrimSpace(stack.Extra["source"]), "title_book_get") {
|
||
return false
|
||
}
|
||
category, categoryErr := strconv.ParseInt(strings.TrimSpace(stack.Extra["title_book_category"]), 10, 32)
|
||
index, indexErr := strconv.ParseInt(strings.TrimSpace(stack.Extra["title_book_index"]), 10, 32)
|
||
return categoryErr == nil && category >= 0 && category < 5 &&
|
||
indexErr == nil && index >= 0 && index < 200
|
||
}
|
||
|
||
func buildCurrentTitleEquipmentRawEntry(itemID int64, equipSlot int16) []byte {
|
||
// Current selected-equipment records use the same 43-byte constructor
|
||
// summary as initial equipment. A title has no durability; byte 0 is the
|
||
// validated actor slot, +1 is the PVF item ID, and +5 is the constructor's
|
||
// required create scalar 1. The remaining fields are zero.
|
||
// 实现在 titlebook 包里,equip 与 titlebook 共用同一份,避免两份构造规则漂移。
|
||
return dnftitlebook.EquipmentRawEntry(itemID, equipSlot)
|
||
}
|
||
|
||
func petCreatureEntryFromStack(stack dnfrepo.ItemStack, equipSlot int16) (dnfrepo.EquipmentEntry, error) {
|
||
serial := firstExtraInt(stack.Extra, 0, "creature_serial_or_handle", "creature_serial", "pet_serial", "serial", "handle", "instance_value", "item_uid", "item_serial")
|
||
if serial <= 0 {
|
||
return dnfrepo.EquipmentEntry{}, ErrMoveRawEntryMissing
|
||
}
|
||
enchantCardID, enchantUpgradeCount, err := petEnchantFieldsFromStack(stack)
|
||
if err != nil {
|
||
return dnfrepo.EquipmentEntry{}, err
|
||
}
|
||
raw := buildPetCreatureEquipEntry(equipSlot, stack.ItemID, serial)
|
||
binary.LittleEndian.PutUint32(raw[0x10:0x14], enchantCardID)
|
||
raw[0x14] = enchantUpgradeCount
|
||
extra := cloneExtra(stack.Extra)
|
||
extra["equipped_slot"] = strconv.Itoa(int(equipSlot))
|
||
extra["pet_enchant_card_item_id"] = strconv.FormatUint(uint64(enchantCardID), 10)
|
||
extra["enchant_card_id"] = strconv.FormatUint(uint64(enchantCardID), 10)
|
||
extra["enchant_upgrade_count"] = strconv.FormatUint(uint64(enchantUpgradeCount), 10)
|
||
extra["value_a"] = strconv.FormatUint(uint64(enchantCardID), 10)
|
||
extra["byte_12"] = strconv.FormatUint(uint64(enchantUpgradeCount), 10)
|
||
extra["raw_entry_hex"] = hex.EncodeToString(raw)
|
||
return dnfrepo.EquipmentEntry{
|
||
SlotIndex: equipSlot,
|
||
ItemID: stack.ItemID,
|
||
Bind: stack.Bind,
|
||
ExpireAt: stack.ExpireAt,
|
||
RawEntry: raw,
|
||
Extra: extra,
|
||
}, nil
|
||
}
|
||
|
||
func petEnchantFieldsFromStack(stack dnfrepo.ItemStack) (uint32, byte, error) {
|
||
cardID := firstPetEnchantExtraInt(stack.Extra, -1, "pet_enchant_card_item_id", "enchant_card_id", "value_a")
|
||
if cardID < 0 && len(stack.RawEntry) >= 0x13 {
|
||
cardID = int64(binary.LittleEndian.Uint32(stack.RawEntry[0x0E:0x12]))
|
||
}
|
||
if cardID < 0 {
|
||
cardID = 0
|
||
}
|
||
if cardID > int64(^uint32(0)) {
|
||
return 0, 0, ErrMoveRawEntryInvalid
|
||
}
|
||
upgradeCount := firstExtraInt(stack.Extra, -1, "enchant_upgrade_count", "pet_enchant_upgrade_count", "byte_12")
|
||
if upgradeCount < 0 && len(stack.RawEntry) >= 0x13 {
|
||
upgradeCount = int64(stack.RawEntry[0x12])
|
||
}
|
||
if upgradeCount < 0 {
|
||
upgradeCount = 0
|
||
}
|
||
if upgradeCount > int64(^byte(0)) {
|
||
return 0, 0, ErrMoveRawEntryInvalid
|
||
}
|
||
return uint32(cardID), byte(upgradeCount), nil
|
||
}
|
||
|
||
func firstPetEnchantExtraInt(extra map[string]string, fallback int64, keys ...string) int64 {
|
||
first := fallback
|
||
for _, key := range keys {
|
||
raw := strings.TrimSpace(extra[key])
|
||
if raw == "" {
|
||
continue
|
||
}
|
||
value, err := strconv.ParseInt(raw, 0, 64)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if first == fallback {
|
||
first = value
|
||
}
|
||
if value > 0 {
|
||
return value
|
||
}
|
||
}
|
||
return first
|
||
}
|
||
|
||
func stackFromEntry(entry dnfrepo.EquipmentEntry) dnfrepo.ItemStack {
|
||
extra := cloneExtra(entry.Extra)
|
||
raw := overlayEquipmentTailDataFromExtra(append([]byte(nil), entry.RawEntry...), entry.Extra)
|
||
if len(raw) > 0 {
|
||
extra["raw_entry_hex"] = hex.EncodeToString(raw)
|
||
}
|
||
extra["equipment_slot"] = strconv.Itoa(int(entry.SlotIndex))
|
||
if entry.SlotIndex == petCreatureWornSlot {
|
||
if serial := petSerialFromEquippedRaw(raw); serial > 0 {
|
||
extra["creature_serial_or_handle"] = strconv.FormatInt(serial, 10)
|
||
}
|
||
}
|
||
return dnfrepo.ItemStack{
|
||
ItemID: entry.ItemID,
|
||
Count: equipmentEntryInventoryCount(entry),
|
||
Bind: entry.Bind,
|
||
ExpireAt: entry.ExpireAt,
|
||
RawEntry: raw,
|
||
Extra: extra,
|
||
}
|
||
}
|
||
|
||
const (
|
||
equipmentSocketCollectionOffset = 0x27
|
||
equipmentSocketCollectionBytes = 17
|
||
equipmentLegacyTailDataOffset = 0x2F
|
||
equipmentTailDataBytes = 37
|
||
)
|
||
|
||
var equipmentTailDataExtraKeys = []string{"tail_data_2f", "tailData2F", "tail2f", "raw_data_2f"}
|
||
|
||
func overlayEquipmentTailDataFromExtra(raw []byte, extra map[string]string) []byte {
|
||
// Current NoPack parses raw+0x27..+0x37 as a live fixed-row vector.
|
||
// Earlier code overlaid a legacy C# tail representation on that vector
|
||
// during ordinary equip moves. The current EXE has not proved that either
|
||
// structure represents a normal-equipment socket, so preserve repository
|
||
// raw bytes exactly until the real mutation is closed.
|
||
_ = extra
|
||
return raw
|
||
}
|
||
|
||
func equipmentSocketPrimaryValue(value []byte, visible bool) uint32 {
|
||
if !visible {
|
||
return 0
|
||
}
|
||
if len(value) >= 4 {
|
||
if raw := binary.LittleEndian.Uint32(value[:4]); raw != 0 {
|
||
return raw
|
||
}
|
||
}
|
||
return ^uint32(0)
|
||
}
|
||
|
||
func equipmentTailDataFromExtra(extra map[string]string) []byte {
|
||
if len(extra) == 0 {
|
||
return nil
|
||
}
|
||
var fallback []byte
|
||
for _, key := range equipmentTailDataExtraKeys {
|
||
raw := strings.TrimSpace(extra[key])
|
||
if raw == "" {
|
||
continue
|
||
}
|
||
raw = strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "", "0x", "", "0X", "").Replace(raw)
|
||
decoded, err := hex.DecodeString(raw)
|
||
if err != nil || len(decoded) == 0 {
|
||
continue
|
||
}
|
||
out := make([]byte, equipmentTailDataBytes)
|
||
copy(out, decoded)
|
||
if equipmentTailHasSocketData(out) {
|
||
return out
|
||
}
|
||
if fallback == nil {
|
||
fallback = out
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func equipmentTailHasSocketData(tail []byte) bool {
|
||
if len(tail) == 0 {
|
||
return false
|
||
}
|
||
if tail[0] != 0 {
|
||
return true
|
||
}
|
||
if len(tail) >= 9 {
|
||
return binary.LittleEndian.Uint32(tail[1:5]) != 0 || binary.LittleEndian.Uint32(tail[5:9]) != 0
|
||
}
|
||
for _, value := range tail {
|
||
if value != 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isEquipmentMoveUnit accepts exactly one ordinary equipment item. The
|
||
// current client represents a permanent avatar in list 1 with amount/count
|
||
// zero, but it is still a single wearable object (its non-empty item id and
|
||
// later PVF placement check are authoritative). Do not broaden this exception
|
||
// to normal inventory lists, where zero remains invalid for an equip move.
|
||
func isEquipmentMoveUnit(stack dnfrepo.ItemStack, listType byte) bool {
|
||
if stack.Count == 1 {
|
||
return true
|
||
}
|
||
return listType == wireListAvatar && stack.Count == 0
|
||
}
|
||
|
||
// equipmentEntryInventoryCount restores the exact container count recorded at
|
||
// equip time. Historical worn rows predate that marker and are normal
|
||
// equipment, so they retain the proven single-item fallback of one.
|
||
func equipmentEntryInventoryCount(entry dnfrepo.EquipmentEntry) int64 {
|
||
if raw := strings.TrimSpace(entry.Extra["current_exe_inventory_count"]); raw != "" {
|
||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil && value >= 0 {
|
||
return value
|
||
}
|
||
}
|
||
return 1
|
||
}
|
||
|
||
func rawEntryFromExtra(extra map[string]string) ([]byte, error) {
|
||
if extra == nil {
|
||
return nil, ErrMoveRawEntryMissing
|
||
}
|
||
raw := strings.TrimSpace(extra["raw_entry_hex"])
|
||
if raw == "" {
|
||
raw = strings.TrimSpace(extra["equipment_raw_entry_hex"])
|
||
}
|
||
if raw == "" {
|
||
return nil, ErrMoveRawEntryMissing
|
||
}
|
||
raw = strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "", "0x", "", "0X", "").Replace(raw)
|
||
if len(raw)%2 != 0 {
|
||
return nil, fmt.Errorf("%w: odd hex length", ErrMoveRawEntryInvalid)
|
||
}
|
||
decoded, err := hex.DecodeString(raw)
|
||
if err != nil || len(decoded) == 0 {
|
||
return nil, fmt.Errorf("%w: %v", ErrMoveRawEntryInvalid, err)
|
||
}
|
||
return decoded, nil
|
||
}
|
||
|
||
func buildPetCreatureEquipEntry(slot int16, itemID int64, serial int64) []byte {
|
||
var buf bytes.Buffer
|
||
writeByte := func(value byte) {
|
||
_ = buf.WriteByte(value)
|
||
}
|
||
writeUint16 := func(value uint16) {
|
||
var tmp [2]byte
|
||
binary.LittleEndian.PutUint16(tmp[:], value)
|
||
buf.Write(tmp[:])
|
||
}
|
||
writeUint32 := func(value uint32) {
|
||
var tmp [4]byte
|
||
binary.LittleEndian.PutUint32(tmp[:], value)
|
||
buf.Write(tmp[:])
|
||
}
|
||
|
||
writeByte(byte(slot))
|
||
writeUint32(uint32(clampInt32(itemID)))
|
||
writeUint32(uint32(clampInt32(serial)))
|
||
writeByte(0)
|
||
writeUint16(0)
|
||
writeUint32(0)
|
||
writeUint32(0)
|
||
writeByte(0)
|
||
writeByte(0)
|
||
writeUint16(0)
|
||
writeUint32(uint32(clampInt32(serial)))
|
||
writeByte(0)
|
||
writeUint32(0)
|
||
writeByte(0)
|
||
writeUint16(0)
|
||
writeByte(0)
|
||
buf.Write(make([]byte, 10))
|
||
return buf.Bytes()
|
||
}
|
||
|
||
func petSerialFromEquippedRaw(raw []byte) int64 {
|
||
// The current pet constructor repeats the creature serial/handle at +24.
|
||
// Do not accept the ordinary equipment instance scalar at +5. The current
|
||
// creature row carries its authoritative serial at +24, while ordinary
|
||
// equipment instance values are not creature identities.
|
||
// identity. buildPetCreatureEquipEntry always writes the authoritative +24
|
||
// field, so a shorter historical/ordinary row must fail closed here.
|
||
if len(raw) >= 28 {
|
||
value := int64(binary.LittleEndian.Uint32(raw[24:28]))
|
||
if value > 0 {
|
||
return value
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func firstExtraInt(extra map[string]string, fallback int64, keys ...string) int64 {
|
||
for _, key := range keys {
|
||
raw := strings.TrimSpace(extra[key])
|
||
if raw == "" {
|
||
continue
|
||
}
|
||
value, err := strconv.ParseInt(raw, 0, 64)
|
||
if err == nil {
|
||
return value
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func clampInt32(value int64) int32 {
|
||
if value > int64(^uint32(0)>>1) {
|
||
return int32(^uint32(0) >> 1)
|
||
}
|
||
if value < -int64(^uint32(0)>>1)-1 {
|
||
return -int32(^uint32(0)>>1) - 1
|
||
}
|
||
return int32(value)
|
||
}
|
||
|
||
func cloneExtra(extra map[string]string) map[string]string {
|
||
if len(extra) == 0 {
|
||
return make(map[string]string)
|
||
}
|
||
out := make(map[string]string, len(extra)+2)
|
||
for key, value := range extra {
|
||
out[key] = value
|
||
}
|
||
return out
|
||
}
|
||
|
||
func cloneItemMap(in map[string]dnfrepo.ItemStack) map[string]dnfrepo.ItemStack {
|
||
if len(in) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string]dnfrepo.ItemStack, len(in))
|
||
for key, value := range in {
|
||
value.Extra = cloneExtra(value.Extra)
|
||
out[key] = value
|
||
}
|
||
return out
|
||
}
|