服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1183 lines
41 KiB
Go
1183 lines
41 KiB
Go
package packageitem
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"math/rand"
|
||
"reflect"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
|
||
)
|
||
|
||
var (
|
||
ErrOwnerUnavailable = errors.New("package owner unavailable")
|
||
ErrCharacterRequired = errors.New("selected character id required")
|
||
ErrInventoryNotFound = errors.New("inventory record not found")
|
||
ErrSlotNotFound = errors.New("package source slot not found")
|
||
ErrMaterialNotFound = errors.New("package material slot not found")
|
||
ErrRewardMissing = errors.New("package reward evidence is missing")
|
||
ErrMagicBoxResolverRequired = errors.New("magic box requires runtime-PVF resolvers")
|
||
ErrInventoryTransactionRequired = errors.New("package mutation requires a character item transaction")
|
||
ErrMailboxTransactionRequired = errors.New("package overflow requires a mailbox asset transaction")
|
||
ErrRewardInventoryFull = errors.New("package reward target inventory is full")
|
||
)
|
||
|
||
// MagicBoxResult 描述一次随机盒开启的写库结果;Success=false 仍映射为
|
||
// 同 opcode 的 {0x00} 失败回包(该家族没有细分错误码),Changed 只表示
|
||
// repository 状态已变化。OpenCount 是实际开启次数:单开恒为 1,连开按
|
||
// 请求数、盒子数和材料数的最小值收敛。DoubleRewards 是赛丽亚幸运值满
|
||
// 触发翻倍的那一轮奖励(86JP SeriaLuck 契约,见下)。
|
||
type MagicBoxResult struct {
|
||
CharacterID string
|
||
Success bool
|
||
Reason string
|
||
ClientType byte
|
||
BoxSlotIndex int16
|
||
BoxItemID int64
|
||
MaterialSlotIndex int16
|
||
MaterialItemID int64
|
||
OpenCount int64
|
||
Rewards []MagicBoxGrantedReward
|
||
DoubleRewards []MagicBoxGrantedReward
|
||
// DisplayRewards 是 86JP GetPrimaryRewards 语义:仅基础抽取(不含翻倍
|
||
// 轮),用于赛丽亚批量回包左栏;右栏用 DoubleRewards。两个列表重复会
|
||
// 让客户端把左栏合并清空(2026-07-26 实测)。
|
||
DisplayRewards []MagicBoxGrantedReward
|
||
MainRefresh map[string]dnfrepo.ItemStack
|
||
// ChangedSlots is the exact durable inventory delta produced by this open.
|
||
// A reward item's lowest occupied slot is not necessarily the slot changed
|
||
// by this transaction: an earlier stack can already be full while a later
|
||
// stack receives the new count. The client must receive the latter in its
|
||
// incremental item update or it will keep the stale bag until relogin.
|
||
ChangedSlots []MagicBoxChangedSlot
|
||
// OverflowMailID identifies the system mail created when a main-inventory
|
||
// reward cannot be placed. The packet result omits those rows because the
|
||
// current EXE requires a real slot for every magic-box result entry.
|
||
OverflowMailID string
|
||
// DirectActivations carries premium contract units (item id -> count) that
|
||
// this open rolled but deliberately left out of the bag. The bridge turns
|
||
// them into account entitlement, so they never own a slot and never compete
|
||
// with ordinary rewards for the remaining cells.
|
||
DirectActivations map[int64]int64
|
||
Changed bool
|
||
// SeriaLuckDoubleTriggered 表示本次开启至少一轮触发了赛丽亚翻倍
|
||
// (86JP doubleFlag)。SeriaLuckBefore/After 不进入魔盒 ACK;handler 将其
|
||
// 作为独立投影交给 bridge,在原生 ACK 与增量背包刷新之后更新当前 HUD。
|
||
SeriaLuckDoubleTriggered bool
|
||
SeriaLuckBefore int64
|
||
SeriaLuckAfter int64
|
||
}
|
||
|
||
// currentSeriaLuckItemID 是 86JP SeriaLuckItemConstants.ItemTemplateId 对应
|
||
// 的当前 PVF 赛丽亚的幸运物品;只有开它才累计/触发幸运值翻倍。
|
||
const currentSeriaLuckItemID = int64(2682272)
|
||
|
||
// SeriaLuckValueMax 与 86JP SqliteAccountRepository.SeriaLuckValueMax 一致:
|
||
// 幸运值满 8 的那一轮奖励翻倍,随后清零重新累计。导出该契约供 bridge
|
||
// 把同一账号绝对值投影到当前 EXE 的 HUD gauge。
|
||
const SeriaLuckValueMax = int64(8)
|
||
|
||
// SeriaLuckMetadataKey 是账号级赛丽亚幸运值的唯一持久化键。
|
||
const SeriaLuckMetadataKey = "seria_luck_value"
|
||
|
||
const (
|
||
currentSeriaLuckValueMax = SeriaLuckValueMax
|
||
currentSeriaLuckMetadataKey = SeriaLuckMetadataKey
|
||
)
|
||
|
||
// MagicBoxGrantedReward 是聚合后的实际入库奖励行(同 item 合并数量)。
|
||
// Slot 是奖励落入的主背包槽位(连开 ACK 的 0x77 展示行需要真实槽位),
|
||
// 找不到时为 -1。装备类奖励(Kind=equipment)以 QualitySeed 代替数量展示,
|
||
// 否则客户端结果窗口渲染为"加载中"(2026-07-26 实测)。
|
||
type MagicBoxGrantedReward struct {
|
||
ItemID int64
|
||
Count int64
|
||
Slot int16
|
||
Kind string
|
||
QualitySeed uint32
|
||
Durability uint16
|
||
}
|
||
|
||
// MagicBoxChangedSlot identifies one exact inventory row changed by a magic
|
||
// box transaction. It deliberately keeps the list type because box rewards
|
||
// may target main, avatar, or pet inventories.
|
||
type MagicBoxChangedSlot struct {
|
||
ListType byte
|
||
SlotIndex int16
|
||
}
|
||
|
||
// ApplyMagicBox opens one magic box following the 86JP InventoryPackageStore
|
||
// contract: resolve the pool and material requirement from the runtime PVF,
|
||
// draw one weighted entry per group (DrawCount picks each), consume the box
|
||
// and the per-open material, then grant the aggregated rewards into the main
|
||
// inventory inside one character-item transaction. Only the single-open
|
||
// count is owned here; batch opens and the Seria luck doubling are
|
||
// intentionally not owned yet.
|
||
func (o *Owner) ApplyMagicBox(ctx context.Context, cmd MagicBoxCommand, boxResolver alignedcmd.MagicBoxResolver, rewardResolver alignedcmd.MagicBoxRewardItemResolver) (MagicBoxResult, error) {
|
||
base := MagicBoxResult{
|
||
ClientType: cmd.RawListType,
|
||
BoxSlotIndex: cmd.SlotIndex,
|
||
MaterialSlotIndex: cmd.MaterialSlotIndex,
|
||
}
|
||
if boxResolver == nil || rewardResolver == nil {
|
||
return MagicBoxResult{}, ErrMagicBoxResolverRequired
|
||
}
|
||
if o == nil || o.inventory == nil {
|
||
return MagicBoxResult{}, ErrOwnerUnavailable
|
||
}
|
||
if cmd.SelectedCharacterID == 0 {
|
||
return MagicBoxResult{}, ErrCharacterRequired
|
||
}
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
base.CharacterID = characterID
|
||
if !o.inMailboxTransaction && o.mailboxAssets != nil {
|
||
var result MagicBoxResult
|
||
err := o.mailboxAssets.WithinMailboxAssets(ctx, characterID, characterID, func(
|
||
accounts dnfrepo.AccountRepository,
|
||
_ dnfrepo.AccountInventoryRepository,
|
||
_ dnfrepo.CharacterRepository,
|
||
inventory dnfrepo.InventoryRepository,
|
||
mailboxes dnfrepo.MailboxRepository,
|
||
) error {
|
||
txOwner := *o
|
||
// A Seria source updates the account-wide luck counter. Use the
|
||
// callback's scoped account store so the write stays inside this
|
||
// mailbox-assets transaction instead of taking SQLite's writer gate
|
||
// a second time through the outer store.
|
||
txOwner.accounts = accounts
|
||
txOwner.inventory = inventory
|
||
txOwner.mailbox = mailboxes
|
||
txOwner.inMailboxTransaction = true
|
||
var err error
|
||
result, err = txOwner.ApplyMagicBox(ctx, cmd, boxResolver, rewardResolver)
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return MagicBoxResult{}, err
|
||
}
|
||
if result.CharacterID == "" {
|
||
result = base
|
||
}
|
||
return result, nil
|
||
}
|
||
if !o.inItemTransaction && !o.inMailboxTransaction {
|
||
if o.items == nil {
|
||
return MagicBoxResult{}, ErrInventoryTransactionRequired
|
||
}
|
||
var result MagicBoxResult
|
||
err := o.items.WithinCharacterItems(ctx, characterID, func(inventory dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
||
txOwner := *o
|
||
txOwner.inventory = inventory
|
||
txOwner.inItemTransaction = true
|
||
var err error
|
||
result, err = txOwner.ApplyMagicBox(ctx, cmd, boxResolver, rewardResolver)
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return MagicBoxResult{}, err
|
||
}
|
||
if result.CharacterID == "" {
|
||
result = base
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
return o.applyMagicBoxInTransaction(ctx, base, cmd, 1, 0, 0, cmd.AccountID, boxResolver, rewardResolver)
|
||
}
|
||
|
||
// ApplyMagicBoxExpand 处理 0x0468 连开(全部开启):在同一角色物品事务里按
|
||
// 请求数、盒子堆叠数和材料可用数的最小值开启 N 次,消耗 N 个盒子与
|
||
// N×单次材料,聚合全部抽奖结果一次性入库。盒子/材料 item id 以仓库堆叠
|
||
// 为准,请求里客户端自报的 id 只作一致性校验,不匹配直接失败回包。
|
||
func (o *Owner) ApplyMagicBoxExpand(ctx context.Context, cmd MagicBoxExpandCommand, boxResolver alignedcmd.MagicBoxResolver, rewardResolver alignedcmd.MagicBoxRewardItemResolver) (MagicBoxResult, error) {
|
||
base := MagicBoxResult{
|
||
ClientType: cmd.RawListType,
|
||
BoxSlotIndex: cmd.SlotIndex,
|
||
MaterialSlotIndex: cmd.MaterialSlotIndex,
|
||
}
|
||
if boxResolver == nil || rewardResolver == nil {
|
||
return MagicBoxResult{}, ErrMagicBoxResolverRequired
|
||
}
|
||
if o == nil || o.inventory == nil {
|
||
return MagicBoxResult{}, ErrOwnerUnavailable
|
||
}
|
||
if cmd.SelectedCharacterID == 0 {
|
||
return MagicBoxResult{}, ErrCharacterRequired
|
||
}
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
base.CharacterID = characterID
|
||
if !o.inMailboxTransaction && o.mailboxAssets != nil {
|
||
var result MagicBoxResult
|
||
err := o.mailboxAssets.WithinMailboxAssets(ctx, characterID, characterID, func(
|
||
accounts dnfrepo.AccountRepository,
|
||
_ dnfrepo.AccountInventoryRepository,
|
||
_ dnfrepo.CharacterRepository,
|
||
inventory dnfrepo.InventoryRepository,
|
||
mailboxes dnfrepo.MailboxRepository,
|
||
) error {
|
||
txOwner := *o
|
||
// Keep the account-level Seria luck update inside this same atomic
|
||
// mailbox-assets transaction; see ApplyMagicBox above.
|
||
txOwner.accounts = accounts
|
||
txOwner.inventory = inventory
|
||
txOwner.mailbox = mailboxes
|
||
txOwner.inMailboxTransaction = true
|
||
var err error
|
||
result, err = txOwner.ApplyMagicBoxExpand(ctx, cmd, boxResolver, rewardResolver)
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return MagicBoxResult{}, err
|
||
}
|
||
if result.CharacterID == "" {
|
||
result = base
|
||
}
|
||
return result, nil
|
||
}
|
||
if !o.inItemTransaction && !o.inMailboxTransaction {
|
||
if o.items == nil {
|
||
return MagicBoxResult{}, ErrInventoryTransactionRequired
|
||
}
|
||
var result MagicBoxResult
|
||
err := o.items.WithinCharacterItems(ctx, characterID, func(inventory dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
||
txOwner := *o
|
||
txOwner.inventory = inventory
|
||
txOwner.inItemTransaction = true
|
||
var err error
|
||
result, err = txOwner.ApplyMagicBoxExpand(ctx, cmd, boxResolver, rewardResolver)
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return MagicBoxResult{}, err
|
||
}
|
||
if result.CharacterID == "" {
|
||
result = base
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
single := MagicBoxCommand{
|
||
SelectedCharacterID: cmd.SelectedCharacterID,
|
||
RawListType: cmd.RawListType,
|
||
ListType: cmd.ListType,
|
||
SlotIndex: cmd.SlotIndex,
|
||
MaterialSlotIndex: cmd.MaterialSlotIndex,
|
||
ActivateContractUnits: cmd.ActivateContractUnits,
|
||
}
|
||
return o.applyMagicBoxInTransaction(ctx, base, single, int64(cmd.OpenCount), cmd.BoxItemID, cmd.MaterialItemID, cmd.AccountID, boxResolver, rewardResolver)
|
||
}
|
||
|
||
// applyMagicBoxInTransaction 是单开/连开共用的事务内核心;opens 是请求的
|
||
// 开启次数(单开=1),expectedBoxItemID/expectedMaterialItemID 非零时按
|
||
// 客户端自报值做一致性校验。accountID 用于赛丽亚幸运值的账号级读写,
|
||
// 为空时跳过 SeriaLuck 累计(奖励照常发放)。
|
||
func (o *Owner) applyMagicBoxInTransaction(
|
||
ctx context.Context,
|
||
result MagicBoxResult,
|
||
cmd MagicBoxCommand,
|
||
opens int64,
|
||
expectedBoxItemID uint32,
|
||
expectedMaterialItemID uint32,
|
||
accountID string,
|
||
boxResolver alignedcmd.MagicBoxResolver,
|
||
rewardResolver alignedcmd.MagicBoxRewardItemResolver,
|
||
) (MagicBoxResult, error) {
|
||
if opens < 1 {
|
||
opens = 1
|
||
}
|
||
record, _, err := o.loadInventory(ctx, cmd.SelectedCharacterID)
|
||
if err != nil {
|
||
return MagicBoxResult{}, err
|
||
}
|
||
beforeItems := cloneMagicBoxItemMap(record.Slots)
|
||
items := record.Slots
|
||
boxKey := slotKey(cmd.ListType, cmd.SlotIndex)
|
||
box, ok := items[boxKey]
|
||
if !ok || box.Count <= 0 {
|
||
result.Reason = fmt.Sprintf("box slot empty: list=%d slot=%d", cmd.ListType, cmd.SlotIndex)
|
||
return result, nil
|
||
}
|
||
if expectedBoxItemID != 0 && box.ItemID != int64(expectedBoxItemID) {
|
||
result.Reason = fmt.Sprintf("box item mismatch: slot=%d stack=%d request=%d", cmd.SlotIndex, box.ItemID, expectedBoxItemID)
|
||
return result, nil
|
||
}
|
||
result.BoxItemID = box.ItemID
|
||
|
||
resolution, err := boxResolver(box.ItemID)
|
||
if err != nil {
|
||
return MagicBoxResult{}, err
|
||
}
|
||
if resolution.Kind != "random" {
|
||
result.Reason = fmt.Sprintf("box kind unsupported: item=%d kind=%q path=%s", box.ItemID, resolution.Kind, resolution.BoxPVFPath)
|
||
return result, nil
|
||
}
|
||
|
||
var materialKey string
|
||
var material dnfrepo.ItemStack
|
||
if resolution.MaterialItemID > 0 && resolution.MaterialCountPerUse > 0 {
|
||
result.MaterialItemID = resolution.MaterialItemID
|
||
if expectedMaterialItemID != 0 && int64(expectedMaterialItemID) != resolution.MaterialItemID {
|
||
result.Reason = fmt.Sprintf("material item mismatch: request=%d resolved=%d", expectedMaterialItemID, resolution.MaterialItemID)
|
||
return result, nil
|
||
}
|
||
if cmd.MaterialSlotIndex >= 0 {
|
||
candidateKey := slotKey(listTypeMain, cmd.MaterialSlotIndex)
|
||
if candidate, found := items[candidateKey]; found && candidate.Count >= resolution.MaterialCountPerUse && candidate.ItemID == resolution.MaterialItemID {
|
||
materialKey, material = candidateKey, candidate
|
||
}
|
||
}
|
||
if materialKey == "" {
|
||
keys := make([]string, 0, len(items))
|
||
for key, stack := range items {
|
||
if stack.ItemID == resolution.MaterialItemID && stack.Count >= resolution.MaterialCountPerUse {
|
||
keys = append(keys, key)
|
||
}
|
||
}
|
||
sort.Strings(keys)
|
||
if len(keys) > 0 {
|
||
materialKey = keys[0]
|
||
material = items[materialKey]
|
||
}
|
||
}
|
||
if materialKey == "" {
|
||
result.Reason = fmt.Sprintf("material missing: item=%d need=%d", resolution.MaterialItemID, resolution.MaterialCountPerUse)
|
||
return result, nil
|
||
}
|
||
result.MaterialSlotIndex = magicBoxSlotFromKey(materialKey)
|
||
}
|
||
|
||
// 连开按实际可开次数收敛:盒子堆叠与材料堆叠都不够时直接失败。
|
||
if box.Count < opens {
|
||
opens = box.Count
|
||
}
|
||
if materialKey != "" && material.Count/resolution.MaterialCountPerUse < opens {
|
||
opens = material.Count / resolution.MaterialCountPerUse
|
||
}
|
||
if opens < 1 {
|
||
result.Reason = fmt.Sprintf("nothing openable: box=%d material=%d perUse=%d", box.Count, material.Count, resolution.MaterialCountPerUse)
|
||
return result, nil
|
||
}
|
||
|
||
// 86JP SeriaLuck:开 2682272 时按账号持久化幸运值逐轮判定,满 8 的
|
||
// 那一轮奖励翻倍(再次加入发放表并进入 DoubleRewards),随后清零重新
|
||
// 累计。非赛丽亚源跳过整段。
|
||
seriaSource := box.ItemID == currentSeriaLuckItemID
|
||
seriaLuck := int64(0)
|
||
var account dnfrepo.AccountRecord
|
||
accountFound := false
|
||
// The account is also needed when this open may carry premium contract
|
||
// rewards, whose entitlement the bridge extends instead of granting a row.
|
||
if (seriaSource || cmd.ActivateContractUnits != nil) && o.accounts != nil && accountID != "" {
|
||
loaded, found, loadErr := o.accounts.Load(ctx, accountID)
|
||
if loadErr != nil {
|
||
return MagicBoxResult{}, loadErr
|
||
}
|
||
if found {
|
||
account = dnfrepo.CloneAccount(loaded)
|
||
accountFound = true
|
||
if raw := strings.TrimSpace(account.Metadata[currentSeriaLuckMetadataKey]); raw != "" {
|
||
if value, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && value >= 0 {
|
||
seriaLuck = value
|
||
}
|
||
}
|
||
}
|
||
}
|
||
result.SeriaLuckBefore = seriaLuck
|
||
|
||
granted := make(map[int64]int64)
|
||
doubleGranted := make(map[int64]int64)
|
||
displayGranted := make(map[int64]int64)
|
||
for open := int64(0); open < opens; open++ {
|
||
drawn := drawMagicBoxRewards(resolution, 1)
|
||
for itemID, count := range drawn {
|
||
granted[itemID] += count
|
||
displayGranted[itemID] += count
|
||
}
|
||
if seriaSource {
|
||
if seriaLuck >= currentSeriaLuckValueMax {
|
||
for itemID, count := range drawn {
|
||
granted[itemID] += count
|
||
doubleGranted[itemID] += count
|
||
}
|
||
seriaLuck = 0
|
||
}
|
||
if seriaLuck < currentSeriaLuckValueMax {
|
||
seriaLuck++
|
||
}
|
||
}
|
||
}
|
||
if seriaSource {
|
||
result.SeriaLuckAfter = seriaLuck
|
||
result.SeriaLuckDoubleTriggered = len(doubleGranted) > 0
|
||
}
|
||
if len(granted) == 0 {
|
||
result.Reason = fmt.Sprintf("empty reward pool: item=%d path=%s", box.ItemID, resolution.BoxPVFPath)
|
||
return result, nil
|
||
}
|
||
|
||
rewardIDs := make([]int64, 0, len(granted))
|
||
for itemID := range granted {
|
||
rewardIDs = append(rewardIDs, itemID)
|
||
}
|
||
sort.Slice(rewardIDs, func(i, j int) bool { return rewardIDs[i] < rewardIDs[j] })
|
||
rewardItems := make(map[int64]alignedcmd.MagicBoxRewardItem, len(rewardIDs))
|
||
for _, itemID := range rewardIDs {
|
||
rewardItem, err := rewardResolver(itemID)
|
||
if err != nil {
|
||
return MagicBoxResult{}, err
|
||
}
|
||
if rewardItem.ItemID <= 0 {
|
||
result.Reason = fmt.Sprintf("reward item unresolved: %d", itemID)
|
||
return result, nil
|
||
}
|
||
rewardItems[itemID] = rewardItem
|
||
}
|
||
overflowCounts := make(map[int64]int64)
|
||
overflow := make([]dnfrepo.MailAttachment, 0)
|
||
directActivations := make(map[int64]int64)
|
||
// Premium contracts must not take a bag cell, so ask the bridge to extend the
|
||
// account entitlement for them inside this same transaction. When no
|
||
// activator is supplied (or it fails) the rewards fall back to ordinary
|
||
// placement, which keeps the old recoverable-row behaviour intact.
|
||
contractUnits := make(map[int64]int64)
|
||
for _, itemID := range rewardIDs {
|
||
if rewardItems[itemID].DirectActivation && granted[itemID] > 0 {
|
||
contractUnits[itemID] += granted[itemID]
|
||
}
|
||
}
|
||
activateContracts := len(contractUnits) > 0 && cmd.ActivateContractUnits != nil && accountFound
|
||
if activateContracts {
|
||
if activateErr := cmd.ActivateContractUnits(&account, contractUnits, time.Now().UTC()); activateErr != nil {
|
||
activateContracts = false
|
||
}
|
||
}
|
||
for _, itemID := range rewardIDs {
|
||
if activateContracts && rewardItems[itemID].DirectActivation {
|
||
// PVF premium contract: no bag row. The entitlement was extended
|
||
// above, so the reward cannot occupy a cell and cannot push an
|
||
// ordinary reward into overflow mail.
|
||
directActivations[itemID] += granted[itemID]
|
||
continue
|
||
}
|
||
remaining, stacks, grantErr := grantMagicBoxReward(items, rewardItems[itemID], granted[itemID])
|
||
if grantErr != nil {
|
||
if errors.Is(grantErr, ErrRewardInventoryFull) {
|
||
result.Reason = grantErr.Error()
|
||
return result, nil
|
||
}
|
||
return MagicBoxResult{}, grantErr
|
||
}
|
||
if remaining == 0 {
|
||
continue
|
||
}
|
||
if !o.inMailboxTransaction || o.mailbox == nil {
|
||
return MagicBoxResult{}, ErrMailboxTransactionRequired
|
||
}
|
||
overflowCounts[itemID] = remaining
|
||
for _, stack := range stacks {
|
||
overflow = append(overflow, dnfrepo.MailAttachment{
|
||
ItemID: stack.ItemID,
|
||
Count: stack.Count,
|
||
Bind: stack.Bind,
|
||
ExpireAt: stack.ExpireAt,
|
||
RawEntry: append([]byte(nil), stack.RawEntry...),
|
||
Extra: cloneMagicBoxExtra(stack.Extra),
|
||
})
|
||
}
|
||
}
|
||
|
||
box = cloneMagicBoxStack(box)
|
||
box.Count -= opens
|
||
if box.Count <= 0 {
|
||
delete(items, boxKey)
|
||
} else {
|
||
updateMagicBoxStackRawAmount(&box)
|
||
items[boxKey] = box
|
||
}
|
||
if materialKey != "" {
|
||
material = cloneMagicBoxStack(material)
|
||
material.Count -= resolution.MaterialCountPerUse * opens
|
||
if material.Count <= 0 {
|
||
delete(items, materialKey)
|
||
} else {
|
||
updateMagicBoxStackRawAmount(&material)
|
||
items[materialKey] = material
|
||
}
|
||
}
|
||
|
||
result.ChangedSlots = magicBoxChangedSlots(beforeItems, record.Slots)
|
||
record.UpdatedAt = time.Now()
|
||
if err := dnfrepo.SaveInventoryFields(ctx, o.inventory, record, dnfrepo.InventoryFieldSlots); err != nil {
|
||
return MagicBoxResult{}, err
|
||
}
|
||
if len(overflow) > 0 {
|
||
mailID, mailErr := dnfrepo.AppendSystemMail(ctx, o.mailbox, dnfrepo.SystemMailDelivery{
|
||
RecipientCharacterID: result.CharacterID,
|
||
Title: "背包已满:礼盒奖励",
|
||
Body: "背包空间不足,礼盒奖励已通过邮件发送。请清理对应道具分页后领取。",
|
||
Source: "magic_box_reward_inventory_full",
|
||
Attachments: overflow,
|
||
CreatedAt: record.UpdatedAt,
|
||
})
|
||
if mailErr != nil {
|
||
return MagicBoxResult{}, mailErr
|
||
}
|
||
result.OverflowMailID = mailID
|
||
}
|
||
// 账号保存失败则整个角色物品事务一起回滚,避免奖励已发而幸运值/契约未记。
|
||
if (seriaSource || len(directActivations) > 0) && accountFound {
|
||
if account.Metadata == nil {
|
||
account.Metadata = make(map[string]string, 4)
|
||
}
|
||
if seriaSource {
|
||
account.Metadata[currentSeriaLuckMetadataKey] = strconv.FormatInt(seriaLuck, 10)
|
||
}
|
||
account.UpdatedAt = time.Now().UTC()
|
||
if err := o.accounts.Save(ctx, account); err != nil {
|
||
return MagicBoxResult{}, err
|
||
}
|
||
}
|
||
result.Rewards = make([]MagicBoxGrantedReward, 0, len(rewardIDs))
|
||
for _, itemID := range rewardIDs {
|
||
delivered := granted[itemID] - overflowCounts[itemID]
|
||
if delivered <= 0 || directActivations[itemID] > 0 {
|
||
continue
|
||
}
|
||
result.Rewards = append(result.Rewards, magicBoxResultReward(rewardItems[itemID], delivered, magicBoxRewardSlot(record.Slots, itemID)))
|
||
}
|
||
if len(directActivations) > 0 {
|
||
result.DirectActivations = directActivations
|
||
}
|
||
if len(doubleGranted) > 0 {
|
||
doubleIDs := make([]int64, 0, len(doubleGranted))
|
||
for itemID := range doubleGranted {
|
||
doubleIDs = append(doubleIDs, itemID)
|
||
}
|
||
sort.Slice(doubleIDs, func(i, j int) bool { return doubleIDs[i] < doubleIDs[j] })
|
||
result.DoubleRewards = make([]MagicBoxGrantedReward, 0, len(doubleIDs))
|
||
for _, itemID := range doubleIDs {
|
||
if overflowCounts[itemID] > 0 || directActivations[itemID] > 0 {
|
||
continue
|
||
}
|
||
result.DoubleRewards = append(result.DoubleRewards, magicBoxResultReward(rewardItems[itemID], doubleGranted[itemID], magicBoxRewardSlot(record.Slots, itemID)))
|
||
}
|
||
}
|
||
if seriaSource {
|
||
displayIDs := make([]int64, 0, len(displayGranted))
|
||
for itemID := range displayGranted {
|
||
displayIDs = append(displayIDs, itemID)
|
||
}
|
||
sort.Slice(displayIDs, func(i, j int) bool { return displayIDs[i] < displayIDs[j] })
|
||
result.DisplayRewards = make([]MagicBoxGrantedReward, 0, len(displayIDs))
|
||
for _, itemID := range displayIDs {
|
||
if overflowCounts[itemID] > 0 || directActivations[itemID] > 0 {
|
||
continue
|
||
}
|
||
result.DisplayRewards = append(result.DisplayRewards, magicBoxResultReward(rewardItems[itemID], displayGranted[itemID], magicBoxRewardSlot(record.Slots, itemID)))
|
||
}
|
||
}
|
||
result.Success = true
|
||
result.OpenCount = opens
|
||
result.MainRefresh = cloneMagicBoxItemMap(record.Slots)
|
||
result.Changed = true
|
||
return result, nil
|
||
}
|
||
|
||
// currentMagicBoxTopQualitySeed 是 86JP ItemQuality.TopQualitySeed:礼包/魔盒
|
||
// 产出的装备统一发顶级品质种子。
|
||
const currentMagicBoxTopQualitySeed = uint32(999999998)
|
||
|
||
// magicBoxResultReward 组装一条结果奖励行;装备类按 86JP 契约带顶级品质种子
|
||
// 与 PVF 耐久(Count 恒为 1)。
|
||
func magicBoxResultReward(item alignedcmd.MagicBoxRewardItem, count int64, slot int16) MagicBoxGrantedReward {
|
||
reward := MagicBoxGrantedReward{
|
||
ItemID: item.ItemID,
|
||
Count: count,
|
||
Slot: slot,
|
||
Kind: item.Kind,
|
||
QualitySeed: 0,
|
||
Durability: item.Durability,
|
||
}
|
||
if item.Kind == "equipment" {
|
||
reward.Count = 1
|
||
reward.QualitySeed = currentMagicBoxTopQualitySeed
|
||
}
|
||
return reward
|
||
}
|
||
|
||
// drawMagicBoxRewards 按 86JP InventoryPackageStore 的组规则抽 opens 轮:
|
||
// 每组先算总权重,DrawCount 决定每轮每组抽几次,全部轮次聚合到同一张
|
||
// item->count 表。
|
||
func drawMagicBoxRewards(resolution alignedcmd.MagicBoxResolution, opens int64) map[int64]int64 {
|
||
granted := make(map[int64]int64)
|
||
if opens < 1 {
|
||
opens = 1
|
||
}
|
||
for _, group := range resolution.Groups {
|
||
totalWeight := int64(0)
|
||
for _, entry := range group.Entries {
|
||
if entry.Weight > 0 {
|
||
totalWeight += entry.Weight
|
||
}
|
||
}
|
||
if totalWeight <= 0 {
|
||
continue
|
||
}
|
||
drawCount := group.DrawCount
|
||
if drawCount < 1 {
|
||
drawCount = 1
|
||
}
|
||
for open := int64(0); open < opens; open++ {
|
||
for draw := int64(0); draw < drawCount; draw++ {
|
||
roll := rand.Int63n(totalWeight)
|
||
cumulative := int64(0)
|
||
for _, entry := range group.Entries {
|
||
if entry.Weight <= 0 {
|
||
continue
|
||
}
|
||
cumulative += entry.Weight
|
||
if roll >= cumulative {
|
||
continue
|
||
}
|
||
count := entry.Count
|
||
if count < 1 {
|
||
count = 1
|
||
}
|
||
granted[entry.ItemID] += count
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return granted
|
||
}
|
||
|
||
// grantMagicBoxReward places one aggregated reward into the main inventory:
|
||
// existing same-item-and-expiration stacks in quick slots 3-8 first, then
|
||
// ascending slots, each topped up to the PVF stack limit (0 = unlimited),
|
||
// then fresh empty slots inside the item's PVF slot range (86JP
|
||
// TryAddBoosterRewardItems order). An old expire=0 row can therefore never
|
||
// absorb a newly granted relative-period reward.
|
||
func grantMagicBoxReward(items map[string]dnfrepo.ItemStack, reward alignedcmd.MagicBoxRewardItem, count int64) (int64, []dnfrepo.ItemStack, error) {
|
||
if count <= 0 {
|
||
return 0, nil, nil
|
||
}
|
||
// A revive coin from a box belongs in the fixed slot-1 wallet, not in an
|
||
// ordinary consumable row. Left in the bag it shows as a second counter
|
||
// beside the wallet and its right-click use falls through to the
|
||
// experience/SP branch ("缺少可增加的经验值/SP"), until the next character
|
||
// select consolidates it.
|
||
if reward.TargetListType == listTypeMain && dnfrevivecoin.IsReward(reward.ItemID) {
|
||
if _, _, err := dnfrevivecoin.GrantSlots(items, count, "revive_coin_magic_box"); err != nil {
|
||
return 0, nil, err
|
||
}
|
||
return 0, nil, nil
|
||
}
|
||
targetListType := reward.TargetListType
|
||
remaining := count
|
||
// PVF [stack limit] 同时是单行上限和"这个容器里该道具的总量上限":额度按容器里
|
||
// 同 id 总量算,容器里已经有该道具的行时只能并进那一行,余量走既有溢出邮件。
|
||
// 装备(Kind=equipment,每件一行)和没写 [stack limit] 的可叠加道具沿用旧行为。
|
||
capped := reward.Kind == "stackable" && reward.StackLimit > 0
|
||
newRowStackLimit := int64(0)
|
||
space := int64(math.MaxInt32)
|
||
if capped {
|
||
newRowStackLimit = reward.StackLimit
|
||
space = dnfrepo.ContainerItemSpace(items, targetListType, reward.ItemID, reward.StackLimit)
|
||
}
|
||
fill := func(key string) {
|
||
if remaining <= 0 || space <= 0 || reward.StackLimit == 1 {
|
||
return
|
||
}
|
||
// 装备每件一行,绝不并入已有行:魔盒的装备奖励行在客户端不显示数量
|
||
// (0x77 行 0x06 字段是品质种子而不是数量,见 buildMagicBoxBatchRewardRow),
|
||
// 而 NPC 出售对 equipment 恒 applied=1(npc_shop_projection.go),所以一旦
|
||
// 把 N 件同名装备并进同一格,玩家每卖一次只少一件、格子却原样还在,看到的
|
||
// 就是「卖掉又回到背包、可以一直卖钱」。现场 2026-09-27 线上
|
||
// 109010047(传承 : 旭光精灵的碧岩双剑)被写成 count=8 的一行,单格在
|
||
// packet_log 里被连卖 29 次。新建行分支本来就强制 Count=1,这里补齐同一约定,
|
||
// 与 grantCurrentCeraShopProductWithCarryLimit 的
|
||
// `definition.Kind != dungeonDropItemStackable` 护栏保持一致。
|
||
if reward.Kind == "equipment" {
|
||
return
|
||
}
|
||
stack, ok := items[key]
|
||
if !ok || stack.ItemID != reward.ItemID || stack.Count <= 0 || !magicBoxRewardExpirationMatches(stack, reward.ExpireAt) {
|
||
return
|
||
}
|
||
if reward.StackLimit > 0 && stack.Count >= reward.StackLimit {
|
||
return
|
||
}
|
||
capacity := remaining
|
||
if reward.StackLimit > 0 {
|
||
capacity = reward.StackLimit - stack.Count
|
||
}
|
||
if capacity > space {
|
||
capacity = space
|
||
}
|
||
if capacity <= 0 {
|
||
return
|
||
}
|
||
add := remaining
|
||
if add > capacity {
|
||
add = capacity
|
||
}
|
||
stack = cloneMagicBoxStack(stack)
|
||
stack.Count += add
|
||
updateMagicBoxStackRawAmount(&stack)
|
||
items[key] = stack
|
||
remaining -= add
|
||
space -= add
|
||
}
|
||
if targetListType == listTypeMain {
|
||
for slot := int16(3); slot <= 8 && remaining > 0; slot++ {
|
||
fill(slotKey(listTypeMain, slot))
|
||
}
|
||
}
|
||
for slot := reward.SlotStart; slot <= reward.SlotEnd && remaining > 0; slot++ {
|
||
fill(slotKey(targetListType, slot))
|
||
}
|
||
for slot := reward.SlotStart; slot <= reward.SlotEnd && remaining > 0; slot++ {
|
||
key := slotKey(targetListType, slot)
|
||
if _, occupied := items[key]; occupied {
|
||
continue
|
||
}
|
||
if !dnfrepo.CanOpenContainerItemRow(items, targetListType, reward.ItemID, newRowStackLimit) {
|
||
break
|
||
}
|
||
insert := remaining
|
||
if reward.StackLimit > 0 && insert > reward.StackLimit {
|
||
insert = reward.StackLimit
|
||
}
|
||
if insert > space {
|
||
insert = space
|
||
}
|
||
if reward.Kind == "equipment" && insert > 1 {
|
||
insert = 1
|
||
}
|
||
if insert <= 0 {
|
||
break
|
||
}
|
||
stack := dnfrepo.ItemStack{
|
||
ItemID: reward.ItemID,
|
||
Count: insert,
|
||
ExpireAt: reward.ExpireAt,
|
||
Extra: map[string]string{
|
||
"item_kind": reward.Kind,
|
||
"pvf_path": reward.PVFPath,
|
||
},
|
||
}
|
||
if reward.Kind == "equipment" {
|
||
// 86JP ItemQuality 契约:礼包/魔盒装备统一发顶级品质种子与 PVF 耐久。
|
||
stack.Count = 1
|
||
stack.Extra["quality_seed"] = strconv.FormatUint(uint64(currentMagicBoxTopQualitySeed), 10)
|
||
if reward.Durability > 0 {
|
||
stack.Extra["durability"] = strconv.FormatUint(uint64(reward.Durability), 10)
|
||
stack.Extra["max_durability"] = strconv.FormatUint(uint64(reward.Durability), 10)
|
||
}
|
||
}
|
||
if reward.EquipmentType != "" {
|
||
stack.Extra["equipment_type"] = reward.EquipmentType
|
||
}
|
||
if !reward.ExpireAt.IsZero() && reward.ExpireAt.Unix() > 0 {
|
||
expireUnix := strconv.FormatInt(reward.ExpireAt.Unix(), 10)
|
||
stack.Extra["expire_time"] = expireUnix
|
||
stack.Extra["expire_unix"] = expireUnix
|
||
}
|
||
if reward.UsablePeriodDays > 0 {
|
||
stack.Extra["usable_period_days"] = strconv.FormatInt(reward.UsablePeriodDays, 10)
|
||
stack.Extra["expiration_source"] = "runtime_pvf_usable_period_grant"
|
||
}
|
||
if reward.Seal {
|
||
stack.Extra["seal_flag"] = "1"
|
||
}
|
||
updateMagicBoxStackRawAmount(&stack)
|
||
items[key] = stack
|
||
remaining -= insert
|
||
space -= insert
|
||
}
|
||
if remaining == 0 {
|
||
return 0, nil, nil
|
||
}
|
||
if targetListType != listTypeMain {
|
||
return 0, nil, fmt.Errorf("%w: target_list=%d item=%d", ErrRewardInventoryFull, targetListType, reward.ItemID)
|
||
}
|
||
left := make([]dnfrepo.ItemStack, 0)
|
||
for remaining > 0 {
|
||
part := remaining
|
||
if reward.StackLimit > 0 && part > reward.StackLimit {
|
||
part = reward.StackLimit
|
||
}
|
||
if reward.Kind == "equipment" && part > 1 {
|
||
part = 1
|
||
}
|
||
left = append(left, newMagicBoxOverflowStack(reward, part))
|
||
remaining -= part
|
||
}
|
||
var total int64
|
||
for _, stack := range left {
|
||
total += stack.Count
|
||
}
|
||
return total, left, nil
|
||
}
|
||
|
||
func newMagicBoxOverflowStack(reward alignedcmd.MagicBoxRewardItem, count int64) dnfrepo.ItemStack {
|
||
stack := dnfrepo.ItemStack{
|
||
ItemID: reward.ItemID,
|
||
Count: count,
|
||
ExpireAt: reward.ExpireAt,
|
||
Extra: map[string]string{
|
||
"item_kind": reward.Kind,
|
||
"pvf_path": reward.PVFPath,
|
||
},
|
||
}
|
||
if reward.Kind == "equipment" {
|
||
stack.Count = 1
|
||
stack.Extra["quality_seed"] = strconv.FormatUint(uint64(currentMagicBoxTopQualitySeed), 10)
|
||
if reward.Durability > 0 {
|
||
stack.Extra["durability"] = strconv.FormatUint(uint64(reward.Durability), 10)
|
||
stack.Extra["max_durability"] = strconv.FormatUint(uint64(reward.Durability), 10)
|
||
}
|
||
}
|
||
if reward.EquipmentType != "" {
|
||
stack.Extra["equipment_type"] = reward.EquipmentType
|
||
}
|
||
if !reward.ExpireAt.IsZero() && reward.ExpireAt.Unix() > 0 {
|
||
expireUnix := strconv.FormatInt(reward.ExpireAt.Unix(), 10)
|
||
stack.Extra["expire_time"] = expireUnix
|
||
stack.Extra["expire_unix"] = expireUnix
|
||
}
|
||
if reward.UsablePeriodDays > 0 {
|
||
stack.Extra["usable_period_days"] = strconv.FormatInt(reward.UsablePeriodDays, 10)
|
||
stack.Extra["expiration_source"] = "runtime_pvf_usable_period_grant"
|
||
}
|
||
if reward.Seal {
|
||
stack.Extra["seal_flag"] = "1"
|
||
}
|
||
updateMagicBoxStackRawAmount(&stack)
|
||
return stack
|
||
}
|
||
|
||
func magicBoxRewardExpirationMatches(stack dnfrepo.ItemStack, expiration time.Time) bool {
|
||
expected := int64(0)
|
||
if !expiration.IsZero() && expiration.Unix() > 0 {
|
||
expected = expiration.Unix()
|
||
}
|
||
return magicBoxStackExpireUnix(stack) == expected
|
||
}
|
||
|
||
func magicBoxStackExpireUnix(stack dnfrepo.ItemStack) int64 {
|
||
for _, key := range []string{"expire_time", "expire_unix"} {
|
||
value, err := strconv.ParseInt(strings.TrimSpace(stack.Extra[key]), 10, 64)
|
||
if err == nil && value > 0 {
|
||
return value
|
||
}
|
||
}
|
||
if !stack.ExpireAt.IsZero() && stack.ExpireAt.Unix() > 0 {
|
||
return stack.ExpireAt.Unix()
|
||
}
|
||
if len(stack.RawEntry) == currentMagicBoxEntrySize {
|
||
return int64(binary.LittleEndian.Uint32(stack.RawEntry[0x38:0x3C]))
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func magicBoxSlotFromKey(key string) int16 {
|
||
parts := strings.SplitN(key, ":", 2)
|
||
if len(parts) != 2 {
|
||
return -1
|
||
}
|
||
slot, err := strconv.ParseInt(parts[1], 10, 16)
|
||
if err != nil {
|
||
return -1
|
||
}
|
||
return int16(slot)
|
||
}
|
||
|
||
// magicBoxRewardSlot 返回主背包里持有该奖励 item 的最低槽位;连开 ACK 的
|
||
// 0x77 展示行必须引用真实槽位,否则客户端结果窗口无法定位奖励。
|
||
func magicBoxRewardSlot(items map[string]dnfrepo.ItemStack, itemID int64) int16 {
|
||
// Revive coins were folded into the wallet, so the consumable id has no
|
||
// row of its own to find. The result popup still has to name a slot.
|
||
if dnfrevivecoin.IsReward(itemID) {
|
||
return dnfrevivecoin.WalletSlot
|
||
}
|
||
best := int16(-1)
|
||
for key, stack := range items {
|
||
if stack.ItemID != itemID || stack.Count <= 0 {
|
||
continue
|
||
}
|
||
parts := strings.SplitN(key, ":", 2)
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
slot := magicBoxSlotFromKey(key)
|
||
if slot < 0 {
|
||
continue
|
||
}
|
||
if best < 0 || slot < best {
|
||
best = slot
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
func magicBoxChangedSlots(before, after map[string]dnfrepo.ItemStack) []MagicBoxChangedSlot {
|
||
keys := make(map[string]struct{}, len(before)+len(after))
|
||
for key := range before {
|
||
keys[key] = struct{}{}
|
||
}
|
||
for key := range after {
|
||
keys[key] = struct{}{}
|
||
}
|
||
changed := make([]MagicBoxChangedSlot, 0, len(keys))
|
||
for key := range keys {
|
||
beforeStack, beforeFound := before[key]
|
||
afterStack, afterFound := after[key]
|
||
if beforeFound == afterFound && (!beforeFound || reflect.DeepEqual(beforeStack, afterStack)) {
|
||
continue
|
||
}
|
||
listType, slot, ok := magicBoxListAndSlotFromKey(key)
|
||
if !ok {
|
||
continue
|
||
}
|
||
changed = append(changed, MagicBoxChangedSlot{ListType: listType, SlotIndex: slot})
|
||
}
|
||
sort.Slice(changed, func(i, j int) bool {
|
||
if changed[i].ListType != changed[j].ListType {
|
||
return changed[i].ListType < changed[j].ListType
|
||
}
|
||
return changed[i].SlotIndex < changed[j].SlotIndex
|
||
})
|
||
return changed
|
||
}
|
||
|
||
func magicBoxListAndSlotFromKey(key string) (byte, int16, bool) {
|
||
parts := strings.SplitN(key, ":", 2)
|
||
if len(parts) != 2 {
|
||
return 0, 0, false
|
||
}
|
||
listType, err := strconv.ParseUint(parts[0], 10, 8)
|
||
if err != nil {
|
||
return 0, 0, false
|
||
}
|
||
slot, err := strconv.ParseInt(parts[1], 10, 16)
|
||
if err != nil || slot < 0 {
|
||
return 0, 0, false
|
||
}
|
||
return byte(listType), int16(slot), true
|
||
}
|
||
|
||
func cloneMagicBoxStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
|
||
out := stack
|
||
if stack.Extra != nil {
|
||
extra := make(map[string]string, len(stack.Extra))
|
||
for key, value := range stack.Extra {
|
||
extra[key] = value
|
||
}
|
||
out.Extra = extra
|
||
}
|
||
if stack.RawEntry != nil {
|
||
out.RawEntry = append([]byte(nil), stack.RawEntry...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func cloneMagicBoxExtra(extra map[string]string) map[string]string {
|
||
if len(extra) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string]string, len(extra))
|
||
for key, value := range extra {
|
||
out[key] = value
|
||
}
|
||
return out
|
||
}
|
||
|
||
func updateMagicBoxStackRawAmount(stack *dnfrepo.ItemStack) {
|
||
if stack == nil {
|
||
return
|
||
}
|
||
if stack.Extra == nil {
|
||
stack.Extra = make(map[string]string, 4)
|
||
}
|
||
stack.Extra["amount_or_count"] = strconv.FormatInt(stack.Count, 10)
|
||
stack.Extra["amount"] = strconv.FormatInt(stack.Count, 10)
|
||
if len(stack.RawEntry) == currentMagicBoxEntrySize {
|
||
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
||
value := uint32(stack.Count)
|
||
stack.RawEntry[0x06] = byte(value)
|
||
stack.RawEntry[0x07] = byte(value >> 8)
|
||
stack.RawEntry[0x08] = byte(value >> 16)
|
||
stack.RawEntry[0x09] = byte(value >> 24)
|
||
}
|
||
}
|
||
|
||
func cloneMagicBoxItemMap(items map[string]dnfrepo.ItemStack) map[string]dnfrepo.ItemStack {
|
||
out := make(map[string]dnfrepo.ItemStack, len(items))
|
||
for key, stack := range items {
|
||
out[key] = cloneMagicBoxStack(stack)
|
||
}
|
||
return out
|
||
}
|
||
|
||
const currentMagicBoxEntrySize = 0x77
|
||
|
||
// SelectableCommand 描述可选礼包开启前的库存预检。
|
||
type SelectableCommand struct {
|
||
SelectedCharacterID uint16
|
||
SlotIndex int16
|
||
SelectedItemTemplateID int32
|
||
}
|
||
|
||
// MagicBoxCommand 描述随机盒子单开前的库存预检。
|
||
type MagicBoxCommand struct {
|
||
SelectedCharacterID uint16
|
||
AccountID string
|
||
RawListType byte
|
||
ListType byte
|
||
SlotIndex int16
|
||
MaterialSlotIndex int16
|
||
// ActivateContractUnits is optional. When set, rewards the runtime PVF marks
|
||
// as premium contracts are handed to it (inside this transaction) instead of
|
||
// taking a bag cell; see AlignmentContractActivator in alignedcmd.
|
||
ActivateContractUnits alignedcmd.MagicBoxContractActivator
|
||
}
|
||
|
||
// MagicBoxExpandCommand 描述 0x0468 随机盒连开请求;BoxItemID 与
|
||
// MaterialItemID 是客户端自报值,仅用于和仓库堆叠/PVF 解析结果做一致性
|
||
// 校验,不参与发放决策。
|
||
type MagicBoxExpandCommand struct {
|
||
SelectedCharacterID uint16
|
||
AccountID string
|
||
RawListType byte
|
||
ListType byte
|
||
SlotIndex int16
|
||
BoxItemID uint32
|
||
MaterialSlotIndex int16
|
||
MaterialItemID uint32
|
||
OpenCount uint16
|
||
// ActivateContractUnits mirrors MagicBoxCommand's optional activator.
|
||
ActivateContractUnits alignedcmd.MagicBoxContractActivator
|
||
}
|
||
|
||
// PlanResult 描述已验证的开包输入;它不代表已经消耗物品或发放奖励。
|
||
type PlanResult struct {
|
||
CharacterID string
|
||
SourceListType byte
|
||
SourceSlotIndex int16
|
||
SourceItemID int64
|
||
MaterialSlotIndex int16
|
||
MaterialItemID int64
|
||
SelectedItemTemplateID int64
|
||
}
|
||
|
||
// Owner 只负责礼包/随机箱的可靠边界预检,不在奖励包体闭合前提交资产变更。
|
||
type Owner struct {
|
||
inventory dnfrepo.InventoryRepository
|
||
items dnfrepo.CharacterItemUnitOfWork
|
||
accounts dnfrepo.AccountRepository
|
||
mailbox dnfrepo.MailboxRepository
|
||
mailboxAssets dnfrepo.MailboxAssetUnitOfWork
|
||
inItemTransaction bool
|
||
inMailboxTransaction bool
|
||
}
|
||
|
||
// NewOwner 创建礼包 owner;缺少背包仓储时拒绝处理。
|
||
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
|
||
if repos.Inventory == nil {
|
||
return nil, ErrOwnerUnavailable
|
||
}
|
||
return &Owner{
|
||
inventory: repos.Inventory,
|
||
items: repos.CharacterItems,
|
||
accounts: repos.Account,
|
||
mailbox: repos.Mailbox,
|
||
mailboxAssets: repos.MailboxAssets,
|
||
}, nil
|
||
}
|
||
|
||
// PlanSelectable 校验可选礼包源物品和选择奖励;不扣物品、不写奖励。
|
||
func (o *Owner) PlanSelectable(ctx context.Context, cmd SelectableCommand) (PlanResult, error) {
|
||
if cmd.SelectedItemTemplateID <= 0 {
|
||
return PlanResult{}, ErrRewardMissing
|
||
}
|
||
record, characterID, err := o.loadInventory(ctx, cmd.SelectedCharacterID)
|
||
if err != nil {
|
||
return PlanResult{}, err
|
||
}
|
||
stack, ok := record.Slots[slotKey(listTypeMain, cmd.SlotIndex)]
|
||
if !ok || stack.Count <= 0 {
|
||
return PlanResult{}, fmt.Errorf("%w: slot=%d", ErrSlotNotFound, cmd.SlotIndex)
|
||
}
|
||
return PlanResult{
|
||
CharacterID: characterID,
|
||
SourceListType: listTypeMain,
|
||
SourceSlotIndex: cmd.SlotIndex,
|
||
SourceItemID: stack.ItemID,
|
||
SelectedItemTemplateID: int64(cmd.SelectedItemTemplateID),
|
||
}, nil
|
||
}
|
||
|
||
// PlanMagicBox 校验随机箱源物品和可选材料格;不执行随机、不扣物品、不发奖。
|
||
func (o *Owner) PlanMagicBox(ctx context.Context, cmd MagicBoxCommand) (PlanResult, error) {
|
||
record, characterID, err := o.loadInventory(ctx, cmd.SelectedCharacterID)
|
||
if err != nil {
|
||
return PlanResult{}, err
|
||
}
|
||
stack, ok := record.Slots[slotKey(cmd.ListType, cmd.SlotIndex)]
|
||
if !ok || stack.Count <= 0 {
|
||
return PlanResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.ListType, cmd.SlotIndex)
|
||
}
|
||
result := PlanResult{
|
||
CharacterID: characterID,
|
||
SourceListType: cmd.ListType,
|
||
SourceSlotIndex: cmd.SlotIndex,
|
||
SourceItemID: stack.ItemID,
|
||
MaterialSlotIndex: cmd.MaterialSlotIndex,
|
||
}
|
||
if cmd.MaterialSlotIndex >= 0 {
|
||
material, ok := record.Slots[slotKey(listTypeMain, cmd.MaterialSlotIndex)]
|
||
if !ok || material.Count <= 0 {
|
||
return PlanResult{}, fmt.Errorf("%w: slot=%d", ErrMaterialNotFound, cmd.MaterialSlotIndex)
|
||
}
|
||
result.MaterialItemID = material.ItemID
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (o *Owner) loadInventory(ctx context.Context, selectedCharacterID uint16) (dnfrepo.InventoryRecord, string, error) {
|
||
if o == nil || o.inventory == nil {
|
||
return dnfrepo.InventoryRecord{}, "", ErrOwnerUnavailable
|
||
}
|
||
if selectedCharacterID == 0 {
|
||
return dnfrepo.InventoryRecord{}, "", ErrCharacterRequired
|
||
}
|
||
characterID := strconv.FormatUint(uint64(selectedCharacterID), 10)
|
||
record, ok, err := o.inventory.Load(ctx, characterID)
|
||
if err != nil {
|
||
return dnfrepo.InventoryRecord{}, "", err
|
||
}
|
||
if !ok {
|
||
return dnfrepo.InventoryRecord{}, "", ErrInventoryNotFound
|
||
}
|
||
record = dnfrepo.CloneInventory(record)
|
||
if record.Slots == nil {
|
||
record.Slots = make(map[string]dnfrepo.ItemStack)
|
||
}
|
||
return record, characterID, nil
|
||
}
|
||
|
||
func slotKey(listType byte, slotIndex int16) string {
|
||
return fmt.Sprintf("%d:%d", listType, slotIndex)
|
||
}
|