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