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

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

831 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfdrops "longheng.io/server/internal/modules/dnf/drop"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
var currentDungeonCardGoldDifficultyBonus = [...]float64{1.02, 1.38, 1.60, 1.90, 2.0}
const currentDungeonGoldCardItemMultiplier int64 = 2
// currentDungeonGoldCardUse 读副本 PVF 的 [gold card use]。
// 该段被 worldmap 收进 DungeonMetadata.IntegerValues。
//
// 第二个返回值区分**「显式声明 0」**与**「未声明该段」**,因为二者含义相反:
//
// 显式 0 → 策划明确要求关闭付费翻牌(周常五图 4906~4910 都是这样)
// 未声明 → 老副本没有这个字段,应当**保持原有付费翻牌**,不能当成 0
//
// 过去只用普通 map 取值,未声明时会得到零值 0,导致「未声明」被误判成
// 「显式 0」,绝大多数老副本(例如 Act1 系列)的付费翻牌被整条关闭 ——
// 现场表现为结算时下面 4 张黄金卡牌点击后不扣费、奖励也不入包。
func currentDungeonGoldCardUse(dungeon worldmap.Dungeon) (value int64, declared bool) {
if dungeon.Metadata.IntegerValues == nil {
return 0, false
}
value, declared = dungeon.Metadata.IntegerValues["gold card use"]
return value, declared
}
func (s *Service) freezeCurrentDungeonCardRewardStateLocked(
session *gameSession,
runtime *runtimeDungeonState,
) error {
if s == nil || session == nil || runtime == nil || session.dungeon.runtime != runtime {
return errDungeonCardAssetOwnerUnavailable
}
if currentDungeonTowerRewardMsgID(runtime) != 0 || runtime.settlementCardRewardState != nil {
return nil
}
monsterCatalog, err := s.dungeonMonsterCatalog()
if err != nil {
return err
}
goldReferences, err := currentDungeonGoldReferences(monsterCatalog)
if err != nil {
return err
}
if !runtime.Dungeon.Metadata.BasisLevel.Set || runtime.Dungeon.Metadata.BasisLevel.Value <= 0 {
return errCurrentDungeonSettlementPVFInvalid
}
dungeonLevel := int(runtime.Dungeon.Metadata.BasisLevel.Value)
goldReference, found := goldReferences[int64(dungeonLevel)]
if !found {
return fmt.Errorf("%w: dungeon_level=%d", errCurrentDungeonGoldReferenceInvalid, runtime.Dungeon.Metadata.BasisLevel.Value)
}
itemCatalog, err := monsterCatalog.DropCatalog()
if err != nil {
return err
}
clearRewardRules, err := currentDungeonClearRewardRules(monsterCatalog)
if err != nil {
return err
}
partyMembers := 1
if runtime.partyDungeonOwner != nil {
partyMembers = len(runtime.partyDungeonOwner.roster())
}
visitedRooms := len(runtime.Rooms)
clearRewardRule, err := clearRewardRules.Resolve(
int64(dungeonLevel),
int(runtime.Request.Difficulty),
partyMembers,
visitedRooms,
)
if err != nil {
return err
}
// Freeze the commission beside the plan it prices. Resolve already proves
// [gold card cost table] covers this level, so the value is the same one the
// 86JP reference hands the client in op35's PAID-CARD COMMISSION field.
runtime.settlementPaidCardCost = clearRewardRule.PaidCardCost
goldTerms := currentDungeonCardGoldTerms{
Difficulty: int(runtime.Request.Difficulty),
PartyMembers: partyMembers,
PartyRate: clearRewardRules.PartyMemberRate(partyMembers),
ClearRankRate: s.currentDungeonClearRankGoldRate(runtime),
WeightedKills: runtime.currentDungeonClearWeightedKills(),
}
dimensionCatalog, err := currentDungeonDimensionDrops(monsterCatalog)
if err != nil {
return err
}
dimensionChronicleActive := false
characterJob := byte(0)
characterGrowType := byte(runtime.Character.Stats["grow_type"]) & 0x0f
if _, dimensionMatched := currentDungeonDimensionTier(
dimensionCatalog,
runtime.Dungeon.ID,
runtime.Character.Level,
); dimensionMatched && len(dimensionCatalog.ChronicleByCareer) > 0 {
jobValue, parseErr := strconv.ParseUint(strings.TrimSpace(runtime.Character.Job), 10, 8)
if parseErr != nil {
return fmt.Errorf("%w: dimension character job=%q", errCurrentDungeonDimensionDropInvalid, runtime.Character.Job)
}
characterJob = byte(jobValue)
dimensionChronicleActive = true
}
rng := newCurrentDungeonDropLCG(runtime.Seed)
var visit *runtimeDungeonRoomVisit
if runtime.Session != nil {
if scene, ok := runtime.Session.Scene(); ok {
if ownedVisit, visitErr := runtime.currentDungeonRoomVisit(scene); visitErr == nil {
visit = ownedVisit
rng = newCurrentDungeonDropLCG(ownedVisit.DropRNG)
}
}
}
now := s.gameplayNow().UTC()
randomReward := func() (dungeonCardRewardBundle, error) {
if dimensionChronicleActive {
return currentDungeonRandomDimensionCardRewardBundle(
rng,
goldReference,
goldTerms,
itemCatalog,
dimensionCatalog,
runtime.Dungeon.ID,
runtime.Character.Level,
characterJob,
characterGrowType,
now,
)
}
return currentDungeonRandomCardRewardBundle(
rng,
goldReference,
goldTerms,
itemCatalog,
dungeonLevel,
clearRewardRule,
now,
)
}
free, err := randomReward()
if err != nil {
return err
}
paid, err := randomReward()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
_, account, character, premiumErr := s.currentPremiumServiceRecords(ctx, session)
cancel()
var blackDiamond [dungeonCardSlotsPerSide]dungeonCardRewardBundle
blackDiamondActive := s.currentBlackDiamondActive(session)
if blackDiamondActive {
for member := range blackDiamond {
blackDiamond[member], err = randomReward()
if err != nil {
return err
}
}
}
waivePaidCardCost := false
if premiumErr == nil &&
premium.Active(account, premium.DevilSlotType(premium.DevilSlotGoldCard), now) &&
premium.DailyUsage(character, premium.DevilSlotGoldCard, now) < premium.DailyLimit(premium.DevilSlotGoldCard) {
paid.ConsumePremiumDaily = true
paid.PremiumDailySlot = premium.DevilSlotGoldCard
// The purchased 魔王契约 黄金卡牌免费翻 service pays the commission for
// this flip. 86JP waives the same charge through
// DevilContractBenefitPolicy.GoldCardSlot, whose daily limit of 10
// matches DailyLimit(DevilSlotGoldCard) above.
waivePaidCardCost = true
if err := applyCurrentDungeonGoldCardItemMultiplier(&paid); err != nil {
return err
}
}
if visit != nil {
visit.DropRNG = rng.Seed()
}
plan, err := newDungeonCardRewardPlanWithBlackDiamond(
dungeonCardPlanIdentity{
CharacterID: strconv.Itoa(int(session.selectedCharacterID)),
DungeonID: runtime.Dungeon.ID,
MazeIndex: runtime.MazeIndex,
RunSeed: runtime.Seed,
},
"current_pvf_clear_reward_generator_free_and_paid_rows",
free,
paid,
blackDiamond,
)
if err != nil {
return err
}
// The commission printed under 黄金卡牌 N is the same number spent when that
// card is flipped, so it is frozen beside the plan: the client is told the
// list price whether or not the contract pays it, and a replay cannot charge
// a different amount than it displayed.
plan.PaidCardCost = clearRewardRule.PaidCardCost
if waivePaidCardCost {
plan.PaidCardCost = 0
}
// 【2026-10-16 修正】只有**显式声明** [gold card use] 0 才整条关闭付费翻牌。
//
// 周常五图(4906~4910)显式写 0,报告要求"按五图 gold card use=0 禁用付费翻牌,
// 免费奖励保留原流程"。只置 0 费用不够:那样付费行仍可翻且不收费,等于把付费
// 奖励白送。所以显式 0 时必须同时把该侧标为禁用,reserveSelection 会拒绝它。
//
// 但**未声明**该段的副本(Act1 系列等绝大多数老图)必须保持付费翻牌可用:
// 过去把未声明也当成 0,导致这些副本的付费侧被误关,现场表现为结算时下面
// 4 张黄金卡牌点击后不扣次数、奖励也不入包。
if value, declared := currentDungeonGoldCardUse(runtime.Dungeon); declared && value == 0 {
plan.PaidCardCost = 0
plan.PaidCardDisabled = true
}
state, err := newDungeonCardState(plan)
if err != nil {
return err
}
runtime.settlementCardRewardState = state
s.logGameEvent(session, "game-dungeon-card-reward-plan-frozen",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"plan_id", plan.ID,
"free_gold", free.Gold,
"free_item_count", len(free.Items),
"paid_gold", paid.Gold,
"paid_item_count", len(paid.Items),
"black_diamond_active", blackDiamondActive,
"black_diamond_reward_seats", currentDungeonCardNonEmptyBundleCount(blackDiamond[:]),
"premium_gold_card_available", paid.ConsumePremiumDaily,
"source", plan.Source,
"dimension_chronicle_active", dimensionChronicleActive,
"dimension_chronicle_job", characterJob,
"dimension_chronicle_grow_type", characterGrowType,
"clear_reward_equipment_rate", clearRewardRule.EquipmentRate,
"clear_reward_map_count", clearRewardRule.MapCount,
"clear_reward_party_members", partyMembers,
"domain_source", "current_pvf_clear_reward_equipment_rule")
return nil
}
func currentDungeonRandomCardRewardBundle(
rng *currentDungeonDropLCG,
goldReference currentDungeonGoldReference,
goldTerms currentDungeonCardGoldTerms,
itemCatalog *pvfDungeonDropCatalog,
dungeonLevel int,
clearRewardRule dnfdrops.ClearRewardRule,
now time.Time,
) (dungeonCardRewardBundle, error) {
bundle := dungeonCardRewardBundle{
Gold: currentDungeonCardGoldAmount(rng, goldReference, goldTerms),
}
item, found, err := currentDungeonRandomClearCardEquipment(
rng,
itemCatalog,
dungeonLevel,
clearRewardRule,
now,
)
if err != nil {
return dungeonCardRewardBundle{}, err
}
if found {
bundle.Items = []dungeonCardItemReward{item}
}
return bundle, nil
}
func currentDungeonRandomClearCardEquipment(
rng *currentDungeonDropLCG,
catalog *pvfDungeonDropCatalog,
dungeonLevel int,
rule dnfdrops.ClearRewardRule,
now time.Time,
) (dungeonCardItemReward, bool, error) {
if rng == nil || catalog == nil || dungeonLevel <= 0 || rule.RarityScale == 0 {
return dungeonCardItemReward{}, false, errDungeonCardPVFSourceRequired
}
if uint32(rng.Next(int(dnfdrops.ClearRewardItemRateDenominator))) >= rule.EquipmentRate {
return dungeonCardItemReward{}, false, nil
}
rarityRoll := uint32(rng.Next(int(rule.RarityScale)))
rarity := currentDungeonClearCardEquipmentRarity(rule.RarityThresholds, rarityRoll)
itemID, found, err := catalog.SelectGenericEquipment(
rng,
dungeonLevel,
rule.GradeDown,
rule.GradeUp,
int64(rarity),
)
if err != nil {
return dungeonCardItemReward{}, false, err
}
if !found || itemID == 0 {
// The native clear-card pool is equipment-only. A missing equipment
// candidate leaves this card gold-only; it must never fall through to
// the generic stackable pool (bombs, discs, boomerangs, and materials).
return dungeonCardItemReward{}, false, nil
}
reward, err := currentDungeonCardItemReward(rng, catalog, itemID, now)
return reward, err == nil, err
}
func currentDungeonClearCardEquipmentRarity(thresholds [7]uint32, roll uint32) int {
for rarity, threshold := range thresholds {
if roll < threshold {
return rarity
}
}
return len(thresholds) - 1
}
func currentDungeonRandomDimensionCardRewardBundle(
rng *currentDungeonDropLCG,
goldReference currentDungeonGoldReference,
goldTerms currentDungeonCardGoldTerms,
itemCatalog *pvfDungeonDropCatalog,
dimensionCatalog *currentDungeonDimensionDropCatalog,
dungeonID int64,
characterLevel int,
job byte,
growType byte,
now time.Time,
) (dungeonCardRewardBundle, error) {
bundle := dungeonCardRewardBundle{
Gold: currentDungeonCardGoldAmount(rng, goldReference, goldTerms),
}
_, itemID, _, matched, selected, err := currentDungeonDimensionChronicleItem(
dimensionCatalog,
dungeonID,
characterLevel,
job,
growType,
rng,
true,
)
if err != nil {
return dungeonCardRewardBundle{}, err
}
if !matched {
return dungeonCardRewardBundle{}, fmt.Errorf(
"%w: dimension clear reward dungeon_id=%d",
errCurrentDungeonDimensionDropInvalid,
dungeonID,
)
}
if selected {
item, resolveErr := currentDungeonCardItemReward(rng, itemCatalog, itemID, now)
if resolveErr != nil {
return dungeonCardRewardBundle{}, resolveErr
}
bundle.Items = []dungeonCardItemReward{item}
} else {
bundle.Gold += 100
}
return bundle, nil
}
func currentDungeonCardNonEmptyBundleCount(bundles []dungeonCardRewardBundle) int {
count := 0
for _, bundle := range bundles {
if bundle.Gold > 0 || len(bundle.Items) > 0 {
count++
}
}
return count
}
// applyCurrentDungeonGoldCardItemMultiplier turns the current EXE's visible
// x2 Gold Card result into two durable item units. Stackables stay one reward
// row with a doubled count. Equipment is cloned into independent count-one
// instances because equipment must never be stored as a stack.
func applyCurrentDungeonGoldCardItemMultiplier(bundle *dungeonCardRewardBundle) error {
if bundle == nil || len(bundle.Items) == 0 {
return nil
}
items := make([]dungeonCardItemReward, 0, len(bundle.Items)*int(currentDungeonGoldCardItemMultiplier))
for _, item := range bundle.Items {
if item.ItemID <= 0 || item.Count <= 0 ||
item.Count > math.MaxInt64/currentDungeonGoldCardItemMultiplier {
return fmt.Errorf(
"%w: gold-card item=%d count=%d",
errDungeonCardPlanInvalid,
item.ItemID,
item.Count,
)
}
if item.Stackable {
item.Count *= currentDungeonGoldCardItemMultiplier
items = append(items, cloneDungeonCardItemReward(item))
continue
}
if item.Count != 1 {
return fmt.Errorf(
"%w: gold-card equipment=%d count=%d",
errDungeonCardPlanInvalid,
item.ItemID,
item.Count,
)
}
for range currentDungeonGoldCardItemMultiplier {
items = append(items, cloneDungeonCardItemReward(item))
}
}
bundle.Items = items
return nil
}
func currentDungeonRandomCardItem(
rng *currentDungeonDropLCG,
catalog *pvfDungeonDropCatalog,
dungeonLevel int,
reference dungeonCardPVFItemDropReference,
now time.Time,
) (dungeonCardItemReward, bool, error) {
if rng == nil || catalog == nil || dungeonLevel <= 0 {
return dungeonCardItemReward{}, false, errDungeonCardPVFSourceRequired
}
rarityRoll := rng.Next(1_000_000) + 1
rarity := int64(0)
switch {
case rarityRoll <= 500_000:
rarity = 0
case rarityRoll <= 799_900:
rarity = 1
case rarityRoll <= 999_900:
rarity = 2
default:
rarity = 3
}
itemID, found, err := catalog.SelectGenericEquipment(
rng,
dungeonLevel,
reference.ValueA,
reference.ValueB,
rarity,
)
if err != nil {
return dungeonCardItemReward{}, false, err
}
if !found {
itemID, found, err = catalog.SelectGenericStackable(
rng,
dungeonLevel,
reference.ValueA,
reference.ValueB,
rarity,
)
if err != nil {
return dungeonCardItemReward{}, false, err
}
}
if !found || itemID == 0 {
return dungeonCardItemReward{}, false, nil
}
reward, err := currentDungeonCardItemReward(rng, catalog, itemID, now)
return reward, err == nil, err
}
func currentDungeonCardItemReward(
rng *currentDungeonDropLCG,
catalog *pvfDungeonDropCatalog,
itemID uint32,
now time.Time,
) (dungeonCardItemReward, error) {
if rng == nil || catalog == nil || itemID == 0 {
return dungeonCardItemReward{}, errDungeonCardPVFSourceRequired
}
definition, err := catalog.ResolveItem(itemID)
if err != nil {
return dungeonCardItemReward{}, err
}
definition, err = currentPVFItemDefinitionForGrantAt(definition, now)
if err != nil {
return dungeonCardItemReward{}, err
}
extra := map[string]string{
"source": "dungeon_card_reward",
"item_kind": string(definition.Kind),
"pvf_path": definition.PVFPath,
}
if definition.StackableType != "" {
extra["stackable_type"] = definition.StackableType
}
if definition.StackLimit > 0 {
extra["stack_limit"] = strconv.FormatInt(definition.StackLimit, 10)
}
if definition.EquipmentType != "" {
extra["equipment_type"] = definition.EquipmentType
}
if definition.Durability > 0 {
extra["durability"] = strconv.FormatUint(uint64(definition.Durability), 10)
extra["max_durability"] = strconv.FormatUint(uint64(definition.Durability), 10)
}
if definition.Kind == dungeonDropItemEquipment {
qualitySeed := rng.NextUint32()%currentEquipmentRandomQualitySeedCount + 1
extra["quality_seed"] = strconv.FormatUint(uint64(qualitySeed), 10)
}
return dungeonCardItemReward{
ItemID: int64(itemID),
Count: 1,
Stackable: definition.Kind == dungeonDropItemStackable,
StackLimit: definition.StackLimit,
SlotStart: definition.SlotStart,
SlotEnd: definition.SlotEnd,
ExpireAt: definition.ExpirationDate,
Extra: extra,
}, nil
}
func (s *Service) selectCurrentDungeonCardLocked(
session *gameSession,
runtime *runtimeDungeonState,
side dungeonCardSide,
memberIndex byte,
source string,
clickedSlot ...byte,
) error {
if session == nil || runtime == nil || session.dungeon.runtime != runtime ||
!runtime.settlementCardLayoutSent ||
runtime.settlementPhase < currentDungeonSettlementPhaseCardsRevealed ||
side >= dungeonCardSideCount || memberIndex >= dungeonCardSlotsPerSide {
return nil
}
if runtime.partyDungeonOwner != nil {
member, current := runtime.partyDungeonOwner.member(
session.selectedCharacterID,
session.characterGeneration,
)
if !current || !runtime.partyMemberIndexed || runtime.partyMemberIndex != member.slot || memberIndex != member.slot {
s.logGameEvent(session, "game-party-dungeon-card-select-blocked",
"char_id", session.selectedCharacterID,
"party_id", runtime.partyDungeonOwner.partyID,
"reported_member_index", memberIndex,
"stable_member_index", member.slot,
"source", source,
"reason", "same_version_card_result_must_use_native_party_slot")
return nil
}
}
if runtime.settlementCardSideSelectionKnown[side] &&
runtime.settlementCardSideMember[side] != memberIndex {
s.logGameEvent(session, "game-dungeon-card-select-blocked",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"side", side,
"member_index", memberIndex,
"accepted_member_index", runtime.settlementCardSideMember[side],
"source", source,
"reason", "row_selection_conflicts_with_first_accepted_member")
return nil
}
if runtime.settlementCardRewardState == nil {
if err := s.freezeCurrentDungeonCardRewardStateLocked(session, runtime); err != nil {
s.logGameEvent(session, "game-dungeon-card-reward-plan-blocked",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"side", side,
"member_index", memberIndex,
"source", source,
"error", err)
return nil
}
}
replay := runtime.settlementCardSideSelectionSent[side]
if !runtime.settlementCardSidePositionKnown[side] {
position := memberIndex
if len(clickedSlot) != 0 {
position = clickedSlot[0]
}
if position >= dungeonCardSlotsPerSide {
return errDungeonCardSelectionInvalid
}
if owner := runtime.partyDungeonOwner; owner != nil {
owner.mu.Lock()
if owner.cardScreenOwners[side][position] != 0 && owner.cardScreenOwners[side][position] != session.selectedCharacterID {
if len(clickedSlot) != 0 {
owner.mu.Unlock()
// The DLL restores only this pending choice; no reward is
// committed for two members racing for the same card.
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSelectCard),
[]byte{0, 254, byte(side), position}, currentDungeonCardResponseClass)
}
// Timed auto-flips and old two-byte clients take an empty card.
position = dungeonCardSlotsPerSide
for candidate, occupant := range owner.cardScreenOwners[side] {
if occupant == 0 {
position = byte(candidate)
break
}
}
if position >= dungeonCardSlotsPerSide {
owner.mu.Unlock()
return errDungeonCardSelectionInvalid
}
}
owner.cardScreenOwners[side][position] = session.selectedCharacterID
owner.mu.Unlock()
}
runtime.settlementCardSidePosition[side] = position
runtime.settlementCardSidePositionKnown[side] = true
}
runtime.settlementCardSideSelectionKnown[side] = true
runtime.settlementCardSideMember[side] = memberIndex
runtime.settlementCardSelected = byte(side)
runtime.refreshCurrentDungeonFreeCardReadiness()
slots71, rewardTupleCount := currentDungeonSelectedCardSlots(runtime)
body71, err := buildCurrentDungeonOp71SuccessBody(slots71)
if err != nil {
return err
}
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSelectCard), body71, currentDungeonCardResponseClass); err != nil {
return err
}
runtime.settlementCardSideSelectionSent[side] = true
runtime.refreshCurrentDungeonFreeCardReadiness()
if side == dungeonCardSideFree {
s.cancelCurrentDungeonCardAutoFlipLocked(session, runtime, "free_row_selected")
}
s.logGameEvent(session, "game-dungeon-card-select-ack-sent",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"side", side,
"member_index", memberIndex,
"screen_slot", runtime.settlementCardSidePosition[side],
"replay", replay,
"response_body_len", len(body71),
"classification", currentDungeonCardResponseClass,
"source", source,
"reward_tuple_count", rewardTupleCount,
"body_source", "current_exe_op71_proved_reader_selection_state_with_frozen_card_reward_context")
if err := s.commitCurrentDungeonCardSideRewardLocked(session, runtime, side, source); err != nil {
s.logGameEvent(session, "game-dungeon-card-reward-delivery-blocked",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"side", side,
"member_index", memberIndex,
"source", source,
"error", err)
}
if err := s.publishRuntimePartyDungeonCardSelection(
session,
runtime,
side,
memberIndex,
body71,
); err != nil {
return err
}
return nil
}
func (s *Service) commitCurrentDungeonCardRewardLocked(
session *gameSession,
runtime *runtimeDungeonState,
source string,
) error {
if runtime == nil || runtime.settlementCardRewardCommitted {
return nil
}
return s.commitCurrentDungeonCardSideRewardLocked(session, runtime, dungeonCardSideFree, source)
}
func (s *Service) commitCurrentDungeonCardSideRewardLocked(
session *gameSession,
runtime *runtimeDungeonState,
side dungeonCardSide,
source string,
) error {
if runtime == nil || side >= dungeonCardSideCount ||
runtime.settlementCardSideRewardCommitted[side] {
return nil
}
if !runtime.settlementCardSideSelectionKnown[side] ||
!runtime.settlementCardSideSelectionSent[side] {
return errDungeonCardSelectionInvalid
}
if err := s.deliverCurrentDungeonSelectedCardRewardLocked(
session,
runtime,
side,
runtime.settlementCardSideMember[side],
source,
); err != nil {
return err
}
runtime.settlementCardSideRewardCommitted[side] = true
runtime.refreshCurrentDungeonFreeCardReadiness()
if side == dungeonCardSideFree {
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseRewardCommitted)
}
s.logGameEvent(session, "game-dungeon-card-reward-phase-committed",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"side", side,
"member_index", runtime.settlementCardSideMember[side],
"source", source,
"next_stage", "await_current_exe_c2s_op72_or_completed_op42")
return nil
}
func (s *Service) deliverCurrentDungeonSelectedCardRewardLocked(
session *gameSession,
runtime *runtimeDungeonState,
side dungeonCardSide,
memberIndex byte,
source string,
) error {
if s == nil || session == nil || runtime == nil || runtime.settlementCardRewardState == nil {
return nil
}
reservation, result, err := runtime.settlementCardRewardState.reserveSelection(side, memberIndex)
if err != nil {
return err
}
if !reservation.grant {
s.logGameEvent(session, "game-dungeon-card-reward-delivery-skipped",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"selection_result", result,
"side", side,
"member_index", memberIndex,
"source", source,
"reason", "no_undelivered_reward_for_selection")
return nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterAssets == nil {
runtime.settlementCardRewardState.finishDelivery(reservation, false)
return errDungeonCardAssetOwnerUnavailable
}
// 翻牌落点必须用该角色**真实已购**的背包扩展行数。这里过去写死 0,等于
// "只认默认 4 行":`currentMainInventoryClampedSlotEnd` 会把每个 56 格分类的
// SlotEnd 夹到 `SlotStart+31`,于是买过扩展券的角色在前 4 行占满、扩展行空着
// 的时候也会被判成"背包已满",整份翻牌奖励转投邮件(现场:装备 9-64 只用
// 9-40、材料 65-120 只用 65-96)。取不到容器状态时安全退回 0,与
// 祭坛/赛丽亚商店同一条口径(见 altar_settlement.go 的 owner 契约注释)。
characterID := strconv.Itoa(int(session.selectedCharacterID))
loadCtx, loadCancel := context.WithTimeout(context.Background(), createWriteTimeout)
mainSlotCount := s.currentInventoryPlacementMainSlotCount(loadCtx, nil, characterID)
loadCancel()
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
grant, err := deliverDungeonCardReservation(
ctx,
runtime.settlementCardRewardState,
reservation,
repositories,
mainSlotCount,
s.gameplayNow().UTC(),
)
if err != nil {
return err
}
if grant.GoldAfter != grant.GoldBefore {
body := buildCurrentGoldStateBody(grant.GoldAfter)
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0); err != nil {
return err
}
}
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(grant.ItemSlots))
for _, slot := range grant.ItemSlots {
if slot < 0 {
continue
}
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{
ListType: dnfrepo.MainInventoryListType,
SlotIndex: slot,
})
}
if err := s.sendSelectedIncrementalItemSlotRefreshes(
session,
"dungeon-card-reward",
refreshes,
); err != nil {
return err
}
if grant.OverflowMailID != "" {
if err := s.sendMailboxAlarmToOnlineRecipient(session.selectedCharacterID); err != nil {
// Reward and its mailbox fallback have already committed. A packet
// delivery failure must not turn that durable result into a retryable
// card selection; op96 will project it on the next mailbox open.
s.logWarn("dungeon reward mailbox alarm deferred to next mailbox open",
"character_id", session.selectedCharacterID,
"mail_id", grant.OverflowMailID,
"error", err)
}
}
s.logGameEvent(session, "game-dungeon-card-reward-delivered",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"selection_result", result,
"side", side,
"member_index", memberIndex,
"gold_before", grant.GoldBefore,
"gold_after", grant.GoldAfter,
"item_slots", grant.ItemSlots,
"overflow_mail_id", grant.OverflowMailID,
"source", source,
"domain_source", "86jp_auto_flip_reward_delivery")
return nil
}
func (runtime *runtimeDungeonState) refreshCurrentDungeonFreeCardReadiness() {
if runtime == nil {
return
}
runtime.settlementCardSelectionKnown = runtime.settlementCardSideSelectionKnown[dungeonCardSideFree]
runtime.settlementCardSelectionSent = runtime.settlementCardSideSelectionSent[dungeonCardSideFree]
runtime.settlementCardRewardCommitted = runtime.settlementCardSideRewardCommitted[dungeonCardSideFree]
}