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

734 lines
29 KiB
Go

package dnfbridge
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"sync"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
"longheng.io/server/internal/modules/dnf/premium"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
errCurrentDungeonMonsterExperienceUnavailable = errors.New("current dungeon monster experience is unavailable")
errCurrentDungeonMonsterExperiencePVFInvalid = errors.New("current dungeon monster experience PVF source is invalid")
errCurrentDungeonMonsterExperienceStateInvalid = errors.New("current dungeon monster experience persisted state is invalid")
)
// currentDungeonMonsterExperienceResources intentionally keeps the current
// runtime PVF tables together. The formula below takes its sequencing and
// rounding from the user-authorized 86JP domain implementation, but never
// copies its stale hard-coded base EXP table or old packet body.
type currentDungeonMonsterExperienceResources struct {
Progression *progression.Tables
Sources *progression.MonsterExperienceSources
}
type currentDungeonMonsterExperienceResourcesCacheEntry struct {
resources currentDungeonMonsterExperienceResources
}
var currentDungeonMonsterExperienceResourcesByCatalog sync.Map
func currentDungeonMonsterExperienceResourcesForCatalog(
catalog *pvfDungeonMonsterCatalog,
) (currentDungeonMonsterExperienceResources, error) {
if catalog == nil || catalog.source == nil {
return currentDungeonMonsterExperienceResources{}, errCurrentDungeonMonsterExperienceUnavailable
}
if cached, found := currentDungeonMonsterExperienceResourcesByCatalog.Load(catalog); found {
return cached.(currentDungeonMonsterExperienceResourcesCacheEntry).resources, nil
}
tables, err := progression.Load(context.Background(), catalog.source)
if err != nil {
return currentDungeonMonsterExperienceResources{}, err
}
sources, err := currentDungeonMonsterExperienceSources(catalog)
if err != nil {
return currentDungeonMonsterExperienceResources{}, err
}
resources := currentDungeonMonsterExperienceResources{Progression: tables, Sources: sources}
actual, _ := currentDungeonMonsterExperienceResourcesByCatalog.LoadOrStore(
catalog,
currentDungeonMonsterExperienceResourcesCacheEntry{resources: resources},
)
return actual.(currentDungeonMonsterExperienceResourcesCacheEntry).resources, nil
}
// currentDungeonMonsterExperienceAward records the exact inputs used for one
// accepted hostile death. It intentionally omits contract, account honor,
// party, and result-window bonuses: none of those has a current Go owner in
// this single-character in-dungeon path.
type currentDungeonMonsterExperienceAward struct {
MonsterID int64
MonsterLevel int
MonsterType byte
NamedMonster bool
MonsterTableEXP uint32
DungeonWeight float32
DifficultyRate float32
MonsterTypeRate float32
LevelPenalty float32
PrePenaltyGain uint32
Gain uint32
}
// currentDungeonMonsterExperienceAwardFor reconstructs only the ordinary
// monster EXP domain rule used by 86JP:
//
// runtime monsterexp.tbl base -> dungeon weight -> difficulty -> type/named
// -> killer level penalty.
//
// Each cast is deliberately performed at the same boundaries as the C# float
// to int/uint casts. The base table is the current runtime PVF table because
// the 86JP static table is known to differ from this server's Script.pvf.
func currentDungeonMonsterExperienceAwardFor(
runtime *runtimeDungeonState,
monster runtimeDungeonMonster,
sources *progression.MonsterExperienceSources,
) (currentDungeonMonsterExperienceAward, error) {
if runtime == nil || sources == nil || runtime.Character.Level <= 0 || monster.Spawn.MonsterID <= 0 {
return currentDungeonMonsterExperienceAward{}, errCurrentDungeonMonsterExperienceUnavailable
}
monsterLevel, err := currentDungeonMonsterLevel(monster.Spawn, runtime)
if err != nil {
return currentDungeonMonsterExperienceAward{}, err
}
monsterType, err := currentDungeonMonsterType(monster.Spawn.Rank)
if err != nil {
return currentDungeonMonsterExperienceAward{}, err
}
base, found := sources.MonsterTableValue(int(monsterLevel))
if !found {
return currentDungeonMonsterExperienceAward{}, fmt.Errorf(
"%w: monster level=%d is absent from monsterexp.tbl",
errCurrentDungeonMonsterExperiencePVFInvalid,
monsterLevel,
)
}
weight := float32(1)
if runtime.Dungeon.Metadata.ExperienceIncreasingPoint.Set &&
runtime.Dungeon.Metadata.ExperienceIncreasingPoint.Value >= 0 {
weight = float32(runtime.Dungeon.Metadata.ExperienceIncreasingPoint.Value)
}
if !currentDungeonMonsterExperienceFiniteNonNegative(weight) {
return currentDungeonMonsterExperienceAward{}, fmt.Errorf(
"%w: dungeon weight=%v",
errCurrentDungeonMonsterExperiencePVFInvalid,
weight,
)
}
modifiers := sources.RawModifiers()
difficultyRate := float32(1) // exact 86JP fallback for an out-of-range index.
difficulty := int(runtime.Request.Difficulty)
if difficulty >= 0 && difficulty < len(modifiers.DifficultyRates) {
difficultyRate = float32(modifiers.DifficultyRates[difficulty])
}
if !currentDungeonMonsterExperienceFiniteNonNegative(difficultyRate) {
return currentDungeonMonsterExperienceAward{}, fmt.Errorf(
"%w: difficulty=%d rate=%v",
errCurrentDungeonMonsterExperiencePVFInvalid,
difficulty,
difficultyRate,
)
}
monsterTypeRate := currentDungeonMonsterCSharpTypeRate(modifiers.MonsterGlobalRates, int(monsterType))
if !currentDungeonMonsterExperienceFiniteNonNegative(monsterTypeRate) {
return currentDungeonMonsterExperienceAward{}, fmt.Errorf(
"%w: monster type=%d rate=%v",
errCurrentDungeonMonsterExperiencePVFInvalid,
monsterType,
monsterTypeRate,
)
}
named := monsterType != 3 && currentDungeonMonsterIsNamed(runtime, monster.Spawn.MonsterID)
if named {
monsterTypeRate *= 3
if !currentDungeonMonsterExperienceFiniteNonNegative(monsterTypeRate) {
return currentDungeonMonsterExperienceAward{}, fmt.Errorf(
"%w: named monster type rate=%v",
errCurrentDungeonMonsterExperiencePVFInvalid,
monsterTypeRate,
)
}
}
weightedBase, err := currentDungeonMonsterExperienceCSharpInt(float32(base) * weight)
if err != nil {
return currentDungeonMonsterExperienceAward{}, err
}
prePenalty, err := currentDungeonMonsterExperienceCSharpInt(
float32(weightedBase) * difficultyRate * monsterTypeRate,
)
if err != nil {
return currentDungeonMonsterExperienceAward{}, err
}
levelPenalty := currentDungeonMonsterCSharpBaseExpPenalty(runtime.Character.Level, int(monsterLevel))
gain, err := currentDungeonMonsterExperienceCSharpUint(float32(prePenalty) * levelPenalty)
if err != nil {
return currentDungeonMonsterExperienceAward{}, err
}
return currentDungeonMonsterExperienceAward{
MonsterID: monster.Spawn.MonsterID,
MonsterLevel: int(monsterLevel),
MonsterType: monsterType,
NamedMonster: named,
MonsterTableEXP: base,
DungeonWeight: weight,
DifficultyRate: difficultyRate,
MonsterTypeRate: monsterTypeRate,
LevelPenalty: levelPenalty,
PrePenaltyGain: prePenalty,
Gain: gain,
}, nil
}
func currentDungeonMonsterIsNamed(runtime *runtimeDungeonState, monsterID int64) bool {
if runtime == nil || monsterID <= 0 {
return false
}
for _, namedID := range runtime.Dungeon.Metadata.NamedMonsters {
if namedID == monsterID {
return true
}
}
return false
}
// currentDungeonMonsterCSharpTypeRate preserves the 86JP range fallback:
// missing or out-of-range monster-type entries use index zero, and an empty
// rate list is neutral. Current runtime PVF has one entry, but do not invent
// distinct rank rates where its typed source does not provide them.
func currentDungeonMonsterCSharpTypeRate(values []float64, monsterType int) float32 {
if len(values) == 0 {
return 1
}
if monsterType < 0 || monsterType >= len(values) {
return float32(values[0])
}
return float32(values[monsterType])
}
// currentDungeonMonsterCSharpBaseExpPenalty is the authorized 86JP domain
// rule (MonsterRewardTable.BaseExpPenalty), including its truncation inputs.
// It is not a guessed interpretation of worldmapexppenaltytable.etc.
func currentDungeonMonsterCSharpBaseExpPenalty(characterLevel, monsterLevel int) float32 {
difference := monsterLevel - characterLevel
if difference <= -7 {
return 0.05
}
switch difference {
case -6:
return 0.20
case -5:
return 0.50
case -4:
return 0.75
case -3, -2, -1, 0:
return 1
case 1, 2, 3:
return 1.12
case 4, 5:
return 1
case 6:
return 0.75
case 7:
return 0.70
case 8:
return 0.60
case 9:
return 0.50
default:
return 0.05
}
}
func currentDungeonMonsterExperienceFiniteNonNegative(value float32) bool {
return !math.IsNaN(float64(value)) && !math.IsInf(float64(value), 0) && value >= 0
}
// C# CalcBaseExp and CalcExp both cast their non-negative float result to
// Int32. Rejecting an out-of-range current PVF product is safer than allowing
// Go's conversion semantics to create a fabricated reward.
func currentDungeonMonsterExperienceCSharpInt(value float32) (uint32, error) {
if !currentDungeonMonsterExperienceFiniteNonNegative(value) || value > math.MaxInt32 {
return 0, fmt.Errorf("%w: C# int product=%v", errCurrentDungeonMonsterExperiencePVFInvalid, value)
}
return uint32(value), nil
}
// C# applies BaseExpPenalty after CalcExp and then casts the product to UInt32.
func currentDungeonMonsterExperienceCSharpUint(value float32) (uint32, error) {
if !currentDungeonMonsterExperienceFiniteNonNegative(value) || value > math.MaxUint32 {
return 0, fmt.Errorf("%w: C# uint product=%v", errCurrentDungeonMonsterExperiencePVFInvalid, value)
}
return uint32(value), nil
}
type currentDungeonMonsterExperienceCommitResult struct {
PreviousLevel int
Character dnfrepo.CharacterRecord
Skill dnfrepo.SkillRecord
Award currentDungeonMonsterExperienceAward
SPGain int
TPGain int
Applied bool
Idempotent bool
// GrowthContractBonus is the 成长契约 (premium type 84) +20% bonus
// experience added on top of Award.Gain at commit time.
GrowthContractBonus uint32
// EquipmentBonus is the combined live equipped [exp advantage] percentage
// applied to Award.Gain and committed independently from premium bonuses.
EquipmentBonus uint32
// AttendanceMarbleBonus is the PVF-proven lucky hot dance monster EXP
// bonus from Buff 30 (+100%) and Buff 32 (+50%).
AttendanceMarbleBonus uint32
// WelcomeBonus is the 回归冒险家 (onlineevent returnee) +20% welcome
// monster-kill experience. Classified separately so the native result
// window can show it under its own row instead of folding into the base.
WelcomeBonus uint32
// LuckyCharacterBonus is the 幸运角色 (chn_activatecharacter.etc) monster
// EXP percentage the returnee-lucky activity's character table declares.
LuckyCharacterBonus uint32
// FatigueBatteryBonus is the 30% ancient-spirit bonus consumed with the
// room's fatigue point.
FatigueBatteryBonus uint32
// HonorExpertGain is the accepted part of this award earned after the
// DNF90 character-level cap.
HonorExpertGain uint32
// AdventureGroupCredit 是本次击杀同时镜像进账号冒险团**骁勇结晶**的经验
// (任意等级都记,2026-09-24 服主口径),AdventureGroupMaximum 是其中越过
// 等级上限、额外进成长胶囊槽与名誉经验的部分,AdventureGroupCrystals 是
// 记入后账号持有的骁勇结晶数。
AdventureGroupCredit uint64
AdventureGroupMaximum uint64
AdventureGroupCrystals uint32
// PresentedTotalExperience is the absolute one-shot op37 value. At cap it
// is ordinary baseline plus this award, while durable ordinary EXP remains
// fixed at the baseline.
PresentedTotalExperience uint32
}
// currentMonsterKillOp37TotalExperience owns the cumulative EXP an accepted
// monster death advertises in the op37 (+0x01) field.
//
// 满级(90)之后普通经验条已被 currentCharacterExperienceBaseline 冻结在基准值,
// 本次经验只进名誉等级/骁勇结晶。此前这里恒定送 PresentedTotalExperience
// (基准+本次经验):客户端拿它减去自己那份角色经验后差值恒为本次经验,于是每
// 一次击杀都弹一行"获得X(基础…+成长之契约奖励…)"——满级后这条提示没有意义。
// 满级改送持久化经验后客户端差值为 0,客户端 (VA 0x01D78CEE) 在差值为 0 时只
// 跳过文案(0x01D794EB→0x1D79551),经验条与 SP/TP 提示照旧;经验本身仍在同
// 一个事务里照常进名誉经验/骁勇结晶,见 commitCurrentDungeonMonsterExperience。
//
// 非满级角色一律返回 PresentedTotalExperience,按字节保持原行为。
func currentMonsterKillOp37TotalExperience(result currentDungeonMonsterExperienceCommitResult) uint32 {
if result.Character.Level < currentAdventureCharacterLevelCap {
return result.PresentedTotalExperience
}
return statU32(result.Character, "exp", 0)
}
// recordCurrentDungeonMonsterExperience owns result-window statistics only.
// Monster experience is committed per accepted death and must not be granted
// again while building the settlement packet.
func recordCurrentDungeonMonsterExperience(
runtime *runtimeDungeonState,
monster runtimeDungeonMonster,
result currentDungeonMonsterExperienceCommitResult,
) {
if runtime == nil || result.Award.MonsterID <= 0 {
return
}
// base 用**实际支付**的经验(Award.Gain),不是等级惩罚之前的 PrePenaltyGain。
// 用 PrePenaltyGain 会让结果窗的怪物经验累计与真正发给玩家的经验对不上
// (本轮 returnee 用例实测差 20 点,就是等级惩罚那一段)。
// Gain 里已经含了疲劳电池加成,而电池在结果窗有自己的一行,所以这里减掉一次,
// 避免同一份加成被算两遍。
base := result.Award.Gain - min(result.Award.Gain, result.FatigueBatteryBonus)
if monster.ObjectKey != 0 && result.Award.Gain != 0 {
if runtime.settlementMonsterExperienceByObjectKey == nil {
runtime.settlementMonsterExperienceByObjectKey = make(map[uint32]uint32)
}
// The result list shows the committed, level-penalty-adjusted award
// (the same value shown for ordinary rows), not the pre-penalty
// classified subtotal used by Score[1..3].
runtime.settlementMonsterExperienceByObjectKey[monster.ObjectKey] = result.Award.Gain
}
runtime.settlementMonsterExperienceTotal = saturatingCurrentDungeonUint32Add(
runtime.settlementMonsterExperienceTotal,
base,
)
runtime.settlementMonsterGrowthContractBonus = saturatingCurrentDungeonUint32Add(
runtime.settlementMonsterGrowthContractBonus,
result.GrowthContractBonus,
)
runtime.settlementMonsterEquipmentBonus = saturatingCurrentDungeonUint32Add(
runtime.settlementMonsterEquipmentBonus,
result.EquipmentBonus,
)
runtime.settlementMonsterAttendanceMarbleBonus = saturatingCurrentDungeonUint32Add(
runtime.settlementMonsterAttendanceMarbleBonus,
result.AttendanceMarbleBonus,
)
runtime.settlementMonsterFatigueBatteryBonus = saturatingCurrentDungeonUint32Add(
runtime.settlementMonsterFatigueBatteryBonus,
result.FatigueBatteryBonus,
)
runtime.settlementMonsterWelcomeBonus = saturatingCurrentDungeonUint32Add(
runtime.settlementMonsterWelcomeBonus,
result.WelcomeBonus,
)
runtime.settlementMonsterLuckyCharacterBonus = saturatingCurrentDungeonUint32Add(
runtime.settlementMonsterLuckyCharacterBonus,
result.LuckyCharacterBonus,
)
actorType, err := currentDungeonMonsterActorType(monster.Spawn)
if err != nil {
actorType = result.Award.MonsterType
}
switch actorType {
case 1:
runtime.settlementChampionExperience = saturatingCurrentDungeonUint32Add(
runtime.settlementChampionExperience,
base,
)
case 2:
if !result.Award.NamedMonster {
runtime.settlementSuperChampionExperience = saturatingCurrentDungeonUint32Add(
runtime.settlementSuperChampionExperience,
base,
)
}
case 3:
runtime.settlementBossExperience = saturatingCurrentDungeonUint32Add(
runtime.settlementBossExperience,
base,
)
}
}
// awardCurrentDungeonMonsterExperience commits only after the room's
// authoritative actor-death transition has succeeded. The room owner makes
// that transition one-time for the active dungeon runtime; the progression
// UoW then keeps EXP/level and the SP ledger indivisible.
func (s *Service) awardCurrentDungeonMonsterExperience(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
monster runtimeDungeonMonster,
) (currentDungeonMonsterExperienceCommitResult, error) {
if currentDungeonRuntimeIsPractice(runtime) {
return currentDungeonMonsterExperienceCommitResult{}, nil
}
if s == nil || session == nil || runtime == nil || session.selectedCharacterID == 0 {
return currentDungeonMonsterExperienceCommitResult{}, errCurrentDungeonMonsterExperienceUnavailable
}
catalog, err := s.dungeonMonsterCatalog()
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
resources, err := currentDungeonMonsterExperienceResourcesForCatalog(catalog)
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
award, err := currentDungeonMonsterExperienceAwardFor(runtime, monster, resources.Sources)
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
result := currentDungeonMonsterExperienceCommitResult{Award: award}
if award.Gain == 0 {
return result, nil
}
committed, err := s.commitCurrentDungeonMonsterExperience(ctx, session, runtime, resources.Progression, result)
if err != nil {
return committed, err
}
// 支援兵跟随主角经验/等级/觉醒推进:主角这笔经验已经提交完,再按同一
// 事件键镜像给支援兵。gain 用「实际入账经验 + 成长契约加成」,与主角
// 进度条上看到的一致。
if committed.Award.Gain > 0 {
eventKey := currentCompanionMonsterExperienceEventKey(runtime, monster.ObjectKey)
s.syncCurrentDungeonCompanionProgression(
ctx,
session,
eventKey,
resources.Progression,
committed.Award.Gain+committed.GrowthContractBonus,
nil,
)
}
return committed, nil
}
func (s *Service) commitCurrentDungeonMonsterExperience(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
tables *progression.Tables,
result currentDungeonMonsterExperienceCommitResult,
) (currentDungeonMonsterExperienceCommitResult, error) {
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil || repositories.Skill == nil ||
repositories.CharacterSettlement == nil || tables == nil || result.Award.Gain == 0 {
return currentDungeonMonsterExperienceCommitResult{}, errCurrentDungeonMonsterExperienceUnavailable
}
if ctx == nil {
ctx = context.Background()
}
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
accountID := strings.TrimSpace(s.accountIDForSession(session))
if accountID == "" || runtime.Character.CharacterID != characterID {
return currentDungeonMonsterExperienceCommitResult{}, errCurrentDungeonMonsterExperienceStateInvalid
}
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
if !found || character.CharacterID != characterID || strings.TrimSpace(character.AccountID) != accountID {
return currentDungeonMonsterExperienceCommitResult{}, errCurrentDungeonMonsterExperienceStateInvalid
}
skill, found, err := repositories.Skill.Load(ctx, characterID)
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
if !found || skill.CharacterID != characterID {
return currentDungeonMonsterExperienceCommitResult{}, errCurrentDungeonMonsterExperienceStateInvalid
}
experience, present := character.Stats["exp"]
runtimeExperience, runtimePresent := runtime.Character.Stats["exp"]
if !present || !runtimePresent || experience < 0 || uint64(experience) > math.MaxUint32 ||
character.Level <= 0 || character.Level != runtime.Character.Level || experience != runtimeExperience ||
skill.Points.SyncedLevel != character.Level {
return currentDungeonMonsterExperienceCommitResult{}, errCurrentDungeonMonsterExperienceStateInvalid
}
if runtime.Session != nil {
if scene, ok := runtime.Session.Scene(); ok && currentDungeonRoomUsesFatigueBattery(runtime, scene) {
boosted := fatigueBatteryExperience(result.Award.Gain)
result.FatigueBatteryBonus = boosted - result.Award.Gain
result.Award.Gain = boosted
}
}
// 成长契约 (premium type 84): +20% monster kill experience while active.
result.PreviousLevel = character.Level
// The total is what gets planned and committed; the base Gain stays the
// pure PVF/86JP monster award for logging and tests.
// 燃烧疲劳:打怪经验也双倍(GM 开关)。与通关经验同一口径——在基础怪物
// 经验上翻倍,成长契约/装备/出勤珠等所有加成都按翻倍后的 base 计算。
if s.currentGMBurningEnabled() {
result.Award.Gain = saturatingCurrentDungeonUint32Add(result.Award.Gain, result.Award.Gain)
}
growthBonus := s.currentGrowthContractBonusExp(ctx, accountID, result.Award.Gain)
equipmentPercent, err := s.currentDungeonEquipmentExpAdvantagePercent(ctx, characterID)
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
equipmentBonus := currentPercentageBonusUint32(result.Award.Gain, equipmentPercent)
attendanceMarbleBonus := currentAttendanceMarbleMonsterExperienceBonus(runtime.attendanceMarbleBuffs, result.Award.Gain)
totalGain := saturatingCurrentDungeonUint32Add(result.Award.Gain, attendanceMarbleBonus)
totalGain = saturatingCurrentDungeonUint32Add(totalGain, growthBonus)
totalGain = saturatingCurrentDungeonUint32Add(totalGain, equipmentBonus)
result.GrowthContractBonus = growthBonus
result.EquipmentBonus = equipmentBonus
result.AttendanceMarbleBonus = attendanceMarbleBonus
// 回归冒险家欢迎经验(+20%)。单独归类,让原生结果窗能用自己的那一行显示,
// 而不是被折进基础值里看不出来。
welcomeBonus, err := s.currentReturneeWelcomeExperienceBonus(ctx, session, character, result.Award.Gain)
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
result.WelcomeBonus = min(welcomeBonus, math.MaxUint32-totalGain)
totalGain = saturatingCurrentDungeonUint32Add(totalGain, result.WelcomeBonus)
// 幸运角色(回归活动的角色表)怪物经验百分比,同样单独归类。
if lucky := s.currentLuckyCharacterEffect(session, character); lucky != nil {
result.LuckyCharacterBonus = currentPercentageBonusUint32(result.Award.Gain, lucky.Monster)
totalGain = saturatingCurrentDungeonUint32Add(totalGain, result.LuckyCharacterBonus)
}
presentedTotalExperience, err := currentCharacterExperienceAwardPresentation(
tables,
character.Level,
uint32(experience),
totalGain,
)
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
result.PresentedTotalExperience = presentedTotalExperience
honorExpertGain, err := currentHonorExpertExperienceGain(
tables,
character.Level,
uint32(experience),
totalGain,
)
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
planned, err := planCurrentDungeonSettlementProgression(
tables,
character.Level,
uint32(experience),
totalGain,
skill.Points,
)
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
commit := progression.CommitPlanFromExperienceSkillPoints(characterID, accountID, planned)
if planned.Experience.LevelsGained > 0 {
levelStats, statErr := s.characterPVFStatValuesForLevel(character, planned.Experience.NewLevel)
if statErr != nil {
return currentDungeonMonsterExperienceCommitResult{}, statErr
}
commit.NextCharacterStats = levelStats
}
if honorExpertGain > 0 {
honorTables, honorErr := s.loadHonorTable(ctx)
if honorErr != nil {
return currentDungeonMonsterExperienceCommitResult{}, honorErr
}
currentHonor, honorErr := currentHonorExpertProgress(character)
if honorErr != nil {
return currentDungeonMonsterExperienceCommitResult{}, honorErr
}
nextHonor, honorErr := planCurrentHonorExpertProgress(honorTables, character, honorExpertGain)
if honorErr != nil {
return currentDungeonMonsterExperienceCommitResult{}, honorErr
}
commit.ExpectedCharacterStats = currentHonorExpertStats(currentHonor)
if commit.NextCharacterStats == nil {
commit.NextCharacterStats = make(map[string]int64, 2)
}
for key, value := range currentHonorExpertStats(nextHonor) {
commit.NextCharacterStats[key] = value
}
result.HonorExpertGain = honorExpertGain
}
// 角色经验与账号级冒险团状态(骁勇结晶 / 成长胶囊槽 / 荣誉经验)必须在
// **同一个事务**里提交:以前这里只走角色-技能事务,同一笔满级经验推进了
// 熟练等级却换不到一个结晶,于是"打怪一辈子进度条不动"。
// 线上取证:怪物击杀经验占一次通关总经验的 3~8 成,是结晶累积的主要来源。
//
// 事务与写入本身归 dungeon 域所有(bridge 只管解析/规划/发包),
// 见 internal/modules/dnf/dungeon/experience_award_owner.go。
awardOwner, ownerErr := dnfdungeon.NewOwner(repositories)
if ownerErr != nil {
return currentDungeonMonsterExperienceCommitResult{}, errCurrentDungeonMonsterExperienceUnavailable
}
adventureRuntime := s.adventureGroupTable.Runtime()
award, err := awardOwner.CommitExperienceAward(ctx, dnfdungeon.ExperienceAwardCommand{
AccountID: accountID,
CharacterID: characterID,
Plan: commit,
AdventureRuntime: &adventureRuntime,
// 全额经验进骁勇结晶(任意等级),只有越过上限的部分进胶囊槽/名誉经验。
AdventureCredit: adventuregroup.ExperienceCredit{
AwardedExperience: uint64(totalGain),
MaximumLevelExperience: uint64(honorExpertGain),
},
UpdatedAt: time.Now().UTC(),
})
if err != nil {
return currentDungeonMonsterExperienceCommitResult{}, err
}
committed := award.Commit
result.AdventureGroupCredit = award.AdventureGroupCredit
result.AdventureGroupMaximum = award.AdventureGroupMaximum
result.AdventureGroupCrystals = award.AdventureGroupCrystals
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
return currentDungeonMonsterExperienceCommitResult{}, errCurrentDungeonMonsterExperienceStateInvalid
}
character.Level = committed.Current.Level
character.Stats["exp"] = int64(committed.Current.Experience)
for key, value := range commit.NextCharacterStats {
character.Stats[key] = value
}
skill = dnfrepo.CloneSkill(skill)
skill.Points = committed.Current.SkillPoints
result.Character = character
result.Skill = skill
result.SPGain = planned.SkillPoints.SPGain
result.TPGain = planned.SkillPoints.TPGain
result.Applied = committed.Applied
result.Idempotent = committed.Idempotent
return result, nil
}
// currentGrowthContractEffect resolves the live account entitlement and the
// effect values from the active runtime PVF. A missing account, expired
// contract, or unavailable PVF catalog is inactive rather than inventing a
// compatibility multiplier.
func (s *Service) currentGrowthContractEffect(ctx context.Context, accountID string) (currentPremiumEffectInfo, bool) {
if s == nil || strings.TrimSpace(accountID) == "" {
return currentPremiumEffectInfo{}, false
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Account == nil {
return currentPremiumEffectInfo{}, false
}
account, found, err := repositories.Account.Load(ctx, strings.TrimSpace(accountID))
if err != nil || !found {
return currentPremiumEffectInfo{}, false
}
if !premium.Active(account, premium.TypeBonusExp, time.Now().UTC()) {
return currentPremiumEffectInfo{}, false
}
catalog, err := s.currentPremiumCatalog()
if err != nil || catalog == nil {
return currentPremiumEffectInfo{}, false
}
effect, found := catalog.effectsByType[premium.TypeBonusExp]
if !found {
return currentPremiumEffectInfo{}, false
}
return effect, true
}
// currentGrowthContractBonusExp returns the runtime-PVF [bonus exp]
// percentage while Growth Contract is active. The bonus saturates so the
// committed base+bonus never overflows the current-client u32 experience
// field.
func (s *Service) currentGrowthContractBonusExp(ctx context.Context, accountID string, base uint32) uint32 {
if base == 0 {
return 0
}
effect, active := s.currentGrowthContractEffect(ctx, accountID)
if !active {
return 0
}
return currentPercentageBonusUint32(base, effect.BonusExperiencePercent)
}
func currentPercentageBonusUint32(base uint32, percent int64) uint32 {
if base == 0 || percent <= 0 {
return 0
}
limit := uint64(math.MaxUint32 - base)
if uint64(percent) > math.MaxUint64/uint64(base) {
return uint32(limit)
}
bonus := uint64(base) * uint64(percent) / 100
if bonus > limit {
bonus = limit
}
return uint32(bonus)
}
func fatigueBatteryExperience(base uint32) uint32 {
boosted := uint64(base) * 13 / 10
if boosted > uint64(math.MaxUint32) {
return math.MaxUint32
}
return uint32(boosted)
}