服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
692 lines
30 KiB
Go
692 lines
30 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
dnfbooster "longheng.io/server/internal/modules/dnf/booster"
|
||
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfitemcooldown "longheng.io/server/internal/modules/dnf/itemcooldown"
|
||
dnfitemdailylimit "longheng.io/server/internal/modules/dnf/itemdailylimit"
|
||
"longheng.io/server/internal/modules/dnf/premium"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
|
||
)
|
||
|
||
func boosterGameplayModule() gameplayModuleDefinition {
|
||
opcode := uint16(dnfenum.CmdPacketUseBoosterItem)
|
||
lotteryOpcode := uint16(dnfenum.CmdPacketUseLotteryItem)
|
||
overflowOpcode := uint16(dnfenum.CmdPacketOverflowInfo)
|
||
handler := func(service *Service, session *gameSession, request gameplayRequest) error {
|
||
return service.handleCurrentBoosterItem(session, request.Body)
|
||
}
|
||
return gameplayModuleDefinition{
|
||
Name: "booster-item",
|
||
LegacyHandlers: map[uint16]gameplayHandler{
|
||
opcode: handler,
|
||
lotteryOpcode: func(service *Service, session *gameSession, request gameplayRequest) error {
|
||
return service.handleCurrentLotteryItem(session, request.Body)
|
||
},
|
||
overflowOpcode: func(service *Service, session *gameSession, request gameplayRequest) error {
|
||
return service.handleCurrentLotteryOverflowConfirm(session, request.Body)
|
||
},
|
||
},
|
||
UpperHandlers: map[uint16]gameplayHandler{
|
||
opcode: func(service *Service, session *gameSession, request gameplayRequest) error {
|
||
if !requireDefaultGameplayClassification(service, session, request, "game-current-booster-blocked", "current_exe_op160_command_class_mismatch") {
|
||
return nil
|
||
}
|
||
return handler(service, session, request)
|
||
},
|
||
lotteryOpcode: func(service *Service, session *gameSession, request gameplayRequest) error {
|
||
if !requireDefaultGameplayClassification(service, session, request, "game-lottery-item-blocked", "current_exe_op27_command_class_mismatch") {
|
||
return nil
|
||
}
|
||
return service.handleCurrentLotteryItem(session, request.Body)
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func (s *Service) handleCurrentBoosterItem(session *gameSession, body []byte) error {
|
||
request, err := parseCurrentBoosterOpenRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-current-booster-rejected", "body_len", len(body), "reason", err)
|
||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketUseBoosterItem), 4)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
catalog, err := s.currentPVFItemCatalog()
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-current-booster-rejected", "source_slot", request.SourceSlot, "reason", err)
|
||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketUseBoosterItem), 4)
|
||
}
|
||
definition, err := s.prepareCurrentBooster(ctx, session, catalog, request)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-current-booster-rejected",
|
||
"source_slot", request.SourceSlot,
|
||
"selected_item", request.SelectedItemID,
|
||
"selected_count", len(request.Selections),
|
||
"request_kind", request.Kind,
|
||
"reason", err)
|
||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketUseBoosterItem), 4)
|
||
}
|
||
result, err := s.commitCurrentBooster(ctx, session, catalog, definition, request)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-current-booster-rejected",
|
||
"source_slot", request.SourceSlot,
|
||
"source_item", definition.Source.ItemID,
|
||
"selected_item", request.SelectedItemID,
|
||
"selected_count", len(request.Selections),
|
||
"request_kind", request.Kind,
|
||
"reason", err)
|
||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketUseBoosterItem), 4)
|
||
}
|
||
if err := s.sendGameUpperSuccess(
|
||
session,
|
||
uint16(dnfenum.CmdPacketUseBoosterItem),
|
||
buildCurrentBoosterSuccessBody(result),
|
||
); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendSelectedIncrementalItemSlotRefreshes(
|
||
session,
|
||
"use_booster_item",
|
||
[]alignedcmd.ItemSlotRefresh{{
|
||
ListType: dnfrepo.MainInventoryListType,
|
||
SlotIndex: result.SourceSlot,
|
||
}},
|
||
); err != nil {
|
||
return err
|
||
}
|
||
if result.ReviveCoinWalletChanged {
|
||
if err := s.sendSelectedIncrementalItemSlotRefreshes(
|
||
session,
|
||
"use_booster_item_revive_coin_wallet",
|
||
[]alignedcmd.ItemSlotRefresh{{
|
||
ListType: dnfrepo.MainInventoryListType,
|
||
SlotIndex: dnfrevivecoin.WalletSlot,
|
||
}},
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, listType := range result.ChangedLists {
|
||
listBody, _, _, ok := s.buildCurrentItemListBodyForSession(ctx, session, listType)
|
||
if !ok {
|
||
return errCurrentBoosterOwnerUnavailable
|
||
}
|
||
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), listBody); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// 礼盒开出了代币券 / 欢乐代币券:它们已经直接进账号钱包(没有占背包格),
|
||
// 所以必须把新的钱包值推给客户端,否则玩家看不到到账。
|
||
// 纯展示:失败只记日志,不让一次开启因为推送失败而变成失败。
|
||
if !result.VoucherCredits.IsZero() {
|
||
if err := s.sendCurrentCeraShopWalletProjection(ctx, session, "booster_voucher_credit"); err != nil {
|
||
s.logGameEvent(session, "game-cera-shop-wallet-send-failed", "source", "booster_voucher_credit", "err", err)
|
||
}
|
||
}
|
||
crystalPremiumActivated := false
|
||
blackDiamondActivated := false
|
||
for _, activation := range result.PremiumActivations {
|
||
if activation.PremiumType == premium.TypeCrystal {
|
||
crystalPremiumActivated = true
|
||
}
|
||
if premium.IsBlackDiamondStorageType(activation.PremiumType) {
|
||
blackDiamondActivated = true
|
||
}
|
||
wireType, canNotify := currentPremiumActivationWireType(activation.PremiumType)
|
||
if !canNotify {
|
||
continue
|
||
}
|
||
if err := s.sendGameUpperRawClass(
|
||
session,
|
||
currentPremiumActivatedMsgID,
|
||
buildCurrentPremiumActivatedBody(wireType, activation.RemainingSeconds),
|
||
0,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if blackDiamondActivated {
|
||
if err := s.refreshCurrentBlackDiamondAfterActivation(ctx, session, "booster_after_black_diamond_activation"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if crystalPremiumActivated {
|
||
if err := s.sendCurrentCrystalContractState(session, "booster_after_crystal_contract_activation"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if len(result.EpicBookRows) > 0 {
|
||
body, err := buildCurrentEpicBookInfoBody(1, result.EpicBookRows)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendGameUpperRawClass(session, currentEpicBookInfoWireOpcode, body, 0); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
s.scheduleCurrentRewardSlotHighlights(session, "use_booster_item", result.RewardSlots)
|
||
s.logGameEvent(session, "game-current-booster-committed",
|
||
"source_slot", result.SourceSlot,
|
||
"source_item", result.SourceItemID,
|
||
"source_remaining", result.SourceRemaining,
|
||
"request_kind", request.Kind,
|
||
"selected_item", request.SelectedItemID,
|
||
"selected_count", len(request.Selections),
|
||
"reward_count", len(result.Rewards),
|
||
"rewards", fmt.Sprint(result.Rewards),
|
||
"reward_slots", fmt.Sprint(result.RewardSlots),
|
||
"changed_lists", fmt.Sprint(result.ChangedLists),
|
||
"epic_book_rows", fmt.Sprint(result.EpicBookRows),
|
||
"premium_activations", fmt.Sprint(result.PremiumActivations),
|
||
"revive_coin_wallet_changed", result.ReviveCoinWalletChanged,
|
||
// 券直接入钱包的数量(0 = 本次没开出券)。核对线上到账看这两个数。
|
||
"voucher_token_cera", result.VoucherCredits.TokenCera,
|
||
"voucher_happy_token_cera", result.VoucherCredits.HappyTokenCera,
|
||
// 冷却窗口的证据:非 0 表示这次开启落下了一份跨会话保留的冷却(PVF
|
||
// [cool time] + [cooltime maintenance]),重上后同组道具会被服务端拦住。
|
||
"cooltime_until", result.SourceCooltimeUntil,
|
||
"cooltime_remaining_seconds", currentBoosterCooltimeRemainingSeconds(result.SourceCooltimeUntil, s.gameplayNow()),
|
||
"ack_body", "current_exe_op160_u32_source_u16_slot_u32_remaining_u32_mode_u16_rewards",
|
||
"inventory_refresh", "current_exe_op14_source_slot_then_op13_real_repository_lists")
|
||
// The client keeps its blue lock icons in the op251 lock list, not in the item
|
||
// rows, and an op14 row refresh makes it rebuild those cells and drop them
|
||
// (live 2026-09-15 07:36: opening a package cleared the equipment and avatar
|
||
// locks until the bag was sorted, whose container refresh does re-send op251).
|
||
// A package that hands out new rows must therefore replay the list; the replay
|
||
// only happens when something is actually locked, so an all-unlocked character
|
||
// pays nothing.
|
||
if err := s.sendCurrentSelectedItemLockSnapshotIfAny(session, "use_booster_item_after_reward_grant"); err != nil {
|
||
s.logGameEvent(session, "game-current-booster-lock-snapshot-send-failed", "error", err)
|
||
}
|
||
// The little blue padlock (sort lock) is client-only state dropped by the same
|
||
// rebuild, so replay it too. Both replays are skipped when nothing is locked.
|
||
if err := s.sendCurrentSortLockReplayIfAny(session, "use_booster_item_after_reward_grant"); err != nil {
|
||
s.logGameEvent(session, "game-current-booster-sort-lock-replay-failed", "error", err)
|
||
}
|
||
// 每天可用次数(PVF [total usable count]):结果包已经发出,这里补 op541,
|
||
// 让礼盒的悬浮说明跟着走、也让客户端自己拦住超限的下一次点击。
|
||
s.sendCurrentItemDailyUseState(
|
||
session, result.SourceItemID, result.SourceDailyUsed, result.SourceDailyLimit, "use_booster_item")
|
||
return nil
|
||
}
|
||
|
||
// currentBoosterCooltimeRemainingSeconds 把持久冷却的绝对到期时间换算成日志里的剩余秒数
|
||
// (运营验收时直接对着 packet_log 读)。until<=0(该道具没有 maintenance 冷却)返回 0。
|
||
func currentBoosterCooltimeRemainingSeconds(until int64, now time.Time) int64 {
|
||
if until <= 0 {
|
||
return 0
|
||
}
|
||
remaining := until - now.UTC().Unix()
|
||
if remaining < 0 {
|
||
return 0
|
||
}
|
||
return remaining
|
||
}
|
||
|
||
func (s *Service) commitCurrentBooster(
|
||
ctx context.Context,
|
||
session *gameSession,
|
||
catalog *pvfDungeonDropCatalog,
|
||
definition currentBoosterDefinition,
|
||
request currentBoosterOpenRequest,
|
||
) (currentBoosterCommitResult, error) {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 || catalog == nil || catalog.source == nil || definition.Source.ItemID == 0 {
|
||
return currentBoosterCommitResult{}, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
rewardAmounts, rewardOptions, goldAmount, err := resolveCurrentBoosterRewardAmounts(definition, request, secureCurrentBoosterRoll)
|
||
if err != nil {
|
||
return currentBoosterCommitResult{}, err
|
||
}
|
||
if request.RewardMultiplier > 1 {
|
||
for itemID, count := range rewardAmounts {
|
||
multiplied := uint64(count) * uint64(request.RewardMultiplier)
|
||
if multiplied > currentBoosterMaxRewardUnits {
|
||
return currentBoosterCommitResult{}, errCurrentBoosterPVFInvalid
|
||
}
|
||
rewardAmounts[itemID] = uint32(multiplied)
|
||
}
|
||
multipliedGold := uint64(goldAmount) * uint64(request.RewardMultiplier)
|
||
if multipliedGold > math.MaxInt32 {
|
||
return currentBoosterCommitResult{}, errCurrentBoosterPVFInvalid
|
||
}
|
||
goldAmount = uint32(multipliedGold)
|
||
}
|
||
epicCatalog, err := s.currentEpicBookCatalog()
|
||
if err != nil {
|
||
return currentBoosterCommitResult{}, err
|
||
}
|
||
premiumCatalog, err := s.currentPremiumCatalog()
|
||
if err != nil {
|
||
return currentBoosterCommitResult{}, err
|
||
}
|
||
accountID, characterID, owner, err := s.currentBoosterMutationOwner(session)
|
||
if err != nil {
|
||
return currentBoosterCommitResult{}, err
|
||
}
|
||
now := time.Now().UTC()
|
||
result := currentBoosterCommitResult{
|
||
SourceItemID: definition.Source.ItemID,
|
||
SourceSlot: request.SourceSlot,
|
||
}
|
||
err = owner.Open(ctx, dnfbooster.Command{
|
||
AccountID: accountID,
|
||
CharacterID: characterID,
|
||
UpdatedAt: now,
|
||
Project: func(assets *dnfbooster.Assets) (dnfbooster.Changes, error) {
|
||
accountState := assets.Account
|
||
inventory := assets.Inventory
|
||
account := assets.AccountInventory
|
||
epicBook := assets.EpicBook
|
||
if accountState == nil || epicBook == nil || epicBook.Balances == nil {
|
||
return dnfbooster.Changes{}, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
sourceKey := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, request.SourceSlot)
|
||
sourceStack, found := inventory.Slots[sourceKey]
|
||
if !found || sourceStack.ItemID != int64(definition.Source.ItemID) || sourceStack.Count <= 0 || sourceStack.Count > math.MaxUint32 {
|
||
return dnfbooster.Changes{}, errCurrentBoosterSourceMissing
|
||
}
|
||
if !definition.Source.ExpirationDate.IsZero() {
|
||
sourceStack, _ = applyCurrentPVFItemExpirationAt(sourceStack, definition.Source, now)
|
||
}
|
||
if err := validateCurrentBoosterSourceExpiration(sourceStack, definition.Source, now); err != nil {
|
||
return dnfbooster.Changes{}, err
|
||
}
|
||
characterDirty := false
|
||
// 冷却(PVF `[cool time]` + `[cooltime group]` + `[cooltime maintenance]`):
|
||
// 声明了 maintenance 的道具由服务端保留冷却窗口。客户端的冷却门只是进程内内存
|
||
// ——现场 2026-09-17:1 天冷却的礼盒小退重上后又能用。检查和写入都在同一个
|
||
// 事务里:被拦时整条命令回滚,源道具不会被消耗;成功则落一份到期时间,重上后
|
||
// 由 select 阶段的类 0 通知 172 重放给客户端。同 [cooltime group] 的道具共享
|
||
// 一个窗口,所以解析器要能认出**已存在冷却行**的组。
|
||
if definition.Source.HasCooltimeMaintenance && definition.Source.CooltimeMS > 0 {
|
||
seconds := dnfitemcooldown.MillisecondsToSeconds(definition.Source.CooltimeMS)
|
||
if seconds > 0 {
|
||
if assets.Character == nil {
|
||
return dnfbooster.Changes{}, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
if assets.Character.Stats == nil {
|
||
assets.Character.Stats = make(map[string]int64)
|
||
}
|
||
nowUnix := now.Unix()
|
||
if blocked, active := dnfitemcooldown.Blocks(
|
||
assets.Character.Stats, nowUnix, int64(definition.Source.ItemID),
|
||
definition.Source.CooltimeGroup, s.currentPVFItemCooltimeGroup,
|
||
); active {
|
||
return dnfbooster.Changes{}, fmt.Errorf(
|
||
"%w: item=%d blocked_by=%d until=%d",
|
||
errCurrentBoosterCooltimeActive, definition.Source.ItemID, blocked.ItemID, blocked.ExpireAt)
|
||
}
|
||
dnfitemcooldown.Prune(assets.Character.Stats, nowUnix)
|
||
result.SourceCooltimeUntil = nowUnix + seconds
|
||
dnfitemcooldown.Set(assets.Character.Stats, int64(definition.Source.ItemID), result.SourceCooltimeUntil)
|
||
characterDirty = true
|
||
}
|
||
}
|
||
// 每天可用次数(PVF `[total usable count]`):礼盒同样是消耗品,上限又属于
|
||
// 道具本身,所以这里用的是和 op44/op515 完全相同的那份计数。判定与自增都在
|
||
// **这个事务**里:到上限就整笔失败,源礼盒不会被消耗;成功则随 Character
|
||
// 一起落库,没有任何"先占位再回滚"的中间态。
|
||
if dailyLimit := definition.Source.TotalUsableCount; dailyLimit > 0 {
|
||
if assets.Character == nil {
|
||
return dnfbooster.Changes{}, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
if assets.Character.Stats == nil {
|
||
assets.Character.Stats = make(map[string]int64)
|
||
}
|
||
used, chargeErr := dnfitemdailylimit.Consume(
|
||
assets.Character.Stats, int64(definition.Source.ItemID), dailyLimit, now, assets.Character.Level)
|
||
if chargeErr != nil {
|
||
return dnfbooster.Changes{}, fmt.Errorf(
|
||
"%w: item=%d: %v",
|
||
errCurrentBoosterDailyUseLimitReached, definition.Source.ItemID, chargeErr)
|
||
}
|
||
result.SourceDailyUsed = used
|
||
result.SourceDailyLimit = dailyLimit
|
||
characterDirty = true
|
||
}
|
||
if goldAmount > 0 {
|
||
if assets.Character == nil {
|
||
return dnfbooster.Changes{}, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
if assets.Character.Stats == nil {
|
||
assets.Character.Stats = make(map[string]int64)
|
||
}
|
||
goldBefore := assets.Character.Stats["gold"]
|
||
if goldBefore < 0 || goldBefore > math.MaxInt32-int64(goldAmount) {
|
||
return dnfbooster.Changes{}, errCurrentBoosterPVFInvalid
|
||
}
|
||
result.GoldGranted = goldAmount
|
||
result.GoldAfter = goldBefore + int64(goldAmount)
|
||
assets.Character.Stats["gold"] = result.GoldAfter
|
||
characterDirty = true
|
||
}
|
||
if request.ConsumePremiumDaily {
|
||
if assets.Character == nil || !premium.TryConsumeDaily(assets.Character, request.PremiumDailySlot, now) {
|
||
return dnfbooster.Changes{}, fmt.Errorf("premium slot %d daily limit reached", request.PremiumDailySlot)
|
||
}
|
||
characterDirty = true
|
||
}
|
||
rewards, err := resolveCurrentBoosterRewards(catalog, rewardAmounts, rewardOptions, sourceStack, now)
|
||
if err != nil {
|
||
return dnfbooster.Changes{}, err
|
||
}
|
||
// 选择型 booster(含升级预约的武器箱)要把本分类的强化等级带到奖励上,
|
||
// 否则 [booster equipment upgrade] 读了也没人用,发出去的还是 +0。
|
||
if request.Kind == currentBoosterRequestSelection {
|
||
selected, err := currentBoosterSelectedCandidates(definition, request)
|
||
if err != nil {
|
||
return dnfbooster.Changes{}, err
|
||
}
|
||
for i := range rewards {
|
||
for _, candidate := range selected {
|
||
if rewards[i].Definition.ItemID == candidate.ItemID {
|
||
rewards[i].UpgradeLevel = candidate.UpgradeLevel
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
materialKey := ""
|
||
var materialItemID int64
|
||
var materialCount int64
|
||
if request.Kind == currentBoosterRequestRandom || request.Kind == currentBoosterRequestLottery {
|
||
materialItemID = definition.Random.MaterialItemID
|
||
materialCount = definition.Random.MaterialCountPerUse
|
||
}
|
||
if materialItemID > 0 && materialCount > 0 {
|
||
materialKey = findCurrentBoosterMaterial(inventory.Slots, materialItemID, materialCount, sourceKey)
|
||
if materialKey == "" {
|
||
return dnfbooster.Changes{}, fmt.Errorf("%w: material item=%d count=%d", errCurrentBoosterSourceMissing, materialItemID, materialCount)
|
||
}
|
||
}
|
||
|
||
result.SourceRemaining = uint32(sourceStack.Count - 1)
|
||
changedLists := map[byte]struct{}{dnfrepo.MainInventoryListType: {}}
|
||
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
|
||
}
|
||
if materialKey != "" {
|
||
materialStack := inventory.Slots[materialKey]
|
||
materialStack.Count -= materialCount
|
||
if materialStack.Count <= 0 {
|
||
delete(inventory.Slots, materialKey)
|
||
} else {
|
||
listType, slot, parsed := parseSceneInventorySlotKey(materialKey)
|
||
if !parsed || listType != dnfrepo.MainInventoryListType {
|
||
return dnfbooster.Changes{}, errCurrentBoosterPVFInvalid
|
||
}
|
||
entry := currentItemListEntryFromStack(listType, slot, materialStack)
|
||
materialStack.RawEntry = append([]byte(nil), entry.data[:]...)
|
||
inventory.Slots[materialKey] = materialStack
|
||
}
|
||
}
|
||
|
||
changedRewardKeys := make(map[string]struct{})
|
||
accountDirty := false
|
||
accountStateDirty := false
|
||
epicBookDirty := false
|
||
epicBookBalances := make(map[uint32]uint32)
|
||
premiumTypes := make(map[int64]struct{})
|
||
voucherCredits := dnfcerashop.VoucherCounts{}
|
||
for _, reward := range rewards {
|
||
// 代币券 / 欢乐代币券:在道具 PVF 里它们是普通 `[material]`,既没有
|
||
// `[action type]` 也没有任何"使用后转点券"的定义 —— 客户端**自己
|
||
// 根本无法**把它们变成钱包余额。当成道具发进背包,玩家只会拿到一件
|
||
// 永远用不掉的"材料"(现场 2026-09-19 的抱怨)。所以在这里直接入账号
|
||
// 钱包、不占背包格,与邮件领取路径(mail/owner.go)同一套语义。
|
||
//
|
||
// 放在循环最前面:券的道具号是明确的,不可能同时是契约或史诗图鉴碎片,
|
||
// 先判可以彻底排除被其它分支吞掉的可能。
|
||
if voucher := dnfcerashop.VoucherKindOf(int64(reward.Definition.ItemID)); voucher != dnfcerashop.VoucherNone {
|
||
counts := dnfcerashop.VoucherCounts{}
|
||
if voucher == dnfcerashop.VoucherTokenCera {
|
||
counts.TokenCera = int64(reward.Count)
|
||
} else {
|
||
counts.HappyTokenCera = int64(reward.Count)
|
||
}
|
||
// 溢出就整笔失败(礼盒不会被消耗),绝不静默截断余额。
|
||
if _, creditErr := dnfcerashop.CreditVoucherCounts(accountState, counts); creditErr != nil {
|
||
return dnfbooster.Changes{}, creditErr
|
||
}
|
||
accountStateDirty = true
|
||
voucherCredits = voucherCredits.Add(counts)
|
||
result.Rewards = append(result.Rewards, currentBoosterGrantedReward{ItemID: reward.Definition.ItemID, Count: reward.Count})
|
||
continue
|
||
}
|
||
if contract, exists := premiumCatalog.contractsByItem[int64(reward.Definition.ItemID)]; exists {
|
||
if contract.PremiumType <= 0 || contract.DurationSeconds <= 0 {
|
||
return dnfbooster.Changes{}, errCurrentBoosterPVFInvalid
|
||
}
|
||
premium.Upsert(accountState, contract.PremiumType, contract.DurationSeconds, int64(reward.Count), now)
|
||
for _, activationType := range premium.ActivationTypes(contract.PremiumType) {
|
||
premiumTypes[activationType] = struct{}{}
|
||
}
|
||
accountStateDirty = true
|
||
result.Rewards = append(result.Rewards, currentBoosterGrantedReward{ItemID: reward.Definition.ItemID, Count: reward.Count})
|
||
continue
|
||
}
|
||
if entry, fragment := epicCatalog.EntryByFragment(reward.Definition.ItemID); fragment {
|
||
current := epicBook.Balances[entry.EquipmentID]
|
||
if reward.Count > math.MaxUint32-current {
|
||
return dnfbooster.Changes{}, errCurrentEpicBookBalanceOverflow
|
||
}
|
||
balance := current + reward.Count
|
||
epicBook.Balances[entry.EquipmentID] = balance
|
||
epicBookBalances[entry.FragmentID] = balance
|
||
epicBookDirty = true
|
||
result.Rewards = append(result.Rewards, currentBoosterGrantedReward{ItemID: reward.Definition.ItemID, Count: reward.Count})
|
||
continue
|
||
}
|
||
keys, sharedChanged, grantErr := grantCurrentBoosterRewardByOwner(inventory, account, catalog.source, reward, s.currentInventoryPlacementMainSlotCount(ctx, inventory, ""))
|
||
if grantErr != nil {
|
||
return dnfbooster.Changes{}, grantErr
|
||
}
|
||
accountDirty = accountDirty || sharedChanged
|
||
for _, key := range keys {
|
||
changedRewardKeys[key] = struct{}{}
|
||
}
|
||
result.Rewards = append(result.Rewards, currentBoosterGrantedReward{ItemID: reward.Definition.ItemID, Count: reward.Count})
|
||
if dnfrevivecoin.IsReward(int64(reward.Definition.ItemID)) {
|
||
result.ReviveCoinWalletChanged = true
|
||
}
|
||
}
|
||
premiumTypeIDs := make([]int64, 0, len(premiumTypes))
|
||
for premiumType := range premiumTypes {
|
||
premiumTypeIDs = append(premiumTypeIDs, premiumType)
|
||
}
|
||
sort.Slice(premiumTypeIDs, func(i, j int) bool { return premiumTypeIDs[i] < premiumTypeIDs[j] })
|
||
for _, premiumType := range premiumTypeIDs {
|
||
result.PremiumActivations = append(result.PremiumActivations, currentCeraShopPremiumActivation{
|
||
PremiumType: premiumType,
|
||
RemainingSeconds: premium.ExpireAt(*accountState, premiumType) - now.Unix(),
|
||
})
|
||
}
|
||
result.EpicBookRows = make([]currentEpicBookBalanceRow, 0, len(epicBookBalances))
|
||
for fragmentID, balance := range epicBookBalances {
|
||
result.EpicBookRows = append(result.EpicBookRows, currentEpicBookBalanceRow{FragmentID: fragmentID, Balance: balance})
|
||
}
|
||
sort.Slice(result.EpicBookRows, func(i, j int) bool { return result.EpicBookRows[i].FragmentID < result.EpicBookRows[j].FragmentID })
|
||
changedRewardKeyList := make([]string, 0, len(changedRewardKeys))
|
||
for key := range changedRewardKeys {
|
||
changedRewardKeyList = append(changedRewardKeyList, key)
|
||
}
|
||
sort.Strings(changedRewardKeyList)
|
||
for _, key := range changedRewardKeyList {
|
||
stack, found := inventory.Slots[key]
|
||
if !found || stack.ItemID <= 0 {
|
||
return dnfbooster.Changes{}, errCurrentBoosterPVFInvalid
|
||
}
|
||
if stack.Extra == nil {
|
||
stack.Extra = make(map[string]string, 6)
|
||
}
|
||
stack.Extra["source"] = "booster_item"
|
||
stack.Extra["last_grant_source"] = "booster_item"
|
||
stack.Extra["booster_source_item"] = strconv.FormatUint(uint64(definition.Source.ItemID), 10)
|
||
listType, slot, parsed := parseSceneInventorySlotKey(key)
|
||
if !parsed {
|
||
return dnfbooster.Changes{}, errCurrentBoosterPVFInvalid
|
||
}
|
||
entry := currentItemListEntryFromStack(listType, slot, stack)
|
||
stack.RawEntry = append([]byte(nil), entry.data[:]...)
|
||
inventory.Slots[key] = stack
|
||
changedLists[listType] = struct{}{}
|
||
if request.Kind == currentBoosterRequestLottery &&
|
||
listType == dnfrepo.MainInventoryListType &&
|
||
!result.LotteryDisplayEntrySet {
|
||
displayEntry := entry
|
||
// The op27 row drives the native result animation. Its amount is
|
||
// the quantity granted by this opening, not the post-merge stack
|
||
// total. The following op14/list refresh retains the authoritative
|
||
// accumulated inventory count.
|
||
if binary.LittleEndian.Uint32(displayEntry.data[6:10]) != 0 {
|
||
var granted uint32
|
||
for _, reward := range result.Rewards {
|
||
if int64(reward.ItemID) == stack.ItemID {
|
||
granted += reward.Count
|
||
}
|
||
}
|
||
if granted > 0 {
|
||
binary.LittleEndian.PutUint32(displayEntry.data[6:10], granted)
|
||
}
|
||
}
|
||
result.LotteryDisplayEntry = displayEntry
|
||
result.LotteryDisplayEntrySet = true
|
||
}
|
||
}
|
||
rewardRefreshes, refreshErr := currentRewardSlotRefreshesFromKeys(changedRewardKeys)
|
||
if refreshErr != nil {
|
||
return dnfbooster.Changes{}, errCurrentBoosterPVFInvalid
|
||
}
|
||
result.RewardSlots = rewardRefreshes
|
||
result.ChangedLists = make([]byte, 0, len(changedLists))
|
||
for listType := range changedLists {
|
||
result.ChangedLists = append(result.ChangedLists, listType)
|
||
}
|
||
sort.Slice(result.ChangedLists, func(i, j int) bool { return result.ChangedLists[i] < result.ChangedLists[j] })
|
||
// 券的入账数量用**赋值**而不是累加:赋值天然幂等,将来若某个后端改成重跑
|
||
// apply 也不会翻倍。
|
||
//
|
||
// 注:本仓库当前三套后端 × 四个事务入口的 apply 回调**都不会**重跑
|
||
// (memory 走补偿回滚,mysql/sqlite 直接失败返回),所以这是防御性写法,
|
||
// 不是在修一个已存在的重复记账问题。同函数里 result.Rewards 等仍用 append,
|
||
// 那是存量写法、不是本次引入。
|
||
result.VoucherCredits = voucherCredits
|
||
return dnfbooster.Changes{Account: accountStateDirty, AccountInventory: accountDirty, Character: characterDirty, Inventory: true, EpicBook: epicBookDirty}, nil
|
||
},
|
||
})
|
||
if err != nil {
|
||
return currentBoosterCommitResult{}, currentBoosterMutationError(err)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) currentBoosterMutationOwner(session *gameSession) (string, string, *dnfbooster.Owner, error) {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
||
return "", "", nil, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.BoosterAssets == nil {
|
||
return "", "", nil, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
||
if accountID == "" {
|
||
return "", "", nil, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
owner, err := dnfbooster.NewOwner(repositories)
|
||
if err != nil {
|
||
return "", "", nil, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
||
return accountID, characterID, owner, nil
|
||
}
|
||
|
||
func currentBoosterMutationError(err error) error {
|
||
switch {
|
||
case errors.Is(err, dnfbooster.ErrOwnerUnavailable),
|
||
errors.Is(err, dnfbooster.ErrAccountRequired),
|
||
errors.Is(err, dnfbooster.ErrCharacterRequired),
|
||
errors.Is(err, dnfbooster.ErrCharacterNotFound),
|
||
errors.Is(err, dnfbooster.ErrAccountMismatch),
|
||
errors.Is(err, dnfbooster.ErrInventoryNotFound):
|
||
return errors.Join(errCurrentBoosterOwnerUnavailable, err)
|
||
default:
|
||
return err
|
||
}
|
||
}
|
||
|
||
// prepareCurrentBooster is a read-only fast-fail pass. The domain Owner
|
||
// repeats authorization and inventory validation inside the commit
|
||
// transaction before consuming anything.
|
||
func (s *Service) prepareCurrentBooster(
|
||
ctx context.Context,
|
||
session *gameSession,
|
||
catalog *pvfDungeonDropCatalog,
|
||
request currentBoosterOpenRequest,
|
||
) (currentBoosterDefinition, error) {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 || request.SourceSlot < 0 || catalog == nil || catalog.source == nil {
|
||
return currentBoosterDefinition{}, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Character == nil || repositories.Inventory == nil {
|
||
return currentBoosterDefinition{}, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
||
character, found, err := repositories.Character.Load(ctx, characterID)
|
||
if err != nil {
|
||
return currentBoosterDefinition{}, err
|
||
}
|
||
if !found || character.CharacterID != characterID || strings.TrimSpace(character.AccountID) != strings.TrimSpace(s.accountIDForSession(session)) {
|
||
return currentBoosterDefinition{}, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
inventory, found, err := repositories.Inventory.Load(ctx, characterID)
|
||
if err != nil {
|
||
return currentBoosterDefinition{}, err
|
||
}
|
||
if !found || inventory.CharacterID != characterID {
|
||
return currentBoosterDefinition{}, errCurrentBoosterOwnerUnavailable
|
||
}
|
||
stack, found := inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, request.SourceSlot)]
|
||
if !found || stack.ItemID <= 0 || stack.ItemID > math.MaxUint32 || stack.Count <= 0 || stack.Count > math.MaxUint32 {
|
||
return currentBoosterDefinition{}, errCurrentBoosterSourceMissing
|
||
}
|
||
definition, err := resolveCurrentBoosterDefinition(catalog, uint32(stack.ItemID), request.Kind)
|
||
if err != nil {
|
||
return currentBoosterDefinition{}, err
|
||
}
|
||
// [booster info]/[charactor] 是**按职业**(并带等级区间)分组的:只有开启者自己
|
||
// 职业那一组才该发。不过滤的话,鬼剑士开豪华婚礼化妆箱会把全职业的时装/道具
|
||
// 一起领走 —— 现场(2026-10-04)正是如此。
|
||
// 上游在同一位置做同样的事(gameplay_module_booster.go 的 prepare 路径)。
|
||
if len(definition.Random.Groups) > 0 {
|
||
definition.Random.Groups = alignedcmd.MagicBoxGroupsForCharacter(definition.Random.Groups, character)
|
||
}
|
||
if err := validateCurrentBoosterSourceExpiration(stack, definition.Source, time.Now().UTC()); err != nil {
|
||
return currentBoosterDefinition{}, err
|
||
}
|
||
if request.Kind == currentBoosterRequestSelection {
|
||
if _, err := currentBoosterSelectedCandidates(definition, request); err != nil {
|
||
return currentBoosterDefinition{}, err
|
||
}
|
||
}
|
||
return definition, nil
|
||
}
|