DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
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
|
||||
}
|
||||
Reference in new issue
Block a user