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

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

7380 lines
288 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 本文件负责背包 owner 的真实库存变更边界。
// handler 只提交命令;这里只写 repository,不生成旧客户端成功 ACK。
package inventory
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"math/rand"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/creaturestate"
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
dnfequip "longheng.io/server/internal/modules/dnf/equip"
"longheng.io/server/internal/modules/dnf/experiencebonus"
dnfitemcooldown "longheng.io/server/internal/modules/dnf/itemcooldown"
dnfitemdailylimit "longheng.io/server/internal/modules/dnf/itemdailylimit"
dnfitemlock "longheng.io/server/internal/modules/dnf/itemlock"
"longheng.io/server/internal/modules/dnf/itemquality"
"longheng.io/server/internal/modules/dnf/premium"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
)
var (
ErrOwnerUnavailable = errors.New("inventory owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrInventoryNotFound = errors.New("inventory record not found")
ErrUnsupportedList = errors.New("inventory list type is not supported")
ErrSlotNotFound = errors.New("inventory slot not found")
ErrItemMismatch = errors.New("inventory item mismatch")
ErrSellPriceMissing = errors.New("sell price evidence is missing")
ErrSellPriceInvalid = errors.New("sell price is invalid")
ErrWalletTxnRequired = errors.New("wallet transaction is required")
ErrItemLocked = errors.New("inventory item is equipment locked")
ErrMoveRequiresEquipmentOwner = errors.New("move requires equipment owner")
ErrQuickSlotCharmAlreadyEquipped = errors.New("only one quick-slot charm equipment may be equipped")
ErrQuickSlotCharmValidationUnavailable = errors.New("quick-slot charm validation requires the active PVF catalog")
ErrMoveAccountCargoUnsupported = errors.New("account cargo move is unsupported")
// ErrMoveDestinationFull 表示跨容器移动时目标容器既没有该物品能落进去的空格,
// 也没有同类可叠格,而且客户端声明的那一格放不下这一堆。此时必须回一个明确的
// 失败包(客户端显示"物品栏空间不足"),不能再互换:op19 的 ACK 只带一个目标槽,
// 被换出去的那件在客户端会一直"消失"到小退。
ErrMoveDestinationFull = errors.New("move destination container has no room for the item")
// ErrMoveCarryLimitExceeded 表示目标容器里该道具的总量已经达到 PVF [stack limit]。
// 口径:一个容器(背包 list0 / 个人仓库 list2 / 账号金库 list12)里同一道具 id 的
// 总量不得超过 [stack limit];PVF 没有声明 [stack limit] 的道具(装备等)不受限。
ErrMoveCarryLimitExceeded = errors.New("move would exceed the PVF stack limit for that container")
ErrAccountCargoNotCreated = errors.New("account cargo is not created")
ErrAccountCargoSlotOutOfRange = errors.New("account cargo slot is outside current capacity")
// ErrAccountCargoItemNotAllowed 表示这件道具不允许存进账号金库(list 12)。
// 判定来自当前运行时 PVF 的 [attach type]:只有 free / account / sealing 放行,
// 其余(trade / trade delete / trade limit / sealing trade / 空值)以及"读不出来"
// (没有 PVF 解析器、模板不在 stackable/equipment 列表里)一律按不可交易拒绝。
ErrAccountCargoItemNotAllowed = errors.New("item is not allowed in the account cargo")
// ErrCrossContainerSwapNotAllowed 表示这次移动唯一的出路是"跨容器互换",而运营口径
// 2026-09-21 起跨容器一律不互换:互换会把对方容器里那件搬进来(账号金库这边就是
// "任务道具/SS 装备跟着被拖进金库"),而 op19 的 ACK 只带一个目标槽,客户端不知道
// 被换出去的东西去了哪。目标容器还有空格时走的是顺延落点,不会走到这里。
ErrCrossContainerSwapNotAllowed = errors.New("cross-container slot swap is not allowed")
// ErrEmblemWarehouseForbidden 表示徽章(PVF [stackable type] = [avatar emblem])
// 不允许存进仓库。
//
// 运营口径 2026-10-03:徽章页 289..351 是它唯一该在的地方(9 行 × 7 格 = 63),
// 放进个人仓库/账号金库会让"徽章栏满了"这个状态被绕过 —— 玩家把几摞徽章挪进仓库,
// 页面上就空出格子,而分解产出与邮件领取都只按徽章页找落点,页面状态与真实持有量
// 从此对不上。所以**放入一律拒绝**;历史已经躺在仓库里的那些不拦取出(否刚玩家
// 的徽章就锁死在仓库里了),拖出来时会按 PVF 页落回徽章页
// (见 redirectMainMoveToItemPage)。
ErrEmblemWarehouseForbidden = errors.New("avatar emblem is not allowed in a warehouse")
ErrSortRequiresEquipmentOwner = errors.New("sort requires equipment owner")
ErrRepairRequiresEquipmentOwner = errors.New("repair requires equipment owner")
ErrRepairAllRequiresOwners = errors.New("repair all requires equipment and wallet owners")
ErrRepairDurabilityMissing = errors.New("repair durability evidence is missing")
ErrRepairDurabilityInvalid = errors.New("repair durability evidence is invalid")
ErrRepairCostMissing = errors.New("repair cost evidence is missing")
ErrRepairCostInvalid = errors.New("repair cost evidence is invalid")
ErrDeleteRequiresEquipmentOwner = errors.New("equipment delete requires a typed equipment owner and proven ACK")
ErrPetInventoryOwnerRequired = errors.New("pet inventory mutation requires a typed pet owner")
ErrUseStackableContractRequired = errors.New("use stackable requires PVF-proven [waste] provenance and stable item identity")
ErrStackableUsePlaceContract = errors.New("stackable item is not usable in the active PVF scene")
ErrTransformationContract = errors.New("transformation item requires a complete runtime-PVF action and compatible scene")
ErrProjectileContract = errors.New("projectile item requires a complete runtime-PVF action and compatible dungeon")
ErrPetFeedResolveFailed = errors.New("pet feed runtime-PVF resolution failed")
ErrPetFeedContractRequired = errors.New("pet feed requires a complete runtime-PVF [feed] contract")
ErrPetFeedEquippedCreatureRequired = errors.New("pet feed requires one equipped creature")
ErrInventoryTransactionRequired = errors.New("inventory mutation requires a character item transaction")
ErrCharacterAssetTxnRequired = errors.New("inventory mutation requires a character asset transaction")
ErrAccountSharedOwnerUnavailable = errors.New("account-shared inventory owner unavailable")
ErrReservedSlotRelocationFull = errors.New("reserved main inventory slot cannot be relocated into the proven item range")
ErrAccountRequired = errors.New("authenticated account id required")
ErrAccountNotFound = errors.New("authenticated account record not found")
ErrEnchantResolverRequired = errors.New("enchant by bead requires a runtime-PVF resolver")
ErrEnchantRequiresEquipmentOwner = errors.New("worn equipment enchant requires a typed equipment owner")
ErrUpgradeTicketResolverRequired = errors.New("upgrade ticket requires a runtime-PVF resolver")
ErrPremiumContractResolveFailed = errors.New("premium contract runtime-PVF resolution failed")
ErrExperienceConsumableResolveFailed = errors.New("experience consumable runtime-PVF resolution failed")
ErrExperienceConsumableDungeonRequired = errors.New("experience consumable requires an active dungeon")
ErrDirectExperienceConsumableContract = errors.New("direct experience consumable requires an atomic PVF progression contract")
ErrTPBookContract = errors.New("TP book requires an atomic PVF character, skill, and inventory contract")
ErrLevelUpTicketContract = errors.New("level-up ticket requires an atomic progression, quest, and inventory contract")
ErrIncreaseStatusBookContract = errors.New("increase-status book requires a runtime-PVF SP/TP or immediate-experience book in the addressed main-inventory slot")
ErrRandomRewardResolveFailed = errors.New("random reward item runtime-PVF resolution failed")
ErrRandomRewardContractRequired = errors.New("random reward item requires a complete runtime-PVF outcome table")
ErrRandomRewardInventoryFull = errors.New("random reward item result has no compatible inventory slot")
ErrUpgradeDestroyBonusContract = errors.New("upgrade destruction compensation requires a complete runtime-PVF stack contract")
ErrUpgradeDestroyBonusInventoryFull = errors.New("upgrade destruction compensation has no compatible inventory slot")
ErrQuestCompletionTicketResolveFailed = errors.New("quest completion ticket runtime-PVF resolution failed")
ErrQuestCompletionTicketContract = errors.New("quest completion ticket requires an atomic quest/inventory contract")
ErrFatiguePotionContract = errors.New("fatigue potion requires an atomic character/inventory contract")
ErrRentalPointContract = errors.New("rental point item requires an atomic account/character/inventory contract")
ErrRentalPointLimit = errors.New("rental point wallet is already at its limit")
ErrStoryBookContract = errors.New("story book requires an atomic quest/inventory contract")
ErrDungeonAdmissionTicketContract = errors.New("dungeon admission ticket requires an atomic allowance/inventory contract")
ErrRepairNotRepairable = errors.New("inventory item is not repairable by PVF durability evidence")
ErrRepairGoldInsufficient = errors.New("repair gold is insufficient")
ErrMonsterCardUpgradeResolverRequired = errors.New("monster card upgrade requires a runtime-PVF resolver")
)
// op80 error codes. The current EXE's class-1 handler sub_1D2F410 takes the
// code at [ebp+0x10] and, after the shared table sub_32DDC70 declines it,
// indexes the 242-entry byte table at 0x1D304A0 into the 24-way jump table at
// 0x1D30440. Only 0 4 7 8 9 10 13 14 17 19 21 22 23 95 103 104 118 126 175
// 179 206 214 217 235 241 have a branch; everything else lands on the default
// at 0x1D3032F, which is dstr 1662 "强化装备出现错误, 错误代码 : %d" -- the
// bare number a player reported on 2026-09-09.
//
// Each constant below names the dstr text its branch renders, read out of
// docs/client-dstr-zh.txt.
const (
// 4 -> 1658 缺少物品
upgradeErrorInvalidTarget byte = 4
// 10 -> 1660 金币不足
upgradeErrorInsufficientGold byte = 10
// 4 -> 1658 缺少物品. This used to be 21, which renders 70132
// "物品栏空间不足, 无法进行强化" -- a bag-space message for what is
// actually an unusable protection ticket in the optional slot.
upgradeErrorUnsupportedOptionalTicket byte = 4
// 22 -> 1661 材料不足
upgradeErrorInvalidMaterial byte = 22
// 23 -> 1653 该道具无法进行强化和赋予异次元气息。
upgradeErrorWrongMode byte = 23
// 7 -> 20000 无法强化未修理的装备
upgradeErrorDurability byte = 7
// 95 -> 1654 该物品无法继续强化
upgradeErrorMaxLevel byte = 95
// 175 -> 21304 无法对未净化的装备进行装备增幅。 The tree used 174, one
// below the real code, so the client printed "错误代码 : 174" instead.
upgradeErrorAmplifyNotIdentified byte = 175
// 214 -> 42232 在物品已锁定状态下, 您无法进行当前操作。 Was 213, likewise
// one below.
upgradeErrorLocked byte = 214
upgradeResultCodeSuccess byte = 0
upgradeResultCodeFailNoChange byte = 1
upgradeResultCodeDowngrade byte = 2
upgradeResultCodeDestroy byte = 3
upgradeMaximumCurrentLevelBeforeReject byte = 30
currentUpgradeSuccessBase = 100000
)
// Enchant-by-bead client-visible error codes, matching the current EXE S2C
// 0x0110 handler (sub_1D0B960): 0x11 -> text 22223, 0x13 -> text 46371,
// 0x17 -> text 27162; any other value falls back to the generic text 44171.
const (
enchantErrorInvalidBead byte = 0x11
enchantErrorInvalidTarget byte = 0x13
enchantErrorUnsupported byte = 0x17
)
// Owner 是背包资产的 durable owner 边界。
// 当前只改 repository 状态;旧客户端 ACK/刷新顺序必须在 EXE 证据闭合后再开放。
type Owner struct {
repo dnfrepo.InventoryRepository
equipment dnfrepo.EquipmentRepository
accountRepo dnfrepo.AccountInventoryRepository
accounts dnfrepo.AccountRepository
characters dnfrepo.CharacterRepository
items dnfrepo.CharacterItemUnitOfWork
accountItems dnfrepo.AccountCharacterItemUnitOfWork
assets dnfrepo.CharacterAssetUnitOfWork
accountAssets dnfrepo.AccountCharacterAssetUnitOfWork
rentalAssets dnfrepo.RentalAssetUnitOfWork
pets dnfrepo.PetRepository
petItems dnfrepo.CharacterPetUnitOfWork
settlements dnfrepo.CharacterSettlementUnitOfWork
dungeonAdmission *dnfdungeonadmission.Owner
chronicleRandom func(int) int
cardUpgradeRandom func(int) int
upgradeRandom func(int) int
statChangeRandom func(int) int
enchantRandom func(int) int
now func() time.Time
stackMerge alignedcmd.StackMergeResolver
upgradeProtection alignedcmd.UpgradeProtectionResolver
upgradeRefundMailer alignedcmd.UpgradeRefundMailer
inItemTransaction bool
inAccountItemTx bool
inPetTransaction bool
// settings 只用于读取角色的容器状态(已解锁的背包/仓库格范围)。op19 跨容器
// 落点修正需要一个"证明可见"的槽位范围,读不到时一律不放宽判定。
settings dnfrepo.SettingsRepository
// accountWarehouseSlot 解析"这类道具的固定晶体/灵魂仓库格"(六个晶块 354..359、
// 六个灵魂 360..365)。布局由 bridge 用实测表注入;本包只用它把"落点是背包"的
// 晶体/灵魂改回固定格,见 redirectAccountSharedWarehouseDestination。
accountWarehouseSlot func(itemID int64) (int16, bool)
}
// SetAccountWarehouseSlotResolver installs the fixed crystal/soul warehouse slot
// of the item families that own one (six cubes at 354..359, six souls at
// 360..365). dnfbridge owns the live-verified client layout; this package only
// applies the landing rule to op19 moves.
func (o *Owner) SetAccountWarehouseSlotResolver(resolver func(itemID int64) (int16, bool)) {
if o != nil {
o.accountWarehouseSlot = resolver
}
}
// SetStackMergeResolver installs the active-runtime-PVF authority used by
// stack moves and upgrade-destruction rewards. It is intentionally
// request-scoped and is copied into transaction owners with the rest of Owner.
func (o *Owner) SetStackMergeResolver(resolver alignedcmd.StackMergeResolver) {
if o != nil {
o.stackMerge = resolver
}
}
// SetUpgradeRefundMailer installs the delivery used for destruction refund
// rows the bag could not take. It runs after the asset transaction commits,
// because the character-asset transaction covers characters, inventory and
// equipment but not mailboxes, and the upgrade target may sit on the equipment
// page -- so the mail cannot share the upgrade's transaction. The callback
// owns its own logging; a failure never fails the already-committed upgrade,
// it only leaves the rows reported in UpgradeResult.DestroyRefundOverflow.
func (o *Owner) SetUpgradeRefundMailer(mailer alignedcmd.UpgradeRefundMailer) {
if o != nil {
o.upgradeRefundMailer = mailer
}
}
// SetUpgradeProtectionResolver installs the request-scoped active-PVF proof
// for the exact optional protection-ticket slot carried by op50.
func (o *Owner) SetUpgradeProtectionResolver(resolver alignedcmd.UpgradeProtectionResolver) {
if o != nil {
o.upgradeProtection = resolver
}
}
// DeleteResult 描述一次删除物品写库结果,不代表已经允许回包。
// DeletedAccountMaterial 仅在本次删除实际扣除了账号共享晶体/灵魂材料槽
// (dnfrepo.IsAccountSharedInventorySlot)时为真;是否处于副本由 bridge 判定。
type DeleteResult struct {
CharacterID string
Removed []DeletedItem
Changed bool
DeletedAccountMaterial bool
}
// SellResult 描述一次出售物品写库结果;金币为 0 时只涉及背包 repo。
type SellResult struct {
CharacterID string
Sold DeletedItem
GoldDelta int64
UpdatedGold int64
Changed bool
}
// MoveResult 描述一次物品格子移动的写库结果;它不代表已经可以向旧客户端回成功 ACK。
type MoveResult struct {
CharacterID string
SourceListType byte
SourceSlotIndex int16
DestinationListType byte
DestinationSlotIndex int16
MoveCount int64
Mode string
// RedirectedReservedMainSlot is true when the owner committed the move to an
// endpoint other than the one the client declared, so the ACK (and any
// precise slot refresh built from the same command) must use the committed
// endpoints instead of the request's. Two cases set it: a personal-cargo
// withdrawal that named the current client's slot-0 gold wallet, and a
// cross-container move whose client-declared destination endpoint was empty
// on the client but already occupied in the durable record.
RedirectedReservedMainSlot bool
RefreshListTypes []byte
Refresh map[byte]map[string]dnfrepo.ItemStack
Changed bool
// EmptiedAccountSharedSlots 是这次移动**搬空**的账号共享晶体/灵魂槽(list 0,
// 354..365)。op19 的 ACK 只声明源/目标端点,而客户端不会据它重绘右侧晶体/灵魂
// 面板,于是搬空的那一格会留下一行"幽灵行":现场 2026-09-17,把无色小晶块从晶体
// 仓库拖到个人仓库后,面板仍显示原来的 40449,且那一行点不动(东西已经不在仓库里)。
// bridge 必须在 ACK 之后把这几格按 (itemID=0, count=0) 的"空格"行补发一次。
EmptiedAccountSharedSlots []int16
// ClearedRedirectedMainSlot 是**客户端声明的背包落点**、但被改写到账号共享固定格的
// 那一格(list 0)。op19 是乐观移动:客户端在发包前就已经把物品落在它自己声明的那一格
// 上了,而 ACK 只回执真实落点 —— 不补一条删除行,那一格会留下"幽灵行",玩家再往外拖时
// 客户端连 op19 都不发(2026-09-21 现场:晶体仓库→个人仓库→背包(声明 0:156) 之后
// 那一格点不动,13:20:08 起该连接零报文;DB 里 0:156 是空的、0:357 才是 69 个)。
//
// 只有该格在持久化记录里**确实是空的**时才填:这样 bridge 补发的必然是一行删除行
// (背包空格 = 0xFFFFFFFF),不会盖掉任何真实物品,也避开 2026-09-13 那条"补发物品行
// 会被客户端当成额外变化套到它自己的 op19 对象上"的坑。
ClearedRedirectedMainSlot int16
// SortLockSignals 是移动后仍被整理锁定钉住的端点槽位,需要补发 class1/op713。
SortLockSignals []SortLockSignal
}
// SortResult 描述一次整理物品栏的持久化结果。客户端列表刷新由 bridge
// 在 ACK 后从角色、账号共享仓库与金币所有者重新构造,不能复用这里只含角色
// 背包格子的局部快照。
type SortResult struct {
CharacterID string
ListType byte
Category byte
StartSlot int16
EndSlot int16
MovedCount int
Mode string
Changed bool
}
// RepairResult 描述一次耐久修理的写库结果;当前只允许零金币单件修理回成功 ACK。
// 正价扣费、全修和装备刷新仍需 wallet/equipment owner 闭合后再开放。
type RepairResult struct {
CharacterID string
ListType byte
SlotIndex int16
OldDurability int64
NewDurability int64
Cost int64
UpdatedGold int64
Changed bool
// FreeRepair marks the 魔王契约 auto-repair path (premium type 586).
FreeRepair bool
}
// UpgradeResult describes one equipment reinforcement/amplification attempt.
// Success=false is still a handled client-visible result and maps to an op50
// failure ACK; Changed only means repository state changed.
type UpgradeResult struct {
CharacterID string
Mode string
Success bool
ErrorCode byte
// TargetListType is echoed at byte[14] of the op50 success ACK; the client
// repaints the slot in the list named there, so a worn target must say 3.
TargetListType byte
TargetSlotIndex int16
TargetItemTemplateID int32
MaterialSlotIndex int16
OptionalTicketSlotIndex int16
ProtectionTicketSlotIndex int16
ProtectionTicketItemID int64
ProtectionTicketUsed bool
ProtectionTicketRemaining int64
ProtectionTicketUpdatedStack dnfrepo.ItemStack
OldLevel byte
NewLevel byte
ResultCode byte
UpgradeSucceeded bool
MaterialRemainingStackCount int64
GoldCost int64
UpdatedGold int64
MainRefresh map[string]dnfrepo.ItemStack
TargetUpdatedStack dnfrepo.ItemStack
MaterialUpdated bool
MaterialUpdatedStack dnfrepo.ItemStack
Changed bool
DestroyBonusSlot int16
DestroyBonusItemID int32
DestroyBonusCount int32
DestroyBonusSlots []int16
// DestroyExtraRewards are the fixed rows granted after the compensation
// item (异界 equipment refunds 异次元碎片); their slots are also appended
// to DestroyBonusSlots for the incremental refresh.
DestroyExtraRewards []UpgradeDestroyGrantedReward
// DestroyRefundOverflow are best-effort refund rows the bag had no room
// for. They are handed to the upgrade refund mailer after the transaction
// commits; a row listed here was neither placed in a slot nor reported in
// the op50 body.
DestroyRefundOverflow []alignedcmd.UpgradeDestroyReward
// DestroyRefundMailed is set once the mailer accepted the overflow.
DestroyRefundMailed bool
}
// UpgradeDestroyGrantedReward is one granted destruction reward row as the
// op50 success body reports it.
type UpgradeDestroyGrantedReward struct {
Slot int16
ItemID int32
Count int32
}
// EnchantResult describes one enchant-by-bead attempt. Success=false is still
// a handled client-visible result and maps to a 0x0110 failure ACK carrying
// ErrorCode; Changed only means repository state changed.
type EnchantResult struct {
CharacterID string
Success bool
ErrorCode byte
TargetListType byte
TargetSlotIndex int16
TargetItemID int64
BeadSlotIndex int16
BeadItemID int64
CardItemID int64
EnchantUpgradeCount byte
BeadRemainingCount int64
TargetUpdatedStack dnfrepo.ItemStack
BeadUpdatedStack dnfrepo.ItemStack
MainRefresh map[string]dnfrepo.ItemStack
Changed bool
}
// MonsterCardUpgradeResult describes one processed op603 attempt. Success is
// the generic command result, while UpgradeSucceeded is the probability roll.
// A failed roll still consumes one material card and the fixed gold cost.
type MonsterCardUpgradeResult struct {
CharacterID string
Success bool
ErrorCode byte
TargetSlot int16
MaterialSlot int16
ResultSlot int16
TargetItemID int64
UpgradeSucceeded bool
UpgradeCount byte
Chance int
GoldCost int64
UpdatedGold int64
ChangedSlots []int16
Changed bool
}
// DeletedItem 是已扣减物品的审计摘要。
// UseStackableResult 描述一次消耗品使用写库结果;premium 通知仍由上层 S2C writer 闭合。
// UseStackableResult describes a typed stackable-use result once that owner is implemented.
// UseStackableResult describes a typed stackable-use result once that owner is implemented.
type UseStackableResult struct {
CharacterID string
AccountID string
ListType byte
SlotIndex int16
InstanceValue int32
ItemID int64
RemainingCount int64
Changed bool
PVFPath string
StackableType string
PremiumActivated bool
PremiumType int64
PremiumRemainingSeconds int64
PremiumActivations []PremiumActivation
ExperienceBonusActivated bool
ExperienceBonusPercent int64
ExperienceBonusRemainingSeconds int64
DirectExperienceApplied bool
DirectExperienceGain uint32
DirectExperiencePreviousLevel int
DirectExperienceNewLevel int
DirectExperiencePreviousTotal uint32
DirectExperienceNewTotal uint32
DirectExperienceSPGain int
DirectExperienceTPGain int
TPBookApplied bool
TPBookDelta int
TPBookPreviousRemaining int
TPBookNewRemaining int
SkillPointBookKind alignedcmd.SkillPointBookKind
SkillPointBookSPDelta int
SkillPointBookTPDelta int
SkillPointBookTPReset bool
SkillPointBookPreviousSP int
SkillPointBookNewSP int
SkillPointBookPreviousTP int
SkillPointBookNewTP int
LevelUpTicketApplied bool
LevelUpTicketRange int64
ReviveCoinWalletUpdated bool
ReviveCoinWalletTotal int64
RandomRewardItemID int64
RandomRewardSlots []int16
PetFeedApplied bool
PetFeedPetKey string
PetFeedSatietyBefore int64
PetFeedSatietyAfter int64
PetFeedSatietyDelta int64
PetFeedEffectDurationSeconds int64
PetFeedSkillChargePercent int64
QuestCompletionTicketApplied bool
QuestCompletionAction string
QuestCompletionCount int
QuestCompletionProfessionChange bool
StoryBookApplied bool
StoryBookQuestID int64
StoryBookInitTrigger uint32
TransformationApplied bool
TransformationAction string
ProjectileApplied bool
ProjectileEffectID int64
AvatarFittingRoomOpened bool
CustomAbilityGroupConverterOpened bool
FatiguePotionApplied bool
FatigueBefore int64
FatigueAfter int64
FatigueRecovery int64
// DailyUseItemID / DailyUseUsed / DailyUseLimit carry the item's own
// 每天可用次数 state after this use (PVF [total usable count]). The client
// paints that line from its own op541 state, so the bridge needs these
// numbers to project them; they are item-agnostic on purpose.
DailyUseItemID int64
DailyUseUsed int64
DailyUseLimit int64
// CooltimeItemID / CooltimeExpireAt carry the durable cooldown window this use
// started (PVF [cool time] + [cooltime maintenance]); zero means the item has
// no maintained cooldown. The bridge uses them only for logging — the client
// starts its own local cooldown on use, and the restore path is the
// character-select list (notification 172).
CooltimeItemID int64
CooltimeExpireAt int64
DungeonAdmissionTicketApplied bool
DungeonAdmissionAction string
DungeonAdmissionPolicyCount int
DungeonAdmissionBonus uint32
DungeonAdmissionRemainingUses uint32
RentalPointApplied bool
RentalPointBefore uint32
RentalPointGranted uint32
RentalPointAfter uint32
RentalPointLimit uint32
RentalPointAtLimit bool
// 扩容券([action type] [upgrade inventory] / [upgrade cargo] /
// [upgrade account cargo])落地的结果:档位推进了多少、要重建哪个列表。
// ContainerUpgradeListType 是 0(背包)/2(金库)/12(帐号金库);
// ContainerUpgradeNeedsCargoPad 为真时 bridge 必须先发 op391 重建仓库底板,
// 否则新解锁的格子仍画着锁(现场 2026-09-18 的既有结论)。
ContainerUpgradeApplied bool
ContainerUpgradeKind alignedcmd.ContainerUpgradeKind
ContainerUpgradeBefore int64
ContainerUpgradeAfter int64
ContainerUpgradeListType byte
ContainerUpgradeNeedsCargoPad bool
// 城镇自施放增益药剂(`[stackable type] [expert town potion]` + 无 [action type]
// + [stat change duration],例:史诗Buff药剂 490002458)落地的结果。
// StatChangePotionSeconds 是增益窗口长度;EpicDropRatePercent > 0 表示这次真的
// 落了深渊派对史诗掉落率增益(200 = 2 倍),EpicDropRateExpireAt 是到期 Unix 秒。
StatChangePotionApplied bool
StatChangePotionSeconds int64
EpicDropRatePercent int64
// EpicDropRateBuffID 是掷点得到的 buff id(etc/variousbufflist.etc 的 [index];
// 1136=史诗Buff药剂、1090/1091/1092=赛丽亚三档、1239/1240=小灯笼大/小瓶,
// 0 = 走 [explain] 回退路径没有 buff id)。客户端画图标就是按它取 icon index。
EpicDropRateBuffID int64
// PreviousEpicDropRateBuffID 是这次覆盖掉的上一个 buff id(同族只占一条 buff 槽,
// 换档时要先撤掉它);0 表示之前没有生效中的 buff。
PreviousEpicDropRateBuffID int64
EpicDropRateExpireAt int64
// UpgradeRateKind 是这次落的"强化/增幅成功率"窗口作用对象:
// "reinforce" / "amplify" / ""(这件药剂不涉及)。服主 2026-10-03:
// "看看增幅的秘密 强化秘药这类增加强化增幅成功率的道具 也一并修复了"。
UpgradeRateKind string
// UpgradeRatePercent 是成功率加成(百分点):赛丽亚三档 10/20/30,
// PVF 没有数字的那几件取 statbuff 侧的固定常量(10)。
UpgradeRatePercent int64
// UpgradeRateBuffID 是掷点得到的 buff id(1097/1098/1099…;0 = PVF 无候选表)。
UpgradeRateBuffID int64
// PreviousUpgradeRateBuffID 是被这次覆盖掉的旧 buff id(换档时先撤掉它)。
PreviousUpgradeRateBuffID int64
UpgradeRateExpireAt int64
// DungeonClearExpPercent / BuffID / ExpireAt 是"地下城通关经验值 +N%"窗口
// (赛丽亚的幸运经验药剂 10008222 的三档 50/100/200)。经验由服务端发,所以这一族
// 必须服务端算;buff id 仍然要注册给客户端(图标)。
DungeonClearExpPercent int64
DungeonClearExpBuffID int64
DungeonClearExpExpireAt int64
// ClientBuffFamily / ClientBuffID / Percent / ExpireAt 是"客户端自己施加"的那一族
// (城镇移速 village_move_speed、进图属性 move_speed…)。服务端只掷点 + 注册 buff +
// 记到期,效果本身由客户端按同一份 buff 表施加。窗口按 family 分开存。
ClientBuffFamily string
ClientBuffID int64
ClientBuffPercent int64
ClientBuffExpireAt int64
}
// PremiumActivation is one durable premium row that must be projected after
// a contract item commits. Auction coupons activate both their auction row and
// the bundled private-store license row.
type PremiumActivation struct {
PremiumType int64
RemainingSeconds int64
}
// DeletedItem records one applied inventory removal.
type DeletedItem struct {
ListType byte
SlotIndex int16
ItemID int64
RequestedCount int64
AppliedCount int64
RemainingCount int64
}
// NewOwner 创建背包 owner;缺少背包仓储时拒绝处理,避免 handler 直接写状态。
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.Inventory == nil {
return nil, ErrOwnerUnavailable
}
admissionOwner, _ := dnfdungeonadmission.NewOwner(repos)
return &Owner{
repo: repos.Inventory,
equipment: repos.Equipment,
accountRepo: repos.AccountInventory,
accounts: repos.Account,
characters: repos.Character,
items: repos.CharacterItems,
accountItems: repos.AccountItems,
assets: repos.CharacterAssets,
accountAssets: repos.AccountAssets,
rentalAssets: repos.RentalAssets,
pets: repos.Pet,
petItems: repos.CharacterPets,
settlements: repos.CharacterSettlement,
settings: repos.Settings,
dungeonAdmission: admissionOwner,
chronicleRandom: rand.Intn,
cardUpgradeRandom: rand.Intn,
upgradeRandom: rand.Intn,
statChangeRandom: rand.Intn,
enchantRandom: rand.Intn,
now: time.Now,
}, nil
}
func (o *Owner) withinInventoryTransaction(ctx context.Context, characterID string, apply func(*Owner) error) error {
if o.items == nil {
return ErrInventoryTransactionRequired
}
return dnfrepo.WithinCharacterItemsScoped(ctx, o.items, characterID, func(inventory dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository) error {
txOwner := *o
txOwner.repo = inventory
txOwner.equipment = equipment
txOwner.inItemTransaction = true
return apply(&txOwner)
})
}
func (o *Owner) withinCharacterAssetTransaction(ctx context.Context, characterID string, apply func(*Owner) error) error {
if o.assets == nil {
return ErrCharacterAssetTxnRequired
}
return o.assets.WithinCharacterAssets(ctx, characterID, func(characters dnfrepo.CharacterRepository, inventory dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository) error {
txOwner := *o
txOwner.characters = characters
txOwner.repo = inventory
txOwner.equipment = equipment
txOwner.inItemTransaction = true
return apply(&txOwner)
})
}
func (o *Owner) withinAccountCharacterAssetTransaction(ctx context.Context, accountID string, characterID string, apply func(*Owner) error) error {
if o.accountAssets == nil {
return dnfrepo.ErrAccountCharacterAssetTransactionUnavailable
}
return o.accountAssets.WithinAccountCharacterAssets(ctx, accountID, characterID, func(accountInventory dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, inventory dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository) error {
txOwner := *o
txOwner.accountRepo = accountInventory
txOwner.characters = characters
txOwner.repo = inventory
txOwner.equipment = equipment
txOwner.inItemTransaction = true
return apply(&txOwner)
})
}
// Delete 按 EXE/C# 已确认的删除语义扣减物品。
// 这里只保存背包字段;成功 ACK 与背包刷新顺序由上层在证据闭合后处理。
func (o *Owner) Delete(ctx context.Context, cmd Command) (DeleteResult, error) {
if o == nil || o.repo == nil {
return DeleteResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return DeleteResult{}, ErrCharacterRequired
}
if err := checkDeleteListType(cmd.SourceListType); err != nil {
return DeleteResult{}, err
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
mutations := deleteMutations(cmd)
if len(mutations) == 0 {
return DeleteResult{}, ErrSlotNotFound
}
usesAccountShared := false
for _, mutation := range mutations {
if dnfrepo.IsAccountSharedInventorySlot(mutation.listType, mutation.slotIndex) {
usesAccountShared = true
break
}
}
if usesAccountShared {
accountID := strings.TrimSpace(cmd.AccountID)
if accountID == "" {
return DeleteResult{}, ErrAccountRequired
}
if o.accountRepo == nil || o.accountItems == nil {
return DeleteResult{}, ErrAccountSharedOwnerUnavailable
}
if !o.inAccountItemTx {
var result DeleteResult
err := o.accountItems.WithinAccountCharacterItems(ctx, accountID, characterID, func(accounts dnfrepo.AccountInventoryRepository, characters dnfrepo.InventoryRepository) error {
txOwner := *o
txOwner.accountRepo = accounts
txOwner.repo = characters
txOwner.inAccountItemTx = true
var err error
result, err = txOwner.Delete(ctx, cmd)
return err
})
return result, err
}
} else if !o.inItemTransaction {
var result DeleteResult
err := o.withinInventoryTransaction(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.Delete(ctx, cmd)
return err
})
return result, err
}
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return DeleteResult{}, err
}
if !ok {
return DeleteResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
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)
}
var account dnfrepo.AccountInventoryRecord
if usesAccountShared {
accountID := strings.TrimSpace(cmd.AccountID)
account, _, err = o.accountRepo.Load(ctx, accountID)
if err != nil {
return DeleteResult{}, err
}
account = dnfrepo.CloneAccountInventory(account)
account.AccountID = accountID
if account.Slots == nil {
account.Slots = make(map[string]dnfrepo.ItemStack)
}
}
changedSlots := false
changedWarehouse := false
changedAccount := false
var deletedEggs []deletedEggRow
result := DeleteResult{CharacterID: characterID, Removed: make([]DeletedItem, 0, len(mutations))}
for _, mutation := range mutations {
accountOwned := dnfrepo.IsAccountSharedInventorySlot(mutation.listType, mutation.slotIndex)
var items map[string]dnfrepo.ItemStack
var field dnfrepo.InventoryField
if accountOwned {
items = account.Slots
} else {
items, field = itemMapForList(&record, mutation.listType)
}
key := slotKey(mutation.listType, mutation.slotIndex)
stack, ok := items[key]
if !ok {
return DeleteResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, mutation.listType, mutation.slotIndex)
}
if isEquipmentLocked(stack) {
return DeleteResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrItemLocked, mutation.listType, mutation.slotIndex)
}
if mutation.listType == listTypePet && stackHasLivePetBinding(stack) {
// 宠物栏(list 7):宠物蛋可以丢,已孵化的宠物不行 —— 后者的图鉴/装备
// 状态在 pet owner 手里。
//
// 但只看 serial 是不够的:历史上 creaturestate 把 `equipment/creature/`
// 下的**蛋**当成"直接发放的宠物",给它写了 serial 并建出宠物记录,于是
// 蛋在这里被当成"已绑定宠物"拒绝 —— 现场就是「宠物蛋无法丢弃」。
// 蛋按运行时 PVF 的 `[output index]` 判定;判定不了(探针未装配)时保持
// 原样拒绝,绝不放开真正的宠物。
if !creaturestate.IsPetEgg(stack.ItemID) {
return DeleteResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrPetInventoryOwnerRequired, mutation.listType, mutation.slotIndex)
}
deletedEggs = append(deletedEggs, deletedEggRow{slotIndex: mutation.slotIndex, itemID: stack.ItemID})
}
if mutation.itemID != 0 && stack.ItemID != int64(mutation.itemID) {
return DeleteResult{}, fmt.Errorf("%w: list=%d slot=%d want=%d got=%d", ErrItemMismatch, mutation.listType, mutation.slotIndex, mutation.itemID, stack.ItemID)
}
applied := normalizeDeleteCount(stack.Count, mutation.count)
remaining := stack.Count - applied
if remaining <= 0 {
delete(items, key)
remaining = 0
} else {
stack.Count = remaining
items[key] = stack
}
if accountOwned {
changedAccount = true
// 账号共享晶体/灵魂材料槽(Main 虚拟底部槽)在副本内删除后需要
// 补一条空的 S0/0012 库存动作完成通知,否则客户端忙状态会带到
// 下一地图,普通消耗品在客户端门禁处被拦下(us90 同口径)。
result.DeletedAccountMaterial = true
} else if field == dnfrepo.InventoryFieldWarehouse {
changedWarehouse = true
} else {
changedSlots = true
}
result.Removed = append(result.Removed, DeletedItem{
ListType: mutation.listType,
SlotIndex: mutation.slotIndex,
ItemID: stack.ItemID,
RequestedCount: mutation.count,
AppliedCount: applied,
RemainingCount: remaining,
})
}
fields := make([]dnfrepo.InventoryField, 0, 2)
if changedSlots {
fields = append(fields, dnfrepo.InventoryFieldSlots)
}
if changedWarehouse {
fields = append(fields, dnfrepo.InventoryFieldWarehouse)
}
if len(fields) == 0 && !changedAccount {
return result, nil
}
now := time.Now()
if len(fields) > 0 {
record.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, fields...); err != nil {
return DeleteResult{}, err
}
}
if changedAccount {
account.UpdatedAt = now
if err := o.accountRepo.Save(ctx, account); err != nil {
return DeleteResult{}, err
}
}
result.Changed = true
if len(deletedEggs) > 0 {
o.pruneDeletedEggPetEntries(ctx, characterID, deletedEggs)
}
return result, nil
}
// deletedEggRow 是一次成功删除的蛋行,用于清理历史上误建在它上面的宠物记录。
type deletedEggRow struct {
slotIndex int16
itemID int64
}
// pruneDeletedEggPetEntries 移除挂在已删除蛋行上的宠物记录。
//
// 整段都是 best-effort:道具本身已经删掉了,这里失败只留下一条不被任何背包行
// 引用的孤儿宠物记录,绝不能反过来让丢弃失败。
func (o *Owner) pruneDeletedEggPetEntries(ctx context.Context, characterID string, eggs []deletedEggRow) {
if o == nil || o.pets == nil || len(eggs) == 0 {
return
}
petRecord, found, err := o.pets.Load(ctx, characterID)
if err != nil || !found || len(petRecord.Entries) == 0 {
return
}
petRecord = dnfrepo.ClonePet(petRecord)
changed := false
for key, entry := range petRecord.Entries {
if entry.SourceListType != listTypePet || petRecord.EquippedKey == key {
continue
}
for _, egg := range eggs {
if entry.ItemID != egg.itemID || entry.SourceSlotIndex != egg.slotIndex {
continue
}
delete(petRecord.Entries, key)
changed = true
break
}
}
if !changed {
return
}
petRecord.UpdatedAt = time.Now()
_ = dnfrepo.SavePetFields(ctx, o.pets, petRecord, dnfrepo.PetFieldEntries)
}
// Sell 校验出售价格证据并扣减物品。
// 正价格出售需要钱包和背包同事务提交;当前仓储没有跨 repo 事务实现时拒绝,避免半提交。
func (o *Owner) Sell(ctx context.Context, cmd Command) (SellResult, error) {
if o == nil || o.repo == nil {
return SellResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return SellResult{}, ErrCharacterRequired
}
if err := checkInventoryRemovalListType(cmd.SourceListType); err != nil {
return SellResult{}, err
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
if !o.inItemTransaction {
var result SellResult
err := o.withinInventoryTransaction(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.Sell(ctx, cmd)
return err
})
return result, err
}
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return SellResult{}, err
}
if !ok {
return SellResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
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)
}
items, field := itemMapForList(&record, cmd.SourceListType)
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, ok := items[key]
if !ok {
return SellResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if isEquipmentLocked(stack) {
return SellResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrItemLocked, cmd.SourceListType, cmd.SourceSlotIndex)
}
sellGold, err := sellGoldOf(stack)
if err != nil {
return SellResult{}, err
}
if o.characters == nil {
return SellResult{}, fmt.Errorf("%w: character repository missing", ErrWalletTxnRequired)
}
if o.characters == nil {
return SellResult{}, fmt.Errorf("%w: character repository missing", ErrWalletTxnRequired)
}
character, ok, err := o.characters.Load(ctx, characterID)
if err != nil {
return SellResult{}, err
}
if !ok {
return SellResult{}, fmt.Errorf("%w: character=%s", ErrWalletTxnRequired, characterID)
}
applied := normalizeDeleteCount(stack.Count, int64(cmd.Count))
goldDelta := sellGold * applied
if goldDelta != 0 {
return SellResult{}, fmt.Errorf("%w: goldDelta=%d", ErrWalletTxnRequired, goldDelta)
}
remaining := stack.Count - applied
if remaining <= 0 {
delete(items, key)
remaining = 0
} else {
stack.Count = remaining
items[key] = stack
}
record.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, field); err != nil {
return SellResult{}, err
}
return SellResult{
CharacterID: characterID,
Sold: DeletedItem{
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
ItemID: stack.ItemID,
RequestedCount: int64(cmd.Count),
AppliedCount: applied,
RemainingCount: remaining,
},
GoldDelta: goldDelta,
UpdatedGold: character.Stats["gold"],
Changed: true,
}, nil
}
// Move 按当前已验证的安全子集移动背包格子,并只写 inventory repository。
// 装备穿脱、账号仓库和成功 ACK/刷新顺序仍需要 EXE/MCP 证据闭合后交给对应 owner。
func (o *Owner) Move(ctx context.Context, cmd Command) (MoveResult, error) {
if o == nil || o.repo == nil {
return MoveResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return MoveResult{}, ErrCharacterRequired
}
if cmd.SourceListType == listTypeAccountCargo || cmd.DestinationListType == listTypeAccountCargo {
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
return o.moveAccountCargo(ctx, characterID, cmd)
}
if err := checkMoveListType(cmd.SourceListType); err != nil {
return MoveResult{}, err
}
if err := checkMoveListType(cmd.DestinationListType); err != nil {
return MoveResult{}, err
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
petSegment, petMove := petInventoryMoveSegment(cmd)
if cmd.SourceListType == listTypePet && cmd.DestinationListType == listTypePet {
if !petMove {
return MoveResult{}, fmt.Errorf("%w: pet inventory move crosses container segments src=%d dst=%d", ErrUnsupportedList, cmd.SourceSlotIndex, cmd.DestinationSlotIndex)
}
if !o.inPetTransaction {
return o.movePetInventory(ctx, characterID, cmd, petSegment)
}
}
// 晶体/灵魂类道具(各有唯一固定仓库格的十二个家族)不允许落在角色背包里:客户端把
// "从个人仓库/账号金库取回"的落点声明成材料背包格,这里按固定格改回落点,见
// redirectAccountSharedWarehouseDestination。
cmd, warehouseRedirect, clearedMainSlot, err := o.redirectAccountSharedWarehouseDestination(ctx, cmd, characterID)
if err != nil {
return MoveResult{}, err
}
if dnfrepo.IsAccountSharedInventorySlot(cmd.SourceListType, cmd.SourceSlotIndex) ||
dnfrepo.IsAccountSharedInventorySlot(cmd.DestinationListType, cmd.DestinationSlotIndex) {
result, err := o.moveAccountShared(ctx, characterID, cmd)
if err != nil {
return MoveResult{}, err
}
if warehouseRedirect {
result.RedirectedReservedMainSlot = true
// 客户端声明的那一格必须被显式清掉:它自己的乐观移动已经把那件物品画在那里,
// 而 ACK 只回执真实落点(固定格)。少了这一行,客户端从此留下一格点不动的
// 幽灵行(2026-09-21 现场)。
result.ClearedRedirectedMainSlot = clearedMainSlot
}
return result, nil
}
if !o.inItemTransaction {
var result MoveResult
err := o.withinInventoryTransaction(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.Move(ctx, cmd)
return err
})
return result, err
}
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return MoveResult{}, err
}
if !ok {
return MoveResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
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)
}
// 徽章(PVF [stackable type] = [avatar emblem])禁存仓库:运营口径 2026-10-03。
//
// 必须在**任何**仓库重定向之前判定:redirectAccountSharedWarehouseDestination 会把
// 落点改到 354..365,那是账号仓 owner,先改就拦不住了。取出方向不在本闸门范围内
// (只有"落点是仓库"才拦),历史留在仓库里的徽章仍可拖回背包。
if cmd.SourceListType != cmd.DestinationListType &&
isWarehouseDestination(cmd.DestinationListType, cmd.DestinationSlotIndex) {
sourceItems, _ := itemMapForList(&record, cmd.SourceListType)
if source, occupied := sourceItems[slotKey(cmd.SourceListType, cmd.SourceSlotIndex)]; occupied &&
o.isAvatarEmblem(source) {
return MoveResult{}, fmt.Errorf("%w: list=%d slot=%d item=%d",
ErrEmblemWarehouseForbidden, cmd.DestinationListType, cmd.DestinationSlotIndex, source.ItemID)
}
}
var redirectedReservedMainSlot bool
cmd, redirectedReservedMainSlot, err = o.redirectPersonalCargoMoveFromGoldWalletSlot(cmd, record)
if err != nil {
return MoveResult{}, err
}
var pageRedirect bool
cmd, pageRedirect, err = o.redirectMainMoveToItemPage(ctx, cmd, record, characterID)
if err != nil {
return MoveResult{}, err
}
redirectedReservedMainSlot = redirectedReservedMainSlot || pageRedirect
// 落点在未解锁的个人仓库格:运营口径 2026-09-18 —— 顺延到已解锁区(先叠加同类,
// 再从第一格起找空格),真的满了才回"物品栏空间不足"。客户端允许往未解锁格拖,
// 但物品写进去等于消失,绝不能照它声明的槽位落库(现场:拖进未解锁格居然直接放进去了)。
if isPersonalCargoListType(cmd.DestinationListType) {
previewDestination, _ := itemMapForList(&record, cmd.DestinationListType)
previewSource, _ := itemMapForList(&record, cmd.SourceListType)
if movingStack, found := previewSource[slotKey(cmd.SourceListType, cmd.SourceSlotIndex)]; found {
moveCount := o.normalizeMoveCount(movingStack, int64(cmd.MoveCount))
excludeSourceSlot := int16(-1)
if cmd.SourceListType == cmd.DestinationListType {
excludeSourceSlot = cmd.SourceSlotIndex
}
resolution := o.resolveLockedPersonalCargoDestination(
ctx,
cmd.DestinationListType,
cmd.DestinationSlotIndex,
previewDestination,
o.canonicalStackForMove(movingStack),
moveCount,
excludeSourceSlot,
characterID,
)
switch {
case resolution.Locked && cmd.SourceListType == cmd.DestinationListType:
// 仓库内操作(同容器):落到未解锁格**不适用**"顺延到第一个空格"那套,
// 按运营口径直接回原位(拒绝)。顺延只服务于跨容器搬入。
return MoveResult{}, fmt.Errorf(
"%w: 仓库内拖动落在未解锁格 %d,直接回原位",
ErrMoveDestinationFull,
cmd.DestinationSlotIndex,
)
case resolution.Redirected:
cmd.DestinationSlotIndex = resolution.Slot
redirectedReservedMainSlot = true
case resolution.Locked:
return MoveResult{}, fmt.Errorf(
"%w: 个人仓库已解锁格全满,落点 %d 未解锁",
ErrMoveDestinationFull,
cmd.DestinationSlotIndex,
)
}
}
}
srcItems, srcField := itemMapForList(&record, cmd.SourceListType)
dstItems, dstField := itemMapForList(&record, cmd.DestinationListType)
srcKey := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
dstKey := slotKey(cmd.DestinationListType, cmd.DestinationSlotIndex)
result := MoveResult{
CharacterID: characterID,
SourceListType: cmd.SourceListType,
SourceSlotIndex: cmd.SourceSlotIndex,
DestinationListType: cmd.DestinationListType,
DestinationSlotIndex: cmd.DestinationSlotIndex,
MoveCount: int64(cmd.MoveCount),
Mode: "noop",
RedirectedReservedMainSlot: redirectedReservedMainSlot,
}
if srcField == dstField && srcKey == dstKey {
return result, nil
}
source, sourceOK := srcItems[srcKey]
destination, destinationOK := dstItems[dstKey]
if sourceOK {
source = o.canonicalStackForMove(source)
}
if destinationOK {
destination = o.canonicalStackForMove(destination)
}
if !sourceOK && !destinationOK {
return MoveResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if sourceOK && source.Count <= 0 {
return MoveResult{}, fmt.Errorf("%w: list=%d slot=%d count=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex, source.Count)
}
if !sourceOK {
if destination.Count <= 0 {
return MoveResult{}, fmt.Errorf("%w: list=%d slot=%d count=%d", ErrSlotNotFound, cmd.DestinationListType, cmd.DestinationSlotIndex, destination.Count)
}
moveCount := o.normalizeMoveCount(destination, int64(cmd.MoveCount))
if moveCount <= 0 {
return MoveResult{}, fmt.Errorf("%w: list=%d slot=%d count=%d", ErrSlotNotFound, cmd.DestinationListType, cmd.DestinationSlotIndex, destination.Count)
}
// 反向:物品其实从声明的目标格搬进源容器,携带上限按源容器算。
if err := o.validateCarryLimitOnMove(
cmd.DestinationListType, cmd.SourceListType, cmd.SourceSlotIndex, srcItems, destination, moveCount,
); err != nil {
return MoveResult{}, err
}
excludedSlots := []int16{cmd.SourceSlotIndex}
if cmd.SourceListType == cmd.DestinationListType {
excludedSlots = append(excludedSlots, cmd.DestinationSlotIndex)
}
// 反向落点(声明的 source 端)在未解锁格:必须顺延,绝不能把物品放回看不见的格子。
// 现场 2026-09-18:仓库/金库内部把已有物品拖到未解锁格,客户端把落点写在 source 端。
skipStackSearch := false
if isPersonalCargoListType(cmd.SourceListType) {
resolution := o.resolveLockedPersonalCargoDestination(
ctx,
cmd.SourceListType,
cmd.SourceSlotIndex,
srcItems,
destination,
moveCount,
cmd.DestinationSlotIndex,
characterID,
)
if resolution.Locked {
if cmd.SourceListType == cmd.DestinationListType {
// 仓库内操作(同容器):落到未解锁格直接回原位,不顺延。
return MoveResult{}, fmt.Errorf(
"%w: 仓库内拖动落在未解锁格 %d,直接回原位",
ErrMoveDestinationFull,
cmd.SourceSlotIndex,
)
}
if !resolution.Redirected {
return MoveResult{}, fmt.Errorf(
"%w: 个人仓库已解锁格全满,落点 %d 未解锁",
ErrMoveDestinationFull,
cmd.SourceSlotIndex,
)
}
if resolution.StackFound {
target := mergeIntoStack(resolution.Stack, cmd.SourceListType, resolution.Slot, destination, moveCount)
if destination.Count <= moveCount {
delete(dstItems, dstKey)
} else {
destination.Count -= moveCount
updateStackRawAmount(&destination)
dstItems[dstKey] = destination
}
srcItems[slotKey(cmd.SourceListType, resolution.Slot)] = target
result.MoveCount = moveCount
result.Mode = "auto_stack_reverse"
result.SourceSlotIndex = resolution.Slot
result.RedirectedReservedMainSlot = true
result, err = o.withMoveRefresh(ctx, cmd, result, &record, cmd.SourceListType)
if err != nil {
return MoveResult{}, err
}
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
cmd.SourceSlotIndex = resolution.Slot
srcKey = slotKey(cmd.SourceListType, resolution.Slot)
result.SourceSlotIndex = resolution.Slot
result.RedirectedReservedMainSlot = true
skipStackSearch = true
}
}
if !skipStackSearch && !isMainItemQuickSlot(cmd.SourceListType, cmd.SourceSlotIndex) {
if targetSlot, target, ok := o.findCompatibleStackSlot(
srcItems,
cmd.SourceListType,
destination,
moveCount,
excludedSlots...,
); ok {
target = mergeIntoStack(target, cmd.SourceListType, targetSlot, destination, moveCount)
if destination.Count <= moveCount {
delete(dstItems, dstKey)
} else {
destination.Count -= moveCount
updateStackRawAmount(&destination)
dstItems[dstKey] = destination
}
srcItems[slotKey(cmd.SourceListType, targetSlot)] = target
result.MoveCount = moveCount
result.Mode = "auto_stack_reverse"
result, err = o.withMoveRefresh(ctx, cmd, result, &record, cmd.SourceListType, cmd.DestinationListType)
if err != nil {
return MoveResult{}, err
}
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
}
if o.canSplitStack(destination, moveCount) {
moved := cloneStack(destination)
moved.Count = moveCount
updateStackRawAmount(&moved)
destination.Count -= moveCount
updateStackRawAmount(&destination)
dstItems[dstKey] = destination
srcItems[srcKey] = preparedMoveStack(moved, cmd.DestinationListType, cmd.DestinationSlotIndex, cmd.SourceListType, cmd.SourceSlotIndex)
result.MoveCount = moveCount
result.Mode = "reverse_split"
if cmd.SourceListType == cmd.DestinationListType {
result, err = o.withMoveRefresh(ctx, cmd, result, &record, cmd.SourceListType)
if err != nil {
return MoveResult{}, err
}
}
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
delete(dstItems, dstKey)
srcItems[srcKey] = preparedMoveStack(destination, cmd.DestinationListType, cmd.DestinationSlotIndex, cmd.SourceListType, cmd.SourceSlotIndex)
result.MoveCount = destination.Count
result.Mode = "reverse_move"
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
moveCount := o.normalizeMoveCount(source, int64(cmd.MoveCount))
if moveCount <= 0 {
return MoveResult{}, fmt.Errorf("%w: list=%d slot=%d count=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex, source.Count)
}
// 携带上限(PVF [stack limit])按容器总量算:跨容器搬入前先卡,超了直接拒绝,
// 不允许靠"另寻空格"绕过去。
if err := o.validateCarryLimitOnMove(
cmd.SourceListType, cmd.DestinationListType, cmd.DestinationSlotIndex, dstItems, source, moveCount,
); err != nil {
return MoveResult{}, err
}
if !destinationOK {
excludedSlots := []int16{cmd.DestinationSlotIndex}
if cmd.SourceListType == cmd.DestinationListType {
excludedSlots = append(excludedSlots, cmd.SourceSlotIndex)
}
if !isMainItemQuickSlot(cmd.DestinationListType, cmd.DestinationSlotIndex) {
if targetSlot, target, ok := o.findCompatibleStackSlot(
dstItems,
cmd.DestinationListType,
source,
moveCount,
excludedSlots...,
); ok {
target = mergeIntoStack(target, cmd.DestinationListType, targetSlot, source, moveCount)
if source.Count <= moveCount {
delete(srcItems, srcKey)
} else {
source.Count -= moveCount
updateStackRawAmount(&source)
srcItems[srcKey] = source
}
dstItems[slotKey(cmd.DestinationListType, targetSlot)] = target
result.MoveCount = moveCount
result.Mode = "auto_stack"
if cmd.SourceListType != cmd.DestinationListType {
// 跨容器:物品没有落进客户端拖过去的那一格,而是并进了目标容器里
// 另一处同类堆。真实落点必须写进 ACK,否则客户端会把这一堆画在原
// 本的空格上;同时绝不能走整 list 刷新 —— list-0 的保留槽 0(金币
// 钱包行)会被一起下发,现场就是"获得了 N 金币"+ 物品行错位
// (2026-09-13 05:40:28 抓包 refreshLists=[0 2] 复现)。
// 用 "stack" 让 bridge 按真实端点补精确槽位刷新,与显式合并同一形状。
result.Mode = "stack"
result.DestinationSlotIndex = targetSlot
result.RedirectedReservedMainSlot = true
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
result, err = o.withMoveRefresh(ctx, cmd, result, &record, cmd.SourceListType, cmd.DestinationListType)
if err != nil {
return MoveResult{}, err
}
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
}
if o.canSplitStack(source, moveCount) {
moved := cloneStack(source)
moved.Count = moveCount
updateStackRawAmount(&moved)
source.Count -= moveCount
updateStackRawAmount(&source)
srcItems[srcKey] = source
dstItems[dstKey] = preparedMoveStack(moved, cmd.SourceListType, cmd.SourceSlotIndex, cmd.DestinationListType, cmd.DestinationSlotIndex)
result.MoveCount = moveCount
result.Mode = "split"
if cmd.SourceListType == cmd.DestinationListType {
result, err = o.withMoveRefresh(ctx, cmd, result, &record, cmd.SourceListType)
if err != nil {
return MoveResult{}, err
}
}
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
delete(srcItems, srcKey)
dstItems[dstKey] = preparedMoveStack(source, cmd.SourceListType, cmd.SourceSlotIndex, cmd.DestinationListType, cmd.DestinationSlotIndex)
result.MoveCount = source.Count
result.Mode = "move"
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
// op19 跨容器落点修正:目标格被占且跨容器时,绝不能互换 —— op19 的 ACK 只带一个
// 目标槽,被换出去的那件客户端不知道去了哪,会"消失"到小退(账号金库只有一格时
// 就是这个现象)。所以只剩三条出路,按 86JP
// InventoryMoveService.ApplyCrossSpaceInsertToOccupiedDestination 的顺序:
// ① 与声明格同类可叠 → 就地合并(由下面的常规 canStack 分支执行);
// ② 目标容器里另一处同类可叠格 → 并进那一行,不新开第二行;
// ③ 目标容器里第一个已解锁空格 → 落过去,真实落点写进 ACK。
// 三条都不成立(容器真的满/读不到容器状态)才明确回"空间不足"。
//
// ②③ 以前只在"客户端声明目标格为空、服务端却有行"时才跑;玩家正常丢在占用格上的
// 手势(客户端带着它看见的那一行)会被直接拒掉,即使目标容器还空着好几格 ——
// 2026-09-27 现场两个方向都是这个现象,已修(见 crossContainerOccupiedDestinationRedirect)。
// 主快捷栏(3..8 的互换是常规操作)与账号共享晶体/灵魂槽保持原有语义,不在本规则内。
if cmd.SourceListType != cmd.DestinationListType &&
!isMainItemQuickSlot(cmd.DestinationListType, cmd.DestinationSlotIndex) &&
!dnfrepo.IsAccountSharedInventorySlot(cmd.DestinationListType, cmd.DestinationSlotIndex) {
if redirect, ok := o.crossContainerOccupiedDestinationRedirect(ctx, cmd, dstItems, destination, source, moveCount, characterID); ok {
targetKey := slotKey(cmd.DestinationListType, redirect.Slot)
result.MoveCount = moveCount
if redirect.Mode == "stack" {
target := mergeIntoStack(redirect.Stack, cmd.DestinationListType, redirect.Slot, source, moveCount)
if source.Count <= moveCount {
delete(srcItems, srcKey)
} else {
source.Count -= moveCount
updateStackRawAmount(&source)
srcItems[srcKey] = source
}
dstItems[targetKey] = target
result.Mode = "stack"
} else if o.canSplitStack(source, moveCount) {
moved := cloneStack(source)
moved.Count = moveCount
updateStackRawAmount(&moved)
source.Count -= moveCount
updateStackRawAmount(&source)
srcItems[srcKey] = source
dstItems[targetKey] = preparedMoveStack(moved, cmd.SourceListType, cmd.SourceSlotIndex, cmd.DestinationListType, redirect.Slot)
result.Mode = "split"
} else {
delete(srcItems, srcKey)
dstItems[targetKey] = preparedMoveStack(source, cmd.SourceListType, cmd.SourceSlotIndex, cmd.DestinationListType, redirect.Slot)
result.Mode = "move"
}
// ACK 与精确槽位刷新都必须报真实落点,不能回显客户端声明的槽位。
result.DestinationSlotIndex = redirect.Slot
result.RedirectedReservedMainSlot = true
// 这里刻意不调用 withMoveRefresh:整 list 刷新会把 list-0 的保留槽 0(金币
// 钱包行)一起下发,历史上造成过"获得金币/金币归 0"。落点已经写进 ACK,
// 客户端据此自己重画两个端点即可。
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
if !o.canStack(source, destination, moveCount) {
// 目标容器没有空格也没有同类可叠格,声明的那一格又放不下这一堆:
// 回失败包让客户端弹"物品栏空间不足",而不是偷偷换位。
return MoveResult{}, fmt.Errorf("%w: list=%d slot=%d item=%d count=%d", ErrMoveDestinationFull, cmd.DestinationListType, cmd.DestinationSlotIndex, source.ItemID, moveCount)
}
}
if o.canStack(source, destination, moveCount) {
destination = mergeIntoStack(destination, cmd.DestinationListType, cmd.DestinationSlotIndex, source, moveCount)
if source.Count <= moveCount {
delete(srcItems, srcKey)
} else {
source.Count -= moveCount
updateStackRawAmount(&source)
srcItems[srcKey] = source
}
dstItems[dstKey] = destination
result.MoveCount = moveCount
result.Mode = "stack"
if isMainPersonalCargoCrossList(cmd.SourceListType, cmd.DestinationListType) {
// The bridge can refresh the exact source and destination rows for
// this explicit cross-container merge. Avoid constructing full
// list snapshots that invalidate the client's active op19 objects.
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
result, err = o.withMoveRefresh(ctx, cmd, result, &record, cmd.SourceListType, cmd.DestinationListType)
if err != nil {
return MoveResult{}, err
}
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
srcItems[srcKey] = preparedMoveStack(destination, cmd.DestinationListType, cmd.DestinationSlotIndex, cmd.SourceListType, cmd.SourceSlotIndex)
dstItems[dstKey] = preparedMoveStack(source, cmd.SourceListType, cmd.SourceSlotIndex, cmd.DestinationListType, cmd.DestinationSlotIndex)
result.MoveCount = source.Count
result.Mode = "swap"
// 2026-09-13 回滚记录:这里曾加上
// withMoveRefresh(ctx, cmd, result, &record, SourceListType, DestinationListType)
// 目的是让客户端看到跨容器交换后的两行。但按整个 list 刷新会把 list-0 的保留槽 0
// (金币钱包)也带下去,而那条路径不经过钱包投影,结果客户端弹出"获得 102622810 金币"
// 并且同物品叠加时金币显示为 0。该写法已回滚;若要再做,只能按"精确槽位"下发
// (result.Refresh 里只放源/目标格,或走 incrementalSlots 通道),绝不能整 list 刷新。
return o.saveMoveResult(ctx, record, srcField, dstField, result)
}
// redirectAccountSharedWarehouseDestination 把"落点是角色背包"的晶体/灵魂类道具改回
// 它在晶体/灵魂仓库里的固定格。
//
// 运营口径 2026-09-17:把晶体从个人仓库/账号金库取回时,客户端声明的落点是材料背包格
// (现场 0:156、0:121、0:150),于是无色小晶块落在背包里,而晶体仓库面板那一格还是空的。
// 这类道具在自己账号里有唯一的家:六个晶块固定占 list 0 的 354..359、六个灵魂占
// 360..365(dnfbridge 的 currentDisjointWarehouseFixedSlots,2026-07-15 实测并经运营
// 确认)。其它入包路径(副本拾取、邮件领取、合成产出、NPC 商店材料)本来就按固定格落库,
// 只有玩家手拖这条 op19 路径漏了,所以在这里补齐。
//
// 不接管的情形(保持客户端声明的落点):
// - 落点已经是账号共享槽:客户端自己就声明对了;
// - 这件道具没有固定格(元素结晶等所有其它可叠加道具);
// - 固定格被**别的**道具占着(历史脏数据)——绝不驱赶别人的行;
// - 同 id 合并会超上限——grant 路径的做法是溢出到背包,这里保持一致,不做跨容器互换
// (op19 的 ACK 只带一个目标槽,换出去的那件客户端不知道去了哪)。
//
// 返回的 bool 表示落点被改过,调用方据此把 RedirectedReservedMainSlot 写进结果,让 ACK
// 回显真正落库的端点;第二个返回值是"客户端声明的那一格"(仅当它在持久化记录里确实是空的
// 时才非 0),调用方把它交给 bridge 补一条删除行 —— 客户端发包前已经乐观地画在那一格上了。
// 这一步只读快照:真正的提交仍在账号事务里重新加载并校验。
func (o *Owner) redirectAccountSharedWarehouseDestination(
ctx context.Context,
cmd Command,
characterID string,
) (Command, bool, int16, error) {
if o == nil || o.accountWarehouseSlot == nil ||
cmd.DestinationListType != listTypeMain ||
dnfrepo.IsAccountSharedInventorySlot(cmd.DestinationListType, cmd.DestinationSlotIndex) {
return cmd, false, 0, nil
}
source, record, found, err := o.accountWarehouseMoveSource(ctx, cmd, characterID)
if err != nil || !found || source.ItemID <= 0 || source.Count <= 0 {
return cmd, false, 0, err
}
slot, fixed := o.accountWarehouseSlot(source.ItemID)
if !fixed || slot < dnfrepo.CrystalWarehouseFirstSlot || slot > dnfrepo.SoulWarehouseLastSlot ||
slot == cmd.DestinationSlotIndex {
return cmd, false, 0, nil
}
accountID := strings.TrimSpace(cmd.AccountID)
if accountID == "" || o.accountRepo == nil {
return cmd, false, 0, nil
}
account, _, err := o.accountRepo.Load(ctx, accountID)
if err != nil {
return cmd, false, 0, err
}
if occupied, exists := account.Slots[dnfrepo.AccountSharedInventorySlotKey(slot)]; exists && occupied.ItemID > 0 {
if occupied.ItemID != source.ItemID ||
!o.canStack(source, occupied, o.normalizeMoveCount(source, int64(cmd.MoveCount))) {
return cmd, false, 0, nil
}
}
// 声明格在服务端确实是空的才回报(bridge 会按"背包空格 = 0xFFFFFFFF 删除行"补发)。
// 读不到记录时保守地不回报:宁可维持改造前的行为,也不拿不准的状态去删客户端的格子。
clearedMainSlot := int16(0)
if record.CharacterID != "" {
if _, occupied := record.Slots[slotKey(listTypeMain, cmd.DestinationSlotIndex)]; !occupied {
clearedMainSlot = cmd.DestinationSlotIndex
}
}
cmd.DestinationSlotIndex = slot
return cmd, true, clearedMainSlot, nil
}
// accountWarehouseMoveSource 读这次移动**源端**那一行,用来判断搬的是哪件道具。源端可能
// 在角色容器(背包/个人仓库),也可能已经在账号共享晶体/灵魂槽里。第二个返回值是读源端时
// 顺带拿到的角色记录(源端在账号仓时为"未加载"的零值),调用方据此判断落点格是否为空,
// 避免为了同一件事再读一次库。
func (o *Owner) accountWarehouseMoveSource(
ctx context.Context,
cmd Command,
characterID string,
) (dnfrepo.ItemStack, dnfrepo.InventoryRecord, bool, error) {
if dnfrepo.IsAccountSharedInventorySlot(cmd.SourceListType, cmd.SourceSlotIndex) {
accountID := strings.TrimSpace(cmd.AccountID)
if o.accountRepo == nil || accountID == "" {
return dnfrepo.ItemStack{}, dnfrepo.InventoryRecord{}, false, nil
}
account, _, err := o.accountRepo.Load(ctx, accountID)
if err != nil {
return dnfrepo.ItemStack{}, dnfrepo.InventoryRecord{}, false, err
}
stack, ok := account.Slots[dnfrepo.AccountSharedInventorySlotKey(cmd.SourceSlotIndex)]
return stack, dnfrepo.InventoryRecord{}, ok, nil
}
if o.repo == nil {
return dnfrepo.ItemStack{}, dnfrepo.InventoryRecord{}, false, nil
}
record, found, err := o.repo.Load(ctx, characterID)
if err != nil || !found {
return dnfrepo.ItemStack{}, dnfrepo.InventoryRecord{}, false, err
}
items, _ := itemMapForList(&record, cmd.SourceListType)
stack, ok := items[slotKey(cmd.SourceListType, cmd.SourceSlotIndex)]
return stack, record, ok, nil
}
// redirectPersonalCargoMoveFromGoldWalletSlot prevents an ordinary item from
// ever being persisted in main-list slot 0. The current client owns that row
// as its gold-wallet object, although op19 can name it as an empty backpack
// destination. The moving item's active-PVF page is the only authority for a
// replacement slot; no fixed consumable/material range is assumed here.
func (o *Owner) redirectPersonalCargoMoveFromGoldWalletSlot(cmd Command, record dnfrepo.InventoryRecord) (Command, bool, error) {
if !isMainPersonalCargoCrossList(cmd.SourceListType, cmd.DestinationListType) {
return cmd, false, nil
}
mainIsSource := cmd.SourceListType == listTypeMain && cmd.SourceSlotIndex == 0
mainIsDestination := cmd.DestinationListType == listTypeMain && cmd.DestinationSlotIndex == 0
if !mainIsSource && !mainIsDestination {
return cmd, false, nil
}
if stack, occupied := record.Slots[slotKey(listTypeMain, 0)]; occupied && stack.ItemID != 0 && stack.Count > 0 {
return cmd, false, fmt.Errorf("%w: gold-wallet slot already contains item=%d", ErrReservedSlotRelocationFull, stack.ItemID)
}
warehouseSlot := cmd.SourceSlotIndex
if mainIsSource {
warehouseSlot = cmd.DestinationSlotIndex
}
moving, found := record.Warehouse[slotKey(listTypePersonalCargo, warehouseSlot)]
if !found || moving.ItemID <= 0 || moving.Count <= 0 {
return cmd, false, fmt.Errorf("%w: personal-cargo list=%d slot=%d", ErrSlotNotFound, listTypePersonalCargo, warehouseSlot)
}
if o.stackMerge == nil {
return cmd, false, fmt.Errorf("%w: item=%d active-PVF page resolver unavailable", ErrReservedSlotRelocationFull, moving.ItemID)
}
resolution, resolveErr := o.stackMerge(moving.ItemID)
if resolveErr != nil || resolution.ItemID != moving.ItemID || resolution.SlotStart <= 0 || resolution.SlotEnd < resolution.SlotStart {
return cmd, false, fmt.Errorf("%w: item=%d page=%d..%d resolve=%v", ErrReservedSlotRelocationFull, moving.ItemID, resolution.SlotStart, resolution.SlotEnd, resolveErr)
}
for slot := resolution.SlotStart; slot <= resolution.SlotEnd; slot++ {
stack, occupied := record.Slots[slotKey(listTypeMain, slot)]
if occupied && stack.ItemID != 0 && stack.Count > 0 {
continue
}
if mainIsSource {
cmd.SourceSlotIndex = slot
} else {
cmd.DestinationSlotIndex = slot
}
return cmd, true, nil
}
return cmd, false, fmt.Errorf("%w: item=%d page=%d..%d", ErrReservedSlotRelocationFull, moving.ItemID, resolution.SlotStart, resolution.SlotEnd)
}
// saveMoveResult enforces the quick-slot equipment invariant against the
// final staged record, immediately before persistence. Checking the final
// layout covers ordinary moves, swaps, reverse moves and stack branches with
// one rule instead of attempting to infer the client's intended direction.
func (o *Owner) saveMoveResult(
ctx context.Context,
record dnfrepo.InventoryRecord,
srcField dnfrepo.InventoryField,
dstField dnfrepo.InventoryField,
result MoveResult,
) (MoveResult, error) {
if err := o.validateMainQuickSlotCharmExclusivity(record); err != nil {
return MoveResult{}, err
}
// 86JP SendMoveSortLockSignals:搬完后仍被整理锁定钉住的端点槽位要补发 op713,
// 否则客户端的 padlock 会留在物品刚离开的旧槽位上。
result.SortLockSignals = moveSortLockSignals(&record, result)
return saveMoveResult(ctx, o.repo, record, srcField, dstField, result)
}
// validateMainQuickSlotCharmExclusivity implements the current client's
// quick-slot rule for PVF [charm] equipment. The six quick slots (3..8) can
// contain consumables normally, but only one [charm] equipment may be active
// among them. This deliberately reads the live PVF resolution rather than
// trusting saved Extra data, item names, or a list of historical charm IDs.
//
// A nil resolver is retained for focused domain tests and old offline tools;
// the current op19 route always installs the active-PVF resolver. An item
// that no longer resolves in the active archive is not reclassified from
// stale data here, which still lets a player remove legacy rows safely.
func (o *Owner) validateMainQuickSlotCharmExclusivity(record dnfrepo.InventoryRecord) error {
if o == nil || o.stackMerge == nil || len(record.Slots) == 0 {
return nil
}
charmSlots := make([]int16, 0, 2)
charmItems := make([]int64, 0, 2)
for slot := int16(3); slot <= 8; slot++ {
stack, found := record.Slots[slotKey(listTypeMain, slot)]
if !found || stack.ItemID <= 0 || stack.Count <= 0 {
continue
}
resolution, err := o.stackMerge(stack.ItemID)
if err != nil || resolution.ItemID != stack.ItemID {
continue
}
if !strings.EqualFold(strings.TrimSpace(resolution.Kind), "equipment") ||
!strings.EqualFold(strings.TrimSpace(resolution.EquipmentType), "[charm]") {
continue
}
charmSlots = append(charmSlots, slot)
charmItems = append(charmItems, stack.ItemID)
}
if len(charmSlots) <= 1 {
return nil
}
return fmt.Errorf("%w: slots=%v items=%v", ErrQuickSlotCharmAlreadyEquipped, charmSlots, charmItems)
}
func isMainPersonalCargoCrossList(sourceListType, destinationListType byte) bool {
return sourceListType != destinationListType &&
(sourceListType == listTypeMain || sourceListType == listTypePersonalCargo) &&
(destinationListType == listTypeMain || destinationListType == listTypePersonalCargo)
}
func (o *Owner) movePetInventory(ctx context.Context, characterID string, cmd Command, segment byte) (MoveResult, error) {
if o.petItems == nil || o.pets == nil {
return MoveResult{}, ErrPetInventoryOwnerRequired
}
var result MoveResult
err := o.petItems.WithinCharacterPets(ctx, characterID, func(inventory dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository, pets dnfrepo.PetRepository) error {
txOwner := *o
txOwner.repo = inventory
txOwner.pets = pets
txOwner.inItemTransaction = true
txOwner.inPetTransaction = true
if segment == petInventorySegmentCreature {
if _, err := creaturestate.ReconcileInventory(ctx, characterID, inventory, equipment, pets); err != nil {
return err
}
}
var err error
result, err = txOwner.Move(ctx, cmd)
if err != nil {
return err
}
if segment == petInventorySegmentCreature && result.Changed {
_, err = creaturestate.ReconcileInventory(ctx, characterID, inventory, equipment, pets)
}
return err
})
return result, err
}
const (
petInventorySegmentCreature byte = iota + 1
petInventorySegmentArtifact
petInventorySegmentConsumable
)
func petInventoryMoveSegment(cmd Command) (byte, bool) {
if cmd.SourceListType != listTypePet || cmd.DestinationListType != listTypePet {
return 0, false
}
source := petInventorySlotSegment(cmd.SourceSlotIndex)
destination := petInventorySlotSegment(cmd.DestinationSlotIndex)
return source, source != 0 && source == destination
}
func petInventorySlotSegment(slot int16) byte {
switch {
case slot >= 0 && slot <= 139:
return petInventorySegmentCreature
case slot >= 140 && slot <= 188:
return petInventorySegmentArtifact
case slot >= 189 && slot <= 238:
return petInventorySegmentConsumable
default:
return 0
}
}
// Sort 按当前客户端已确认的 category 段整理物品栏,并只保存对应 inventory 字段。
// 当前 Go 仓储还没有 sort-lock 表,所以这里只实现无锁安全子集;
// Main/Avatar/PersonalCargo 的专用 op13 刷新均由 handler 在成功 ACK 后发送。
// Pet/list7 同时拥有 PetRecord 状态,必须由 typed pet owner 整体整理,通用 inventory owner fail-close。
func (o *Owner) Sort(ctx context.Context, cmd Command) (SortResult, error) {
if o == nil || o.repo == nil {
return SortResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return SortResult{}, ErrCharacterRequired
}
if err := checkSortListType(cmd.SourceListType); err != nil {
return SortResult{}, err
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
if cmd.SourceListType == listTypeAccountCargo {
// 账号金库不在角色 inventory 记录里,必须走账号 owner 的事务。
return o.sortAccountCargo(ctx, characterID, cmd)
}
if !o.inItemTransaction {
var result SortResult
err := o.withinInventoryTransaction(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.Sort(ctx, cmd)
return err
})
return result, err
}
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return SortResult{}, err
}
if !ok {
return SortResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
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)
}
items, field := itemMapForList(&record, cmd.SourceListType)
start, end, ok := sortSegment(cmd.SourceListType, cmd.Category)
result := SortResult{
CharacterID: characterID,
ListType: cmd.SourceListType,
Category: cmd.Category,
StartSlot: start,
EndSlot: end,
Mode: "noop",
}
if !ok {
return result, nil
}
moved, changed, err := sortSegmentItems(items, cmd.SourceListType, start, end)
if err != nil {
return SortResult{}, err
}
result.MovedCount = moved
if !changed {
return result, nil
}
record.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, field); err != nil {
return SortResult{}, err
}
result.Mode = "sort"
result.Changed = true
return result, nil
}
// Repair 按当前仓储中已有的耐久证据修复背包/仓库装备。
// 这里只允许 repair_gold=0 的安全子集;穿戴装备、全修和金币扣减继续交给后续 equipment/wallet owner。
// Repair repairs one bag/cargo equipment stack through the current runtime-PVF
// contract: PVF evidence prices the job, gold and durability commit in
// the same character-asset transaction, body[5]=1 with an active 魔王契约
// (586) is free, and body[7]=1 quick repair pays the pricetable rate.
// slot=-1 (repair all) is owned by the equipment owner upstream.
func (o *Owner) Repair(ctx context.Context, cmd Command, costResolver alignedcmd.RepairCostResolver) (RepairResult, error) {
if o == nil || o.repo == nil || o.assets == nil {
return RepairResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return RepairResult{}, ErrCharacterRequired
}
if cmd.SourceSlotIndex == -1 {
return RepairResult{}, ErrRepairAllRequiresOwners
}
if err := checkRepairListType(cmd.SourceListType); err != nil {
return RepairResult{}, err
}
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, inventoryRepo dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
record, ok, err := inventoryRepo.Load(ctx, characterID)
if err != nil {
return err
}
if !ok {
return ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
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)
}
items, field := itemMapForList(&record, cmd.SourceListType)
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, ok := items[key]
if !ok {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
oldDurability, _, err := repairDurabilityOf(stack)
if err != nil {
return err
}
evidence, err := costResolver(stack.ItemID)
if err != nil {
return err
}
maxDurability := evidence.MaxDurability
if maxDurability <= 0 {
return fmt.Errorf("%w: list=%d slot=%d item=%d", ErrRepairNotRepairable, cmd.SourceListType, cmd.SourceSlotIndex, stack.ItemID)
}
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("%w: character=%s", ErrWalletTxnRequired, characterID)
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
return fmt.Errorf("%w: character=%s has no stats", ErrWalletTxnRequired, characterID)
}
gold := character.Stats["gold"]
result = RepairResult{
CharacterID: characterID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
OldDurability: oldDurability,
NewDurability: maxDurability,
}
if oldDurability >= maxDurability {
result.NewDurability = oldDurability
result.UpdatedGold = gold
return nil
}
cost := dnfequip.CalcRepairCost(evidence, oldDurability, int(upgradeLevelOf(stack)), cmd.QuickRepair)
freeRepair := false
statsChanged := false
if cost != 0 && cmd.AutoRepair && o.premiumActive(ctx, cmd.AccountID, premium.DevilSlotType(premium.DevilSlotAutoRepair)) {
if premium.TryConsumeDaily(&character, premium.DevilSlotAutoRepair, time.Now().UTC()) {
cost = 0
freeRepair = true
statsChanged = true
}
}
if cost > gold {
return 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 err
}
}
stack = cloneStack(stack)
if stack.Extra == nil {
stack.Extra = make(map[string]string, 1)
}
stack.Extra["durability"] = strconv.FormatInt(maxDurability, 10)
// 耐久字段的位置**取决于行布局**,不能无条件按旧布局写:
// - 当前 0x77 行:品级种子 +0x06、打包的强化/属性字节 +0x0A、耐久 u16 +0x0B(高字节 +0x0C);
// - 旧构造行:创建值 +0x05、打包强化 +0x09、耐久 u16 +0x0A。
// 旧代码对任何 len>=12 的行都写 [10:12],于是给当前行把"最大耐久"直接写进了强化字节,
// 同时耐久只写了一半(高字节留在 +0x0C)——现场 2026-09-18:脱下→修理→调整品级之后,
// 装备凭空多出强化等级(数值正好等于最大耐久,如腰带 +25、上衣 +33),而且这次品级
// 调整补发的单行 op14 刷新正好把带毒的属性字节重画了出来。
if raw, ok := currentRawEntry(stack); ok {
stack.RawEntry = append([]byte(nil), raw...)
binary.LittleEndian.PutUint16(stack.RawEntry[0x0B:0x0D], uint16(maxDurability))
} else if raw, ok := legacyRawEntry(stack); ok {
stack.RawEntry = append([]byte(nil), raw...)
binary.LittleEndian.PutUint16(stack.RawEntry[10:12], uint16(maxDurability))
} else if len(stack.RawEntry) >= 12 {
// 认不出的行形状:保持历史行为(旧构造布局),绝不改 +0x0A。
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
stack.RawEntry[10] = byte(maxDurability)
stack.RawEntry[11] = byte(maxDurability >> 8)
}
items[key] = stack
record.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, record, field); err != nil {
return err
}
result.Cost = cost
result.UpdatedGold = gold - cost
result.Changed = true
result.FreeRepair = freeRepair
return nil
})
return result, err
}
// 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())
}
// Upgrade applies the currently tested current-EXE upgrade flow for
// backpack equipment: validate target, consume the runtime-PVF material from
// either character inventory or account-shared crystal/soul slots, persist the
// packed upgrade byte, then let the handler send op50 + op0D.
func (o *Owner) Upgrade(ctx context.Context, cmd Command) (UpgradeResult, error) {
base := newUpgradeResult(cmd, upgradeErrorInvalidTarget)
if o == nil || o.repo == nil {
return UpgradeResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return UpgradeResult{}, ErrCharacterRequired
}
if cmd.Mode != "reinforce" && cmd.Mode != "amplify" {
return newUpgradeResult(cmd, upgradeErrorWrongMode), nil
}
// List 38 (公会徽章) reaches this path only as a fully-resolved reinforce:
// its level, gold cost, material and success weight all come from the native
// flag_upgrade_table row the aligned resolver selected, so none of them may
// be missing. Every other list keeps the pre-existing behaviour, including
// the worn list-3 reinforce this tree added.
if cmd.TargetListType == listTypeGuildMedal &&
(!cmd.UpgradeGuildMedal || cmd.Mode != "reinforce" || cmd.TargetSlotIndex < 0 ||
cmd.TargetSlotIndex > 48 || cmd.OptionalTicketSlot >= 0 || cmd.UpgradePolicyError != "") {
return base, nil
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
if !o.inItemTransaction {
var result UpgradeResult
wrap := o.withinCharacterAssetTransaction
// A destruction refund that owns a fixed warehouse slot writes the
// account record too, so it needs the wider transaction even when the
// material itself is character-owned (every 增幅 attempt: the 增幅器 is
// a backpack stackable, the refunded souls and cubes are not). An
// owner without the account-asset transaction keeps the old scope and
// falls back to the backpack rather than failing the upgrade.
if upgradeCommandRequiresAccountMaterial(cmd) ||
(upgradeDestroyRefundsWarehouse(cmd) &&
o.accountAssets != nil && strings.TrimSpace(cmd.AccountID) != "") {
wrap = func(ctx context.Context, characterID string, apply func(*Owner) error) error {
return o.withinAccountCharacterAssetTransaction(ctx, cmd.AccountID, characterID, apply)
}
}
err := wrap(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.Upgrade(ctx, cmd)
return err
})
if err != nil {
return UpgradeResult{}, err
}
if result.CharacterID == "" {
result = base
result.CharacterID = characterID
}
// Mail the refund rows the bag refused, now that the upgrade itself is
// durable. This cannot join the transaction above: it covers
// characters, inventory and equipment, while mailboxes live in their
// own unit of work. A mail failure is the mailer's to report -- the
// destruction has already committed and must not be undone for it.
if len(result.DestroyRefundOverflow) > 0 && o.upgradeRefundMailer != nil {
if mailErr := o.upgradeRefundMailer(ctx, characterID, result.DestroyRefundOverflow); mailErr == nil {
result.DestroyRefundMailed = true
}
}
return result, nil
}
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return UpgradeResult{}, err
}
if !ok {
return UpgradeResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
record.CharacterID = characterID
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
result := base
result.CharacterID = characterID
items := record.Slots
// A worn target (op50 mode word 0x03xx) lives in the equipment record, not
// the backpack. It is read through the same entry-to-stack projection the
// enchant and forging owners use, mutated by the shared level logic below,
// and written back to its EquipmentEntry before the transaction commits.
// The material, protection ticket and destroy bonus stay in the backpack.
targetEquipped := cmd.TargetListType == listTypeEquipment
var equipmentRecord dnfrepo.EquipmentRecord
var targetEntry dnfrepo.EquipmentEntry
equipmentKey := strconv.Itoa(int(cmd.TargetSlotIndex))
targetKey := slotKey(cmd.TargetListType, cmd.TargetSlotIndex)
var target dnfrepo.ItemStack
switch {
case targetEquipped:
if o.equipment == nil {
return UpgradeResult{}, ErrUpgradeEquipmentOwnerUnavailable
}
var found bool
equipmentRecord, found, err = o.equipment.Load(ctx, characterID)
if err != nil {
return UpgradeResult{}, err
}
if !found {
return result, nil
}
equipmentRecord = dnfrepo.CloneEquipment(equipmentRecord)
targetEntry, ok = equipmentRecord.Entries[equipmentKey]
if !ok || targetEntry.ItemID <= 0 || targetEntry.SlotIndex != cmd.TargetSlotIndex {
return result, nil
}
target = forgingStackFromEquipmentEntry(targetEntry)
case cmd.TargetListType == listTypeMain, cmd.TargetListType == listTypeGuildMedal:
// Both live in the backpack-style slot map: the 公会徽章 page is a set of
// list-38 rows inside the same inventory record.
target, ok = items[targetKey]
default:
return result, nil
}
if !ok || target.Count <= 0 || target.ItemID != int64(cmd.TargetItemTemplateID) || !isUpgradeableEquipmentStack(target) {
return result, nil
}
if isEquipmentLocked(target) {
result.ErrorCode = upgradeErrorLocked
return result, nil
}
if !upgradeDurabilityOK(target) {
result.ErrorCode = upgradeErrorDurability
return result, nil
}
oldLevel := upgradeLevelOf(target)
if cmd.TargetListType == listTypeGuildMedal {
// An expired flag cannot be upgraded, and the resolved row has to match
// the stored stack exactly: one flag in the slot, at the level the row
// was resolved for, with a usable gold/material/weight triple.
if !target.ExpireAt.IsZero() && !target.ExpireAt.After(time.Now()) {
return result, nil
}
if target.Count != 1 || int(oldLevel) != cmd.UpgradePolicyLevel || cmd.UpgradeGoldCost < 0 ||
cmd.UpgradeMaterialItemID <= 0 || cmd.UpgradeMaterialCount <= 0 ||
cmd.UpgradeSuccessWeight < 0 || cmd.UpgradeSuccessWeight > currentUpgradeSuccessBase {
return result, nil
}
}
result.OldLevel = oldLevel
result.NewLevel = oldLevel
portableTool := cmd.UpgradePortableAmplifier || cmd.UpgradePortableReinforcer
if portableTool {
if (cmd.UpgradePortableAmplifier && cmd.Mode != "amplify") ||
(cmd.UpgradePortableReinforcer && cmd.Mode != "reinforce") ||
cmd.UpgradePortableMinimum < 0 || cmd.UpgradePortableMaximum < cmd.UpgradePortableMinimum ||
int64(oldLevel) < cmd.UpgradePortableMinimum || int64(oldLevel) > cmd.UpgradePortableMaximum ||
cmd.UpgradeTargetLevelMinimum < 0 || cmd.UpgradeTargetLevelMaximum < cmd.UpgradeTargetLevelMinimum ||
cmd.UpgradeTargetMinimumLevel < cmd.UpgradeTargetLevelMinimum || cmd.UpgradeTargetMinimumLevel > cmd.UpgradeTargetLevelMaximum {
result.ErrorCode = upgradeErrorInvalidTarget
return result, nil
}
}
if oldLevel > upgradeMaximumCurrentLevelBeforeReject {
result.ErrorCode = upgradeErrorMaxLevel
return result, nil
}
amplifyType, amplifyValue := upgradeAmplifyState(target)
switch cmd.Mode {
case "reinforce":
if amplifyType != 0 {
result.ErrorCode = upgradeErrorWrongMode
return result, nil
}
case "amplify":
if amplifyType == 0 {
result.ErrorCode = upgradeErrorWrongMode
return result, nil
}
if amplifyValue <= 0 || (amplifyType&0x80) != 0 {
result.ErrorCode = upgradeErrorAmplifyNotIdentified
return result, nil
}
}
var protectionKey string
var protectionTicket dnfrepo.ItemStack
var protectionResolution alignedcmd.UpgradeProtectionResolution
hasProtectionTicket := cmd.OptionalTicketSlot >= 0
if hasProtectionTicket {
if cmd.OptionalTicketSlot == cmd.TargetSlotIndex ||
cmd.OptionalTicketSlot == cmd.MaterialSlotIndex ||
!upgradeProtectionSlotAllowed(cmd.OptionalTicketSlot) ||
dnfrepo.IsAccountSharedInventorySlot(listTypeMain, cmd.OptionalTicketSlot) {
result.ErrorCode = upgradeErrorUnsupportedOptionalTicket
return result, nil
}
protectionKey = slotKey(listTypeMain, cmd.OptionalTicketSlot)
stack, found := items[protectionKey]
// A past [expiration date] does not disqualify a protection ticket on
// this server: nothing here acts on item expiry -- the reconcile pass
// only normalizes the row's projection, it never removes or blocks an
// expired stack -- and the client itself counts held tickets without
// checking it (dstr 72968 「因为您拥有装备强化保护券…」). Enforcing it
// here alone meant a player watched a piece blow up while holding ten
// tickets, because most of this PVF's protection tickets are dead 2010
// / 2015 event items (7804, 10007840, 10004511, 10007841).
if !found || stack.ItemID <= 0 || stack.Count <= 0 || o.upgradeProtection == nil {
result.ErrorCode = upgradeErrorUnsupportedOptionalTicket
return result, nil
}
protectionResolution, err = o.upgradeProtection(stack.ItemID)
if err != nil || strings.TrimSpace(protectionResolution.TicketMode) == "" {
result.ErrorCode = upgradeErrorUnsupportedOptionalTicket
return result, nil
}
if !strings.EqualFold(strings.TrimSpace(protectionResolution.TicketMode), cmd.Mode) {
result.ErrorCode = upgradeErrorWrongMode
return result, nil
}
if protectionResolution.FailureRetainLevel < 0 || protectionResolution.FailureRetainLevel > 30 {
result.ErrorCode = upgradeErrorUnsupportedOptionalTicket
return result, nil
}
protectionTicket = stack
result.ProtectionTicketItemID = stack.ItemID
result.ProtectionTicketRemaining = stack.Count
}
const (
upgradeMaterialNone = iota
upgradeMaterialMain
upgradeMaterialAccount
)
var materialKey string
var material dnfrepo.ItemStack
// accountShared is the account-owned warehouse record. It backs both the
// crystal/soul upgrade material and the destruction refund rows that own a
// fixed warehouse slot, so it is loaded at most once and saved at most
// once no matter which of the two touched it.
var accountShared dnfrepo.AccountInventoryRecord
accountSharedLoaded := false
accountSharedDirty := false
materialOwner := upgradeMaterialNone
requiredMaterialItemID, requiredMaterialCount, hasPVFMaterial := upgradeMaterialRequirement(cmd)
// The material must be a different stack from the target. Compare the list
// too: a list-3 (worn) or list-38 (公会徽章) target is reinforced with a
// backpack material, and a backpack slot may legitimately carry the same
// index as the equipment/medal slot. For a list-0 target both indices name
// the same namespace, so the original rejection is unchanged.
if cmd.MaterialSlotIndex >= 0 && cmd.MaterialSlotIndex == cmd.TargetSlotIndex && cmd.TargetListType == listTypeMain {
result.ErrorCode = upgradeErrorInvalidMaterial
return result, nil
}
if hasPVFMaterial {
if fixedSlot, accountOwned := upgradeAccountSharedMaterialSlot(requiredMaterialItemID); accountOwned {
if o.accountRepo == nil || strings.TrimSpace(cmd.AccountID) == "" {
result.ErrorCode = upgradeErrorInvalidMaterial
return result, nil
}
account, found, err := o.accountRepo.Load(ctx, cmd.AccountID)
if err != nil {
return UpgradeResult{}, err
}
if !found || account.Slots == nil {
result.ErrorCode = upgradeErrorInvalidMaterial
return result, nil
}
accountShared = dnfrepo.CloneAccountInventory(account)
accountShared.AccountID = strings.TrimSpace(cmd.AccountID)
accountSharedLoaded = true
materialKey = dnfrepo.AccountSharedInventorySlotKey(fixedSlot)
stack, ok := accountShared.Slots[materialKey]
if !ok || stack.ItemID != requiredMaterialItemID || stack.Count < requiredMaterialCount {
result.ErrorCode = upgradeErrorInvalidMaterial
return result, nil
}
material = stack
materialOwner = upgradeMaterialAccount
} else {
if cmd.MaterialSlotIndex < 0 || dnfrepo.IsAccountSharedInventorySlot(listTypeMain, cmd.MaterialSlotIndex) {
result.ErrorCode = upgradeErrorInvalidMaterial
return result, nil
}
materialKey = slotKey(listTypeMain, cmd.MaterialSlotIndex)
stack, ok := items[materialKey]
if !ok || stack.ItemID != requiredMaterialItemID || stack.Count < requiredMaterialCount ||
(portableTool && !stack.ExpireAt.IsZero() && !stack.ExpireAt.After(time.Now())) {
result.ErrorCode = upgradeErrorInvalidMaterial
return result, nil
}
material = stack
materialOwner = upgradeMaterialMain
}
} else if cmd.MaterialSlotIndex >= 0 {
if dnfrepo.IsAccountSharedInventorySlot(listTypeMain, cmd.MaterialSlotIndex) {
result.ErrorCode = upgradeErrorInvalidMaterial
return result, nil
}
materialKey = slotKey(listTypeMain, cmd.MaterialSlotIndex)
stack, ok := items[materialKey]
if !ok || stack.Count <= 0 {
result.ErrorCode = upgradeErrorInvalidMaterial
return result, nil
}
requiredMaterialCount = 1
material = stack
materialOwner = upgradeMaterialMain
}
// ensureAccountSharedRecord hands out the one warehouse record this
// upgrade may mutate. Unlike the material path a missing record is not an
// error here: a destruction refund simply falls back to the backpack when
// the account warehouse cannot be reached.
ensureAccountSharedRecord := func() (*dnfrepo.AccountInventoryRecord, bool) {
if accountSharedLoaded {
return &accountShared, true
}
if o.accountRepo == nil || strings.TrimSpace(cmd.AccountID) == "" {
return nil, false
}
account, found, loadErr := o.accountRepo.Load(ctx, cmd.AccountID)
if loadErr != nil {
return nil, false
}
if !found {
account = dnfrepo.AccountInventoryRecord{}
}
accountShared = dnfrepo.CloneAccountInventory(account)
accountShared.AccountID = strings.TrimSpace(cmd.AccountID)
if accountShared.Slots == nil {
accountShared.Slots = make(map[string]dnfrepo.ItemStack)
}
accountSharedLoaded = true
return &accountShared, true
}
// warehouseRefundSlots are the fixed warehouse slots a destruction refund
// wrote into, collected so the op0D/op14 refresh can carry their new
// counts: they live in the account record, not in this character's
// backpack map.
var warehouseRefundSlots []int16
// grantUpgradeDestroyRefund places one destruction refund row: warehouse
// stackables (the six cube fragments and six souls) go to their single
// fixed account-shared slot the way the 分解机 places them, and only the
// remainder -- a stack-limit overflow, a foreign stack squatting on the
// fixed slot, or an unreachable warehouse -- falls back to the backpack.
// The returned remainder is what neither could hold, so a best-effort row
// that is half placed only mails the half that was refused.
grantUpgradeDestroyRefund := func(itemID int64, count int64) ([]int16, int64, error) {
remaining := count
var slots []int16
if fixedSlot, warehouse := upgradeAccountSharedMaterialSlot(itemID); warehouse {
if account, ok := ensureAccountSharedRecord(); ok {
left := o.grantUpgradeDestroyWarehouseBonus(account, fixedSlot, itemID, remaining)
if left < remaining {
accountSharedDirty = true
warehouseRefundSlots = append(warehouseRefundSlots, fixedSlot)
slots = append(slots, fixedSlot)
}
remaining = left
}
}
if remaining <= 0 {
return slots, 0, nil
}
bagSlots, grantErr := o.grantUpgradeDestroyBonus(items, itemID, remaining)
if grantErr != nil {
return slots, remaining, grantErr
}
return append(slots, bagSlots...), 0, nil
}
character, ok, err := o.characters.Load(ctx, characterID)
if err != nil {
return UpgradeResult{}, err
}
if !ok {
return UpgradeResult{}, fmt.Errorf("%w: character=%s", ErrWalletTxnRequired, characterID)
}
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
result.UpdatedGold = character.Stats["gold"]
goldCost := upgradeGoldCostOf(target, cmd.Mode, oldLevel)
if cmd.UpgradeGoldCostResolved {
goldCost = cmd.UpgradeGoldCost
}
if cmd.TargetListType == listTypeGuildMedal {
// The flag table states its own gold cost; the equipment table's
// rarity/level formula does not describe a 公会徽章. Upstream applies the
// resolved cost unconditionally here, so a direct owner call is priced
// from the policy rather than from a stale 0x77 upgrade_gold extra.
goldCost = cmd.UpgradeGoldCost
}
if portableTool {
goldCost = 0
}
if goldCost > result.UpdatedGold {
result.ErrorCode = upgradeErrorInsufficientGold
result.GoldCost = goldCost
return result, nil
}
// RNG: determine success or failure based on PVF success weight.
// 权重 <= 0 说明调用方没给策略:生产路径只有 handler.go:Upgrade 一处,
// 它拿不到 PVF 强化策略时会**直接拒绝**(见 handler.go 的 fail-closed 分支),
// 所以这里保留历史回落只为直接调用 owner 的夹具/脚本。
successWeight := cmd.UpgradeSuccessWeight
if successWeight <= 0 && cmd.TargetListType != listTypeGuildMedal {
successWeight = currentUpgradeSuccessBase // backward compat: guaranteed success
}
// 强化/增幅成功率药剂(强化秘药、强化的奥秘、永恒的增幅的秘密、赛丽亚的幸运强化药剂)
// 在这里生效:**掷点之前**按 PVF 的 param 放大权重。窗口本身是角色级的、带到期时间,
// 过期会被 statbuff.ActiveRate 判掉。见 stat_change_potion.go 的
// applyCurrentUpgradeSuccessWindow。
{
upgradeNow := time.Now().UTC()
if o.now != nil {
upgradeNow = o.now().UTC()
}
successWeight = applyCurrentUpgradeSuccessWindow(successWeight, character, cmd.Mode, upgradeNow)
}
roll := o.upgradeRandom
if roll == nil {
roll = rand.Intn
}
rolled := roll(currentUpgradeSuccessBase)
upgradeSuccess := rolled < successWeight
if !upgradeSuccess && cmd.UpgradePenaltyType == 3 && !hasProtectionTicket && cmd.OptionalTicketSlot < 0 {
autoSlot, autoStack, autoResolution, found, resolveErr := o.firstUpgradeProtectionTicket(items, cmd.Mode, cmd.TargetSlotIndex, cmd.MaterialSlotIndex)
if resolveErr != nil {
return UpgradeResult{}, resolveErr
}
if found {
hasProtectionTicket = true
protectionKey = slotKey(listTypeMain, autoSlot)
protectionTicket = autoStack
protectionResolution = autoResolution
result.ProtectionTicketSlotIndex = autoSlot
result.ProtectionTicketItemID = autoStack.ItemID
result.ProtectionTicketRemaining = autoStack.Count
}
}
var newLevel byte
var resultCode byte
if upgradeSuccess {
newLevel = byte(int(oldLevel) + 1)
resultCode = upgradeResultCodeSuccess
target = cloneStack(target)
setUpgradeLevel(&target, newLevel)
items[targetKey] = target
} else {
newLevel = oldLevel
switch cmd.UpgradePenaltyType {
case 3: // destroy
if hasProtectionTicket {
resultCode = upgradeResultCodeDowngrade
retainLevel := protectionResolution.FailureRetainLevel
if retainLevel > int(oldLevel) {
retainLevel = int(oldLevel)
}
newLevel = byte(retainLevel)
target = cloneStack(target)
setUpgradeLevel(&target, newLevel)
items[targetKey] = target
result.ProtectionTicketUsed = true
} else {
resultCode = upgradeResultCodeDestroy
delete(items, targetKey)
// Grant destruction compensation through the active PVF stack and
// inventory-page contract. The compensation is a material stackable,
// not equipment merely because the destroyed source occupied an
// equipment-page slot.
if cmd.UpgradeDestroyBonusItemID > 0 && cmd.UpgradeDestroyBonusCount > 0 {
destroySlots, _, grantErr := grantUpgradeDestroyRefund(
int64(cmd.UpgradeDestroyBonusItemID),
int64(cmd.UpgradeDestroyBonusCount),
)
if grantErr != nil {
return UpgradeResult{}, grantErr
}
result.DestroyBonusSlot = destroySlots[0]
result.DestroyBonusSlots = destroySlots
result.DestroyBonusItemID = int32(cmd.UpgradeDestroyBonusItemID)
result.DestroyBonusCount = int32(cmd.UpgradeDestroyBonusCount)
}
for _, extra := range cmd.UpgradeDestroyExtraRewards {
if extra.ItemID <= 0 || extra.Count <= 0 {
continue
}
extraSlots, unplaced, grantErr := grantUpgradeDestroyRefund(int64(extra.ItemID), int64(extra.Count))
if grantErr != nil {
// A best-effort row that does not fit is dropped: the
// destruction and its PVF compensation have to stand
// even when the bag is full. Only the part neither the
// warehouse nor the bag took is mailed.
if extra.BestEffort && errors.Is(grantErr, ErrUpgradeDestroyBonusInventoryFull) {
overflow := extra
if unplaced > 0 && unplaced <= int64(extra.Count) {
overflow.Count = int(unplaced)
}
result.DestroyRefundOverflow = append(result.DestroyRefundOverflow, overflow)
// The warehouse half, if any, did land and still
// has to be repainted.
result.DestroyBonusSlots = append(result.DestroyBonusSlots, extraSlots...)
if len(extraSlots) > 0 {
result.DestroyExtraRewards = append(result.DestroyExtraRewards, UpgradeDestroyGrantedReward{
Slot: extraSlots[0],
ItemID: int32(extra.ItemID),
Count: int32(int64(extra.Count) - unplaced),
})
}
continue
}
return UpgradeResult{}, grantErr
}
result.DestroyBonusSlots = append(result.DestroyBonusSlots, extraSlots...)
result.DestroyExtraRewards = append(result.DestroyExtraRewards, UpgradeDestroyGrantedReward{
Slot: extraSlots[0],
ItemID: int32(extra.ItemID),
Count: int32(extra.Count),
})
}
}
case 1: // downgrade
resultCode = upgradeResultCodeDowngrade
if oldLevel > 0 {
newLevel = oldLevel - 1
}
target = cloneStack(target)
setUpgradeLevel(&target, newLevel)
items[targetKey] = target
case 2: // downgrade by the PVF row's explicit value
resultCode = upgradeResultCodeDowngrade
penalty := cmd.UpgradePenaltyValue
if penalty < 0 {
penalty = 0
}
if penalty > int(oldLevel) {
penalty = int(oldLevel)
}
newLevel = byte(int(oldLevel) - penalty)
target = cloneStack(target)
setUpgradeLevel(&target, newLevel)
items[targetKey] = target
default: // 0: fail, no change
resultCode = upgradeResultCodeFailNoChange
}
}
if result.ProtectionTicketUsed {
protectionTicket = cloneStack(protectionTicket)
protectionTicket.Count--
result.ProtectionTicketRemaining = protectionTicket.Count
if protectionTicket.Count <= 0 {
delete(items, protectionKey)
result.ProtectionTicketRemaining = 0
result.ProtectionTicketUpdatedStack = dnfrepo.ItemStack{}
} else {
updateStackRawAmount(&protectionTicket)
items[protectionKey] = protectionTicket
result.ProtectionTicketUpdatedStack = protectionTicket
}
}
if materialOwner != upgradeMaterialNone {
// The destruction refund above may already have merged into this very
// stack: the upgrade material is usually one of the fixed warehouse
// stackables itself (无色小晶块 owns slot 358, the souls own 360-365)
// and a destroyed piece refunds exactly those into the same fixed
// slot. material is the copy captured before that grant, so writing it
// back erased the refund while op80 and the op14 refresh went on
// advertising it -- live 2026-09-14 22:03, character 1, +14 -> +15
// destruction promised 3037 x368 in slot 358 and the durable stack
// only ever lost the 200 cubes the attempt consumed (48316 -> 48116,
// which is also why 小退 could not show it). Re-base the consumption on
// what the record holds now, so the two writers add up instead of the
// Re-base the consumption on what the record holds now, so the two
// writers add up instead of the later one winning. Anything unexpected
// (a different item, a stack that somehow shrank) keeps the captured
// copy, which is the historical behavior.
base := material
switch materialOwner {
case upgradeMaterialMain:
if live, ok := items[materialKey]; ok && live.ItemID == material.ItemID &&
live.ItemID > 0 && live.Count >= material.Count {
base = live
}
case upgradeMaterialAccount:
if live, ok := accountShared.Slots[materialKey]; ok && live.ItemID == material.ItemID &&
live.ItemID > 0 && live.Count >= material.Count {
base = live
}
}
material = cloneStack(base)
material.Count -= requiredMaterialCount
result.MaterialRemainingStackCount = material.Count
result.MaterialUpdated = true
if material.Count <= 0 {
result.MaterialRemainingStackCount = 0
result.MaterialUpdatedStack = dnfrepo.ItemStack{}
switch materialOwner {
case upgradeMaterialMain:
delete(items, materialKey)
case upgradeMaterialAccount:
delete(accountShared.Slots, materialKey)
}
} else {
updateStackRawAmount(&material)
result.MaterialUpdatedStack = material
switch materialOwner {
case upgradeMaterialMain:
items[materialKey] = material
case upgradeMaterialAccount:
accountShared.Slots[materialKey] = material
}
}
if materialOwner == upgradeMaterialAccount {
accountSharedDirty = true
}
}
// One save for both writers: the consumed crystal/soul material and the
// destruction refund rows that landed in their fixed warehouse slots.
if accountSharedDirty && o.accountRepo != nil {
accountShared.UpdatedAt = time.Now()
if err := o.accountRepo.Save(ctx, accountShared); err != nil {
return UpgradeResult{}, err
}
}
if goldCost > 0 {
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
character.Stats["gold"] -= goldCost
character.UpdatedAt = time.Now()
if err := dnfrepo.SaveCharacterFields(ctx, o.characters, character, dnfrepo.CharacterFieldStats); err != nil {
return UpgradeResult{}, err
}
result.GoldCost = goldCost
result.UpdatedGold = character.Stats["gold"]
}
if targetEquipped {
// The shared level logic above addressed the target through the list-3
// key of the backpack map. That key must never be persisted there; move
// the outcome back into the worn entry (or drop the entry on destroy).
updatedTarget, changed := items[targetKey]
delete(items, targetKey)
switch {
case resultCode == upgradeResultCodeDestroy:
delete(equipmentRecord.Entries, equipmentKey)
target = dnfrepo.ItemStack{}
case changed:
targetEntry.RawEntry = append([]byte(nil), updatedTarget.RawEntry...)
targetEntry.Extra = cloneStringMapForForging(updatedTarget.Extra)
equipmentRecord.Entries[equipmentKey] = targetEntry
target = updatedTarget
}
}
now := time.Now()
record.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, dnfrepo.InventoryFieldSlots); err != nil {
return UpgradeResult{}, err
}
if targetEquipped {
equipmentRecord.CharacterID = characterID
equipmentRecord.UpdatedAt = now
if err := dnfrepo.SaveEquipmentFields(ctx, o.equipment, equipmentRecord, dnfrepo.EquipmentFieldEntries); err != nil {
return UpgradeResult{}, err
}
}
result.Success = true
result.ErrorCode = 0
result.NewLevel = newLevel
result.ResultCode = resultCode
result.UpgradeSucceeded = upgradeSuccess
if targetEquipped {
result.TargetUpdatedStack = cloneStack(target)
} else if updatedTarget, ok := items[targetKey]; ok {
result.TargetUpdatedStack = updatedTarget
} else {
result.TargetUpdatedStack = dnfrepo.ItemStack{}
}
result.MainRefresh = cloneItemMap(record.Slots)
// The warehouse rows a destruction refund wrote live in the account
// record, but the client paints them in the same list 0. Overlay them so
// the incremental refresh reports their new counts instead of the stale
// character-side view.
for _, slot := range warehouseRefundSlots {
if stack, ok := accountShared.Slots[dnfrepo.AccountSharedInventorySlotKey(slot)]; ok {
result.MainRefresh[slotKey(listTypeMain, slot)] = cloneStack(stack)
}
}
result.Changed = true
return result, nil
}
// Enchant applies one monster card carried by a bead stackable to one main
// inventory equipment entry, one worn list-3 equipment entry, one list-7
// creature, or the worn creature at list-3 slot 26. Ordinary/list-7 rows use
// +0x0E i32 card item id and +0x12 u8 upgrade count; the legacy
// equipped-creature row uses +0x10 and +0x14.
// Creature targets additionally update their typed PetEntry in the same
// character-pet transaction. The server never resolves card stat values,
// because the client renders them from its own card PVF.
func (o *Owner) Enchant(ctx context.Context, cmd Command, resolver alignedcmd.EnchantBeadResolver) (EnchantResult, error) {
base := EnchantResult{
ErrorCode: enchantErrorInvalidBead,
TargetListType: cmd.TargetListType,
TargetSlotIndex: cmd.TargetSlotIndex,
BeadSlotIndex: cmd.BeadSlotIndex,
}
if o == nil || o.repo == nil {
return EnchantResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return EnchantResult{}, ErrCharacterRequired
}
if resolver == nil {
return EnchantResult{}, ErrEnchantResolverRequired
}
equippedCreatureTarget := cmd.TargetListType == listTypeEquipment && cmd.TargetSlotIndex == 26
equippedEquipmentTarget := cmd.TargetListType == listTypeEquipment && !equippedCreatureTarget
if cmd.BeadListType != listTypeMain ||
(cmd.TargetListType != listTypeMain && cmd.TargetListType != listTypePet && !equippedCreatureTarget && !equippedEquipmentTarget) {
base.ErrorCode = enchantErrorUnsupported
return base, nil
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
base.CharacterID = characterID
if (cmd.TargetListType == listTypePet || equippedCreatureTarget) && !o.inPetTransaction {
if o.petItems == nil || o.pets == nil || o.equipment == nil {
return EnchantResult{}, ErrPetInventoryOwnerRequired
}
var result EnchantResult
err := o.petItems.WithinCharacterPets(ctx, characterID, func(inventory dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository, pets dnfrepo.PetRepository) error {
txOwner := *o
txOwner.repo = inventory
txOwner.equipment = equipment
txOwner.pets = pets
txOwner.inItemTransaction = true
txOwner.inPetTransaction = true
if _, err := creaturestate.ReconcileInventory(ctx, characterID, inventory, equipment, pets); err != nil {
return err
}
var err error
result, err = txOwner.Enchant(ctx, cmd, resolver)
return err
})
if err != nil {
return EnchantResult{}, err
}
if result.CharacterID == "" {
result = base
}
return result, nil
}
if (cmd.TargetListType == listTypeMain || equippedEquipmentTarget) && !o.inItemTransaction {
var result EnchantResult
err := o.withinInventoryTransaction(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.Enchant(ctx, cmd, resolver)
return err
})
if err != nil {
return EnchantResult{}, err
}
if result.CharacterID == "" {
result = base
}
return result, nil
}
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return EnchantResult{}, err
}
if !ok {
return EnchantResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
record.CharacterID = characterID
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
result := base
items := record.Slots
beadKey := slotKey(listTypeMain, cmd.BeadSlotIndex)
bead, ok := items[beadKey]
if !ok || bead.Count <= 0 {
return result, nil
}
result.BeadItemID = bead.ItemID
if cmd.TargetListType == cmd.BeadListType && cmd.TargetSlotIndex == cmd.BeadSlotIndex {
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
targetKey := slotKey(cmd.TargetListType, cmd.TargetSlotIndex)
var target dnfrepo.ItemStack
var targetEquipment dnfrepo.EquipmentEntry
var equipmentRecord dnfrepo.EquipmentRecord
if equippedCreatureTarget || equippedEquipmentTarget {
if o.equipment == nil {
return EnchantResult{}, ErrEnchantRequiresEquipmentOwner
}
equipmentRecord, ok, err = o.equipment.Load(ctx, characterID)
if err != nil {
return EnchantResult{}, err
}
if !ok {
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
equipmentRecord = dnfrepo.CloneEquipment(equipmentRecord)
targetEquipment, ok = equipmentRecord.Entries[strconv.Itoa(int(cmd.TargetSlotIndex))]
if !ok || targetEquipment.ItemID <= 0 || targetEquipment.SlotIndex != cmd.TargetSlotIndex {
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
if equippedCreatureTarget {
target = equippedCreatureStack(targetEquipment)
} else {
target = forgingStackFromEquipmentEntry(targetEquipment)
}
} else {
target, ok = items[targetKey]
if !ok || target.Count <= 0 {
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
}
result.TargetItemID = target.ItemID
if !equipmentStackCountIsSingle(target) {
// 一件宝珠只能附一件装备:历史堆叠行(装备 Count>1)一律拒绝。
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
resolution, err := resolver(bead.ItemID, target.ItemID)
if err != nil {
return EnchantResult{}, err
}
randomTitleEnchant := resolution.RandomTitleEnchantConfigID != 0 || resolution.RandomTitleEnchantRowID != 0
if randomTitleEnchant && (resolution.RandomTitleEnchantConfigID == 0 || resolution.RandomTitleEnchantRowID == 0 || cmd.TargetListType != listTypeMain) {
result.ErrorCode = enchantErrorUnsupported
return result, nil
}
if !randomTitleEnchant && resolution.CardItemID <= 0 {
return result, nil
}
if !randomTitleEnchant {
result.CardItemID = resolution.CardItemID
}
if !strings.EqualFold(strings.TrimSpace(resolution.TargetKind), "equipment") {
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
if len(resolution.TargetWhitelist) > 0 && !int64SliceContains(resolution.TargetWhitelist, target.ItemID) {
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
if !stringSliceContainsFold(resolution.AllowedEquipmentTypes, resolution.TargetEquipmentType) {
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
var petRecord dnfrepo.PetRecord
var petEntryKey string
var petEntry dnfrepo.PetEntry
if cmd.TargetListType == listTypePet || equippedCreatureTarget {
if (!equippedCreatureTarget && (cmd.TargetSlotIndex < 0 || cmd.TargetSlotIndex > 139)) ||
(equippedCreatureTarget && cmd.TargetSlotIndex != 26) ||
!strings.EqualFold(strings.Trim(strings.TrimSpace(resolution.TargetEquipmentType), "[]"), "creature") {
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
if o.pets == nil {
return EnchantResult{}, ErrPetInventoryOwnerRequired
}
var found bool
petRecord, found, err = o.pets.Load(ctx, characterID)
if err != nil {
return EnchantResult{}, err
}
if !found {
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
petRecord = dnfrepo.ClonePet(petRecord)
if equippedCreatureTarget {
petEntryKey, petEntry, found = enchantEquippedPetEntryForTarget(petRecord, target, cmd.TargetSlotIndex)
} else {
petEntryKey, petEntry, found = enchantPetEntryForTarget(petRecord, target, cmd.TargetSlotIndex)
}
if !found {
result.ErrorCode = enchantErrorInvalidTarget
return result, nil
}
}
upgradeCount := enchantUpgradeCountOf(bead)
if randomTitleEnchant && upgradeCount != 0 {
result.ErrorCode = enchantErrorUnsupported
return result, nil
}
if len(resolution.UpgradeCounts) > 0 {
if !int64SliceContains(resolution.UpgradeCounts, int64(upgradeCount)) {
result.ErrorCode = enchantErrorUnsupported
return result, nil
}
} else if upgradeCount != 0 {
result.ErrorCode = enchantErrorUnsupported
return result, nil
}
target = cloneStack(target)
if randomTitleEnchant {
setRandomTitleEnchantFields(&target, resolution.RandomTitleEnchantConfigID, resolution.RandomTitleEnchantRowID)
} else {
setEnchantFields(&target, resolution.CardItemID, upgradeCount)
if cmd.TargetListType == listTypePet {
setPetStackEnchantFields(&target, resolution.CardItemID, upgradeCount)
}
}
if equippedCreatureTarget {
setEquippedCreatureEnchantFields(&targetEquipment, resolution.CardItemID, upgradeCount)
equipmentRecord.Entries[strconv.Itoa(int(cmd.TargetSlotIndex))] = targetEquipment
} else if equippedEquipmentTarget {
targetEquipment.RawEntry = append([]byte(nil), target.RawEntry...)
targetEquipment.Extra = cloneEnchantExtra(target.Extra)
equipmentRecord.Entries[strconv.Itoa(int(cmd.TargetSlotIndex))] = targetEquipment
} else {
items[targetKey] = target
}
result.TargetUpdatedStack = cloneStack(target)
if cmd.TargetListType == listTypePet || equippedCreatureTarget {
setPetEntryEnchantFields(&petEntry, resolution.CardItemID, upgradeCount)
petRecord.Entries[petEntryKey] = petEntry
}
bead = cloneStack(bead)
bead.Count--
result.BeadRemainingCount = bead.Count
if bead.Count <= 0 {
delete(items, beadKey)
result.BeadRemainingCount = 0
result.BeadUpdatedStack = dnfrepo.ItemStack{ItemID: -1}
} else {
updateStackRawAmount(&bead)
items[beadKey] = bead
result.BeadUpdatedStack = cloneStack(bead)
}
now := time.Now()
record.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, dnfrepo.InventoryFieldSlots); err != nil {
return EnchantResult{}, err
}
if equippedCreatureTarget || equippedEquipmentTarget {
equipmentRecord.CharacterID = characterID
equipmentRecord.UpdatedAt = now
if err := dnfrepo.SaveEquipmentFields(ctx, o.equipment, equipmentRecord, dnfrepo.EquipmentFieldEntries); err != nil {
return EnchantResult{}, err
}
}
if cmd.TargetListType == listTypePet || equippedCreatureTarget {
petRecord.CharacterID = characterID
petRecord.UpdatedAt = now
if err := dnfrepo.SavePetFields(ctx, o.pets, petRecord, dnfrepo.PetFieldEntries); err != nil {
return EnchantResult{}, err
}
}
result.Success = true
result.ErrorCode = 0
result.EnchantUpgradeCount = upgradeCount
result.MainRefresh = cloneItemMap(record.Slots)
result.Changed = true
return result, nil
}
func enchantPetEntryForTarget(record dnfrepo.PetRecord, target dnfrepo.ItemStack, slot int16) (string, dnfrepo.PetEntry, bool) {
serial := firstExtraInt(target.Extra, 0,
"creature_serial_or_handle", "creature_key", "creature_serial", "pet_serial", "serial", "handle", "instance_value", "item_uid")
if serial <= 0 || serial > math.MaxUint32 {
return "", dnfrepo.PetEntry{}, false
}
for key, entry := range record.Entries {
entrySerial := uint64(entry.CreatureKey)
if entrySerial == 0 {
parsed, err := strconv.ParseUint(strings.TrimSpace(entry.PetKey), 0, 32)
if err != nil || parsed == 0 {
parsed, err = strconv.ParseUint(strings.TrimSpace(key), 0, 32)
}
if err == nil {
entrySerial = parsed
}
}
if entrySerial != uint64(serial) || entry.ItemID != target.ItemID ||
entry.SourceListType != listTypePet || entry.SourceSlotIndex != slot {
continue
}
return key, entry, true
}
return "", dnfrepo.PetEntry{}, false
}
func enchantEquippedPetEntryForTarget(record dnfrepo.PetRecord, target dnfrepo.ItemStack, slot int16) (string, dnfrepo.PetEntry, bool) {
serial := firstExtraInt(target.Extra, 0,
"creature_serial_or_handle", "creature_key", "creature_serial", "pet_serial", "serial", "handle", "instance_value", "item_uid")
if serial <= 0 || serial > math.MaxUint32 || slot != 26 {
return "", dnfrepo.PetEntry{}, false
}
for key, entry := range record.Entries {
entrySerial := uint64(entry.CreatureKey)
if entrySerial == 0 {
parsed, err := strconv.ParseUint(strings.TrimSpace(entry.PetKey), 0, 32)
if err != nil || parsed == 0 {
parsed, err = strconv.ParseUint(strings.TrimSpace(key), 0, 32)
}
if err == nil {
entrySerial = parsed
}
}
if entrySerial == uint64(serial) && entry.ItemID == target.ItemID &&
entry.SourceListType == listTypeEquipment && entry.SourceSlotIndex == slot {
return key, entry, true
}
}
return "", dnfrepo.PetEntry{}, false
}
func equippedCreatureStack(entry dnfrepo.EquipmentEntry) dnfrepo.ItemStack {
extra := cloneEnchantExtra(entry.Extra)
if extra == nil {
extra = make(map[string]string, 4)
}
if len(entry.RawEntry) >= 28 {
serial := binary.LittleEndian.Uint32(entry.RawEntry[24:28])
if serial != 0 {
extra["creature_serial_or_handle"] = strconv.FormatUint(uint64(serial), 10)
}
}
extra["item_kind"] = "equipment"
extra["equipment_type"] = "creature"
return dnfrepo.ItemStack{ItemID: entry.ItemID, Count: 1, Bind: entry.Bind, ExpireAt: entry.ExpireAt, RawEntry: append([]byte(nil), entry.RawEntry...), Extra: extra}
}
func cloneEnchantExtra(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in)+4)
for key, value := range in {
out[key] = value
}
return out
}
func setEquippedCreatureEnchantFields(entry *dnfrepo.EquipmentEntry, cardItemID int64, upgradeCount byte) {
if entry == nil || cardItemID < 0 || cardItemID > math.MaxUint32 {
return
}
if entry.Extra == nil {
entry.Extra = make(map[string]string, 8)
}
value := strconv.FormatInt(cardItemID, 10)
entry.Extra["value_a"] = value
entry.Extra["enchant_card_id"] = value
entry.Extra["pet_enchant_card_item_id"] = value
entry.Extra["byte_12"] = strconv.Itoa(int(upgradeCount))
entry.Extra["enchant_upgrade_count"] = strconv.Itoa(int(upgradeCount))
entry.Extra["pet_enchant_upgrade_count"] = strconv.Itoa(int(upgradeCount))
if len(entry.RawEntry) >= 0x15 {
entry.RawEntry = append([]byte(nil), entry.RawEntry...)
binary.LittleEndian.PutUint32(entry.RawEntry[0x10:0x14], uint32(cardItemID))
entry.RawEntry[0x14] = upgradeCount
}
}
func setPetEntryEnchantFields(entry *dnfrepo.PetEntry, cardItemID int64, upgradeCount byte) {
if entry == nil {
return
}
if entry.Extra == nil {
entry.Extra = make(map[string]string, 6)
}
entry.Extra["value_a"] = strconv.FormatInt(cardItemID, 10)
entry.Extra["enchant_card_id"] = strconv.FormatInt(cardItemID, 10)
entry.Extra["pet_enchant_card_item_id"] = strconv.FormatInt(cardItemID, 10)
entry.Extra["byte_12"] = strconv.Itoa(int(upgradeCount))
entry.Extra["enchant_upgrade_count"] = strconv.Itoa(int(upgradeCount))
entry.Extra["pet_enchant_upgrade_count"] = strconv.Itoa(int(upgradeCount))
if len(entry.RawEntry) == currentItemListEntrySize {
entry.RawEntry = append([]byte(nil), entry.RawEntry...)
binary.LittleEndian.PutUint32(entry.RawEntry[0x0E:0x12], uint32(cardItemID))
entry.RawEntry[0x12] = upgradeCount
}
}
// enchantUpgradeCountOf reads the bead's own dynamic upgrade count, which is
// carried in the same common-entry +0x12 byte that receives it on the target.
func enchantUpgradeCountOf(stack dnfrepo.ItemStack) byte {
if value := firstExtraInt(stack.Extra, 0, "byte_12", "value_12", "enchant_upgrade_count"); value != 0 {
return byte(value)
}
if raw, ok := currentRawEntry(stack); ok {
return raw[0x12]
}
return 0
}
// setEnchantFields writes the enchant card id (+0x0E i32) and upgrade count
// (+0x12 u8) through the Extra/RawEntry dual-write contract used by the
// current-EXE 0x77 item entry serializer.
func setEnchantFields(stack *dnfrepo.ItemStack, cardItemID int64, upgradeCount byte) {
if stack == nil {
return
}
if stack.Extra == nil {
stack.Extra = make(map[string]string, 4)
}
stack.Extra["value_a"] = strconv.FormatInt(cardItemID, 10)
stack.Extra["enchant_card_id"] = strconv.FormatInt(cardItemID, 10)
stack.Extra["byte_12"] = strconv.Itoa(int(upgradeCount))
stack.Extra["enchant_upgrade_count"] = strconv.Itoa(int(upgradeCount))
if len(stack.RawEntry) == currentItemListEntrySize {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
value := uint32(cardItemID)
stack.RawEntry[0x0E] = byte(value)
stack.RawEntry[0x0F] = byte(value >> 8)
stack.RawEntry[0x10] = byte(value >> 16)
stack.RawEntry[0x11] = byte(value >> 24)
stack.RawEntry[0x12] = upgradeCount
}
}
func setPetStackEnchantFields(stack *dnfrepo.ItemStack, cardItemID int64, upgradeCount byte) {
if stack == nil || stack.Extra == nil || cardItemID < 0 || cardItemID > math.MaxUint32 {
return
}
value := strconv.FormatInt(cardItemID, 10)
stack.Extra["pet_enchant_card_item_id"] = value
stack.Extra["pet_enchant_upgrade_count"] = strconv.Itoa(int(upgradeCount))
}
func setRandomTitleEnchantFields(stack *dnfrepo.ItemStack, configID, rowID uint16) {
if stack == nil || configID == 0 || rowID == 0 {
return
}
if stack.Extra == nil {
stack.Extra = make(map[string]string, 8)
}
stack.Extra["value_a"] = "0"
stack.Extra["enchant_card_id"] = "0"
stack.Extra["byte_12"] = "0"
stack.Extra["enchant_upgrade_count"] = "0"
stack.Extra["random_title_enchant_config_id"] = strconv.FormatUint(uint64(configID), 10)
stack.Extra["random_title_enchant_row_id"] = strconv.FormatUint(uint64(rowID), 10)
if len(stack.RawEntry) == currentItemListEntrySize {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
for index := 0; index < 4; index++ {
stack.RawEntry[0x0E+index] = 0
}
stack.RawEntry[0x12] = 0
binary.LittleEndian.PutUint16(stack.RawEntry[0x61:0x63], configID)
binary.LittleEndian.PutUint16(stack.RawEntry[0x63:0x65], rowID)
}
}
func int64SliceContains(values []int64, needle int64) bool {
for _, value := range values {
if value == needle {
return true
}
}
return false
}
func stringSliceContainsFold(values []string, needle string) bool {
trimmed := strings.TrimSpace(needle)
if trimmed == "" {
return false
}
for _, value := range values {
if strings.EqualFold(strings.TrimSpace(value), trimmed) {
return true
}
}
return false
}
// UseStackable consumes one repository-backed item through the current op44
// contract. Main-inventory items remain PVF-proven [waste]; list-7 pet feed is
// handled by its own PVF-proven, character-pet transaction below.
// When premiumResolver proves the item is a premiumlist_new.etc contract item
// ([etc] family, never [waste]), the use activates the account-level premium
// contract instead of the waste path, inside one rental-asset transaction
// (item decrement + account premium upsert).
func (o *Owner) UseStackable(ctx context.Context, cmd Command, premiumResolver alignedcmd.PremiumContractResolver) (UseStackableResult, error) {
return o.useStackableWithResolvers(ctx, cmd, premiumResolver, nil, nil, nil, nil, nil, nil, nil)
}
func (o *Owner) UseStackableWithResolvers(
ctx context.Context,
cmd Command,
premiumResolver alignedcmd.PremiumContractResolver,
experienceResolver alignedcmd.ExperienceConsumableResolver,
stackableResolver ...alignedcmd.StackableUseResolver,
) (UseStackableResult, error) {
return o.useStackableWithResolvers(ctx, cmd, premiumResolver, experienceResolver, nil, nil, nil, nil, nil, nil, stackableResolver...)
}
func (o *Owner) UseStackableWithAllResolvers(
ctx context.Context,
cmd Command,
premiumResolver alignedcmd.PremiumContractResolver,
experienceResolver alignedcmd.ExperienceConsumableResolver,
stackableResolver alignedcmd.StackableUseResolver,
questTicketPlanner alignedcmd.QuestCompletionTicketPlanner,
directExperiencePlanner alignedcmd.DirectExperienceConsumablePlanner,
storyBookPlanner ...alignedcmd.StoryBookPlanner,
) (UseStackableResult, error) {
var planner alignedcmd.StoryBookPlanner
if len(storyBookPlanner) > 0 {
planner = storyBookPlanner[0]
}
return o.useStackableWithResolvers(ctx, cmd, premiumResolver, experienceResolver, questTicketPlanner, directExperiencePlanner, planner, nil, nil, nil, stackableResolver)
}
// UseStackableWithAllResolversAndSkillCatalog adds the immutable active-PVF
// skill catalog needed only by TP reset books. Other consumables retain the
// existing entry point and do not depend on skill data.
func (o *Owner) UseStackableWithAllResolversAndSkillCatalog(
ctx context.Context,
cmd Command,
premiumResolver alignedcmd.PremiumContractResolver,
experienceResolver alignedcmd.ExperienceConsumableResolver,
stackableResolver alignedcmd.StackableUseResolver,
questTicketPlanner alignedcmd.QuestCompletionTicketPlanner,
directExperiencePlanner alignedcmd.DirectExperienceConsumablePlanner,
storyBookPlanner alignedcmd.StoryBookPlanner,
skillCatalog *dnfskill.Table,
initialSkillLevels map[uint16]int,
skillPointBaseline *dnfrepo.SkillPointState,
) (UseStackableResult, error) {
return o.useStackableWithResolvers(
ctx,
cmd,
premiumResolver,
experienceResolver,
questTicketPlanner,
directExperiencePlanner,
storyBookPlanner,
skillCatalog,
initialSkillLevels,
skillPointBaseline,
stackableResolver,
)
}
// UseEventServerLevelUp executes the current NoPack op418 contract. The wire
// request intentionally carries no item identity, so the durable source row is
// loaded first, resolved through the active PVF, and then checked again inside
// useLevelUpTicket's character settlement before anything is consumed.
func (o *Owner) UseEventServerLevelUp(
ctx context.Context,
cmd Command,
stackableResolver alignedcmd.StackableUseResolver,
directExperiencePlanner alignedcmd.DirectExperienceConsumablePlanner,
questTicketPlanner alignedcmd.QuestCompletionTicketPlanner,
inDungeon bool,
) (UseStackableResult, error) {
if o == nil || o.repo == nil {
return UseStackableResult{}, ErrOwnerUnavailable
}
if cmd.Operation != "event_server_level_up" || cmd.SelectedCharacterID == 0 ||
cmd.SourceListType != listTypeMain || cmd.SourceSlotIndex < 0 ||
cmd.SourceInstanceValue != 0 || cmd.ItemTemplateID != 0 || cmd.ReservedValue != 0 ||
stackableResolver == nil || directExperiencePlanner == nil || questTicketPlanner == nil {
return UseStackableResult{}, ErrLevelUpTicketContract
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
record, found, err := o.repo.Load(ctx, characterID)
if err != nil {
return UseStackableResult{}, err
}
if !found {
return UseStackableResult{}, ErrInventoryNotFound
}
stack, found := record.Slots[slotKey(cmd.SourceListType, cmd.SourceSlotIndex)]
if !found || stack.Count <= 0 {
return UseStackableResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if stack.ItemID <= 0 || stack.ItemID > math.MaxInt32 {
return UseStackableResult{}, fmt.Errorf("%w: item=%d", ErrLevelUpTicketContract, stack.ItemID)
}
resolution, err := stackableResolver(stack.ItemID)
if err != nil {
return UseStackableResult{}, fmt.Errorf("%w: item=%d: %v", ErrQuestCompletionTicketResolveFailed, stack.ItemID, err)
}
if resolution.ItemID != stack.ItemID || resolution.DirectExperience == nil ||
resolution.QuestCompletionTicket == nil ||
!strings.EqualFold(strings.TrimSpace(resolution.DirectExperience.EffectType), "[level up ticket]") {
return UseStackableResult{}, fmt.Errorf("%w: item=%d is not a PVF level-up ticket", ErrLevelUpTicketContract, stack.ItemID)
}
if inDungeon && resolution.QuestCompletionTicket.VillageOnly {
return UseStackableResult{}, ErrQuestCompletionTicketContract
}
cmd.ItemTemplateID = int32(stack.ItemID)
return o.useLevelUpTicket(ctx, cmd, resolution, directExperiencePlanner, questTicketPlanner)
}
// UseIncreaseStatusBook executes the current NoPack op30 contract. Like op418
// the wire request carries no item identity at all, so the durable source row
// is loaded first, resolved through the active PVF, and then checked again
// inside the settlement of whichever owner the resolution selects. Only the two
// families the client actually sends op30 for are admitted: the [etc] SP/TP
// point books and the immediate [increase status type] experience books. A
// level-up ticket resolves through the same PVF fields but belongs to op418 and
// is refused here so its quest settlement cannot run without its own route.
func (o *Owner) UseIncreaseStatusBook(
ctx context.Context,
cmd Command,
stackableResolver alignedcmd.StackableUseResolver,
directExperiencePlanner alignedcmd.DirectExperienceConsumablePlanner,
skillCatalog *dnfskill.Table,
initialSkillLevels map[uint16]int,
skillPointBaseline *dnfrepo.SkillPointState,
) (UseStackableResult, error) {
if o == nil || o.repo == nil {
return UseStackableResult{}, ErrOwnerUnavailable
}
if cmd.Operation != "increase_status_book" || cmd.SelectedCharacterID == 0 ||
cmd.SourceListType != listTypeMain || cmd.SourceSlotIndex < 0 ||
cmd.SourceInstanceValue != 0 || cmd.ItemTemplateID != 0 || cmd.ReservedValue != 0 ||
stackableResolver == nil {
return UseStackableResult{}, ErrIncreaseStatusBookContract
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
record, found, err := o.repo.Load(ctx, characterID)
if err != nil {
return UseStackableResult{}, err
}
if !found {
return UseStackableResult{}, ErrInventoryNotFound
}
stack, found := record.Slots[slotKey(cmd.SourceListType, cmd.SourceSlotIndex)]
if !found || stack.Count <= 0 {
return UseStackableResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if stack.ItemID <= 0 || stack.ItemID > math.MaxInt32 {
return UseStackableResult{}, fmt.Errorf("%w: item=%d", ErrIncreaseStatusBookContract, stack.ItemID)
}
resolution, err := stackableResolver(stack.ItemID)
if err != nil {
return UseStackableResult{}, fmt.Errorf("%w: item=%d: %v", ErrIncreaseStatusBookContract, stack.ItemID, err)
}
if resolution.ItemID != stack.ItemID {
return UseStackableResult{}, fmt.Errorf("%w: item=%d resolved as %d", ErrIncreaseStatusBookContract, stack.ItemID, resolution.ItemID)
}
cmd.ItemTemplateID = int32(stack.ItemID)
if resolution.TPBook != nil {
return o.useTPBook(ctx, cmd, resolution, skillCatalog, initialSkillLevels, skillPointBaseline)
}
if resolution.DirectExperience != nil &&
!strings.EqualFold(strings.TrimSpace(resolution.DirectExperience.EffectType), "[level up ticket]") {
if directExperiencePlanner == nil {
return UseStackableResult{}, ErrIncreaseStatusBookContract
}
return o.useDirectExperienceConsumable(ctx, cmd, resolution, directExperiencePlanner)
}
return UseStackableResult{}, fmt.Errorf(
"%w: item=%d path=%q is neither an SP/TP point book nor an immediate experience book",
ErrIncreaseStatusBookContract, stack.ItemID, resolution.PVFPath)
}
// ---------------------------------------------------------------------------
// 每天可用次数 (PVF [total usable count]) 的通用预留与回滚。
//
// The client paints its 每天可用次数 line from op541 alone, so an item the
// server never counts renders 0/N forever and is never refused. The counter is
// one durable stat row per item, sharing the 06:00 Beijing reset of the
// fatigue counters, and the slot is reserved before the use commits so a
// rejected use can never burn part of the allowance.
// ---------------------------------------------------------------------------
// ErrDailyItemUseLimit marks the "[total usable count] reached for today"
// rejection so callers can answer the ordinary failure without consuming.
//
// 计数与判定本身已经收进 itemdailylimit:那是所有消费路径(op44、op515、开箱、
// 礼包)共用的一份实现。这里的两个名字保留成别名,既有调用点与 errors.Is/As
// 判断一字不改,拒绝文本也保持原样。
var ErrDailyItemUseLimit = dnfitemdailylimit.ErrLimitReached
// DailyItemUseLimitError reports the numbers behind that rejection.
type DailyItemUseLimitError = dnfitemdailylimit.LimitReachedError
// DailyItemUseReservation is one reserved 每天可用次数 slot. Reserved stays
// false when the item declares no cap, in which case nothing was read or
// written.
type DailyItemUseReservation struct {
ItemID int64
Used int64
Limit int64
Reserved bool
}
func dailyItemUseStatKey(itemID int64) string {
return dnfitemdailylimit.Key(itemID)
}
// dailyItemUseUsed reads today's used count, honouring the legacy
// fatigue_potion_used_* spelling so counters written before the generic key
// existed are not lost within the same day.
func dailyItemUseUsed(stats map[string]int64, itemID int64) int64 {
return dnfitemdailylimit.Used(stats, itemID)
}
func dailyItemUseTime(now time.Time) time.Time {
if now.IsZero() {
return time.Now().UTC()
}
return now.UTC()
}
// ReserveDailyItemUse consumes one daily slot for itemID. A limit of zero or
// less means the item has no [total usable count]: the zero reservation is
// returned without touching the database.
func (o *Owner) ReserveDailyItemUse(
ctx context.Context,
characterID string,
itemID int64,
limit int64,
now time.Time,
) (DailyItemUseReservation, error) {
if o == nil || o.assets == nil || limit <= 0 || itemID <= 0 {
return DailyItemUseReservation{}, nil
}
now = dailyItemUseTime(now)
reservation := DailyItemUseReservation{ItemID: itemID, Limit: limit}
err := o.assets.WithinCharacterAssets(ctx, characterID, func(characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrCharacterRequired
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64, 4)
}
// Consume 内部先走 06:00 服务日边界,再判定并自增,与这里原来手写的那三行
// 完全同序同键;上限<=0 时它是空操作(PVF 没声明就不读不写)。
used, consumeErr := dnfitemdailylimit.Consume(character.Stats, itemID, limit, now, character.Level)
if consumeErr != nil {
return consumeErr
}
character.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
reservation.Used = used
reservation.Reserved = true
return nil
})
if err != nil {
return DailyItemUseReservation{}, err
}
return reservation, nil
}
// ReleaseDailyItemUse returns a reserved slot when the actual use failed, so a
// rejected command never burns part of the player's daily allowance.
func (o *Owner) ReleaseDailyItemUse(
ctx context.Context,
characterID string,
reservation DailyItemUseReservation,
now time.Time,
) error {
if o == nil || o.assets == nil || !reservation.Reserved || reservation.ItemID <= 0 {
return nil
}
now = dailyItemUseTime(now)
return o.assets.WithinCharacterAssets(ctx, characterID, func(characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrCharacterRequired
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
return nil
}
key := dailyItemUseStatKey(reservation.ItemID)
used := character.Stats[key]
if used <= 0 {
return nil
}
dnfitemdailylimit.Release(character.Stats, reservation.ItemID)
character.UpdatedAt = now
return dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats)
})
}
// ---------------------------------------------------------------------------
// 冷却(PVF [cool time] + [cooltime group] + [cooltime maintenance])的持久预留。
//
// 客户端的冷却门只在进程内内存里(cmd/scratch/potionpatch 已记:"no packet is sent
// while the client blocks the action"),所以小退重上就没了。声明了
// [cooltime maintenance] 的道具必须由服务端落一份到期时间:用过就在窗口内拒绝再次使用,
// 并在选角/回城时按类 0 通知 172 把仍在冷却的道具重放给客户端。
// ---------------------------------------------------------------------------
// ErrItemCooltimeActive marks the "still cooling down" rejection so callers can
// answer the ordinary failure without consuming the item.
var ErrItemCooltimeActive = errors.New("item cooltime is still active")
// ItemCooltimeReservation is one reserved cooldown window. Reserved stays false
// when the item declares no maintained cooldown, in which case nothing was read
// or written.
type ItemCooltimeReservation struct {
ItemID int64
ExpireAt int64
// PreviousExpireAt/HadPrevious let a failed use restore exactly what was
// there before, instead of dropping an older running window.
PreviousExpireAt int64
HadPrevious bool
Reserved bool
}
// ReserveItemCooltime checks itemID's cooldown window and, when it is free,
// writes the new expiry in the same transaction. seconds<=0 or itemID<=0 means
// the item declares no maintained cooldown: nothing is read or written.
//
// groupOf resolves the PVF cooltime group of an already-stored item id so two
// items of the same group share one window (PVF `[cooltime group]`); it may be
// nil when the caller cannot resolve groups.
func (o *Owner) ReserveItemCooltime(
ctx context.Context,
characterID string,
itemID int64,
seconds int64,
group int64,
groupOf func(int64) int64,
now time.Time,
) (ItemCooltimeReservation, error) {
if o == nil || o.assets == nil || itemID <= 0 || seconds <= 0 {
return ItemCooltimeReservation{}, nil
}
now = dailyItemUseTime(now)
reservation := ItemCooltimeReservation{ItemID: itemID}
err := o.assets.WithinCharacterAssets(ctx, characterID, func(characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrCharacterRequired
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64, 4)
}
nowUnix := now.Unix()
if blocked, active := dnfitemcooldown.Blocks(character.Stats, nowUnix, itemID, group, groupOf); active {
return fmt.Errorf("%w: item=%d blocked_by=%d until=%d", ErrItemCooltimeActive, itemID, blocked.ItemID, blocked.ExpireAt)
}
if previous, found := dnfitemcooldown.ExpireAt(character.Stats, itemID); found {
reservation.PreviousExpireAt = previous
reservation.HadPrevious = true
}
dnfitemcooldown.Prune(character.Stats, nowUnix)
reservation.ExpireAt = nowUnix + seconds
dnfitemcooldown.Set(character.Stats, itemID, reservation.ExpireAt)
character.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
reservation.Reserved = true
return nil
})
if err != nil {
return ItemCooltimeReservation{}, err
}
return reservation, nil
}
// ReleaseItemCooltime restores the pre-use window after a failed use, so a
// rejected command never starts a cooldown the player did not get to spend.
func (o *Owner) ReleaseItemCooltime(
ctx context.Context,
characterID string,
reservation ItemCooltimeReservation,
now time.Time,
) error {
if o == nil || o.assets == nil || !reservation.Reserved || reservation.ItemID <= 0 {
return nil
}
now = dailyItemUseTime(now)
return o.assets.WithinCharacterAssets(ctx, characterID, func(characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrCharacterRequired
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
return nil
}
if reservation.HadPrevious {
dnfitemcooldown.Set(character.Stats, reservation.ItemID, reservation.PreviousExpireAt)
} else {
dnfitemcooldown.Clear(character.Stats, reservation.ItemID)
}
character.UpdatedAt = now
return dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats)
})
}
func (o *Owner) useStackableWithResolvers(
ctx context.Context,
cmd Command,
premiumResolver alignedcmd.PremiumContractResolver,
experienceResolver alignedcmd.ExperienceConsumableResolver,
questTicketPlanner alignedcmd.QuestCompletionTicketPlanner,
directExperiencePlanner alignedcmd.DirectExperienceConsumablePlanner,
storyBookPlanner alignedcmd.StoryBookPlanner,
skillCatalog *dnfskill.Table,
initialSkillLevels map[uint16]int,
skillPointBaseline *dnfrepo.SkillPointState,
stackableResolver ...alignedcmd.StackableUseResolver,
) (UseStackableResult, error) {
if o == nil || o.repo == nil {
return UseStackableResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return UseStackableResult{}, ErrCharacterRequired
}
if err := checkUseStackableRequestListType(cmd.SourceListType); err != nil {
return UseStackableResult{}, err
}
if cmd.SourceInstanceValue == 0 || cmd.ItemTemplateID <= 0 || cmd.ReservedValue != 0 {
return UseStackableResult{}, fmt.Errorf(
"%w: instance=0x%08X item=%d reserved=0x%08X",
ErrUseStackableContractRequired,
uint32(cmd.SourceInstanceValue),
cmd.ItemTemplateID,
cmd.ReservedValue,
)
}
if cmd.SourceListType == listTypePet {
if petInventorySlotSegment(cmd.SourceSlotIndex) != petInventorySegmentConsumable {
return UseStackableResult{}, fmt.Errorf(
"%w: list=%d slot=%d is outside pet-consumable slots 189..238",
ErrPetFeedContractRequired,
cmd.SourceListType,
cmd.SourceSlotIndex,
)
}
if len(stackableResolver) == 0 || stackableResolver[0] == nil {
return UseStackableResult{}, ErrPetFeedResolveFailed
}
resolution, err := stackableResolver[0](int64(cmd.ItemTemplateID))
if err != nil {
return UseStackableResult{}, fmt.Errorf("%w: item=%d: %v", ErrPetFeedResolveFailed, cmd.ItemTemplateID, err)
}
return o.usePetFeed(ctx, cmd, resolution)
}
var stackableResolution alignedcmd.StackableUseResolution
if len(stackableResolver) > 0 && stackableResolver[0] != nil {
var resolveErr error
stackableResolution, resolveErr = stackableResolver[0](int64(cmd.ItemTemplateID))
if resolveErr != nil {
return UseStackableResult{}, fmt.Errorf("%w: item=%d: %v", ErrQuestCompletionTicketResolveFailed, cmd.ItemTemplateID, resolveErr)
}
if stackableResolution.DirectExperience != nil && stackableResolution.QuestCompletionTicket != nil &&
strings.EqualFold(strings.TrimSpace(stackableResolution.DirectExperience.EffectType), "[level up ticket]") {
return o.useLevelUpTicket(ctx, cmd, stackableResolution, directExperiencePlanner, questTicketPlanner)
}
if stackableResolution.QuestCompletionTicket != nil {
return o.useQuestCompletionTicket(ctx, cmd, stackableResolution, questTicketPlanner)
}
if stackableResolution.StoryBook != nil {
return o.useStoryBook(ctx, cmd, stackableResolution, storyBookPlanner)
}
if stackableResolution.DirectExperience != nil {
return o.useDirectExperienceConsumable(ctx, cmd, stackableResolution, directExperiencePlanner)
}
if stackableResolution.TPBook != nil {
return o.useTPBook(ctx, cmd, stackableResolution, skillCatalog, initialSkillLevels, skillPointBaseline)
}
if stackableResolution.DungeonAdmissionTicket != nil {
return o.useDungeonAdmissionTicket(ctx, cmd, stackableResolution)
}
if stackableResolution.Transformation != nil {
if err := validateTransformationUse(cmd, *stackableResolution.Transformation); err != nil {
return UseStackableResult{}, err
}
}
if stackableResolution.Projectile != nil {
if err := validateProjectileUse(cmd, *stackableResolution.Projectile); err != nil {
return UseStackableResult{}, err
}
}
if stackableResolution.AvatarFittingRoomTicket != nil {
return o.openAvatarFittingRoom(ctx, cmd, stackableResolution)
}
if stackableResolution.CustomAbilityGroupConverter != nil {
return o.openCustomAbilityGroupConverter(ctx, cmd, stackableResolution)
}
// 塔罗牌(PVF `[monster tarot card]`):金币与道具必须同笔,所以它走
// withinCharacterAssetTransaction,而不是上面这些只有背包的路径。
if stackableResolution.TarotLottery != nil {
return o.UseTarotLotteryCard(ctx, cmd, *stackableResolution.TarotLottery)
}
// 城镇自施放增益药剂(史诗Buff药剂一族):背包减 1 与角色增益窗口必须同笔,
// 所以它也走账号+角色+背包同一条事务(见 stat_change_potion.go)。
if stackableResolution.StatChangePotion != nil {
return o.useStatChangePotion(ctx, cmd, stackableResolution)
}
}
if premiumResolver != nil {
resolution, err := premiumResolver(int64(cmd.ItemTemplateID))
if err != nil {
return UseStackableResult{}, fmt.Errorf(
"%w: item=%d: %v",
ErrPremiumContractResolveFailed,
cmd.ItemTemplateID,
err,
)
}
if resolution.PremiumType > 0 {
return o.usePremiumContract(ctx, cmd, resolution)
}
}
if experienceResolver != nil {
resolution, err := experienceResolver(int64(cmd.ItemTemplateID))
if err != nil {
return UseStackableResult{}, fmt.Errorf(
"%w: item=%d: %v",
ErrExperienceConsumableResolveFailed,
cmd.ItemTemplateID,
err,
)
}
if resolution.BonusPercent > 0 {
return o.useExperienceConsumable(ctx, cmd, resolution)
}
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
if !o.inItemTransaction {
var result UseStackableResult
err := o.withinInventoryTransaction(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.useStackableWithResolvers(ctx, cmd, premiumResolver, nil, questTicketPlanner, directExperiencePlanner, storyBookPlanner, skillCatalog, initialSkillLevels, skillPointBaseline, stackableResolver...)
return err
})
return result, err
}
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return UseStackableResult{}, err
}
if !ok {
return UseStackableResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
if record.Slots == nil {
return UseStackableResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, ok := record.Slots[key]
if !ok || stack.Count <= 0 {
return UseStackableResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if stack.ItemID != int64(cmd.ItemTemplateID) {
return UseStackableResult{}, fmt.Errorf(
"%w: list=%d slot=%d want=%d got=%d",
ErrItemMismatch,
cmd.SourceListType,
cmd.SourceSlotIndex,
cmd.ItemTemplateID,
stack.ItemID,
)
}
if stackableResolution.UsePlace != nil {
if err := validateStackableUsePlace(cmd, *stackableResolution.UsePlace); err != nil {
return UseStackableResult{}, err
}
}
pvfPath, stackableType, ok := pvfProvenWasteStack(stack)
if !ok && len(stackableResolver) > 0 && stackableResolver[0] != nil {
resolution := stackableResolution
var resolveErr error
if resolution.ItemID == 0 {
resolution, resolveErr = stackableResolver[0](stack.ItemID)
}
durablePVFPath := strings.TrimSpace(stack.Extra["pvf_path"])
resolvedType := strings.TrimSpace(strings.ReplaceAll(resolution.StackableType, "`", ""))
transformationAuthorized := resolution.Transformation != nil && validateTransformationUse(cmd, *resolution.Transformation) == nil
projectileAuthorized := resolution.Projectile != nil && validateProjectileUse(cmd, *resolution.Projectile) == nil
dynamicDungeonWaste := resolution.UsePlace != nil &&
len(resolution.UsePlace.ActionUsableDungeonIDs) > 0 &&
validateStackableUsePlace(cmd, *resolution.UsePlace) == nil
pathAuthorized := durablePVFPath != "" && strings.EqualFold(durablePVFPath, strings.TrimSpace(resolution.PVFPath))
if resolveErr == nil && resolution.ItemID == stack.ItemID && (pathAuthorized || dynamicDungeonWaste) &&
(strings.HasPrefix(strings.ToLower(resolvedType), "[waste]") || transformationAuthorized || projectileAuthorized) {
pvfPath, stackableType, ok = strings.TrimSpace(resolution.PVFPath), resolvedType, true
}
}
if !ok {
return UseStackableResult{}, fmt.Errorf(
"%w: list=%d slot=%d item=%d",
ErrUseStackableContractRequired,
cmd.SourceListType,
cmd.SourceSlotIndex,
stack.ItemID,
)
}
reviveCoinConsumable := dnfrevivecoin.IsConsumable(stack)
// 修炼道场 (5000) is a sandbox: the consumable is usable - the caller still
// computes and sends the recovery frame from this result - but the stack must
// stay untouched, so nothing is deducted and the ack carries the unchanged
// count. Every durable side effect below is skipped for that run.
remaining := stack.Count
if !cmd.ConsumableFree {
remaining = stack.Count - 1
if remaining == 0 {
delete(record.Slots, key)
} else {
stack = cloneStack(stack)
stack.Count = remaining
updateStackRawAmount(&stack)
record.Slots[key] = stack
}
}
var reviveCoinWalletTotal int64
if reviveCoinConsumable && !cmd.ConsumableFree {
reviveCoinWalletTotal, err = dnfrevivecoin.Grant(
&record,
1,
"current_exe_op44_coin_general",
)
if err != nil {
return UseStackableResult{}, err
}
}
record.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, dnfrepo.InventoryFieldSlots); err != nil {
return UseStackableResult{}, err
}
result := UseStackableResult{
CharacterID: characterID,
AccountID: cmd.AccountID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
InstanceValue: cmd.SourceInstanceValue,
ItemID: stack.ItemID,
RemainingCount: remaining,
// A sandbox run keeps the stack count, but the caller must still publish
// the authoritative item row: the client adjusts its own stack on use and
// without the row the cell stays empty until the next inventory reload
// (live 2026-09-12: the item vanished until the player relogged).
Changed: true,
PVFPath: pvfPath,
StackableType: stackableType,
ReviveCoinWalletUpdated: reviveCoinConsumable,
ReviveCoinWalletTotal: reviveCoinWalletTotal,
}
if stackableResolution.Transformation != nil {
result.TransformationApplied = true
result.TransformationAction = stackableResolution.Transformation.ActionType
}
if stackableResolution.Projectile != nil {
result.ProjectileApplied = true
result.ProjectileEffectID = stackableResolution.Projectile.EffectID
}
return result, nil
}
func (o *Owner) useDungeonAdmissionTicket(
ctx context.Context,
cmd Command,
resolution alignedcmd.StackableUseResolution,
) (UseStackableResult, error) {
ticket := resolution.DungeonAdmissionTicket
actionPacketMatches := (cmd.Operation == "use_stackable" && ticket != nil && ticket.ActionPacket == 0) ||
(cmd.Operation == "use_stackable_action" && ticket != nil && ticket.ActionPacket == cmd.ActionIndex && cmd.ActionIndex == dungeonAdmissionTicketActionIndex)
if o == nil || o.dungeonAdmission == nil || ticket == nil ||
cmd.SourceListType != listTypeMain || cmd.InDungeon ||
resolution.ItemID != int64(cmd.ItemTemplateID) ||
strings.TrimSpace(resolution.PVFPath) == "" ||
!strings.EqualFold(strings.TrimSpace(strings.ReplaceAll(resolution.StackableType, "`", "")), "[waste]") ||
strings.TrimSpace(ticket.ActionType) == "" || len(ticket.Grants) == 0 ||
(!ticket.VillageAllowed && !ticket.SeriaRoomAllowed) || !actionPacketMatches {
return UseStackableResult{}, ErrDungeonAdmissionTicketContract
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
now := time.Now().UTC()
if o.now != nil {
now = o.now().UTC()
}
var remaining int64
allowance, err := o.dungeonAdmission.GrantAllowances(
ctx,
characterID,
ticket.Grants,
ticket.UseLimit,
now,
func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Inventory == nil {
return ErrDungeonAdmissionTicketContract
}
character, found, loadErr := tx.Character.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !found || strings.TrimSpace(character.CharacterID) != characterID ||
(strings.TrimSpace(cmd.AccountID) != "" && strings.TrimSpace(character.AccountID) != strings.TrimSpace(cmd.AccountID)) ||
(ticket.MinimumLevel > 0 && int64(character.Level) < ticket.MinimumLevel) ||
(ticket.MaximumLevel > 0 && int64(character.Level) > ticket.MaximumLevel) {
return ErrDungeonAdmissionTicketContract
}
inventory, found, loadErr := tx.Inventory.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !found {
return ErrInventoryNotFound
}
inventory = dnfrepo.CloneInventory(inventory)
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, found := inventory.Slots[key]
if !found || stack.Count <= 0 {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if stack.ItemID != resolution.ItemID {
return fmt.Errorf("%w: list=%d slot=%d want=%d got=%d", ErrItemMismatch, cmd.SourceListType, cmd.SourceSlotIndex, resolution.ItemID, stack.ItemID)
}
durablePath := strings.TrimSpace(stack.Extra["pvf_path"])
durableKind := strings.ToLower(strings.TrimSpace(stack.Extra["item_kind"]))
durableType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(stack.Extra["stackable_type"], "`", "")))
if durablePath == "" || !strings.EqualFold(durablePath, strings.TrimSpace(resolution.PVFPath)) ||
(durableKind != "" && durableKind != "stackable") ||
(durableType != "" && durableType != "[waste]") {
return fmt.Errorf("%w: item=%d durable_path=%q durable_kind=%q durable_type=%q", ErrDungeonAdmissionTicketContract, stack.ItemID, durablePath, durableKind, durableType)
}
remaining = stack.Count - 1
if remaining == 0 {
delete(inventory.Slots, key)
} else {
stack = cloneStack(stack)
stack.Count = remaining
updateStackRawAmount(&stack)
inventory.Slots[key] = stack
}
inventory.UpdatedAt = now
return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots)
},
)
if err != nil {
return UseStackableResult{}, err
}
bonus := uint32(0)
if len(ticket.Grants) > 0 {
bonus = ticket.Grants[0].Amount
}
return UseStackableResult{
CharacterID: characterID, AccountID: cmd.AccountID, ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex, InstanceValue: cmd.SourceInstanceValue,
ItemID: resolution.ItemID, RemainingCount: remaining, Changed: true,
PVFPath: resolution.PVFPath, StackableType: resolution.StackableType,
DungeonAdmissionTicketApplied: true, DungeonAdmissionAction: ticket.ActionType,
DungeonAdmissionPolicyCount: len(allowance.Snapshots), DungeonAdmissionBonus: bonus,
DungeonAdmissionRemainingUses: allowance.RemainingUses,
}, nil
}
const dungeonAdmissionTicketActionIndex uint32 = 1
// UseDungeonAdmissionTicketAction handles the current op515/action-1 family.
// The request contains only a dynamic source slot, so item identity is loaded
// from storage, resolved through the active PVF, and revalidated again inside
// the shared allowance+inventory settlement.
func (o *Owner) UseDungeonAdmissionTicketAction(
ctx context.Context,
cmd Command,
resolver alignedcmd.StackableUseResolver,
inDungeon bool,
) (UseStackableResult, error) {
if o == nil || o.repo == nil || resolver == nil || cmd.SelectedCharacterID == 0 ||
cmd.Operation != "use_stackable_action" || cmd.ActionIndex != dungeonAdmissionTicketActionIndex ||
cmd.SourceListType != listTypeMain || cmd.SourceSlotIndex < 0 || inDungeon {
return UseStackableResult{}, ErrDungeonAdmissionTicketContract
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
record, found, err := o.repo.Load(ctx, characterID)
if err != nil {
return UseStackableResult{}, err
}
if !found {
return UseStackableResult{}, ErrInventoryNotFound
}
stack, found := record.Slots[slotKey(cmd.SourceListType, cmd.SourceSlotIndex)]
if !found || stack.Count <= 0 || stack.ItemID <= 0 || stack.ItemID > math.MaxInt32 {
return UseStackableResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
resolution, err := resolver(stack.ItemID)
if err != nil {
return UseStackableResult{}, fmt.Errorf("%w: item=%d: %v", ErrDungeonAdmissionTicketContract, stack.ItemID, err)
}
if resolution.ItemID != stack.ItemID || resolution.DungeonAdmissionTicket == nil ||
resolution.DungeonAdmissionTicket.ActionPacket != dungeonAdmissionTicketActionIndex {
return UseStackableResult{}, ErrDungeonAdmissionTicketContract
}
cmd.ItemTemplateID = int32(stack.ItemID)
cmd.InDungeon = false
return o.useDungeonAdmissionTicket(ctx, cmd, resolution)
}
// openCustomAbilityGroupConverter proves the exact source row without
// consuming it. The successful op44 opens the native window; op1340 later
// atomically updates the target and decrements one device, so cancellation is
// free and an op44 write failure cannot charge the player.
func (o *Owner) openCustomAbilityGroupConverter(
ctx context.Context,
cmd Command,
resolution alignedcmd.StackableUseResolution,
) (UseStackableResult, error) {
converter := resolution.CustomAbilityGroupConverter
if converter == nil || resolution.ItemID != int64(cmd.ItemTemplateID) ||
strings.TrimSpace(resolution.PVFPath) == "" ||
!strings.EqualFold(strings.TrimSpace(strings.ReplaceAll(resolution.StackableType, "`", "")), "[etc]") ||
cmd.SourceListType != listTypeMain {
return UseStackableResult{}, ErrUseStackableContractRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
if o.characters == nil {
return UseStackableResult{}, ErrCharacterRequired
}
character, found, err := o.characters.Load(ctx, characterID)
if err != nil {
return UseStackableResult{}, err
}
if !found || character.CharacterID != characterID ||
(converter.MinimumLevel > 0 && int64(character.Level) < converter.MinimumLevel) ||
(converter.MaximumLevel > 0 && int64(character.Level) > converter.MaximumLevel) {
return UseStackableResult{}, ErrUseStackableContractRequired
}
record, found, err := o.repo.Load(ctx, characterID)
if err != nil {
return UseStackableResult{}, err
}
if !found {
return UseStackableResult{}, ErrInventoryNotFound
}
stack, found := record.Slots[slotKey(cmd.SourceListType, cmd.SourceSlotIndex)]
if !found || stack.Count <= 0 || stack.ItemID != resolution.ItemID {
return UseStackableResult{}, ErrItemMismatch
}
durablePath := strings.TrimSpace(stack.Extra["pvf_path"])
durableType := strings.TrimSpace(strings.ReplaceAll(stack.Extra["stackable_type"], "`", ""))
if durablePath == "" || !strings.EqualFold(durablePath, strings.TrimSpace(resolution.PVFPath)) ||
(durableType != "" && !strings.EqualFold(durableType, "[etc]")) {
return UseStackableResult{}, ErrUseStackableContractRequired
}
return UseStackableResult{
CharacterID: characterID,
AccountID: cmd.AccountID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
InstanceValue: cmd.SourceInstanceValue,
ItemID: stack.ItemID,
RemainingCount: stack.Count,
Changed: false,
PVFPath: resolution.PVFPath,
StackableType: "[etc]",
CustomAbilityGroupConverterOpened: true,
}, nil
}
// openAvatarFittingRoom proves the exact ticket row without consuming it.
// The current client first uses op44 to open the window and later submits the
// actual changed parts through op1488. Consuming here would double-charge one
// ticket before the server knows how many parts are being changed.
func (o *Owner) openAvatarFittingRoom(
ctx context.Context,
cmd Command,
resolution alignedcmd.StackableUseResolution,
) (UseStackableResult, error) {
ticket := resolution.AvatarFittingRoomTicket
if ticket == nil || resolution.ItemID != int64(cmd.ItemTemplateID) ||
strings.TrimSpace(resolution.PVFPath) == "" ||
!strings.EqualFold(strings.TrimSpace(strings.ReplaceAll(resolution.StackableType, "`", "")), "[etc]") ||
cmd.SourceListType != listTypeMain || cmd.InDungeon ||
(!ticket.VillageAllowed && !ticket.SeriaAllowed) {
return UseStackableResult{}, ErrUseStackableContractRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
if o.characters == nil {
return UseStackableResult{}, ErrCharacterRequired
}
character, found, err := o.characters.Load(ctx, characterID)
if err != nil {
return UseStackableResult{}, err
}
if !found || character.CharacterID != characterID ||
(ticket.MinimumLevel > 0 && int64(character.Level) < ticket.MinimumLevel) ||
(ticket.MaximumLevel > 0 && int64(character.Level) > ticket.MaximumLevel) {
return UseStackableResult{}, ErrUseStackableContractRequired
}
record, found, err := o.repo.Load(ctx, characterID)
if err != nil {
return UseStackableResult{}, err
}
if !found {
return UseStackableResult{}, ErrInventoryNotFound
}
stack, found := record.Slots[slotKey(cmd.SourceListType, cmd.SourceSlotIndex)]
if !found || stack.Count <= 0 || stack.ItemID != resolution.ItemID {
return UseStackableResult{}, ErrItemMismatch
}
durablePath := strings.TrimSpace(stack.Extra["pvf_path"])
durableType := strings.TrimSpace(strings.ReplaceAll(stack.Extra["stackable_type"], "`", ""))
if durablePath == "" || !strings.EqualFold(durablePath, strings.TrimSpace(resolution.PVFPath)) ||
(durableType != "" && !strings.EqualFold(durableType, "[etc]")) {
return UseStackableResult{}, ErrUseStackableContractRequired
}
return UseStackableResult{
CharacterID: characterID,
AccountID: cmd.AccountID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
InstanceValue: cmd.SourceInstanceValue,
ItemID: stack.ItemID,
RemainingCount: stack.Count,
Changed: false,
PVFPath: resolution.PVFPath,
StackableType: resolution.StackableType,
AvatarFittingRoomOpened: true,
}, nil
}
func validateTransformationUse(cmd Command, transformation alignedcmd.TransformationUseResolution) error {
actionType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(transformation.ActionType, "`", "")))
if actionType == "" || len(transformation.FormIDs) == 0 {
return fmt.Errorf("%w: action=%q forms=%v", ErrTransformationContract, transformation.ActionType, transformation.FormIDs)
}
for _, formID := range transformation.FormIDs {
if formID <= 0 {
return fmt.Errorf("%w: action=%q invalid form=%d", ErrTransformationContract, transformation.ActionType, formID)
}
}
if cmd.InDungeon {
if !transformation.DungeonAllowed || cmd.DungeonID <= 0 {
return fmt.Errorf("%w: action=%q dungeon=%d is not allowed", ErrTransformationContract, transformation.ActionType, cmd.DungeonID)
}
if len(transformation.ActionUsableDungeonIDs) > 0 && !containsTransformationDungeonID(transformation.ActionUsableDungeonIDs, cmd.DungeonID) {
return fmt.Errorf("%w: action=%q dungeon=%d not in action whitelist", ErrTransformationContract, transformation.ActionType, cmd.DungeonID)
}
if len(transformation.LimitDungeonIDs) > 0 && !containsTransformationDungeonID(transformation.LimitDungeonIDs, cmd.DungeonID) {
return fmt.Errorf("%w: action=%q dungeon=%d not in limit whitelist", ErrTransformationContract, transformation.ActionType, cmd.DungeonID)
}
return nil
}
if !transformation.VillageAllowed && !transformation.SeriaRoomAllowed {
return fmt.Errorf("%w: action=%q is dungeon-only", ErrTransformationContract, transformation.ActionType)
}
return nil
}
func containsTransformationDungeonID(values []int64, dungeonID int64) bool {
for _, value := range values {
if value == dungeonID {
return true
}
}
return false
}
func validateProjectileUse(cmd Command, projectile alignedcmd.ProjectileUseResolution) error {
stackableType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(projectile.StackableType, "`", "")))
if (stackableType != "[throw]" && stackableType != "[set]") || projectile.EffectID <= 0 ||
len(projectile.IntData) == 0 || projectile.IntData[0] != projectile.EffectID {
return fmt.Errorf(
"%w: type=%q effect=%d ints=%v particles=%v",
ErrProjectileContract,
projectile.StackableType,
projectile.EffectID,
projectile.IntData,
projectile.ParticlePaths,
)
}
if !cmd.InDungeon || !projectile.DungeonAllowed || cmd.DungeonID <= 0 {
return fmt.Errorf("%w: type=%q dungeon=%d is not active", ErrProjectileContract, projectile.StackableType, cmd.DungeonID)
}
if len(projectile.ActionUsableDungeonIDs) > 0 && !containsTransformationDungeonID(projectile.ActionUsableDungeonIDs, cmd.DungeonID) {
return fmt.Errorf("%w: type=%q dungeon=%d not in action whitelist", ErrProjectileContract, projectile.StackableType, cmd.DungeonID)
}
if len(projectile.LimitDungeonIDs) > 0 && !containsTransformationDungeonID(projectile.LimitDungeonIDs, cmd.DungeonID) {
return fmt.Errorf("%w: type=%q dungeon=%d not in limit whitelist", ErrProjectileContract, projectile.StackableType, cmd.DungeonID)
}
return nil
}
func validateStackableUsePlace(cmd Command, place alignedcmd.StackableUsePlaceResolution) error {
if cmd.InDungeon {
if !place.DungeonAllowed || cmd.DungeonID <= 0 {
return fmt.Errorf("%w: dungeon=%d is not allowed", ErrStackableUsePlaceContract, cmd.DungeonID)
}
if len(place.ActionUsableDungeonIDs) > 0 && !containsTransformationDungeonID(place.ActionUsableDungeonIDs, cmd.DungeonID) {
return fmt.Errorf("%w: dungeon=%d not in action whitelist", ErrStackableUsePlaceContract, cmd.DungeonID)
}
if len(place.LimitDungeonIDs) > 0 && !containsTransformationDungeonID(place.LimitDungeonIDs, cmd.DungeonID) {
return fmt.Errorf("%w: dungeon=%d not in limit whitelist", ErrStackableUsePlaceContract, cmd.DungeonID)
}
return nil
}
if !place.VillageAllowed && !place.SeriaRoomAllowed {
return fmt.Errorf("%w: village/seria is not allowed", ErrStackableUsePlaceContract)
}
return nil
}
func (o *Owner) useLevelUpTicket(
ctx context.Context,
cmd Command,
resolution alignedcmd.StackableUseResolution,
directPlanner alignedcmd.DirectExperienceConsumablePlanner,
questPlanner alignedcmd.QuestCompletionTicketPlanner,
) (UseStackableResult, error) {
if o == nil || o.settlements == nil || directPlanner == nil || questPlanner == nil ||
resolution.DirectExperience == nil || resolution.QuestCompletionTicket == nil {
return UseStackableResult{}, ErrLevelUpTicketContract
}
effect := *resolution.DirectExperience
questTicket := *resolution.QuestCompletionTicket
if cmd.SourceListType != listTypeMain || resolution.ItemID != int64(cmd.ItemTemplateID) ||
effect.ItemID != resolution.ItemID || strings.TrimSpace(resolution.PVFPath) == "" ||
!strings.EqualFold(strings.TrimSpace(effect.PVFPath), strings.TrimSpace(resolution.PVFPath)) ||
!strings.EqualFold(strings.TrimSpace(effect.EffectType), "[level up ticket]") ||
len(effect.EffectValues) != 1 || effect.EffectValues[0] <= 0 ||
!strings.EqualFold(strings.TrimSpace(questTicket.ActionType), "[quest clear]") {
return UseStackableResult{}, fmt.Errorf("%w: item=%d resolution=%+v", ErrLevelUpTicketContract, cmd.ItemTemplateID, resolution)
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
updatedAt := time.Now().UTC()
var result UseStackableResult
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Skill == nil || tx.Inventory == nil || tx.Quest == nil {
return ErrLevelUpTicketContract
}
character, found, err := tx.Character.Load(ctx, characterID)
if err != nil {
return err
}
if !found || character.CharacterID != characterID {
return ErrCharacterRequired
}
skill, found, err := tx.Skill.Load(ctx, characterID)
if err != nil {
return err
}
if !found || skill.CharacterID != characterID {
return ErrLevelUpTicketContract
}
inventory, found, err := tx.Inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
inventory = dnfrepo.CloneInventory(inventory)
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, found := inventory.Slots[key]
if !found || stack.Count <= 0 {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if stack.ItemID != resolution.ItemID {
return fmt.Errorf("%w: list=%d slot=%d want=%d got=%d", ErrItemMismatch, cmd.SourceListType, cmd.SourceSlotIndex, resolution.ItemID, stack.ItemID)
}
durableKind := strings.ToLower(strings.TrimSpace(stack.Extra["item_kind"]))
durablePath := strings.TrimSpace(stack.Extra["pvf_path"])
durableType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(stack.Extra["stackable_type"], "`", "")))
resolvedType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(resolution.StackableType, "`", "")))
if durableKind != "stackable" || durablePath == "" ||
!strings.EqualFold(durablePath, strings.TrimSpace(resolution.PVFPath)) ||
(durableType != "" && durableType != resolvedType) {
return fmt.Errorf("%w: item=%d durable_kind=%q durable_path=%q durable_type=%q resolved_type=%q", ErrLevelUpTicketContract, stack.ItemID, durableKind, durablePath, durableType, resolvedType)
}
progressionPlan, err := directPlanner(character, skill, effect, updatedAt)
if err != nil {
return err
}
if progressionPlan.Gain == 0 || progressionPlan.Character.CharacterID != characterID ||
progressionPlan.Skill.CharacterID != characterID || len(progressionPlan.CharacterFields) == 0 ||
progressionPlan.PreviousLevel != character.Level || progressionPlan.NewLevel <= progressionPlan.PreviousLevel ||
progressionPlan.NewLevel-progressionPlan.PreviousLevel != int(effect.EffectValues[0]) ||
progressionPlan.PreviousExperience != uint32(character.Stats["exp"]) ||
progressionPlan.NewExperience <= progressionPlan.PreviousExperience ||
progressionPlan.SPGain < 0 || progressionPlan.SPGain > math.MaxUint16 ||
progressionPlan.TPGain < 0 || progressionPlan.TPGain > math.MaxUint16 {
return ErrLevelUpTicketContract
}
questRecord, questFound, err := tx.Quest.Load(ctx, characterID)
if err != nil {
return err
}
if !questFound {
questRecord = dnfrepo.QuestRecord{CharacterID: characterID}
}
// A multi-level ticket (the runtime PVF has +3 and +10 families) skips
// every intermediate level. Its native description says those preceding
// levels' story/ordinary chains are completed too, so the cutoff is the
// level immediately before the committed target, not only the original
// character level. Ticket use eligibility was already validated against
// the real pre-upgrade character by directPlanner.
questCharacter := dnfrepo.CloneCharacter(character)
questCharacter.Level = progressionPlan.NewLevel - 1
questTicket.MinimumLevel = 0
questTicket.MaximumLevel = int64(questCharacter.Level)
// The level-up ticket must not consume optional side stories or a Hell
// Party unlock. The latter's normal completion writes the permission used
// by dungeon entry and its selector state.
questTicket.PreserveSideQuests = true
questTicket.PreserveHellPartyUnlocks = true
questPlan, questErr := questPlanner(questCharacter, questRecord, questTicket, updatedAt)
if questErr != nil && !errors.Is(questErr, dnfquest.ErrCompletionTicketNoApplicableQuest) {
return questErr
}
if questErr == nil {
if len(questPlan.CompletedQuestIDs) == 0 || len(questPlan.QuestFields) == 0 ||
strings.TrimSpace(questPlan.Quest.CharacterID) != characterID || len(questPlan.CharacterFields) != 0 ||
questPlan.ProfessionChanged {
return ErrLevelUpTicketContract
}
}
remaining := stack.Count - 1
if remaining == 0 {
delete(inventory.Slots, key)
} else {
stack = cloneStack(stack)
stack.Count = remaining
updateStackRawAmount(&stack)
inventory.Slots[key] = stack
}
inventory.UpdatedAt = updatedAt
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, progressionPlan.Character, progressionPlan.CharacterFields...); err != nil {
return err
}
if len(progressionPlan.SkillFields) > 0 {
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, progressionPlan.Skill, progressionPlan.SkillFields...); err != nil {
return err
}
}
if questErr == nil {
if err := dnfrepo.SaveQuestFields(ctx, tx.Quest, questPlan.Quest, questPlan.QuestFields...); err != nil {
return err
}
}
result = UseStackableResult{
CharacterID: characterID,
AccountID: cmd.AccountID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
InstanceValue: cmd.SourceInstanceValue,
ItemID: stack.ItemID,
RemainingCount: remaining,
Changed: true,
PVFPath: durablePath,
StackableType: resolution.StackableType,
DirectExperienceApplied: true,
DirectExperienceGain: progressionPlan.Gain,
DirectExperiencePreviousLevel: progressionPlan.PreviousLevel,
DirectExperienceNewLevel: progressionPlan.NewLevel,
DirectExperiencePreviousTotal: progressionPlan.PreviousExperience,
DirectExperienceNewTotal: progressionPlan.NewExperience,
DirectExperienceSPGain: progressionPlan.SPGain,
DirectExperienceTPGain: progressionPlan.TPGain,
LevelUpTicketApplied: true,
LevelUpTicketRange: effect.EffectValues[0],
QuestCompletionTicketApplied: questErr == nil,
QuestCompletionAction: "[level up ticket]",
QuestCompletionCount: len(questPlan.CompletedQuestIDs),
}
return nil
})
if err != nil {
return UseStackableResult{}, err
}
return result, nil
}
func (o *Owner) useDirectExperienceConsumable(
ctx context.Context,
cmd Command,
resolution alignedcmd.StackableUseResolution,
planner alignedcmd.DirectExperienceConsumablePlanner,
) (UseStackableResult, error) {
if o == nil || o.settlements == nil || planner == nil || resolution.DirectExperience == nil {
return UseStackableResult{}, ErrDirectExperienceConsumableContract
}
effect := *resolution.DirectExperience
if cmd.SourceListType != listTypeMain || resolution.ItemID != int64(cmd.ItemTemplateID) ||
effect.ItemID != resolution.ItemID || strings.TrimSpace(resolution.PVFPath) == "" ||
!strings.EqualFold(strings.TrimSpace(effect.PVFPath), strings.TrimSpace(resolution.PVFPath)) ||
strings.TrimSpace(effect.EffectType) == "" || len(effect.EffectValues) == 0 {
return UseStackableResult{}, fmt.Errorf("%w: item=%d resolution=%+v", ErrDirectExperienceConsumableContract, cmd.ItemTemplateID, resolution)
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
updatedAt := time.Now().UTC()
var result UseStackableResult
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Skill == nil || tx.Inventory == nil {
return ErrDirectExperienceConsumableContract
}
character, found, err := tx.Character.Load(ctx, characterID)
if err != nil {
return err
}
if !found || character.CharacterID != characterID {
return ErrCharacterRequired
}
skill, found, err := tx.Skill.Load(ctx, characterID)
if err != nil {
return err
}
if !found || skill.CharacterID != characterID {
return ErrDirectExperienceConsumableContract
}
inventory, found, err := tx.Inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
inventory = dnfrepo.CloneInventory(inventory)
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, found := inventory.Slots[key]
if !found || stack.Count <= 0 {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if stack.ItemID != resolution.ItemID {
return fmt.Errorf("%w: list=%d slot=%d want=%d got=%d", ErrItemMismatch, cmd.SourceListType, cmd.SourceSlotIndex, resolution.ItemID, stack.ItemID)
}
durableKind := strings.ToLower(strings.TrimSpace(stack.Extra["item_kind"]))
durablePath := strings.TrimSpace(stack.Extra["pvf_path"])
durableType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(stack.Extra["stackable_type"], "`", "")))
resolvedType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(resolution.StackableType, "`", "")))
if durableKind != "stackable" || durablePath == "" ||
!strings.EqualFold(durablePath, strings.TrimSpace(resolution.PVFPath)) ||
(durableType != "" && durableType != resolvedType) {
return fmt.Errorf("%w: item=%d durable_kind=%q durable_path=%q durable_type=%q resolved_type=%q", ErrDirectExperienceConsumableContract, stack.ItemID, durableKind, durablePath, durableType, resolvedType)
}
plan, err := planner(character, skill, effect, updatedAt)
if err != nil {
return err
}
if plan.Gain == 0 || plan.Character.CharacterID != characterID || plan.Skill.CharacterID != characterID ||
len(plan.CharacterFields) == 0 || plan.PreviousLevel != character.Level ||
plan.PreviousExperience != uint32(character.Stats["exp"]) || plan.NewLevel < plan.PreviousLevel ||
plan.NewExperience <= plan.PreviousExperience {
return ErrDirectExperienceConsumableContract
}
remaining := stack.Count - 1
if remaining == 0 {
delete(inventory.Slots, key)
} else {
stack = cloneStack(stack)
stack.Count = remaining
updateStackRawAmount(&stack)
inventory.Slots[key] = stack
}
inventory.UpdatedAt = updatedAt
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, plan.Character, plan.CharacterFields...); err != nil {
return err
}
if len(plan.SkillFields) > 0 {
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, plan.Skill, plan.SkillFields...); err != nil {
return err
}
}
result = UseStackableResult{
CharacterID: characterID,
AccountID: cmd.AccountID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
InstanceValue: cmd.SourceInstanceValue,
ItemID: stack.ItemID,
RemainingCount: remaining,
Changed: true,
PVFPath: durablePath,
StackableType: resolution.StackableType,
DirectExperienceApplied: true,
DirectExperienceGain: plan.Gain,
DirectExperiencePreviousLevel: plan.PreviousLevel,
DirectExperienceNewLevel: plan.NewLevel,
DirectExperiencePreviousTotal: plan.PreviousExperience,
DirectExperienceNewTotal: plan.NewExperience,
DirectExperienceSPGain: plan.SPGain,
DirectExperienceTPGain: plan.TPGain,
}
return nil
})
if err != nil {
return UseStackableResult{}, err
}
return result, nil
}
// grantQuestCompletionTitles inserts the title-book items unlocked by the
// achievement half of a completion ticket. The achievement owner normally
// writes them into inventory list 100 when its op417 target reaches zero; the
// bulk ticket bypasses that path, so it must materialise the same physical
// titles for the title book to show them.
func grantQuestCompletionTitles(
inventory *dnfrepo.InventoryRecord,
grants []alignedcmd.QuestCompletionTitleGrant,
at time.Time,
) {
if inventory == nil || len(grants) == 0 {
return
}
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
const (
titleListType = 100
titleSlotBase = 1000
titlePerCategory = 200
)
for _, grant := range grants {
if grant.ItemID <= 0 || grant.Category < 0 || grant.Category >= 5 {
continue
}
for offset := int32(0); offset < titlePerCategory; offset++ {
index := grant.BookIndex + offset
if index >= titlePerCategory {
index -= titlePerCategory
}
slot := int16(grant.Category)*titleSlotBase + int16(index)
key := fmt.Sprintf("%d:%d", titleListType, slot)
if existing, found := inventory.Slots[key]; found {
if existing.ItemID == int64(grant.ItemID) {
break
}
continue
}
inventory.Slots[key] = dnfrepo.ItemStack{
ItemID: int64(grant.ItemID),
Count: 1,
Extra: map[string]string{
"source": "quest_completion_ticket",
"quest_id": strconv.FormatInt(grant.QuestID, 10),
},
}
break
}
}
inventory.UpdatedAt = at
}
func (o *Owner) useQuestCompletionTicket(
ctx context.Context,
cmd Command,
resolution alignedcmd.StackableUseResolution,
planner alignedcmd.QuestCompletionTicketPlanner,
) (UseStackableResult, error) {
if o == nil || o.settlements == nil || planner == nil || resolution.QuestCompletionTicket == nil {
return UseStackableResult{}, ErrQuestCompletionTicketContract
}
if cmd.SourceListType != listTypeMain || resolution.ItemID != int64(cmd.ItemTemplateID) ||
strings.TrimSpace(resolution.PVFPath) == "" || strings.TrimSpace(resolution.QuestCompletionTicket.ActionType) == "" {
return UseStackableResult{}, fmt.Errorf("%w: item=%d resolution=%+v", ErrQuestCompletionTicketContract, cmd.ItemTemplateID, resolution)
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
completedAt := time.Now().UTC()
var result UseStackableResult
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Inventory == nil || tx.Quest == nil {
return ErrQuestCompletionTicketContract
}
character, found, err := tx.Character.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrCharacterRequired
}
inventory, found, err := tx.Inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
inventory = dnfrepo.CloneInventory(inventory)
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, found := inventory.Slots[key]
if !found || stack.Count <= 0 {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if stack.ItemID != resolution.ItemID {
return fmt.Errorf("%w: list=%d slot=%d want=%d got=%d", ErrItemMismatch, cmd.SourceListType, cmd.SourceSlotIndex, resolution.ItemID, stack.ItemID)
}
durablePVFPath := strings.TrimSpace(stack.Extra["pvf_path"])
if durablePVFPath == "" || !strings.EqualFold(durablePVFPath, strings.TrimSpace(resolution.PVFPath)) {
return fmt.Errorf("%w: item=%d durable_path=%q resolved_path=%q", ErrQuestCompletionTicketContract, stack.ItemID, durablePVFPath, resolution.PVFPath)
}
questRecord, questFound, err := tx.Quest.Load(ctx, characterID)
if err != nil {
return err
}
if !questFound {
questRecord = dnfrepo.QuestRecord{CharacterID: characterID}
}
plan, err := planner(character, questRecord, *resolution.QuestCompletionTicket, completedAt)
if err != nil {
return err
}
if len(plan.CompletedQuestIDs) == 0 || len(plan.QuestFields) == 0 || strings.TrimSpace(plan.Quest.CharacterID) != characterID {
return ErrQuestCompletionTicketContract
}
remaining := stack.Count - 1
if remaining == 0 {
delete(inventory.Slots, key)
} else {
stack = cloneStack(stack)
stack.Count = remaining
inventory.Slots[key] = stack
}
inventory.UpdatedAt = completedAt
grantQuestCompletionTitles(&inventory, plan.TitleGrants, completedAt)
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
if err := dnfrepo.SaveQuestFields(ctx, tx.Quest, plan.Quest, plan.QuestFields...); err != nil {
return err
}
if len(plan.CharacterFields) > 0 {
if strings.TrimSpace(plan.Character.CharacterID) != characterID {
return ErrQuestCompletionTicketContract
}
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, plan.Character, plan.CharacterFields...); err != nil {
return err
}
}
result = UseStackableResult{
CharacterID: characterID,
AccountID: cmd.AccountID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
InstanceValue: cmd.SourceInstanceValue,
ItemID: stack.ItemID,
RemainingCount: remaining,
Changed: true,
PVFPath: durablePVFPath,
StackableType: resolution.StackableType,
QuestCompletionTicketApplied: true,
QuestCompletionAction: resolution.QuestCompletionTicket.ActionType,
QuestCompletionCount: len(plan.CompletedQuestIDs),
QuestCompletionProfessionChange: plan.ProfessionChanged,
}
return nil
})
if err != nil {
return UseStackableResult{}, err
}
return result, nil
}
func (o *Owner) usePetFeed(
ctx context.Context,
cmd Command,
resolution alignedcmd.StackableUseResolution,
) (UseStackableResult, error) {
if o.petItems == nil || o.pets == nil {
return UseStackableResult{}, ErrPetInventoryOwnerRequired
}
normalizedType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(resolution.StackableType, "`", "")))
if resolution.ItemID != int64(cmd.ItemTemplateID) ||
strings.TrimSpace(resolution.PVFPath) == "" ||
normalizedType != "[feed]" ||
resolution.PetFeedEffectDurationSeconds <= 0 ||
resolution.PetFeedSkillChargePercent >= 0 ||
resolution.PetFeedSatietyDelta <= 0 || resolution.PetFeedSatietyDelta > 100 {
return UseStackableResult{}, fmt.Errorf("%w: item=%d resolution=%+v", ErrPetFeedContractRequired, cmd.ItemTemplateID, resolution)
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
var result UseStackableResult
err := dnfrepo.WithinCharacterPetsScoped(ctx, o.petItems, characterID, func(
inventoryRepo dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
petRepo dnfrepo.PetRepository,
) error {
inventoryRecord, found, err := inventoryRepo.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
inventoryRecord = dnfrepo.CloneInventory(inventoryRecord)
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, found := inventoryRecord.Slots[key]
if !found || stack.Count <= 0 {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if stack.ItemID != int64(cmd.ItemTemplateID) {
return fmt.Errorf(
"%w: list=%d slot=%d want=%d got=%d",
ErrItemMismatch,
cmd.SourceListType,
cmd.SourceSlotIndex,
cmd.ItemTemplateID,
stack.ItemID,
)
}
durableKind := strings.ToLower(strings.TrimSpace(stack.Extra["item_kind"]))
durablePath := strings.TrimSpace(stack.Extra["pvf_path"])
durableType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(stack.Extra["stackable_type"], "`", "")))
if durableKind != "stackable" || durablePath == "" ||
!strings.EqualFold(durablePath, strings.TrimSpace(resolution.PVFPath)) ||
(durableType != "" && durableType != "[feed]") {
return fmt.Errorf(
"%w: list=%d slot=%d item=%d durable_kind=%q durable_path=%q durable_type=%q",
ErrPetFeedContractRequired,
cmd.SourceListType,
cmd.SourceSlotIndex,
stack.ItemID,
durableKind,
durablePath,
durableType,
)
}
petRecord, found, err := petRepo.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrPetFeedEquippedCreatureRequired
}
petRecord = dnfrepo.ClonePet(petRecord)
petKey := strings.TrimSpace(petRecord.EquippedKey)
entry, found := petRecord.Entries[petKey]
if petKey == "" || !found || entry.ItemID <= 0 || entry.SourceListType != listTypeEquipment || entry.SourceSlotIndex != 26 {
return fmt.Errorf("%w: equipped_key=%q", ErrPetFeedEquippedCreatureRequired, petKey)
}
feedUpdate, err := creaturestate.ApplyFeed(entry, resolution.PetFeedSatietyDelta)
if err != nil {
return err
}
remaining := stack.Count - 1
if remaining == 0 {
delete(inventoryRecord.Slots, key)
} else {
stack = cloneStack(stack)
stack.Count = remaining
updateStackRawAmount(&stack)
inventoryRecord.Slots[key] = stack
}
now := time.Now()
inventoryRecord.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, inventoryRecord, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
if feedUpdate.Changed {
entry.Satiety = feedUpdate.AfterSatiety
entry.SatietyMicros = feedUpdate.AfterSatietyMicros
petRecord.Entries[petKey] = entry
petRecord.UpdatedAt = now
if err := dnfrepo.SavePetFields(ctx, petRepo, petRecord, dnfrepo.PetFieldEntries); err != nil {
return err
}
}
result = UseStackableResult{
CharacterID: characterID,
AccountID: cmd.AccountID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
InstanceValue: cmd.SourceInstanceValue,
ItemID: stack.ItemID,
RemainingCount: remaining,
Changed: true,
PVFPath: resolution.PVFPath,
StackableType: resolution.StackableType,
PetFeedApplied: true,
PetFeedPetKey: petKey,
PetFeedSatietyBefore: int64(feedUpdate.BeforeSatiety),
PetFeedSatietyAfter: int64(feedUpdate.AfterSatiety),
PetFeedSatietyDelta: int64(feedUpdate.AppliedDelta),
PetFeedEffectDurationSeconds: resolution.PetFeedEffectDurationSeconds,
PetFeedSkillChargePercent: resolution.PetFeedSkillChargePercent,
}
return nil
})
return result, err
}
func (o *Owner) useExperienceConsumable(
ctx context.Context,
cmd Command,
resolution alignedcmd.ExperienceConsumableResolution,
) (UseStackableResult, error) {
if o.rentalAssets == nil {
return UseStackableResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(cmd.AccountID)
if accountID == "" {
return UseStackableResult{}, ErrAccountRequired
}
if resolution.ItemID != int64(cmd.ItemTemplateID) || resolution.BonusPercent <= 0 ||
resolution.DurationSeconds <= 0 || strings.TrimSpace(resolution.PVFPath) == "" {
return UseStackableResult{}, ErrExperienceConsumableResolveFailed
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
var result UseStackableResult
err := dnfrepo.WithinRentalAssetsScoped(ctx, o.rentalAssets, accountID, characterID, func(accounts dnfrepo.AccountRepository, _ dnfrepo.CharacterRepository, inventoryRepo dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
record, ok, err := inventoryRepo.Load(ctx, characterID)
if err != nil {
return err
}
if !ok {
return ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, ok := record.Slots[key]
if !ok || stack.Count <= 0 {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if stack.ItemID != int64(cmd.ItemTemplateID) {
return fmt.Errorf("%w: list=%d slot=%d want=%d got=%d", ErrItemMismatch, cmd.SourceListType, cmd.SourceSlotIndex, cmd.ItemTemplateID, stack.ItemID)
}
pvfPath, stackableType, reusable, proven := pvfProvenExperienceStack(stack)
if !proven || !strings.EqualFold(strings.TrimSpace(pvfPath), strings.TrimSpace(resolution.PVFPath)) {
return ErrUseStackableContractRequired
}
if reusable != resolution.Reusable {
return ErrUseStackableContractRequired
}
account, found, err := accounts.Load(ctx, accountID)
if err != nil {
return err
}
if !found {
return ErrAccountNotFound
}
account = dnfrepo.CloneAccount(account)
now := time.Now().UTC()
effect, err := experiencebonus.ActivateForCharacter(
&account,
characterID,
resolution.ItemID,
resolution.BonusPercent,
time.Duration(resolution.DurationSeconds)*time.Second,
now,
)
if err != nil {
return err
}
remaining := stack.Count
if !resolution.Reusable {
remaining--
if remaining == 0 {
delete(record.Slots, key)
} else {
stack = cloneStack(stack)
stack.Count = remaining
record.Slots[key] = stack
}
record.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, record, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
account.UpdatedAt = now
if err := accounts.Save(ctx, account); err != nil {
return err
}
result = UseStackableResult{
CharacterID: characterID,
AccountID: accountID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
InstanceValue: cmd.SourceInstanceValue,
ItemID: stack.ItemID,
RemainingCount: remaining,
Changed: true,
PVFPath: pvfPath,
StackableType: stackableType,
ExperienceBonusActivated: true,
ExperienceBonusPercent: effect.BonusPercent,
ExperienceBonusRemainingSeconds: int64(effect.ExpireAt.Sub(now).Seconds()),
}
return nil
})
return result, err
}
// UseSharedEffectItem handles the current class-1/type251 shared-effect item
// route. The request carries its source item template and source slot, both of
// which must match durable inventory before the runtime PVF result table can
// authorize any mutation.
func (o *Owner) UseSharedEffectItem(ctx context.Context, cmd Command, resolver alignedcmd.RandomRewardItemResolver) (UseStackableResult, error) {
if o == nil || o.repo == nil {
return UseStackableResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return UseStackableResult{}, ErrCharacterRequired
}
if err := checkUseStackableListType(cmd.SourceListType); err != nil {
return UseStackableResult{}, err
}
if cmd.ItemTemplateID <= 0 {
return UseStackableResult{}, fmt.Errorf("%w: item=%d", ErrUseStackableContractRequired, cmd.ItemTemplateID)
}
if resolver == nil {
return UseStackableResult{}, fmt.Errorf("%w: type251 resolver unavailable", ErrRandomRewardResolveFailed)
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
if !o.inItemTransaction {
var result UseStackableResult
err := o.withinInventoryTransaction(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.UseSharedEffectItem(ctx, cmd, resolver)
return err
})
return result, err
}
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return UseStackableResult{}, err
}
if !ok {
return UseStackableResult{}, ErrInventoryNotFound
}
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
source, ok := record.Slots[key]
if !ok || source.ItemID <= 0 || source.Count <= 0 {
return UseStackableResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if source.ItemID != int64(cmd.ItemTemplateID) {
return UseStackableResult{}, fmt.Errorf(
"%w: list=%d slot=%d item want=%d got=%d",
ErrItemMismatch,
cmd.SourceListType,
cmd.SourceSlotIndex,
cmd.ItemTemplateID,
source.ItemID,
)
}
resolution, err := resolver(source.ItemID)
if err != nil {
return UseStackableResult{}, fmt.Errorf("%w: item=%d: %v", ErrRandomRewardResolveFailed, source.ItemID, err)
}
if resolution.SourceItemID == 0 {
return UseStackableResult{}, fmt.Errorf("%w: item=%d", ErrRandomRewardContractRequired, source.ItemID)
}
return o.useRandomRewardItem(ctx, cmd, resolution)
}
func (o *Owner) useRandomRewardItem(ctx context.Context, cmd Command, resolution alignedcmd.RandomRewardItemResolution) (UseStackableResult, error) {
if resolution.SourceItemID != int64(cmd.ItemTemplateID) || strings.TrimSpace(resolution.SourcePVFPath) == "" ||
!strings.EqualFold(strings.Trim(resolution.StackableType, "` []"), "random reward item") || len(resolution.Outcomes) == 0 {
return UseStackableResult{}, fmt.Errorf("%w: item=%d", ErrRandomRewardContractRequired, cmd.ItemTemplateID)
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
if !o.inItemTransaction {
var result UseStackableResult
err := o.withinInventoryTransaction(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.useRandomRewardItem(ctx, cmd, resolution)
return err
})
return result, err
}
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return UseStackableResult{}, err
}
if !ok {
return UseStackableResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
source, ok := record.Slots[key]
if !ok || source.Count <= 0 {
return UseStackableResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if source.ItemID != int64(cmd.ItemTemplateID) {
return UseStackableResult{}, fmt.Errorf("%w: list=%d slot=%d want=%d got=%d", ErrItemMismatch, cmd.SourceListType, cmd.SourceSlotIndex, cmd.ItemTemplateID, source.ItemID)
}
outcome, err := chooseRandomRewardItemOutcome(resolution.Outcomes)
if err != nil {
return UseStackableResult{}, err
}
remaining := source.Count - 1
if remaining == 0 {
delete(record.Slots, key)
} else {
source = cloneStack(source)
source.Count = remaining
updateStackRawAmount(&source)
record.Slots[key] = source
}
result := UseStackableResult{
CharacterID: characterID,
AccountID: cmd.AccountID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
InstanceValue: cmd.SourceInstanceValue,
ItemID: int64(cmd.ItemTemplateID),
RemainingCount: remaining,
Changed: true,
PVFPath: resolution.SourcePVFPath,
StackableType: resolution.StackableType,
}
if outcome.Reward.ItemID > 0 {
slots, err := grantRandomRewardItem(record.Slots, outcome.Reward, 1)
if err != nil {
return UseStackableResult{}, err
}
result.RandomRewardItemID = outcome.Reward.ItemID
result.RandomRewardSlots = slots
}
record.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, dnfrepo.InventoryFieldSlots); err != nil {
return UseStackableResult{}, err
}
return result, nil
}
func chooseRandomRewardItemOutcome(outcomes []alignedcmd.RandomRewardItemOutcome) (alignedcmd.RandomRewardItemOutcome, error) {
var total int64
for _, outcome := range outcomes {
if outcome.Weight <= 0 {
continue
}
if total > int64(^uint64(0)>>1)-outcome.Weight {
return alignedcmd.RandomRewardItemOutcome{}, ErrRandomRewardContractRequired
}
total += outcome.Weight
}
if total <= 0 {
return alignedcmd.RandomRewardItemOutcome{}, ErrRandomRewardContractRequired
}
roll := rand.Int63n(total)
for _, outcome := range outcomes {
if outcome.Weight <= 0 {
continue
}
if roll < outcome.Weight {
return outcome, nil
}
roll -= outcome.Weight
}
return alignedcmd.RandomRewardItemOutcome{}, ErrRandomRewardContractRequired
}
func grantRandomRewardItem(items map[string]dnfrepo.ItemStack, reward alignedcmd.MagicBoxRewardItem, count int64) ([]int16, error) {
if count <= 0 {
return nil, nil
}
// 契约校验分两段:先做与 kind 无关的共同项,再按 kind 分派。这样"装备行"不会
// 把可叠加那一支的条件放松掉,可叠加那一支的行为与改动前逐字相同。
if reward.ItemID <= 0 || reward.TargetListType != listTypeMain || reward.SlotStart < 0 || reward.SlotEnd < reward.SlotStart {
return nil, fmt.Errorf("%w: item=%d kind=%q target_list=%d slots=%d..%d", ErrRandomRewardContractRequired, reward.ItemID, reward.Kind, reward.TargetListType, reward.SlotStart, reward.SlotEnd)
}
// 真档奖池是**混合表**:塔罗牌 [int data] 里既有可叠加物(光之根源),也有装备
// (血红战斧 / 阿努比斯陶瓷长靴 …)。装备行过去被下面这个 kind 判断整体拒掉,
// 而奖池的抽取又是按权重随机的,于是"抽到装备就等于什么都没发生"。
if strings.EqualFold(strings.TrimSpace(reward.Kind), "equipment") {
return grantRandomRewardEquipment(items, reward, count)
}
if reward.Kind != "stackable" {
return nil, fmt.Errorf("%w: item=%d kind=%q target_list=%d slots=%d..%d", ErrRandomRewardContractRequired, reward.ItemID, reward.Kind, reward.TargetListType, reward.SlotStart, reward.SlotEnd)
}
// Revive coins go to the fixed slot-1 wallet like every other channel;
// the sibling useStackableWithResolvers path already does this.
if dnfrevivecoin.IsReward(reward.ItemID) {
if _, _, err := dnfrevivecoin.GrantSlots(items, count, "revive_coin_random_reward"); err != nil {
return nil, err
}
return []int16{dnfrevivecoin.WalletSlot}, nil
}
remaining := count
// PVF [stack limit] 同时是单行上限和"这个容器里该道具的总量上限":先按容器总量算出
// 还能收多少,并且在容器里已经有该道具的行时只并进那一行,不再开第二行。
space := dnfrepo.ContainerItemSpace(items, listTypeMain, reward.ItemID, reward.StackLimit)
changed := make([]int16, 0, 2)
markChanged := func(slot int16) {
for _, previous := range changed {
if previous == slot {
return
}
}
changed = append(changed, slot)
}
for slot := reward.SlotStart; slot <= reward.SlotEnd && remaining > 0; slot++ {
key := slotKey(listTypeMain, slot)
stack, found := items[key]
if !found || stack.ItemID != reward.ItemID || stack.Count <= 0 || reward.StackLimit == 1 || !randomRewardExpirationMatches(stack, reward.ExpireAt) {
continue
}
capacity := remaining
if reward.StackLimit > 0 {
capacity = reward.StackLimit - stack.Count
}
if capacity > space {
capacity = space
}
if capacity <= 0 {
continue
}
add := remaining
if add > capacity {
add = capacity
}
stack = cloneStack(stack)
stack.Count += add
updateStackRawAmount(&stack)
items[key] = stack
remaining -= add
space -= add
markChanged(slot)
}
for slot := reward.SlotStart; slot <= reward.SlotEnd && remaining > 0; slot++ {
key := slotKey(listTypeMain, slot)
if _, occupied := items[key]; occupied {
continue
}
if !dnfrepo.CanOpenContainerItemRow(items, listTypeMain, reward.ItemID, reward.StackLimit) {
break
}
insert := remaining
if reward.StackLimit > 0 && insert > reward.StackLimit {
insert = reward.StackLimit
}
if insert > space {
insert = space
}
if insert <= 0 {
break
}
stack := dnfrepo.ItemStack{
ItemID: reward.ItemID,
Count: insert,
ExpireAt: reward.ExpireAt,
Extra: map[string]string{
"item_kind": "stackable",
"pvf_path": reward.PVFPath,
},
}
if !reward.ExpireAt.IsZero() && reward.ExpireAt.Unix() > 0 {
expire := strconv.FormatInt(reward.ExpireAt.Unix(), 10)
stack.Extra["expire_time"] = expire
stack.Extra["expire_unix"] = expire
}
if reward.UsablePeriodDays > 0 {
stack.Extra["usable_period_days"] = strconv.FormatInt(reward.UsablePeriodDays, 10)
stack.Extra["expiration_source"] = "runtime_pvf_usable_period_grant"
}
updateStackRawAmount(&stack)
items[key] = stack
remaining -= insert
space -= insert
markChanged(slot)
}
if remaining > 0 {
return nil, fmt.Errorf("%w: item=%d remaining=%d", ErrRandomRewardInventoryFull, reward.ItemID, remaining)
}
return changed, nil
}
// grantRandomRewardEquipment 发放一件**装备**奖励:一格一件、绝不叠加。
//
// 现场 2026-10-03(服主报「塔罗牌用不了」):真档奖池是混合表 ——
// stackable/10159001/10159717.stk(暗之偶像阿努比斯 塔罗牌)的
//
// [int data]
// 108040415 175000 1 100270516 175000 1 114030107 175000 1 10159142 300000 250
// [/int data]
//
// 前三条是装备(血红战斧 / 阿努比斯陶瓷长靴 / 战戟),第四条才是可叠加的「光之根源」。
// 装备行以前被 grantRandomRewardItem 的 kind 判断整体拒掉,抽到装备就等于**什么都没发生**。
//
// 与可叠加那一支的三点差别,逐条都有出处:
//
// 1. **不合并**。装备的 PVF 没有 [stack limit],ContainerSpace 那边按"不受限"处理;
// 但客户端把装备画成一格一件,把 count 并进同一行会让它变成叠加物。所以这里
// 一件占一格,count > 1(真档不会出现,但契约上允许)就开多行。
// 2. **行内容按装备投影**:item_kind=equipment + pvf_path + equipment_type,RawEntry
// 用仓库自己那条 currentRawEntryForStack 生成 —— 与
// owner_equipment_regeneration.go 发装备、booster_projection.go 的发宠物装备同一写法。
// 3. **耐久**。PVF 的 [durability] 是**满耐久**;不写进去客户端会把新装备画成 0 耐久
// (writeCommonItemListEntry 的 +0x0B 直接取 Extra["durability"])。
func grantRandomRewardEquipment(items map[string]dnfrepo.ItemStack, reward alignedcmd.MagicBoxRewardItem, count int64) ([]int16, error) {
changed := make([]int16, 0, 2)
remaining := count
for slot := reward.SlotStart; slot <= reward.SlotEnd && remaining > 0; slot++ {
key := slotKey(listTypeMain, slot)
if _, occupied := items[key]; occupied {
continue
}
extra := map[string]string{
"source": "random_reward_equipment",
"item_kind": "equipment",
"pvf_path": reward.PVFPath,
"equipment_type": reward.EquipmentType,
"amount_or_count": "0",
}
if reward.Seal {
extra["seal_flag"] = "1"
}
if reward.Durability > 0 {
durability := strconv.FormatUint(uint64(reward.Durability), 10)
extra["durability"] = durability
extra["max_durability"] = durability
}
if !reward.ExpireAt.IsZero() && reward.ExpireAt.Unix() > 0 {
expire := strconv.FormatInt(reward.ExpireAt.Unix(), 10)
extra["expire_time"] = expire
extra["expire_unix"] = expire
}
if reward.UsablePeriodDays > 0 {
extra["usable_period_days"] = strconv.FormatInt(reward.UsablePeriodDays, 10)
extra["expiration_source"] = "runtime_pvf_usable_period_grant"
}
stack := dnfrepo.ItemStack{
ItemID: reward.ItemID,
Count: 1,
ExpireAt: reward.ExpireAt,
Extra: extra,
}
// 品级种子由解析方掷好传进来(见 alignedcmd.MagicBoxRewardItem.QualitySeed)。
// 不写的话 raw+0x06 会落到投影的 Count 兜底(=1),客户端把奖励装备画成"最下级"。
if reward.QualitySeed != 0 {
_ = itemquality.Apply(&stack, reward.QualitySeed)
}
stack.RawEntry = currentRawEntryForStack(slot, stack)
items[key] = stack
changed = append(changed, slot)
remaining--
}
if remaining > 0 {
return nil, fmt.Errorf("%w: item=%d remaining=%d", ErrRandomRewardInventoryFull, reward.ItemID, remaining)
}
return changed, nil
}
func randomRewardExpirationMatches(stack dnfrepo.ItemStack, expiration time.Time) bool {
want := int64(0)
if !expiration.IsZero() && expiration.Unix() > 0 {
want = expiration.Unix()
}
got := int64(0)
for _, key := range []string{"expire_time", "expire_unix"} {
value, err := strconv.ParseInt(strings.TrimSpace(stack.Extra[key]), 10, 64)
if err == nil && value > 0 {
got = value
break
}
}
if got == 0 && !stack.ExpireAt.IsZero() && stack.ExpireAt.Unix() > 0 {
got = stack.ExpireAt.Unix()
}
return got == want
}
// usePremiumContract activates one premiumlist_new.etc contract item: consume
// exactly one stack item and upsert the account-level premium expiry in the
// same rental-asset transaction, so a failure can never lose the item without
// the contract or grant the contract without the item. The resolver output is
// the only authority for type/duration; request and inventory-Extra metadata
// are never trusted.
func (o *Owner) usePremiumContract(ctx context.Context, cmd Command, resolution alignedcmd.PremiumContractResolution) (UseStackableResult, error) {
if o.rentalAssets == nil {
return UseStackableResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(cmd.AccountID)
if accountID == "" {
return UseStackableResult{}, ErrAccountRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
var result UseStackableResult
err := dnfrepo.WithinRentalAssetsScoped(ctx, o.rentalAssets, accountID, characterID, func(accounts dnfrepo.AccountRepository, _ dnfrepo.CharacterRepository, inventoryRepo dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
record, ok, err := inventoryRepo.Load(ctx, characterID)
if err != nil {
return err
}
if !ok {
return ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
if record.Slots == nil {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, ok := record.Slots[key]
if !ok || stack.Count <= 0 {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
if stack.ItemID != int64(cmd.ItemTemplateID) {
return fmt.Errorf(
"%w: list=%d slot=%d want=%d got=%d",
ErrItemMismatch,
cmd.SourceListType,
cmd.SourceSlotIndex,
cmd.ItemTemplateID,
stack.ItemID,
)
}
account, found, err := accounts.Load(ctx, accountID)
if err != nil {
return err
}
if !found {
return ErrAccountNotFound
}
account = dnfrepo.CloneAccount(account)
remaining := stack.Count - 1
if remaining == 0 {
delete(record.Slots, key)
} else {
stack = cloneStack(stack)
stack.Count = remaining
record.Slots[key] = stack
}
now := time.Now()
record.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, record, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
premium.Upsert(&account, resolution.PremiumType, resolution.DurationSeconds, 1, now)
account.UpdatedAt = now
if err := accounts.Save(ctx, account); err != nil {
return err
}
activations := make([]PremiumActivation, 0, len(premium.ActivationTypes(resolution.PremiumType)))
for _, activationType := range premium.ActivationTypes(resolution.PremiumType) {
activations = append(activations, PremiumActivation{
PremiumType: activationType,
RemainingSeconds: premium.ExpireAt(account, activationType) - now.Unix(),
})
}
result = UseStackableResult{
CharacterID: characterID,
AccountID: accountID,
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
InstanceValue: cmd.SourceInstanceValue,
ItemID: stack.ItemID,
RemainingCount: remaining,
Changed: true,
PremiumActivated: true,
PremiumType: resolution.PremiumType,
PremiumRemainingSeconds: premium.ExpireAt(account, resolution.PremiumType) - now.Unix(),
PremiumActivations: activations,
}
return nil
})
return result, err
}
func newUpgradeResult(cmd Command, errorCode byte) UpgradeResult {
return UpgradeResult{
Mode: cmd.Mode,
ErrorCode: errorCode,
TargetListType: cmd.TargetListType,
TargetSlotIndex: cmd.TargetSlotIndex,
TargetItemTemplateID: cmd.TargetItemTemplateID,
MaterialSlotIndex: cmd.MaterialSlotIndex,
OptionalTicketSlotIndex: cmd.OptionalTicketSlot,
ProtectionTicketSlotIndex: -1,
}
}
func isUpgradeableEquipmentStack(stack dnfrepo.ItemStack) bool {
if stack.ItemID <= 0 || stack.Count <= 0 {
return false
}
kind := strings.ToLower(strings.TrimSpace(stack.Extra["item_kind"]))
if kind == "" {
kind = strings.ToLower(strings.TrimSpace(stack.Extra["kind"]))
}
switch kind {
case "equipment":
// 装备一件一行:PVF 对 equipment 一律按单件处理,Count != 1 只可能是历史脏数据
// (魔盒发奖把 N 件同名装备并进一格,现场 2026-09-27 线上 90 行 357 件)。
// 强化/强化券是单件语义入口,一次费用只能作用一件;放整行进来就会
// 「一次强化把 N 件全部 +1」。
return stack.Count == 1
case "", "unknown":
return stack.Count == 1
default:
return false
}
}
// isEquipmentKindStack 判断这一行是不是装备类行(只看 kind,不看数量)。
func isEquipmentKindStack(stack dnfrepo.ItemStack) bool {
kind := strings.ToLower(strings.TrimSpace(stack.Extra["item_kind"]))
if kind == "" {
kind = strings.ToLower(strings.TrimSpace(stack.Extra["kind"]))
}
return kind == "equipment"
}
// equipmentStackCountIsSingle 是「单件语义」入口的统一护栏:装备一件一行。
//
// 为什么必须有它:线上存在 Extra.item_kind=equipment 且 Count=2..49 的历史堆叠行
// (根因见魔盒发奖 grantMagicBoxReward,2026-09-27 现场 90 行 357 件)。客户端装备行的
// 0x77 里 0x06 字段是品质种子而不是数量,玩家在界面上看不见 N;于是「一份材料/一张券/
// 一次费用」的操作(强化、强化券、锻造、附魔、符文)会被作用到整行 N 件上。
// 非装备行一律放行 —— 本护栏只针对装备。
func equipmentStackCountIsSingle(stack dnfrepo.ItemStack) bool {
if !isEquipmentKindStack(stack) {
return true
}
return stack.Count == 1
}
func upgradeDurabilityOK(stack dnfrepo.ItemStack) bool {
if stack.Extra == nil {
return true
}
maxRaw := strings.TrimSpace(stack.Extra["max_durability"])
if maxRaw == "" {
return true
}
maxValue, err := strconv.ParseInt(maxRaw, 0, 64)
if err != nil || maxValue < 0 {
return false
}
currentRaw := strings.TrimSpace(stack.Extra["durability"])
if currentRaw == "" {
return maxValue == 0
}
current, err := strconv.ParseInt(currentRaw, 0, 64)
if err != nil || current < 0 {
return false
}
return current == maxValue
}
func upgradeCommandRequiresAccountMaterial(cmd Command) bool {
itemID, _, ok := upgradeMaterialRequirement(cmd)
if !ok {
return dnfrepo.IsAccountSharedInventorySlot(listTypeMain, cmd.MaterialSlotIndex)
}
_, accountOwned := upgradeAccountSharedMaterialSlot(itemID)
return accountOwned
}
// upgradeDestroyRefundsWarehouse reports whether any destruction refund row
// resolved for this attempt owns a fixed account-shared warehouse slot.
func upgradeDestroyRefundsWarehouse(cmd Command) bool {
if cmd.UpgradeDestroyBonusItemID > 0 && cmd.UpgradeDestroyBonusCount > 0 {
if _, warehouse := upgradeAccountSharedMaterialSlot(int64(cmd.UpgradeDestroyBonusItemID)); warehouse {
return true
}
}
for _, extra := range cmd.UpgradeDestroyExtraRewards {
if extra.ItemID <= 0 || extra.Count <= 0 {
continue
}
if _, warehouse := upgradeAccountSharedMaterialSlot(int64(extra.ItemID)); warehouse {
return true
}
}
return false
}
func upgradeMaterialRequirement(cmd Command) (int64, int64, bool) {
itemID := int64(cmd.UpgradeMaterialItemID)
count := int64(cmd.UpgradeMaterialCount)
if itemID <= 0 || count <= 0 {
return 0, 0, false
}
return itemID, count, true
}
// upgradeAccountSharedMaterialSlot 给出该道具在账号共享晶体/灵魂仓库里的固定槽。
// 这里过去是一份手写的 12 条 switch 副本,与 dnfbridge 的两处副本各自独立 ——
// 三份表任何一处漏改都会让某条途径把道具落进角色背包(2026-09-21 NPC 商店金币
// 购买的无色落在 0:154 就是这条链路的产物)。现统一走权威表
// (repository/account_shared_materials.go)。运营口径见该文件。
func upgradeAccountSharedMaterialSlot(itemID int64) (int16, bool) {
return dnfrepo.AccountSharedWarehouseSlotForItem(itemID)
}
func upgradeProtectionSlotAllowed(slot int16) bool {
return (slot >= 3 && slot <= 8) || (slot >= 121 && slot <= 176)
}
// firstUpgradeProtectionTicket mirrors the native/reference normal-upgrade
// owner: op50 commonly carries optional slot -1, so a destructive failure
// searches quick slots first and then the material page. Only the first live
// PVF-proved ticket for the exact reinforcement/amplification mode is selected.
// The caller consumes it in the same character-asset transaction as the
// target, upgrade material and wallet mutation.
func (o *Owner) firstUpgradeProtectionTicket(items map[string]dnfrepo.ItemStack, mode string, targetSlot int16, materialSlot int16) (int16, dnfrepo.ItemStack, alignedcmd.UpgradeProtectionResolution, bool, error) {
if o == nil || o.upgradeProtection == nil {
return -1, dnfrepo.ItemStack{}, alignedcmd.UpgradeProtectionResolution{}, false, nil
}
// The ticket's [expiration date] is deliberately not consulted: see the
// explicit-slot path above. Most of this PVF's protection tickets are dead
// 2010/2015 event items, nothing else on this server acts on item expiry,
// and the client promises protection from the bag count alone.
for _, bounds := range [][2]int16{{3, 8}, {121, 176}} {
for slot := bounds[0]; slot <= bounds[1]; slot++ {
if slot == targetSlot || slot == materialSlot {
continue
}
stack, found := items[slotKey(listTypeMain, slot)]
if !found || stack.ItemID <= 0 || stack.Count <= 0 {
continue
}
resolution, err := o.upgradeProtection(stack.ItemID)
if err != nil {
return -1, dnfrepo.ItemStack{}, alignedcmd.UpgradeProtectionResolution{}, false, err
}
if !strings.EqualFold(strings.TrimSpace(resolution.TicketMode), mode) {
continue
}
if resolution.FailureRetainLevel < 0 || resolution.FailureRetainLevel > 30 {
return -1, dnfrepo.ItemStack{}, alignedcmd.UpgradeProtectionResolution{}, false, fmt.Errorf("invalid upgrade protection retain level item=%d level=%d", stack.ItemID, resolution.FailureRetainLevel)
}
return slot, stack, resolution, true, nil
}
}
return -1, dnfrepo.ItemStack{}, alignedcmd.UpgradeProtectionResolution{}, false, nil
}
// firstEmptyUpgradeSlot finds the first empty main-inventory slot for destroy
// grantUpgradeDestroyWarehouseBonus stacks one destruction refund into the
// item's single fixed account-shared warehouse slot and returns the unplaced
// remainder, which the caller sends to the backpack so no refund is lost.
// The twelve warehouse stackables (six cube fragments, six souls) are the
// same set the 分解机 places there, and a blown-up piece refunds exactly what
// disassembling it would have: the destination must therefore be the same.
// A foreign stack squatting on the fixed slot is never merged into or
// evicted -- that row goes to the backpack instead.
func (o *Owner) grantUpgradeDestroyWarehouseBonus(
account *dnfrepo.AccountInventoryRecord,
slot int16,
itemID int64,
count int64,
) int64 {
if o == nil || account == nil || itemID <= 0 || count <= 0 {
return count
}
if account.Slots == nil {
account.Slots = make(map[string]dnfrepo.ItemStack)
}
key := dnfrepo.AccountSharedInventorySlotKey(slot)
stack, exists := account.Slots[key]
if exists && stack.ItemID > 0 && stack.ItemID != itemID {
return count
}
room := count
if o.stackMerge != nil {
if resolution, err := o.stackMerge(itemID); err == nil &&
resolution.ItemID == itemID && resolution.StackLimit > 0 {
if space := resolution.StackLimit - stack.Count; space < room {
room = space
}
}
}
if room <= 0 {
return count
}
if !exists || stack.ItemID <= 0 {
stack = o.canonicalStackForMove(dnfrepo.ItemStack{
ItemID: itemID,
Extra: map[string]string{"source": "upgrade_destroy_bonus"},
})
} else {
stack = cloneStack(stack)
}
stack.Count += room
updateStackRawAmount(&stack)
account.Slots[key] = stack
return count - room
}
func (o *Owner) grantUpgradeDestroyBonus(items map[string]dnfrepo.ItemStack, itemID int64, count int64) ([]int16, error) {
if itemID <= 0 || count <= 0 {
return nil, ErrUpgradeDestroyBonusContract
}
// Preserve the historical isolated-domain behavior when no runtime PVF
// resolver is installed. The live op50 route always supplies the resolver.
if o == nil || o.stackMerge == nil {
destroySlot := firstEmptyUpgradeSlot(items)
if destroySlot < 0 {
return nil, ErrUpgradeDestroyBonusInventoryFull
}
bonusStack := dnfrepo.ItemStack{
ItemID: itemID,
Count: count,
Extra: map[string]string{"source": "upgrade_destroy_bonus"},
}
updateStackRawAmount(&bonusStack)
items[slotKey(listTypeMain, destroySlot)] = bonusStack
return []int16{destroySlot}, nil
}
resolution, err := o.stackMerge(itemID)
if err != nil || resolution.ItemID != itemID || resolution.StackLimit < 0 ||
!strings.EqualFold(strings.TrimSpace(resolution.Kind), "stackable") ||
resolution.SlotStart < 0 || resolution.SlotEnd < resolution.SlotStart {
return nil, fmt.Errorf(
"%w: item=%d kind=%q slots=%d..%d: %v",
ErrUpgradeDestroyBonusContract,
itemID,
resolution.Kind,
resolution.SlotStart,
resolution.SlotEnd,
err,
)
}
pvfPath := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(resolution.PVFPath, "\\", "/")))
if !strings.HasPrefix(pvfPath, "stackable/") || strings.Contains(pvfPath, "../") {
return nil, fmt.Errorf("%w: item=%d pvf_path=%q", ErrUpgradeDestroyBonusContract, itemID, resolution.PVFPath)
}
remaining := count
// PVF [stack limit] 同时是单行上限和该容器里这道具的总量上限:先算容器还能收多少,
// 有现有行就只能并进去,没有再考虑空格。PVF 未声明上限(StackLimit==0)时
// dnfrepo 返回哨兵额度,行为与以前一致。
space := dnfrepo.ContainerItemSpace(items, listTypeMain, itemID, resolution.StackLimit)
changed := make([]int16, 0, 2)
markChanged := func(slot int16) {
for _, existing := range changed {
if existing == slot {
return
}
}
changed = append(changed, slot)
}
for slot := resolution.SlotStart; slot <= resolution.SlotEnd && remaining > 0; slot++ {
key := slotKey(listTypeMain, slot)
stack, found := items[key]
if !found || stack.ItemID != itemID || stack.Count <= 0 {
continue
}
_, stackLimit, stackable := o.stackContract(stack)
if !stackable {
continue
}
capacity := int64(math.MaxInt32) - stack.Count
if stackLimit > 0 {
capacity = stackLimit - stack.Count
}
if capacity > space {
capacity = space
}
if capacity <= 0 {
continue
}
add := remaining
if add > capacity {
add = capacity
}
stack = o.canonicalStackForMove(cloneStack(stack))
stack.Count += add
updateStackRawAmount(&stack)
items[key] = stack
remaining -= add
space -= add
markChanged(slot)
}
for slot := resolution.SlotStart; slot <= resolution.SlotEnd && remaining > 0; slot++ {
key := slotKey(listTypeMain, slot)
if _, occupied := items[key]; occupied {
continue
}
if !dnfrepo.CanOpenContainerItemRow(items, listTypeMain, itemID, resolution.StackLimit) {
break
}
insert := remaining
limit := resolution.StackLimit
if limit == 0 {
limit = math.MaxInt32
}
if insert > limit {
insert = limit
}
if insert > space {
insert = space
}
if insert <= 0 {
break
}
bonusStack := o.canonicalStackForMove(dnfrepo.ItemStack{
ItemID: itemID,
Count: insert,
Extra: map[string]string{"source": "upgrade_destroy_bonus"},
})
updateStackRawAmount(&bonusStack)
items[key] = bonusStack
remaining -= insert
space -= insert
markChanged(slot)
}
if remaining > 0 {
return nil, fmt.Errorf("%w: item=%d remaining=%d slots=%d..%d", ErrUpgradeDestroyBonusInventoryFull, itemID, remaining, resolution.SlotStart, resolution.SlotEnd)
}
return changed, nil
}
// firstEmptyUpgradeSlot preserves the legacy bonus placement used only when
// an isolated test does not install the active runtime-PVF resolver. It
// searches quickbar 60..69 first, then general 9..59.
func firstEmptyUpgradeSlot(items map[string]dnfrepo.ItemStack) int16 {
for slot := int16(60); slot <= 69; slot++ {
if _, ok := items[slotKey(listTypeMain, slot)]; !ok {
return slot
}
}
for slot := int16(9); slot <= 59; slot++ {
if _, ok := items[slotKey(listTypeMain, slot)]; !ok {
return slot
}
}
return -1
}
func upgradeGoldCostOf(stack dnfrepo.ItemStack, mode string, oldLevel byte) int64 {
mode = strings.ToLower(strings.TrimSpace(mode))
levelKey := fmt.Sprintf("%s_upgrade_gold_%d", mode, oldLevel)
return firstExtraInt(
stack.Extra,
0,
levelKey,
mode+"_upgrade_gold",
mode+"_gold_cost",
"upgrade_gold_"+strconv.Itoa(int(oldLevel)),
"upgrade_gold",
"upgrade_gold_cost",
"gold_cost",
)
}
// The legacy constructor summary writes a u8 slot at +0 where the current 0x77
// row writes a u16, so every current offset reads one byte high on such a row:
// +0x0A lands on the low durability byte instead of the packed reinforce byte.
// Only the packed byte has a proved legacy position, at +9.
const (
legacyRawEntryItemIDOffset = 0x01
legacyRawEntryPackedOffset = 0x09
legacyRawEntryMinSize = 0x0C
)
// currentRawEntry returns the row only when it is a complete current 0x77 row,
// so a current offset is never read out of a legacy row.
func currentRawEntry(stack dnfrepo.ItemStack) ([]byte, bool) {
if len(stack.RawEntry) != currentItemListEntrySize {
return nil, false
}
return stack.RawEntry, true
}
// legacyRawEntry returns the row when it is still the legacy constructor
// summary. The layout is identified rather than assumed: the legacy row
// carries the PVF item id at +1, where the current row carries it at +2.
func legacyRawEntry(stack dnfrepo.ItemStack) ([]byte, bool) {
if stack.ItemID <= 0 || stack.ItemID > int64(^uint32(0)) {
return nil, false
}
raw := stack.RawEntry
if len(raw) < legacyRawEntryMinSize || len(raw) >= currentItemListEntrySize {
return nil, false
}
if binary.LittleEndian.Uint32(raw[legacyRawEntryItemIDOffset:legacyRawEntryItemIDOffset+4]) != uint32(stack.ItemID) {
return nil, false
}
return raw, true
}
func upgradeLevelOf(stack dnfrepo.ItemStack) byte {
packed := packedUpgradeAttrOf(stack)
if packed != 0 {
return packed & 0x1F
}
if value := firstExtraInt(stack.Extra, 0, "reinforce", "upgrade_level", "enchant_upgrade", "enchant_upgrade_count"); value != 0 {
return byte(value) & 0x1F
}
return 0
}
func packedUpgradeAttrOf(stack dnfrepo.ItemStack) byte {
if stack.Extra != nil {
if value := firstExtraInt(stack.Extra, 0, "ext_data0", "ext0", "packed_flag_byte", "packed_flag", "packed"); value != 0 {
return byte(value)
}
}
if raw, ok := currentRawEntry(stack); ok {
return raw[0x0A]
}
if raw, ok := legacyRawEntry(stack); ok {
return raw[legacyRawEntryPackedOffset]
}
return 0
}
func setUpgradeLevel(stack *dnfrepo.ItemStack, level byte) {
if stack == nil {
return
}
if stack.Extra == nil {
stack.Extra = make(map[string]string, 4)
}
packed := (packedUpgradeAttrOf(*stack) & 0xE0) | (level & 0x1F)
stack.Extra["ext_data0"] = strconv.Itoa(int(packed))
stack.Extra["packed_flag_byte"] = strconv.Itoa(int(packed))
stack.Extra["reinforce"] = strconv.Itoa(int(level))
stack.Extra["upgrade_level"] = strconv.Itoa(int(level))
if len(stack.RawEntry) == currentItemListEntrySize {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
stack.RawEntry[0x0A] = packed
}
}
func updateStackRawAmount(stack *dnfrepo.ItemStack) {
if stack == nil {
return
}
value := uint32(clampInt32(stack.Count))
if len(stack.RawEntry) == currentItemListEntrySize {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
binary.LittleEndian.PutUint32(stack.RawEntry[0x06:0x0A], value)
}
if len(stack.Extra) == 0 {
return
}
extra := make(map[string]string, len(stack.Extra))
for key, raw := range stack.Extra {
extra[key] = raw
}
changed := false
for _, key := range []string{"amount_or_count", "amount", "count", "stack", "quantity"} {
if _, exists := extra[key]; !exists {
continue
}
extra[key] = strconv.FormatUint(uint64(value), 10)
changed = true
}
if changed {
stack.Extra = extra
}
}
func upgradeAmplifyState(stack dnfrepo.ItemStack) (byte, int64) {
ampType := byte(firstExtraInt(stack.Extra, 0, "amplify_type", "amplification_type", "amplify_attr", "amplification_attr", "byte_13", "value_13", "value_c"))
ampValue := firstExtraInt(stack.Extra, 0, "amplify_value", "amplification_value", "amplify_bonus", "amplification_bonus", "marker_16", "marker16", "value_d")
if raw, ok := currentRawEntry(stack); ok {
if ampType == 0 {
ampType = raw[0x13]
}
if ampValue == 0 {
ampValue = int64(raw[0x14]) | int64(raw[0x15])<<8
}
}
prefix := fixedExtraBytes(stack.Extra, 8, "prefix_data_0e", "prefix0e", "raw_data_0e")
if ampType == 0 && len(prefix) >= 6 {
ampType = prefix[5]
}
if ampValue == 0 && len(prefix) >= 8 {
ampValue = int64(prefix[6]) | int64(prefix[7])<<8
}
return ampType, ampValue
}
func pvfProvenWasteStack(stack dnfrepo.ItemStack) (string, string, bool) {
if stack.Extra == nil || !strings.EqualFold(strings.TrimSpace(stack.Extra["item_kind"]), "stackable") {
return "", "", false
}
pvfPath := strings.TrimSpace(stack.Extra["pvf_path"])
stackableType := strings.TrimSpace(strings.ReplaceAll(stack.Extra["stackable_type"], "`", ""))
if pvfPath == "" || !strings.HasPrefix(strings.ToLower(stackableType), "[waste]") {
return "", "", false
}
return pvfPath, stackableType, true
}
func pvfProvenExperienceStack(stack dnfrepo.ItemStack) (string, string, bool, bool) {
if stack.Extra == nil || !strings.EqualFold(strings.TrimSpace(stack.Extra["item_kind"]), "stackable") {
return "", "", false, false
}
pvfPath := strings.TrimSpace(stack.Extra["pvf_path"])
stackableType := strings.TrimSpace(strings.ReplaceAll(stack.Extra["stackable_type"], "`", ""))
normalizedType := strings.ToLower(stackableType)
if pvfPath == "" || (normalizedType != "[waste]" && normalizedType != "[unlimited waste]") {
return "", "", false, false
}
return pvfPath, stackableType, normalizedType == "[unlimited waste]", true
}
type deleteMutation struct {
listType byte
slotIndex int16
itemID int32
count int64
}
func deleteMutations(cmd Command) []deleteMutation {
if len(cmd.DeleteEntries) == 0 {
return []deleteMutation{{
listType: cmd.SourceListType,
slotIndex: cmd.SourceSlotIndex,
itemID: cmd.ItemTemplateID,
count: int64(cmd.Count),
}}
}
out := make([]deleteMutation, 0, len(cmd.DeleteEntries))
for _, entry := range cmd.DeleteEntries {
out = append(out, deleteMutation{
listType: cmd.SourceListType,
slotIndex: entry.SlotIndex,
itemID: entry.ItemID,
count: int64(entry.DeleteCount),
})
}
return out
}
func checkDeleteListType(listType byte) error {
if listType == listTypeEquipment {
return fmt.Errorf("%w: %d", ErrDeleteRequiresEquipmentOwner, listType)
}
// 宠物栏(list 7):宠物蛋/宠物消耗品这类**没有绑定宠物实体**的行允许直接删除,
// 现场 2026-09-18「宠物蛋无法丢弃」就是被这里一刀切拦下的。绑定到宠物图鉴的行
// (已孵化的宠物带 serial/handle)仍然必须走 typed pet owner —— 见 Delete 里的
// stackHasLivePetBinding 判定,绕过它会留下孤儿宠物记录。
if listType == listTypePet {
return nil
}
return checkInventoryRemovalListType(listType)
}
// stackHasLivePetBinding 报告一行宠物栏道具是否绑定了一个宠物实体。孵化的宠物在
// 发放/孵化时会被写上 serial/handle(pet owner 与 inventory owner 都写这个键),
// 而宠物蛋只是一个道具行。
func stackHasLivePetBinding(stack dnfrepo.ItemStack) bool {
for _, key := range []string{
"creature_serial_or_handle", "creature_serial", "pet_serial", "serial", "handle",
} {
value := strings.TrimSpace(stack.Extra[key])
if value != "" && value != "0" {
return true
}
}
return false
}
func checkInventoryRemovalListType(listType byte) error {
switch listType {
case listTypePet:
return fmt.Errorf("%w: %d", ErrPetInventoryOwnerRequired, listType)
case listTypeMain, listTypePersonalCargo, listTypeAvatar:
return nil
default:
return fmt.Errorf("%w: %d", ErrUnsupportedList, listType)
}
}
func checkMoveListType(listType byte) error {
switch listType {
case listTypeEquipment:
return fmt.Errorf("%w: %d", ErrMoveRequiresEquipmentOwner, listType)
case listTypeMain, listTypePersonalCargo, listTypeAvatar, listTypePet:
return nil
default:
return fmt.Errorf("%w: %d", ErrUnsupportedList, listType)
}
}
func isMainItemQuickSlot(listType byte, slot int16) bool {
return listType == listTypeMain && slot >= 3 && slot <= 8
}
func moveTouchesMainQuickSlot(cmd Command) bool {
return isMainItemQuickSlot(cmd.SourceListType, cmd.SourceSlotIndex) ||
isMainItemQuickSlot(cmd.DestinationListType, cmd.DestinationSlotIndex)
}
func checkSortListType(listType byte) error {
switch listType {
case listTypeEquipment:
return fmt.Errorf("%w: %d", ErrSortRequiresEquipmentOwner, listType)
case listTypePet:
return fmt.Errorf("%w: %d", ErrPetInventoryOwnerRequired, listType)
case listTypeMain, listTypePersonalCargo, listTypeAvatar, listTypeAccountCargo:
return nil
default:
return fmt.Errorf("%w: %d", ErrUnsupportedList, listType)
}
}
func checkRepairListType(listType byte) error {
switch listType {
case listTypeEquipment:
return fmt.Errorf("%w: %d", ErrRepairRequiresEquipmentOwner, listType)
case listTypeMain, listTypePersonalCargo:
return nil
default:
return fmt.Errorf("%w: %d", ErrUnsupportedList, listType)
}
}
func checkUseStackableListType(listType byte) error {
if listType == listTypeMain {
return nil
}
return fmt.Errorf("%w: %d", ErrUnsupportedList, listType)
}
func checkUseStackableRequestListType(listType byte) error {
if listType == listTypeMain || listType == listTypePet {
return nil
}
return fmt.Errorf("%w: %d", ErrUnsupportedList, listType)
}
func sortSegment(listType byte, category byte) (int16, int16, bool) {
switch listType {
case listTypeMain:
switch category {
case 1:
return 9, 64, true
case 2:
return 65, 120, true
case 3:
return 121, 176, true
case 4:
return 177, 232, true
case 10:
return 233, 288, true
default:
return 0, 0, false
}
case listTypePet:
switch category {
case 5:
return 0, 139, true
case 6:
return 140, 188, true
case 7:
// Current NoPack sub_232A9F0 accepts list-7 pet-consumable
// slots 189 through 238 inclusive.
return 189, 238, true
default:
return 0, 0, false
}
case listTypeAvatar:
if category == 8 {
return 0, 209, true
}
case listTypePersonalCargo:
if category == 11 {
return 0, 151, true
}
}
return 0, 0, false
}
// sortSegmentItems 按 kind/itemID/slot 重排段内未钉住的物品,返回参与整理的数量与是否
// 真的发生移动。角色容器与账号金库共用同一套规则,避免两条整理路径的结果不一致。
//
// A sort lock is deliberately independent from the ordinary equipment
// protection lock. It anchors its current grid cell while the remaining
// entries are compacted around it; moving the lock together with the item
// would make the client's blue sort-lock marker look like it had been
// ignored by the Sort command.
func sortSegmentItems(items map[string]dnfrepo.ItemStack, listType byte, start int16, end int16) (int, bool, error) {
candidates := sortCandidates(items, listType, start, end)
targetSlots := sortTargetSlots(items, listType, start, end)
if len(candidates) == 0 {
return 0, false, nil
}
if len(candidates) > len(targetSlots) {
return 0, false, fmt.Errorf("sort target slots unavailable: candidates=%d targets=%d", len(candidates), len(targetSlots))
}
sort.SliceStable(candidates, func(i, j int) bool {
left := candidates[i]
right := candidates[j]
if left.kind != right.kind {
return left.kind < right.kind
}
if left.stack.ItemID != right.stack.ItemID {
return left.stack.ItemID < right.stack.ItemID
}
return left.slot < right.slot
})
changed := false
for index, candidate := range candidates {
if candidate.slot != targetSlots[index] {
changed = true
break
}
}
if !changed {
return len(candidates), false, nil
}
for _, candidate := range candidates {
delete(items, slotKey(listType, candidate.slot))
}
for index, candidate := range candidates {
items[slotKey(listType, targetSlots[index])] = candidate.stack
}
return len(candidates), true, nil
}
type sortCandidate struct {
slot int16
kind string
stack dnfrepo.ItemStack
}
func sortCandidates(items map[string]dnfrepo.ItemStack, listType byte, start int16, end int16) []sortCandidate {
out := make([]sortCandidate, 0)
for key, stack := range items {
keyListType, slot, ok := parseSlotKey(key)
if !ok || keyListType != listType || slot < start || slot > end {
continue
}
if dnfitemlock.IsSortLocked(stack) {
continue
}
out = append(out, sortCandidate{
slot: slot,
kind: sortItemKind(stack),
stack: stack,
})
}
return out
}
// sortTargetSlots returns every mutable cell in the requested sort segment.
// Locked rows remain in the map and are therefore excluded from the compaction
// targets. The result is naturally deterministic because slot order, rather
// than map iteration order, owns the destination layout.
func sortTargetSlots(items map[string]dnfrepo.ItemStack, listType byte, start int16, end int16) []int16 {
targets := make([]int16, 0, int(end-start)+1)
for slot := start; slot <= end; slot++ {
stack, found := items[slotKey(listType, slot)]
if found && dnfitemlock.IsSortLocked(stack) {
continue
}
targets = append(targets, slot)
}
return targets
}
func parseSlotKey(key string) (byte, int16, bool) {
listRaw, slotRaw, ok := strings.Cut(key, ":")
if !ok {
return 0, 0, false
}
listValue, err := strconv.ParseInt(strings.TrimSpace(listRaw), 10, 16)
if err != nil || listValue < 0 || listValue > 255 {
return 0, 0, false
}
slotValue, err := strconv.ParseInt(strings.TrimSpace(slotRaw), 10, 16)
if err != nil {
return 0, 0, false
}
return byte(listValue), int16(slotValue), true
}
func sortItemKind(stack dnfrepo.ItemStack) string {
if stack.Extra == nil {
return "unknown"
}
raw := strings.TrimSpace(stack.Extra["item_kind"])
if raw == "" {
raw = strings.TrimSpace(stack.Extra["kind"])
}
if raw == "" {
return "unknown"
}
return strings.ToLower(raw)
}
func itemMapForList(record *dnfrepo.InventoryRecord, listType byte) (map[string]dnfrepo.ItemStack, dnfrepo.InventoryField) {
if listType == listTypePersonalCargo {
return record.Warehouse, dnfrepo.InventoryFieldWarehouse
}
return record.Slots, dnfrepo.InventoryFieldSlots
}
func slotKey(listType byte, slotIndex int16) string {
return fmt.Sprintf("%d:%d", listType, slotIndex)
}
func normalizeDeleteCount(stackCount int64, requested int64) int64 {
if stackCount <= 0 {
return 0
}
if requested <= 0 || requested >= stackCount {
return stackCount
}
return requested
}
func normalizeMoveCount(stack dnfrepo.ItemStack, requested int64) int64 {
if stack.Count <= 0 {
return 0
}
if !isStackable(stack) {
return stack.Count
}
if requested <= 0 || requested >= stack.Count {
return stack.Count
}
return requested
}
func (o *Owner) normalizeMoveCount(stack dnfrepo.ItemStack, requested int64) int64 {
if stack.Count <= 0 {
return 0
}
if !o.isStackable(stack) {
return stack.Count
}
if requested <= 0 || requested >= stack.Count {
return stack.Count
}
return requested
}
func canSplitStack(stack dnfrepo.ItemStack, moveCount int64) bool {
return isStackable(stack) && moveCount > 0 && moveCount < stack.Count
}
func (o *Owner) canSplitStack(stack dnfrepo.ItemStack, moveCount int64) bool {
return o.isStackable(stack) && moveCount > 0 && moveCount < stack.Count
}
func canStack(source dnfrepo.ItemStack, destination dnfrepo.ItemStack, moveCount int64) bool {
if moveCount <= 0 || source.Count < moveCount || destination.Count <= 0 {
return false
}
if source.ItemID != destination.ItemID || source.Bind != destination.Bind || !source.ExpireAt.Equal(destination.ExpireAt) {
return false
}
if monsterCardUpgradeCountOf(source) != monsterCardUpgradeCountOf(destination) {
return false
}
sourcePath, sourceLimit, sourceOK := pvfStackContract(source)
destinationPath, destinationLimit, destinationOK := pvfStackContract(destination)
if !sourceOK || !destinationOK || sourcePath != destinationPath || sourceLimit != destinationLimit {
return false
}
if sourceLimit == 0 {
// Runtime PVF uses an omitted [stack limit] as an uncapped stackable,
// not as evidence that the item cannot stack. Current inventory rows
// keep these counts in a signed 32-bit value.
return moveCount <= math.MaxInt32 && destination.Count <= math.MaxInt32-moveCount
}
return destination.Count <= destinationLimit-moveCount
}
func (o *Owner) canStack(source dnfrepo.ItemStack, destination dnfrepo.ItemStack, moveCount int64) bool {
if moveCount <= 0 || source.Count < moveCount || destination.Count <= 0 {
return false
}
if source.ItemID != destination.ItemID || source.Bind != destination.Bind || !source.ExpireAt.Equal(destination.ExpireAt) {
return false
}
if monsterCardUpgradeCountOf(source) != monsterCardUpgradeCountOf(destination) {
return false
}
sourcePath, sourceLimit, sourceOK := o.stackContract(source)
destinationPath, destinationLimit, destinationOK := o.stackContract(destination)
if !sourceOK || !destinationOK || sourcePath != destinationPath || sourceLimit != destinationLimit {
return false
}
if sourceLimit == 0 {
return moveCount <= math.MaxInt32 && destination.Count <= math.MaxInt32-moveCount
}
return moveCount <= sourceLimit && destination.Count <= destinationLimit-moveCount
}
func isStackable(stack dnfrepo.ItemStack) bool {
_, _, ok := pvfStackContract(stack)
return ok
}
func (o *Owner) isStackable(stack dnfrepo.ItemStack) bool {
_, _, ok := o.stackContract(stack)
return ok
}
func (o *Owner) stackContract(stack dnfrepo.ItemStack) (string, int64, bool) {
if o == nil || o.stackMerge == nil {
return pvfStackContract(stack)
}
resolution, err := o.stackMerge(stack.ItemID)
if err != nil || resolution.ItemID != stack.ItemID || resolution.StackLimit < 0 ||
!strings.EqualFold(strings.TrimSpace(resolution.Kind), "stackable") || stack.Count <= 0 {
return "", 0, false
}
pvfPath := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(resolution.PVFPath, "\\", "/")))
if !strings.HasPrefix(pvfPath, "stackable/") || strings.Contains(pvfPath, "../") {
return "", 0, false
}
if resolution.StackLimit == 0 {
if stack.Count > math.MaxInt32 {
return "", 0, false
}
return pvfPath, 0, true
}
return pvfPath, resolution.StackLimit, true
}
func (o *Owner) canonicalStackForMove(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
if o == nil || o.stackMerge == nil || stack.ItemID <= 0 {
return stack
}
resolution, err := o.stackMerge(stack.ItemID)
if err != nil || resolution.ItemID != stack.ItemID || resolution.StackLimit < 0 ||
!strings.EqualFold(strings.TrimSpace(resolution.Kind), "stackable") {
return stack
}
pvfPath := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(resolution.PVFPath, "\\", "/")))
if !strings.HasPrefix(pvfPath, "stackable/") || strings.Contains(pvfPath, "../") {
return stack
}
extra := make(map[string]string, len(stack.Extra)+4)
for key, value := range stack.Extra {
extra[key] = value
}
extra["item_kind"] = "stackable"
extra["pvf_path"] = pvfPath
if stackableType := strings.TrimSpace(resolution.StackableType); stackableType != "" {
extra["stackable_type"] = stackableType
} else {
delete(extra, "stackable_type")
}
if resolution.StackLimit > 0 {
extra["stack_limit"] = strconv.FormatInt(resolution.StackLimit, 10)
} else {
delete(extra, "stack_limit")
}
stack.Extra = extra
return stack
}
func pvfStackContract(stack dnfrepo.ItemStack) (string, int64, bool) {
if stack.ItemID <= 0 || stack.Extra == nil {
return "", 0, false
}
if !strings.EqualFold(strings.TrimSpace(stack.Extra["item_kind"]), "stackable") {
return "", 0, false
}
pvfPath := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(stack.Extra["pvf_path"], "\\", "/")))
if !strings.HasPrefix(pvfPath, "stackable/") || strings.Contains(pvfPath, "../") {
return "", 0, false
}
raw := strings.TrimSpace(stack.Extra["stack_limit"])
if raw == "" {
if stack.Count <= 0 || stack.Count > math.MaxInt32 {
return "", 0, false
}
return pvfPath, 0, true
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 || stack.Count <= 0 {
return "", 0, false
}
if value == 0 {
if stack.Count > math.MaxInt32 {
return "", 0, false
}
return pvfPath, 0, true
}
if stack.Count > value {
return "", 0, false
}
return pvfPath, value, true
}
func findCompatibleStackSlot(
items map[string]dnfrepo.ItemStack,
listType byte,
moving dnfrepo.ItemStack,
moveCount int64,
excludedSlots ...int16,
) (int16, dnfrepo.ItemStack, bool) {
if listType != listTypeMain && listType != listTypeAvatar && listType != listTypePersonalCargo {
return 0, dnfrepo.ItemStack{}, false
}
excluded := make(map[int16]struct{}, len(excludedSlots))
for _, slot := range excludedSlots {
excluded[slot] = struct{}{}
}
var selectedSlot int16
var selected dnfrepo.ItemStack
found := false
for key, candidate := range items {
keyListType, slot, ok := parseSlotKey(key)
if !ok || keyListType != listType {
continue
}
if _, skip := excluded[slot]; skip || dnfrepo.IsAccountSharedInventorySlot(listType, slot) {
continue
}
if !canStack(moving, candidate, moveCount) {
continue
}
if !found || slot < selectedSlot {
selectedSlot = slot
selected = candidate
found = true
}
}
return selectedSlot, selected, found
}
func (o *Owner) findCompatibleStackSlot(
items map[string]dnfrepo.ItemStack,
listType byte,
moving dnfrepo.ItemStack,
moveCount int64,
excludedSlots ...int16,
) (int16, dnfrepo.ItemStack, bool) {
if listType != listTypeMain && listType != listTypeAvatar && listType != listTypePersonalCargo {
return 0, dnfrepo.ItemStack{}, false
}
excluded := make(map[int16]struct{}, len(excludedSlots))
for _, slot := range excludedSlots {
excluded[slot] = struct{}{}
}
var selectedSlot int16
var selected dnfrepo.ItemStack
found := false
for key, candidate := range items {
keyListType, slot, ok := parseSlotKey(key)
if !ok || keyListType != listType {
continue
}
if _, skip := excluded[slot]; skip || dnfrepo.IsAccountSharedInventorySlot(listType, slot) {
continue
}
candidate = o.canonicalStackForMove(candidate)
if !o.canStack(moving, candidate, moveCount) {
continue
}
if !found || slot < selectedSlot {
selectedSlot = slot
selected = candidate
found = true
}
}
return selectedSlot, selected, found
}
// crossContainerDestinationRedirect 是一次跨容器落点修正选出的真实目标格。
// Mode 沿用既有分支名:合并到已有堆叠用 "stack"(bridge 会据此补发两个端点的精确
// 槽位刷新),搬进空格用 "move"/"split"(只回 op19 ACK)。
type crossContainerDestinationRedirect struct {
Slot int16
Stack dnfrepo.ItemStack
Mode string
}
// redirectMainMoveToItemPage 把"落点在主背包、但那一页不是这件道具的页"的移动改到
// 道具真正所属的那一页(当前 PVF 页的空格,或页里已有的同类可叠行)。
//
// 现场 2026-10-03(服主报,已用解密后的包日志定证):徽章存进个人仓库再取出时,客户端
// 声明的落点是它自己挑的第一个空格 —— 消耗品页:
//
// 05:41:28 move src=(0,289 华丽的双重徽章 ×3) dst=(2,166) 存进仓库
// 05:41:31 move src=(2,166 ×3) dst=(0,116) ← 取出,落进消耗品栏
//
// 徽章页是 289..351(dnfbridge.dungeonDropStackableSlotRange),116 在消耗品页里,
// 于是这一摞徽章被写到了客户端从来不渲染它的那一页上 —— 玩家看到的就是"取出后跑到
// 消耗品栏"。26 秒后同一件道具又被写进 0:113,可见是稳定复现,不是偶发。
//
// 与 redirectAccountSharedWarehouseDestination(晶体/灵魂固定格)、
// redirectPersonalCargoMoveFromGoldWalletSlot(金币钱包行 0)同属一类:
// **op19 的落点不能由客户端单方面决定**,道具的 PVF 页才是权威。
//
// 刻意不接管的情形:
// - 源端与声明格都在道具页里:那是页内重排,客户端没声明错,什么都不用做;
// - 声明格被**别的**道具占着:那是玩家明着做的互换,语义不该被悄悄改写;
// - 拿不到容器状态或 PVF 页(解析失败/页非法):fail-close,不发明格子;
// - 道具页里既没有可叠行也没有空格:不改落点,让调用方照旧报"空间不足"。
//
// 源端在错页、目标端也在错页的**同容器**移动同样接管:这样已经落在消耗品栏里的历史
// 脏行,玩家把它拖到任意一格就会自动回到徽章页,不用手工洗数据。源端本来就在正确
// 页里的同容器移动不受影响(那是页内重排,见上一条)。
//
// 返回的 bool 表示落点被改过,调用方据此把 RedirectedReservedMainSlot 写进结果,
// 让 op19 ACK 回执真正落库的槽位。
func (o *Owner) redirectMainMoveToItemPage(
ctx context.Context,
cmd Command,
record dnfrepo.InventoryRecord,
characterID string,
) (Command, bool, error) {
if cmd.DestinationListType != listTypeMain ||
dnfrepo.IsAccountSharedInventorySlot(cmd.DestinationListType, cmd.DestinationSlotIndex) {
return cmd, false, nil
}
srcItems, _ := itemMapForList(&record, cmd.SourceListType)
dstItems, _ := itemMapForList(&record, cmd.DestinationListType)
source, sourceOK := srcItems[slotKey(cmd.SourceListType, cmd.SourceSlotIndex)]
if !sourceOK || source.ItemID <= 0 || source.Count <= 0 {
return cmd, false, nil
}
source = o.canonicalStackForMove(source)
start, end, ok := o.moveDestinationUnlockedSlotRange(ctx, cmd.DestinationListType, source, characterID)
if !ok {
return cmd, false, nil
}
if cmd.SourceListType == listTypeMain &&
cmd.SourceSlotIndex >= start && cmd.SourceSlotIndex <= end {
// 同容器、源端本来就在正确页:页内重排,落点由玩家决定,不改。
return cmd, false, nil
}
if cmd.DestinationSlotIndex >= start && cmd.DestinationSlotIndex <= end {
return cmd, false, nil
}
if destination, occupied := dstItems[slotKey(cmd.DestinationListType, cmd.DestinationSlotIndex)]; occupied {
destination = o.canonicalStackForMove(destination)
moveCount := o.normalizeMoveCount(source, int64(cmd.MoveCount))
if !o.canStack(source, destination, moveCount) {
// 声明格上是别的道具:这是玩家在做一个互换,不是"客户端挑错了空格"。
return cmd, false, nil
}
}
excludeSlot := int16(-1)
if cmd.SourceListType == cmd.DestinationListType {
excludeSlot = cmd.SourceSlotIndex
}
moveCount := o.normalizeMoveCount(source, int64(cmd.MoveCount))
// 先并进道具页里已有的同类行(同一容器里同 id 只应有一行),没有才另开一格。
if slot, _, found := o.findCompatibleStackSlotInSlotRange(dstItems, cmd.DestinationListType, source, moveCount, start, end, excludeSlot); found {
cmd.DestinationSlotIndex = slot
return cmd, true, nil
}
if slot, found := emptySlotInSlotRange(dstItems, cmd.DestinationListType, start, end, excludeSlot); found {
cmd.DestinationSlotIndex = slot
return cmd, true, nil
}
return cmd, false, nil
}
// reservedMainWalletSlot 是客户端当作金币钱包对象的那一行;普通物品永远不能落在
// 那里(见 redirectPersonalCargoMoveFromGoldWalletSlot)。
const reservedMainWalletSlot int16 = 0
// 当前 EXE 主背包每个标准分类都是 56 格,前 32 格随角色解锁,之后按
// CharacterContainerState.MainSlotCount(0/8/16/24,即已购买的行数)逐行开放。
// 口径与 dnfbridge.currentMainInventoryBaseSlotCount /
// currentMainInventoryCategorySlotCount 一致;未解锁的格子客户端不渲染,物品放进去
// 等于"消失",所以这里绝不猜格子。
const (
mainInventoryBaseSlotCount int16 = 32
mainInventoryCategorySlotCount int16 = 56
)
// crossContainerClientSawEmptyDestination 判断这次 op19 是不是"跨容器 + 客户端声明
// 目标格为空"。客户端用 DestinationInstance / DestinationStack 回填它那一侧该格的
// 物品实例与数量:两者都是 0 才是"我这份列表里目标格没有东西";DestinationStack
// 非 0 说明玩家是把物品丢在客户端看得见的一行上,那次合并必须继续就地发生。
func crossContainerClientSawEmptyDestination(cmd Command) bool {
return cmd.SourceListType != cmd.DestinationListType &&
cmd.DestinationInstance == 0 &&
cmd.DestinationStack <= 0
}
// crossContainerOccupiedDestinationRedirect 为"客户端声明目标格为空、服务端该格
// 却有行"的跨容器移动重新选一个落点。返回 ok=false 表示没有可证明的落点,调用方
// 保持原有语义。
//
// 顺序按"同一容器里同 id 只应有一行"来定:声明格自己就是同类可叠格就地合并,
// 否则并进容器里另一处同类可叠格;只有容器里根本没有这件道具的行时才另开一格。
func (o *Owner) crossContainerOccupiedDestinationRedirect(
ctx context.Context,
cmd Command,
destinationItems map[string]dnfrepo.ItemStack,
destination dnfrepo.ItemStack,
moving dnfrepo.ItemStack,
moveCount int64,
characterID string,
) (crossContainerDestinationRedirect, bool) {
// 账号共享晶体/灵魂槽(354..365)属于另一个 durable owner,落点只能由账号侧
// 事务决定;把这种移动改投到角色自有格子会改变归属,所以这里不接管。
if dnfrepo.IsAccountSharedInventorySlot(cmd.DestinationListType, cmd.DestinationSlotIndex) {
return crossContainerDestinationRedirect{}, false
}
// ① 声明格自己就是同类可叠格:容器里已经有这件道具的行,就地合并,绝不新开第二行。
if o.canStack(moving, destination, moveCount) {
if !crossContainerClientSawEmptyDestination(cmd) {
// 客户端看得见这一行(DestinationInstance/DestinationStack 非 0):就地合并
// 交给调用方的常规分支,由它决定刷新形状,这里不接管落点。
return crossContainerDestinationRedirect{}, false
}
// 客户端以为目标格是空的、服务端却有行:ACK 仍按它声明的槽位回,就地合并。
return crossContainerDestinationRedirect{Slot: cmd.DestinationSlotIndex, Stack: destination, Mode: "stack"}, true
}
// ② 容器里另一处同类可叠格:同样并进去,不新开行。
if slot, stack, ok := o.findCompatibleStackSlot(destinationItems, cmd.DestinationListType, moving, moveCount, cmd.DestinationSlotIndex); ok {
return crossContainerDestinationRedirect{Slot: slot, Stack: stack, Mode: "stack"}, true
}
// ③ 叠不上就只能另开一格:目标容器里第一个已解锁空格。
//
// 2026-09-27 现场(用户报):这一段以前整体被 crossContainerClientSawEmptyDestination
// 挡在门外 —— 只要客户端在包体里带了它看见的那一行(DestinationInstance /
// DestinationStack 非 0,也就是玩家正常"丢在占用格上"的手势),函数立刻 ok=false,
// 调用方随即回"空间不足"(错误码 4)。于是两个方向都被挡下:
// - 金库里锁好的频道喇叭往背包拖((2,143) → (0,69)/(0,92)/(0,111)),
// - 背包里的材料往金库拖((0,124) → (2,150),目标格放着消耗品),
// 而目标容器其实还空着好几格;只有丢进同类堆(走 ① 的就地合并)才给动。
// 跨容器本来就不允许互换,所以"叠不上"之后唯一的出路就是空格 —— 与 86JP
// InventoryMoveService.ApplyCrossSpaceInsertToOccupiedDestination 的
// TryFindEmptyDestinationSlot 一致(账号金库路径早已是这个形状)。
if slot, ok := o.crossContainerEmptyDestinationSlot(ctx, destinationItems, cmd.DestinationListType, moving, characterID, cmd.DestinationSlotIndex); ok {
return crossContainerDestinationRedirect{Slot: slot, Mode: "move"}, true
}
return crossContainerDestinationRedirect{}, false
}
// crossContainerEmptyDestinationSlot 返回目标容器里第一个"已解锁且确实为空"的格子,
// 跳过保留的金币钱包行、快捷栏、账号共享槽与已存在的行。
func (o *Owner) crossContainerEmptyDestinationSlot(
ctx context.Context,
destinationItems map[string]dnfrepo.ItemStack,
listType byte,
moving dnfrepo.ItemStack,
characterID string,
takenSlot int16,
) (int16, bool) {
start, end, ok := o.moveDestinationUnlockedSlotRange(ctx, listType, moving, characterID)
if !ok {
return 0, false
}
return emptySlotInSlotRange(destinationItems, listType, start, end, takenSlot)
}
// emptySlotInSlotRange 在 [start,end] 里挑第一个空格,跳过保留的金币钱包行、快捷栏、
// 账号共享槽与已经存在的行。调用方必须给出"证明可见"的范围(已解锁主背包页 / 金库
// 容量),这里绝不猜格子。
func emptySlotInSlotRange(
items map[string]dnfrepo.ItemStack,
listType byte,
start int16,
end int16,
takenSlot int16,
) (int16, bool) {
for slot := start; slot <= end; slot++ {
if slot == takenSlot || isMainItemQuickSlot(listType, slot) ||
dnfrepo.IsAccountSharedInventorySlot(listType, slot) ||
(listType == listTypeMain && slot == reservedMainWalletSlot) {
continue
}
if _, occupied := items[slotKey(listType, slot)]; occupied {
continue
}
return slot, true
}
return 0, false
}
// moveDestinationUnlockedSlotRange 给出目标容器里"该物品真正可见"的槽位范围。
// 读不到角色容器状态、主背包目标拿不到当前 PVF 的物品页、或容器类型无法界定范围时
// 一律 fail-close(ok=false),宁可不改落点,也绝不发明一个格子。
func (o *Owner) moveDestinationUnlockedSlotRange(
ctx context.Context,
listType byte,
moving dnfrepo.ItemStack,
characterID string,
) (int16, int16, bool) {
if o == nil || o.settings == nil || strings.TrimSpace(characterID) == "" {
return 0, 0, false
}
state, found, err := dnfrepo.LoadCharacterContainerState(ctx, o.settings, characterID)
if err != nil || !found {
return 0, 0, false
}
switch listType {
case listTypePersonalCargo:
if state.PersonalCargoSlotCount == 0 {
return 0, 0, false
}
return 0, int16(state.PersonalCargoSlotCount) - 1, true
case listTypeMain:
if o.stackMerge == nil || moving.ItemID <= 0 {
return 0, 0, false
}
resolution, resolveErr := o.stackMerge(moving.ItemID)
if resolveErr != nil || resolution.ItemID != moving.ItemID ||
resolution.SlotStart <= 0 || resolution.SlotEnd < resolution.SlotStart {
return 0, 0, false
}
end := resolution.SlotEnd
if resolution.SlotEnd-resolution.SlotStart+1 == mainInventoryCategorySlotCount {
if unlocked := resolution.SlotStart + mainInventoryBaseSlotCount - 1 + int16(state.MainSlotCount); end > unlocked {
end = unlocked
}
}
return resolution.SlotStart, end, true
default:
return 0, 0, false
}
}
// cargoLockedDestinationResolution 描述"客户端声明的落点在未解锁区"时按运营口径解析出来的落点。
// Locked 表示落点确实在未解锁区;Redirected 表示已改到另一个已解锁格(Slot);
// StackFound 表示那个格子上已经有同类可叠行(Stack),必须并进去而不是当成空格覆盖。
type cargoLockedDestinationResolution struct {
Locked bool
Redirected bool
Slot int16
Stack dnfrepo.ItemStack
StackFound bool
}
// isPersonalCargoListType 判断容器是不是有解锁档位、且客户端允许往未解锁格拖入的个人仓库。
func isPersonalCargoListType(listType byte) bool {
return listType == listTypePersonalCargo
}
// isWarehouseDestination 报告一次移动的**落点**是不是仓库类容器:个人仓库(list 2)、
// 账号金库(list 12),以及"客户端把 list-0 的 354..365 当账号共享晶体/灵魂仓"那一段。
// 快捷栏、宠物页、装扮页都不是仓库,不在此列。
func isWarehouseDestination(listType byte, slot int16) bool {
switch listType {
case listTypePersonalCargo, listTypeAccountCargo:
return true
case listTypeMain:
return dnfrepo.IsAccountSharedInventorySlot(listType, slot)
default:
return false
}
}
// isAvatarEmblem 用当前运行时 PVF 判定这一行是不是徽章([stackable type] = [avatar emblem])。
//
// 判定走解析器而不是留存的 Extra:PVF 是唯一权威,Extra 可能来自历史数据或被改过。
// 解析不出来一律当作"不是徽章"(fail-open)——这条规则是**新增限制**,误判成徽章会把
// 普通道具也挡在仓库外,代价比漏拦一件大。
func (o *Owner) isAvatarEmblem(stack dnfrepo.ItemStack) bool {
if o == nil || o.stackMerge == nil || stack.ItemID <= 0 {
return false
}
resolution, err := o.stackMerge(stack.ItemID)
if err != nil || resolution.ItemID != stack.ItemID {
return false
}
normalized := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(resolution.StackableType, "`", "")))
return normalized == "[avatar emblem]"
}
// resolveLockedPersonalCargoDestination 按运营口径(2026-09-18)处理"落点在未解锁的个人仓库格":
//
// ① 已解锁区里已经有同 ID 的可叠行(含 PVF [stack limit] 校验)→ 并进最早的那一行;
// ② 否则落到已解锁区第一个空格(从第一格开始找,占了的往后顺延);
// ③ 都没有(真的满了)→ Redirected=false 且 Locked=true,调用方回"物品栏空间不足"。
//
// listType/slot 是**真正接收物品**的那一端:正向移动是 destination;而客户端在金库/仓库
// 内部把已有物品拖到某个格子时,会把落点写在 source 端(服务端看到的是 reverse_move),
// 所以 reverse 分支必须传 source 端(现场 2026-09-18:那个分支没判定,物品直接进了未解锁格)。
// excludeSlot 用来排除本次移动的另一端(同容器时就是物品原来所在的那一格),避免把这一堆
// 放回它自己身上。
//
// 读不到解锁范围(没有容器状态/PVF 页信息)时 fail-close:Locked=false,完全不改落点,
// 也不发明格子。
func (o *Owner) resolveLockedPersonalCargoDestination(
ctx context.Context,
listType byte,
slot int16,
items map[string]dnfrepo.ItemStack,
moving dnfrepo.ItemStack,
moveCount int64,
excludeSlot int16,
characterID string,
) cargoLockedDestinationResolution {
if o == nil || !isPersonalCargoListType(listType) ||
moving.ItemID <= 0 || moving.Count <= 0 || moveCount <= 0 {
return cargoLockedDestinationResolution{}
}
start, end, ok := o.moveDestinationUnlockedSlotRange(ctx, listType, moving, characterID)
if !ok {
return cargoLockedDestinationResolution{}
}
if slot >= start && slot <= end {
return cargoLockedDestinationResolution{}
}
resolution := cargoLockedDestinationResolution{Locked: true}
// ① 同类可叠行优先:同一个容器里同 ID 只应有一行。
if target, stack, found := o.findCompatibleStackSlotInSlotRange(
items,
listType,
moving,
moveCount,
start,
end,
excludeSlot,
); found {
resolution.Slot, resolution.Redirected = target, true
resolution.Stack, resolution.StackFound = stack, true
return resolution
}
// ② 第一个已解锁空格。
if target, found := emptySlotInSlotRange(items, listType, start, end, excludeSlot); found {
resolution.Slot, resolution.Redirected = target, true
}
return resolution
}
// findCompatibleStackSlotInSlotRange 在 [start,end] 里挑**编号最小**的同类可叠格;
// 与 findCompatibleStackSlot 的区别只有一个:范围由调用方按"已解锁"证明给出。
// excludeSlot < 0 表示不排除任何格子。
func (o *Owner) findCompatibleStackSlotInSlotRange(
items map[string]dnfrepo.ItemStack,
listType byte,
moving dnfrepo.ItemStack,
moveCount int64,
start int16,
end int16,
excludeSlot int16,
) (int16, dnfrepo.ItemStack, bool) {
for slot := start; slot <= end; slot++ {
if slot == excludeSlot ||
dnfrepo.IsAccountSharedInventorySlot(listType, slot) ||
(listType == listTypeMain && slot == reservedMainWalletSlot) {
continue
}
candidate, exists := items[slotKey(listType, slot)]
if !exists || candidate.ItemID != moving.ItemID {
continue
}
candidate = o.canonicalStackForMove(candidate)
if o.canStack(moving, candidate, moveCount) {
return slot, candidate, true
}
}
return 0, dnfrepo.ItemStack{}, false
}
// carryLimitForContainer 取出该道具在"一个容器内"的总量上限(PVF [stack limit])。
// ok=false 表示不受限:PVF 没写 [stack limit](装备、部分老材料)或该物品不是可叠加
// 类别。limit==0 同样是"PVF 省略",按不限处理。
func (o *Owner) carryLimitForContainer(stack dnfrepo.ItemStack) (int64, bool) {
_, limit, ok := o.stackContract(stack)
if !ok || limit <= 0 {
return 0, false
}
return limit, true
}
// containerItemTotal 统计某个容器里同一道具 id 的总量。整理锁、绑定/期限不同的行都算
// 在一起:口径是"这道具在这个容器里一共有多少件"。实现见 dnfrepo,模块内所有入包路径
// 共用同一份口径。
func containerItemTotal(items map[string]dnfrepo.ItemStack, listType byte, itemID int64) int64 {
return dnfrepo.ContainerItemTotal(items, listType, itemID)
}
// validateCarryLimitOnMove 在跨容器搬入之前检查"目标容器里该道具总量 + 本次搬入量"是否
// 超过 PVF [stack limit]。超了就拒绝:不能靠另寻空格绕过去(上限 1 的道具每容器只能有
// 一件,上限 1000 的道具每容器总共只能有 1000 件)。同容器移动总量不变,直接放行。
func (o *Owner) validateCarryLimitOnMove(
sourceListType byte,
destinationListType byte,
destinationSlotIndex int16,
destinationItems map[string]dnfrepo.ItemStack,
moving dnfrepo.ItemStack,
moveCount int64,
) error {
if moveCount <= 0 || sourceListType == destinationListType {
return nil
}
limit, capped := o.carryLimitForContainer(moving)
if !capped {
return nil
}
total := containerItemTotal(destinationItems, destinationListType, moving.ItemID)
if total > limit-moveCount {
return fmt.Errorf(
"%w: item=%d list=%d slot=%d container_total=%d move=%d limit=%d",
ErrMoveCarryLimitExceeded,
moving.ItemID,
destinationListType,
destinationSlotIndex,
total,
moveCount,
limit,
)
}
return nil
}
func (o *Owner) withMoveRefresh(
ctx context.Context,
cmd Command,
result MoveResult,
record *dnfrepo.InventoryRecord,
listTypes ...byte,
) (MoveResult, error) {
if record == nil {
return result, nil
}
if result.Refresh == nil {
result.Refresh = make(map[byte]map[string]dnfrepo.ItemStack, len(listTypes))
}
for _, listType := range listTypes {
if listType != listTypeMain && listType != listTypeAvatar && listType != listTypePersonalCargo {
continue
}
if _, exists := result.Refresh[listType]; exists {
continue
}
items, _ := itemMapForList(record, listType)
snapshot := cloneItemMap(items)
if listType == listTypeMain {
if o == nil || o.accountRepo == nil {
return MoveResult{}, ErrAccountSharedOwnerUnavailable
}
accountID := strings.TrimSpace(cmd.AccountID)
if accountID == "" {
return MoveResult{}, ErrAccountRequired
}
account, _, err := o.accountRepo.Load(ctx, accountID)
if err != nil {
return MoveResult{}, err
}
merged := dnfrepo.MergeAccountSharedInventory(dnfrepo.InventoryRecord{Slots: snapshot}, account)
snapshot = merged.Slots
}
result.Refresh[listType] = snapshot
result.RefreshListTypes = append(result.RefreshListTypes, listType)
}
return result, nil
}
func isEquipmentLocked(stack dnfrepo.ItemStack) bool {
if stack.Extra == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(stack.Extra["equipment_lock_state"])) {
case "1", "2", "active", "locked", "unlocking", "pending_unlock":
return true
default:
return false
}
}
func cloneStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
if len(stack.Extra) == 0 {
return stack
}
extra := make(map[string]string, len(stack.Extra))
for key, value := range stack.Extra {
extra[key] = value
}
stack.Extra = extra
return stack
}
func cloneItemMap(items map[string]dnfrepo.ItemStack) map[string]dnfrepo.ItemStack {
if len(items) == 0 {
return map[string]dnfrepo.ItemStack{}
}
out := make(map[string]dnfrepo.ItemStack, len(items))
for key, stack := range items {
out[key] = cloneStack(stack)
}
return out
}
func saveMoveResult(ctx context.Context, repo dnfrepo.InventoryRepository, record dnfrepo.InventoryRecord, srcField dnfrepo.InventoryField, dstField dnfrepo.InventoryField, result MoveResult) (MoveResult, error) {
fields := []dnfrepo.InventoryField{srcField}
if dstField != srcField {
fields = append(fields, dstField)
}
record.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, repo, record, fields...); err != nil {
return MoveResult{}, err
}
result.Changed = true
return result, nil
}
func sellGoldOf(stack dnfrepo.ItemStack) (int64, error) {
if stack.Extra == nil {
return 0, ErrSellPriceMissing
}
raw, ok := stack.Extra["sell_gold"]
if !ok {
return 0, ErrSellPriceMissing
}
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || value < 0 {
return 0, fmt.Errorf("%w: %q", ErrSellPriceInvalid, raw)
}
return value, nil
}
func repairDurabilityOf(stack dnfrepo.ItemStack) (int64, int64, error) {
if stack.Extra == nil {
return 0, 0, ErrRepairDurabilityMissing
}
currentRaw := strings.TrimSpace(stack.Extra["durability"])
maxRaw := strings.TrimSpace(stack.Extra["max_durability"])
if currentRaw == "" || maxRaw == "" {
return 0, 0, ErrRepairDurabilityMissing
}
current, err := strconv.ParseInt(currentRaw, 10, 64)
if err != nil || current < 0 {
return 0, 0, fmt.Errorf("%w: durability=%q", ErrRepairDurabilityInvalid, currentRaw)
}
maxValue, err := strconv.ParseInt(maxRaw, 10, 64)
if err != nil || maxValue <= 0 || current > maxValue {
return 0, 0, fmt.Errorf("%w: max_durability=%q durability=%q", ErrRepairDurabilityInvalid, maxRaw, currentRaw)
}
return current, maxValue, nil
}
// Upgrade-ticket (op50 ticket scene) client-visible error codes, matching the
// current packet regressions: 4 invalid target, 7 durability, 13 unsupported,
// 19 restriction/forbidden, 22 invalid material, 23 wrong mode, 95 max level,
// 174 amplify not identified, 213 item locked.
const (
upgradeTicketErrorInvalidTarget byte = 4
upgradeTicketErrorDurability byte = 7
upgradeTicketErrorUnsupported byte = 13
upgradeTicketErrorForbidden byte = 19
upgradeTicketErrorInvalidMaterial byte = 22
upgradeTicketErrorWrongMode byte = 23
upgradeTicketErrorMaxLevel byte = 95
upgradeTicketErrorAmplifyNotIdentified byte = 174
upgradeTicketErrorLocked byte = 213
upgradeTicketResultCodeSuccess byte = 0
upgradeTicketResultCodeFailureRetain byte = 1
)
// UpgradeTicketResult describes one op50 ticket-scene attempt. Success=true
// means the operation completed and the success-layout op50 ACK must be sent
// (ResultCode carries the roll outcome); TicketResolved=false means the
// material is not an upgrade ticket and the request stays on the pending
// normal-reinforcement path.
type UpgradeTicketResult struct {
CharacterID string
Success bool
TicketResolved bool
ErrorCode byte
Mode string
TargetListType byte
TargetSlotIndex int16
MaterialSlotIndex int16
TicketItemID int64
OldLevel byte
NewLevel byte
ResultCode byte
UpgradeSucceeded bool
MaterialRemainingStackCount int64
MainRefresh map[string]dnfrepo.ItemStack
TargetUpdatedStack dnfrepo.ItemStack
MaterialUpdated bool
MaterialUpdatedStack dnfrepo.ItemStack
Changed bool
}
// UpgradeTicket applies one equipment reinforcement/amplify ticket (op50 with
// the ticket in the material slot) to one main-inventory equipment entry,
// following the current op50 ticket-scene regressions: on a successful
// roll the level jumps directly to the ticket's target level, on failure the
// level is retained without penalty; exactly one ticket is consumed either
// way. [enchant random] tickets validate their er_slot / er_grade / er_seal
// target restrictions and then roll one weighted er_enchant candidate instead
// of using a fixed target level. A direct-level ticket is already
// non-destructive, so its unrelated optional protection slot is ignored.
// Normal op50 reinforcement/amplification owns protection tickets separately.
// Gold cost and the 0x0056 notice broadcast are intentionally not owned by
// this direct-level ticket path.
func (o *Owner) UpgradeTicket(ctx context.Context, cmd Command, resolver alignedcmd.UpgradeTicketResolver) (UpgradeTicketResult, error) {
base := UpgradeTicketResult{
TicketResolved: true,
ErrorCode: upgradeTicketErrorInvalidTarget,
Mode: cmd.Mode,
TargetListType: cmd.TargetListType,
TargetSlotIndex: cmd.TargetSlotIndex,
MaterialSlotIndex: cmd.MaterialSlotIndex,
}
if o == nil || o.repo == nil {
return UpgradeTicketResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return UpgradeTicketResult{}, ErrCharacterRequired
}
if resolver == nil {
return UpgradeTicketResult{}, ErrUpgradeTicketResolverRequired
}
if cmd.Mode != "reinforce" && cmd.Mode != "amplify" {
base.ErrorCode = upgradeTicketErrorWrongMode
return base, nil
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
base.CharacterID = characterID
if !o.inItemTransaction {
var result UpgradeTicketResult
err := o.withinInventoryTransaction(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.UpgradeTicket(ctx, cmd, resolver)
return err
})
if err != nil {
return UpgradeTicketResult{}, err
}
if result.CharacterID == "" {
result = base
}
return result, nil
}
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return UpgradeTicketResult{}, err
}
if !ok {
return UpgradeTicketResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
record.CharacterID = characterID
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
result := base
items := record.Slots
// Same worn-target resolution as Upgrade: read the EquipmentEntry through
// the forging projection, let the shared level logic run, write it back.
targetEquipped := cmd.TargetListType == listTypeEquipment
var equipmentRecord dnfrepo.EquipmentRecord
var targetEntry dnfrepo.EquipmentEntry
equipmentKey := strconv.Itoa(int(cmd.TargetSlotIndex))
targetKey := slotKey(cmd.TargetListType, cmd.TargetSlotIndex)
var target dnfrepo.ItemStack
switch {
case targetEquipped:
if o.equipment == nil {
return UpgradeTicketResult{}, ErrUpgradeEquipmentOwnerUnavailable
}
var found bool
equipmentRecord, found, err = o.equipment.Load(ctx, characterID)
if err != nil {
return UpgradeTicketResult{}, err
}
if !found {
return result, nil
}
equipmentRecord = dnfrepo.CloneEquipment(equipmentRecord)
targetEntry, ok = equipmentRecord.Entries[equipmentKey]
if !ok || targetEntry.ItemID <= 0 || targetEntry.SlotIndex != cmd.TargetSlotIndex {
return result, nil
}
target = forgingStackFromEquipmentEntry(targetEntry)
case cmd.TargetListType == listTypeMain:
target, ok = items[targetKey]
default:
// A target list this ticket scene cannot serve (the list-38 公会徽章 page)
// must hand the request back instead of claiming it: base carries
// TicketResolved=true, and the handler's ticket pre-pass reads that as
// "a ticket owns this attempt" and answers the op50 error ACK. Leaving it
// true here made every 公会徽章 reinforce die on error 4 (invalid target)
// before the ported list-38 owner path could run (live 2026-10-04 19:16:30
// and 19:17:16). TicketResolved=false keeps the request on the normal
// reinforce/amplify path.
result.TicketResolved = false
return result, nil
}
if !ok || target.Count <= 0 || target.ItemID != int64(cmd.TargetItemTemplateID) {
return result, nil
}
if !equipmentStackCountIsSingle(target) {
// 历史堆叠行(装备 Count>1)不允许用券:一张券只能作用一件。
return result, nil
}
result.OldLevel = upgradeLevelOf(target)
result.NewLevel = result.OldLevel
if isEquipmentLocked(target) {
result.ErrorCode = upgradeTicketErrorLocked
return result, nil
}
if !upgradeDurabilityOK(target) {
result.ErrorCode = upgradeTicketErrorDurability
return result, nil
}
if result.OldLevel > 30 {
result.ErrorCode = upgradeTicketErrorMaxLevel
return result, nil
}
if cmd.MaterialSlotIndex < 0 || cmd.MaterialSlotIndex == cmd.TargetSlotIndex {
result.ErrorCode = upgradeTicketErrorInvalidMaterial
return result, nil
}
materialKey := slotKey(listTypeMain, cmd.MaterialSlotIndex)
material, ok := items[materialKey]
if !ok || material.Count <= 0 {
result.ErrorCode = upgradeTicketErrorInvalidMaterial
return result, nil
}
result.TicketItemID = material.ItemID
resolution, err := resolver(material.ItemID, target.ItemID)
if err != nil {
return UpgradeTicketResult{}, err
}
if resolution.TicketMode == "" {
result.TicketResolved = false
return result, nil
}
result.TicketResolved = true
if !strings.EqualFold(strings.TrimSpace(resolution.TargetKind), "equipment") {
result.ErrorCode = upgradeTicketErrorInvalidTarget
return result, nil
}
if resolution.TargetUpgradeForbidden {
result.ErrorCode = upgradeTicketErrorForbidden
return result, nil
}
if !strings.EqualFold(strings.TrimSpace(resolution.TicketMode), cmd.Mode) {
result.ErrorCode = upgradeTicketErrorWrongMode
return result, nil
}
amplifyType, amplifyValue := upgradeAmplifyState(target)
switch cmd.Mode {
case "reinforce":
if amplifyType != 0 {
result.ErrorCode = upgradeTicketErrorWrongMode
return result, nil
}
case "amplify":
if amplifyType == 0 {
result.ErrorCode = upgradeTicketErrorWrongMode
return result, nil
}
if amplifyValue <= 0 || (amplifyType&0x80) != 0 {
result.ErrorCode = upgradeTicketErrorAmplifyNotIdentified
return result, nil
}
}
weight := resolution.SuccessWeight
targetLevel := resolution.TargetLevel
if resolution.TicketRandom {
if !enchantRandomRestrictionAllows(resolution.RandomTicket, resolution.TargetEquipmentType, resolution.TargetRarity, target) {
result.ErrorCode = upgradeTicketErrorForbidden
return result, nil
}
entry, ok := o.selectEnchantRandomEntry(resolution.RandomTicket.Entries)
if !ok {
result.ErrorCode = upgradeTicketErrorUnsupported
return result, nil
}
weight = entry.Weight
targetLevel = entry.TargetLevel
}
if weight < 0 {
weight = 0
}
if weight > 100000 {
weight = 100000
}
roll := int64(rand.Intn(100000))
if roll < weight {
newLevel := targetLevel
if newLevel < 0 {
newLevel = 0
}
if newLevel > 31 {
newLevel = 31
}
target = cloneStack(target)
setUpgradeLevel(&target, byte(newLevel))
items[targetKey] = target
result.NewLevel = byte(newLevel)
result.UpgradeSucceeded = true
result.ResultCode = upgradeTicketResultCodeSuccess
} else {
result.ResultCode = upgradeTicketResultCodeFailureRetain
}
material = cloneStack(material)
material.Count--
result.MaterialRemainingStackCount = material.Count
result.MaterialUpdated = true
if material.Count <= 0 {
delete(items, materialKey)
result.MaterialRemainingStackCount = 0
result.MaterialUpdatedStack = dnfrepo.ItemStack{}
} else {
updateStackRawAmount(&material)
items[materialKey] = material
result.MaterialUpdatedStack = material
}
if targetEquipped {
// The shared level logic wrote the mutated stack under the list-3 key of
// the backpack map. That key must never be persisted there; move the
// raw row and extra mirrors back into the worn entry instead.
if updatedTarget, changed := items[targetKey]; changed {
delete(items, targetKey)
targetEntry.RawEntry = append([]byte(nil), updatedTarget.RawEntry...)
targetEntry.Extra = cloneStringMapForForging(updatedTarget.Extra)
equipmentRecord.Entries[equipmentKey] = targetEntry
target = updatedTarget
}
}
now := time.Now()
record.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, dnfrepo.InventoryFieldSlots); err != nil {
return UpgradeTicketResult{}, err
}
if targetEquipped {
equipmentRecord.CharacterID = characterID
equipmentRecord.UpdatedAt = now
if err := dnfrepo.SaveEquipmentFields(ctx, o.equipment, equipmentRecord, dnfrepo.EquipmentFieldEntries); err != nil {
return UpgradeTicketResult{}, err
}
}
result.Success = true
result.ErrorCode = 0
if targetEquipped {
result.TargetUpdatedStack = cloneStack(target)
} else if updatedTarget, ok := items[targetKey]; ok {
result.TargetUpdatedStack = updatedTarget
} else {
result.TargetUpdatedStack = dnfrepo.ItemStack{}
}
result.MainRefresh = cloneItemMap(record.Slots)
result.Changed = true
return result, nil
}