DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,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
|
||||
`,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user