Files
90CNServer/server/internal/services/dnfbridge/dungeon_settlement_reward_owner.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

1281 lines
55 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"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
dnfhonor "longheng.io/server/internal/modules/dnf/honor"
dnfonlineevent "longheng.io/server/internal/modules/dnf/onlineevent"
"longheng.io/server/internal/modules/dnf/premium"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/wedding"
)
var (
errCurrentDungeonSettlementOwnerUnavailable = dnfdungeon.ErrOwnerUnavailable
errCurrentDungeonSettlementStateInvalid = dnfdungeon.ErrSettlementStateInvalid
errCurrentDungeonSettlementPVFInvalid = errors.New("current dungeon settlement PVF reward source is invalid")
)
const (
currentDungeonBlackDiamondBonusRate = 0.10
// Current NoPack sub_1D4D380 stores the op35 quest rows in its task map.
// The same client represents a completed active task with trigger/progress
// zero, which is the value used by the settlement result UI.
currentDungeonClearRewardQuestCompletedState uint32 = 0
)
type currentDungeonSettlementRewardResources struct {
Progression *progression.Tables
ExperienceSources *progression.MonsterExperienceSources
ClearRankCatalog currentDungeonClearRankCatalog
}
type currentDungeonSettlementCommitResult struct {
Character dnfrepo.CharacterRecord
Skill dnfrepo.SkillRecord
Inventory dnfrepo.InventoryRecord
ExperienceGain uint32
HonorExpertGain uint32
SPGain int
TPGain int
LuckyStarBefore uint32
LuckyStarApplied uint32
LuckyStarAfter uint32
LuckyStarLimit uint32
LuckyStarAttempted bool
Replayed bool
}
// currentDungeonClearExperiencePresentation 是"这一次通关的经验分类"的冻结视图:
// Base 四格 + Bonus 全表。它在第一次出包时算好并钉在 runtime 上,重试直接复用。
type currentDungeonClearExperiencePresentation struct {
Base [4]uint32
Bonus [currentDungeonClearRewardBonusFieldCount]uint32
}
// produceCurrentDungeonSettlementPlanLocked is the missing owner between the
// accepted current-EXE op46 and the frozen op34 -> op37 -> op35 send plan.
// The caller holds session.dungeon.mu, so consumed-drop receipts cannot change
// while the database snapshot and packet plan are frozen.
func (s *Service) produceCurrentDungeonSettlementPlanLocked(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
) error {
if session == nil || runtime == nil || runtime.Session == nil || session.dungeon.runtime != runtime ||
!runtime.settlementPlayResultReceived {
return errCurrentDungeonSettlementOwnerUnavailable
}
if runtime.clearMapCompletionKey == "" {
if err := ensureCurrentDungeonCompletionReceiptKey(
session,
runtime,
runtime.Session.Snapshot(),
s.gameplayNow(),
"settlement_plan_without_matching_clear_map_quest",
); err != nil {
return err
}
}
if runtime.settlementResultPlan != nil {
return nil
}
resources, err := s.currentDungeonSettlementRewardResources(ctx)
if err != nil {
return err
}
return s.produceCurrentDungeonSettlementPlanWithResourcesLocked(ctx, session, runtime, resources)
}
func (s *Service) produceCurrentDungeonSettlementPlanWithResourcesLocked(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
resources currentDungeonSettlementRewardResources,
) error {
if session == nil || runtime == nil || runtime.Session == nil || session.dungeon.runtime != runtime ||
!runtime.settlementPlayResultReceived {
return errCurrentDungeonSettlementOwnerUnavailable
}
if runtime.clearMapCompletionKey == "" {
if err := ensureCurrentDungeonCompletionReceiptKey(
session,
runtime,
runtime.Session.Snapshot(),
s.gameplayNow(),
"settlement_plan_without_matching_clear_map_quest",
); err != nil {
return err
}
}
if runtime.settlementResultPlan != nil {
return nil
}
gain, err := currentDungeonClearExperience(runtime, resources)
if err != nil {
return err
}
// 燃烧疲劳:通关经验翻倍(GM 开关)。放在基础经验上翻倍,所有加成
// (成长契约、评分、黑钻等)都按翻倍后的 base 计算,与"燃烧=双倍收益"一致。
if s.currentGMBurningEnabled() {
gain = saturatingCurrentDungeonUint32Add(gain, gain)
}
// A raid gate never pays clear experience. Real Anton runs settle each gate
// through the raid module (86JP RaidHandler.HandleDungeonClearedAsync owns the
// phase result and never enters the ordinary dungeon-clear settlement), so the
// full clear-exp package this path builds does not belong to one: live
// 2026-09-16 20:28Z granted 20,312,851 EXP plus a growth-contract bonus for
// clearing gate 210. Every derived bonus is computed from this base, so zeroing
// it here removes the whole package in one place.
if s.currentDungeonRuntimeIsRaidGate(runtime) {
gain = 0
}
// 成长契约 (premium type 84): +20% clear experience while active. The
// total is committed and receipted, so an idempotent replay re-applies
// exactly the same amount (86JP ComputeClearExp growth part).
accountID := s.accountIDForSession(session)
growthBonus := s.currentGrowthContractBonusExp(ctx, accountID, gain)
// 评分奖励: base × ClearRankRates[bonusIndex] (86JP scoreBonusExp).
clientRankPoint := runtime.settlementPlayResultBody[currentDungeonPlayResultClientRankPointOffset]
settlementRankPoint, debugRankForced := s.localDebugSettlementRankPoint(resources.ClearRankCatalog, clientRankPoint)
scoreBonus := currentDungeonScoreBonusExp(gain, resources, settlementRankPoint)
// 黑钻加成:base × 10%(专用权益类型 1000)。
blackDiamondBonus := s.currentBlackDiamondBonusExpForSession(session, gain)
ancientElixirBonus, ancientElixirEffect, ancientElixirActive := s.currentDungeonExperienceConsumableBonusExp(
ctx,
accountID,
strconv.FormatUint(uint64(session.selectedCharacterID), 10),
gain,
)
adventureBonus, adventureLevel, adventurePercent := s.currentDungeonAdventureGroupClearBonusExp(ctx, session, runtime, gain)
attendanceMarbleBonus := currentAttendanceMarbleClearExperienceBonus(runtime.attendanceMarbleBuffs, gain)
// 幸运角色(回归活动的角色表)的通关经验加成:单人只有 Clear,组队再叠加 Party。
//
// 参考实现把 Party 那一半另外拆进 Base[2] 做"组队通关经验"展示行;本树的
// Base[4] 是另一套线上布局([0]=经验总额、[1]=评分、[2]/[3]=详情分类),
// 没有可用的组队槽,所以两部分合在一起进小计——与本文件里其它"没有已验证
// 专属行"的加成(幸运单人、欢迎经验、热舞)同一口径,保证原生面板求和与
// 持久化回执一致。
// 幸运角色(回归活动的角色表)的通关经验加成:单人只有 Clear,组队再叠加 Party。
// 两段合起来算一次(保持一次性取整);本树 Base[4] 没有"组队通关经验"的专属行,
// 所以整段并入小计。
var luckyCharacterBonus uint32
if lucky := s.currentLuckyCharacterEffect(session, runtime.Character); lucky != nil {
percent := lucky.Clear
if runtime.partyDungeonOwner != nil && len(runtime.partyDungeonOwner.roster()) > 1 {
percent += lucky.Party
}
luckyCharacterBonus = currentPercentageBonusUint32(gain, percent)
}
// 城镇增益药剂:地下城通关经验 +N%(赛丽亚的幸运经验药剂 10008222 的三档
// 50/100/200)。与上面几项同一口径:按 base 算加成再累加;窗口过期由
// statbuff.ActiveRate 自己判掉(返回 0)。
statChangePotionBonus := s.currentStatChangePotionClearExperienceBonus(runtime.Character, gain)
totalGain := gain
for _, bonus := range []uint32{growthBonus, scoreBonus, blackDiamondBonus, ancientElixirBonus, adventureBonus, attendanceMarbleBonus, luckyCharacterBonus, statChangePotionBonus} {
totalGain = saturatingCurrentDungeonUint32Add(totalGain, bonus)
}
// 回归冒险家(onlineevent returnee,+20% 通关经验)。一旦算过就钉在 runtime 上:
// 出包计划可能重试,重试期间账号的回归资格/离线时长可能已经变了,重新计算会让
// 同一个通关拿到两份不同的经验,而底层已经按第一份落了账。
var welcomeBonus uint32
if runtime.settlementClearWelcomeBonus != nil {
welcomeBonus = *runtime.settlementClearWelcomeBonus
} else {
welcomeBonus, err = s.currentReturneeWelcomeExperienceBonus(ctx, session, runtime.Character, gain)
if err != nil {
return fmt.Errorf("resolve settlement welcome experience: %w", err)
}
welcomeBonus = min(welcomeBonus, math.MaxUint32-totalGain)
}
totalGain = saturatingCurrentDungeonUint32Add(totalGain, welcomeBonus)
storyQuestBase, storyQuestTotal, storyQuestID, err := s.currentDungeonSettlementStoryQuestExperience(
ctx, runtime, resources.Progression, totalGain,
)
if err != nil {
return fmt.Errorf("resolve settlement story quest experience: %w", err)
}
presentation, err := currentDungeonSettlementPresentationForRuntime(
runtime,
resources.ClearRankCatalog,
clientRankPoint,
)
if err != nil {
return fmt.Errorf("resolve settlement presentation: %w", err)
}
result, err := s.commitCurrentDungeonSettlement(
ctx,
session,
runtime,
resources.Progression,
totalGain,
)
if err != nil {
return fmt.Errorf("commit settlement progression: %w", err)
}
// 钉住本次通关的欢迎加成分类,供出包计划重试复用(与上面 totalGain 同一理由)。
runtime.settlementClearWelcomeBonus = &welcomeBonus
// A tower commits its floor advance and ten reward slots inside the same
// settlement transaction; nothing extra is committed out of band here.
towerSettlement := currentDungeonTowerRewardMsgID(runtime) != 0
permissionBody, permissionState, permissionUpdated, permissionErr := s.commitCurrentDungeonPermission(
ctx,
session,
runtime,
)
if permissionErr != nil {
s.logGameEvent(session, "game-dungeon-permission-clear-state-update-failed",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"difficulty", runtime.Request.Difficulty,
"source", "settlement_plan_after_reward_commit",
"error", permissionErr)
}
if !towerSettlement {
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,
"completion_key", runtime.clearMapCompletionKey,
"source", "settlement_plan_pre_op35_freeze",
"error", err)
}
}
drops, err := currentDungeonCommittedDropReceipts(runtime, result.Inventory)
if err != nil {
// Pickup rows in op35 are presentation receipts only. The pickup owner
// already committed the actual item and sent the authoritative op14 slot
// update. A stale runtime receipt (for example, a later pickup reused the
// same slot) must therefore be omitted instead of suppressing the entire
// dungeon settlement after EXP/SP and card state have been committed.
s.logGameEvent(session, "game-dungeon-settlement-drop-receipt-omitted",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"completion_key", runtime.clearMapCompletionKey,
"valid_receipt_count", len(drops),
"source", "database_inventory_is_authoritative_for_op35_presentation",
"error", err)
}
// 现场 2026-09-18(魔界地图):本局捡过的材料(诞生之芽/时空石/金刚石/碎布片…)在
// 通关结算瞬间翻倍显示(50 → 100),小退即恢复。排除法锁定来源:结算那一串只发了
// op34/op37/op35/op1000/op985,没有任何物品行——客户端把 op35 的 Drops 每一行当成
// "本次结算发放的数量"累加进背包。捡取本身在捡起那一刻就已经下发(op14 绝对行),
// 这里再报一次等于二次发放。所以 Drops 段一律留空;收据只用于日志核对。
s.logGameEvent(session, "game-dungeon-settlement-drop-receipts-suppressed",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"completion_key", runtime.clearMapCompletionKey,
"receipt_count", len(drops),
"reason", "op35_drops_are_granted_again_by_the_client_pickups_already_sent_at_pickup_time")
questRows, err := s.currentDungeonSettlementPendingQuestRows(ctx, session, runtime)
if err != nil {
return fmt.Errorf("resolve settlement quest rows: %w", err)
}
cardSlots := currentDungeonClearRewardCardSlotsFromRuntime(runtime)
blackDiamondCardSlots := currentDungeonClearRewardBlackDiamondSlotsFromRuntime(runtime)
// 先钉住本次通关的经验分类,再在其上叠加本局怪物侧的分项。顺序不能反:
// bonusPresentation 就是冻结表的别名,提前展开会让"冻结"落在一份还没写完的副本上。
runtime.settlementClearWelcomeBonus = &welcomeBonus
if runtime.settlementClearExperiencePresentation == nil {
exp := &currentDungeonClearExperiencePresentation{}
// Base[4] 是"把这次通关经验拆开展示"的视图,沿用本树已验证的口径:
// Base[0] = **通关基础经验**(= gain,不含任何加成),Base[1] = 评分奖励。
// 活动带来的加成(幸运角色、回归欢迎、热舞)**不改写 Base[0]**:它们要么走
// 各自的展示通道(欢迎经验走 op37 category_9 + Bonus[14]),要么并入已提交
// 总额。参考实现的 take() 口径(从 committed 里按顺序扣)在本树会得到
// 240 而它自己的断言要 200,所以不采用。
exp.Base[currentDungeonClearRewardBaseClearExperienceIndex] = gain
exp.Base[currentDungeonClearRewardBaseScoreBonusIndex] = scoreBonus
// Bonus 表里属于"通关"的那几格一并冻结。
exp.Bonus[currentDungeonClearRewardBonusClearBlackDiamondIndex] = blackDiamondBonus
exp.Bonus[currentDungeonClearRewardBonusClearAncientElixirIndex] = ancientElixirBonus
exp.Bonus[currentDungeonClearRewardBonusClearGrowthContractIndex] = growthBonus
runtime.settlementClearExperiencePresentation = exp
}
// 怪物侧的分项是本局累计值,每次出包都按当前 runtime 重算;通关侧的分项
// 取自上面那张冻结表。
bonusPresentation := runtime.settlementClearExperiencePresentation.Bonus
bonusPresentation[currentDungeonClearRewardBonusMonsterGrowthContractIndex] = runtime.settlementMonsterGrowthContractBonus
bonusPresentation[currentDungeonClearRewardBonusMonsterFatigueBuffIndex] = runtime.settlementMonsterFatigueBatteryBonus
bonusPresentation[currentDungeonClearRewardBonusMonsterEquipmentIndex] = runtime.settlementMonsterEquipmentBonus
// 原生怪物详情 5(DSTR 49141)会把 Bonus[7] 与 Bonus[14] 相加,所以回归冒险家
// 的欢迎经验只能落在其中一半,否则结果窗会把它显示成两倍。
bonusPresentation[currentDungeonClearRewardBonusMonsterReturnedHeroPart2Index] = runtime.settlementMonsterWelcomeBonus
monsterTotalExperience := currentDungeonSettlementMonsterTotalExperience(runtime)
notice := currentDungeonPlayResultNotice{
RankGrade: presentation.RankGrade,
ClearTimeMS: presentation.ClearTimeMS,
TimeBonusPoint: presentation.TimeBonusPoint,
// Current NoPack requires this result-state byte to echo C2S op46. The
// debug reward threshold remains server-side; replacing this byte makes
// the client fall back to an F result even when the numeric display rises.
RankPoint: clientRankPoint,
AllVisitedClear: presentation.AllVisitedClear,
Participants: []currentDungeonPlayResultParticipant{{
ObjectKey: session.selectedCharacterID,
ClearTimeMS: presentation.ClearTimeMS,
}},
}
reward := currentDungeonClearRewardSnapshot{
CharacterID: session.selectedCharacterID,
CompletionKey: runtime.clearMapCompletionKey,
Source: "runtime_pvf_dungeon_clear_and_committed_pickups",
Committed: true,
AllowZeroReward: currentDungeonSettlementAllowsZeroReward(runtime, result.LuckyStarAttempted),
CommittedExperienceGain: result.ExperienceGain,
CommittedSPGain: result.SPGain,
CommittedTPGain: result.TPGain,
// Base 直接取冻结表:重试之间资格变化(幸运/回归到期、队伍解散)不能改写
// 已经提交的分类,否则面板数字与真实入账对不上。
Base: runtime.settlementClearExperiencePresentation.Base,
Bonus: bonusPresentation,
// The active client maps BonusGroupsB key=0 to an unrelated banquet label,
// adventure-group experience. Keep the bonus in committed total EXP and
// logs, but do not project an unverified category into the result panel.
BonusGroupsB: nil,
Score: currentDungeonSettlementScore(runtime),
MonsterExperienceByObjectKey: currentDungeonSettlementMonsterExperienceRows(runtime),
Quest: questRows,
// Drops 段刻意留空:见上面 game-dungeon-settlement-drop-receipts-suppressed 的说明,
// 客户端会把这一段的 StackCount 当成结算发放量再加一次(现场 2026-09-18 材料翻倍)。
Drops: nil,
CardSlots: cardSlots,
// op35's PAID-CARD COMMISSION field, the gold-card commission the
// current EXE prints under 黄金卡牌 N. Presentation only: freezing it
// here does not charge the flip, exactly like the 86JP reference, which
// resolves the same number from [gold card cost table] and writes it
// into DungeonNotificationBuilder's "PAID-CARD COMMISSION" slot after
// the eight free-card seats.
TotalReward: runtime.settlementPaidCardCost,
AuxGroupsB: blackDiamondCardSlots,
Tail: currentDungeonClearRewardTail{
ShowResult: true,
MonsterTotalExp: monsterTotalExperience,
Value2: storyQuestBase,
Value3: storyQuestTotal,
},
}
plan, err := buildCurrentDungeonSettlementPacketPlanWithWelcome(
clientRankPoint,
notice,
reward,
result.Character,
result.Skill.Points,
// 本次通关已提交的欢迎经验分类(op37 的 category_9)。指针为 nil 时表示
// 这笔通关没有欢迎加成,出包体里那一行写 0。
runtime.settlementClearWelcomeBonus,
)
if err != nil {
return err
}
if len(permissionBody) != 0 {
plan.DungeonPermissionBody = append([]byte(nil), permissionBody...)
}
// 已婚角色(4)通关推荐副本后回婚姻成长胶囊进度。这里读的 result.Character 是
// 本次结算**已提交**的记录,所以进度已经含上这一把的累加值——与上游同口径。
if result.Character.Stats["wedding_state"] == 4 && currentDungeonIsRecommendedClear(runtime) {
var w packetWriter
w.writeUint32(uint32(result.Character.Stats["wedding_capsule_progress"]))
plan.WeddingCapsuleBody = w.bytes()
}
if towerSettlement {
// The tower window carries exactly ten reward slots resolved from the
// PVF. Despair shows what this run actually picked up; grief shows the
// frozen draw committed with the receipt.
towerFloor, err := s.currentDungeonTowerFloor(runtime)
if err != nil {
return err
}
towerRewards, err := currentDungeonTowerDisplayRewards(runtime, result.Character, currentDungeonTowerPickupRewards(runtime, drops))
if err != nil {
return err
}
plan.TowerRewardMsgID = currentDungeonTowerRewardMsgID(runtime)
plan.TowerRewardBody, err = buildCurrentDungeonTowerRewardBody(presentation.ClearTimeMS, towerFloor, towerRewards)
if err != nil {
return err
}
}
runtime.Character = dnfrepo.CloneCharacter(result.Character)
if result.LuckyStarAttempted {
runtime.settlementLuckyStarResolved = true
runtime.settlementLuckyStarRefreshPending = true
runtime.settlementLuckyStarBefore = result.LuckyStarBefore
runtime.settlementLuckyStarApplied = result.LuckyStarApplied
runtime.settlementLuckyStarAfter = result.LuckyStarAfter
runtime.settlementLuckyStarLimit = result.LuckyStarLimit
s.logGameEvent(session, "game-dungeon-lucky-star-settlement-applied",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"completion_key", runtime.clearMapCompletionKey,
"points_before", result.LuckyStarBefore,
"points_applied", result.LuckyStarApplied,
"points_after", result.LuckyStarAfter,
"replayed", result.Replayed)
}
runtime.settlementResultPlan = &plan
s.logGameEvent(session, "game-dungeon-settlement-plan-frozen",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"completion_key", runtime.clearMapCompletionKey,
"experience_gain", result.ExperienceGain,
"growth_contract_bonus", growthBonus,
"sp_gain", result.SPGain,
"tp_gain", result.TPGain,
"remaining_sp", result.Skill.Points.RemainingSP,
"remaining_tp", result.Skill.Points.RemainingTP,
"op34_rank_grade", presentation.RankGrade,
"client_op46_rank_point", clientRankPoint,
"settlement_reward_rank_point", settlementRankPoint,
"op34_rank_point", clientRankPoint,
"local_debug_rank_forced", debugRankForced,
"op34_clear_time_ms", presentation.ClearTimeMS,
"op34_all_visited_clear", presentation.AllVisitedClear,
"op34_time_bonus_point", presentation.TimeBonusPoint,
"picked_drop_receipts", len(drops),
"replayed", result.Replayed,
"op35_clear_base_exp", gain,
"op35_clear_score_bonus_exp", scoreBonus,
"op35_clear_growth_contract_bonus", growthBonus,
"op35_clear_black_diamond_bonus", blackDiamondBonus,
"op35_clear_ancient_elixir_bonus", ancientElixirBonus,
"op35_clear_ancient_elixir_active", ancientElixirActive,
"op35_clear_ancient_elixir_item_id", ancientElixirEffect.ItemID,
"op35_clear_ancient_elixir_percent", ancientElixirEffect.BonusPercent,
"op35_clear_adventure_group_level", adventureLevel,
"op35_clear_adventure_group_percent", adventurePercent,
"op35_clear_adventure_group_bonus", adventureBonus,
"op35_clear_attendance_marble_bonus", attendanceMarbleBonus,
"op35_monster_base_exp", runtime.settlementMonsterExperienceTotal,
"op35_monster_growth_contract_bonus", runtime.settlementMonsterGrowthContractBonus,
"op35_monster_equipment_bonus", runtime.settlementMonsterEquipmentBonus,
"op35_monster_attendance_marble_bonus", runtime.settlementMonsterAttendanceMarbleBonus,
"op35_monster_fatigue_battery_bonus", runtime.settlementMonsterFatigueBatteryBonus,
"op35_monster_total_exp", monsterTotalExperience,
"op35_story_quest_id", storyQuestID,
"op35_story_quest_base_exp", storyQuestBase,
"op35_story_quest_total_exp", storyQuestTotal,
"op35_show_result", true,
"op35_pending_quest_pairs", len(questRows),
"op35_monster_detail_pairs", len(currentDungeonSettlementMonsterExperienceRows(runtime)),
"op35_free_card_pairs", len(cardSlots[0]),
"op35_black_diamond_card_seats", currentDungeonClearRewardNonEmptyGroupCount(blackDiamondCardSlots),
"op35_champion_experience", runtime.settlementChampionExperience,
"op35_super_champion_experience", runtime.settlementSuperChampionExperience,
"op35_boss_experience", runtime.settlementBossExperience,
"card_rewards", runtime.settlementCardRewardState != nil,
"dungeon_permission_updated", permissionUpdated,
"dungeon_permission_state", permissionState,
"unknown_op35_business_fields", "zero_except_proved_result_gate_score_experience_and_card_reward_context")
return nil
}
// currentDungeonSettlementStoryQuestExperience resolves the selected maze's
// active story connection and computes the split Base/Total values consumed by
// the right-side op35 result panel. This is presentation metadata only: the
// already committed dungeon gain is the sole database experience mutation.
func (s *Service) currentDungeonSettlementStoryQuestExperience(
ctx context.Context,
runtime *runtimeDungeonState,
tables *progression.Tables,
totalGain uint32,
) (base, total uint32, questID int64, err error) {
if runtime == nil {
return 0, 0, 0, errCurrentDungeonSettlementStateInvalid
}
if runtime.settlementStoryQuestBaseResolved {
return runtime.settlementStoryQuestBase, runtime.settlementStoryQuestTotal,
currentDungeonStoryQuestID(runtime), nil
}
questID = currentDungeonStoryQuestID(runtime)
if questID == 0 {
runtime.settlementStoryQuestBaseResolved = true
return 0, 0, 0, nil
}
if s == nil || tables == nil || runtime.Character.Level <= 0 {
return 0, 0, questID, errCurrentDungeonSettlementPVFInvalid
}
currentExperience, present := runtime.Character.Stats["exp"]
if !present || currentExperience < 0 || uint64(currentExperience) > math.MaxUint32 {
return 0, 0, questID, errCurrentDungeonSettlementStateInvalid
}
projectionMaxLevel := currentAdventureCharacterLevelCap
if snapshot := tables.Snapshot(); snapshot.ExperienceThresholds > 0 && snapshot.ExperienceThresholds+1 < projectionMaxLevel {
// Test and compatibility PVFs may intentionally expose a shorter
// progression table; do not ask the pure table rule to index beyond it.
projectionMaxLevel = snapshot.ExperienceThresholds + 1
}
projected, err := tables.ApplyExperience(
runtime.Character.Level,
uint32(currentExperience),
totalGain,
projectionMaxLevel,
)
if err != nil {
return 0, 0, questID, fmt.Errorf("project story quest settlement level: %w", err)
}
catalog, err := s.loadQuestCatalog(ctx)
if err != nil {
return 0, 0, questID, fmt.Errorf("load story quest catalog: %w", err)
}
definition, found := catalog.Find(questID)
if !found {
return 0, 0, questID, fmt.Errorf("story quest %d is absent from PVF catalog", questID)
}
if definition.NoExperience {
runtime.settlementStoryQuestBaseResolved = true
return 0, 0, questID, nil
}
questDifficulty := 'G'
if values := []rune(strings.TrimSpace(definition.Difficulty)); len(values) == 1 {
questDifficulty = values[0]
}
base, total, err = tables.QuestExperienceAward(
projected.NewLevel,
definition.LevelMin,
questDifficulty,
definition.IgnoreQuestLevel4Exp,
)
if err != nil {
return 0, 0, questID, fmt.Errorf("calculate story quest %d settlement experience: %w", questID, err)
}
runtime.settlementStoryQuestBase = base
runtime.settlementStoryQuestTotal = total
runtime.settlementStoryQuestBaseResolved = true
return base, total, questID, nil
}
func currentDungeonStoryQuestID(runtime *runtimeDungeonState) int64 {
if runtime == nil || runtime.MazeIndex < 0 || runtime.MazeIndex >= len(runtime.Dungeon.Mazes) {
return 0
}
connection := runtime.Dungeon.Mazes[runtime.MazeIndex].QuestConnection
if len(connection) < 2 || connection[0] != 0 || connection[1] <= 0 {
return 0
}
return connection[1]
}
func currentDungeonSettlementMonsterExperienceRows(runtime *runtimeDungeonState) []currentDungeonClearRewardPair {
if runtime == nil || len(runtime.settlementMonsterExperienceByObjectKey) == 0 {
return nil
}
keys := make([]uint32, 0, len(runtime.settlementMonsterExperienceByObjectKey))
for key, value := range runtime.settlementMonsterExperienceByObjectKey {
if key != 0 && value != 0 {
keys = append(keys, key)
}
}
sort.Slice(keys, func(left, right int) bool { return keys[left] < keys[right] })
rows := make([]currentDungeonClearRewardPair, 0, len(keys))
for _, key := range keys {
rows = append(rows, currentDungeonClearRewardPair{
Key: key,
Value: runtime.settlementMonsterExperienceByObjectKey[key],
})
}
return rows
}
// currentDungeonSettlementPendingQuestRows returns only the task completions
// belonging to this settlement's durable clear-map receipt. The client uses
// these op35 rows to construct the left-side quest result panel from its PVF;
// they are presentation metadata and do not grant any additional reward.
func (s *Service) currentDungeonSettlementPendingQuestRows(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
) ([]currentDungeonClearRewardPair, error) {
if session == nil || runtime == nil || runtime.clearMapCompletionKey == "" ||
len(runtime.clearMapCompletionPendingQuestIDs) == 0 {
return nil, nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Quest == nil {
return nil, errCurrentDungeonSettlementOwnerUnavailable
}
if ctx == nil {
ctx = context.Background()
}
record, found, err := repositories.Quest.Load(ctx, strconv.FormatUint(uint64(session.selectedCharacterID), 10))
if err != nil {
return nil, err
}
if !found {
return nil, nil
}
rows := make([]currentDungeonClearRewardPair, 0, len(runtime.clearMapCompletionPendingQuestIDs))
seen := make(map[int64]struct{}, len(runtime.clearMapCompletionPendingQuestIDs))
for _, questID := range runtime.clearMapCompletionPendingQuestIDs {
if questID <= 0 || questID > math.MaxUint32 {
continue
}
if _, duplicate := seen[questID]; duplicate {
continue
}
seen[questID] = struct{}{}
state, present := currentDungeonClearMapQuestState(record, questID)
if !present || state.ProgressValue != 0 || !stringsEqualFoldTrim(state.Status, "active") ||
state.Extra == nil || state.Extra["completion_key"] != runtime.clearMapCompletionKey ||
state.Extra["reward_state"] != "pending" {
continue
}
rows = append(rows, currentDungeonClearRewardPair{
Key: uint32(questID),
Value: currentDungeonClearRewardQuestCompletedState,
})
}
sort.Slice(rows, func(left, right int) bool {
return rows[left].Key < rows[right].Key
})
return rows, nil
}
func currentDungeonAdventureGroupBonusGroup(bonus uint32) []currentDungeonClearRewardPair {
// No current-EXE slot for this category has been proven. Returning nil is
// preferable to displaying the bonus under that unrelated label.
return nil
}
func currentDungeonSettlementMonsterTotalExperience(runtime *runtimeDungeonState) uint32 {
if runtime == nil {
return 0
}
total := runtime.settlementMonsterExperienceTotal
total = saturatingCurrentDungeonUint32Add(total, runtime.settlementMonsterGrowthContractBonus)
total = saturatingCurrentDungeonUint32Add(total, runtime.settlementMonsterEquipmentBonus)
total = saturatingCurrentDungeonUint32Add(total, runtime.settlementMonsterAttendanceMarbleBonus)
total = saturatingCurrentDungeonUint32Add(total, runtime.settlementMonsterWelcomeBonus)
total = saturatingCurrentDungeonUint32Add(total, runtime.settlementMonsterLuckyCharacterBonus)
return saturatingCurrentDungeonUint32Add(total, runtime.settlementMonsterFatigueBatteryBonus)
}
func currentDungeonSettlementScore(runtime *runtimeDungeonState) [currentDungeonClearRewardScoreFieldCount]uint32 {
if runtime == nil {
return [currentDungeonClearRewardScoreFieldCount]uint32{}
}
// Current NoPack consumes Score[1..3] as the champion,
// super-champion, and boss result breakdown. Score[0] remains neutral;
// aggregate monster EXP has its dedicated tail field. Clamp every
// classified subtotal to that aggregate so damaged runtime state cannot
// overstate it.
return [currentDungeonClearRewardScoreFieldCount]uint32{
0,
min(runtime.settlementChampionExperience, runtime.settlementMonsterExperienceTotal),
min(runtime.settlementSuperChampionExperience, runtime.settlementMonsterExperienceTotal),
min(runtime.settlementBossExperience, runtime.settlementMonsterExperienceTotal),
}
}
func currentDungeonClearRewardCardSlotsFromRuntime(runtime *runtimeDungeonState) [currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair {
var slots [currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair
if runtime == nil || runtime.settlementCardRewardState == nil {
return slots
}
runtime.settlementCardRewardState.mu.Lock()
plan := cloneDungeonCardRewardPlan(runtime.settlementCardRewardState.plan)
runtime.settlementCardRewardState.mu.Unlock()
currentDungeonProjectCardBundleToClearRewardSlots(&slots,
int(runtimePartyDungeonCardMemberIndex(runtime)), plan.Sides[dungeonCardSideFree])
return slots
}
func currentDungeonClearRewardBlackDiamondSlotsFromRuntime(runtime *runtimeDungeonState) [currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair {
var slots [currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair
if runtime == nil || runtime.settlementCardRewardState == nil {
return slots
}
runtime.settlementCardRewardState.mu.Lock()
plan := cloneDungeonCardRewardPlan(runtime.settlementCardRewardState.plan)
runtime.settlementCardRewardState.mu.Unlock()
for member := range plan.BlackDiamond {
currentDungeonProjectCardBundleToClearRewardSlots(&slots, member, plan.BlackDiamond[member])
}
return slots
}
func currentDungeonClearRewardNonEmptyGroupCount(groups [currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair) int {
count := 0
for _, group := range groups {
if len(group) > 0 {
count++
}
}
return count
}
func currentDungeonProjectCardBundleToClearRewardSlots(
slots *[currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair,
base int,
bundle dungeonCardRewardBundle,
) {
if slots == nil || base < 0 || base >= len(slots) {
return
}
displayItems := currentDungeonCardDisplayItems(bundle)
pairs := make([]currentDungeonClearRewardPair, 0, 1+len(displayItems))
if bundle.Gold > 0 && bundle.Gold <= math.MaxUint32 {
pairs = append(pairs, currentDungeonClearRewardPair{Key: 0, Value: uint32(bundle.Gold)})
}
for _, item := range displayItems {
if item.ItemID <= 0 || item.ItemID > math.MaxUint32 || item.Count <= 0 {
continue
}
count := item.Count
if count > math.MaxUint32 {
count = math.MaxUint32
}
pairs = append(pairs, currentDungeonClearRewardPair{
Key: uint32(item.ItemID),
Value: uint32(count),
})
}
// The eight op35 groups are party-member seats (free 0..3, paid 4..7),
// not separate gold/item card faces. Keep every reward tuple for one side
// in that member's single group so the free-row reveal can render the item
// while retaining the accompanying gold amount.
slots[base] = pairs
}
func (s *Service) currentDungeonSettlementRewardResources(ctx context.Context) (currentDungeonSettlementRewardResources, error) {
if s == nil {
return currentDungeonSettlementRewardResources{}, errCurrentDungeonSettlementOwnerUnavailable
}
if ctx == nil {
ctx = context.Background()
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return currentDungeonSettlementRewardResources{}, err
}
tables, err := progression.Load(ctx, archive)
if err != nil {
return currentDungeonSettlementRewardResources{}, err
}
sources, err := progression.LoadMonsterExperienceSources(archive)
if err != nil {
return currentDungeonSettlementRewardResources{}, err
}
clearRankCatalog, err := loadCurrentDungeonClearRankCatalog(archive)
if err != nil {
return currentDungeonSettlementRewardResources{}, err
}
return currentDungeonSettlementRewardResources{
Progression: tables,
ExperienceSources: sources,
ClearRankCatalog: clearRankCatalog,
}, nil
}
func currentDungeonClearExperience(
runtime *runtimeDungeonState,
resources currentDungeonSettlementRewardResources,
) (uint32, error) {
if runtime == nil || resources.Progression == nil || resources.ExperienceSources == nil ||
!runtime.Dungeon.Metadata.BasisLevel.Set || runtime.Dungeon.Metadata.BasisLevel.Value <= 0 {
return 0, errCurrentDungeonSettlementPVFInvalid
}
// The dungeon field is optional in the runtime PVF: ordinary dungeon 3
// (幽暗密林) omits it. The established domain rule is neutral weight 1.0
// when absent or negative; an explicit non-negative value remains exact.
weight := 1.0
if runtime.Dungeon.Metadata.ExperienceIncreasingPoint.Set &&
runtime.Dungeon.Metadata.ExperienceIncreasingPoint.Value >= 0 {
weight = runtime.Dungeon.Metadata.ExperienceIncreasingPoint.Value
}
if math.IsNaN(weight) || math.IsInf(weight, 0) {
return 0, errCurrentDungeonSettlementPVFInvalid
}
// A PVF tutorial may declare an explicit zero weight, which means "this
// clear pays no clear experience". Live 2026-09-23 (character 123456, male
// Priest): 7113 (dungeon/Cataclysm/NewTutorial/7113_Priest_M_Tutorial.dgn)
// declares `[experience increasing point] 0`, and rejecting it blocked the
// whole settlement plan -- op34/op37/op35 were never sent, so the client
// never showed the result, never auto-returned, and every town-return op42
// was answered with completed_run_missing_play_result_or_statistic_barrier.
// A tutorial is a mandatory one-time handshake whose settlement panel is the
// client's own exit condition, so a legitimately zero reward must still
// settle. Every other dungeon keeps the strict rule, because the same value
// also scales monster experience and the local drop rate.
if weight == 0 && isPVFTutorialDungeon(runtime) {
return 0, nil
}
if weight <= 0 {
return 0, errCurrentDungeonSettlementPVFInvalid
}
base, err := resources.Progression.DungeonClearBaseExperience(int(runtime.Dungeon.Metadata.BasisLevel.Value))
if err != nil || base == 0 {
return 0, fmt.Errorf("%w: base=%d: %v", errCurrentDungeonSettlementPVFInvalid, base, err)
}
modifiers := resources.ExperienceSources.RawModifiers()
difficulty := int(runtime.Request.Difficulty)
if difficulty < 0 || difficulty >= len(modifiers.DifficultyRates) {
return 0, errCurrentDungeonSettlementPVFInvalid
}
difficultyRate := modifiers.DifficultyRates[difficulty]
if math.IsNaN(difficultyRate) || math.IsInf(difficultyRate, 0) || difficultyRate <= 0 {
return 0, errCurrentDungeonSettlementPVFInvalid
}
value := math.Floor(float64(base) * weight * difficultyRate)
if value <= 0 || value > math.MaxUint32 {
return 0, fmt.Errorf("%w: computed=%v", errCurrentDungeonSettlementPVFInvalid, value)
}
return uint32(value), nil
}
// currentDungeonScoreBonusExp calculates the score bonus experience from the
// PVF [clear rank exp bonusrate] table. The bonus index is the number of rank
// grade thresholds the client rank point meets or exceeds (86JP
// MonsterRewardTable.GetClearRankBonusIndex).
// currentDungeonClearRankBonusRate resolves the PVF [clear rank exp bonusrate]
// term for a client rank point. The clear-card gold formula applies the same
// rate, so both rewards move together with the run's grade.
//
// RankSystemInfo contains more display grades than the five clear-rank rates.
// The client/server rule is therefore not "count every matched grade": only
// the first five thresholds participate, with the highest threshold using the
// last rate and the fifth threshold using the first rate. Counting all eight
// thresholds made a 99-point clear produce an out-of-range index and silently
// dropped the score reward.
func currentDungeonClearRankBonusRate(resources currentDungeonSettlementRewardResources, clientRankPoint byte) (float64, bool) {
if resources.ExperienceSources == nil {
return 0, false
}
modifiers := resources.ExperienceSources.RawModifiers()
bonusIndex := -1
limit := len(resources.ClearRankCatalog.gradeThresholds)
if limit > len(modifiers.ClearRankRates) {
limit = len(modifiers.ClearRankRates)
}
for thresholdIndex := 0; thresholdIndex < limit; thresholdIndex++ {
if clientRankPoint >= resources.ClearRankCatalog.gradeThresholds[thresholdIndex] {
bonusIndex = len(modifiers.ClearRankRates) - 1 - thresholdIndex
break
}
}
if bonusIndex < 0 || bonusIndex >= len(modifiers.ClearRankRates) {
return 0, false
}
rate := modifiers.ClearRankRates[bonusIndex]
if rate <= 0 {
return 0, false
}
return rate, true
}
func currentDungeonScoreBonusExp(base uint32, resources currentDungeonSettlementRewardResources, clientRankPoint byte) uint32 {
if base == 0 || resources.ExperienceSources == nil {
return 0
}
rate, ok := currentDungeonClearRankBonusRate(resources, clientRankPoint)
if !ok {
return 0
}
bonus := math.Floor(float64(base) * rate)
if bonus <= 0 || bonus > math.MaxUint32 {
return 0
}
return uint32(bonus)
}
// currentBlackDiamondBonusExp returns +10% clear experience when the account
// has an active dedicated Black Diamond entitlement (storage type 1000). The
// current client sees that entitlement only as wire type 56.
func (s *Service) currentBlackDiamondBonusExp(ctx context.Context, accountID string, base uint32) uint32 {
if s == nil || base == 0 || strings.TrimSpace(accountID) == "" {
return 0
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Account == nil {
return 0
}
account, found, err := repositories.Account.Load(ctx, strings.TrimSpace(accountID))
if err != nil || !found {
return 0
}
now := time.Now().UTC()
if !premium.BlackDiamondActive(account, now) {
return 0
}
bonus := math.Floor(float64(base) * currentDungeonBlackDiamondBonusRate)
if bonus <= 0 || bonus > math.MaxUint32-float64(base) {
return 0
}
return uint32(bonus)
}
func (s *Service) currentBlackDiamondBonusExpForSession(session *gameSession, base uint32) uint32 {
if base == 0 || !s.currentBlackDiamondActive(session) {
return 0
}
return uint32(uint64(base) * 10 / 100)
}
// currentDungeonSettlementAllowsZeroExperience reports a completion that
// legitimately pays no clear experience. The dungeon module's settlement owner
// rejects Experience == 0 unless the caller proves the zero is intended:
// - a raid gate, whose reward is the raid phase settlement, not the map clear;
// - a PVF tutorial that declares an explicit zero experience weight (male
// Priest tutorial 7113), whose clear reward is the tutorial handshake the
// op143 checkpoint already paid.
//
// Everything else keeps the strict guard, so an accidental zero can never enter
// the settlement ledger.
func (s *Service) currentDungeonSettlementAllowsZeroExperience(runtime *runtimeDungeonState) bool {
return s.currentDungeonRuntimeIsRaidGate(runtime) || isPVFTutorialDungeon(runtime)
}
// currentDungeonSettlementAllowsZeroReward mirrors the
// currentDungeonClearRewardSnapshot.AllowZeroReward ownership proof. It only
// skips the "committed snapshot contains no reward value" shape rejection, so it
// cannot change one byte of a non-zero reward. Recommended-dungeon Lucky Stars
// already use it; a zero-weight PVF tutorial is the second owner, because its
// clear experience, monster experience, drops and cards are all legitimately
// absent while the result panel is still the client's exit condition.
func currentDungeonSettlementAllowsZeroReward(runtime *runtimeDungeonState, luckyStarAttempted bool) bool {
return luckyStarAttempted || isPVFTutorialDungeon(runtime)
}
func (s *Service) commitCurrentDungeonSettlement(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
tables *progression.Tables,
gain uint32,
) (currentDungeonSettlementCommitResult, error) {
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterSettlement == nil || tables == nil {
return currentDungeonSettlementCommitResult{}, errCurrentDungeonSettlementOwnerUnavailable
}
owner, err := dnfdungeon.NewOwner(repositories)
if err != nil {
return currentDungeonSettlementCommitResult{}, errCurrentDungeonSettlementOwnerUnavailable
}
if runtime == nil {
return currentDungeonSettlementCommitResult{}, errCurrentDungeonSettlementStateInvalid
}
runtimeExperience, present := runtime.Character.Stats["exp"]
if !present || runtimeExperience < 0 || uint64(runtimeExperience) > math.MaxUint32 {
return currentDungeonSettlementCommitResult{}, errCurrentDungeonSettlementStateInvalid
}
honorExpertGain, err := currentHonorExpertExperienceGain(
tables,
runtime.Character.Level,
uint32(runtimeExperience),
gain,
)
if err != nil {
return currentDungeonSettlementCommitResult{}, err
}
// Only load the independent PVF honor table for an award that actually
// reaches the character-level-cap split.
var honorTables *dnfhonor.Tables
if honorExpertGain > 0 {
loaded, loadErr := s.loadHonorTable(ctx)
if loadErr != nil {
return currentDungeonSettlementCommitResult{}, loadErr
}
honorTables = loaded
}
accountID := s.accountIDForSession(session)
adventureRuntime := s.adventureGroupTable.Runtime()
rentalPointAward, rentalPointLimit := uint32(0), uint32(0)
if currentDungeonIsRecommendedClear(runtime) {
if rentalCatalog, rentalErr := s.currentRentalCatalogOnly(ctx); rentalErr != nil {
s.logGameEvent(session, "game-dungeon-lucky-star-pvf-blocked",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"reason", rentalErr)
return currentDungeonSettlementCommitResult{}, fmt.Errorf("load recommended-dungeon rental catalog: %w", rentalErr)
} else {
rentalPointAward = rentalCatalog.DungeonClearPoints
rentalPointLimit = rentalCatalog.Limit
}
}
// A tower clear carries its ten frozen reward slots and the entry pool it
// consumes, all committed with this receipt.
towerClear, err := s.currentDungeonTowerClear(runtime)
if err != nil {
return currentDungeonSettlementCommitResult{}, err
}
// 婚姻成长胶囊只在"已婚角色 + 推荐副本通关"时累加,所以先按同一判据决定是否
// 加载 PVF 规则;条件不满足时保持 nil,结算 owner 会整段跳过(见
// wedding.AccumulateCapsuleClear 的调用点)。
recommendedClear := currentDungeonIsRecommendedClear(runtime)
var weddingCapsuleRules *wedding.CapsuleCatalog
if recommendedClear && runtime.Character.Stats["wedding_state"] == 4 {
weddingCapsuleRules, err = s.currentWeddingCapsuleCatalog()
if err != nil {
return currentDungeonSettlementCommitResult{}, fmt.Errorf("load wedding growth capsule catalog: %w", err)
}
}
// 签到类活动的进度**只在副本结算里累加**,没有第二个写入方,所以这里必须把
// 目录备齐并传下去:
// - 月度签到(活动 2370):只要推荐副本通关就记一次日计数
// - 周周签到(活动 2208):需要目录判定当周与补签
// - 签到弹珠:通关清次数决定额外骰子
// 目录拿不到(PVF 缺失/未加载)时保持零值,结算 owner 会整段跳过而不报错——
// 不能让一个没开的活动的目录问题把普通通关卡死。
var (
monthlyCatalog *dnfonlineevent.MonthlyCatalog
weeklyCatalog *dnfonlineevent.WeeklyCatalog
marbleCatalog *dnfonlineevent.MarbleCatalog
)
if recommendedClear {
if catalog, catalogErr := s.currentMonthlyCatalog(ctx); catalogErr != nil {
s.logGameEvent(session, "game-dungeon-monthly-attendance-pvf-blocked",
"char_id", session.selectedCharacterID, "dungeon_id", runtime.Dungeon.ID, "reason", catalogErr)
} else {
monthlyCatalog = catalog
}
if catalog, catalogErr := s.currentWeeklyCatalog(ctx); catalogErr != nil {
s.logGameEvent(session, "game-dungeon-weekly-attendance-pvf-blocked",
"char_id", session.selectedCharacterID, "dungeon_id", runtime.Dungeon.ID, "reason", catalogErr)
} else {
weeklyCatalog = catalog
}
if catalog, catalogErr := s.currentAttendanceMarbleCatalog(); catalogErr != nil {
s.logGameEvent(session, "game-dungeon-attendance-marble-pvf-blocked",
"char_id", session.selectedCharacterID, "dungeon_id", runtime.Dungeon.ID, "reason", catalogErr)
} else {
marbleCatalog = catalog
}
}
// 公会贡献度: assemble this clear's contribution side. Nil for non-members, for
// practice runs, and for clears that never took an entry snapshot.
contributionClear, err := s.currentGuildContributionClear(ctx, runtime)
if err != nil {
return currentDungeonSettlementCommitResult{}, err
}
// Keep the guild registry read-modify-write of the contribution path on the
// same lock as every other guild mutation, or a concurrent guild change on
// another session can interleave and lose one side's update. Upstream's third
// conjunct here was runtime.guildDungeonID != 0; this tree identifies a 公会地下城
// by reverse lookup instead (decision D5).
//
// The lock is taken and released around the commit alone rather than defer'd:
// applySettlementContribution goes through the transaction's own settings
// repository (module-level guild.Load/Save), never through Service.guildMu, so
// it cannot re-enter this lock -- but holding it through the unrelated
// post-settlement projection below would still be gratuitous.
guildLocked := contributionClear != nil || runtime.guildWarID != 0 || s.currentGuildDungeonRun(runtime)
if guildLocked {
s.guildMu.Lock()
}
result, err := owner.CommitSettlement(ctx, dnfdungeon.SettlementCommand{
AccountID: accountID,
CharacterID: fmt.Sprintf("%d", session.selectedCharacterID),
CompletionKey: runtime.clearMapCompletionKey,
Tables: tables,
Experience: gain,
RecommendedDungeonClear: recommendedClear,
WeddingCapsule: weddingCapsuleRules,
MonthlyAttendance: monthlyCatalog != nil,
WeeklyAttendance: weeklyCatalog,
AttendanceMarble: marbleCatalog,
AdventureRuntime: &adventureRuntime,
HonorExpertTables: honorTables,
MaximumCharacterLevel: currentAdventureCharacterLevelCap,
RentalPointAward: rentalPointAward,
RentalPointLimit: rentalPointLimit,
Tower: towerClear,
// A raid gate settles with zero clear experience on purpose; without this
// the zero-experience guard rejected the whole settlement and every op72
// afterwards answered settlement_result_not_ready. A PVF tutorial whose
// own weight is zero is the second legitimate zero-experience completion
// (male Priest tutorial 7113), and it must settle for the same reason:
// the client's exit handshake is the settlement panel itself.
AllowZeroExperience: s.currentDungeonSettlementAllowsZeroExperience(runtime),
GuildContribution: contributionClear,
UpdatedAt: time.Now().UTC(),
})
if guildLocked {
s.guildMu.Unlock()
}
if err != nil {
return currentDungeonSettlementCommitResult{}, err
}
if towerClear != nil {
// Publish only the rows this clear actually granted, so the client's
// list-0 update matches the committed inventory.
runtime.towerRewardUpdates = nil
rewards, err := dnfdungeon.TowerLastRewards(result.Character, towerClear.Kind)
if err != nil {
return currentDungeonSettlementCommitResult{}, err
}
for key, stack := range result.Inventory.Slots {
list, slot, valid := parseSceneInventorySlotKey(key)
if !valid || list != dnfrepo.MainInventoryListType {
continue
}
for _, reward := range rewards {
if reward.ItemID != 0 && stack.ItemID == int64(reward.ItemID) {
runtime.towerRewardUpdates = append(runtime.towerRewardUpdates, currentItemListEntryFromStack(list, slot, stack))
break
}
}
}
}
if contributionClear != nil {
// The delivered 贡献币 rows, captured here so the result send can replay them
// as one op14 batch instead of re-reading the inventory. Read the committed
// inventory, not the pre-settlement copy.
runtime.guildContributionUpdates = currentGuildContributionInventoryEntries(result.Inventory)
}
// 通关结算提交后,把这次经验/等级推进镜像给支援兵。放在这里是因为
// clearMapCompletionKey 已经确定、主角事务也已经提交,支援兵在各自独立
// 的结算事务里追赶,失败不会回滚主角。
s.syncCurrentDungeonCompanionProgression(
ctx,
session,
runtime.clearMapCompletionKey,
tables,
result.ExperienceGain,
nil,
)
return currentDungeonSettlementCommitResult{
Character: result.Character,
Skill: result.Skill,
Inventory: result.Inventory,
ExperienceGain: result.ExperienceGain,
HonorExpertGain: result.HonorExpertGain,
SPGain: result.SPGain,
TPGain: result.TPGain,
LuckyStarBefore: result.LuckyStarBefore,
LuckyStarApplied: result.LuckyStarApplied,
LuckyStarAfter: result.LuckyStarAfter,
LuckyStarLimit: rentalPointLimit,
LuckyStarAttempted: result.LuckyStarAttempted,
Replayed: result.Replayed,
}, nil
}
func planCurrentDungeonSettlementProgression(
tables *progression.Tables,
level int,
total uint32,
gain uint32,
points dnfrepo.SkillPointState,
) (progression.ExperienceSkillPointPlan, error) {
return dnfdungeon.PlanSettlementProgressionAtCap(
tables,
level,
total,
gain,
points,
currentAdventureCharacterLevelCap,
)
}
func currentDungeonCommittedDropReceipts(
runtime *runtimeDungeonState,
inventory dnfrepo.InventoryRecord,
) ([]currentDungeonClearRewardDrop, error) {
if runtime == nil || runtime.DropOwner == nil {
return nil, nil
}
bySlot := make(map[uint16]currentDungeonClearRewardDrop)
activeDiscardAmounts := currentDungeonActiveDiscardAmounts(runtime)
objectKeys := make([]uint32, 0, len(runtime.DropOwner.byObjectKey))
for objectKey := range runtime.DropOwner.byObjectKey {
objectKeys = append(objectKeys, objectKey)
}
sort.Slice(objectKeys, func(left, right int) bool { return objectKeys[left] < objectKeys[right] })
invalid := make([]string, 0)
for _, objectKey := range objectKeys {
drop := runtime.DropOwner.byObjectKey[objectKey]
if drop == nil || drop.Status != runtimeDungeonDropConsumed {
continue
}
if drop.Item.ItemID == 0 {
continue
}
// Epic fragments are committed to the account-owned Epic Book. Their
// destination slot is only a transient client pickup slot which is
// cleared immediately, so it must not be validated or projected as a
// character-inventory receipt in op35.
if len(drop.PickupEpicBookBody) > 0 {
continue
}
if drop.DestinationSlot == 0 {
invalid = append(invalid, fmt.Sprintf("drop=%d item=%d destination=0", drop.ObjectKey, drop.Item.ItemID))
continue
}
stack, found := inventory.Slots[currentDungeonPickupMainSlotKey(int16(drop.DestinationSlot))]
if !found || stack.ItemID != int64(drop.Item.ItemID) || stack.Count <= 0 || stack.Count > math.MaxUint32 {
// Pickup receipts live for the complete run, but the player may
// legally discard that pickup back onto the current dungeon floor.
// In that case the original destination slot is intentionally gone;
// do not reject settlement or invent a reward row for the absent item.
key := currentDungeonDiscardReceiptKey{
SourceSlot: int16(drop.DestinationSlot),
ItemID: drop.Item.ItemID,
}
amount := uint64(drop.Amount)
if amount > 0 && activeDiscardAmounts[key] >= amount {
activeDiscardAmounts[key] -= amount
continue
}
invalid = append(invalid, fmt.Sprintf(
"drop=%d destination=%d runtime_item=%d found=%t inventory_item=%d inventory_count=%d",
drop.ObjectKey, drop.DestinationSlot, drop.Item.ItemID, found, stack.ItemID, stack.Count,
))
continue
}
bySlot[drop.DestinationSlot] = currentDungeonClearRewardDrop{
ObjectKey: drop.DestinationSlot, TemplateID: drop.Item.ItemID, StackCount: uint32(stack.Count),
}
}
result := make([]currentDungeonClearRewardDrop, 0, len(bySlot))
for _, drop := range bySlot {
result = append(result, drop)
}
sort.Slice(result, func(i, j int) bool { return result[i].ObjectKey < result[j].ObjectKey })
if len(invalid) > 0 {
return result, fmt.Errorf("%w: stale pickup presentation receipts: %s", errCurrentDungeonSettlementStateInvalid, strings.Join(invalid, "; "))
}
return result, nil
}
type currentDungeonDiscardReceiptKey struct {
SourceSlot int16
ItemID uint32
}
func currentDungeonActiveDiscardAmounts(runtime *runtimeDungeonState) map[currentDungeonDiscardReceiptKey]uint64 {
amounts := make(map[currentDungeonDiscardReceiptKey]uint64)
if runtime == nil || runtime.DropOwner == nil {
return amounts
}
for _, discard := range runtime.DropOwner.byObjectKey {
if discard == nil || discard.DiscardOrigin == nil ||
(discard.Status != runtimeDungeonDropAvailable && discard.Status != runtimeDungeonDropPending) {
continue
}
origin := discard.DiscardOrigin
if origin.ListType != dnfrepo.MainInventoryListType || origin.SourceSlot < 0 ||
origin.Stack.ItemID <= 0 || uint64(origin.Stack.ItemID) > math.MaxUint32 ||
origin.Stack.Count <= 0 {
continue
}
key := currentDungeonDiscardReceiptKey{
SourceSlot: origin.SourceSlot,
ItemID: uint32(origin.Stack.ItemID),
}
amounts[key] += uint64(origin.Stack.Count)
}
return amounts
}