Files
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

401 lines
15 KiB
Go

package progression
import (
"errors"
"fmt"
"math"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
MonsterExperienceTablePath = "monster/monsterexp.tbl"
ServerParameterPath = "Etc/ServerParameter.etc"
WorldMapExperiencePenaltyPath = "etc/worldmapexppenaltytable.etc"
)
var (
ErrMonsterExperienceSourceRequired = errors.New("dnf monster experience PVF source is required")
ErrMonsterExperienceSectionMissing = errors.New("dnf monster experience PVF section is missing")
ErrMonsterExperienceSectionShape = errors.New("dnf monster experience PVF section shape is invalid")
ErrMonsterExperienceLookup = errors.New("dnf monster experience PVF lookup is absent")
)
// MonsterExperienceSourceSnapshot reports only typed PVF source coverage. It
// does not claim that the current server's award formula has been reconstructed.
type MonsterExperienceSourceSnapshot struct {
MonsterLevels int
MonsterGlobalRates int
PartyRates int
StarterPartyRates int
DifficultyRates int
DungeonIncreasingRates int
PenaltyRows int
ClearGlobalRates int
ClearRankRates int
}
// MonsterExperienceRawModifiers preserves the exact ServerParameter.etc
// sections whose names mention monster/dungeon experience. Section ordering is
// retained; no multiplication order or hidden level-difference rule is implied.
type MonsterExperienceRawModifiers struct {
MonsterGlobalRates []float64
PartyRates [4]float64
StarterPartyRates [4]float64
DifficultyRates [5]float64
ClearGlobalRates []float64
ClearRankRates [5]float64
}
type MonsterExperienceBlocker string
const (
MonsterExperienceCombinationUnproved MonsterExperienceBlocker = "modifier_combination_unproved"
MonsterExperiencePenaltyDirectionUnproved MonsterExperienceBlocker = "level_difference_direction_unproved"
MonsterExperienceActorTypeUnproved MonsterExperienceBlocker = "actor_type_rate_mapping_unproved"
)
// MonsterExperienceSourcePlan is a pure, non-awarding lookup. Values come from
// the current runtime PVF, but AwardReady deliberately remains false until the
// current server/EXE proves how these independent sources combine.
type MonsterExperienceSourcePlan struct {
MonsterLevel int
MonsterTableValue uint32
PartyMembers int
PartyRate float64
StarterPartyRate float64
DifficultyIndex int
DifficultyRate float64
DungeonID int64
DungeonIncreasingRate float64
DungeonRateFound bool
AwardReady bool
Blockers []MonsterExperienceBlocker
}
// MonsterExperienceSources is an immutable typed view of
// monster/monsterexp.tbl plus the experience-named sections in
// Etc/ServerParameter.etc.
type MonsterExperienceSources struct {
monsterByLevel []uint32
monsterGlobalRates []float64
partyRates [4]float64
starterPartyRates [4]float64
difficultyRates [5]float64
dungeonRates map[int64]float64
penaltyRates map[int]float64
clearGlobalRates []float64
clearRankRates [5]float64
}
func LoadMonsterExperienceSources(source dnfpvf.Source) (*MonsterExperienceSources, error) {
if source == nil {
return nil, ErrMonsterExperienceSourceRequired
}
monsterDocument, err := readDocument(source, MonsterExperienceTablePath)
if err != nil {
return nil, err
}
monsterByLevel, err := parseMonsterExperienceTable(monsterDocument)
if err != nil {
return nil, err
}
serverDocument, err := readDocument(source, ServerParameterPath)
if err != nil {
return nil, err
}
globalRates, err := strictMonsterNumbers(serverDocument, "monster exp bonusrate")
if err != nil {
return nil, err
}
if len(globalRates) != 1 {
return nil, fmt.Errorf("%w: path=%s section=monster exp bonusrate values=%d want=1",
ErrMonsterExperienceSectionShape, ServerParameterPath, len(globalRates))
}
partyValues, err := strictMonsterFixedNumbers(serverDocument, "party user number exp bonusrate", 4)
if err != nil {
return nil, err
}
starterPartyValues, err := strictMonsterFixedNumbers(serverDocument, "party user number exp bonusrate starter server", 4)
if err != nil {
return nil, err
}
difficultyValues, err := strictMonsterFixedNumbers(serverDocument, "dungeon difficulty exp bonusrate", 5)
if err != nil {
return nil, err
}
dungeonRates, err := parseMonsterDungeonRates(serverDocument)
if err != nil {
return nil, err
}
clearGlobalRates, err := strictMonsterNumbers(serverDocument, "clear exp bonusrate")
if err != nil {
return nil, err
}
if len(clearGlobalRates) != 1 {
return nil, fmt.Errorf("%w: path=%s section=clear exp bonusrate values=%d want=1",
ErrMonsterExperienceSectionShape, ServerParameterPath, len(clearGlobalRates))
}
clearRankValues, err := strictMonsterFixedNumbers(serverDocument, "clear rank exp bonusrate", 5)
if err != nil {
return nil, err
}
penaltyDocument, err := readDocument(source, WorldMapExperiencePenaltyPath)
if err != nil {
return nil, err
}
penaltyRates, err := parseMonsterPenaltyRates(penaltyDocument)
if err != nil {
return nil, err
}
result := &MonsterExperienceSources{
monsterByLevel: monsterByLevel,
monsterGlobalRates: globalRates,
dungeonRates: dungeonRates,
penaltyRates: penaltyRates,
clearGlobalRates: clearGlobalRates,
}
copy(result.partyRates[:], partyValues)
copy(result.starterPartyRates[:], starterPartyValues)
copy(result.difficultyRates[:], difficultyValues)
copy(result.clearRankRates[:], clearRankValues)
return result, nil
}
func parseMonsterExperienceTable(document *dnfpvf.Document) ([]uint32, error) {
if document == nil || len(document.Tokens) == 0 {
return nil, fmt.Errorf("%w: path=%s empty", ErrMonsterExperienceSectionShape, MonsterExperienceTablePath)
}
values := make([]int64, 0, len(document.Tokens))
for index, token := range document.Tokens {
if token.Kind != dnfpvf.TokenInt {
return nil, fmt.Errorf("%w: path=%s token=%d kind=%s", ErrMonsterExperienceSectionShape, MonsterExperienceTablePath, index, token.Kind)
}
values = append(values, token.Int)
}
if len(values)%2 != 0 {
return nil, fmt.Errorf("%w: path=%s values=%d row_width=2", ErrMonsterExperienceSectionShape, MonsterExperienceTablePath, len(values))
}
table := make([]uint32, 0, len(values)/2)
for offset := 0; offset < len(values); offset += 2 {
level, value := values[offset], values[offset+1]
wantLevel := int64(len(table) + 1)
if level != wantLevel || value <= 0 || value > math.MaxUint32 {
return nil, fmt.Errorf("%w: path=%s row=%d level=%d want_level=%d value=%d",
ErrMonsterExperienceSectionShape, MonsterExperienceTablePath, offset/2, level, wantLevel, value)
}
table = append(table, uint32(value))
}
return table, nil
}
func strictMonsterNumbers(document *dnfpvf.Document, section string) ([]float64, error) {
tokens, found := document.Section(section)
if !found {
return nil, fmt.Errorf("%w: path=%s section=%s", ErrMonsterExperienceSectionMissing, ServerParameterPath, section)
}
if len(tokens) == 0 {
return nil, fmt.Errorf("%w: path=%s section=%s empty", ErrMonsterExperienceSectionShape, ServerParameterPath, section)
}
values := make([]float64, 0, len(tokens))
for index, token := range tokens {
var value float64
switch token.Kind {
case dnfpvf.TokenInt:
value = float64(token.Int)
case dnfpvf.TokenFloat:
value = token.Float
default:
return nil, fmt.Errorf("%w: path=%s section=%s token=%d kind=%s",
ErrMonsterExperienceSectionShape, ServerParameterPath, section, index, token.Kind)
}
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
return nil, fmt.Errorf("%w: path=%s section=%s token=%d value=%v",
ErrMonsterExperienceSectionShape, ServerParameterPath, section, index, value)
}
values = append(values, value)
}
return values, nil
}
func strictMonsterFixedNumbers(document *dnfpvf.Document, section string, want int) ([]float64, error) {
values, err := strictMonsterNumbers(document, section)
if err != nil {
return nil, err
}
if len(values) != want {
return nil, fmt.Errorf("%w: path=%s section=%s values=%d want=%d",
ErrMonsterExperienceSectionShape, ServerParameterPath, section, len(values), want)
}
return values, nil
}
func parseMonsterDungeonRates(document *dnfpvf.Document) (map[int64]float64, error) {
tokens, found := document.Section("experience increasing point")
if !found {
return nil, fmt.Errorf("%w: path=%s section=experience increasing point", ErrMonsterExperienceSectionMissing, ServerParameterPath)
}
if len(tokens) == 0 || len(tokens)%2 != 0 {
return nil, fmt.Errorf("%w: path=%s section=experience increasing point values=%d row_width=2",
ErrMonsterExperienceSectionShape, ServerParameterPath, len(tokens))
}
rates := make(map[int64]float64, len(tokens)/2)
for offset := 0; offset < len(tokens); offset += 2 {
idToken, rateToken := tokens[offset], tokens[offset+1]
if idToken.Kind != dnfpvf.TokenInt || idToken.Int <= 0 {
return nil, fmt.Errorf("%w: path=%s section=experience increasing point row=%d id_kind=%s id=%d",
ErrMonsterExperienceSectionShape, ServerParameterPath, offset/2, idToken.Kind, idToken.Int)
}
var rate float64
switch rateToken.Kind {
case dnfpvf.TokenInt:
rate = float64(rateToken.Int)
case dnfpvf.TokenFloat:
rate = rateToken.Float
default:
return nil, fmt.Errorf("%w: path=%s section=experience increasing point row=%d rate_kind=%s",
ErrMonsterExperienceSectionShape, ServerParameterPath, offset/2, rateToken.Kind)
}
if math.IsNaN(rate) || math.IsInf(rate, 0) || rate < 0 {
return nil, fmt.Errorf("%w: path=%s section=experience increasing point row=%d rate=%v",
ErrMonsterExperienceSectionShape, ServerParameterPath, offset/2, rate)
}
if _, duplicate := rates[idToken.Int]; duplicate {
return nil, fmt.Errorf("%w: path=%s section=experience increasing point duplicate_dungeon=%d",
ErrMonsterExperienceSectionShape, ServerParameterPath, idToken.Int)
}
rates[idToken.Int] = rate
}
return rates, nil
}
func parseMonsterPenaltyRates(document *dnfpvf.Document) (map[int]float64, error) {
tokens, found := document.Section("penalty table info")
if !found {
return nil, fmt.Errorf("%w: path=%s section=penalty table info", ErrMonsterExperienceSectionMissing, WorldMapExperiencePenaltyPath)
}
const firstKey, lastKey = -16, 20
if len(tokens) != (lastKey-firstKey+1)*2 {
return nil, fmt.Errorf("%w: path=%s section=penalty table info values=%d want=%d",
ErrMonsterExperienceSectionShape, WorldMapExperiencePenaltyPath, len(tokens), (lastKey-firstKey+1)*2)
}
rates := make(map[int]float64, lastKey-firstKey+1)
for offset := 0; offset < len(tokens); offset += 2 {
keyToken, rateToken := tokens[offset], tokens[offset+1]
wantKey := int64(firstKey + offset/2)
if keyToken.Kind != dnfpvf.TokenInt || keyToken.Int != wantKey {
return nil, fmt.Errorf("%w: path=%s section=penalty table info row=%d key=%d want=%d kind=%s",
ErrMonsterExperienceSectionShape, WorldMapExperiencePenaltyPath, offset/2, keyToken.Int, wantKey, keyToken.Kind)
}
var rate float64
switch rateToken.Kind {
case dnfpvf.TokenInt:
rate = float64(rateToken.Int)
case dnfpvf.TokenFloat:
rate = rateToken.Float
default:
return nil, fmt.Errorf("%w: path=%s section=penalty table info row=%d rate_kind=%s",
ErrMonsterExperienceSectionShape, WorldMapExperiencePenaltyPath, offset/2, rateToken.Kind)
}
if math.IsNaN(rate) || math.IsInf(rate, 0) || rate < 0 {
return nil, fmt.Errorf("%w: path=%s section=penalty table info row=%d rate=%v",
ErrMonsterExperienceSectionShape, WorldMapExperiencePenaltyPath, offset/2, rate)
}
rates[int(wantKey)] = rate
}
return rates, nil
}
func (sources *MonsterExperienceSources) Snapshot() MonsterExperienceSourceSnapshot {
if sources == nil {
return MonsterExperienceSourceSnapshot{}
}
return MonsterExperienceSourceSnapshot{
MonsterLevels: len(sources.monsterByLevel),
MonsterGlobalRates: len(sources.monsterGlobalRates),
PartyRates: len(sources.partyRates),
StarterPartyRates: len(sources.starterPartyRates),
DifficultyRates: len(sources.difficultyRates),
DungeonIncreasingRates: len(sources.dungeonRates),
PenaltyRows: len(sources.penaltyRates),
ClearGlobalRates: len(sources.clearGlobalRates),
ClearRankRates: len(sources.clearRankRates),
}
}
func (sources *MonsterExperienceSources) MonsterTableValue(level int) (uint32, bool) {
if sources == nil || level <= 0 || level > len(sources.monsterByLevel) {
return 0, false
}
return sources.monsterByLevel[level-1], true
}
func (sources *MonsterExperienceSources) DungeonIncreasingRate(dungeonID int64) (float64, bool) {
if sources == nil {
return 0, false
}
value, found := sources.dungeonRates[dungeonID]
return value, found
}
// PenaltyRateBySourceKey returns one exact key/value pair from
// worldmapexppenaltytable.etc. The method intentionally does not accept player
// and monster levels because the current EXE has not yet proved which
// subtraction direction produces the source key.
func (sources *MonsterExperienceSources) PenaltyRateBySourceKey(key int) (float64, bool) {
if sources == nil {
return 0, false
}
value, found := sources.penaltyRates[key]
return value, found
}
func (sources *MonsterExperienceSources) RawModifiers() MonsterExperienceRawModifiers {
if sources == nil {
return MonsterExperienceRawModifiers{}
}
return MonsterExperienceRawModifiers{
MonsterGlobalRates: append([]float64(nil), sources.monsterGlobalRates...),
PartyRates: sources.partyRates,
StarterPartyRates: sources.starterPartyRates,
DifficultyRates: sources.difficultyRates,
ClearGlobalRates: append([]float64(nil), sources.clearGlobalRates...),
ClearRankRates: sources.clearRankRates,
}
}
func (sources *MonsterExperienceSources) PlanSource(monsterLevel int, partyMembers int, difficultyIndex int, dungeonID int64) (MonsterExperienceSourcePlan, error) {
if sources == nil {
return MonsterExperienceSourcePlan{}, ErrMonsterExperienceSourceRequired
}
monsterValue, found := sources.MonsterTableValue(monsterLevel)
if !found {
return MonsterExperienceSourcePlan{}, fmt.Errorf("%w: monster_level=%d", ErrMonsterExperienceLookup, monsterLevel)
}
if partyMembers <= 0 || partyMembers > len(sources.partyRates) {
return MonsterExperienceSourcePlan{}, fmt.Errorf("%w: party_members=%d", ErrMonsterExperienceLookup, partyMembers)
}
if difficultyIndex < 0 || difficultyIndex >= len(sources.difficultyRates) {
return MonsterExperienceSourcePlan{}, fmt.Errorf("%w: difficulty_index=%d", ErrMonsterExperienceLookup, difficultyIndex)
}
dungeonRate, dungeonFound := sources.DungeonIncreasingRate(dungeonID)
return MonsterExperienceSourcePlan{
MonsterLevel: monsterLevel,
MonsterTableValue: monsterValue,
PartyMembers: partyMembers,
PartyRate: sources.partyRates[partyMembers-1],
StarterPartyRate: sources.starterPartyRates[partyMembers-1],
DifficultyIndex: difficultyIndex,
DifficultyRate: sources.difficultyRates[difficultyIndex],
DungeonID: dungeonID,
DungeonIncreasingRate: dungeonRate,
DungeonRateFound: dungeonFound,
AwardReady: false,
Blockers: []MonsterExperienceBlocker{
MonsterExperienceCombinationUnproved,
MonsterExperiencePenaltyDirectionUnproved,
MonsterExperienceActorTypeUnproved,
},
}, nil
}