服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
931 lines
38 KiB
Go
931 lines
38 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfitemdailylimit "longheng.io/server/internal/modules/dnf/itemdailylimit"
|
||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
const (
|
||
currentCeraPackageRequestHeaderSize = 3
|
||
currentCeraPackageChoiceStride = 5
|
||
currentCeraPackageMaxChoices = 32
|
||
currentCeraPackageMaxRewardRows = 128
|
||
currentCeraPackageMaxRewardAmount = 1_000_000
|
||
)
|
||
|
||
var (
|
||
errCurrentCeraPackageRequestMalformed = errors.New("dnf cera package request is malformed")
|
||
errCurrentCeraPackageOwnerUnavailable = errors.New("dnf cera package selected character is unavailable")
|
||
errCurrentCeraPackageSourceMissing = errors.New("dnf cera package source item is unavailable")
|
||
errCurrentCeraPackagePVFInvalid = errors.New("dnf cera package PVF data is invalid")
|
||
errCurrentCeraPackageChoiceInvalid = errors.New("dnf cera package selection is invalid")
|
||
errCurrentCeraPackageExpired = errors.New("dnf cera package has expired")
|
||
// errCurrentCeraPackageDailyUseLimitReached:PVF `[total usable count]`(客户端
|
||
// 悬浮说明里的「每天可用次数」)今天已经用满。礼包同样是消耗品,上限只由道具自己的
|
||
// PVF 决定,所以这条路径用与 op44/op515/开箱完全相同的那份计数。
|
||
errCurrentCeraPackageDailyUseLimitReached = errors.New("dnf cera package source item reached its daily usable count")
|
||
)
|
||
|
||
// currentCeraPackageOpenRequest is the exact current-EXE op518 body written by
|
||
// sub_1EC46D0: source slot, selected-avatar count, then item/option pairs.
|
||
// A zero count is valid for packages whose PVF rewards contain no avatars.
|
||
// Item quantities never come from the client; they are resolved from PVF.
|
||
type currentCeraPackageOpenRequest struct {
|
||
SourceSlot int16
|
||
Choices []currentCeraPackageChoice
|
||
}
|
||
|
||
type currentCeraPackageChoice struct {
|
||
ItemID uint32
|
||
Option byte
|
||
}
|
||
|
||
type currentCeraPackageDefinition struct {
|
||
Source dungeonDropItemDefinition
|
||
StackableType string
|
||
Rewards []currentCeraPackageReward
|
||
SelectionGroups [][]currentCeraPackageReward
|
||
AvatarItemIDs []uint32
|
||
}
|
||
|
||
type currentCeraPackageReward struct {
|
||
Definition dungeonDropItemDefinition
|
||
Count uint32
|
||
Avatar bool
|
||
Option byte
|
||
}
|
||
|
||
type currentCeraPackageCommitResult struct {
|
||
ChangedListTypes []byte
|
||
RewardSlots []alignedcmd.ItemSlotRefresh
|
||
SourceRemaining uint32
|
||
DisplayRewards []currentBoosterGrantedReward
|
||
RewardRows int
|
||
RewardUnits uint64
|
||
OverflowMailID string
|
||
OverflowItems int
|
||
// SourceDailyUsed / SourceDailyLimit 是这次开启之后的「每天可用次数」进度
|
||
// (PVF `[total usable count]`)。Limit<=0 表示该礼包没有声明上限。
|
||
SourceDailyUsed int64
|
||
SourceDailyLimit int64
|
||
}
|
||
|
||
func parseCurrentCeraPackageOpenRequest(body []byte) (currentCeraPackageOpenRequest, error) {
|
||
if len(body) < currentCeraPackageRequestHeaderSize {
|
||
return currentCeraPackageOpenRequest{}, errCurrentCeraPackageRequestMalformed
|
||
}
|
||
count := int(body[2])
|
||
if count > currentCeraPackageMaxChoices || len(body) != currentCeraPackageRequestHeaderSize+count*currentCeraPackageChoiceStride {
|
||
return currentCeraPackageOpenRequest{}, errCurrentCeraPackageRequestMalformed
|
||
}
|
||
request := currentCeraPackageOpenRequest{
|
||
SourceSlot: int16(binary.LittleEndian.Uint16(body[0:2])),
|
||
Choices: make([]currentCeraPackageChoice, 0, count),
|
||
}
|
||
seen := make(map[uint32]struct{}, count)
|
||
for index := 0; index < count; index++ {
|
||
offset := currentCeraPackageRequestHeaderSize + index*currentCeraPackageChoiceStride
|
||
itemID := binary.LittleEndian.Uint32(body[offset : offset+4])
|
||
if itemID == 0 {
|
||
return currentCeraPackageOpenRequest{}, errCurrentCeraPackageRequestMalformed
|
||
}
|
||
if _, duplicate := seen[itemID]; duplicate {
|
||
return currentCeraPackageOpenRequest{}, fmt.Errorf("%w: duplicate item=%d", errCurrentCeraPackageChoiceInvalid, itemID)
|
||
}
|
||
seen[itemID] = struct{}{}
|
||
request.Choices = append(request.Choices, currentCeraPackageChoice{ItemID: itemID, Option: body[offset+4]})
|
||
}
|
||
return request, nil
|
||
}
|
||
|
||
func resolveCurrentCeraPackageDefinition(
|
||
catalog *pvfDungeonDropCatalog,
|
||
sourceItemID uint32,
|
||
) (currentCeraPackageDefinition, error) {
|
||
if catalog == nil || catalog.source == nil || sourceItemID == 0 {
|
||
return currentCeraPackageDefinition{}, errCurrentCeraPackagePVFInvalid
|
||
}
|
||
sourceDefinition, err := catalog.ResolveItem(sourceItemID)
|
||
if err != nil {
|
||
return currentCeraPackageDefinition{}, fmt.Errorf("%w: resolve source item=%d: %v", errCurrentCeraPackagePVFInvalid, sourceItemID, err)
|
||
}
|
||
definition, recognized, err := resolveCurrentCeraPackageDefinitionForSource(catalog, sourceDefinition)
|
||
if err != nil {
|
||
return currentCeraPackageDefinition{}, err
|
||
}
|
||
if !recognized {
|
||
return currentCeraPackageDefinition{}, fmt.Errorf("%w: source item=%d stackable_type=%q", errCurrentCeraPackagePVFInvalid, sourceItemID, sourceDefinition.StackableType)
|
||
}
|
||
if err := validateCurrentCeraPackageRewardGraph(catalog, sourceItemID); err != nil {
|
||
return currentCeraPackageDefinition{}, err
|
||
}
|
||
return definition, nil
|
||
}
|
||
|
||
// resolveCurrentCeraPackageDefinitionForSource is the single PVF parser shared
|
||
// by op64 checkout and inventory op518. [cera package] and
|
||
// [usable cera package] have the same fixed and repeated-selection grammar;
|
||
// only their owning protocol differs. Keeping the grammar here prevents the
|
||
// two paths from silently disagreeing about historical nested gift boxes.
|
||
func resolveCurrentCeraPackageDefinitionForSource(
|
||
catalog *pvfDungeonDropCatalog,
|
||
sourceDefinition dungeonDropItemDefinition,
|
||
) (currentCeraPackageDefinition, bool, error) {
|
||
if catalog == nil || catalog.source == nil || sourceDefinition.ItemID == 0 {
|
||
return currentCeraPackageDefinition{}, false, errCurrentCeraPackagePVFInvalid
|
||
}
|
||
if sourceDefinition.Kind != dungeonDropItemStackable {
|
||
return currentCeraPackageDefinition{}, false, nil
|
||
}
|
||
document, err := parseDungeonCardPVFDocument(catalog.source, sourceDefinition.PVFPath)
|
||
if err != nil {
|
||
return currentCeraPackageDefinition{}, false, fmt.Errorf("%w: read source item=%d: %v", errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID, err)
|
||
}
|
||
stackableType := normalizeDungeonDropStackableType(magicBoxDocumentText(document, "stackable type"))
|
||
if stackableType != "[cera package]" && stackableType != "[usable cera package]" {
|
||
return currentCeraPackageDefinition{}, false, nil
|
||
}
|
||
definition := currentCeraPackageDefinition{
|
||
Source: sourceDefinition,
|
||
StackableType: stackableType,
|
||
}
|
||
avatarIDs := make(map[uint32]struct{})
|
||
var total uint64
|
||
rowCount := 0
|
||
parseRows := func(values []int64, label string, collectFixedAvatars bool) ([]currentCeraPackageReward, error) {
|
||
if len(values) == 0 || len(values)%2 != 0 {
|
||
return nil, fmt.Errorf("%w: source item=%d %s_values=%d", errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID, label, len(values))
|
||
}
|
||
rowCount += len(values) / 2
|
||
if rowCount > currentCeraPackageMaxRewardRows {
|
||
return nil, fmt.Errorf("%w: source item=%d reward_rows=%d", errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID, rowCount)
|
||
}
|
||
rewards := make([]currentCeraPackageReward, 0, len(values)/2)
|
||
seen := make(map[uint32]struct{}, len(values)/2)
|
||
for offset := 0; offset < len(values); offset += 2 {
|
||
itemValue, countValue := values[offset], values[offset+1]
|
||
if itemValue <= 0 || itemValue > math.MaxUint32 || countValue <= 0 || countValue > currentCeraPackageMaxRewardAmount {
|
||
return nil, fmt.Errorf("%w: source item=%d %s_pair=%d/%d", errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID, label, itemValue, countValue)
|
||
}
|
||
itemID := uint32(itemValue)
|
||
if _, duplicate := seen[itemID]; duplicate && !collectFixedAvatars {
|
||
return nil, fmt.Errorf("%w: source item=%d %s duplicate reward=%d", errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID, label, itemID)
|
||
}
|
||
seen[itemID] = struct{}{}
|
||
total += uint64(countValue)
|
||
if total > currentCeraPackageMaxRewardAmount {
|
||
return nil, fmt.Errorf("%w: source item=%d declared_reward_total=%d", errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID, total)
|
||
}
|
||
rewardDefinition, resolveErr := catalog.ResolveItem(itemID)
|
||
if resolveErr != nil {
|
||
return nil, fmt.Errorf("%w: source item=%d reward item=%d: %v", errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID, itemValue, resolveErr)
|
||
}
|
||
avatar, avatarErr := currentCeraPackageRewardIsAvatar(catalog.source, rewardDefinition)
|
||
if avatarErr != nil {
|
||
return nil, fmt.Errorf("%w: source item=%d reward item=%d: %v", errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID, itemValue, avatarErr)
|
||
}
|
||
reward := currentCeraPackageReward{Definition: rewardDefinition, Count: uint32(countValue), Avatar: avatar}
|
||
rewards = append(rewards, reward)
|
||
if collectFixedAvatars && avatar {
|
||
if _, exists := avatarIDs[itemID]; !exists {
|
||
avatarIDs[itemID] = struct{}{}
|
||
definition.AvatarItemIDs = append(definition.AvatarItemIDs, itemID)
|
||
}
|
||
}
|
||
}
|
||
return rewards, nil
|
||
}
|
||
if values := document.Ints("package data include usable period"); len(values) > 0 {
|
||
// Legacy 2010 cash packages store (item, count, usable-period-days)
|
||
// triplets in this section. The current package runtime does not retain
|
||
// the per-row period, but the item/count pairs are the authoritative
|
||
// rewards and must be accepted instead of reporting "has no rewards".
|
||
if len(values)%3 != 0 {
|
||
return currentCeraPackageDefinition{}, true, fmt.Errorf(
|
||
"%w: source item=%d package_data_include_usable_period_values=%d",
|
||
errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID, len(values))
|
||
}
|
||
pairs := make([]int64, 0, (len(values)/3)*2)
|
||
for offset := 0; offset < len(values); offset += 3 {
|
||
pairs = append(pairs, values[offset], values[offset+1])
|
||
}
|
||
definition.Rewards, err = parseRows(pairs, "package_data_include_usable_period", true)
|
||
if err != nil {
|
||
return currentCeraPackageDefinition{}, true, err
|
||
}
|
||
}
|
||
if values := document.Ints("package data"); len(values) > 0 {
|
||
definition.Rewards, err = parseRows(values, "package_data", true)
|
||
if err != nil {
|
||
return currentCeraPackageDefinition{}, true, err
|
||
}
|
||
}
|
||
selectionItems := make(map[uint32]int)
|
||
for _, section := range document.Sections {
|
||
if !strings.EqualFold(strings.TrimSpace(section.Name), "package data selection") {
|
||
continue
|
||
}
|
||
group, groupErr := parseRows(magicBoxSectionInts(document, section), "package_data_selection", false)
|
||
if groupErr != nil {
|
||
return currentCeraPackageDefinition{}, true, groupErr
|
||
}
|
||
groupIndex := len(definition.SelectionGroups)
|
||
for _, reward := range group {
|
||
if previous, duplicate := selectionItems[reward.Definition.ItemID]; duplicate {
|
||
return currentCeraPackageDefinition{}, true, fmt.Errorf(
|
||
"%w: source item=%d selection reward=%d appears in groups=%d/%d",
|
||
errCurrentCeraPackagePVFInvalid,
|
||
sourceDefinition.ItemID,
|
||
reward.Definition.ItemID,
|
||
previous,
|
||
groupIndex,
|
||
)
|
||
}
|
||
selectionItems[reward.Definition.ItemID] = groupIndex
|
||
}
|
||
definition.SelectionGroups = append(definition.SelectionGroups, group)
|
||
}
|
||
if len(definition.Rewards) == 0 && len(definition.SelectionGroups) == 0 {
|
||
return currentCeraPackageDefinition{}, true, fmt.Errorf("%w: source item=%d has no rewards", errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID)
|
||
}
|
||
if len(definition.AvatarItemIDs) > currentCeraPackageMaxChoices {
|
||
return currentCeraPackageDefinition{}, true, fmt.Errorf("%w: source item=%d avatar_choices=%d", errCurrentCeraPackagePVFInvalid, sourceDefinition.ItemID, len(definition.AvatarItemIDs))
|
||
}
|
||
return definition, true, nil
|
||
}
|
||
|
||
func currentCeraPackageRewardIsAvatar(source dnfpvf.Source, definition dungeonDropItemDefinition) (bool, error) {
|
||
if definition.Kind != dungeonDropItemEquipment {
|
||
return false, nil
|
||
}
|
||
document, err := parseDungeonCardPVFDocument(source, definition.PVFPath)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
equipmentType, found := document.Text("equipment type")
|
||
if !found {
|
||
return false, nil
|
||
}
|
||
rule, supported := currentEquipmentPlacementRuleForPVFType(equipmentType)
|
||
return supported && rule.class == currentEquipmentPlacementClassAvatar, nil
|
||
}
|
||
|
||
func validateCurrentCeraPackageChoices(
|
||
definition currentCeraPackageDefinition,
|
||
choices []currentCeraPackageChoice,
|
||
) (map[uint32]byte, error) {
|
||
if len(choices) != len(definition.AvatarItemIDs) {
|
||
return nil, fmt.Errorf("%w: got=%d want=%d", errCurrentCeraPackageChoiceInvalid, len(choices), len(definition.AvatarItemIDs))
|
||
}
|
||
expected := make(map[uint32]struct{}, len(definition.AvatarItemIDs))
|
||
for _, itemID := range definition.AvatarItemIDs {
|
||
expected[itemID] = struct{}{}
|
||
}
|
||
options := make(map[uint32]byte, len(choices))
|
||
for _, choice := range choices {
|
||
if _, ok := expected[choice.ItemID]; !ok {
|
||
return nil, fmt.Errorf("%w: item=%d is not an avatar reward", errCurrentCeraPackageChoiceInvalid, choice.ItemID)
|
||
}
|
||
if _, duplicate := options[choice.ItemID]; duplicate {
|
||
return nil, fmt.Errorf("%w: duplicate item=%d", errCurrentCeraPackageChoiceInvalid, choice.ItemID)
|
||
}
|
||
options[choice.ItemID] = choice.Option
|
||
}
|
||
for itemID := range expected {
|
||
if _, ok := options[itemID]; !ok {
|
||
return nil, fmt.Errorf("%w: avatar item=%d was not selected", errCurrentCeraPackageChoiceInvalid, itemID)
|
||
}
|
||
}
|
||
return options, nil
|
||
}
|
||
|
||
// resolveCurrentCeraPackageOpenRewards applies the op518 item/option pairs to
|
||
// the shared package definition. A pair can serve two roles at once: select
|
||
// one row from a repeated [package data selection] group and carry the avatar
|
||
// option for that selected reward. Singleton groups are deterministic and may
|
||
// be omitted by the client; every group with alternatives requires one explicit
|
||
// item choice. No unconsumed choice is tolerated.
|
||
func resolveCurrentCeraPackageOpenRewards(
|
||
definition currentCeraPackageDefinition,
|
||
choices []currentCeraPackageChoice,
|
||
) ([]currentCeraPackageReward, error) {
|
||
choiceByItem := make(map[uint32]currentCeraPackageChoice, len(choices))
|
||
for _, choice := range choices {
|
||
if choice.ItemID == 0 {
|
||
return nil, errCurrentCeraPackageChoiceInvalid
|
||
}
|
||
if _, duplicate := choiceByItem[choice.ItemID]; duplicate {
|
||
return nil, fmt.Errorf("%w: duplicate item=%d", errCurrentCeraPackageChoiceInvalid, choice.ItemID)
|
||
}
|
||
choiceByItem[choice.ItemID] = choice
|
||
}
|
||
|
||
used := make(map[uint32]struct{}, len(choices))
|
||
rewards := append([]currentCeraPackageReward(nil), definition.Rewards...)
|
||
for groupIndex, group := range definition.SelectionGroups {
|
||
if len(group) == 0 {
|
||
return nil, fmt.Errorf("%w: selection group=%d is empty", errCurrentCeraPackagePVFInvalid, groupIndex)
|
||
}
|
||
selectedIndex := -1
|
||
for index, candidate := range group {
|
||
if _, selected := choiceByItem[candidate.Definition.ItemID]; !selected {
|
||
continue
|
||
}
|
||
if selectedIndex >= 0 {
|
||
return nil, fmt.Errorf("%w: selection group=%d has multiple choices", errCurrentCeraPackageChoiceInvalid, groupIndex)
|
||
}
|
||
selectedIndex = index
|
||
}
|
||
if selectedIndex < 0 {
|
||
if len(group) != 1 {
|
||
return nil, fmt.Errorf("%w: selection group=%d has no explicit choice", errCurrentCeraPackageChoiceInvalid, groupIndex)
|
||
}
|
||
selectedIndex = 0
|
||
}
|
||
selected := group[selectedIndex]
|
||
if choice, explicit := choiceByItem[selected.Definition.ItemID]; explicit {
|
||
used[selected.Definition.ItemID] = struct{}{}
|
||
if selected.Avatar {
|
||
selected.Option = choice.Option
|
||
} else if choice.Option != 0 {
|
||
return nil, fmt.Errorf("%w: non-avatar selection item=%d option=%d", errCurrentCeraPackageChoiceInvalid, choice.ItemID, choice.Option)
|
||
}
|
||
}
|
||
rewards = append(rewards, selected)
|
||
}
|
||
|
||
avatarItems := make(map[uint32]struct{})
|
||
for index := range rewards {
|
||
if !rewards[index].Avatar {
|
||
continue
|
||
}
|
||
itemID := rewards[index].Definition.ItemID
|
||
choice, selected := choiceByItem[itemID]
|
||
if !selected {
|
||
return nil, fmt.Errorf("%w: avatar item=%d was not selected", errCurrentCeraPackageChoiceInvalid, itemID)
|
||
}
|
||
rewards[index].Option = choice.Option
|
||
used[itemID] = struct{}{}
|
||
avatarItems[itemID] = struct{}{}
|
||
}
|
||
if len(used) != len(choiceByItem) {
|
||
for itemID := range choiceByItem {
|
||
if _, ok := used[itemID]; !ok {
|
||
return nil, fmt.Errorf("%w: item=%d is not required by the selected package rewards", errCurrentCeraPackageChoiceInvalid, itemID)
|
||
}
|
||
}
|
||
}
|
||
if len(avatarItems) > currentCeraPackageMaxChoices || len(rewards) > currentCeraPackageMaxRewardRows {
|
||
return nil, errCurrentCeraPackagePVFInvalid
|
||
}
|
||
var total uint64
|
||
for _, reward := range rewards {
|
||
total += uint64(reward.Count)
|
||
if total > currentCeraPackageMaxRewardAmount {
|
||
return nil, fmt.Errorf("%w: selected reward total=%d", errCurrentCeraPackagePVFInvalid, total)
|
||
}
|
||
}
|
||
return rewards, nil
|
||
}
|
||
|
||
func validateCurrentCeraPackageSourceExpiration(
|
||
stack dnfrepo.ItemStack,
|
||
source dungeonDropItemDefinition,
|
||
now time.Time,
|
||
) error {
|
||
if now.IsZero() {
|
||
now = time.Now().UTC()
|
||
}
|
||
if expire := currentItemListStackExpire(stack); expire != 0 {
|
||
if uint64(expire) <= uint64(now.Unix()) {
|
||
return errCurrentCeraPackageExpired
|
||
}
|
||
return nil
|
||
}
|
||
if source.UsablePeriodDays > 0 {
|
||
return errCurrentCeraPackageExpired
|
||
}
|
||
if !source.ExpirationDate.IsZero() && !now.Before(source.ExpirationDate) {
|
||
return errCurrentCeraPackageExpired
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) handleCurrentCeraPackageOpen(session *gameSession, body []byte) error {
|
||
request, err := parseCurrentCeraPackageOpenRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-cera-package-open-rejected", "body_len", len(body), "reason", err)
|
||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketOpenCerapackage), 4)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
catalog, err := s.currentPVFItemCatalog()
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-cera-package-open-rejected", "source_slot", request.SourceSlot, "reason", err)
|
||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketOpenCerapackage), 4)
|
||
}
|
||
definition, err := s.prepareCurrentCeraPackage(ctx, session, catalog, request)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-cera-package-open-rejected", "source_slot", request.SourceSlot, "choice_count", len(request.Choices), "reason", err)
|
||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketOpenCerapackage), 4)
|
||
}
|
||
result, err := s.commitCurrentCeraPackage(ctx, session, catalog, definition, request)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-cera-package-open-rejected", "source_slot", request.SourceSlot, "source_item", definition.Source.ItemID, "choice_count", len(request.Choices), "reason", err)
|
||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketOpenCerapackage), 4)
|
||
}
|
||
var success packetWriter
|
||
success.writeUint16(uint16(request.SourceSlot))
|
||
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketOpenCerapackage), success.bytes()); err != nil {
|
||
return err
|
||
}
|
||
rewardNotice := buildCurrentBoosterSuccessBody(currentBoosterCommitResult{
|
||
SourceItemID: definition.Source.ItemID,
|
||
SourceSlot: request.SourceSlot,
|
||
SourceRemaining: result.SourceRemaining,
|
||
Rewards: result.DisplayRewards,
|
||
})
|
||
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketUseBoosterItem), rewardNotice); err != nil {
|
||
return err
|
||
}
|
||
for _, listType := range result.ChangedListTypes {
|
||
listBody, _, _, ok := s.buildCurrentItemListBodyForSession(ctx, session, listType)
|
||
if !ok {
|
||
return errCurrentCeraPackageOwnerUnavailable
|
||
}
|
||
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), listBody); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
s.scheduleCurrentRewardSlotHighlights(session, "open_cera_package", result.RewardSlots)
|
||
if result.OverflowMailID != "" {
|
||
if err := s.sendMailboxAlarmToOnlineRecipient(session.selectedCharacterID); err != nil {
|
||
// The package, its directly placed rewards, and the overflow mail
|
||
// have already committed together. A missed live alarm is harmless:
|
||
// the durable mailbox snapshot will still show the attachment when
|
||
// the player next opens the mailbox.
|
||
s.logWarn("cera package mailbox alarm deferred to next mailbox open",
|
||
"character_id", session.selectedCharacterID,
|
||
"mail_id", result.OverflowMailID,
|
||
"error", err)
|
||
}
|
||
}
|
||
s.logGameEvent(session, "game-cera-package-open-committed",
|
||
"source_slot", request.SourceSlot,
|
||
"source_item", definition.Source.ItemID,
|
||
"choice_count", len(request.Choices),
|
||
"source_remaining", result.SourceRemaining,
|
||
"reward_rows", result.RewardRows,
|
||
"reward_units", result.RewardUnits,
|
||
"overflow_mail_id", result.OverflowMailID,
|
||
"overflow_items", result.OverflowItems,
|
||
"changed_lists", fmt.Sprint(result.ChangedListTypes),
|
||
"ack_body", "current_exe_op518_success_u16_slot_then_op160_reward_result",
|
||
"inventory_refresh", "current_exe_op13_real_repository_lists")
|
||
// Same reason as op160: the client's lock icons live in its op251 lock list,
|
||
// not in the item rows, so the container rebuild after this package drops them
|
||
// until the list is replayed (live 2026-09-15 07:38).
|
||
if err := s.sendCurrentSelectedItemLockSnapshotIfAny(session, "open_cera_package_after_reward_grant"); err != nil {
|
||
s.logGameEvent(session, "game-cera-package-lock-snapshot-send-failed", "error", err)
|
||
}
|
||
// Same for the blue sort-lock markers (see sendCurrentSortLockReplayIfAny).
|
||
if err := s.sendCurrentSortLockReplayIfAny(session, "open_cera_package_after_reward_grant"); err != nil {
|
||
s.logGameEvent(session, "game-cera-package-sort-lock-replay-failed", "error", err)
|
||
}
|
||
// 每天可用次数(PVF [total usable count]):礼包与开箱同理 —— 不走 aligned
|
||
// 通道,必须自己补一包 op541,否则客户端那份计数永远是 0。
|
||
s.sendCurrentItemDailyUseState(
|
||
session, definition.Source.ItemID, result.SourceDailyUsed, result.SourceDailyLimit, "open_cera_package")
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) prepareCurrentCeraPackage(
|
||
ctx context.Context,
|
||
session *gameSession,
|
||
catalog *pvfDungeonDropCatalog,
|
||
request currentCeraPackageOpenRequest,
|
||
) (currentCeraPackageDefinition, error) {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 || request.SourceSlot < 0 || catalog == nil {
|
||
return currentCeraPackageDefinition{}, errCurrentCeraPackageOwnerUnavailable
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Inventory == nil {
|
||
return currentCeraPackageDefinition{}, errCurrentCeraPackageOwnerUnavailable
|
||
}
|
||
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
||
inventory, found, err := repositories.Inventory.Load(ctx, characterID)
|
||
if err != nil {
|
||
return currentCeraPackageDefinition{}, err
|
||
}
|
||
if !found || inventory.CharacterID != characterID {
|
||
return currentCeraPackageDefinition{}, errCurrentCeraPackageOwnerUnavailable
|
||
}
|
||
stack, found := inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, request.SourceSlot)]
|
||
if !found || stack.ItemID <= 0 || stack.ItemID > math.MaxUint32 || stack.Count <= 0 {
|
||
return currentCeraPackageDefinition{}, errCurrentCeraPackageSourceMissing
|
||
}
|
||
definition, err := resolveCurrentCeraPackageDefinition(catalog, uint32(stack.ItemID))
|
||
if err != nil {
|
||
return currentCeraPackageDefinition{}, err
|
||
}
|
||
now := time.Now().UTC()
|
||
if err := validateCurrentCeraPackageSourceExpiration(stack, definition.Source, now); err != nil {
|
||
return currentCeraPackageDefinition{}, err
|
||
}
|
||
selectedRewards, err := resolveCurrentCeraPackageOpenRewards(definition, request.Choices)
|
||
if err != nil {
|
||
return currentCeraPackageDefinition{}, err
|
||
}
|
||
if _, err := expandCurrentDeterministicNestedCeraPackages(catalog, selectedRewards); err != nil {
|
||
return currentCeraPackageDefinition{}, err
|
||
}
|
||
return definition, nil
|
||
}
|
||
|
||
func (s *Service) commitCurrentCeraPackage(
|
||
ctx context.Context,
|
||
session *gameSession,
|
||
catalog *pvfDungeonDropCatalog,
|
||
definition currentCeraPackageDefinition,
|
||
request currentCeraPackageOpenRequest,
|
||
) (currentCeraPackageCommitResult, error) {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 || catalog == nil || catalog.source == nil || definition.Source.ItemID == 0 {
|
||
return currentCeraPackageCommitResult{}, errCurrentCeraPackageOwnerUnavailable
|
||
}
|
||
selectedRewards, err := resolveCurrentCeraPackageOpenRewards(definition, request.Choices)
|
||
if err != nil {
|
||
return currentCeraPackageCommitResult{}, err
|
||
}
|
||
selectedRewards, err = expandCurrentDeterministicNestedCeraPackages(catalog, selectedRewards)
|
||
if err != nil {
|
||
return currentCeraPackageCommitResult{}, err
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.MailboxAssets == nil {
|
||
return currentCeraPackageCommitResult{}, errCurrentCeraPackageOwnerUnavailable
|
||
}
|
||
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
||
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
||
now := time.Now().UTC()
|
||
mainSlotCount := uint16(0)
|
||
if repositories.Settings != nil {
|
||
containerState := s.loadCurrentItemListContainerState(ctx, repositories.Settings, characterID, dnfrepo.MainInventoryListType)
|
||
mainSlotCount = containerState.MainSlotCount
|
||
}
|
||
result := currentCeraPackageCommitResult{
|
||
DisplayRewards: make([]currentBoosterGrantedReward, 0, len(selectedRewards)),
|
||
}
|
||
for _, reward := range selectedRewards {
|
||
result.DisplayRewards = append(result.DisplayRewards, currentBoosterGrantedReward{
|
||
ItemID: reward.Definition.ItemID,
|
||
Count: reward.Count,
|
||
})
|
||
}
|
||
err = repositories.MailboxAssets.WithinMailboxAssets(ctx, characterID, characterID, func(
|
||
_ dnfrepo.AccountRepository,
|
||
accountInventories dnfrepo.AccountInventoryRepository,
|
||
characters dnfrepo.CharacterRepository,
|
||
inventories dnfrepo.InventoryRepository,
|
||
mailboxes dnfrepo.MailboxRepository,
|
||
) error {
|
||
character, found, err := characters.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found || character.CharacterID != characterID || accountID == "" || strings.TrimSpace(character.AccountID) != accountID {
|
||
return errCurrentCeraPackageOwnerUnavailable
|
||
}
|
||
inventory, found, err := inventories.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found || inventory.CharacterID != characterID {
|
||
return errCurrentCeraPackageOwnerUnavailable
|
||
}
|
||
inventory = dnfrepo.CloneInventory(inventory)
|
||
if inventory.Slots == nil {
|
||
return errCurrentCeraPackageSourceMissing
|
||
}
|
||
// 礼包奖励里的无色/灵魂必须落账号共享仓(下面调用统一入口)。
|
||
// accountID 已在上面校验过非空且与角色记录一致。
|
||
accountInventory := dnfrepo.AccountInventoryRecord{}
|
||
accountInventoryReady := false
|
||
accountDirty := false
|
||
loadAccountInventory := func() error {
|
||
if accountInventoryReady || accountInventories == nil {
|
||
return nil
|
||
}
|
||
record, _, loadErr := accountInventories.Load(ctx, accountID)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
accountInventory = dnfrepo.CloneAccountInventory(record)
|
||
accountInventory.AccountID = accountID
|
||
if accountInventory.Slots == nil {
|
||
accountInventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||
}
|
||
accountInventoryReady = true
|
||
return nil
|
||
}
|
||
sourceKey := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, request.SourceSlot)
|
||
sourceStack, found := inventory.Slots[sourceKey]
|
||
if !found || sourceStack.ItemID != int64(definition.Source.ItemID) || sourceStack.Count <= 0 {
|
||
return errCurrentCeraPackageSourceMissing
|
||
}
|
||
if !definition.Source.ExpirationDate.IsZero() {
|
||
sourceStack, _ = applyCurrentPVFItemExpirationAt(sourceStack, definition.Source, now)
|
||
}
|
||
if err := validateCurrentCeraPackageSourceExpiration(sourceStack, definition.Source, now); err != nil {
|
||
return err
|
||
}
|
||
// 每天可用次数(PVF `[total usable count]`):礼包同样是消耗品,上限属于道具
|
||
// 自身,所以这里用的是与 op44/op515/开箱完全相同的那份计数。判定与自增都在
|
||
// **这个事务**里:到上限就整笔失败、礼包不会被消耗;成功则与背包同事务落库。
|
||
if dailyLimit := definition.Source.TotalUsableCount; dailyLimit > 0 {
|
||
character = dnfrepo.CloneCharacter(character)
|
||
if character.Stats == nil {
|
||
character.Stats = make(map[string]int64)
|
||
}
|
||
used, chargeErr := dnfitemdailylimit.Consume(
|
||
character.Stats, int64(definition.Source.ItemID), dailyLimit, now, character.Level)
|
||
if chargeErr != nil {
|
||
return fmt.Errorf(
|
||
"%w: item=%d: %v",
|
||
errCurrentCeraPackageDailyUseLimitReached, definition.Source.ItemID, chargeErr)
|
||
}
|
||
character.UpdatedAt = now
|
||
if saveErr := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); saveErr != nil {
|
||
return saveErr
|
||
}
|
||
result.SourceDailyUsed = used
|
||
result.SourceDailyLimit = dailyLimit
|
||
}
|
||
|
||
changed := map[byte]struct{}{dnfrepo.MainInventoryListType: {}}
|
||
rewardSlots := make(map[string]struct{})
|
||
// 落到账号共享槽(0:354..365)的奖励行单独收集:它们不在 inventory 里,
|
||
// 不能混进下面那个"必须在 inventory 里找得到"的盖章循环。
|
||
accountRewardSlots := make(map[string]struct{})
|
||
overflow := make([]dnfrepo.MailAttachment, 0)
|
||
result.SourceRemaining = sceneInventoryUint32FromInt64(sourceStack.Count - 1)
|
||
if sourceStack.Count == 1 {
|
||
delete(inventory.Slots, sourceKey)
|
||
} else {
|
||
sourceStack.Count--
|
||
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, request.SourceSlot, sourceStack)
|
||
sourceStack.RawEntry = append([]byte(nil), entry.data[:]...)
|
||
inventory.Slots[sourceKey] = sourceStack
|
||
}
|
||
|
||
var rewardUnits uint64
|
||
for _, reward := range selectedRewards {
|
||
rewardUnits += uint64(reward.Count)
|
||
grantDefinition, expirationErr := currentPVFItemDefinitionForNestedRewardGrantAt(reward.Definition, sourceStack, now)
|
||
if expirationErr != nil {
|
||
return fmt.Errorf("%w: reward item=%d expiration: %v", errCurrentCeraPackagePVFInvalid, reward.Definition.ItemID, expirationErr)
|
||
}
|
||
grantDefinition = limitCurrentMainInventoryDefinitionSlots(grantDefinition, mainSlotCount)
|
||
if reward.Avatar {
|
||
for count := uint32(0); count < reward.Count; count++ {
|
||
slot, grantErr := grantCurrentCeraShopAvatar(
|
||
&inventory,
|
||
catalog.source,
|
||
grantDefinition,
|
||
currentCeraShopProduct{ItemID: grantDefinition.ItemID, Count: 1, Section: "avatar"},
|
||
reward.Option,
|
||
)
|
||
if grantErr != nil {
|
||
return grantErr
|
||
}
|
||
key := currentCeraShopInventorySlotKey(1, slot)
|
||
rewardSlots[key] = struct{}{}
|
||
}
|
||
changed[1] = struct{}{}
|
||
continue
|
||
}
|
||
if isCurrentCeraShopCreatureItem(grantDefinition) {
|
||
for count := uint32(0); count < reward.Count; count++ {
|
||
slot, grantErr := grantCurrentCeraShopPet(&inventory, grantDefinition)
|
||
if grantErr != nil {
|
||
return grantErr
|
||
}
|
||
rewardSlots[currentCeraShopInventorySlotKey(currentPetInventoryListType, slot)] = struct{}{}
|
||
}
|
||
changed[currentPetInventoryListType] = struct{}{}
|
||
continue
|
||
}
|
||
if isCurrentCeraShopPetConsumable(grantDefinition) {
|
||
slots, grantErr := grantCurrentCeraShopPetConsumable(&inventory, grantDefinition, reward.Count)
|
||
if grantErr != nil {
|
||
return grantErr
|
||
}
|
||
for _, slot := range slots {
|
||
rewardSlots[currentCeraShopInventorySlotKey(currentPetInventoryListType, slot)] = struct{}{}
|
||
}
|
||
changed[currentPetInventoryListType] = struct{}{}
|
||
continue
|
||
}
|
||
// 无色小晶块 / 灵魂 这两族礼包奖励必须先落账号共享仓的固定格
|
||
// (统一入口见 account_shared_grant.go)。账号侧是"并进固定格",
|
||
// 不需要下面的 candidate 探针;余量才交给角色侧那条探针/邮件溢出。
|
||
if err := loadAccountInventory(); err != nil {
|
||
return err
|
||
}
|
||
if accountInventoryReady {
|
||
shared := grantCurrentAccountSharedFirst(&accountInventory, grantDefinition, reward.Count, "cera_package")
|
||
if shared.Changed {
|
||
accountDirty = true
|
||
accountKey := dnfrepo.AccountSharedInventorySlotKey(shared.Slot)
|
||
accountStack := accountInventory.Slots[accountKey]
|
||
if accountStack.Extra == nil {
|
||
accountStack.Extra = make(map[string]string, 5)
|
||
}
|
||
accountStack.Extra["source"] = "cera_package"
|
||
accountStack.Extra["last_grant_source"] = "cera_package"
|
||
accountStack.Extra["cera_package_source_item"] = strconv.FormatUint(uint64(definition.Source.ItemID), 10)
|
||
accountInventory.Slots[accountKey] = accountStack
|
||
// 账号共享槽也是 list-0 线地址:让客户端把晶体/灵魂面板那一格重画。
|
||
accountRewardSlots[accountKey] = struct{}{}
|
||
}
|
||
if shared.Remainder == 0 {
|
||
continue
|
||
}
|
||
// 只剩余量要落角色背包(reward.Count 是循环变量副本,改它只影响下面这几行)。
|
||
reward.Count = shared.Remainder
|
||
}
|
||
// Product placement can merge part of a stack before discovering a
|
||
// full category. Probe a clone first, so the fallback either grants
|
||
// the whole reward directly or sends the whole row to mail, never a
|
||
// duplicated or half-consumed remainder.
|
||
candidate := dnfrepo.CloneInventory(inventory)
|
||
slots, grantErr := grantCurrentCeraShopProduct(&candidate, grantDefinition, reward.Count, s.currentInventoryPlacementMainSlotCount(ctx, &candidate, ""))
|
||
if grantErr != nil {
|
||
if !errors.Is(grantErr, errDungeonPickupInventoryFull) {
|
||
return grantErr
|
||
}
|
||
attachments, attachmentErr := currentCeraPackageOverflowAttachments(
|
||
grantDefinition,
|
||
reward.Count,
|
||
definition.Source.ItemID,
|
||
)
|
||
if attachmentErr != nil {
|
||
return attachmentErr
|
||
}
|
||
overflow = append(overflow, attachments...)
|
||
continue
|
||
}
|
||
inventory = candidate
|
||
for _, slot := range slots {
|
||
rewardSlots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, int16(slot))] = struct{}{}
|
||
}
|
||
}
|
||
|
||
for key := range rewardSlots {
|
||
stack, found := inventory.Slots[key]
|
||
if !found || stack.ItemID <= 0 {
|
||
return errCurrentCeraPackagePVFInvalid
|
||
}
|
||
if stack.Extra == nil {
|
||
stack.Extra = make(map[string]string, 5)
|
||
}
|
||
stack.Extra["source"] = "cera_package"
|
||
stack.Extra["last_grant_source"] = "cera_package"
|
||
stack.Extra["cera_package_source_item"] = strconv.FormatUint(uint64(definition.Source.ItemID), 10)
|
||
listType, slot, parsed := parseSceneInventorySlotKey(key)
|
||
if !parsed {
|
||
return errCurrentCeraPackagePVFInvalid
|
||
}
|
||
entry := currentItemListEntryFromStack(listType, slot, stack)
|
||
stack.RawEntry = append([]byte(nil), entry.data[:]...)
|
||
inventory.Slots[key] = stack
|
||
}
|
||
inventory.UpdatedAt = now
|
||
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||
return err
|
||
}
|
||
if accountDirty {
|
||
accountInventory.UpdatedAt = now
|
||
if err := accountInventories.Save(ctx, accountInventory); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if len(overflow) > 0 {
|
||
mailID, mailErr := dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{
|
||
RecipientCharacterID: characterID,
|
||
Title: "背包已满:礼包奖励",
|
||
Body: "背包空间不足,礼包中未能放入背包的奖励已通过邮件发送。请清理对应道具分页后领取。",
|
||
Source: "cera_package_main_inventory_full",
|
||
Attachments: overflow,
|
||
CreatedAt: now,
|
||
})
|
||
if mailErr != nil {
|
||
return mailErr
|
||
}
|
||
result.OverflowMailID = mailID
|
||
result.OverflowItems = len(overflow)
|
||
}
|
||
|
||
listTypes := make([]byte, 0, len(changed))
|
||
for listType := range changed {
|
||
listTypes = append(listTypes, listType)
|
||
}
|
||
sort.Slice(listTypes, func(left, right int) bool { return listTypes[left] < listTypes[right] })
|
||
rewardRefreshes, refreshErr := currentRewardSlotRefreshesFromKeys(rewardSlots)
|
||
if refreshErr != nil {
|
||
return fmt.Errorf("%w: %v", errCurrentCeraPackagePVFInvalid, refreshErr)
|
||
}
|
||
if len(accountRewardSlots) > 0 {
|
||
accountRefreshes, accountRefreshErr := currentRewardSlotRefreshesFromKeys(accountRewardSlots)
|
||
if accountRefreshErr != nil {
|
||
return fmt.Errorf("%w: %v", errCurrentCeraPackagePVFInvalid, accountRefreshErr)
|
||
}
|
||
rewardRefreshes = append(rewardRefreshes, accountRefreshes...)
|
||
}
|
||
result = currentCeraPackageCommitResult{
|
||
ChangedListTypes: listTypes,
|
||
RewardSlots: rewardRefreshes,
|
||
SourceRemaining: result.SourceRemaining,
|
||
DisplayRewards: result.DisplayRewards,
|
||
RewardRows: len(selectedRewards),
|
||
RewardUnits: rewardUnits,
|
||
OverflowMailID: result.OverflowMailID,
|
||
OverflowItems: result.OverflowItems,
|
||
// 这次整体重写结果结构体,每日次数的进度必须一起带过来,否则上面刚扣的
|
||
// 那一次在返回时就丢了(也就不会投影给客户端)。
|
||
SourceDailyUsed: result.SourceDailyUsed,
|
||
SourceDailyLimit: result.SourceDailyLimit,
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return currentCeraPackageCommitResult{}, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// currentCeraPackageOverflowAttachments makes mail-owned instances for a
|
||
// package row that cannot fit its PVF inventory category. The item instances
|
||
// retain the same expiration and PVF metadata used by the direct placement
|
||
// path, so claiming them later is equivalent to receiving them immediately.
|
||
func currentCeraPackageOverflowAttachments(
|
||
definition dungeonDropItemDefinition,
|
||
count uint32,
|
||
sourceItemID uint32,
|
||
) ([]dnfrepo.MailAttachment, error) {
|
||
if definition.ItemID == 0 || count == 0 || sourceItemID == 0 {
|
||
return nil, errCurrentCeraPackagePVFInvalid
|
||
}
|
||
attachments := make([]dnfrepo.MailAttachment, 0, 1)
|
||
remaining := count
|
||
for remaining > 0 {
|
||
chunk := remaining
|
||
switch definition.Kind {
|
||
case dungeonDropItemEquipment:
|
||
chunk = 1
|
||
case dungeonDropItemStackable:
|
||
if definition.StackLimit > 0 && int64(chunk) > definition.StackLimit {
|
||
chunk = uint32(definition.StackLimit)
|
||
}
|
||
default:
|
||
return nil, errCurrentCeraShopProductUnavailable
|
||
}
|
||
// The mailbox wire builder replaces the location field. Build with a
|
||
// valid PVF category slot solely to obtain the exact item-instance
|
||
// payload and expiration fields.
|
||
stack, err := buildCurrentDungeonPickupStack(definition.SlotStart, definition, chunk)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if stack.Extra == nil {
|
||
stack.Extra = make(map[string]string, 4)
|
||
}
|
||
stack.Extra["source"] = "cera_package"
|
||
stack.Extra["last_grant_source"] = "cera_package"
|
||
stack.Extra["cera_package_source_item"] = strconv.FormatUint(uint64(sourceItemID), 10)
|
||
stack.Extra["cera_package_overflow_mail"] = "true"
|
||
attachments = append(attachments, dnfrepo.MailAttachment{
|
||
ItemID: stack.ItemID,
|
||
Count: stack.Count,
|
||
Bind: stack.Bind,
|
||
ExpireAt: stack.ExpireAt,
|
||
RawEntry: append([]byte(nil), stack.RawEntry...),
|
||
Extra: stack.Extra,
|
||
})
|
||
remaining -= chunk
|
||
}
|
||
return attachments, nil
|
||
}
|