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

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

692 lines
30 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}