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