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

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

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...)
}