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,932 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
package honor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestTablesParseRepeatedOrdinaryGradesAndResolveExpertProgress(t *testing.T) {
|
||||
tables, err := LoadTables(honorMemorySource{TablePath: honorTestDocument()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantSnapshot := Snapshot{
|
||||
OrdinaryGrades: 2,
|
||||
OrdinaryLevels: 3,
|
||||
MaxOrdinaryLevel: 3,
|
||||
MaxLevelExperience: 29,
|
||||
MaxTotalExperience: 59,
|
||||
ExpertGrades: 2,
|
||||
ExpertExperienceRows: 3,
|
||||
ExpertCalculationReady: true,
|
||||
}
|
||||
if snapshot := tables.Snapshot(); snapshot != wantSnapshot {
|
||||
t.Fatalf("snapshot=%+v want=%+v", snapshot, wantSnapshot)
|
||||
}
|
||||
if tables.MaxOrdinaryLevel() != 3 || tables.MaxLevelExperience() != 29 || tables.MaxTotalExperience() != 59 {
|
||||
t.Fatalf("ordinary maxima level=%d level_exp=%d total_exp=%d", tables.MaxOrdinaryLevel(), tables.MaxLevelExperience(), tables.MaxTotalExperience())
|
||||
}
|
||||
|
||||
grades := tables.OrdinaryGrades()
|
||||
if len(grades) != 2 || grades[0].Grade != 1 || grades[1].Grade != 2 ||
|
||||
grades[0].EffectPath != "effect/one.ani" ||
|
||||
grades[0].Medal != (ImageReference{Path: "medal.img", Index: 0}) ||
|
||||
grades[1].Icon != (ImageReference{Path: "icon.img", Index: 1}) ||
|
||||
!reflect.DeepEqual(grades[0].Levels, []LevelRequirement{
|
||||
{Level: 1, RequiredExperience: 0},
|
||||
{Level: 2, RequiredExperience: 10},
|
||||
}) ||
|
||||
!reflect.DeepEqual(grades[1].Levels, []LevelRequirement{{Level: 3, RequiredExperience: 20}}) {
|
||||
t.Fatalf("ordinary grades=%+v", grades)
|
||||
}
|
||||
grades[0].Levels[0].RequiredExperience = 999
|
||||
if fresh := tables.OrdinaryGrades(); fresh[0].Levels[0].RequiredExperience != 0 {
|
||||
t.Fatal("ordinary grade accessor leaked mutable level slice")
|
||||
}
|
||||
|
||||
expertGrades := tables.ExpertGrades()
|
||||
if !reflect.DeepEqual(expertGrades, []ExpertGradeInfo{
|
||||
{Grade: 0, Name: "challenger", MinLevel: 0, MaxLevel: 0},
|
||||
{
|
||||
Grade: 1, Name: "veteran",
|
||||
Medal: ImageReference{Path: "expert-medal.img", Index: 2}, HasMedal: true,
|
||||
Icon: ImageReference{Path: "expert-icon.img", Index: 3}, HasIcon: true,
|
||||
MinLevel: 1, MaxLevel: -1,
|
||||
},
|
||||
}) {
|
||||
t.Fatalf("expert grades=%+v", expertGrades)
|
||||
}
|
||||
wantExpertRows := []ExpertExperienceRow{
|
||||
{Level: 1, Experience: 100},
|
||||
{Level: 2, Experience: 204},
|
||||
{Level: 3, Experience: 9999999999},
|
||||
}
|
||||
if rows := tables.ExpertExperienceRows(); !reflect.DeepEqual(rows, wantExpertRows) {
|
||||
t.Fatalf("expert rows=%+v", rows)
|
||||
}
|
||||
if !tables.Snapshot().ExpertCalculationReady {
|
||||
t.Fatal("expert progression calculation is unavailable")
|
||||
}
|
||||
|
||||
for level, wantRequired := range map[int]uint64{1: 0, 2: 10, 3: 20} {
|
||||
if got, found := tables.RequiredExperienceToEnter(level); !found || got != wantRequired {
|
||||
t.Fatalf("required level=%d got=%d,%t want=%d", level, got, found, wantRequired)
|
||||
}
|
||||
}
|
||||
if _, found := tables.RequiredExperienceToEnter(4); found {
|
||||
t.Fatal("out-of-table ordinary requirement fabricated")
|
||||
}
|
||||
for level, wantGrade := range map[int]int{1: 1, 2: 1, 3: 2} {
|
||||
if got, found := tables.GradeForLevel(level); !found || got != wantGrade {
|
||||
t.Fatalf("grade level=%d got=%d,%t want=%d", level, got, found, wantGrade)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablesAdvanceExpertUsesPerLevelThresholds(t *testing.T) {
|
||||
tables, err := LoadTables(honorMemorySource{TablePath: honorTestDocument()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
state ExpertProgress
|
||||
gain uint64
|
||||
want ExpertProgress
|
||||
}{
|
||||
{name: "zero gain", state: ExpertProgress{}, gain: 0, want: ExpertProgress{}},
|
||||
{name: "partial level zero", state: ExpertProgress{}, gain: 99, want: ExpertProgress{CurrentLevelExperience: 99}},
|
||||
{name: "first level", state: ExpertProgress{CurrentLevelExperience: 99}, gain: 1, want: ExpertProgress{Level: 1}},
|
||||
{name: "cross multiple levels", state: ExpertProgress{}, gain: 304, want: ExpertProgress{Level: 2}},
|
||||
{name: "final level caps excess", state: ExpertProgress{Level: 2}, gain: 9999999999 + 17, want: ExpertProgress{Level: 3}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := tables.AdvanceExpert(test.state, test.gain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("AdvanceExpert(%+v, %d) = %+v, want %+v", test.state, test.gain, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, state := range []ExpertProgress{
|
||||
{Level: 4},
|
||||
{CurrentLevelExperience: 100},
|
||||
{Level: 3, CurrentLevelExperience: 1},
|
||||
} {
|
||||
if _, err := tables.ResolveExpert(state); !errors.Is(err, ErrExpertStateInvalid) {
|
||||
t.Fatalf("ResolveExpert(%+v) error = %v", state, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablesAdvanceExpertRepairsAFullBarInsteadOfRefusing pins the 2026-10-03
|
||||
// operator fix.
|
||||
//
|
||||
// ResolveExpert refuses a "full bar" -- progress at or above the current level's
|
||||
// requirement, or any progress at the final level. AdvanceExpert used to hand
|
||||
// that refusal straight back, and both of its callers treat the error as fatal
|
||||
// to their whole transaction, so a record in that shape made a character unable
|
||||
// to finish ANY dungeon: the boss died, the settlement rolled back, and the
|
||||
// client stayed in the instance with no result window until it was force-closed.
|
||||
//
|
||||
// The stored bar is now folded into the level-ups it already earned, so the
|
||||
// result is exactly what the missing award would have produced, and the repaired
|
||||
// state is always one ResolveExpert accepts.
|
||||
func TestTablesAdvanceExpertRepairsAFullBarInsteadOfRefusing(t *testing.T) {
|
||||
tables, err := LoadTables(honorMemorySource{TablePath: honorTestDocument()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Levels 0/1/2 cost 100 / 204 / 9999999999 to leave; level 3 is the last row.
|
||||
tests := []struct {
|
||||
name string
|
||||
state ExpertProgress
|
||||
gain uint64
|
||||
want ExpertProgress
|
||||
}{
|
||||
{
|
||||
name: "full bar at level zero is the level-up it earned",
|
||||
state: ExpertProgress{CurrentLevelExperience: 100},
|
||||
want: ExpertProgress{Level: 1},
|
||||
},
|
||||
{
|
||||
name: "full bar carries its excess into the next level",
|
||||
state: ExpertProgress{CurrentLevelExperience: 150},
|
||||
want: ExpertProgress{Level: 1, CurrentLevelExperience: 50},
|
||||
},
|
||||
{
|
||||
name: "a bar holding several levels is folded in one step",
|
||||
state: ExpertProgress{CurrentLevelExperience: 304},
|
||||
want: ExpertProgress{Level: 2},
|
||||
},
|
||||
{
|
||||
name: "the final level discards progress it cannot show",
|
||||
state: ExpertProgress{Level: 3, CurrentLevelExperience: 1},
|
||||
want: ExpertProgress{Level: 3},
|
||||
},
|
||||
{
|
||||
name: "a level beyond the table is clamped to the final level",
|
||||
state: ExpertProgress{Level: 4},
|
||||
want: ExpertProgress{Level: 3},
|
||||
},
|
||||
{
|
||||
name: "clamping also drops the unshowable progress",
|
||||
state: ExpertProgress{Level: 7, CurrentLevelExperience: 123},
|
||||
want: ExpertProgress{Level: 3},
|
||||
},
|
||||
{
|
||||
name: "a repaired bar still accepts the new award",
|
||||
state: ExpertProgress{CurrentLevelExperience: 100},
|
||||
gain: 100,
|
||||
want: ExpertProgress{Level: 1, CurrentLevelExperience: 100},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := tables.AdvanceExpert(test.state, test.gain)
|
||||
if err != nil {
|
||||
t.Fatalf("AdvanceExpert(%+v, %d) refused a record it must repair: %v", test.state, test.gain, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("AdvanceExpert(%+v, %d) = %+v, want %+v", test.state, test.gain, got, test.want)
|
||||
}
|
||||
if _, err := tables.ResolveExpert(got); err != nil {
|
||||
t.Fatalf("repair produced a state the validator still refuses: %+v: %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablesAdvanceExpertNeverRefusesAnyStoredRecord is the property the
|
||||
// operator fix actually has to hold: no durable (level, progress) pair may be
|
||||
// able to fail an award, because failing an award fails a whole dungeon clear.
|
||||
//
|
||||
// The grid deliberately includes every boundary the old code refused -- the
|
||||
// exact requirement, one past it, several levels' worth, the final level with
|
||||
// progress, and levels past the table -- plus a huge unaligned progress value.
|
||||
func TestTablesAdvanceExpertNeverRefusesAnyStoredRecord(t *testing.T) {
|
||||
tables, err := LoadTables(honorMemorySource{TablePath: honorTestDocument()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := tables.ExpertExperienceRows()
|
||||
finalLevel := uint32(len(rows))
|
||||
|
||||
levels := []uint32{0, 1, 2, 3, 4, 255, math.MaxUint32}
|
||||
progresses := []uint64{
|
||||
0, 1, 99, 100, 101, 204, 205, 304, 9999999999, 9999999999 + 1,
|
||||
math.MaxUint64,
|
||||
}
|
||||
checked := 0
|
||||
for _, level := range levels {
|
||||
for _, progress := range progresses {
|
||||
for _, gain := range []uint64{0, 1, 100, 304, 9999999999 + 17, math.MaxUint64} {
|
||||
state := ExpertProgress{Level: level, CurrentLevelExperience: progress}
|
||||
got, err := tables.AdvanceExpert(state, gain)
|
||||
if err != nil {
|
||||
t.Fatalf("AdvanceExpert(%+v, %d) refused: %v", state, gain, err)
|
||||
}
|
||||
if got.Level > finalLevel {
|
||||
t.Fatalf("AdvanceExpert(%+v, %d) = %+v exceeds the final level %d", state, gain, got, finalLevel)
|
||||
}
|
||||
if _, err := tables.ResolveExpert(got); err != nil {
|
||||
t.Fatalf("AdvanceExpert(%+v, %d) = %+v is still invalid: %v", state, gain, got, err)
|
||||
}
|
||||
checked++
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != len(levels)*len(progresses)*6 {
|
||||
t.Fatalf("checked=%d, want %d", checked, len(levels)*len(progresses)*6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablesResolveOrdinaryCumulativeExperience(t *testing.T) {
|
||||
tables, err := LoadTables(honorMemorySource{TablePath: honorTestDocument()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
total uint64
|
||||
want Progress
|
||||
}{
|
||||
{total: 0, want: Progress{TotalExperience: 0, Level: 1, CurrentLevelExperience: 0, Grade: 1}},
|
||||
{total: 9, want: Progress{TotalExperience: 9, Level: 1, CurrentLevelExperience: 9, Grade: 1}},
|
||||
{total: 10, want: Progress{TotalExperience: 10, Level: 2, CurrentLevelExperience: 0, Grade: 1}},
|
||||
{total: 29, want: Progress{TotalExperience: 29, Level: 2, CurrentLevelExperience: 19, Grade: 1}},
|
||||
{total: 30, want: Progress{TotalExperience: 30, Level: 3, CurrentLevelExperience: 0, Grade: 2}},
|
||||
{total: 59, want: Progress{TotalExperience: 59, Level: 3, CurrentLevelExperience: 29, Grade: 2}},
|
||||
{total: math.MaxUint64, want: Progress{TotalExperience: 59, Level: 3, CurrentLevelExperience: 29, Grade: 2}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, err := tables.Resolve(test.total)
|
||||
if err != nil {
|
||||
t.Fatalf("total=%d: %v", test.total, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("total=%d got=%+v want=%+v", test.total, got, test.want)
|
||||
}
|
||||
}
|
||||
var nilTables *Tables
|
||||
if _, err := nilTables.Resolve(0); !errors.Is(err, ErrTablesRequired) {
|
||||
t.Fatalf("nil resolve error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateHonorExperienceGainUsesCallerCharacterCap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
level int
|
||||
previous uint32
|
||||
gained uint32
|
||||
cap CharacterExperienceCap
|
||||
want uint32
|
||||
}{
|
||||
{
|
||||
name: "already caller max level",
|
||||
level: 70, previous: 123, gained: 1000,
|
||||
cap: CharacterExperienceCap{MaxLevel: 70, MaxLevelEntryExperience: 9000}, want: 1000,
|
||||
},
|
||||
{
|
||||
name: "below caller threshold",
|
||||
level: 69, previous: 800, gained: 100,
|
||||
cap: CharacterExperienceCap{MaxLevel: 70, MaxLevelEntryExperience: 1000}, want: 0,
|
||||
},
|
||||
{
|
||||
name: "exactly reaches caller threshold",
|
||||
level: 69, previous: 900, gained: 100,
|
||||
cap: CharacterExperienceCap{MaxLevel: 70, MaxLevelEntryExperience: 1000}, want: 0,
|
||||
},
|
||||
{
|
||||
name: "only crossing overflow becomes honor",
|
||||
level: 69, previous: 900, gained: 250,
|
||||
cap: CharacterExperienceCap{MaxLevel: 70, MaxLevelEntryExperience: 1000}, want: 150,
|
||||
},
|
||||
{
|
||||
name: "inconsistent pre-cap level above threshold keeps only new delta",
|
||||
level: 69, previous: 1100, gained: 25,
|
||||
cap: CharacterExperienceCap{MaxLevel: 70, MaxLevelEntryExperience: 1000}, want: 25,
|
||||
},
|
||||
{
|
||||
name: "uint32 cumulative experience saturates",
|
||||
level: 69, previous: math.MaxUint32 - 10, gained: 100,
|
||||
cap: CharacterExperienceCap{MaxLevel: 70, MaxLevelEntryExperience: math.MaxUint32 - 20}, want: 10,
|
||||
},
|
||||
{
|
||||
name: "zero gain",
|
||||
level: 89, previous: 900, gained: 0,
|
||||
cap: CharacterExperienceCap{MaxLevel: 90, MaxLevelEntryExperience: 1000}, want: 0,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := CalculateHonorExperienceGain(test.level, test.previous, test.gained, test.cap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("got=%d want=%d", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, request := range []struct {
|
||||
level int
|
||||
cap CharacterExperienceCap
|
||||
}{
|
||||
{level: 0, cap: CharacterExperienceCap{MaxLevel: 90, MaxLevelEntryExperience: 1000}},
|
||||
{level: 89, cap: CharacterExperienceCap{MaxLevel: 0, MaxLevelEntryExperience: 1000}},
|
||||
{level: 89, cap: CharacterExperienceCap{MaxLevel: 90, MaxLevelEntryExperience: 0}},
|
||||
} {
|
||||
if _, err := CalculateHonorExperienceGain(request.level, 0, 1, request.cap); !errors.Is(err, ErrCharacterCapInvalid) {
|
||||
t.Fatalf("request=%+v error=%v", request, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablesRejectMalformedOrPartialPVF(t *testing.T) {
|
||||
if _, err := LoadTables(nil); !errors.Is(err, ErrSourceRequired) {
|
||||
t.Fatalf("nil source error=%v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(string) string
|
||||
}{
|
||||
{
|
||||
name: "missing ordinary grade",
|
||||
mutate: func(text string) string {
|
||||
start := strings.Index(text, "[grade]")
|
||||
end := strings.LastIndex(text, "[/grade]") + len("[/grade]")
|
||||
return text[:start] + text[end:]
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate ordinary grade id",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "[grade]\n2 `effect/two.ani`", "[grade]\n1 `effect/two.ani`", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ordinary level gap",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "3 20", "4 20", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ordinary level one nonzero",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "1 0 2 10", "1 1 2 10", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ordinary positive level zero requirement",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "2 10", "2 0", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ordinary grade missing close",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "[/grade]\n[grade]", "[grade]", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate maxexp section",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "[expert info]", "[maxexp on maxlevel]\n29\n[expert info]", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zero maxexp",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "[maxexp on maxlevel]\n29", "[maxexp on maxlevel]\n0", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing expert info close",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "[/expert info]", "", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate expert grade id",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "[grade info]\n1 `veteran`", "[grade info]\n0 `veteran`", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expert grade missing icon",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "[icon img]\n`expert-icon.img` 3\n", "", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expert grade invalid range",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "[max lv]\n-1", "[max lv]\n0", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expert exp level gap",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "2 `204`", "3 `204`", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expert exp nonnumeric",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "2 `204`", "2 `unknown`", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expert exp missing close",
|
||||
mutate: func(text string) string {
|
||||
return strings.Replace(text, "[/honor expert exp table]", "", 1)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := LoadTables(honorMemorySource{TablePath: test.mutate(honorTestDocument())})
|
||||
if !errors.Is(err, ErrSectionShape) && !errors.Is(err, ErrSectionMissing) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablesRejectOrdinaryTotalOverflow(t *testing.T) {
|
||||
text := honorTestDocument()
|
||||
text = strings.Replace(text, "1 0 2 10", fmt.Sprintf("1 0 2 %d", int64(math.MaxInt64)), 1)
|
||||
text = strings.Replace(text, "3 20", fmt.Sprintf("3 %d", int64(math.MaxInt64)), 1)
|
||||
text = strings.Replace(text, "[maxexp on maxlevel]\n29", fmt.Sprintf("[maxexp on maxlevel]\n%d", int64(math.MaxInt64)), 1)
|
||||
if _, err := LoadTables(honorMemorySource{TablePath: text}); !errors.Is(err, ErrSectionShape) {
|
||||
t.Fatalf("overflow error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealScriptPVFHonorTablesPreserveOrdinaryAndExpertSources(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNF_HONOR_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
pvfPath = os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
}
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNF_HONOR_REAL_PVF_SMOKE to verify real honor PVF tables")
|
||||
}
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tables, err := LoadTables(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantSnapshot := Snapshot{
|
||||
OrdinaryGrades: 6,
|
||||
OrdinaryLevels: 59,
|
||||
MaxOrdinaryLevel: 59,
|
||||
MaxLevelExperience: 589999999,
|
||||
MaxTotalExperience: 17699999999,
|
||||
ExpertGrades: 7,
|
||||
ExpertExperienceRows: 289,
|
||||
ExpertCalculationReady: true,
|
||||
}
|
||||
if snapshot := tables.Snapshot(); snapshot != wantSnapshot {
|
||||
t.Fatalf("real snapshot=%+v want=%+v", snapshot, wantSnapshot)
|
||||
}
|
||||
progress, err := tables.Resolve(35000000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if progress != (Progress{TotalExperience: 35000000, Level: 3, CurrentLevelExperience: 5000000, Grade: 1}) {
|
||||
t.Fatalf("real progress=%+v", progress)
|
||||
}
|
||||
capped, err := tables.Resolve(math.MaxUint64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if capped != (Progress{TotalExperience: 17699999999, Level: 59, CurrentLevelExperience: 589999999, Grade: 6}) {
|
||||
t.Fatalf("real capped progress=%+v", capped)
|
||||
}
|
||||
rows := tables.ExpertExperienceRows()
|
||||
for level, want := range map[int]uint64{
|
||||
1: 1000000,
|
||||
2: 2040000,
|
||||
100: 48562450,
|
||||
200: 2452644029,
|
||||
289: 80464018485,
|
||||
} {
|
||||
if got := rows[level-1]; got != (ExpertExperienceRow{Level: level, Experience: want}) {
|
||||
t.Fatalf("real expert row level=%d got=%+v want=%d", level, got, want)
|
||||
}
|
||||
}
|
||||
expertGrades := tables.ExpertGrades()
|
||||
if expertGrades[0].HasMedal || expertGrades[0].HasIcon || expertGrades[0].MinLevel != 0 || expertGrades[0].MaxLevel != 0 {
|
||||
t.Fatalf("real expert grade zero=%+v", expertGrades[0])
|
||||
}
|
||||
if !expertGrades[6].HasMedal || !expertGrades[6].HasIcon || expertGrades[6].MinLevel != 200 || expertGrades[6].MaxLevel != -1 {
|
||||
t.Fatalf("real expert grade six=%+v", expertGrades[6])
|
||||
}
|
||||
}
|
||||
|
||||
type honorMemorySource map[string]string
|
||||
|
||||
func (source honorMemorySource) ReadText(path string) (string, error) {
|
||||
text, found := source[path]
|
||||
if !found {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func honorTestDocument() string {
|
||||
return `[grade]
|
||||
1 ` + "`effect/one.ani` `medal.img` 0 `icon.img` 0" + ` 1 0 2 10
|
||||
[/grade]
|
||||
[grade]
|
||||
2 ` + "`effect/two.ani` `medal.img` 2 `icon.img` 1" + ` 3 20
|
||||
[/grade]
|
||||
[maxexp on maxlevel]
|
||||
29
|
||||
[expert info]
|
||||
[grade info]
|
||||
0 ` + "`challenger`" + `
|
||||
[min lv]
|
||||
0
|
||||
[max lv]
|
||||
0
|
||||
[/grade info]
|
||||
[grade info]
|
||||
1 ` + "`veteran`" + `
|
||||
[medal img]
|
||||
` + "`expert-medal.img`" + ` 2
|
||||
[icon img]
|
||||
` + "`expert-icon.img`" + ` 3
|
||||
[min lv]
|
||||
1
|
||||
[max lv]
|
||||
-1
|
||||
[/grade info]
|
||||
[/expert info]
|
||||
[honor expert exp table]
|
||||
1 ` + "`100`" + ` 2 ` + "`204`" + ` 3 ` + "`9999999999`" + `
|
||||
[/honor expert exp table]
|
||||
`
|
||||
}
|
||||
Reference in new issue
Block a user