DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,357 @@
package progression
import (
"context"
"errors"
"fmt"
"math"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const characterExperienceStatKey = "exp"
var (
ErrCommitPlanInvalid = errors.New("dnf progression commit plan is invalid")
ErrCommitOwnershipMismatch = errors.New("dnf progression commit ownership mismatch")
ErrCommitStateMissing = errors.New("dnf progression commit state is missing")
ErrCommitStateInvalid = errors.New("dnf progression persisted state is invalid")
ErrCommitVersionPrecondition = errors.New("dnf progression commit version precondition failed")
)
// CommitState is the complete optimistic version used by one progression
// commit. Expected is compared before any write; Next is the caller-computed
// state to persist. The repository layer does not calculate EXP, levels, SP,
// or TP.
type CommitState struct {
Level int
Experience uint32
SkillPoints dnfrepo.SkillPointState
}
// CommitPlan binds a caller-computed Expected -> Next transition to one
// account-owned character. Expected is the version precondition; it avoids a
// lost update without coupling progression to unrelated character fields.
type CommitPlan struct {
CharacterID string
AccountID string
Expected CommitState
Next CommitState
// Allows a saturated same-level legacy overflow to converge back to the
// current PVF max-level entry threshold. Ordinary EXP regressions remain
// invalid.
AllowExperienceCapNormalization bool
// ExpectedCharacterStats protects state that shares the character stats
// record with EXP but has its own domain progression, such as HonorExpert.
ExpectedCharacterStats map[string]int64
NextCharacterStats map[string]int64
}
// CommitPlanFromExperienceSkillPoints maps the existing pure planner result
// into the atomic repository plan without recalculating any rule.
func CommitPlanFromExperienceSkillPoints(
characterID string,
accountID string,
planned ExperienceSkillPointPlan,
) CommitPlan {
return CommitPlan{
CharacterID: characterID,
AccountID: accountID,
Expected: CommitState{
Level: planned.Experience.PreviousLevel,
Experience: planned.Experience.PreviousExperience,
SkillPoints: planned.SkillPoints.Previous,
},
Next: CommitState{
Level: planned.Experience.NewLevel,
Experience: planned.Experience.NewExperience,
SkillPoints: planned.SkillPoints.New,
},
AllowExperienceCapNormalization: planned.Experience.Saturated &&
planned.Experience.NewLevel == planned.Experience.PreviousLevel &&
planned.Experience.NewExperience < planned.Experience.PreviousExperience,
}
}
// CommitResult reports the exact state observed in the transaction. A retry
// that observes Next is successful and Idempotent without issuing a write. If
// a later progression state is present, the Expected precondition fails.
type CommitResult struct {
Previous CommitState
Current CommitState
Applied bool
Idempotent bool
}
// Commit 在角色-技能事务里应用一个 CommitPlan(规则见 ApplyCommitPlan)。
func Commit(
ctx context.Context,
uow dnfrepo.CharacterProgressionUnitOfWork,
plan CommitPlan,
) (CommitResult, error) {
plan.CharacterID = strings.TrimSpace(plan.CharacterID)
plan.AccountID = strings.TrimSpace(plan.AccountID)
if uow == nil {
return CommitResult{}, dnfrepo.ErrCharacterProgressionTransactionUnavailable
}
// 调用方的计划与上下文必须在**开事务之前**判定:无效计划不得打开事务。
// 这条契约由 atomic_commit_test.go 钉着;ApplyCommitPlan 内部会再校验一次。
if err := validateCommitPlan(plan); err != nil {
return CommitResult{}, err
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return CommitResult{}, err
}
var result CommitResult
err := uow.WithinCharacterProgression(
ctx,
plan.CharacterID,
func(characters dnfrepo.CharacterRepository, skills dnfrepo.SkillRepository) error {
applied, applyErr := ApplyCommitPlan(ctx, characters, skills, plan)
result = applied
return applyErr
},
)
if err != nil {
return CommitResult{}, err
}
return result, nil
}
// ApplyCommitPlan 在**调用方已经打开的事务**里应用同一个 CommitPlan:前置校验
// (expected → next)、幂等判定与字段写入全部照旧,只是不再由本函数自己开
// 角色-技能事务。
//
// 需要把「角色经验」和「账号级冒险团状态(骁勇结晶 / 成长胶囊槽 / 荣誉经验)」
// 放进同一个提交的调用方用它 —— 例如怪物击杀经验:同一笔经验同时喂熟练等级和
// 结晶,拆成两次写就会出现"经验涨了、结晶没涨"的静默状态。
func ApplyCommitPlan(
ctx context.Context,
characters dnfrepo.CharacterRepository,
skills dnfrepo.SkillRepository,
plan CommitPlan,
) (CommitResult, error) {
plan.CharacterID = strings.TrimSpace(plan.CharacterID)
plan.AccountID = strings.TrimSpace(plan.AccountID)
if characters == nil || skills == nil {
return CommitResult{}, dnfrepo.ErrCharacterProgressionTransactionUnavailable
}
if err := validateCommitPlan(plan); err != nil {
return CommitResult{}, err
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return CommitResult{}, err
}
character, characterExists, err := characters.Load(ctx, plan.CharacterID)
if err != nil {
return CommitResult{}, err
}
if !characterExists {
return CommitResult{}, fmt.Errorf("%w: character=%s", ErrCommitStateMissing, plan.CharacterID)
}
if strings.TrimSpace(character.CharacterID) != plan.CharacterID || strings.TrimSpace(character.AccountID) != plan.AccountID {
return CommitResult{}, fmt.Errorf(
"%w: character=%s expected_account=%s actual_character=%s actual_account=%s",
ErrCommitOwnershipMismatch,
plan.CharacterID,
plan.AccountID,
character.CharacterID,
character.AccountID,
)
}
skill, skillExists, err := skills.Load(ctx, plan.CharacterID)
if err != nil {
return CommitResult{}, err
}
if !skillExists {
return CommitResult{}, fmt.Errorf("%w: character=%s", ErrCommitStateMissing, plan.CharacterID)
}
if strings.TrimSpace(skill.CharacterID) != plan.CharacterID {
return CommitResult{}, fmt.Errorf(
"%w: character=%s skill_character=%s",
ErrCommitOwnershipMismatch,
plan.CharacterID,
skill.CharacterID,
)
}
current, err := persistedCommitState(character, skill)
if err != nil {
return CommitResult{}, err
}
result := CommitResult{Previous: current, Current: current}
if commitStatesEqual(current, plan.Next) && characterStatsMatch(character.Stats, plan.NextCharacterStats) {
result.Idempotent = true
return result, nil
}
if commitStatesEqual(current, plan.Next) && !commitStatesEqual(current, plan.Expected) {
return CommitResult{}, fmt.Errorf(
"%w: character=%s progression reached next state without matching protected stats",
ErrCommitStateInvalid,
plan.CharacterID,
)
}
if !commitStatesEqual(current, plan.Expected) {
return CommitResult{}, fmt.Errorf(
"%w: character=%s expected=%+v actual=%+v next=%+v",
ErrCommitVersionPrecondition,
plan.CharacterID,
plan.Expected,
current,
plan.Next,
)
}
if !characterStatsMatch(character.Stats, plan.ExpectedCharacterStats) {
return CommitResult{}, fmt.Errorf(
"%w: character=%s expected character stats do not match",
ErrCommitVersionPrecondition,
plan.CharacterID,
)
}
character.Level = plan.Next.Level
character.Stats[characterExperienceStatKey] = int64(plan.Next.Experience)
for key, value := range plan.NextCharacterStats {
character.Stats[key] = value
}
skill.Points = plan.Next.SkillPoints
if err := dnfrepo.SaveCharacterFields(
ctx,
characters,
character,
dnfrepo.CharacterFieldBase,
dnfrepo.CharacterFieldStats,
); err != nil {
return CommitResult{}, err
}
if err := dnfrepo.SaveSkillFields(ctx, skills, skill, dnfrepo.SkillFieldPoints); err != nil {
return CommitResult{}, err
}
result.Current = plan.Next
result.Applied = true
return result, nil
}
func validateCommitPlan(plan CommitPlan) error {
if plan.CharacterID == "" || plan.AccountID == "" {
return fmt.Errorf("%w: character/account ownership is required", ErrCommitPlanInvalid)
}
if err := validateCommitState(plan.Expected); err != nil {
return fmt.Errorf("%w: expected: %v", ErrCommitPlanInvalid, err)
}
if err := validateCommitState(plan.Next); err != nil {
return fmt.Errorf("%w: next: %v", ErrCommitPlanInvalid, err)
}
if plan.Next.Level < plan.Expected.Level {
return fmt.Errorf("%w: level regressed %d -> %d", ErrCommitPlanInvalid, plan.Expected.Level, plan.Next.Level)
}
if plan.Next.Experience < plan.Expected.Experience &&
(!plan.AllowExperienceCapNormalization || plan.Next.Level != plan.Expected.Level) {
return fmt.Errorf("%w: cumulative experience regressed %d -> %d", ErrCommitPlanInvalid, plan.Expected.Experience, plan.Next.Experience)
}
if plan.Next.SkillPoints.TotalSP < plan.Expected.SkillPoints.TotalSP ||
plan.Next.SkillPoints.TotalTP < plan.Expected.SkillPoints.TotalTP {
return fmt.Errorf("%w: total SP/TP regressed expected=%+v next=%+v", ErrCommitPlanInvalid, plan.Expected.SkillPoints, plan.Next.SkillPoints)
}
if err := validateCharacterStatMap(plan.ExpectedCharacterStats); err != nil {
return err
}
if err := validateCharacterStatMap(plan.NextCharacterStats); err != nil {
return err
}
for key := range plan.ExpectedCharacterStats {
if _, found := plan.NextCharacterStats[key]; !found {
return fmt.Errorf("%w: expected character stat %q has no next value", ErrCommitPlanInvalid, key)
}
}
expectedSpentSP := plan.Expected.SkillPoints.TotalSP - plan.Expected.SkillPoints.RemainingSP
nextSpentSP := plan.Next.SkillPoints.TotalSP - plan.Next.SkillPoints.RemainingSP
expectedSpentTP := plan.Expected.SkillPoints.TotalTP - plan.Expected.SkillPoints.RemainingTP
nextSpentTP := plan.Next.SkillPoints.TotalTP - plan.Next.SkillPoints.RemainingTP
if expectedSpentSP != nextSpentSP || expectedSpentTP != nextSpentTP {
return fmt.Errorf(
"%w: progression changed spent points SP=%d->%d TP=%d->%d",
ErrCommitPlanInvalid,
expectedSpentSP,
nextSpentSP,
expectedSpentTP,
nextSpentTP,
)
}
return nil
}
func validateCharacterStatMap(values map[string]int64) error {
for key := range values {
if strings.TrimSpace(key) == "" || key == characterExperienceStatKey {
return fmt.Errorf("%w: invalid character stat key %q", ErrCommitPlanInvalid, key)
}
}
return nil
}
func characterStatsMatch(current map[string]int64, expected map[string]int64) bool {
for key, value := range expected {
if current[key] != value {
return false
}
}
return true
}
func validateCommitState(state CommitState) error {
if state.Level <= 0 || state.Level > MaximumCharacterLevel {
return fmt.Errorf("level=%d outside current DNF90 range 1..%d", state.Level, MaximumCharacterLevel)
}
points := state.SkillPoints
if points.SyncedLevel != state.Level {
return fmt.Errorf("level=%d synced_level=%d", state.Level, points.SyncedLevel)
}
if points.TotalSP < 0 || points.TotalSP > math.MaxUint16 ||
points.RemainingSP < 0 || points.RemainingSP > points.TotalSP ||
points.TotalTP < 0 || points.TotalTP > math.MaxUint16 ||
points.RemainingTP < 0 || points.RemainingTP > points.TotalTP {
return fmt.Errorf("SP/TP ledger outside current EXE u16 range: %+v", points)
}
return nil
}
func persistedCommitState(character dnfrepo.CharacterRecord, skill dnfrepo.SkillRecord) (CommitState, error) {
experience, ok := character.Stats[characterExperienceStatKey]
if !ok {
return CommitState{}, fmt.Errorf("%w: character=%s stat=%s", ErrCommitStateMissing, character.CharacterID, characterExperienceStatKey)
}
if experience < 0 || uint64(experience) > math.MaxUint32 {
return CommitState{}, fmt.Errorf(
"%w: character=%s experience=%d outside current EXE u32 range",
ErrCommitStateInvalid,
character.CharacterID,
experience,
)
}
state := CommitState{
Level: character.Level,
Experience: uint32(experience),
SkillPoints: skill.Points,
}
if err := validateCommitState(state); err != nil {
return CommitState{}, fmt.Errorf("%w: character=%s: %v", ErrCommitStateInvalid, character.CharacterID, err)
}
return state, nil
}
func commitStatesEqual(left, right CommitState) bool {
return left.Level == right.Level &&
left.Experience == right.Experience &&
left.SkillPoints == right.SkillPoints
}
@@ -0,0 +1,464 @@
package progression
import (
"context"
"errors"
"math"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestCommitAtomicallyPersistsPrecomputedExperienceLevelSPAndTP(t *testing.T) {
ctx := context.Background()
repos := seedProgressionCommitRepositories(t, ctx)
plan := validProgressionCommitPlan()
result, err := Commit(ctx, repos.CharacterProgression, plan)
if err != nil {
t.Fatalf("Commit() error = %v", err)
}
if !result.Applied || result.Idempotent || result.Previous != plan.Expected || result.Current != plan.Next {
t.Fatalf("Commit() result = %+v", result)
}
character, _, _ := repos.Character.Load(ctx, plan.CharacterID)
skill, _, _ := repos.Skill.Load(ctx, plan.CharacterID)
if character.Level != plan.Next.Level || character.Stats["exp"] != int64(plan.Next.Experience) {
t.Fatalf("character progression = level %d exp %d", character.Level, character.Stats["exp"])
}
if skill.Points != plan.Next.SkillPoints {
t.Fatalf("skill points = %+v, want %+v", skill.Points, plan.Next.SkillPoints)
}
if len(skill.Skills) != 1 || skill.Skills[46].Level != 2 {
t.Fatalf("non-point skill state changed: %+v", skill.Skills)
}
}
func TestCommitPlanFromExperienceSkillPointsOnlyMapsCallerPlan(t *testing.T) {
planned := ExperienceSkillPointPlan{
Experience: ExperienceResult{
PreviousLevel: 2,
PreviousExperience: 240,
NewLevel: 4,
NewExperience: 540,
},
SkillPoints: SkillPointAdvance{
Previous: dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 75, TotalTP: 3, RemainingTP: 2, SyncedLevel: 2},
New: dnfrepo.SkillPointState{TotalSP: 170, RemainingSP: 145, TotalTP: 3, RemainingTP: 2, SyncedLevel: 4},
},
}
got := CommitPlanFromExperienceSkillPoints("77", "dnf:1", planned)
if got.CharacterID != "77" || got.AccountID != "dnf:1" ||
got.Expected.Level != 2 || got.Expected.Experience != 240 || got.Expected.SkillPoints != planned.SkillPoints.Previous ||
got.Next.Level != 4 || got.Next.Experience != 540 || got.Next.SkillPoints != planned.SkillPoints.New {
t.Fatalf("mapped commit plan = %+v", got)
}
}
func TestCommitPlanAllowsOnlyExplicitSameLevelCapNormalization(t *testing.T) {
planned := ExperienceSkillPointPlan{
Experience: ExperienceResult{
PreviousLevel: 5, PreviousExperience: 1000,
NewLevel: 5, NewExperience: 900, Saturated: true,
},
SkillPoints: SkillPointAdvance{
Previous: dnfrepo.SkillPointState{SyncedLevel: 5},
New: dnfrepo.SkillPointState{SyncedLevel: 5},
},
}
plan := CommitPlanFromExperienceSkillPoints("1", "account-1", planned)
if !plan.AllowExperienceCapNormalization {
t.Fatal("cap normalization flag was not derived from saturated planner result")
}
if err := validateCommitPlan(plan); err != nil {
t.Fatalf("explicit cap normalization rejected: %v", err)
}
plan.AllowExperienceCapNormalization = false
if err := validateCommitPlan(plan); !errors.Is(err, ErrCommitPlanInvalid) {
t.Fatalf("ordinary regression error=%v, want ErrCommitPlanInvalid", err)
}
}
func TestCommitAppliesCharacterStatOnlyProgressionAtExperienceCap(t *testing.T) {
ctx := context.Background()
repos := seedProgressionCommitRepositories(t, ctx)
character, found, err := repos.Character.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load character found=%t err=%v", found, err)
}
character.Stats["honor_expert_level"] = 1
character.Stats["honor_expert_progress_experience"] = 100
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
plan := validProgressionCommitPlan()
plan.Next = plan.Expected
plan.ExpectedCharacterStats = map[string]int64{
"honor_expert_level": 1,
"honor_expert_progress_experience": 100,
}
plan.NextCharacterStats = map[string]int64{
"honor_expert_level": 1,
"honor_expert_progress_experience": 120,
}
result, err := Commit(ctx, repos.CharacterProgression, plan)
if err != nil {
t.Fatal(err)
}
if !result.Applied || result.Idempotent {
t.Fatalf("first stat-only result=%+v", result)
}
result, err = Commit(ctx, repos.CharacterProgression, plan)
if err != nil || !result.Idempotent || result.Applied {
t.Fatalf("stat-only replay result=%+v err=%v", result, err)
}
}
func TestCommitExactReplayIsIdempotentWithoutWrites(t *testing.T) {
ctx := context.Background()
repos := seedProgressionCommitRepositories(t, ctx)
plan := validProgressionCommitPlan()
if _, err := Commit(ctx, repos.CharacterProgression, plan); err != nil {
t.Fatalf("first Commit() error = %v", err)
}
counted := &countingProgressionUnitOfWork{
characters: repos.Character,
skills: repos.Skill,
}
result, err := Commit(ctx, counted, plan)
if err != nil {
t.Fatalf("replay Commit() error = %v", err)
}
if result.Applied || !result.Idempotent || result.Previous != plan.Next || result.Current != plan.Next {
t.Fatalf("replay result = %+v", result)
}
if counted.characterSaves != 0 || counted.skillSaves != 0 {
t.Fatalf("idempotent replay writes character=%d skill=%d", counted.characterSaves, counted.skillSaves)
}
}
func TestCommitAtomicallyTransitionsProtectedCharacterStats(t *testing.T) {
ctx := context.Background()
repos := seedProgressionCommitRepositories(t, ctx)
plan := validProgressionCommitPlan()
plan.ExpectedCharacterStats = map[string]int64{"honor_expert_level": 0, "honor_expert_progress_experience": 0}
plan.NextCharacterStats = map[string]int64{"honor_expert_level": 1, "honor_expert_progress_experience": 42}
if _, err := Commit(ctx, repos.CharacterProgression, plan); err != nil {
t.Fatal(err)
}
character, _, _ := repos.Character.Load(ctx, plan.CharacterID)
if character.Stats["honor_expert_level"] != 1 || character.Stats["honor_expert_progress_experience"] != 42 {
t.Fatalf("expert character stats=%+v", character.Stats)
}
if replay, err := Commit(ctx, repos.CharacterProgression, plan); err != nil || !replay.Idempotent {
t.Fatalf("replay=%+v err=%v", replay, err)
}
stale := validProgressionCommitPlan()
stale.ExpectedCharacterStats = map[string]int64{"honor_expert_level": 0}
stale.NextCharacterStats = map[string]int64{"honor_expert_level": 2}
if _, err := Commit(ctx, repos.CharacterProgression, stale); !errors.Is(err, ErrCommitStateInvalid) {
t.Fatalf("stale protected stats error=%v", err)
}
}
func TestCommitRejectsStaleOrPartiallyCommittedVersion(t *testing.T) {
ctx := context.Background()
for _, test := range []struct {
name string
mutateSeed func(*dnfrepo.CharacterRecord, *dnfrepo.SkillRecord)
want error
}{
{
name: "later progression",
mutateSeed: func(character *dnfrepo.CharacterRecord, skill *dnfrepo.SkillRecord) {
character.Level = 5
character.Stats["exp"] = 800
skill.Points = dnfrepo.SkillPointState{TotalSP: 200, RemainingSP: 175, TotalTP: 8, RemainingTP: 7, SyncedLevel: 5}
},
want: ErrCommitVersionPrecondition,
},
{
name: "character-only partial state",
mutateSeed: func(character *dnfrepo.CharacterRecord, _ *dnfrepo.SkillRecord) {
character.Level = 4
character.Stats["exp"] = 540
},
want: ErrCommitStateInvalid,
},
{
name: "skill-only partial state",
mutateSeed: func(_ *dnfrepo.CharacterRecord, skill *dnfrepo.SkillRecord) {
skill.Points = validProgressionCommitPlan().Next.SkillPoints
},
want: ErrCommitStateInvalid,
},
} {
t.Run(test.name, func(t *testing.T) {
repos := seedProgressionCommitRepositories(t, ctx)
character, _, _ := repos.Character.Load(ctx, "77")
skill, _, _ := repos.Skill.Load(ctx, "77")
test.mutateSeed(&character, &skill)
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatalf("mutate character: %v", err)
}
if err := repos.Skill.Save(ctx, skill); err != nil {
t.Fatalf("mutate skill: %v", err)
}
beforeCharacter := dnfrepo.CloneCharacter(character)
beforeSkill := dnfrepo.CloneSkill(skill)
_, err := Commit(ctx, repos.CharacterProgression, validProgressionCommitPlan())
if !errors.Is(err, test.want) {
t.Fatalf("Commit() error = %v, want %v", err, test.want)
}
afterCharacter, _, _ := repos.Character.Load(ctx, "77")
afterSkill, _, _ := repos.Skill.Load(ctx, "77")
if afterCharacter.Level != beforeCharacter.Level || afterCharacter.Stats["exp"] != beforeCharacter.Stats["exp"] || afterSkill.Points != beforeSkill.Points {
t.Fatalf("stale plan mutated state character=%+v skill=%+v", afterCharacter, afterSkill)
}
})
}
}
func TestCommitRequiresExactAccountAndBothOwnedRows(t *testing.T) {
ctx := context.Background()
for _, test := range []struct {
name string
prepare func(t *testing.T, repos dnfrepo.Group)
plan func() CommitPlan
want error
}{
{
name: "account mismatch",
prepare: func(*testing.T, dnfrepo.Group) {},
plan: func() CommitPlan {
plan := validProgressionCommitPlan()
plan.AccountID = "dnf:other"
return plan
},
want: ErrCommitOwnershipMismatch,
},
{
name: "skill row missing",
prepare: func(t *testing.T, repos dnfrepo.Group) {
deleter, ok := repos.Skill.(interface {
Delete(context.Context, string) error
})
if !ok {
t.Fatal("memory skill store does not support Delete")
}
if err := deleter.Delete(ctx, "77"); err != nil {
t.Fatalf("delete skill: %v", err)
}
},
plan: validProgressionCommitPlan,
want: ErrCommitStateMissing,
},
{
name: "exp state missing",
prepare: func(t *testing.T, repos dnfrepo.Group) {
character, _, _ := repos.Character.Load(ctx, "77")
delete(character.Stats, "exp")
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatalf("remove exp: %v", err)
}
},
plan: validProgressionCommitPlan,
want: ErrCommitStateMissing,
},
} {
t.Run(test.name, func(t *testing.T) {
repos := seedProgressionCommitRepositories(t, ctx)
test.prepare(t, repos)
_, err := Commit(ctx, repos.CharacterProgression, test.plan())
if !errors.Is(err, test.want) {
t.Fatalf("Commit() error = %v, want %v", err, test.want)
}
})
}
}
func TestCommitRejectsInvalidCallerPlansBeforeTransaction(t *testing.T) {
valid := validProgressionCommitPlan()
for _, test := range []struct {
name string
mutate func(*CommitPlan)
}{
{name: "missing owner", mutate: func(plan *CommitPlan) { plan.AccountID = "" }},
{name: "zero level", mutate: func(plan *CommitPlan) { plan.Expected.Level = 0; plan.Expected.SkillPoints.SyncedLevel = 0 }},
{name: "level overflow", mutate: func(plan *CommitPlan) {
plan.Next.Level = math.MaxUint8 + 1
plan.Next.SkillPoints.SyncedLevel = math.MaxUint8 + 1
}},
{name: "above DNF90 ceiling", mutate: func(plan *CommitPlan) {
plan.Next.Level = MaximumCharacterLevel + 1
plan.Next.SkillPoints.SyncedLevel = MaximumCharacterLevel + 1
}},
{name: "synced level mismatch", mutate: func(plan *CommitPlan) { plan.Next.SkillPoints.SyncedLevel-- }},
{name: "negative points", mutate: func(plan *CommitPlan) { plan.Next.SkillPoints.RemainingSP = -1 }},
{name: "point overflow", mutate: func(plan *CommitPlan) {
plan.Next.SkillPoints.TotalTP = math.MaxUint16 + 1
plan.Next.SkillPoints.RemainingTP = math.MaxUint16 + 1
}},
{name: "remaining exceeds total", mutate: func(plan *CommitPlan) { plan.Next.SkillPoints.RemainingSP = plan.Next.SkillPoints.TotalSP + 1 }},
{name: "level regression", mutate: func(plan *CommitPlan) { plan.Next.Level = 1; plan.Next.SkillPoints.SyncedLevel = 1 }},
{name: "experience regression", mutate: func(plan *CommitPlan) { plan.Next.Experience = plan.Expected.Experience - 1 }},
{name: "total SP regression", mutate: func(plan *CommitPlan) {
plan.Next.SkillPoints.TotalSP = plan.Expected.SkillPoints.TotalSP - 1
plan.Next.SkillPoints.RemainingSP = plan.Expected.SkillPoints.RemainingSP - 1
}},
{name: "spent SP changed", mutate: func(plan *CommitPlan) { plan.Next.SkillPoints.RemainingSP-- }},
{name: "spent TP changed", mutate: func(plan *CommitPlan) { plan.Next.SkillPoints.RemainingTP-- }},
} {
t.Run(test.name, func(t *testing.T) {
plan := valid
test.mutate(&plan)
counted := &countingProgressionUnitOfWork{}
_, err := Commit(context.Background(), counted, plan)
if !errors.Is(err, ErrCommitPlanInvalid) {
t.Fatalf("Commit() error = %v, want ErrCommitPlanInvalid", err)
}
if counted.calls != 0 {
t.Fatalf("invalid plan entered transaction %d times", counted.calls)
}
})
}
}
func TestCommitRejectsPersistedNegativeOrOverflowExperience(t *testing.T) {
ctx := context.Background()
for _, experience := range []int64{-1, int64(math.MaxUint32) + 1} {
t.Run("invalid persisted exp", func(t *testing.T) {
repos := seedProgressionCommitRepositories(t, ctx)
character, _, _ := repos.Character.Load(ctx, "77")
character.Stats["exp"] = experience
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatalf("seed invalid exp: %v", err)
}
_, err := Commit(ctx, repos.CharacterProgression, validProgressionCommitPlan())
if !errors.Is(err, ErrCommitStateInvalid) {
t.Fatalf("Commit() error = %v, want ErrCommitStateInvalid", err)
}
})
}
}
func TestCommitRejectsMissingUnitOfWorkAndCancelledContext(t *testing.T) {
if _, err := Commit(context.Background(), nil, validProgressionCommitPlan()); !errors.Is(err, dnfrepo.ErrCharacterProgressionTransactionUnavailable) {
t.Fatalf("nil UoW error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
counted := &countingProgressionUnitOfWork{}
if _, err := Commit(ctx, counted, validProgressionCommitPlan()); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled Commit() error = %v", err)
}
if counted.calls != 0 {
t.Fatalf("cancelled commit entered transaction %d times", counted.calls)
}
}
func seedProgressionCommitRepositories(t *testing.T, ctx context.Context) dnfrepo.Group {
t.Helper()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "dnf:1",
Name: "owner",
Level: 2,
Stats: map[string]int64{"exp": 240, "gold": 50},
}); err != nil {
t.Fatalf("seed character: %v", err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{
CharacterID: "77",
Skills: map[int64]dnfrepo.SkillState{46: {Level: 2, Enabled: true}},
Points: validProgressionCommitPlan().Expected.SkillPoints,
}); err != nil {
t.Fatalf("seed skill: %v", err)
}
return repos
}
func validProgressionCommitPlan() CommitPlan {
return CommitPlan{
CharacterID: "77",
AccountID: "dnf:1",
Expected: CommitState{
Level: 2,
Experience: 240,
SkillPoints: dnfrepo.SkillPointState{
TotalSP: 100, RemainingSP: 75,
TotalTP: 3, RemainingTP: 2,
SyncedLevel: 2,
},
},
Next: CommitState{
Level: 4,
Experience: 540,
SkillPoints: dnfrepo.SkillPointState{
TotalSP: 170, RemainingSP: 145,
TotalTP: 5, RemainingTP: 4,
SyncedLevel: 4,
},
},
}
}
type countingProgressionUnitOfWork struct {
characters dnfrepo.CharacterRepository
skills dnfrepo.SkillRepository
calls int
characterSaves int
skillSaves int
}
func (u *countingProgressionUnitOfWork) WithinCharacterProgression(
ctx context.Context,
_ string,
apply func(dnfrepo.CharacterRepository, dnfrepo.SkillRepository) error,
) error {
u.calls++
if u.characters == nil || u.skills == nil {
return dnfrepo.ErrCharacterProgressionTransactionUnavailable
}
return apply(
&countingProgressionCharacterRepository{CharacterRepository: u.characters, saves: &u.characterSaves},
&countingProgressionSkillRepository{SkillRepository: u.skills, saves: &u.skillSaves},
)
}
type countingProgressionCharacterRepository struct {
dnfrepo.CharacterRepository
saves *int
}
func (r *countingProgressionCharacterRepository) Save(ctx context.Context, record dnfrepo.CharacterRecord) error {
*r.saves++
return r.CharacterRepository.Save(ctx, record)
}
func (r *countingProgressionCharacterRepository) SaveFields(ctx context.Context, record dnfrepo.CharacterRecord, fields ...dnfrepo.CharacterField) error {
*r.saves++
return dnfrepo.SaveCharacterFields(ctx, r.CharacterRepository, record, fields...)
}
type countingProgressionSkillRepository struct {
dnfrepo.SkillRepository
saves *int
}
func (r *countingProgressionSkillRepository) Save(ctx context.Context, record dnfrepo.SkillRecord) error {
*r.saves++
return r.SkillRepository.Save(ctx, record)
}
func (r *countingProgressionSkillRepository) SaveFields(ctx context.Context, record dnfrepo.SkillRecord, fields ...dnfrepo.SkillField) error {
*r.saves++
return dnfrepo.SaveSkillFields(ctx, r.SkillRepository, record, fields...)
}
@@ -0,0 +1,8 @@
// Package progression contains PVF-backed, protocol-independent character
// progression rules.
//
// The package deliberately performs no repository writes and emits no DNF
// packets. A dungeon/quest settlement owner must apply the returned character,
// quest, and skill-point changes in one unit of work before acknowledging the
// client.
package progression
@@ -0,0 +1,191 @@
package progression
import (
"errors"
"fmt"
"path"
"strings"
"sync"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const DungeonDefinitionListPath = "dungeon/dungeon.lst"
var (
ErrDungeonLocalSourceRequired = errors.New("dnf local dungeon experience PVF source is required")
ErrDungeonLocalSectionMissing = errors.New("dnf local dungeon experience PVF section is missing")
ErrDungeonLocalSectionShape = errors.New("dnf local dungeon experience PVF section shape is invalid")
ErrDungeonLocalLookup = errors.New("dnf local dungeon experience PVF lookup is absent")
)
// DungeonLocalExperienceRow preserves one exact [increase exp rate] pair from
// a dungeon definition. Base and Bonus remain source columns; no multiplier
// or award amount is derived from them here.
type DungeonLocalExperienceRow struct {
DungeonID int64
Path string
Base int64
Bonus int64
Present bool
}
type DungeonLocalExperienceSnapshot struct {
DungeonDefinitions int
LoadedDefinitions int
}
// DungeonLocalExperienceSources resolves dungeon/dungeon.lst paths and lazily
// reads each dungeon's local experience section. Lazy reads keep normal PVF
// startup bounded while still exposing the exact source row for diagnostics.
type DungeonLocalExperienceSources struct {
source dnfpvf.Source
paths map[int64]string
mu sync.RWMutex
cache map[int64]dungeonLocalExperienceCacheEntry
}
type dungeonLocalExperienceCacheEntry struct {
row DungeonLocalExperienceRow
err error
}
func LoadDungeonLocalExperienceSources(source dnfpvf.Source) (*DungeonLocalExperienceSources, error) {
if source == nil {
return nil, ErrDungeonLocalSourceRequired
}
document, err := readProgressionDocument(source, DungeonDefinitionListPath)
if err != nil {
return nil, err
}
paths, err := parseDungeonDefinitionPaths(document)
if err != nil {
return nil, err
}
return &DungeonLocalExperienceSources{
source: source,
paths: paths,
cache: make(map[int64]dungeonLocalExperienceCacheEntry),
}, nil
}
func readProgressionDocument(source dnfpvf.Source, relativePath string) (*dnfpvf.Document, error) {
text, err := source.ReadText(relativePath)
if err != nil {
return nil, fmt.Errorf("read progression PVF %q: %w", relativePath, err)
}
document, err := dnfpvf.Parse(relativePath, text)
if err != nil {
return nil, fmt.Errorf("parse progression PVF %q: %w", relativePath, err)
}
return document, nil
}
func parseDungeonDefinitionPaths(document *dnfpvf.Document) (map[int64]string, error) {
if document == nil || len(document.Tokens) == 0 {
return nil, fmt.Errorf("%w: path=%s empty", ErrDungeonLocalSectionShape, DungeonDefinitionListPath)
}
if len(document.Tokens)%2 != 0 {
return nil, fmt.Errorf("%w: path=%s values=%d row_width=2", ErrDungeonLocalSectionShape, DungeonDefinitionListPath, len(document.Tokens))
}
paths := make(map[int64]string, len(document.Tokens)/2)
for offset := 0; offset < len(document.Tokens); offset += 2 {
idToken, pathToken := document.Tokens[offset], document.Tokens[offset+1]
if idToken.Kind != dnfpvf.TokenInt || idToken.Int <= 0 ||
(pathToken.Kind != dnfpvf.TokenString && pathToken.Kind != dnfpvf.TokenIdent) {
return nil, fmt.Errorf("%w: path=%s row=%d id_kind=%s path_kind=%s", ErrDungeonLocalSectionShape,
DungeonDefinitionListPath, offset/2, idToken.Kind, pathToken.Kind)
}
relative, err := normalizeDungeonDefinitionPath(pathToken.Value)
if err != nil {
return nil, fmt.Errorf("%w: path=%s row=%d: %v", ErrDungeonLocalSectionShape, DungeonDefinitionListPath, offset/2, err)
}
if _, duplicate := paths[idToken.Int]; duplicate {
return nil, fmt.Errorf("%w: path=%s duplicate_dungeon=%d", ErrDungeonLocalSectionShape, DungeonDefinitionListPath, idToken.Int)
}
paths[idToken.Int] = relative
}
return paths, nil
}
func normalizeDungeonDefinitionPath(value string) (string, error) {
value = strings.TrimSpace(strings.Trim(value, "`"))
value = strings.ReplaceAll(value, `\`, "/")
if value == "" {
return "", errors.New("dungeon definition path is empty")
}
if !strings.HasPrefix(strings.ToLower(value), "dungeon/") {
value = path.Join("dungeon", value)
}
value = path.Clean(value)
if value == "." || value == ".." || strings.HasPrefix(value, "../") {
return "", fmt.Errorf("dungeon definition path %q escapes dungeon root", value)
}
return value, nil
}
// Rule returns the source row for dungeonID. The second result reports that
// dungeon/dungeon.lst contained the ID; Present reports whether the definition
// itself contained [increase exp rate].
func (sources *DungeonLocalExperienceSources) Rule(dungeonID int64) (DungeonLocalExperienceRow, bool, error) {
if sources == nil {
return DungeonLocalExperienceRow{}, false, ErrDungeonLocalSourceRequired
}
if dungeonID <= 0 {
return DungeonLocalExperienceRow{}, false, fmt.Errorf("%w: dungeon_id=%d", ErrDungeonLocalLookup, dungeonID)
}
sources.mu.RLock()
pathValue, pathFound := sources.paths[dungeonID]
cached, cachedFound := sources.cache[dungeonID]
sources.mu.RUnlock()
if !pathFound {
return DungeonLocalExperienceRow{}, false, nil
}
if cachedFound {
return cached.row, true, cached.err
}
row, err := sources.loadRule(dungeonID, pathValue)
sources.mu.Lock()
if existing, exists := sources.cache[dungeonID]; exists {
cached = existing
} else {
cached = dungeonLocalExperienceCacheEntry{row: row, err: err}
sources.cache[dungeonID] = cached
}
sources.mu.Unlock()
return cached.row, true, cached.err
}
func (sources *DungeonLocalExperienceSources) loadRule(dungeonID int64, relativePath string) (DungeonLocalExperienceRow, error) {
document, err := readProgressionDocument(sources.source, relativePath)
if err != nil {
return DungeonLocalExperienceRow{}, err
}
row := DungeonLocalExperienceRow{DungeonID: dungeonID, Path: relativePath}
tokens, found := document.Section("increase exp rate")
if !found {
return row, nil
}
if len(tokens) != 2 || tokens[0].Kind != dnfpvf.TokenInt || tokens[1].Kind != dnfpvf.TokenInt {
return DungeonLocalExperienceRow{}, fmt.Errorf("%w: path=%s section=increase exp rate values=%d", ErrDungeonLocalSectionShape, relativePath, len(tokens))
}
base, bonus := tokens[0].Int, tokens[1].Int
if base <= 0 || bonus < -base {
return DungeonLocalExperienceRow{}, fmt.Errorf("%w: path=%s section=increase exp rate base=%d bonus=%d", ErrDungeonLocalSectionShape, relativePath, base, bonus)
}
row.Base, row.Bonus, row.Present = base, bonus, true
return row, nil
}
func (sources *DungeonLocalExperienceSources) Snapshot() DungeonLocalExperienceSnapshot {
if sources == nil {
return DungeonLocalExperienceSnapshot{}
}
sources.mu.RLock()
defer sources.mu.RUnlock()
return DungeonLocalExperienceSnapshot{
DungeonDefinitions: len(sources.paths),
LoadedDefinitions: len(sources.cache),
}
}
@@ -0,0 +1,129 @@
package progression
import (
"errors"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestDungeonLocalExperienceSourcesLoadAndCacheRawRows(t *testing.T) {
sources, err := LoadDungeonLocalExperienceSources(dungeonLocalExperienceTestSource{
DungeonDefinitionListPath: "7146 `dungeon/story_a.dgn` 7155 `dungeon/story_b.dgn` 4907 `raid/legacy.dgn`",
"dungeon/story_a.dgn": "[increase exp rate]\n100 130\n[/increase exp rate]",
"dungeon/story_b.dgn": "[other]\n1\n[/other]",
"dungeon/raid/legacy.dgn": "[increase exp rate]\n10 -10\n[/increase exp rate]",
})
if err != nil {
t.Fatal(err)
}
if snapshot := sources.Snapshot(); snapshot != (DungeonLocalExperienceSnapshot{DungeonDefinitions: 3}) {
t.Fatalf("initial snapshot=%+v", snapshot)
}
row, pathFound, err := sources.Rule(7146)
if err != nil || !pathFound || !row.Present || row.DungeonID != 7146 || row.Path != "dungeon/story_a.dgn" || row.Base != 100 || row.Bonus != 130 {
t.Fatalf("7146 row=%+v path_found=%t err=%v", row, pathFound, err)
}
if snapshot := sources.Snapshot(); snapshot.LoadedDefinitions != 1 {
t.Fatalf("loaded snapshot=%+v", snapshot)
}
row, pathFound, err = sources.Rule(7155)
if err != nil || !pathFound || row.Present {
t.Fatalf("missing local section row=%+v path_found=%t err=%v", row, pathFound, err)
}
if row, pathFound, err = sources.Rule(3); err != nil || pathFound || row.Present {
t.Fatalf("unknown dungeon got fallback row=%+v path_found=%t err=%v", row, pathFound, err)
}
if _, _, err := sources.Rule(0); !errors.Is(err, ErrDungeonLocalLookup) {
t.Fatalf("zero lookup error=%v", err)
}
}
func TestDungeonLocalExperienceSourcesRejectMalformedDefinitions(t *testing.T) {
if _, err := LoadDungeonLocalExperienceSources(nil); !errors.Is(err, ErrDungeonLocalSourceRequired) {
t.Fatalf("nil source error=%v", err)
}
tests := []struct {
name string
source dungeonLocalExperienceTestSource
}{
{
name: "odd list",
source: dungeonLocalExperienceTestSource{
DungeonDefinitionListPath: "7146 `dungeon/story_a.dgn` 7155",
},
},
{
name: "duplicate id",
source: dungeonLocalExperienceTestSource{
DungeonDefinitionListPath: "7146 `dungeon/story_a.dgn` 7146 `dungeon/story_b.dgn`",
},
},
{
name: "malformed row",
source: dungeonLocalExperienceTestSource{
DungeonDefinitionListPath: "7146 `dungeon/story_a.dgn`",
"dungeon/story_a.dgn": "[increase exp rate]\n100\n[/increase exp rate]",
},
},
{
name: "negative effective rate",
source: dungeonLocalExperienceTestSource{
DungeonDefinitionListPath: "7146 `dungeon/story_a.dgn`",
"dungeon/story_a.dgn": "[increase exp rate]\n100 -101\n[/increase exp rate]",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
sources, err := LoadDungeonLocalExperienceSources(test.source)
if test.name == "malformed row" || test.name == "negative effective rate" {
if err != nil {
t.Fatal(err)
}
if _, _, err := sources.Rule(7146); !errors.Is(err, ErrDungeonLocalSectionShape) {
t.Fatalf("row error=%v", err)
}
return
}
if !errors.Is(err, ErrDungeonLocalSectionShape) {
t.Fatalf("load error=%v", err)
}
})
}
}
func TestRealScriptPVFDungeonLocalExperienceSourcesPreserveRawRows(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify real dungeon local experience sources")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
sources, err := LoadDungeonLocalExperienceSources(archive)
if err != nil {
t.Fatal(err)
}
if snapshot := sources.Snapshot(); snapshot.DungeonDefinitions < 100 {
t.Fatalf("real dungeon local snapshot=%+v", snapshot)
}
row, found, err := sources.Rule(7146)
if err != nil || !found || row.DungeonID != 7146 || row.Path == "" {
t.Fatalf("real dungeon 7146 row=%+v found=%t err=%v", row, found, err)
}
if snapshot := sources.Snapshot(); snapshot.LoadedDefinitions != 1 {
t.Fatalf("real loaded snapshot=%+v", snapshot)
}
}
type dungeonLocalExperienceTestSource map[string]string
func (source dungeonLocalExperienceTestSource) ReadText(relativePath string) (string, error) {
if text, found := source[relativePath]; found {
return text, nil
}
return "", errors.New("missing test PVF path: " + relativePath)
}
@@ -0,0 +1,126 @@
package progression
import (
"context"
"fmt"
"math"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
// ExperienceTable is the immutable cumulative level-threshold projection from
// the runtime PVF. Keeping the level-entry lookup in the progression domain
// prevents administrative writes from treating cumulative EXP as per-level
// EXP.
type ExperienceTable struct {
thresholds []uint32
}
// LoadExperienceTable reads only character/ExpTable.tbl. Callers that need no
// SP, TP, or quest data should not make those unrelated PVF files prerequisites.
func LoadExperienceTable(ctx context.Context, source dnfpvf.Source) (*ExperienceTable, error) {
if source == nil {
return nil, dnfpvf.ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
doc, err := readDocument(source, ExperienceTablePath)
if err != nil {
return nil, err
}
thresholds, err := parseExperienceThresholds(doc)
if err != nil {
return nil, err
}
return &ExperienceTable{thresholds: thresholds}, nil
}
// EntryExperience returns the cumulative EXP at the beginning of level. Level
// one begins at zero; every later level begins at the previous PVF threshold.
func (t *ExperienceTable) EntryExperience(level int) (uint32, error) {
if t == nil || level <= 0 || level-1 > len(t.thresholds) {
return 0, fmt.Errorf("%w: entry level=%d thresholds=%d", ErrLevelOutOfRange, level, experienceThresholdCount(t))
}
if level == 1 {
return 0, nil
}
return t.thresholds[level-2], nil
}
func experienceThresholdCount(t *ExperienceTable) int {
if t == nil {
return 0
}
return len(t.thresholds)
}
type ExperienceResult struct {
PreviousLevel int
PreviousExperience uint32
Gain uint32
NewLevel int
NewExperience uint32
LevelsGained int
Saturated bool
}
// ThresholdToNext returns the cumulative experience required to advance from
// level to level+1. The caller supplies the server's proven level cap when
// applying experience; the threshold-file length is not guessed to be that cap.
func (t *Tables) ThresholdToNext(level int) (uint32, error) {
if t == nil || level <= 0 || level > len(t.experienceThresholds) {
return 0, fmt.Errorf("%w: threshold level=%d entries=%d", ErrLevelOutOfRange, level, thresholdCount(t))
}
return t.experienceThresholds[level-1], nil
}
// ApplyExperience saturating-adds a current-EXE u32 experience gain and applies
// cumulative PVF thresholds up to maxLevel. It does not persist or emit an op37.
func (t *Tables) ApplyExperience(level int, totalExperience, gain uint32, maxLevel int) (ExperienceResult, error) {
result := ExperienceResult{
PreviousLevel: level,
PreviousExperience: totalExperience,
Gain: gain,
NewLevel: level,
NewExperience: totalExperience,
}
if t == nil || level <= 0 || maxLevel <= 0 || maxLevel > MaximumCharacterLevel ||
level > maxLevel || maxLevel-1 > len(t.experienceThresholds) {
return ExperienceResult{}, fmt.Errorf("%w: level=%d max=%d thresholds=%d", ErrLevelOutOfRange, level, maxLevel, thresholdCount(t))
}
sum := uint64(totalExperience) + uint64(gain)
if sum > math.MaxUint32 {
sum = math.MaxUint32
result.Saturated = true
}
result.NewExperience = uint32(sum)
for result.NewLevel < maxLevel {
threshold := t.experienceThresholds[result.NewLevel-1]
if result.NewExperience < threshold {
break
}
result.NewLevel++
}
// Ordinary EXP stops at the cumulative threshold that enters max level;
// later accepted EXP is owned by HonorExpert.
if result.NewLevel == maxLevel && maxLevel > 1 {
entryThreshold := t.experienceThresholds[maxLevel-2]
if result.NewExperience > entryThreshold {
result.NewExperience = entryThreshold
result.Saturated = true
}
}
result.LevelsGained = result.NewLevel - result.PreviousLevel
return result, nil
}
func thresholdCount(t *Tables) int {
if t == nil {
return 0
}
return len(t.experienceThresholds)
}
@@ -0,0 +1,400 @@
package progression
import (
"errors"
"fmt"
"math"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
MonsterExperienceTablePath = "monster/monsterexp.tbl"
ServerParameterPath = "Etc/ServerParameter.etc"
WorldMapExperiencePenaltyPath = "etc/worldmapexppenaltytable.etc"
)
var (
ErrMonsterExperienceSourceRequired = errors.New("dnf monster experience PVF source is required")
ErrMonsterExperienceSectionMissing = errors.New("dnf monster experience PVF section is missing")
ErrMonsterExperienceSectionShape = errors.New("dnf monster experience PVF section shape is invalid")
ErrMonsterExperienceLookup = errors.New("dnf monster experience PVF lookup is absent")
)
// MonsterExperienceSourceSnapshot reports only typed PVF source coverage. It
// does not claim that the current server's award formula has been reconstructed.
type MonsterExperienceSourceSnapshot struct {
MonsterLevels int
MonsterGlobalRates int
PartyRates int
StarterPartyRates int
DifficultyRates int
DungeonIncreasingRates int
PenaltyRows int
ClearGlobalRates int
ClearRankRates int
}
// MonsterExperienceRawModifiers preserves the exact ServerParameter.etc
// sections whose names mention monster/dungeon experience. Section ordering is
// retained; no multiplication order or hidden level-difference rule is implied.
type MonsterExperienceRawModifiers struct {
MonsterGlobalRates []float64
PartyRates [4]float64
StarterPartyRates [4]float64
DifficultyRates [5]float64
ClearGlobalRates []float64
ClearRankRates [5]float64
}
type MonsterExperienceBlocker string
const (
MonsterExperienceCombinationUnproved MonsterExperienceBlocker = "modifier_combination_unproved"
MonsterExperiencePenaltyDirectionUnproved MonsterExperienceBlocker = "level_difference_direction_unproved"
MonsterExperienceActorTypeUnproved MonsterExperienceBlocker = "actor_type_rate_mapping_unproved"
)
// MonsterExperienceSourcePlan is a pure, non-awarding lookup. Values come from
// the current runtime PVF, but AwardReady deliberately remains false until the
// current server/EXE proves how these independent sources combine.
type MonsterExperienceSourcePlan struct {
MonsterLevel int
MonsterTableValue uint32
PartyMembers int
PartyRate float64
StarterPartyRate float64
DifficultyIndex int
DifficultyRate float64
DungeonID int64
DungeonIncreasingRate float64
DungeonRateFound bool
AwardReady bool
Blockers []MonsterExperienceBlocker
}
// MonsterExperienceSources is an immutable typed view of
// monster/monsterexp.tbl plus the experience-named sections in
// Etc/ServerParameter.etc.
type MonsterExperienceSources struct {
monsterByLevel []uint32
monsterGlobalRates []float64
partyRates [4]float64
starterPartyRates [4]float64
difficultyRates [5]float64
dungeonRates map[int64]float64
penaltyRates map[int]float64
clearGlobalRates []float64
clearRankRates [5]float64
}
func LoadMonsterExperienceSources(source dnfpvf.Source) (*MonsterExperienceSources, error) {
if source == nil {
return nil, ErrMonsterExperienceSourceRequired
}
monsterDocument, err := readDocument(source, MonsterExperienceTablePath)
if err != nil {
return nil, err
}
monsterByLevel, err := parseMonsterExperienceTable(monsterDocument)
if err != nil {
return nil, err
}
serverDocument, err := readDocument(source, ServerParameterPath)
if err != nil {
return nil, err
}
globalRates, err := strictMonsterNumbers(serverDocument, "monster exp bonusrate")
if err != nil {
return nil, err
}
if len(globalRates) != 1 {
return nil, fmt.Errorf("%w: path=%s section=monster exp bonusrate values=%d want=1",
ErrMonsterExperienceSectionShape, ServerParameterPath, len(globalRates))
}
partyValues, err := strictMonsterFixedNumbers(serverDocument, "party user number exp bonusrate", 4)
if err != nil {
return nil, err
}
starterPartyValues, err := strictMonsterFixedNumbers(serverDocument, "party user number exp bonusrate starter server", 4)
if err != nil {
return nil, err
}
difficultyValues, err := strictMonsterFixedNumbers(serverDocument, "dungeon difficulty exp bonusrate", 5)
if err != nil {
return nil, err
}
dungeonRates, err := parseMonsterDungeonRates(serverDocument)
if err != nil {
return nil, err
}
clearGlobalRates, err := strictMonsterNumbers(serverDocument, "clear exp bonusrate")
if err != nil {
return nil, err
}
if len(clearGlobalRates) != 1 {
return nil, fmt.Errorf("%w: path=%s section=clear exp bonusrate values=%d want=1",
ErrMonsterExperienceSectionShape, ServerParameterPath, len(clearGlobalRates))
}
clearRankValues, err := strictMonsterFixedNumbers(serverDocument, "clear rank exp bonusrate", 5)
if err != nil {
return nil, err
}
penaltyDocument, err := readDocument(source, WorldMapExperiencePenaltyPath)
if err != nil {
return nil, err
}
penaltyRates, err := parseMonsterPenaltyRates(penaltyDocument)
if err != nil {
return nil, err
}
result := &MonsterExperienceSources{
monsterByLevel: monsterByLevel,
monsterGlobalRates: globalRates,
dungeonRates: dungeonRates,
penaltyRates: penaltyRates,
clearGlobalRates: clearGlobalRates,
}
copy(result.partyRates[:], partyValues)
copy(result.starterPartyRates[:], starterPartyValues)
copy(result.difficultyRates[:], difficultyValues)
copy(result.clearRankRates[:], clearRankValues)
return result, nil
}
func parseMonsterExperienceTable(document *dnfpvf.Document) ([]uint32, error) {
if document == nil || len(document.Tokens) == 0 {
return nil, fmt.Errorf("%w: path=%s empty", ErrMonsterExperienceSectionShape, MonsterExperienceTablePath)
}
values := make([]int64, 0, len(document.Tokens))
for index, token := range document.Tokens {
if token.Kind != dnfpvf.TokenInt {
return nil, fmt.Errorf("%w: path=%s token=%d kind=%s", ErrMonsterExperienceSectionShape, MonsterExperienceTablePath, index, token.Kind)
}
values = append(values, token.Int)
}
if len(values)%2 != 0 {
return nil, fmt.Errorf("%w: path=%s values=%d row_width=2", ErrMonsterExperienceSectionShape, MonsterExperienceTablePath, len(values))
}
table := make([]uint32, 0, len(values)/2)
for offset := 0; offset < len(values); offset += 2 {
level, value := values[offset], values[offset+1]
wantLevel := int64(len(table) + 1)
if level != wantLevel || value <= 0 || value > math.MaxUint32 {
return nil, fmt.Errorf("%w: path=%s row=%d level=%d want_level=%d value=%d",
ErrMonsterExperienceSectionShape, MonsterExperienceTablePath, offset/2, level, wantLevel, value)
}
table = append(table, uint32(value))
}
return table, nil
}
func strictMonsterNumbers(document *dnfpvf.Document, section string) ([]float64, error) {
tokens, found := document.Section(section)
if !found {
return nil, fmt.Errorf("%w: path=%s section=%s", ErrMonsterExperienceSectionMissing, ServerParameterPath, section)
}
if len(tokens) == 0 {
return nil, fmt.Errorf("%w: path=%s section=%s empty", ErrMonsterExperienceSectionShape, ServerParameterPath, section)
}
values := make([]float64, 0, len(tokens))
for index, token := range tokens {
var value float64
switch token.Kind {
case dnfpvf.TokenInt:
value = float64(token.Int)
case dnfpvf.TokenFloat:
value = token.Float
default:
return nil, fmt.Errorf("%w: path=%s section=%s token=%d kind=%s",
ErrMonsterExperienceSectionShape, ServerParameterPath, section, index, token.Kind)
}
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
return nil, fmt.Errorf("%w: path=%s section=%s token=%d value=%v",
ErrMonsterExperienceSectionShape, ServerParameterPath, section, index, value)
}
values = append(values, value)
}
return values, nil
}
func strictMonsterFixedNumbers(document *dnfpvf.Document, section string, want int) ([]float64, error) {
values, err := strictMonsterNumbers(document, section)
if err != nil {
return nil, err
}
if len(values) != want {
return nil, fmt.Errorf("%w: path=%s section=%s values=%d want=%d",
ErrMonsterExperienceSectionShape, ServerParameterPath, section, len(values), want)
}
return values, nil
}
func parseMonsterDungeonRates(document *dnfpvf.Document) (map[int64]float64, error) {
tokens, found := document.Section("experience increasing point")
if !found {
return nil, fmt.Errorf("%w: path=%s section=experience increasing point", ErrMonsterExperienceSectionMissing, ServerParameterPath)
}
if len(tokens) == 0 || len(tokens)%2 != 0 {
return nil, fmt.Errorf("%w: path=%s section=experience increasing point values=%d row_width=2",
ErrMonsterExperienceSectionShape, ServerParameterPath, len(tokens))
}
rates := make(map[int64]float64, len(tokens)/2)
for offset := 0; offset < len(tokens); offset += 2 {
idToken, rateToken := tokens[offset], tokens[offset+1]
if idToken.Kind != dnfpvf.TokenInt || idToken.Int <= 0 {
return nil, fmt.Errorf("%w: path=%s section=experience increasing point row=%d id_kind=%s id=%d",
ErrMonsterExperienceSectionShape, ServerParameterPath, offset/2, idToken.Kind, idToken.Int)
}
var rate float64
switch rateToken.Kind {
case dnfpvf.TokenInt:
rate = float64(rateToken.Int)
case dnfpvf.TokenFloat:
rate = rateToken.Float
default:
return nil, fmt.Errorf("%w: path=%s section=experience increasing point row=%d rate_kind=%s",
ErrMonsterExperienceSectionShape, ServerParameterPath, offset/2, rateToken.Kind)
}
if math.IsNaN(rate) || math.IsInf(rate, 0) || rate < 0 {
return nil, fmt.Errorf("%w: path=%s section=experience increasing point row=%d rate=%v",
ErrMonsterExperienceSectionShape, ServerParameterPath, offset/2, rate)
}
if _, duplicate := rates[idToken.Int]; duplicate {
return nil, fmt.Errorf("%w: path=%s section=experience increasing point duplicate_dungeon=%d",
ErrMonsterExperienceSectionShape, ServerParameterPath, idToken.Int)
}
rates[idToken.Int] = rate
}
return rates, nil
}
func parseMonsterPenaltyRates(document *dnfpvf.Document) (map[int]float64, error) {
tokens, found := document.Section("penalty table info")
if !found {
return nil, fmt.Errorf("%w: path=%s section=penalty table info", ErrMonsterExperienceSectionMissing, WorldMapExperiencePenaltyPath)
}
const firstKey, lastKey = -16, 20
if len(tokens) != (lastKey-firstKey+1)*2 {
return nil, fmt.Errorf("%w: path=%s section=penalty table info values=%d want=%d",
ErrMonsterExperienceSectionShape, WorldMapExperiencePenaltyPath, len(tokens), (lastKey-firstKey+1)*2)
}
rates := make(map[int]float64, lastKey-firstKey+1)
for offset := 0; offset < len(tokens); offset += 2 {
keyToken, rateToken := tokens[offset], tokens[offset+1]
wantKey := int64(firstKey + offset/2)
if keyToken.Kind != dnfpvf.TokenInt || keyToken.Int != wantKey {
return nil, fmt.Errorf("%w: path=%s section=penalty table info row=%d key=%d want=%d kind=%s",
ErrMonsterExperienceSectionShape, WorldMapExperiencePenaltyPath, offset/2, keyToken.Int, wantKey, keyToken.Kind)
}
var rate float64
switch rateToken.Kind {
case dnfpvf.TokenInt:
rate = float64(rateToken.Int)
case dnfpvf.TokenFloat:
rate = rateToken.Float
default:
return nil, fmt.Errorf("%w: path=%s section=penalty table info row=%d rate_kind=%s",
ErrMonsterExperienceSectionShape, WorldMapExperiencePenaltyPath, offset/2, rateToken.Kind)
}
if math.IsNaN(rate) || math.IsInf(rate, 0) || rate < 0 {
return nil, fmt.Errorf("%w: path=%s section=penalty table info row=%d rate=%v",
ErrMonsterExperienceSectionShape, WorldMapExperiencePenaltyPath, offset/2, rate)
}
rates[int(wantKey)] = rate
}
return rates, nil
}
func (sources *MonsterExperienceSources) Snapshot() MonsterExperienceSourceSnapshot {
if sources == nil {
return MonsterExperienceSourceSnapshot{}
}
return MonsterExperienceSourceSnapshot{
MonsterLevels: len(sources.monsterByLevel),
MonsterGlobalRates: len(sources.monsterGlobalRates),
PartyRates: len(sources.partyRates),
StarterPartyRates: len(sources.starterPartyRates),
DifficultyRates: len(sources.difficultyRates),
DungeonIncreasingRates: len(sources.dungeonRates),
PenaltyRows: len(sources.penaltyRates),
ClearGlobalRates: len(sources.clearGlobalRates),
ClearRankRates: len(sources.clearRankRates),
}
}
func (sources *MonsterExperienceSources) MonsterTableValue(level int) (uint32, bool) {
if sources == nil || level <= 0 || level > len(sources.monsterByLevel) {
return 0, false
}
return sources.monsterByLevel[level-1], true
}
func (sources *MonsterExperienceSources) DungeonIncreasingRate(dungeonID int64) (float64, bool) {
if sources == nil {
return 0, false
}
value, found := sources.dungeonRates[dungeonID]
return value, found
}
// PenaltyRateBySourceKey returns one exact key/value pair from
// worldmapexppenaltytable.etc. The method intentionally does not accept player
// and monster levels because the current EXE has not yet proved which
// subtraction direction produces the source key.
func (sources *MonsterExperienceSources) PenaltyRateBySourceKey(key int) (float64, bool) {
if sources == nil {
return 0, false
}
value, found := sources.penaltyRates[key]
return value, found
}
func (sources *MonsterExperienceSources) RawModifiers() MonsterExperienceRawModifiers {
if sources == nil {
return MonsterExperienceRawModifiers{}
}
return MonsterExperienceRawModifiers{
MonsterGlobalRates: append([]float64(nil), sources.monsterGlobalRates...),
PartyRates: sources.partyRates,
StarterPartyRates: sources.starterPartyRates,
DifficultyRates: sources.difficultyRates,
ClearGlobalRates: append([]float64(nil), sources.clearGlobalRates...),
ClearRankRates: sources.clearRankRates,
}
}
func (sources *MonsterExperienceSources) PlanSource(monsterLevel int, partyMembers int, difficultyIndex int, dungeonID int64) (MonsterExperienceSourcePlan, error) {
if sources == nil {
return MonsterExperienceSourcePlan{}, ErrMonsterExperienceSourceRequired
}
monsterValue, found := sources.MonsterTableValue(monsterLevel)
if !found {
return MonsterExperienceSourcePlan{}, fmt.Errorf("%w: monster_level=%d", ErrMonsterExperienceLookup, monsterLevel)
}
if partyMembers <= 0 || partyMembers > len(sources.partyRates) {
return MonsterExperienceSourcePlan{}, fmt.Errorf("%w: party_members=%d", ErrMonsterExperienceLookup, partyMembers)
}
if difficultyIndex < 0 || difficultyIndex >= len(sources.difficultyRates) {
return MonsterExperienceSourcePlan{}, fmt.Errorf("%w: difficulty_index=%d", ErrMonsterExperienceLookup, difficultyIndex)
}
dungeonRate, dungeonFound := sources.DungeonIncreasingRate(dungeonID)
return MonsterExperienceSourcePlan{
MonsterLevel: monsterLevel,
MonsterTableValue: monsterValue,
PartyMembers: partyMembers,
PartyRate: sources.partyRates[partyMembers-1],
StarterPartyRate: sources.starterPartyRates[partyMembers-1],
DifficultyIndex: difficultyIndex,
DifficultyRate: sources.difficultyRates[difficultyIndex],
DungeonID: dungeonID,
DungeonIncreasingRate: dungeonRate,
DungeonRateFound: dungeonFound,
AwardReady: false,
Blockers: []MonsterExperienceBlocker{
MonsterExperienceCombinationUnproved,
MonsterExperiencePenaltyDirectionUnproved,
MonsterExperienceActorTypeUnproved,
},
}, nil
}
@@ -0,0 +1,264 @@
package progression
import (
"errors"
"os"
"reflect"
"strings"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestMonsterExperienceSourcesPreservePVFValuesWithoutInventingAwardFormula(t *testing.T) {
sources, err := LoadMonsterExperienceSources(monsterExperienceTestSource())
if err != nil {
t.Fatal(err)
}
if snapshot := sources.Snapshot(); snapshot != (MonsterExperienceSourceSnapshot{
MonsterLevels: 3, MonsterGlobalRates: 1, PartyRates: 4,
StarterPartyRates: 4, DifficultyRates: 5, DungeonIncreasingRates: 2,
PenaltyRows: 37, ClearGlobalRates: 1, ClearRankRates: 5,
}) {
t.Fatalf("snapshot=%+v", snapshot)
}
if value, found := sources.MonsterTableValue(2); !found || value != 60 {
t.Fatalf("level 2=%d,%t", value, found)
}
if _, found := sources.MonsterTableValue(4); found {
t.Fatal("out-of-table monster level fabricated")
}
if value, found := sources.DungeonIncreasingRate(7146); !found || value != 2.3 {
t.Fatalf("dungeon 7146=%v,%t", value, found)
}
if _, found := sources.DungeonIncreasingRate(3); found {
t.Fatal("dungeon 3 rate fabricated")
}
modifiers := sources.RawModifiers()
if !reflect.DeepEqual(modifiers.MonsterGlobalRates, []float64{1}) ||
modifiers.PartyRates != ([4]float64{1, 2, 3, 4}) ||
modifiers.StarterPartyRates != ([4]float64{1, 2, 3, 4}) ||
modifiers.DifficultyRates != ([5]float64{1.3, 2, 2.5, 3, 4}) ||
!reflect.DeepEqual(modifiers.ClearGlobalRates, []float64{1}) ||
modifiers.ClearRankRates != ([5]float64{0.05, 0.1, 0.12, 0.15, 0.2}) {
t.Fatalf("raw modifiers=%+v", modifiers)
}
if rate, found := sources.PenaltyRateBySourceKey(-16); !found || rate != 0.3 {
t.Fatalf("penalty -16=%v,%t", rate, found)
}
if rate, found := sources.PenaltyRateBySourceKey(5); !found || rate != 1 {
t.Fatalf("penalty 5=%v,%t", rate, found)
}
if _, found := sources.PenaltyRateBySourceKey(21); found {
t.Fatal("penalty key outside PVF table fabricated")
}
plan, err := sources.PlanSource(1, 1, 0, 7146)
if err != nil {
t.Fatal(err)
}
if plan.MonsterTableValue != 30 || plan.PartyRate != 1 || plan.StarterPartyRate != 1 ||
plan.DifficultyRate != 1.3 || !plan.DungeonRateFound || plan.DungeonIncreasingRate != 2.3 {
t.Fatalf("plan source values=%+v", plan)
}
if plan.AwardReady || !reflect.DeepEqual(plan.Blockers, []MonsterExperienceBlocker{
MonsterExperienceCombinationUnproved,
MonsterExperiencePenaltyDirectionUnproved,
MonsterExperienceActorTypeUnproved,
}) {
t.Fatalf("unproved award was enabled: %+v", plan)
}
missingDungeon, err := sources.PlanSource(1, 1, 0, 3)
if err != nil {
t.Fatal(err)
}
if missingDungeon.DungeonRateFound || missingDungeon.DungeonIncreasingRate != 0 || missingDungeon.AwardReady {
t.Fatalf("missing dungeon modifier got a fallback: %+v", missingDungeon)
}
for _, request := range []struct {
level int
party int
difficulty int
}{
{level: 0, party: 1, difficulty: 0},
{level: 1, party: 0, difficulty: 0},
{level: 1, party: 1, difficulty: 5},
} {
if _, err := sources.PlanSource(request.level, request.party, request.difficulty, 3); !errors.Is(err, ErrMonsterExperienceLookup) {
t.Fatalf("request=%+v error=%v", request, err)
}
}
}
func TestMonsterExperienceSourcesRejectMalformedOrPartialPVF(t *testing.T) {
if _, err := LoadMonsterExperienceSources(nil); !errors.Is(err, ErrMonsterExperienceSourceRequired) {
t.Fatalf("nil source error=%v", err)
}
tests := []struct {
name string
mutate func(progressionMemorySource)
}{
{
name: "odd monster table",
mutate: func(source progressionMemorySource) {
source[MonsterExperienceTablePath] = "1 30 2"
},
},
{
name: "noncontiguous monster level",
mutate: func(source progressionMemorySource) {
source[MonsterExperienceTablePath] = "1 30 3 72"
},
},
{
name: "nonnumeric monster value",
mutate: func(source progressionMemorySource) {
source[MonsterExperienceTablePath] = "1 30 2 missing"
},
},
{
name: "missing global section",
mutate: func(source progressionMemorySource) {
source[ServerParameterPath] = strings.Replace(source[ServerParameterPath], "[monster exp bonusrate]\n1.00\n", "", 1)
},
},
{
name: "short party vector",
mutate: func(source progressionMemorySource) {
source[ServerParameterPath] = strings.Replace(source[ServerParameterPath], "[party user number exp bonusrate]\n1 2 3 4", "[party user number exp bonusrate]\n1 2 3", 1)
},
},
{
name: "short difficulty vector",
mutate: func(source progressionMemorySource) {
source[ServerParameterPath] = strings.Replace(source[ServerParameterPath], "1.30 2.00 2.50 3.00 4.00", "1.30 2.00", 1)
},
},
{
name: "missing clear rate",
mutate: func(source progressionMemorySource) {
source[ServerParameterPath] = strings.Replace(source[ServerParameterPath], "[clear exp bonusrate]\n1.00\n", "", 1)
},
},
{
name: "short clear rank vector",
mutate: func(source progressionMemorySource) {
source[ServerParameterPath] = strings.Replace(source[ServerParameterPath], "0.05 0.10 0.12 0.15 0.20", "0.05 0.10", 1)
},
},
{
name: "duplicate dungeon modifier",
mutate: func(source progressionMemorySource) {
source[ServerParameterPath] = strings.Replace(source[ServerParameterPath], "7146 2.30 4907 22.00", "7146 2.30 7146 22.00", 1)
},
},
{
name: "nonnumeric dungeon rate",
mutate: func(source progressionMemorySource) {
source[ServerParameterPath] = strings.Replace(source[ServerParameterPath], "7146 2.30", "7146 unknown", 1)
},
},
{
name: "short penalty table",
mutate: func(source progressionMemorySource) {
source[WorldMapExperiencePenaltyPath] = "[penalty table info]\n-16 0.30\n[/penalty table info]\n"
},
},
{
name: "noncontiguous penalty key",
mutate: func(source progressionMemorySource) {
source[WorldMapExperiencePenaltyPath] = strings.Replace(source[WorldMapExperiencePenaltyPath], "-15 0.50", "-14 0.50", 1)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := monsterExperienceTestSource()
test.mutate(source)
_, err := LoadMonsterExperienceSources(source)
if !errors.Is(err, ErrMonsterExperienceSectionShape) && !errors.Is(err, ErrMonsterExperienceSectionMissing) {
t.Fatalf("error=%v", err)
}
})
}
}
func TestRealScriptPVFMonsterExperienceSourcesPreserveExactRuntimeRows(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
pvfPath = os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
}
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify real monster experience PVF sources")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
sources, err := LoadMonsterExperienceSources(archive)
if err != nil {
t.Fatal(err)
}
if snapshot := sources.Snapshot(); snapshot != (MonsterExperienceSourceSnapshot{
MonsterLevels: 300, MonsterGlobalRates: 1, PartyRates: 4,
StarterPartyRates: 4, DifficultyRates: 5, DungeonIncreasingRates: 11,
PenaltyRows: 37, ClearGlobalRates: 1, ClearRankRates: 5,
}) {
t.Fatalf("real snapshot=%+v", snapshot)
}
for level, want := range map[int]uint32{1: 30, 2: 60, 3: 72, 100: 9517, 300: 64906} {
if got, found := sources.MonsterTableValue(level); !found || got != want {
t.Fatalf("real monster table level=%d value=%d,%t want=%d", level, got, found, want)
}
}
modifiers := sources.RawModifiers()
if !reflect.DeepEqual(modifiers.MonsterGlobalRates, []float64{1}) ||
modifiers.PartyRates != ([4]float64{1, 2, 3, 4}) ||
modifiers.StarterPartyRates != ([4]float64{1, 2, 3, 4}) ||
modifiers.DifficultyRates != ([5]float64{1.3, 2, 2.5, 3, 4}) ||
!reflect.DeepEqual(modifiers.ClearGlobalRates, []float64{1}) ||
modifiers.ClearRankRates != ([5]float64{0.05, 0.1, 0.12, 0.15, 0.2}) {
t.Fatalf("real raw modifiers=%+v", modifiers)
}
for key, want := range map[int]float64{-16: 0.3, -7: 0.8, -3: 1, 5: 1, 6: 0.9, 20: 0.3} {
if got, found := sources.PenaltyRateBySourceKey(key); !found || got != want {
t.Fatalf("real penalty key=%d value=%v,%t want=%v", key, got, found, want)
}
}
for dungeonID, want := range map[int64]float64{7146: 2.3, 7155: 2.9, 4907: 22} {
if got, found := sources.DungeonIncreasingRate(dungeonID); !found || got != want {
t.Fatalf("real dungeon=%d value=%v,%t want=%v", dungeonID, got, found, want)
}
}
if plan, err := sources.PlanSource(1, 1, 0, 3); err != nil || plan.AwardReady || plan.DungeonRateFound {
t.Fatalf("real dungeon 3 source plan=%+v err=%v", plan, err)
}
}
func monsterExperienceTestSource() progressionMemorySource {
return progressionMemorySource{
MonsterExperienceTablePath: "1 30 2 60 3 72\n",
ServerParameterPath: `[monster exp bonusrate]
1.00
[party user number exp bonusrate]
1 2 3 4
[party user number exp bonusrate starter server]
1 2 3 4
[dungeon difficulty exp bonusrate]
1.30 2.00 2.50 3.00 4.00
[clear exp bonusrate]
1.00
[clear rank exp bonusrate]
0.05 0.10 0.12 0.15 0.20
[experience increasing point]
7146 2.30 4907 22.00
[/experience increasing point]
`,
WorldMapExperiencePenaltyPath: `[penalty table info]
-16 0.30 -15 0.50 -14 0.50 -13 0.50 -12 0.70 -11 0.70 -10 0.70 -9 0.80 -8 0.80 -7 0.80 -6 0.90 -5 0.90 -4 0.90 -3 1.00 -2 1.00 -1 1.00 0 1.00 1 1.00 2 1.00 3 1.00 4 1.00 5 1.00 6 0.90 7 0.90 8 0.90 9 0.80 10 0.80 11 0.80 12 0.70 13 0.70 14 0.70 15 0.50 16 0.50 17 0.50 18 0.30 19 0.30 20 0.30
[/penalty table info]
`,
}
}
@@ -0,0 +1,39 @@
package progression
import (
"fmt"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// ExperienceSkillPointPlan couples the two values that must be committed
// together when experience crosses a level threshold.
type ExperienceSkillPointPlan struct {
Experience ExperienceResult
SkillPoints SkillPointAdvance
}
// PlanExperienceAndSkillPoints is the single pure entry point for the rule
// "experience levels the character and each crossed level grants PVF SP".
// The caller must commit CharacterRecord.Level/Stats["exp"] and
// SkillRecord.Points atomically; this method does not write either repository.
func (t *Tables) PlanExperienceAndSkillPoints(
level int,
totalExperience uint32,
gain uint32,
maxLevel int,
points dnfrepo.SkillPointState,
) (ExperienceSkillPointPlan, error) {
if points.SyncedLevel != level {
return ExperienceSkillPointPlan{}, fmt.Errorf("%w: character_level=%d synced_level=%d", ErrSkillPointLedger, level, points.SyncedLevel)
}
experience, err := t.ApplyExperience(level, totalExperience, gain, maxLevel)
if err != nil {
return ExperienceSkillPointPlan{}, err
}
skillPoints, err := t.AdvanceSkillPoints(points, experience.NewLevel)
if err != nil {
return ExperienceSkillPointPlan{}, err
}
return ExperienceSkillPointPlan{Experience: experience, SkillPoints: skillPoints}, nil
}
@@ -0,0 +1,188 @@
package progression
import (
"fmt"
"math"
"strings"
"unicode/utf8"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
type questExperienceRules struct {
baseByLevel []uint32
goldByLevel []uint32
difficultyWeight map[rune]int
greenPenalty int
greyPenalty int
}
// DungeonClearBaseExperience returns the exact level row from the runtime
// PVF [exp reward table]. It deliberately applies no difficulty, rank,
// premium, party, or account modifier; the dungeon settlement owner must
// combine only modifiers it has independently proved.
func (t *Tables) DungeonClearBaseExperience(dungeonBasisLevel int) (uint32, error) {
if t == nil || dungeonBasisLevel <= 0 || dungeonBasisLevel > len(t.quest.baseByLevel) {
return 0, fmt.Errorf(
"%w: dungeon basis level=%d entries=%d",
ErrQuestExperienceLookup,
dungeonBasisLevel,
func() int {
if t == nil {
return 0
}
return len(t.quest.baseByLevel)
}(),
)
}
return t.quest.baseByLevel[dungeonBasisLevel-1], nil
}
// QuestExperienceAward returns both the PVF quest reward-table base and the
// level/difficulty-adjusted amount. The two values are separate on the current
// dungeon result window: Base is the unadjusted quest row and Gain is the
// amount rendered after the quest level penalty and difficulty weight.
func (t *Tables) QuestExperienceAward(playerLevel, questMinLevel int, difficulty rune, ignoreLevel bool) (base, gain uint32, err error) {
if t == nil || playerLevel <= 0 || questMinLevel <= 0 {
return 0, 0, fmt.Errorf("%w: player=%d quest_min=%d", ErrLevelOutOfRange, playerLevel, questMinLevel)
}
lookupLevel := questMinLevel
if ignoreLevel {
lookupLevel = playerLevel
}
if lookupLevel > len(t.quest.baseByLevel) {
return 0, 0, fmt.Errorf("%w: level=%d entries=%d", ErrQuestExperienceLookup, lookupLevel, len(t.quest.baseByLevel))
}
weight, ok := t.quest.difficultyWeight[normalizeDifficulty(difficulty)]
if !ok {
return 0, 0, fmt.Errorf("%w: %q", ErrQuestDifficulty, string(difficulty))
}
penalty := 100
if !ignoreLevel {
difference := playerLevel - questMinLevel
switch {
case difference > 11:
penalty = t.quest.greyPenalty
case difference > 6:
penalty = t.quest.greenPenalty
}
}
value := uint64(t.quest.baseByLevel[lookupLevel-1])
value = uint64(penalty) * ((uint64(weight) * value / 100) / 100)
if value > math.MaxUint32 {
return 0, 0, fmt.Errorf("%w: quest experience=%d", ErrExperienceOutOfRange, value)
}
return t.quest.baseByLevel[lookupLevel-1], uint32(value), nil
}
// QuestExperience computes only the adjusted PVF quest reward. Callers that
// render the split result-window fields should use QuestExperienceAward.
func (t *Tables) QuestExperience(playerLevel, questMinLevel int, difficulty rune, ignoreLevel bool) (uint32, error) {
_, gain, err := t.QuestExperienceAward(playerLevel, questMinLevel, difficulty, ignoreLevel)
return gain, err
}
// QuestGold ports the C# QuestParameterTable.ComputeGoldReward domain rule:
// [gold reward table] supplies the base row and quest [gold multiple] (or a
// reward-int item 0 row with count) supplies the percentage multiplier. This is
// pure PVF/accounting data; packet ACK shape is still owned by the bridge.
func (t *Tables) QuestGold(playerLevel, questMinLevel, goldMultiple int, ignoreLevel bool) (uint32, error) {
if t == nil || playerLevel <= 0 || questMinLevel <= 0 {
return 0, fmt.Errorf("%w: player=%d quest_min=%d", ErrLevelOutOfRange, playerLevel, questMinLevel)
}
if len(t.quest.goldByLevel) == 0 {
return 0, fmt.Errorf("%w: %s [gold reward table]", ErrTableEmpty, QuestParameterPath)
}
if goldMultiple <= 0 {
goldMultiple = 100
}
// The gold table is zero-based by quest level. Unlike quest experience,
// gold never switches to the player-level row and never receives the
// green/grey level-gap penalty. Keep the flag in this API because it comes
// from the same quest definition, but it applies only to the EXP path.
_ = ignoreLevel
lookupLevel := questMinLevel
if lookupLevel >= len(t.quest.goldByLevel) {
return 0, fmt.Errorf("%w: level=%d entries=%d", ErrQuestExperienceLookup, lookupLevel, len(t.quest.goldByLevel))
}
value := uint64(goldMultiple) * uint64(t.quest.goldByLevel[lookupLevel]) / 100
if value > math.MaxUint32 {
return 0, fmt.Errorf("%w: quest gold=%d", ErrExperienceOutOfRange, value)
}
return uint32(value), nil
}
func parseQuestExperienceRules(doc *dnfpvf.Document) (questExperienceRules, error) {
if doc == nil {
return questExperienceRules{}, fmt.Errorf("%w: %s", ErrTableEmpty, QuestParameterPath)
}
rules := questExperienceRules{
difficultyWeight: make(map[rune]int),
}
expValues := doc.Ints("exp reward table")
if len(expValues) == 0 || len(expValues)%2 != 0 {
return questExperienceRules{}, fmt.Errorf("%w: %s [exp reward table] integers=%d", ErrTableMalformed, QuestParameterPath, len(expValues))
}
rules.baseByLevel = make([]uint32, 0, len(expValues)/2)
for index := 0; index < len(expValues); index += 2 {
value := expValues[index]
if value < 0 || value > math.MaxUint32 {
return questExperienceRules{}, fmt.Errorf("%w: %s quest base=%d", ErrTableMalformed, QuestParameterPath, value)
}
rules.baseByLevel = append(rules.baseByLevel, uint32(value))
}
goldValues := doc.Ints("gold reward table")
if len(goldValues) == 0 {
return questExperienceRules{}, fmt.Errorf("%w: %s [gold reward table] integers=%d", ErrTableMalformed, QuestParameterPath, len(goldValues))
}
rules.goldByLevel = make([]uint32, 0, len(goldValues))
for index := 0; index < len(goldValues); index++ {
value := goldValues[index]
if value < 0 || value > math.MaxUint32 {
return questExperienceRules{}, fmt.Errorf("%w: %s quest gold=%d", ErrTableMalformed, QuestParameterPath, value)
}
rules.goldByLevel = append(rules.goldByLevel, uint32(value))
}
difficultyTokens := sectionTokens(doc, "difficulty")
for index := 0; index < len(difficultyTokens); index++ {
token := difficultyTokens[index]
if token.Kind != dnfpvf.TokenString && token.Kind != dnfpvf.TokenIdent {
continue
}
key := strings.TrimSpace(token.Value)
r, width := utf8.DecodeRuneInString(key)
if r == utf8.RuneError || width != len(key) {
continue
}
for next := index + 1; next < len(difficultyTokens); next++ {
if difficultyTokens[next].Kind != dnfpvf.TokenInt {
continue
}
value := difficultyTokens[next].Int
if value <= 0 || value > math.MaxInt {
return questExperienceRules{}, fmt.Errorf("%w: %s difficulty=%q weight=%d", ErrTableMalformed, QuestParameterPath, key, value)
}
rules.difficultyWeight[normalizeDifficulty(r)] = int(value)
index = next
break
}
}
if len(rules.difficultyWeight) == 0 {
return questExperienceRules{}, fmt.Errorf("%w: %s [difficulty]", ErrTableEmpty, QuestParameterPath)
}
green, ok := doc.Int("green level penalty")
if !ok || green < 0 || green > 100 {
return questExperienceRules{}, fmt.Errorf("%w: %s green penalty=%d present=%t", ErrTableMalformed, QuestParameterPath, green, ok)
}
grey, ok := doc.Int("grey level penalty")
if !ok || grey < 0 || grey > 100 {
return questExperienceRules{}, fmt.Errorf("%w: %s grey penalty=%d present=%t", ErrTableMalformed, QuestParameterPath, grey, ok)
}
rules.greenPenalty = int(green)
rules.greyPenalty = int(grey)
return rules, nil
}
func normalizeDifficulty(value rune) rune {
return []rune(strings.ToUpper(string(value)))[0]
}
@@ -0,0 +1,138 @@
package progression
import (
"fmt"
"math"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type SkillPointAdvance struct {
Previous dnfrepo.SkillPointState
New dnfrepo.SkillPointState
SPGain int
TPGain int
}
func (t *Tables) SkillPointsAtLevel(level int) (int, bool) {
if t == nil {
return 0, false
}
value, ok := t.spByLevel[level]
return value, ok
}
func (t *Tables) TechniquePointsAtLevel(level int) (int, bool) {
if t == nil {
return 0, false
}
value, ok := t.tpByLevel[level]
return value, ok
}
// TotalSkillPoints returns the cumulative PVF SP through level. A missing row
// is an error rather than an implicit zero because silently skipping a runtime
// PVF level would permanently underpay a character.
func (t *Tables) TotalSkillPoints(level int) (int, error) {
if t == nil || level <= 0 {
return 0, fmt.Errorf("%w: SP level=%d", ErrLevelOutOfRange, level)
}
total := 0
for current := 1; current <= level; current++ {
award, ok := t.spByLevel[current]
if !ok {
return 0, fmt.Errorf("%w: %s missing level=%d", ErrTableMalformed, SkillPointTablePath, current)
}
if award > math.MaxInt-total {
return 0, fmt.Errorf("%w: %s cumulative SP overflow at level=%d", ErrTableMalformed, SkillPointTablePath, current)
}
total += award
}
return total, nil
}
// TotalTechniquePoints returns the cumulative PVF TP through level. Levels
// below the first TP row award zero; once the table starts, every crossed
// level must have an explicit row so a malformed runtime PVF cannot silently
// underpay the ledger.
func (t *Tables) TotalTechniquePoints(level int) (int, error) {
if t == nil || level <= 0 {
return 0, fmt.Errorf("%w: TP level=%d", ErrLevelOutOfRange, level)
}
firstLevel := t.firstTechniquePointLevel()
if firstLevel == 0 || level < firstLevel {
return 0, nil
}
total := 0
for current := firstLevel; current <= level; current++ {
award, ok := t.tpByLevel[current]
if !ok {
return 0, fmt.Errorf("%w: %s [tp table] missing level=%d", ErrTableMalformed, SkillPointTablePath, current)
}
if award > math.MaxInt-total {
return 0, fmt.Errorf("%w: %s cumulative TP overflow at level=%d", ErrTableMalformed, SkillPointTablePath, current)
}
total += award
}
return total, nil
}
func (t *Tables) firstTechniquePointLevel() int {
first := 0
if t == nil {
return first
}
for level := range t.tpByLevel {
if first == 0 || level < first {
first = level
}
}
return first
}
// AdvanceSkillPoints adds only the PVF awards for levels crossed since the
// ledger's SyncedLevel. Both total and remaining SP grow by the same delta, so
// previously spent SP is preserved exactly.
func (t *Tables) AdvanceSkillPoints(points dnfrepo.SkillPointState, newLevel int) (SkillPointAdvance, error) {
result := SkillPointAdvance{Previous: points, New: points}
if points.SyncedLevel <= 0 || newLevel < points.SyncedLevel ||
points.TotalSP < 0 || points.RemainingSP < 0 || points.RemainingSP > points.TotalSP ||
points.TotalTP < 0 || points.RemainingTP < 0 || points.RemainingTP > points.TotalTP {
return SkillPointAdvance{}, fmt.Errorf("%w: target_level=%d ledger=%+v", ErrSkillPointLedger, newLevel, points)
}
spDelta := 0
tpDelta := 0
firstTPLevel := t.firstTechniquePointLevel()
for level := points.SyncedLevel + 1; level <= newLevel; level++ {
award, ok := t.SkillPointsAtLevel(level)
if !ok {
return SkillPointAdvance{}, fmt.Errorf("%w: %s missing level=%d", ErrTableMalformed, SkillPointTablePath, level)
}
if award > math.MaxInt-spDelta {
return SkillPointAdvance{}, fmt.Errorf("%w: %s SP delta overflow at level=%d", ErrTableMalformed, SkillPointTablePath, level)
}
spDelta += award
if firstTPLevel != 0 && level >= firstTPLevel {
tpAward, ok := t.TechniquePointsAtLevel(level)
if !ok {
return SkillPointAdvance{}, fmt.Errorf("%w: %s [tp table] missing level=%d", ErrTableMalformed, SkillPointTablePath, level)
}
if tpAward > math.MaxInt-tpDelta {
return SkillPointAdvance{}, fmt.Errorf("%w: %s TP delta overflow at level=%d", ErrTableMalformed, SkillPointTablePath, level)
}
tpDelta += tpAward
}
}
if spDelta > math.MaxInt-points.TotalSP || spDelta > math.MaxInt-points.RemainingSP ||
tpDelta > math.MaxInt-points.TotalTP || tpDelta > math.MaxInt-points.RemainingTP {
return SkillPointAdvance{}, fmt.Errorf("%w: SP/TP ledger overflow sp_delta=%d tp_delta=%d ledger=%+v", ErrSkillPointLedger, spDelta, tpDelta, points)
}
result.SPGain = spDelta
result.TPGain = tpDelta
result.New.TotalSP += spDelta
result.New.RemainingSP += spDelta
result.New.TotalTP += tpDelta
result.New.RemainingTP += tpDelta
result.New.SyncedLevel = newLevel
return result, nil
}
@@ -0,0 +1,199 @@
package progression
import (
"context"
"errors"
"fmt"
"math"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
ExperienceTablePath = "character/ExpTable.tbl"
SkillPointTablePath = "Etc/spTable.etc"
QuestParameterPath = "n_quest/questParameter.etc"
// MaximumCharacterLevel is the absolute playable level ceiling for the
// current DNF90 compatibility unit. Runtime PVF tables intentionally retain
// later rows, so their length must never be treated as authorization to
// persist level 91 or above.
MaximumCharacterLevel = 90
// CharacterLevelCap 是 Server-90 对同一个上限使用的名字。
// 支援兵(companion)模块是从 Server-90 移植过来的,引用的是这个名字;
// 这里做成别名,既不用改移植进来的代码,也保证两边永远同值。
CharacterLevelCap = MaximumCharacterLevel
)
var (
ErrTableEmpty = errors.New("dnf progression PVF table is empty")
ErrTableMalformed = errors.New("dnf progression PVF table is malformed")
ErrLevelOutOfRange = errors.New("dnf progression level is outside the loaded PVF table")
ErrExperienceOutOfRange = errors.New("dnf progression experience is outside the current EXE u32 range")
ErrSkillPointLedger = errors.New("dnf progression skill-point ledger is invalid")
ErrQuestDifficulty = errors.New("dnf progression quest difficulty is absent from PVF")
ErrQuestExperienceLookup = errors.New("dnf progression quest experience level is absent from PVF")
)
// Tables is an immutable snapshot of the current runtime PVF progression data.
type Tables struct {
experienceThresholds []uint32
spByLevel map[int]int
tpByLevel map[int]int
quest questExperienceRules
}
type Snapshot struct {
ExperienceThresholds int
SkillPointLevels int
TechniquePointLevels int
QuestExperienceLevels int
QuestGoldLevels int
QuestDifficulties int
}
// Load reads only the three typed progression files. No C# constants or packet
// captures are used as data fallbacks.
func Load(ctx context.Context, source dnfpvf.Source) (*Tables, error) {
if source == nil {
return nil, dnfpvf.ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
experience, err := LoadExperienceTable(ctx, source)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
spDoc, err := readDocument(source, SkillPointTablePath)
if err != nil {
return nil, err
}
spByLevel, tpByLevel, err := parseSkillPointTable(spDoc)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
questDoc, err := readDocument(source, QuestParameterPath)
if err != nil {
return nil, err
}
quest, err := parseQuestExperienceRules(questDoc)
if err != nil {
return nil, err
}
return &Tables{
experienceThresholds: append([]uint32(nil), experience.thresholds...),
spByLevel: spByLevel,
tpByLevel: tpByLevel,
quest: quest,
}, nil
}
func (t *Tables) Snapshot() Snapshot {
if t == nil {
return Snapshot{}
}
return Snapshot{
ExperienceThresholds: len(t.experienceThresholds),
SkillPointLevels: len(t.spByLevel),
TechniquePointLevels: len(t.tpByLevel),
QuestExperienceLevels: len(t.quest.baseByLevel),
QuestGoldLevels: len(t.quest.goldByLevel),
QuestDifficulties: len(t.quest.difficultyWeight),
}
}
func readDocument(source dnfpvf.Source, path string) (*dnfpvf.Document, error) {
text, err := source.ReadText(path)
if err != nil {
return nil, fmt.Errorf("read dnf progression PVF %q: %w", path, err)
}
doc, err := dnfpvf.Parse(path, text)
if err != nil {
return nil, fmt.Errorf("parse dnf progression PVF %q: %w", path, err)
}
return doc, nil
}
func parseExperienceThresholds(doc *dnfpvf.Document) ([]uint32, error) {
if doc == nil {
return nil, fmt.Errorf("%w: %s", ErrTableEmpty, ExperienceTablePath)
}
values := make([]uint32, 0, len(doc.Tokens))
for _, token := range doc.Tokens {
if token.Kind != dnfpvf.TokenInt {
continue
}
if token.Int < 0 || token.Int > math.MaxUint32 {
return nil, fmt.Errorf("%w: %s value=%d", ErrExperienceOutOfRange, ExperienceTablePath, token.Int)
}
value := uint32(token.Int)
// The runtime table uses repeated MaxInt32 sentinels above its playable
// range. Equal adjacent sentinels are valid; a decrease is not.
if len(values) > 0 && value < values[len(values)-1] {
return nil, fmt.Errorf("%w: %s threshold[%d]=%d previous=%d", ErrTableMalformed, ExperienceTablePath, len(values), value, values[len(values)-1])
}
values = append(values, value)
}
if len(values) == 0 {
return nil, fmt.Errorf("%w: %s", ErrTableEmpty, ExperienceTablePath)
}
return values, nil
}
func parseSkillPointTable(doc *dnfpvf.Document) (map[int]int, map[int]int, error) {
if doc == nil {
return nil, nil, fmt.Errorf("%w: %s", ErrTableEmpty, SkillPointTablePath)
}
sp, err := parseSkillPointSection(doc, "sp table")
if err != nil {
return nil, nil, err
}
tp, err := parseSkillPointSection(doc, "tp table")
if err != nil {
return nil, nil, err
}
return sp, tp, nil
}
func parseSkillPointSection(doc *dnfpvf.Document, section string) (map[int]int, error) {
values := doc.Ints(section)
if len(values) == 0 {
return nil, fmt.Errorf("%w: %s [%s]", ErrTableEmpty, SkillPointTablePath, section)
}
if len(values)%2 != 0 {
return nil, fmt.Errorf("%w: %s [%s] integers=%d", ErrTableMalformed, SkillPointTablePath, section, len(values))
}
table := make(map[int]int, len(values)/2)
for index := 0; index < len(values); index += 2 {
levelValue, spValue := values[index], values[index+1]
if levelValue <= 0 || levelValue > math.MaxInt || spValue < 0 || spValue > math.MaxInt {
return nil, fmt.Errorf("%w: %s [%s] level=%d points=%d", ErrTableMalformed, SkillPointTablePath, section, levelValue, spValue)
}
level := int(levelValue)
if _, duplicate := table[level]; duplicate {
return nil, fmt.Errorf("%w: %s [%s] duplicate level=%d", ErrTableMalformed, SkillPointTablePath, section, level)
}
table[level] = int(spValue)
}
return table, nil
}
func sectionTokens(doc *dnfpvf.Document, name string) []dnfpvf.Token {
if doc == nil {
return nil
}
tokens, ok := doc.Section(name)
if !ok {
return nil
}
return tokens
}
@@ -0,0 +1,329 @@
package progression
import (
"context"
"errors"
"fmt"
"math"
"os"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestPVFTablesDriveExperienceLevelUpsAndSPTP(t *testing.T) {
tables, err := Load(context.Background(), progressionTestSource())
if err != nil {
t.Fatal(err)
}
if got := tables.Snapshot(); got.ExperienceThresholds != 4 || got.SkillPointLevels != 5 ||
got.TechniquePointLevels != 3 || got.QuestExperienceLevels != 5 || got.QuestGoldLevels != 14 || got.QuestDifficulties != 2 {
t.Fatalf("snapshot = %+v", got)
}
experience, err := tables.ApplyExperience(1, 90, 180, 5)
if err != nil {
t.Fatal(err)
}
if experience.NewLevel != 3 || experience.NewExperience != 270 || experience.LevelsGained != 2 || experience.Saturated {
t.Fatalf("experience result = %+v", experience)
}
points := dnfrepo.SkillPointState{
TotalSP: 40, RemainingSP: 15, TotalTP: 3, RemainingTP: 1, SyncedLevel: 2,
}
advance, err := tables.AdvanceSkillPoints(points, experience.NewLevel)
if err != nil {
t.Fatal(err)
}
if advance.SPGain != 30 || advance.New.TotalSP != 70 || advance.New.RemainingSP != 45 || advance.New.SyncedLevel != 3 {
t.Fatalf("SP advance = %+v", advance)
}
if advance.TPGain != 2 || advance.New.TotalTP != 5 || advance.New.RemainingTP != 3 {
t.Fatalf("TP advance = %+v", advance)
}
if spentBefore, spentAfter := points.TotalSP-points.RemainingSP, advance.New.TotalSP-advance.New.RemainingSP; spentBefore != spentAfter {
t.Fatalf("spent SP changed: before=%d after=%d", spentBefore, spentAfter)
}
}
func TestExperienceTableReturnsCumulativeLevelEntryThreshold(t *testing.T) {
table, err := LoadExperienceTable(context.Background(), progressionTestSource())
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
level int
want uint32
}{{1, 0}, {2, 100}, {3, 250}, {5, 900}} {
got, err := table.EntryExperience(test.level)
if err != nil || got != test.want {
t.Fatalf("EntryExperience(%d)=%d error=%v, want=%d", test.level, got, err, test.want)
}
}
if _, err := table.EntryExperience(6); !errors.Is(err, ErrLevelOutOfRange) {
t.Fatalf("EntryExperience(6) error=%v, want ErrLevelOutOfRange", err)
}
}
func TestPlanExperienceAndSkillPointsCouplesLevelAndSPTP(t *testing.T) {
tables, err := Load(context.Background(), progressionTestSource())
if err != nil {
t.Fatal(err)
}
points := dnfrepo.SkillPointState{TotalSP: 40, RemainingSP: 15, TotalTP: 3, RemainingTP: 1, SyncedLevel: 2}
plan, err := tables.PlanExperienceAndSkillPoints(2, 240, 300, 5, points)
if err != nil {
t.Fatal(err)
}
if plan.Experience.NewLevel != 4 || plan.Experience.NewExperience != 540 {
t.Fatalf("experience = %+v", plan.Experience)
}
if plan.SkillPoints.SPGain != 70 || plan.SkillPoints.New.TotalSP != 110 || plan.SkillPoints.New.RemainingSP != 85 || plan.SkillPoints.New.SyncedLevel != 4 {
t.Fatalf("skill points = %+v", plan.SkillPoints)
}
if plan.SkillPoints.TPGain != 3 || plan.SkillPoints.New.TotalTP != 6 || plan.SkillPoints.New.RemainingTP != 4 {
t.Fatalf("technique points = %+v", plan.SkillPoints)
}
if _, err := tables.PlanExperienceAndSkillPoints(2, 240, 300, 5, dnfrepo.SkillPointState{SyncedLevel: 1}); !errors.Is(err, ErrSkillPointLedger) {
t.Fatalf("stale ledger error = %v", err)
}
}
func TestApplyExperienceSaturatesCurrentEXEU32(t *testing.T) {
tables, err := Load(context.Background(), progressionTestSource())
if err != nil {
t.Fatal(err)
}
result, err := tables.ApplyExperience(4, math.MaxUint32-3, 10, 5)
if err != nil {
t.Fatal(err)
}
if result.NewExperience != 900 || result.NewLevel != 5 || !result.Saturated {
t.Fatalf("result = %+v", result)
}
}
func TestApplyExperiencePinsLegacyOverflowAtMaximumLevelEntry(t *testing.T) {
tables, err := Load(context.Background(), progressionTestSource())
if err != nil {
t.Fatal(err)
}
result, err := tables.ApplyExperience(5, 10_000, 20, 5)
if err != nil {
t.Fatal(err)
}
if result.PreviousExperience != 10_000 || result.NewExperience != 900 ||
result.NewLevel != 5 || !result.Saturated {
t.Fatalf("result = %+v", result)
}
}
func TestApplyExperienceRejectsCallerCapAboveDNF90Ceiling(t *testing.T) {
tables, err := Load(context.Background(), progressionTestSource())
if err != nil {
t.Fatal(err)
}
if _, err := tables.ApplyExperience(1, 0, 100, MaximumCharacterLevel+1); !errors.Is(err, ErrLevelOutOfRange) {
t.Fatalf("ApplyExperience() error = %v, want ErrLevelOutOfRange", err)
}
}
func TestProgressionRejectsMissingSPLevelInsteadOfUnderpaying(t *testing.T) {
source := progressionTestSource()
source[SkillPointTablePath] = "[sp table]\n1 10\n3 30\n[/sp table]\n[tp table]\n3 1\n[/tp table]\n"
tables, err := Load(context.Background(), source)
if err != nil {
t.Fatal(err)
}
if _, err := tables.TotalSkillPoints(3); !errors.Is(err, ErrTableMalformed) {
t.Fatalf("TotalSkillPoints error = %v, want ErrTableMalformed", err)
}
points := dnfrepo.SkillPointState{TotalSP: 10, RemainingSP: 10, SyncedLevel: 1}
if _, err := tables.AdvanceSkillPoints(points, 3); !errors.Is(err, ErrTableMalformed) {
t.Fatalf("AdvanceSkillPoints error = %v, want ErrTableMalformed", err)
}
}
func TestQuestExperienceUsesPVFDifficultyAndPenalty(t *testing.T) {
tables, err := Load(context.Background(), progressionTestSource())
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
playerLevel int
questLevel int
difficulty rune
ignore bool
want uint32
}{
{name: "same level", playerLevel: 1, questLevel: 1, difficulty: 'N', want: 100},
{name: "green penalty", playerLevel: 8, questLevel: 1, difficulty: 'N', want: 80},
{name: "grey penalty", playerLevel: 13, questLevel: 1, difficulty: 'E', want: 60},
{name: "ignore level uses player row", playerLevel: 5, questLevel: 1, difficulty: 'E', ignore: true, want: 1000},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := tables.QuestExperience(test.playerLevel, test.questLevel, test.difficulty, test.ignore)
if err != nil {
t.Fatal(err)
}
if got != test.want {
t.Fatalf("QuestExperience = %d, want %d", got, test.want)
}
})
}
if _, err := tables.QuestExperience(1, 1, 'X', false); !errors.Is(err, ErrQuestDifficulty) {
t.Fatalf("unknown difficulty error = %v", err)
}
}
func TestQuestGoldUsesPVFBaseMultiplierBeforeIntegerDivision(t *testing.T) {
tables, err := Load(context.Background(), progressionTestSource())
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
playerLevel int
questLevel int
goldMultiple int
ignore bool
want uint32
}{
{name: "default hundred percent keeps low base", playerLevel: 1, questLevel: 1, want: 10},
{name: "explicit hundred fifty percent", playerLevel: 1, questLevel: 1, goldMultiple: 150, want: 15},
{name: "direct zero-based quest-level row", playerLevel: 3, questLevel: 3, want: 30},
{name: "green level gap does not penalize gold", playerLevel: 8, questLevel: 1, want: 10},
{name: "grey level gap does not penalize gold", playerLevel: 13, questLevel: 1, want: 10},
{name: "ignore EXP level remains on quest gold row", playerLevel: 8, questLevel: 1, goldMultiple: 150, ignore: true, want: 15},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := tables.QuestGold(test.playerLevel, test.questLevel, test.goldMultiple, test.ignore)
if err != nil {
t.Fatal(err)
}
if got != test.want {
t.Fatalf("QuestGold = %d, want %d", got, test.want)
}
})
}
}
func TestRealScriptPVFProgressionTables(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify real progression tables")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
tables, err := Load(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
experienceTable, err := LoadExperienceTable(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
snapshot := tables.Snapshot()
if snapshot.ExperienceThresholds < 90 || snapshot.SkillPointLevels < 90 || snapshot.TechniquePointLevels < 41 ||
snapshot.QuestExperienceLevels < 90 || snapshot.QuestGoldLevels != 200 || snapshot.QuestDifficulties == 0 {
t.Fatalf("real progression snapshot = %+v", snapshot)
}
thresholds := []uint32{1000, 2035, 3315}
for level, want := range thresholds {
got, err := tables.ThresholdToNext(level + 1)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("real threshold level %d = %d, want %d", level+1, got, want)
}
}
if value, ok := tables.SkillPointsAtLevel(1); !ok || value != 0 {
t.Fatalf("real level-1 SP = %d,%t, want 0,true", value, ok)
}
if value, ok := tables.SkillPointsAtLevel(2); !ok || value != 30 {
t.Fatalf("real level-2 SP = %d,%t, want 30,true", value, ok)
}
advance, err := tables.AdvanceSkillPoints(dnfrepo.SkillPointState{SyncedLevel: 1}, 2)
if err != nil {
t.Fatal(err)
}
if advance.SPGain != 30 || advance.New.TotalSP != 30 || advance.New.RemainingSP != 30 {
t.Fatalf("real SP advance = %+v", advance)
}
if totalTP, err := tables.TotalTechniquePoints(90); err != nil || totalTP != 41 {
t.Fatalf("real level-90 TP total=%d err=%v, want 41", totalTP, err)
}
level90Entry, err := experienceTable.EntryExperience(90)
if err != nil {
t.Fatal(err)
}
level89Threshold, err := tables.ThresholdToNext(89)
if err != nil {
t.Fatal(err)
}
if level90Entry != level89Threshold || level90Entry == 0 {
t.Fatalf("real level-90 entry EXP=%d threshold89=%d", level90Entry, level89Threshold)
}
for _, test := range []struct {
name string
playerLevel int
questLevel int
goldMultiple int
ignore bool
want uint32
}{
{name: "level60 ten percent", playerLevel: 90, questLevel: 60, goldMultiple: 10, want: 442},
{name: "level60 ignores player gap", playerLevel: 72, questLevel: 60, goldMultiple: 10, want: 442},
{name: "level60 ignores EXP lookup flag", playerLevel: 90, questLevel: 60, goldMultiple: 10, ignore: true, want: 442},
{name: "level90 full value", playerLevel: 90, questLevel: 90, goldMultiple: 100, want: 8208},
{name: "level118 full value", playerLevel: 90, questLevel: 118, goldMultiple: 100, want: 16272},
} {
t.Run("quest gold "+test.name, func(t *testing.T) {
got, err := tables.QuestGold(test.playerLevel, test.questLevel, test.goldMultiple, test.ignore)
if err != nil || got != test.want {
t.Fatalf("real QuestGold=%d err=%v want=%d", got, err, test.want)
}
})
}
t.Logf("real progression snapshot=%+v level2_threshold=%d level2_sp=%d", snapshot, thresholds[0], advance.SPGain)
}
type progressionMemorySource map[string]string
func (s progressionMemorySource) ReadText(path string) (string, error) {
value, ok := s[path]
if !ok {
return "", fmt.Errorf("%w: %s", platformpvf.ErrFileNotFound, path)
}
return value, nil
}
func progressionTestSource() progressionMemorySource {
return progressionMemorySource{
ExperienceTablePath: "100 250 500 900\n",
SkillPointTablePath: "[sp table]\n1 10\n2 30\n3 30\n4 40\n5 50\n[/sp table]\n[tp table]\n3 2\n4 1\n5 1\n[/tp table]\n",
QuestParameterPath: `[difficulty]
` + "`N` 100\n`E` 200\n" + `[/difficulty]
[exp reward table]
100 -1
200 -1
300 -1
400 -1
500 -1
[gold reward table]
0 10 20 30 40 50 60 70 80 90 100 110 120 130
[green level penalty]
80
[grey level penalty]
30
`,
}
}