Files
90CNServer/server/internal/modules/dnf/progression/quest_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

189 lines
7.3 KiB
Go

package progression
import (
"fmt"
"math"
"strings"
"unicode/utf8"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
type questExperienceRules struct {
baseByLevel []uint32
goldByLevel []uint32
difficultyWeight map[rune]int
greenPenalty int
greyPenalty int
}
// DungeonClearBaseExperience returns the exact level row from the runtime
// PVF [exp reward table]. It deliberately applies no difficulty, rank,
// premium, party, or account modifier; the dungeon settlement owner must
// combine only modifiers it has independently proved.
func (t *Tables) DungeonClearBaseExperience(dungeonBasisLevel int) (uint32, error) {
if t == nil || dungeonBasisLevel <= 0 || dungeonBasisLevel > len(t.quest.baseByLevel) {
return 0, fmt.Errorf(
"%w: dungeon basis level=%d entries=%d",
ErrQuestExperienceLookup,
dungeonBasisLevel,
func() int {
if t == nil {
return 0
}
return len(t.quest.baseByLevel)
}(),
)
}
return t.quest.baseByLevel[dungeonBasisLevel-1], nil
}
// QuestExperienceAward returns both the PVF quest reward-table base and the
// level/difficulty-adjusted amount. The two values are separate on the current
// dungeon result window: Base is the unadjusted quest row and Gain is the
// amount rendered after the quest level penalty and difficulty weight.
func (t *Tables) QuestExperienceAward(playerLevel, questMinLevel int, difficulty rune, ignoreLevel bool) (base, gain uint32, err error) {
if t == nil || playerLevel <= 0 || questMinLevel <= 0 {
return 0, 0, fmt.Errorf("%w: player=%d quest_min=%d", ErrLevelOutOfRange, playerLevel, questMinLevel)
}
lookupLevel := questMinLevel
if ignoreLevel {
lookupLevel = playerLevel
}
if lookupLevel > len(t.quest.baseByLevel) {
return 0, 0, fmt.Errorf("%w: level=%d entries=%d", ErrQuestExperienceLookup, lookupLevel, len(t.quest.baseByLevel))
}
weight, ok := t.quest.difficultyWeight[normalizeDifficulty(difficulty)]
if !ok {
return 0, 0, fmt.Errorf("%w: %q", ErrQuestDifficulty, string(difficulty))
}
penalty := 100
if !ignoreLevel {
difference := playerLevel - questMinLevel
switch {
case difference > 11:
penalty = t.quest.greyPenalty
case difference > 6:
penalty = t.quest.greenPenalty
}
}
value := uint64(t.quest.baseByLevel[lookupLevel-1])
value = uint64(penalty) * ((uint64(weight) * value / 100) / 100)
if value > math.MaxUint32 {
return 0, 0, fmt.Errorf("%w: quest experience=%d", ErrExperienceOutOfRange, value)
}
return t.quest.baseByLevel[lookupLevel-1], uint32(value), nil
}
// QuestExperience computes only the adjusted PVF quest reward. Callers that
// render the split result-window fields should use QuestExperienceAward.
func (t *Tables) QuestExperience(playerLevel, questMinLevel int, difficulty rune, ignoreLevel bool) (uint32, error) {
_, gain, err := t.QuestExperienceAward(playerLevel, questMinLevel, difficulty, ignoreLevel)
return gain, err
}
// QuestGold ports the C# QuestParameterTable.ComputeGoldReward domain rule:
// [gold reward table] supplies the base row and quest [gold multiple] (or a
// reward-int item 0 row with count) supplies the percentage multiplier. This is
// pure PVF/accounting data; packet ACK shape is still owned by the bridge.
func (t *Tables) QuestGold(playerLevel, questMinLevel, goldMultiple int, ignoreLevel bool) (uint32, error) {
if t == nil || playerLevel <= 0 || questMinLevel <= 0 {
return 0, fmt.Errorf("%w: player=%d quest_min=%d", ErrLevelOutOfRange, playerLevel, questMinLevel)
}
if len(t.quest.goldByLevel) == 0 {
return 0, fmt.Errorf("%w: %s [gold reward table]", ErrTableEmpty, QuestParameterPath)
}
if goldMultiple <= 0 {
goldMultiple = 100
}
// The gold table is zero-based by quest level. Unlike quest experience,
// gold never switches to the player-level row and never receives the
// green/grey level-gap penalty. Keep the flag in this API because it comes
// from the same quest definition, but it applies only to the EXP path.
_ = ignoreLevel
lookupLevel := questMinLevel
if lookupLevel >= len(t.quest.goldByLevel) {
return 0, fmt.Errorf("%w: level=%d entries=%d", ErrQuestExperienceLookup, lookupLevel, len(t.quest.goldByLevel))
}
value := uint64(goldMultiple) * uint64(t.quest.goldByLevel[lookupLevel]) / 100
if value > math.MaxUint32 {
return 0, fmt.Errorf("%w: quest gold=%d", ErrExperienceOutOfRange, value)
}
return uint32(value), nil
}
func parseQuestExperienceRules(doc *dnfpvf.Document) (questExperienceRules, error) {
if doc == nil {
return questExperienceRules{}, fmt.Errorf("%w: %s", ErrTableEmpty, QuestParameterPath)
}
rules := questExperienceRules{
difficultyWeight: make(map[rune]int),
}
expValues := doc.Ints("exp reward table")
if len(expValues) == 0 || len(expValues)%2 != 0 {
return questExperienceRules{}, fmt.Errorf("%w: %s [exp reward table] integers=%d", ErrTableMalformed, QuestParameterPath, len(expValues))
}
rules.baseByLevel = make([]uint32, 0, len(expValues)/2)
for index := 0; index < len(expValues); index += 2 {
value := expValues[index]
if value < 0 || value > math.MaxUint32 {
return questExperienceRules{}, fmt.Errorf("%w: %s quest base=%d", ErrTableMalformed, QuestParameterPath, value)
}
rules.baseByLevel = append(rules.baseByLevel, uint32(value))
}
goldValues := doc.Ints("gold reward table")
if len(goldValues) == 0 {
return questExperienceRules{}, fmt.Errorf("%w: %s [gold reward table] integers=%d", ErrTableMalformed, QuestParameterPath, len(goldValues))
}
rules.goldByLevel = make([]uint32, 0, len(goldValues))
for index := 0; index < len(goldValues); index++ {
value := goldValues[index]
if value < 0 || value > math.MaxUint32 {
return questExperienceRules{}, fmt.Errorf("%w: %s quest gold=%d", ErrTableMalformed, QuestParameterPath, value)
}
rules.goldByLevel = append(rules.goldByLevel, uint32(value))
}
difficultyTokens := sectionTokens(doc, "difficulty")
for index := 0; index < len(difficultyTokens); index++ {
token := difficultyTokens[index]
if token.Kind != dnfpvf.TokenString && token.Kind != dnfpvf.TokenIdent {
continue
}
key := strings.TrimSpace(token.Value)
r, width := utf8.DecodeRuneInString(key)
if r == utf8.RuneError || width != len(key) {
continue
}
for next := index + 1; next < len(difficultyTokens); next++ {
if difficultyTokens[next].Kind != dnfpvf.TokenInt {
continue
}
value := difficultyTokens[next].Int
if value <= 0 || value > math.MaxInt {
return questExperienceRules{}, fmt.Errorf("%w: %s difficulty=%q weight=%d", ErrTableMalformed, QuestParameterPath, key, value)
}
rules.difficultyWeight[normalizeDifficulty(r)] = int(value)
index = next
break
}
}
if len(rules.difficultyWeight) == 0 {
return questExperienceRules{}, fmt.Errorf("%w: %s [difficulty]", ErrTableEmpty, QuestParameterPath)
}
green, ok := doc.Int("green level penalty")
if !ok || green < 0 || green > 100 {
return questExperienceRules{}, fmt.Errorf("%w: %s green penalty=%d present=%t", ErrTableMalformed, QuestParameterPath, green, ok)
}
grey, ok := doc.Int("grey level penalty")
if !ok || grey < 0 || grey > 100 {
return questExperienceRules{}, fmt.Errorf("%w: %s grey penalty=%d present=%t", ErrTableMalformed, QuestParameterPath, grey, ok)
}
rules.greenPenalty = int(green)
rules.greyPenalty = int(grey)
return rules, nil
}
func normalizeDifficulty(value rune) rune {
return []rune(strings.ToUpper(string(value)))[0]
}