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,830 @@
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]
}