Files
90CNServer/server/internal/services/dnfbridge/cera_package.go
T
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

931 lines
38 KiB
Go
Raw 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.
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
}