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