DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,691 @@
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
}