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

933 lines
33 KiB
Go

// Package honor provides typed, read-only honor progression rules from the
// runtime PVF. It intentionally contains no packet, persistence, or award
// side effects.
package honor
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const TablePath = "etc/honorlevel.etc"
const (
// ExpertLevelStatKey and ExpertProgressExperienceStatKey are the durable
// character-stat keys for the current client's HonorExpert actor state.
ExpertLevelStatKey = "honor_expert_level"
ExpertProgressExperienceStatKey = "honor_expert_progress_experience"
)
var (
ErrSourceRequired = errors.New("dnf honor PVF source is required")
ErrTablesRequired = errors.New("dnf honor tables are required")
ErrSectionMissing = errors.New("dnf honor PVF section is missing")
ErrSectionShape = errors.New("dnf honor PVF section shape is invalid")
ErrCharacterCapInvalid = errors.New("dnf honor character experience cap is invalid")
ErrExpertStateInvalid = errors.New("dnf honor expert state is invalid")
)
// ImageReference preserves one exact PVF image path/index pair. It does not
// imply that the server owns client rendering of that image.
type ImageReference struct {
Path string
Index int
}
// LevelRequirement is the amount consumed when entering Level. Level 1 is
// required to be present with a zero requirement.
type LevelRequirement struct {
Level int
RequiredExperience uint64
}
// OrdinaryGrade preserves one repeated [grade] section and its ordinary
// honor-level rows.
type OrdinaryGrade struct {
Grade int
EffectPath string
Medal ImageReference
Icon ImageReference
Levels []LevelRequirement
}
// ExpertGradeInfo preserves one [grade info] block under [expert info].
// Medal/Icon are optional because the runtime PVF's grade zero has neither.
// MaxLevel == -1 is preserved as the PVF's open-ended sentinel.
type ExpertGradeInfo struct {
Grade int
Name string
Medal ImageReference
HasMedal bool
Icon ImageReference
HasIcon bool
MinLevel int
MaxLevel int
}
// ExpertExperienceRow is the amount required to enter Level from the prior
// HonorExpert level. The current EXE keeps level zero as the challenger state,
// so row one is the threshold for the first expert-level advance.
type ExpertExperienceRow struct {
Level int
Experience uint64
}
// ExpertProgress is the character-scoped HonorExpert state carried by the
// current client. CurrentLevelExperience is always progress towards the next
// level, never a cumulative cross-level total.
type ExpertProgress struct {
Level uint32
CurrentLevelExperience uint64
}
// ExpertProgressFromStats decodes the durable character-scoped state. Older
// records with no expert keys are the valid level-zero challenger state.
func ExpertProgressFromStats(stats map[string]int64) (ExpertProgress, error) {
level := int64(0)
progress := int64(0)
if stats != nil {
level = stats[ExpertLevelStatKey]
progress = stats[ExpertProgressExperienceStatKey]
}
if level < 0 || uint64(level) > math.MaxUint32 || progress < 0 {
return ExpertProgress{}, fmt.Errorf(
"%w: level=%d progress=%d",
ErrExpertStateInvalid,
level,
progress,
)
}
return ExpertProgress{Level: uint32(level), CurrentLevelExperience: uint64(progress)}, nil
}
// ExpertStats returns the durable representation of one already-validated
// HonorExpert state.
func ExpertStats(progress ExpertProgress) (map[string]int64, error) {
if progress.CurrentLevelExperience > math.MaxInt64 {
return nil, fmt.Errorf("%w: progress=%d exceeds persistence range", ErrExpertStateInvalid, progress.CurrentLevelExperience)
}
return map[string]int64{
ExpertLevelStatKey: int64(progress.Level),
ExpertProgressExperienceStatKey: int64(progress.CurrentLevelExperience),
}, nil
}
// Snapshot reports source coverage without exposing mutable internal maps.
type Snapshot struct {
OrdinaryGrades int
OrdinaryLevels int
MaxOrdinaryLevel int
MaxLevelExperience uint64
MaxTotalExperience uint64
ExpertGrades int
ExpertExperienceRows int
ExpertCalculationReady bool
}
// Progress is the ordinary honor state derived from account-scoped cumulative
// honor experience.
type Progress struct {
TotalExperience uint64
Level int
CurrentLevelExperience uint64
Grade int
}
// CharacterExperienceCap is supplied by the caller because the character
// level cap and cumulative EXP threshold are version/runtime data, not honor
// constants.
type CharacterExperienceCap struct {
MaxLevel int
MaxLevelEntryExperience uint32
}
// Tables is an immutable typed view of etc/honorlevel.etc.
type Tables struct {
ordinaryGrades []OrdinaryGrade
requirementByLevel []uint64
gradeByLevel []int
maxLevelExperience uint64
maxTotalExperience uint64
expertGrades []ExpertGradeInfo
expertExperienceRows []ExpertExperienceRow
}
// LoadTables reads and strictly parses the current runtime PVF honor table.
func LoadTables(source dnfpvf.Source) (*Tables, error) {
if source == nil {
return nil, ErrSourceRequired
}
text, err := source.ReadText(TablePath)
if err != nil {
return nil, fmt.Errorf("read dnf honor PVF path %s: %w", TablePath, err)
}
document, err := dnfpvf.Parse(TablePath, text)
if err != nil {
return nil, fmt.Errorf("parse dnf honor PVF path %s: %w", TablePath, err)
}
return ParseTables(document)
}
// ParseTables strictly converts one already-tokenized honor PVF document.
func ParseTables(document *dnfpvf.Document) (*Tables, error) {
if document == nil {
return nil, ErrTablesRequired
}
if !strings.EqualFold(strings.TrimSpace(document.Path), TablePath) {
return nil, fmt.Errorf("%w: path=%s want=%s", ErrSectionShape, document.Path, TablePath)
}
ordinaryGrades, requirements, gradeByLevel, maxSectionIndex, err := parseOrdinaryGrades(document)
if err != nil {
return nil, err
}
maxLevelExperience, err := parseMaxLevelExperience(document, maxSectionIndex)
if err != nil {
return nil, err
}
expertGrades, expertCloseIndex, err := parseExpertGrades(document, maxSectionIndex)
if err != nil {
return nil, err
}
expertRows, err := parseExpertExperienceRows(document, expertCloseIndex)
if err != nil {
return nil, err
}
maxTotalExperience := maxLevelExperience
for level := 2; level < len(requirements); level++ {
required := requirements[level]
if math.MaxUint64-maxTotalExperience < required {
return nil, fmt.Errorf("%w: ordinary total experience overflow at level=%d", ErrSectionShape, level)
}
maxTotalExperience += required
}
return &Tables{
ordinaryGrades: ordinaryGrades,
requirementByLevel: requirements,
gradeByLevel: gradeByLevel,
maxLevelExperience: maxLevelExperience,
maxTotalExperience: maxTotalExperience,
expertGrades: expertGrades,
expertExperienceRows: expertRows,
}, nil
}
func parseOrdinaryGrades(document *dnfpvf.Document) ([]OrdinaryGrade, []uint64, []int, int, error) {
gradeIndices := sectionIndices(document, "grade")
if len(gradeIndices) == 0 {
return nil, nil, nil, -1, fmt.Errorf("%w: path=%s section=grade", ErrSectionMissing, TablePath)
}
maxIndices := sectionIndices(document, "maxexp on maxlevel")
if len(maxIndices) != 1 {
return nil, nil, nil, -1, fmt.Errorf("%w: path=%s section=maxexp on maxlevel count=%d want=1", ErrSectionShape, TablePath, len(maxIndices))
}
maxSectionIndex := maxIndices[0]
grades := make([]OrdinaryGrade, 0, len(gradeIndices))
requirements := []uint64{0}
gradeByLevel := []int{0}
wantGrade := 1
wantLevel := 1
wantSectionIndex := 0
for _, sectionIndex := range gradeIndices {
if sectionIndex != wantSectionIndex {
return nil, nil, nil, -1, fmt.Errorf("%w: path=%s ordinary grade section_index=%d want=%d", ErrSectionShape, TablePath, sectionIndex, wantSectionIndex)
}
if sectionIndex >= maxSectionIndex {
return nil, nil, nil, -1, fmt.Errorf("%w: path=%s ordinary grade after maxexp section", ErrSectionShape, TablePath)
}
if sectionIndex+1 >= len(document.Sections) || sectionName(document.Sections[sectionIndex+1].Name) != "/grade" {
return nil, nil, nil, -1, fmt.Errorf("%w: path=%s section=grade index=%d missing_close", ErrSectionShape, TablePath, sectionIndex)
}
if err := requireEmptySection(document, sectionIndex+1); err != nil {
return nil, nil, nil, -1, err
}
tokens, err := tokensForSection(document, sectionIndex)
if err != nil {
return nil, nil, nil, -1, err
}
if len(tokens) < 8 || (len(tokens)-6)%2 != 0 {
return nil, nil, nil, -1, fmt.Errorf("%w: path=%s section=grade values=%d", ErrSectionShape, TablePath, len(tokens))
}
grade, err := positiveIntToken(tokens[0], "grade.grade")
if err != nil {
return nil, nil, nil, -1, err
}
if grade != wantGrade {
return nil, nil, nil, -1, fmt.Errorf("%w: path=%s section=grade grade=%d want=%d", ErrSectionShape, TablePath, grade, wantGrade)
}
effectPath, err := nonemptyStringToken(tokens[1], "grade.effect")
if err != nil {
return nil, nil, nil, -1, err
}
medalPath, err := nonemptyStringToken(tokens[2], "grade.medal.path")
if err != nil {
return nil, nil, nil, -1, err
}
medalIndex, err := nonnegativeIntToken(tokens[3], "grade.medal.index")
if err != nil {
return nil, nil, nil, -1, err
}
iconPath, err := nonemptyStringToken(tokens[4], "grade.icon.path")
if err != nil {
return nil, nil, nil, -1, err
}
iconIndex, err := nonnegativeIntToken(tokens[5], "grade.icon.index")
if err != nil {
return nil, nil, nil, -1, err
}
ordinary := OrdinaryGrade{
Grade: grade,
EffectPath: effectPath,
Medal: ImageReference{Path: medalPath, Index: medalIndex},
Icon: ImageReference{Path: iconPath, Index: iconIndex},
Levels: make([]LevelRequirement, 0, (len(tokens)-6)/2),
}
for offset := 6; offset < len(tokens); offset += 2 {
level, err := positiveIntToken(tokens[offset], "grade.level")
if err != nil {
return nil, nil, nil, -1, err
}
if level != wantLevel {
return nil, nil, nil, -1, fmt.Errorf("%w: path=%s section=grade level=%d want=%d", ErrSectionShape, TablePath, level, wantLevel)
}
required, err := nonnegativeIntegerUint64Token(tokens[offset+1], "grade.required_experience")
if err != nil {
return nil, nil, nil, -1, err
}
if (level == 1 && required != 0) || (level > 1 && required == 0) {
return nil, nil, nil, -1, fmt.Errorf("%w: path=%s section=grade level=%d required_experience=%d", ErrSectionShape, TablePath, level, required)
}
ordinary.Levels = append(ordinary.Levels, LevelRequirement{Level: level, RequiredExperience: required})
requirements = append(requirements, required)
gradeByLevel = append(gradeByLevel, grade)
wantLevel++
}
grades = append(grades, ordinary)
wantGrade++
wantSectionIndex = sectionIndex + 2
}
if maxSectionIndex != wantSectionIndex {
return nil, nil, nil, -1, fmt.Errorf("%w: path=%s section=maxexp on maxlevel index=%d want=%d", ErrSectionShape, TablePath, maxSectionIndex, wantSectionIndex)
}
return grades, requirements, gradeByLevel, maxSectionIndex, nil
}
func parseMaxLevelExperience(document *dnfpvf.Document, maxSectionIndex int) (uint64, error) {
tokens, err := tokensForSection(document, maxSectionIndex)
if err != nil {
return 0, err
}
if len(tokens) != 1 {
return 0, fmt.Errorf("%w: path=%s section=maxexp on maxlevel values=%d want=1", ErrSectionShape, TablePath, len(tokens))
}
value, err := positiveIntegerUint64Token(tokens[0], "maxexp on maxlevel")
if err != nil {
return 0, err
}
return value, nil
}
func parseExpertGrades(document *dnfpvf.Document, afterSectionIndex int) ([]ExpertGradeInfo, int, error) {
outerStarts := sectionIndices(document, "expert info")
outerCloses := sectionIndices(document, "/expert info")
if len(outerStarts) != 1 || len(outerCloses) != 1 {
return nil, -1, fmt.Errorf("%w: path=%s section=expert info opens=%d closes=%d want=1/1", ErrSectionShape, TablePath, len(outerStarts), len(outerCloses))
}
start, closeIndex := outerStarts[0], outerCloses[0]
if start != afterSectionIndex+1 || closeIndex <= start {
return nil, -1, fmt.Errorf("%w: path=%s section=expert info invalid_order", ErrSectionShape, TablePath)
}
outerTokens, err := tokensForSection(document, start)
if err != nil {
return nil, -1, err
}
if len(outerTokens) != 0 {
return nil, -1, fmt.Errorf("%w: path=%s section=expert info values=%d want=0", ErrSectionShape, TablePath, len(outerTokens))
}
grades := make([]ExpertGradeInfo, 0)
for index := start + 1; index < closeIndex; {
if sectionName(document.Sections[index].Name) != "grade info" {
return nil, -1, fmt.Errorf("%w: path=%s section=expert info unexpected=%s", ErrSectionShape, TablePath, document.Sections[index].Name)
}
blockClose := -1
for cursor := index + 1; cursor < closeIndex; cursor++ {
if sectionName(document.Sections[cursor].Name) == "/grade info" {
blockClose = cursor
break
}
}
if blockClose < 0 {
return nil, -1, fmt.Errorf("%w: path=%s section=grade info missing_close", ErrSectionShape, TablePath)
}
grade, err := parseExpertGradeBlock(document, index, blockClose, len(grades))
if err != nil {
return nil, -1, err
}
grades = append(grades, grade)
index = blockClose + 1
}
if len(grades) == 0 {
return nil, -1, fmt.Errorf("%w: path=%s section=grade info", ErrSectionMissing, TablePath)
}
if err := requireEmptySection(document, closeIndex); err != nil {
return nil, -1, err
}
return grades, closeIndex, nil
}
func parseExpertGradeBlock(document *dnfpvf.Document, start, closeIndex, wantGrade int) (ExpertGradeInfo, error) {
tokens, err := tokensForSection(document, start)
if err != nil {
return ExpertGradeInfo{}, err
}
if len(tokens) != 2 {
return ExpertGradeInfo{}, fmt.Errorf("%w: path=%s section=grade info values=%d want=2", ErrSectionShape, TablePath, len(tokens))
}
grade, err := nonnegativeIntToken(tokens[0], "grade info.grade")
if err != nil {
return ExpertGradeInfo{}, err
}
if grade != wantGrade {
return ExpertGradeInfo{}, fmt.Errorf("%w: path=%s section=grade info grade=%d want=%d", ErrSectionShape, TablePath, grade, wantGrade)
}
name, err := nonemptyStringToken(tokens[1], "grade info.name")
if err != nil {
return ExpertGradeInfo{}, err
}
result := ExpertGradeInfo{Grade: grade, Name: name}
seen := make(map[string]bool, 4)
for index := start + 1; index < closeIndex; index++ {
key := sectionName(document.Sections[index].Name)
if seen[key] {
return ExpertGradeInfo{}, fmt.Errorf("%w: path=%s section=grade info duplicate=%s", ErrSectionShape, TablePath, key)
}
seen[key] = true
values, err := tokensForSection(document, index)
if err != nil {
return ExpertGradeInfo{}, err
}
switch key {
case "medal img", "icon img":
if len(values) != 2 {
return ExpertGradeInfo{}, fmt.Errorf("%w: path=%s section=%s values=%d want=2", ErrSectionShape, TablePath, key, len(values))
}
imagePath, err := nonemptyStringToken(values[0], key+".path")
if err != nil {
return ExpertGradeInfo{}, err
}
imageIndex, err := nonnegativeIntToken(values[1], key+".index")
if err != nil {
return ExpertGradeInfo{}, err
}
if key == "medal img" {
result.Medal, result.HasMedal = ImageReference{Path: imagePath, Index: imageIndex}, true
} else {
result.Icon, result.HasIcon = ImageReference{Path: imagePath, Index: imageIndex}, true
}
case "min lv", "max lv":
if len(values) != 1 {
return ExpertGradeInfo{}, fmt.Errorf("%w: path=%s section=%s values=%d want=1", ErrSectionShape, TablePath, key, len(values))
}
value, err := signedIntToken(values[0], key)
if err != nil {
return ExpertGradeInfo{}, err
}
if key == "min lv" {
result.MinLevel = value
} else {
result.MaxLevel = value
}
default:
return ExpertGradeInfo{}, fmt.Errorf("%w: path=%s section=grade info unexpected=%s", ErrSectionShape, TablePath, key)
}
}
if !seen["min lv"] || !seen["max lv"] || result.MinLevel < 0 || (result.MaxLevel != -1 && result.MaxLevel < result.MinLevel) {
return ExpertGradeInfo{}, fmt.Errorf("%w: path=%s section=grade info grade=%d min=%d max=%d", ErrSectionShape, TablePath, grade, result.MinLevel, result.MaxLevel)
}
if result.HasMedal != result.HasIcon {
return ExpertGradeInfo{}, fmt.Errorf("%w: path=%s section=grade info grade=%d incomplete_images", ErrSectionShape, TablePath, grade)
}
if err := requireEmptySection(document, closeIndex); err != nil {
return ExpertGradeInfo{}, err
}
return result, nil
}
func parseExpertExperienceRows(document *dnfpvf.Document, afterSectionIndex int) ([]ExpertExperienceRow, error) {
starts := sectionIndices(document, "honor expert exp table")
closes := sectionIndices(document, "/honor expert exp table")
if len(starts) != 1 || len(closes) != 1 {
return nil, fmt.Errorf("%w: path=%s section=honor expert exp table opens=%d closes=%d want=1/1", ErrSectionShape, TablePath, len(starts), len(closes))
}
start, closeIndex := starts[0], closes[0]
if start != afterSectionIndex+1 || closeIndex != start+1 || closeIndex != len(document.Sections)-1 {
return nil, fmt.Errorf("%w: path=%s section=honor expert exp table invalid_order_or_nested_section", ErrSectionShape, TablePath)
}
tokens, err := tokensForSection(document, start)
if err != nil {
return nil, err
}
if len(tokens) == 0 || len(tokens)%2 != 0 {
return nil, fmt.Errorf("%w: path=%s section=honor expert exp table values=%d row_width=2", ErrSectionShape, TablePath, len(tokens))
}
rows := make([]ExpertExperienceRow, 0, len(tokens)/2)
for offset := 0; offset < len(tokens); offset += 2 {
level, err := positiveIntToken(tokens[offset], "honor expert exp table.level")
if err != nil {
return nil, err
}
wantLevel := len(rows) + 1
if level != wantLevel {
return nil, fmt.Errorf("%w: path=%s section=honor expert exp table level=%d want=%d", ErrSectionShape, TablePath, level, wantLevel)
}
experience, err := positiveUint64Token(tokens[offset+1], "honor expert exp table.experience")
if err != nil {
return nil, err
}
rows = append(rows, ExpertExperienceRow{Level: level, Experience: experience})
}
if err := requireEmptySection(document, closeIndex); err != nil {
return nil, err
}
return rows, nil
}
// Resolve derives ordinary honor state and caps cumulative experience at the
// exact ordinary-table maximum.
func (tables *Tables) Resolve(totalExperience uint64) (Progress, error) {
if tables == nil || len(tables.requirementByLevel) <= 1 {
return Progress{}, ErrTablesRequired
}
if totalExperience > tables.maxTotalExperience {
totalExperience = tables.maxTotalExperience
}
remaining := totalExperience
level := 1
for nextLevel := 2; nextLevel < len(tables.requirementByLevel); nextLevel++ {
required := tables.requirementByLevel[nextLevel]
if remaining < required {
break
}
remaining -= required
level = nextLevel
}
currentCap := tables.maxLevelExperience
if level+1 < len(tables.requirementByLevel) {
currentCap = tables.requirementByLevel[level+1]
}
if remaining > currentCap {
remaining = currentCap
}
return Progress{
TotalExperience: totalExperience,
Level: level,
CurrentLevelExperience: remaining,
Grade: tables.gradeByLevel[level],
}, nil
}
// ResolveExpert validates and returns one current-client HonorExpert state.
// A level N state keeps only its progress toward N+1; the final table level
// has no following threshold and therefore always carries zero progress.
func (tables *Tables) ResolveExpert(progress ExpertProgress) (ExpertProgress, error) {
if tables == nil || len(tables.expertExperienceRows) == 0 {
return ExpertProgress{}, ErrTablesRequired
}
maximumLevel := uint32(len(tables.expertExperienceRows))
if progress.Level > maximumLevel {
return ExpertProgress{}, fmt.Errorf(
"%w: level=%d maximum=%d",
ErrExpertStateInvalid,
progress.Level,
maximumLevel,
)
}
if progress.Level == maximumLevel {
if progress.CurrentLevelExperience != 0 {
return ExpertProgress{}, fmt.Errorf(
"%w: final level=%d progress=%d",
ErrExpertStateInvalid,
progress.Level,
progress.CurrentLevelExperience,
)
}
return progress, nil
}
required := tables.expertExperienceRows[progress.Level].Experience
if progress.CurrentLevelExperience >= required {
return ExpertProgress{}, fmt.Errorf(
"%w: level=%d progress=%d required=%d",
ErrExpertStateInvalid,
progress.Level,
progress.CurrentLevelExperience,
required,
)
}
return progress, nil
}
// AdvanceExpert applies accepted post-character-cap experience to one
// HonorExpert state. A single award may cross several levels. Reaching the
// final PVF level discards any excess because the current client exposes no
// progress field for a level above the final table row.
//
// The stored state is REPAIRED here, never rejected. ResolveExpert stays strict
// on purpose -- it is the read-only validator -- but a durable record can hold a
// state that validator refuses: a hand-edited database row, a GM tool, or an
// older build. Advancing from such a record used to return that error, and every
// caller treats it as fatal to its whole transaction:
//
// - the dungeon settlement (dungeon/settlement_owner.go) rolled back, so the
// boss died with no EXP and no reward row and the client sat in the instance
// with no way out until it was force-closed;
// - the monster-kill award (dnfbridge/dungeon_monster_experience.go) failed
// the same way, so the character stopped gaining anything at all.
//
// Operator report 2026-10-03: "熟练度满级、熟练度经验满了之后就无法再通关副本了".
// Repairing here means the next award self-heals the record instead of bricking
// the character.
//
// The repairable shapes, all of which say "the bar is already full":
//
// - progress >= the current level's requirement: an earned level-up that was
// never applied. It is folded in, with the excess carried to the next level,
// so the result is exactly what the missing award would have produced.
// - the final table level holding any progress: the final level exposes no
// progress field, so the excess is discarded exactly as it is for an award
// that reaches the final level.
// - a level beyond the final table level: clamped to the final level.
//
// Reachability: the final level is out of play range on the shipped PVF
// (reaching expert level 289 costs 2,092,040,480,482 cumulative experience), so
// these states come from an edited record -- which is precisely the case that
// must not be able to brick a character.
func (tables *Tables) AdvanceExpert(progress ExpertProgress, gain uint64) (ExpertProgress, error) {
if tables == nil || len(tables.expertExperienceRows) == 0 {
return ExpertProgress{}, ErrTablesRequired
}
maximumLevel := uint32(len(tables.expertExperienceRows))
if progress.Level > maximumLevel {
progress.Level = maximumLevel
progress.CurrentLevelExperience = 0
}
// Settle every level-up the stored bar has already earned, before the new
// award is applied. required is strictly positive for every parsed row
// (positiveUint64Token), and the loop is bounded by maximumLevel regardless,
// so this cannot spin.
for progress.Level < maximumLevel {
required := tables.expertExperienceRows[progress.Level].Experience
if required == 0 || progress.CurrentLevelExperience < required {
break
}
progress.CurrentLevelExperience -= required
progress.Level++
}
if progress.Level >= maximumLevel {
progress.Level = maximumLevel
progress.CurrentLevelExperience = 0
}
for gain > 0 && progress.Level < maximumLevel {
required := tables.expertExperienceRows[progress.Level].Experience
remaining := required - progress.CurrentLevelExperience
if gain < remaining {
progress.CurrentLevelExperience += gain
break
}
gain -= remaining
progress.Level++
progress.CurrentLevelExperience = 0
}
return progress, nil
}
// ThresholdSource 是运行时 PVF 的累计经验阈值读取口(progression.Tables
// 实现它)。PostCapExperienceShare 只需要这一个方法,所以 honor 不必反向
// 依赖 progression 包。
type ThresholdSource interface {
ThresholdToNext(level int) (uint32, error)
}
// PostCapExperienceShare 从一笔角色经验里切出「越过等级上限之后」的那部分。
//
// 满级角色的整笔经验都算(角色经验本身被钉在满级入口阈值上,不会再涨),
// 未满级角色只算越过满级入口门槛的溢出部分。这条口径同时决定熟练等级
// (HonorExpert)、骁勇结晶与成长胶囊槽,所以只在这里定义一次:
// dnfbridge 的怪物击杀/结算、quest 的任务完成都必须走它。
func PostCapExperienceShare(
tables ThresholdSource,
previousLevel int,
previousExperience uint32,
gainedExperience uint32,
maxLevel int,
) (uint32, error) {
if previousLevel <= 0 || maxLevel <= 0 {
return 0, fmt.Errorf("%w: previous_level=%d max_level=%d", ErrCharacterCapInvalid, previousLevel, maxLevel)
}
if gainedExperience == 0 || previousLevel < maxLevel-1 {
return 0, nil
}
if previousLevel >= maxLevel {
return gainedExperience, nil
}
if tables == nil {
return 0, fmt.Errorf("%w: max_level=%d entry experience table is missing", ErrCharacterCapInvalid, maxLevel)
}
entryExperience, err := tables.ThresholdToNext(maxLevel - 1)
if err != nil {
return 0, err
}
return CalculateHonorExperienceGain(
previousLevel,
previousExperience,
gainedExperience,
CharacterExperienceCap{MaxLevel: maxLevel, MaxLevelEntryExperience: entryExperience},
)
}
// CalculateHonorExperienceGain applies only the proved ordinary split at the
// character level cap. It does not persist, calculate character levels, or
// activate expert honor progression.
func CalculateHonorExperienceGain(previousLevel int, previousExperience, gainedExperience uint32, cap CharacterExperienceCap) (uint32, error) {
if previousLevel <= 0 || cap.MaxLevel <= 0 || (cap.MaxLevel > 1 && cap.MaxLevelEntryExperience == 0) {
return 0, fmt.Errorf("%w: previous_level=%d max_level=%d", ErrCharacterCapInvalid, previousLevel, cap.MaxLevel)
}
if gainedExperience == 0 {
return 0, nil
}
if previousLevel >= cap.MaxLevel {
return gainedExperience, nil
}
nextExperience := previousExperience + gainedExperience
if nextExperience < previousExperience {
nextExperience = math.MaxUint32
}
if nextExperience <= cap.MaxLevelEntryExperience {
return 0, nil
}
baseline := previousExperience
if baseline < cap.MaxLevelEntryExperience {
baseline = cap.MaxLevelEntryExperience
}
if nextExperience <= baseline {
return 0, nil
}
return nextExperience - baseline, nil
}
func (tables *Tables) Snapshot() Snapshot {
if tables == nil {
return Snapshot{}
}
return Snapshot{
OrdinaryGrades: len(tables.ordinaryGrades),
OrdinaryLevels: len(tables.requirementByLevel) - 1,
MaxOrdinaryLevel: len(tables.requirementByLevel) - 1,
MaxLevelExperience: tables.maxLevelExperience,
MaxTotalExperience: tables.maxTotalExperience,
ExpertGrades: len(tables.expertGrades),
ExpertExperienceRows: len(tables.expertExperienceRows),
ExpertCalculationReady: true,
}
}
// MaxOrdinaryLevel returns the highest strictly validated ordinary honor level.
func (tables *Tables) MaxOrdinaryLevel() int {
if tables == nil {
return 0
}
return len(tables.requirementByLevel) - 1
}
// MaxLevelExperience returns [maxexp on maxlevel].
func (tables *Tables) MaxLevelExperience() uint64 {
if tables == nil {
return 0
}
return tables.maxLevelExperience
}
// MaxTotalExperience returns all ordinary entry requirements plus the maximum
// current-level experience at the highest ordinary level.
func (tables *Tables) MaxTotalExperience() uint64 {
if tables == nil {
return 0
}
return tables.maxTotalExperience
}
func (tables *Tables) OrdinaryGrades() []OrdinaryGrade {
if tables == nil {
return nil
}
result := make([]OrdinaryGrade, len(tables.ordinaryGrades))
for index, grade := range tables.ordinaryGrades {
result[index] = grade
result[index].Levels = append([]LevelRequirement(nil), grade.Levels...)
}
return result
}
func (tables *Tables) ExpertGrades() []ExpertGradeInfo {
if tables == nil {
return nil
}
return append([]ExpertGradeInfo(nil), tables.expertGrades...)
}
func (tables *Tables) ExpertExperienceRows() []ExpertExperienceRow {
if tables == nil {
return nil
}
return append([]ExpertExperienceRow(nil), tables.expertExperienceRows...)
}
func (tables *Tables) RequiredExperienceToEnter(level int) (uint64, bool) {
if tables == nil || level <= 0 || level >= len(tables.requirementByLevel) {
return 0, false
}
return tables.requirementByLevel[level], true
}
func (tables *Tables) GradeForLevel(level int) (int, bool) {
if tables == nil || level <= 0 || level >= len(tables.gradeByLevel) {
return 0, false
}
return tables.gradeByLevel[level], true
}
func sectionIndices(document *dnfpvf.Document, name string) []int {
want := sectionName(name)
indices := make([]int, 0)
for index, section := range document.Sections {
if sectionName(section.Name) == want {
indices = append(indices, index)
}
}
return indices
}
func sectionName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func tokensForSection(document *dnfpvf.Document, sectionIndex int) ([]dnfpvf.Token, error) {
if document == nil || sectionIndex < 0 || sectionIndex >= len(document.Sections) {
return nil, fmt.Errorf("%w: path=%s section_index=%d", ErrSectionShape, TablePath, sectionIndex)
}
section := document.Sections[sectionIndex]
if section.Start < 0 || section.End < section.Start || section.End > len(document.Tokens) {
return nil, fmt.Errorf("%w: path=%s section=%s start=%d end=%d tokens=%d", ErrSectionShape, TablePath, section.Name, section.Start, section.End, len(document.Tokens))
}
result := make([]dnfpvf.Token, section.End-section.Start)
copy(result, document.Tokens[section.Start:section.End])
return result, nil
}
func requireEmptySection(document *dnfpvf.Document, sectionIndex int) error {
tokens, err := tokensForSection(document, sectionIndex)
if err != nil {
return err
}
if len(tokens) != 0 {
return fmt.Errorf("%w: path=%s section=%s values=%d want=0", ErrSectionShape, TablePath, document.Sections[sectionIndex].Name, len(tokens))
}
return nil
}
func positiveIntToken(token dnfpvf.Token, field string) (int, error) {
value, err := signedIntToken(token, field)
if err != nil {
return 0, err
}
if value <= 0 {
return 0, fmt.Errorf("%w: path=%s field=%s value=%d want_positive", ErrSectionShape, TablePath, field, value)
}
return value, nil
}
func nonnegativeIntToken(token dnfpvf.Token, field string) (int, error) {
value, err := signedIntToken(token, field)
if err != nil {
return 0, err
}
if value < 0 {
return 0, fmt.Errorf("%w: path=%s field=%s value=%d want_nonnegative", ErrSectionShape, TablePath, field, value)
}
return value, nil
}
func signedIntToken(token dnfpvf.Token, field string) (int, error) {
if token.Kind != dnfpvf.TokenInt || token.Int < math.MinInt32 || token.Int > math.MaxInt32 {
return 0, fmt.Errorf("%w: path=%s field=%s kind=%s value=%d", ErrSectionShape, TablePath, field, token.Kind, token.Int)
}
return int(token.Int), nil
}
func nonnegativeIntegerUint64Token(token dnfpvf.Token, field string) (uint64, error) {
if token.Kind != dnfpvf.TokenInt {
return 0, fmt.Errorf("%w: path=%s field=%s kind=%s want=int", ErrSectionShape, TablePath, field, token.Kind)
}
value, err := uint64Token(token, field)
if err != nil {
return 0, err
}
return value, nil
}
func positiveIntegerUint64Token(token dnfpvf.Token, field string) (uint64, error) {
value, err := nonnegativeIntegerUint64Token(token, field)
if err != nil {
return 0, err
}
if value == 0 {
return 0, fmt.Errorf("%w: path=%s field=%s value=0 want_positive", ErrSectionShape, TablePath, field)
}
return value, nil
}
func positiveUint64Token(token dnfpvf.Token, field string) (uint64, error) {
value, err := uint64Token(token, field)
if err != nil {
return 0, err
}
if value == 0 {
return 0, fmt.Errorf("%w: path=%s field=%s value=0 want_positive", ErrSectionShape, TablePath, field)
}
return value, nil
}
func uint64Token(token dnfpvf.Token, field string) (uint64, error) {
switch token.Kind {
case dnfpvf.TokenInt:
if token.Int < 0 {
return 0, fmt.Errorf("%w: path=%s field=%s value=%d want_nonnegative", ErrSectionShape, TablePath, field, token.Int)
}
return uint64(token.Int), nil
case dnfpvf.TokenString:
value, err := strconv.ParseUint(strings.TrimSpace(token.Value), 10, 64)
if err != nil {
return 0, fmt.Errorf("%w: path=%s field=%s value=%q: %v", ErrSectionShape, TablePath, field, token.Value, err)
}
return value, nil
default:
return 0, fmt.Errorf("%w: path=%s field=%s kind=%s", ErrSectionShape, TablePath, field, token.Kind)
}
}
func nonemptyStringToken(token dnfpvf.Token, field string) (string, error) {
if token.Kind != dnfpvf.TokenString || strings.TrimSpace(token.Value) == "" {
return "", fmt.Errorf("%w: path=%s field=%s kind=%s value=%q", ErrSectionShape, TablePath, field, token.Kind, token.Value)
}
return token.Value, nil
}