Files
90CNServer/server/internal/modules/dnf/skillcmd/owner_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

2230 lines
77 KiB
Go

package skillcmd
import (
"context"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
)
func TestOwnerPlanReadsSkillState(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "31",
AccountID: "acc-skill",
}); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{
CharacterID: "31",
Skills: map[int64]dnfrepo.SkillState{
7: {Level: 3, Enabled: true},
8: {Level: 1, Enabled: true},
},
Cooldowns: map[int64]time.Time{7: time.Unix(100, 0)},
}); err != nil {
t.Fatalf("save skill: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.Plan(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
RawSkillTree: 0,
Count: 2,
Entries: []BuySkillEntry{
{SkillID: 7, LevelDelta: 3},
{SkillID: 8, RefundFlag: 1, LevelDelta: 1},
},
}))
if err != nil {
t.Fatalf("Plan() error = %v", err)
}
if got.AccountID != "acc-skill" || got.CharacterID != "31" || !got.Known || got.SkillCount != 2 || got.CooldownCount != 1 || got.RefundCount != 1 {
t.Fatalf("result = %+v", got)
}
if len(got.RequestedSkillIDs) != 2 || got.RequestedSkillIDs[0] != 7 || got.RequestedSkillIDs[1] != 8 {
t.Fatalf("requested skill IDs = %+v", got.RequestedSkillIDs)
}
}
func TestOwnerApplyTreeSwitchPersistsOppositeTreeAndAcceptsIdempotentReplay(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
command := NewTreeCommand(alignedRequest("acc-skill", 31), ChangeAnotherSkillTreeRequest{SkillTree: 0})
result, err := owner.ApplyTreeSwitch(ctx, command)
if err != nil {
t.Fatalf("ApplyTreeSwitch() error = %v", err)
}
if result.CharacterID != "31" || result.Current != 0 || result.Target != 1 {
t.Fatalf("result = %+v", result)
}
character, ok, err := repos.Character.Load(ctx, "31")
if err != nil || !ok || character.Stats[currentEXESkillTreeIndexStat] != 1 {
t.Fatalf("persisted character ok=%t err=%v record=%+v", ok, err, character)
}
replayed, err := owner.ApplyTreeSwitch(ctx, command)
if err != nil {
t.Fatalf("replayed ApplyTreeSwitch() error = %v", err)
}
if replayed.Current != 0 || replayed.Target != 1 {
t.Fatalf("replayed result = %+v", replayed)
}
character, _, _ = repos.Character.Load(ctx, "31")
if character.Stats[currentEXESkillTreeIndexStat] != 1 {
t.Fatalf("replayed switch persisted tree = %d", character.Stats[currentEXESkillTreeIndexStat])
}
}
func TestOwnerApplyTreeSwitchRequiresOwnedTreeMarker(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
character, _, _ := repos.Character.Load(ctx, "31")
delete(character.Stats, currentEXESkillTreeIndexStat)
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
_, err = owner.ApplyTreeSwitch(ctx, NewTreeCommand(alignedRequest("acc-skill", 31), ChangeAnotherSkillTreeRequest{SkillTree: 0}))
if !errors.Is(err, ErrSkillTreeUnavailable) {
t.Fatalf("ApplyTreeSwitch() error = %v, want %v", err, ErrSkillTreeUnavailable)
}
}
func TestOwnerPlanRejectsMissingCharacter(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
_, err = owner.Plan(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{SkillTree: 0, From: 1, To: 2}))
if !errors.Is(err, ErrCharacterNotFound) {
t.Fatalf("Plan() error = %v, want ErrCharacterNotFound", err)
}
}
func TestOwnerApplyBuyAllowsProvenTreeZeroAndCommitsAtomically(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
RawSkillTree: 0,
Count: 2,
FinalMode: 3,
Entries: []BuySkillEntry{
// The dependent skill can appear first because prerequisites are
// validated against the final state of the current EXE batch.
{SkillID: 47, LevelDelta: 1},
{SkillID: 46, LevelDelta: 1},
},
}))
if err != nil {
t.Fatalf("ApplyBuy() error = %v", err)
}
if result.Points.RemainingSP != 50 || result.SkillTree != 0 || result.FinalMode != 3 || len(result.Entries) != 2 {
t.Fatalf("mutation result = %+v", result)
}
if result.Entries[0].Slot != 1 || result.Entries[1].Slot != 0 {
t.Fatalf("tree-zero slots = %+v", result.Entries)
}
record, ok, err := repos.Skill.Load(ctx, "31")
if err != nil || !ok {
t.Fatalf("load skill ok=%t err=%v", ok, err)
}
if record.Skills[46].Level != 2 || record.Skills[47].Level != 1 || record.Points.RemainingSP != 50 {
t.Fatalf("persisted mutation = %+v", record)
}
if record.Layouts[0][0] != 46 || record.Layouts[0][1] != 47 {
t.Fatalf("persisted tree-zero layout = %+v", record.Layouts)
}
}
func TestOwnerApplySlotSwapsPersistedLayoutAtomically(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[47] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Layouts = map[int]dnfrepo.SkillLayout{0: {0: 46, 1: 47}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save skill layout: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
result, err := owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{
SkillTree: 0, From: 0, To: 1, ContextIndex: -1, Mode: 2,
}))
if err != nil {
t.Fatalf("ApplySlot() error = %v", err)
}
if result.FromSkillID != 46 || result.ToSkillID != 47 || !result.ToOccupied {
t.Fatalf("result = %+v", result)
}
persisted, ok, err := repos.Skill.Load(ctx, "31")
if err != nil || !ok {
t.Fatalf("load persisted layout ok=%t err=%v", ok, err)
}
if persisted.Layouts[0][0] != 47 || persisted.Layouts[0][1] != 46 {
t.Fatalf("persisted layout = %+v", persisted.Layouts[0])
}
}
func TestOwnerApplyResetRestoresPVFInitialStateAndRefundsPointsAtomically(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[46] = dnfrepo.SkillState{Level: 3, Enabled: true}
record.Skills[47] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Points.RemainingSP = 10
record.Points.RemainingTP = 4
record.Layouts = map[int]dnfrepo.SkillLayout{0: {0: 47, 1: 46}, 1: {0: 47}}
cooldown := time.Unix(1234, 0).UTC()
record.Cooldowns = map[int64]time.Time{46: cooldown}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
PointBaseline: &dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, TotalTP: 10, RemainingTP: 10, SyncedLevel: 10},
})
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyReset(ctx, NewResetCommand(alignedRequest("acc-skill", 31), SkillInitRequest{SkillTree: 0, Mode: 0}))
if err != nil {
t.Fatalf("ApplyReset() error = %v", err)
}
if result.SkillCount != 1 || result.Points.RemainingSP != 100 || result.Points.RemainingTP != 10 {
t.Fatalf("result = %+v", result)
}
persisted, ok, err := repos.Skill.Load(ctx, "31")
if err != nil || !ok {
t.Fatalf("load reset record ok=%t err=%v", ok, err)
}
if len(persisted.Skills) != 1 || persisted.Skills[46].Level != 1 {
t.Fatalf("reset skills = %+v", persisted.Skills)
}
if persisted.Points != result.Points || len(persisted.Layouts) != 2 || persisted.Layouts[0][0] != 46 || persisted.Layouts[1][0] != 47 {
t.Fatalf("reset points/layouts = %+v", persisted)
}
if !persisted.Cooldowns[46].Equal(cooldown) {
t.Fatalf("reset overwrote cooldowns = %+v", persisted.Cooldowns)
}
if persisted.UpdatedAt.IsZero() {
t.Fatal("reset did not update mutation timestamp")
}
}
func TestOwnerMutationsKeepSkillTreesIndependent(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
record.Layouts = map[int]dnfrepo.SkillLayout{
0: {0: 46},
1: {150: 46},
}
record.Trees = map[int]dnfrepo.SkillTreeState{
1: {
Skills: map[int64]dnfrepo.SkillState{46: {Level: 1, Enabled: true}},
Points: record.Points,
},
}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
treeZeroBefore, _ := dnfrepo.SkillTree(record, 0)
layoutZeroBefore := dnfrepo.CloneSkill(record).Layouts[0]
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
PointBaseline: &dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, TotalTP: 10, RemainingTP: 10, SyncedLevel: 10},
})
if err != nil {
t.Fatal(err)
}
buy, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
RawSkillTree: 1,
Count: 1,
Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}},
}))
if err != nil {
t.Fatalf("ApplyBuy(tree 1): %v", err)
}
if buy.Points.RemainingSP != 80 || len(buy.Entries) != 1 || buy.Entries[0].Slot != 150 {
t.Fatalf("tree 1 buy result = %+v", buy)
}
if _, err := owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{
SkillTree: 1, From: 150, To: 198, ContextIndex: -1, Mode: 0,
})); err != nil {
t.Fatalf("ApplySlot(tree 1): %v", err)
}
afterSlot, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
treeZeroAfterSlot, _ := dnfrepo.SkillTree(afterSlot, 0)
if !reflect.DeepEqual(treeZeroAfterSlot, treeZeroBefore) || !reflect.DeepEqual(afterSlot.Layouts[0], layoutZeroBefore) {
t.Fatalf("tree 1 mutations changed tree 0: before=%+v/%+v after=%+v/%+v", treeZeroBefore, layoutZeroBefore, treeZeroAfterSlot, afterSlot.Layouts[0])
}
treeOneAfterSlot, found := dnfrepo.SkillTree(afterSlot, 1)
if !found || treeOneAfterSlot.Skills[46].Level != 2 || treeOneAfterSlot.Points.RemainingSP != 80 || afterSlot.Layouts[1][198] != 46 {
t.Fatalf("tree 1 mutation was not persisted independently: tree=%+v layout=%+v", treeOneAfterSlot, afterSlot.Layouts[1])
}
if _, err := owner.ApplyReset(ctx, NewResetCommand(alignedRequest("acc-skill", 31), SkillInitRequest{SkillTree: 1, Mode: 0})); err != nil {
t.Fatalf("ApplyReset(tree 1): %v", err)
}
afterReset, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
treeZeroAfterReset, _ := dnfrepo.SkillTree(afterReset, 0)
treeOneAfterReset, found := dnfrepo.SkillTree(afterReset, 1)
if !reflect.DeepEqual(treeZeroAfterReset, treeZeroBefore) || !reflect.DeepEqual(afterReset.Layouts[0], layoutZeroBefore) {
t.Fatalf("tree 1 reset changed tree 0: before=%+v/%+v after=%+v/%+v", treeZeroBefore, layoutZeroBefore, treeZeroAfterReset, afterReset.Layouts[0])
}
if !found || treeOneAfterReset.Skills[46].Level != 1 || treeOneAfterReset.Points.RemainingSP != 100 || afterReset.Layouts[1][0] != 46 {
t.Fatalf("tree 1 reset state = %+v layout=%+v", treeOneAfterReset, afterReset.Layouts[1])
}
}
func TestOwnerApplyBuyAutoAssignsTreeOneActiveSkillToQuickbar(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
record.Layouts = map[int]dnfrepo.SkillLayout{
0: {0: 46},
1: {150: 46},
}
record.Trees = map[int]dnfrepo.SkillTreeState{
1: {
Skills: map[int64]dnfrepo.SkillState{46: {Level: 1, Enabled: true}},
Points: record.Points,
},
}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
treeZeroBefore, _ := dnfrepo.SkillTree(record, 0)
layoutZeroBefore := dnfrepo.CloneSkill(record).Layouts[0]
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
PointBaseline: &dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, TotalTP: 10, RemainingTP: 10, SyncedLevel: 10},
})
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
RawSkillTree: 1,
Count: 2,
Entries: []BuySkillEntry{
{SkillID: 47, LevelDelta: 1},
{SkillID: 46, LevelDelta: 1},
},
}))
if err != nil {
t.Fatalf("ApplyBuy(tree 1): %v", err)
}
if len(result.Entries) != 2 || result.Entries[0].SkillID != 47 || result.Entries[0].Slot != 0 || result.Entries[1].Slot != 150 {
t.Fatalf("tree 1 buy slots = %+v", result.Entries)
}
persisted, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
treeZeroAfter, _ := dnfrepo.SkillTree(persisted, 0)
if !reflect.DeepEqual(treeZeroAfter, treeZeroBefore) || !reflect.DeepEqual(persisted.Layouts[0], layoutZeroBefore) {
t.Fatalf("tree 1 auto assignment changed tree 0: before=%+v/%+v after=%+v/%+v", treeZeroBefore, layoutZeroBefore, treeZeroAfter, persisted.Layouts[0])
}
if persisted.Layouts[1][0] != 47 || persisted.Layouts[1][150] != 46 {
t.Fatalf("persisted tree 1 layout = %+v", persisted.Layouts[1])
}
}
func TestOwnerApplySlotMovesIntoEmptyQuickSlot(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Layouts = map[int]dnfrepo.SkillLayout{0: {0: 46}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save skill layout: %v", err)
}
owner, _ := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
result, err := owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{
SkillTree: 0, From: 0, To: 3, ContextIndex: -1, Mode: 0,
}))
if err != nil {
t.Fatalf("ApplySlot() error = %v", err)
}
if result.ToOccupied {
t.Fatalf("result = %+v, want empty destination", result)
}
persisted, _, _ := repos.Skill.Load(ctx, "31")
if _, exists := persisted.Layouts[0][0]; exists || persisted.Layouts[0][3] != 46 {
t.Fatalf("persisted layout = %+v", persisted.Layouts[0])
}
}
func TestOwnerApplySlotUsesClientSourceSlotVerbatim(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Layouts = map[int]dnfrepo.SkillLayout{0: {107: 46}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save skill layout: %v", err)
}
owner, _ := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
result, err := owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{
SkillTree: 0, From: 107, To: 198, ContextIndex: -1, Mode: 0,
}))
if err != nil {
t.Fatalf("ApplySlot() source-slot error = %v", err)
}
if result.From != 107 || result.To != 198 || result.FromSkillID != 46 || result.ToOccupied {
t.Fatalf("result = %+v", result)
}
persisted, _, _ := repos.Skill.Load(ctx, "31")
if _, exists := persisted.Layouts[0][107]; exists || persisted.Layouts[0][198] != 46 {
t.Fatalf("persisted layout = %+v", persisted.Layouts[0])
}
}
func TestOwnerApplySlotUsesBothQuickbarRowsVerbatim(t *testing.T) {
targets := []byte{0, 1, 2, 3, 4, 5, 198, 199, 200, 201, 202, 203}
for _, target := range targets {
t.Run(fmt.Sprintf("target_%d", target), func(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[49] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Layouts = map[int]dnfrepo.SkillLayout{0: {7: 49}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save live-shaped layout: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{
SkillTree: 0, From: 7, To: target, ContextIndex: -1, Mode: 0,
}))
if err != nil {
t.Fatalf("ApplySlot() live-shaped request: %v", err)
}
if result.From != 7 || result.To != target || result.FromSkillID != 49 {
t.Fatalf("result = %+v", result)
}
persisted, _, _ := repos.Skill.Load(ctx, "31")
if persisted.Layouts[0][int(target)] != 49 {
t.Fatalf("target=%d layout=%+v", target, persisted.Layouts[0])
}
if target == 0 {
if persisted.Layouts[0][7] != 46 {
t.Fatalf("source slot 7 did not receive displaced skill: %+v", persisted.Layouts[0])
}
} else if int(target) != 7 {
if _, exists := persisted.Layouts[0][7]; exists {
t.Fatalf("source slot 7 remained occupied after move: %+v", persisted.Layouts[0])
}
}
})
}
}
func TestOwnerApplySlotAcceptsRepeatedCurrentEXEQuickbarDrags(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[49] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Skills[47] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Layouts = map[int]dnfrepo.SkillLayout{0: {1: 46, 7: 49, 8: 47}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save current EXE layout: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
if err != nil {
t.Fatal(err)
}
for _, request := range []ChangeSkillSlotRequest{
{SkillTree: 0, From: 7, To: 1, ContextIndex: -1, Mode: 0},
{SkillTree: 0, From: 8, To: 199, ContextIndex: -1, Mode: 0},
} {
if _, err := owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), request)); err != nil {
t.Fatalf("ApplySlot(%d -> %d): %v", request.From, request.To, err)
}
}
persisted, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
if persisted.Layouts[0][1] != 49 || persisted.Layouts[0][7] != 46 || persisted.Layouts[0][199] != 47 {
t.Fatalf("repeated current EXE drags layout=%+v", persisted.Layouts[0])
}
}
func TestOwnerApplySlotMapsObservedTreeDragSourceOnlyIntoQuickbar(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[49] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Skills[47] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Layouts = map[int]dnfrepo.SkillLayout{0: {3: 49, 56: 46, 58: 47}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save tree layout: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
if err != nil {
t.Fatal(err)
}
for _, request := range []ChangeSkillSlotRequest{
{SkillTree: 0, From: 57, To: 199, ContextIndex: -1, Mode: 0},
{SkillTree: 0, From: 60, To: 3, ContextIndex: -1, Mode: 0},
} {
result, err := owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), request))
if err != nil {
t.Fatalf("ApplySlot(%d -> %d): %v", request.From, request.To, err)
}
if result.From != request.From || result.To != request.To {
t.Fatalf("ACK coordinates changed: result=%+v request=%+v", result, request)
}
ack, err := BuildChangeSkillSlotSuccess(result)
if err != nil {
t.Fatalf("BuildChangeSkillSlotSuccess(%+v): %v", result, err)
}
wantACK := []byte{1, request.SkillTree, request.From, request.To}
if !reflect.DeepEqual(ack, wantACK) {
t.Fatalf("ACK=%x want=%x", ack, wantACK)
}
}
persisted, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
if persisted.Layouts[0][199] != 46 || persisted.Layouts[0][3] != 47 || persisted.Layouts[0][60] != 49 {
t.Fatalf("tree-to-quickbar layout=%+v", persisted.Layouts[0])
}
if _, exists := persisted.Layouts[0][56]; exists {
t.Fatalf("first persisted tree source remained: %+v", persisted.Layouts[0])
}
if _, exists := persisted.Layouts[0][58]; exists {
t.Fatalf("second persisted tree source remained: %+v", persisted.Layouts[0])
}
}
func TestOwnerApplySlotConvergesTwoSlotTreeSourceOffset(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[47] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Layouts = map[int]dnfrepo.SkillLayout{0: {2: 46, 56: 47}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save drifted layout: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{
SkillTree: 0, From: 58, To: 2, ContextIndex: -1, Mode: 0,
}))
if err != nil {
t.Fatalf("ApplySlot(58 -> 2): %v", err)
}
if result.From != 58 || result.FromSkillID != 47 || result.ToSkillID != 46 {
t.Fatalf("result=%+v", result)
}
persisted, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
if persisted.Layouts[0][2] != 47 || persisted.Layouts[0][58] != 46 {
t.Fatalf("converged layout=%+v", persisted.Layouts[0])
}
if _, exists := persisted.Layouts[0][56]; exists {
t.Fatalf("stale source remained: %+v", persisted.Layouts[0])
}
}
func TestOwnerApplySlotConvergesClientAndPersistedSourceCoordinates(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[47] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Skills[49] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Layouts = map[int]dnfrepo.SkillLayout{0: {3: 49, 58: 47, 202: 46}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save drifted layout: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{
SkillTree: 0, From: 60, To: 3, ContextIndex: -1, Mode: 0,
}))
if err != nil {
t.Fatalf("ApplySlot(60 -> 3): %v", err)
}
if result.From != 60 || result.FromSkillID != 47 || result.ToSkillID != 49 {
t.Fatalf("result=%+v", result)
}
persisted, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
if persisted.Layouts[0][3] != 47 || persisted.Layouts[0][60] != 49 {
t.Fatalf("converged layout=%+v", persisted.Layouts[0])
}
if _, exists := persisted.Layouts[0][58]; exists {
t.Fatalf("stale source remained: %+v", persisted.Layouts[0])
}
}
func TestOwnerApplySlotKeepsNonQuickbarSourcesLiteral(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Layouts = map[int]dnfrepo.SkillLayout{0: {56: 46}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save tree layout: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
if err != nil {
t.Fatal(err)
}
_, err = owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{
SkillTree: 0, From: 57, To: 58, ContextIndex: -1, Mode: 0,
}))
if !errors.Is(err, ErrSkillSlot) {
t.Fatalf("ApplySlot() error = %v, want empty literal source", err)
}
}
func TestOwnerApplySlotRejectsUnprovenWriterFieldsWithoutMutation(t *testing.T) {
tests := []struct {
name string
req ChangeSkillSlotRequest
err error
}{
{name: "tree", req: ChangeSkillSlotRequest{SkillTree: 2, From: 0, To: 1, ContextIndex: -1, Mode: 0}, err: ErrSkillTree},
{name: "retired_normal_seventh", req: ChangeSkillSlotRequest{SkillTree: 0, From: 0, To: 204, ContextIndex: -1, Mode: 0}, err: ErrSkillSlot},
{name: "retired_extension_seventh", req: ChangeSkillSlotRequest{SkillTree: 0, From: 0, To: 205, ContextIndex: -1, Mode: 0}, err: ErrSkillSlot},
{name: "reserved_destination", req: ChangeSkillSlotRequest{SkillTree: 0, From: 0, To: 138, ContextIndex: -1, Mode: 0}, err: ErrSkillSlot},
{name: "context", req: ChangeSkillSlotRequest{SkillTree: 0, From: 0, To: 1, ContextIndex: 3, Mode: 0}, err: ErrSkillSlotContext},
{name: "mode", req: ChangeSkillSlotRequest{SkillTree: 0, From: 0, To: 1, ContextIndex: -1, Mode: 1}, err: ErrSkillSlotMode},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
before, _, _ := repos.Skill.Load(ctx, "31")
unitOfWork := &countingCharacterSkillUnitOfWork{delegate: repos.CharacterSkills}
repos.CharacterSkills = unitOfWork
owner, _ := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
_, err := owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), tt.req))
if !errors.Is(err, tt.err) {
t.Fatalf("ApplySlot() error = %v, want %v", err, tt.err)
}
if unitOfWork.calls != 0 {
t.Fatalf("unit of work calls = %d, want 0", unitOfWork.calls)
}
after, _, _ := repos.Skill.Load(ctx, "31")
if !reflect.DeepEqual(after, before) {
t.Fatalf("rejected request mutated record: before=%+v after=%+v", before, after)
}
})
}
}
func TestOwnerApplyBuyRejectsUnprovenTreesBeforeUnitOfWork(t *testing.T) {
tests := []struct {
name string
tree byte
}{
{name: "unknown_tree", tree: 2},
{name: "sentinel", tree: 0xff},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
before, ok, err := repos.Skill.Load(ctx, "31")
if err != nil || !ok {
t.Fatalf("load skill before rejection ok=%t err=%v", ok, err)
}
unitOfWork := &countingCharacterSkillUnitOfWork{delegate: repos.CharacterSkills}
repos.CharacterSkills = unitOfWork
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
_, err = owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
RawSkillTree: tt.tree,
Count: 1,
Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}},
}))
if !errors.Is(err, ErrSkillTree) {
t.Fatalf("ApplyBuy() error = %v, want ErrSkillTree", err)
}
if unitOfWork.calls != 0 {
t.Fatalf("CharacterSkillUnitOfWork calls = %d, want 0", unitOfWork.calls)
}
after, ok, loadErr := repos.Skill.Load(ctx, "31")
if loadErr != nil || !ok {
t.Fatalf("load skill after rejection ok=%t err=%v", ok, loadErr)
}
if !reflect.DeepEqual(after, before) {
t.Fatalf("rejected tree mutated skill state: before=%+v after=%+v", before, after)
}
})
}
}
func TestOwnerApplyBuyRejectsRefundBelowPVFInitialFloor(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
_, err = owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 46, RefundFlag: 1, LevelDelta: 1}},
}))
if !errors.Is(err, ErrSkillLevel) {
t.Fatalf("ApplyBuy() error = %v, want ErrSkillLevel", err)
}
record, _, _ := repos.Skill.Load(ctx, "31")
if record.Skills[46].Level != 1 || record.Points.RemainingSP != 100 {
t.Fatalf("rejected mutation changed state: %+v", record)
}
}
func TestOwnerApplyBuyUsesSpecialPurchaseCostForTPSkill(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{}})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 48, LevelDelta: 2}},
}))
if err != nil {
t.Fatalf("ApplyBuy() error = %v", err)
}
if result.Points.RemainingTP != 6 || result.Points.RemainingSP != 100 || len(result.Entries) != 1 || !result.Entries[0].TP {
t.Fatalf("TP mutation result = %+v", result)
}
if result.Entries[0].Slot != 1 {
t.Fatalf("tree-zero active slot = %d, want 1", result.Entries[0].Slot)
}
}
func TestOwnerApplyBuyRejectsStalePointLedger(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Points.SyncedLevel = 9
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save stale ledger: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{}})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
_, err = owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}},
}))
if !errors.Is(err, ErrSkillPointLedger) {
t.Fatalf("ApplyBuy() error = %v, want ErrSkillPointLedger", err)
}
}
func TestOwnerApplyBuyHydratesLegacyLedgerFromPVFBaseline(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[46] = dnfrepo.SkillState{Level: 2, Enabled: true}
record.Points = dnfrepo.SkillPointState{}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save legacy ledger: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
PointBaseline: &dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, TotalTP: 10, RemainingTP: 10, SyncedLevel: 10},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 47, LevelDelta: 1}},
}))
if err != nil {
t.Fatalf("ApplyBuy() error = %v", err)
}
if result.Points.RemainingSP != 50 || result.Points.SyncedLevel != 10 {
t.Fatalf("hydrated points = %+v", result.Points)
}
persisted, ok, err := repos.Skill.Load(ctx, "31")
if err != nil || !ok {
t.Fatalf("load persisted skill ok=%t err=%v", ok, err)
}
if persisted.Points != result.Points || persisted.Layouts[0][0] != 46 || persisted.Layouts[0][1] != 47 {
t.Fatalf("persisted hydrated record = %+v", persisted)
}
}
func TestOwnerApplyBuySynchronizesLevelGainFromPVFBaseline(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Points = dnfrepo.SkillPointState{TotalSP: 80, RemainingSP: 40, TotalTP: 8, RemainingTP: 5, SyncedLevel: 9}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save stale ledger: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
PointBaseline: &dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, TotalTP: 10, RemainingTP: 10, SyncedLevel: 10},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}},
}))
if err != nil {
t.Fatalf("ApplyBuy() error = %v", err)
}
if result.Points.RemainingSP != 40 || result.Points.RemainingTP != 7 || result.Points.TotalSP != 100 || result.Points.TotalTP != 10 || result.Points.SyncedLevel != 10 {
t.Fatalf("synchronized points = %+v", result.Points)
}
}
// TestSynchronizePointLedgerKeepsAndRestoresPersistedBookBonus pins the contract
// behind "+SP books stopped letting me learn skills". The baseline handed to the
// buy/reset path now carries the persisted bonus_sp/bonus_tp (see
// dnfbridge.skillOwnerRules), so synchronization has to (1) leave a book-bonused
// ledger alone instead of clawing the bonus back, and (2) hand the missing bonus
// back exactly once to a ledger an older build already stripped, then stay a
// no-op. Case 1 is the fix; case 2 is the repair for existing characters.
func TestSynchronizePointLedgerKeepsAndRestoresPersistedBookBonus(t *testing.T) {
const level = 90
// Etc/spTable.etc totals 1000 SP / 20 TP at level 90, plus a paid bonus of
// 180 SP / 4 TP granted by nine +20 SP books.
bonusBaseline := &dnfrepo.SkillPointState{
TotalSP: 1180, RemainingSP: 1180, TotalTP: 24, RemainingTP: 24, SyncedLevel: level,
}
t.Run("keeps the bonus on a matching ledger", func(t *testing.T) {
current := dnfrepo.SkillPointState{
TotalSP: 1180, RemainingSP: 220, TotalTP: 24, RemainingTP: 2, SyncedLevel: level,
}
got, err := synchronizePointLedger(current, bonusBaseline, level, nil, 0, nil, nil)
if err != nil {
t.Fatalf("synchronizePointLedger() error = %v", err)
}
if got != current {
t.Fatalf("points = %+v, want unchanged %+v", got, current)
}
})
t.Run("restores a ledger stripped by the older build", func(t *testing.T) {
// The older baseline omitted the bonus, so a purchase reset TotalSP to the
// PVF table and deducted the whole bonus from RemainingSP: 220 spendable SP
// became 40, which is exactly what the live server reported right before it
// refused a 200 SP skill for remaining=-160.
stripped := dnfrepo.SkillPointState{
TotalSP: 1000, RemainingSP: 40, TotalTP: 20, RemainingTP: 2, SyncedLevel: level,
}
want := dnfrepo.SkillPointState{
TotalSP: 1180, RemainingSP: 220, TotalTP: 24, RemainingTP: 6, SyncedLevel: level,
}
got, err := synchronizePointLedger(stripped, bonusBaseline, level, nil, 0, nil, nil)
if err != nil {
t.Fatalf("synchronizePointLedger() error = %v", err)
}
if got != want {
t.Fatalf("restored points = %+v, want %+v", got, want)
}
again, err := synchronizePointLedger(got, bonusBaseline, level, nil, 0, nil, nil)
if err != nil {
t.Fatalf("second synchronizePointLedger() error = %v", err)
}
if again != want {
t.Fatalf("second call points = %+v, want idempotent %+v", again, want)
}
})
}
// TestSkillPointTargetForCharacterAddsPersistedBookBonus pins the boundary
// between the two baseline contracts: OwnerOptions.PointBaseline stays the
// ordinary PVF target (the SP/TP book owner adds the bonus itself), and the
// skill plan path adds the persisted bonus here.
func TestSkillPointTargetForCharacterAddsPersistedBookBonus(t *testing.T) {
pvfBaseline := &dnfrepo.SkillPointState{
TotalSP: 1000, RemainingSP: 1000, TotalTP: 20, RemainingTP: 20, SyncedLevel: 90,
}
plain := dnfrepo.CharacterRecord{CharacterID: "77"}
withBonus := dnfrepo.CharacterRecord{
CharacterID: "77",
Stats: map[string]int64{"bonus_sp": 180, "bonus_tp": 4},
}
got, err := skillPointTargetForCharacter(pvfBaseline, withBonus)
if err != nil {
t.Fatalf("skillPointTargetForCharacter() error = %v", err)
}
want := dnfrepo.SkillPointState{
TotalSP: 1180, RemainingSP: 1000, TotalTP: 24, RemainingTP: 20, SyncedLevel: 90,
}
if got == nil || *got != want {
t.Fatalf("target = %+v, want %+v", got, want)
}
// The helper must not mutate the caller's baseline: the same pointer is
// shared with the book owner for the lifetime of one request.
if *pvfBaseline != (dnfrepo.SkillPointState{TotalSP: 1000, RemainingSP: 1000, TotalTP: 20, RemainingTP: 20, SyncedLevel: 90}) {
t.Fatalf("baseline was mutated: %+v", *pvfBaseline)
}
if got, err := skillPointTargetForCharacter(nil, withBonus); err != nil || got != nil {
t.Fatalf("nil baseline got=%+v err=%v, want nil/nil", got, err)
}
// A character that never used a book, and one with no stats map at all, must
// both fall through to the raw PVF target.
for name, character := range map[string]dnfrepo.CharacterRecord{"no bonus": plain, "no stats": {CharacterID: "77", Stats: map[string]int64{}}} {
got, err := skillPointTargetForCharacter(pvfBaseline, character)
if err != nil {
t.Fatalf("%s: error = %v", name, err)
}
if got == nil || *got != *pvfBaseline {
t.Fatalf("%s: target = %+v, want unchanged %+v", name, got, *pvfBaseline)
}
}
for _, bad := range []map[string]int64{{"bonus_sp": -1}, {"bonus_tp": -1}} {
if _, err := skillPointTargetForCharacter(pvfBaseline, dnfrepo.CharacterRecord{CharacterID: "77", Stats: bad}); !errors.Is(err, ErrSkillPointLedger) {
t.Fatalf("stats=%v error = %v, want ErrSkillPointLedger", bad, err)
}
}
}
// TestOwnerApplyBuyKeepsPersistedBookBonus is the end-to-end guard for "+SP books
// stopped letting me learn skills": a character that used +60 SP / +4 TP books
// must keep that bonus when it buys a skill, and a ledger an older build already
// stripped down to the raw PVF total must get the bonus handed back exactly once.
func TestOwnerApplyBuyKeepsPersistedBookBonus(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
character, found, err := repos.Character.Load(ctx, "31")
if err != nil || !found {
t.Fatalf("load character found=%t err=%v", found, err)
}
character.Stats["bonus_sp"] = 60
character.Stats["bonus_tp"] = 4
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatalf("save character: %v", err)
}
// An older build reset TotalSP to the PVF total and deducted the whole bonus
// from RemainingSP, so 100 spendable SP is displayed as 40.
record, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
record.Points = dnfrepo.SkillPointState{
TotalSP: 100, RemainingSP: 40, TotalTP: 10, RemainingTP: 2, SyncedLevel: 10,
}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
// The ordinary PVF target, WITHOUT the bonus: the book owner shares this.
PointBaseline: &dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, TotalTP: 10, RemainingTP: 10, SyncedLevel: 10},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}},
}))
if err != nil {
t.Fatalf("ApplyBuy() error = %v", err)
}
// skill 46 costs 20 at this level. 40 + 60 (bonus restored) - 20 = 80, and the
// totals must carry PVF 100/10 plus the bonus 60/4.
want := dnfrepo.SkillPointState{
TotalSP: 160, RemainingSP: 80, TotalTP: 14, RemainingTP: 6, SyncedLevel: 10,
}
if result.Points != want {
t.Fatalf("points = %+v, want %+v", result.Points, want)
}
persisted, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
if persisted.Points != want {
t.Fatalf("persisted points = %+v, want %+v", persisted.Points, want)
}
}
func TestBuildInitialSkillLayoutLimitsQuickbarToThreeActiveSkillsForEachTree(t *testing.T) {
catalog := skillRuleCatalog(t)
states := map[int64]dnfrepo.SkillState{
46: {Level: 1, Enabled: true},
47: {Level: 1, Enabled: true},
48: {Level: 1, Enabled: true},
49: {Level: 1, Enabled: true},
50: {Level: 1, Enabled: true},
}
want := dnfrepo.SkillLayout{0: 46, 1: 47, 2: 48, 54: 50, 150: 49}
for _, tree := range []int{0, 1} {
layout, err := BuildInitialSkillLayout(catalog, 0, tree, states)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(layout, want) {
t.Fatalf("tree %d layout=%v, want %v", tree, layout, want)
}
for slot := 3; slot < 6; slot++ {
if skillID, ok := layout[slot]; ok {
t.Fatalf("tree %d unexpected primary slot %d skill %d", tree, slot, skillID)
}
}
}
}
func TestBuildInitialSkillLayoutKeepsRealPVFGrantWithoutSkillDefinition(t *testing.T) {
catalog := skillRuleCatalog(t)
states := map[int64]dnfrepo.SkillState{
46: {Level: 1, Enabled: true},
777: {Level: 1, Enabled: true},
}
layout, err := BuildInitialSkillLayout(catalog, 0, 0, states)
if err != nil {
t.Fatal(err)
}
if layout[0] != 46 || layout[6] != 777 {
t.Fatalf("missing-definition PVF grant layout=%v, want active skill 46 and fallback skill 777", layout)
}
}
func TestBuildCurrentSkillLayoutMovesHistoricalPassiveOutOfQuickbar(t *testing.T) {
layout, err := BuildCurrentSkillLayout(skillRuleCatalog(t), 0, 0,
map[int64]dnfrepo.SkillState{46: {Level: 1, Enabled: true}, 50: {Level: 1, Enabled: true}},
dnfrepo.SkillLayout{0: 50, 1: 46})
if err != nil {
t.Fatal(err)
}
if layout[1] != 46 || layout[54] != 50 {
t.Fatalf("historical passive quickbar layout=%+v", layout)
}
if _, exists := layout[0]; exists {
t.Fatalf("passive remained in quickbar: %+v", layout)
}
}
func TestBuildCurrentSkillLayoutMigratesRetiredSeventhSlotsIntoLearnedActiveGroup(t *testing.T) {
states := map[int64]dnfrepo.SkillState{
46: {Level: 8, Enabled: true},
47: {Level: 1, Enabled: true},
}
existing := dnfrepo.SkillLayout{204: 46, 205: 47}
for slot := 0; slot < 6; slot++ {
skillID := uint16(100 + slot)
states[int64(skillID)] = dnfrepo.SkillState{Level: 1, Enabled: true}
existing[slot] = skillID
}
for slot := 198; slot < 204; slot++ {
skillID := uint16(200 + slot)
states[int64(skillID)] = dnfrepo.SkillState{Level: 1, Enabled: true}
existing[slot] = skillID
}
layout, err := BuildCurrentSkillLayout(skillRuleCatalog(t), 0, 0, states, existing)
if err != nil {
t.Fatal(err)
}
if layout[150] != 46 || layout[151] != 47 {
t.Fatalf("retired seventh-slot skills were not restored to learned active group: %v", layout)
}
for _, slot := range []int{204, 205} {
if _, exists := layout[slot]; exists {
t.Fatalf("retired slot %d survived stock-HUD repair: %v", slot, layout)
}
}
}
func TestBuildInitialDarkKnightSkillLayoutReservesASDFGHForCombos(t *testing.T) {
states := map[int64]dnfrepo.SkillState{
5: {Level: 1, Enabled: true},
8: {Level: 1, Enabled: true},
46: {Level: 1, Enabled: true},
118: {Level: 1, Enabled: true},
119: {Level: 1, Enabled: true},
}
layout, err := BuildInitialSkillLayout(darkKnightSkillRuleCatalog(t), 9, 0, states)
if err != nil {
t.Fatal(err)
}
if layout[198] != 5 || layout[199] != 8 || layout[200] != 46 {
t.Fatalf("dark knight QWERTY layout=%v, want skills 5/8/46 at 198/199/200", layout)
}
if layout[0] != 118 || layout[1] != 119 {
t.Fatalf("dark knight fixed combo roots=%v, want slots 0/1 = 118/119", layout)
}
for slot := 2; slot < currentEXEPrimaryQuickSlotEnd; slot++ {
if _, occupied := layout[slot]; occupied {
t.Fatalf("dark knight unlearned combo root slot %d occupied: %v", slot, layout)
}
}
}
func TestOwnerApplySlotRejectsDarkKnightOrdinarySkillIntoASDFGH(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "31",
AccountID: "acc-skill",
Job: "9",
Level: 10,
}); err != nil {
t.Fatal(err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{
CharacterID: "31",
Skills: map[int64]dnfrepo.SkillState{46: {Level: 1, Enabled: true}},
Points: dnfrepo.SkillPointState{SyncedLevel: 10},
Layouts: map[int]dnfrepo.SkillLayout{0: {198: 46}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: darkKnightSkillRuleCatalog(t)})
if err != nil {
t.Fatal(err)
}
_, err = owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{
SkillTree: 0, From: 198, To: 0, ContextIndex: -1, Mode: 0,
}))
if !errors.Is(err, ErrSkillSlot) {
t.Fatalf("ApplySlot() error=%v, want exclusive combo slot rejection", err)
}
persisted, found, loadErr := repos.Skill.Load(ctx, "31")
if loadErr != nil || !found || persisted.Layouts[0][198] != 46 {
t.Fatalf("persisted found=%t error=%v layout=%v", found, loadErr, persisted.Layouts[0])
}
if _, occupied := persisted.Layouts[0][0]; occupied {
t.Fatalf("ordinary skill entered dark knight ASDFGH: %v", persisted.Layouts[0])
}
}
func TestOwnerApplySlotRejectsPassiveSkillIntoQuickbar(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[50] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Layouts = map[int]dnfrepo.SkillLayout{0: {54: 50}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatalf("save passive layout: %v", err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1}})
if err != nil {
t.Fatal(err)
}
_, err = owner.ApplySlot(ctx, NewSlotCommand(alignedRequest("acc-skill", 31), ChangeSkillSlotRequest{
SkillTree: 0, From: 54, To: 0, ContextIndex: -1, Mode: 0,
}))
if !errors.Is(err, ErrSkillSlot) {
t.Fatalf("ApplySlot() error = %v, want passive quickbar rejection", err)
}
persisted, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
if persisted.Layouts[0][54] != 50 {
t.Fatalf("passive source changed after rejected move: %+v", persisted.Layouts[0])
}
if _, exists := persisted.Layouts[0][0]; exists {
t.Fatalf("passive was persisted in quickbar: %+v", persisted.Layouts[0])
}
}
func TestOwnerApplyCommandsRejectsUnknownSkillWithoutChangingStoredCommands(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
record.Commands = map[int64][]byte{46: {1, 2, 3}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
_, err = owner.ApplyCommands(ctx, Command{
AccountID: "acc-skill",
SelectedCharacterID: 31,
CommandRecords: []SkillCommandRecord{{
SkillID: 99,
CommandBytes: []byte{4},
}},
})
if !errors.Is(err, ErrSkillCommandUnknown) {
t.Fatalf("ApplyCommands() error = %v, want %v", err, ErrSkillCommandUnknown)
}
persisted, _, err := repos.Skill.Load(ctx, "31")
if err != nil || string(persisted.Commands[46]) != string([]byte{1, 2, 3}) || len(persisted.Commands) != 1 {
t.Fatalf("unknown command changed persistence: err=%v commands=%x", err, persisted.Commands)
}
}
func TestOwnerApplyCommandsValidatesOwnedSkillAcrossAllPages(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
record.Skills[248] = dnfrepo.SkillState{Level: 1, Enabled: true}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
_, err = owner.ApplyCommands(ctx, Command{
AccountID: "acc-skill",
SelectedCharacterID: 31,
CommandRecords: []SkillCommandRecord{{
SkillID: 248,
CommandBytes: []byte{3, 1, 8},
}},
})
if err != nil {
t.Fatalf("ApplyCommands() error = %v", err)
}
persisted, found, err := repos.Skill.Load(ctx, "31")
if err != nil || !found || string(persisted.Commands[248]) != string([]byte{3, 1, 8}) {
t.Fatalf("persisted found=%t err=%v command=% X", found, err, persisted.Commands[248])
}
}
func seededSkillOwnerRepositories(t *testing.T, ctx context.Context) dnfrepo.Group {
t.Helper()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "31",
AccountID: "acc-skill",
Job: "0",
Level: 10,
Stats: map[string]int64{"grow_type": 0, currentEXESkillTreeIndexStat: 0},
}); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{
CharacterID: "31",
Skills: map[int64]dnfrepo.SkillState{46: {Level: 1, Enabled: true}},
Points: dnfrepo.SkillPointState{
TotalSP: 100, RemainingSP: 100,
TotalTP: 10, RemainingTP: 10,
SyncedLevel: 10,
},
}); err != nil {
t.Fatalf("save skill: %v", err)
}
return repos
}
func skillRuleCatalog(t *testing.T) *dnfskill.Table {
t.Helper()
source := skillRuleTextSource{
"skill/skilllist.lst": "0 `SwordmanSkill.lst`\n",
"skill/SwordmanSkill.lst": "46 `Swordman/Base.skl`\n47 `Swordman/Dependent.skl`\n48 `Swordman/TP.skl`\n49 `Swordman/Fourth.skl`\n50 `Swordman/Passive.skl`\n51 `Swordman/HighLevel.skl`\n52 `Swordman/HighDep.skl`\n197 `Swordman/Conversion.skl`\n",
"skill/Swordman/Base.skl": `
[type]
` + "`[active]`" + `
[required level]
1
[maximum level]
20
[skill fitness growtype]
0
[/skill fitness growtype]
[purchase cost]
20
[/purchase cost]
`,
"skill/Swordman/Dependent.skl": `
[type]
` + "`[active]`" + `
[required level]
5
[maximum level]
10
[skill fitness growtype]
0
[/skill fitness growtype]
[pre required skill]
46 2
[/pre required skill]
[purchase cost]
30
[/purchase cost]
`,
"skill/Swordman/TP.skl": `
[type]
` + "`[active]`" + `
[required level]
1
[maximum level]
3
[skill fitness growtype]
0
[/skill fitness growtype]
[pre required skill]
46 1
[/pre required skill]
[feature skill type]
1
[special purchase cost]
2
[/special purchase cost]
`,
"skill/Swordman/Fourth.skl": `
[type]
` + "`[active]`" + `
[required level]
1
[maximum level]
20
`,
"skill/Swordman/Passive.skl": `
[type]
` + "`[passive]`" + `
[required level]
1
[maximum level]
20
`,
"skill/Swordman/Conversion.skl": `
[type]
` + "`[passive]`" + `
[required level]
20
[maximum level]
1
`,
"skill/Swordman/HighLevel.skl": `
[type]
` + "`[active]`" + `
[required level]
12
[maximum level]
5
[skill fitness growtype]
0
[/skill fitness growtype]
[purchase cost]
20
[/purchase cost]
`,
"skill/Swordman/HighDep.skl": `
[type]
` + "`[active]`" + `
[required level]
1
[maximum level]
5
[skill fitness growtype]
0
[/skill fitness growtype]
[pre required skill]
51 2
[/pre required skill]
[purchase cost]
30
[/purchase cost]
`,
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatalf("build skill index: %v", err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatalf("load skill catalog: %v", err)
}
return catalog
}
func darkKnightSkillRuleCatalog(t *testing.T) *dnfskill.Table {
t.Helper()
source := skillRuleTextSource{
"skill/skilllist.lst": "9 `DemonicSwordmanSkill.lst`\n",
"skill/DemonicSwordmanSkill.lst": "5 `DemonicSwordman/Five.skl`\n" +
"8 `DemonicSwordman/Eight.skl`\n" +
"46 `DemonicSwordman/FortySix.skl`\n" +
"118 `DemonicSwordman/ComboSet0.skl`\n" +
"119 `DemonicSwordman/ComboSet1.skl`\n",
}
for _, name := range []string{"Five", "Eight", "FortySix"} {
source["skill/DemonicSwordman/"+name+".skl"] = "[type]\n`[active]`\n[required level]\n1\n[maximum level]\n20\n"
}
source["skill/DemonicSwordman/ComboSet0.skl"] = "[combo set]\n5 8\n[/combo set]\n"
source["skill/DemonicSwordman/ComboSet1.skl"] = "[combo set]\n46\n[/combo set]\n"
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
return catalog
}
func secondAwakeningSkillCatalog(t *testing.T) *dnfskill.Table {
t.Helper()
source := skillRuleTextSource{
"skill/skilllist.lst": "0 `SwordmanSkill.lst`\n",
"skill/SwordmanSkill.lst": "46 `Swordman/Second.skl`\n",
"skill/Swordman/Second.skl": `[type]
` + "`[active]`" + `
[required level]
1
[maximum level]
20
[skill fitness growtype]
0
[/skill fitness growtype]
[skill fitness second growtype]
2
[/skill fitness second growtype]
[purchase cost]
20
[/purchase cost]
`,
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
return catalog
}
func growthMaximumSkillCatalog(t *testing.T) *dnfskill.Table {
t.Helper()
source := skillRuleTextSource{
"skill/skilllist.lst": "0 `SwordmanSkill.lst`\n",
"skill/SwordmanSkill.lst": "46 `Swordman/Base.skl`\n",
"skill/Swordman/Base.skl": `[type]
` + "`[active]`" + `
[required level]
1
[maximum level]
20
[growtype maximum level]
0 20
[/growtype maximum level]
[purchase cost]
20
[/purchase cost]
`,
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
return catalog
}
func learnableLevelCapCatalog(t *testing.T) *dnfskill.Table {
t.Helper()
source := skillRuleTextSource{
"skill/skilllist.lst": "0 `SwordmanSkill.lst`\n",
"skill/SwordmanSkill.lst": "46 `Swordman/Base.skl`\n67 `Swordman/WeaponCombo.skl`\n126 `Swordman/SecondAwakening.skl`\n127 `Swordman/LegacyUnbounded.skl`\n",
"skill/Swordman/Base.skl": `[type]
` + "`[active]`" + `
[required level]
1
[maximum level]
20
[skill fitness growtype]
1
[/skill fitness growtype]
[growtype maximum level]
0 20 0 0 0 0
[/growtype maximum level]
[purchase cost]
0
[/purchase cost]
`,
"skill/Swordman/WeaponCombo.skl": `[type]
` + "`[active]`" + `
[required level]
15
[required level range]
15
[maximum level]
15
[skill fitness growtype]
1
[/skill fitness growtype]
[growtype maximum level]
0 15 0 0 0 0
[/growtype maximum level]
[purchase cost]
30
[/purchase cost]
`,
"skill/Swordman/SecondAwakening.skl": `[type]
` + "`[passive]`" + `
[required level]
75
[required level range]
3
[maximum level]
40
[skill fitness growtype]
1
[/skill fitness growtype]
[growtype maximum level]
0 0 0 0 0 0
[/growtype maximum level]
[skill fitness second growtype]
2
[/skill fitness second growtype]
[second growtype maximum level]
0 0 0 2 0 0 0 0 0 0 0 0
[/second growtype maximum level]
[purchase cost]
80
[/purchase cost]
`,
"skill/Swordman/LegacyUnbounded.skl": `[type]
` + "`[active]`" + `
[required level]
1
[skill fitness growtype]
1
[/skill fitness growtype]
[purchase cost]
20
[/purchase cost]
`,
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
return catalog
}
type skillRuleTextSource map[string]string
func (s skillRuleTextSource) ReadText(relativePath string) (string, error) {
want := strings.ToLower(strings.ReplaceAll(relativePath, "\\", "/"))
for key, value := range s {
if strings.ToLower(strings.ReplaceAll(key, "\\", "/")) == want {
return value, nil
}
}
return "", dnfpvf.ErrDocNotFound
}
func alignedRequest(accountID string, selectedCharacterID uint16) alignedcmd.Request {
return alignedcmd.Request{AccountID: accountID, SelectedCharacterID: selectedCharacterID}
}
type countingCharacterSkillUnitOfWork struct {
delegate dnfrepo.CharacterSkillUnitOfWork
calls int
}
func (u *countingCharacterSkillUnitOfWork) WithinCharacterSkill(ctx context.Context, characterID string, apply func(dnfrepo.SkillRepository) error) error {
u.calls++
return u.delegate.WithinCharacterSkill(ctx, characterID, apply)
}
func TestOwnerApplyBuyOverSkillContractRaisesEffectiveLevel(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
buy := BuySkillRequest{Count: 1, Entries: []BuySkillEntry{{SkillID: 51, LevelDelta: 1}}}
if _, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), buy)); !errors.Is(err, ErrSkillLevel) {
t.Fatalf("without contract ApplyBuy() error = %v, want ErrSkillLevel", err)
}
future := time.Now().Add(24 * time.Hour).Unix()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-skill",
Metadata: map[string]string{"premium_expire_27": strconv.FormatInt(future, 10)},
}); err != nil {
t.Fatal(err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), buy))
if err != nil {
t.Fatalf("with over-skill contract ApplyBuy() error = %v", err)
}
if result.Points.RemainingSP != 80 {
t.Fatalf("remaining SP = %d, want 80", result.Points.RemainingSP)
}
record, _, _ := repos.Skill.Load(ctx, "31")
if record.Skills[51].Level != 1 {
t.Fatalf("skill 51 level = %d, want 1", record.Skills[51].Level)
}
}
func TestOwnerApplyBuyEnforcesPVFMaximumLearnableLevel(t *testing.T) {
newOwner := func(t *testing.T, level int, growType int, overSkillContract bool) (dnfrepo.Group, *Owner) {
t.Helper()
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
character, found, err := repos.Character.Load(ctx, "31")
if err != nil || !found {
t.Fatalf("load character found=%t err=%v", found, err)
}
character.Level = level
character.Stats["grow_type"] = int64(growType)
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
record, found, err := repos.Skill.Load(ctx, "31")
if err != nil || !found {
t.Fatalf("load skill found=%t err=%v", found, err)
}
record.Points = dnfrepo.SkillPointState{
TotalSP: 10000,
RemainingSP: 10000,
TotalTP: 100,
RemainingTP: 100,
SyncedLevel: level,
}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
if overSkillContract {
future := time.Now().Add(24 * time.Hour).Unix()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-skill",
Metadata: map[string]string{"premium_expire_27": strconv.FormatInt(future, 10)},
}); err != nil {
t.Fatal(err)
}
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: learnableLevelCapCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatal(err)
}
return repos, owner
}
t.Run("weapon combo level 90 caps at six", func(t *testing.T) {
ctx := context.Background()
repos, owner := newOwner(t, 90, 0x01, false)
command := func(delta byte) Command {
return NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 67, LevelDelta: delta}},
})
}
if _, err := owner.ApplyBuy(ctx, command(6)); err != nil {
t.Fatalf("learn level 6: %v", err)
}
if _, err := owner.ApplyBuy(ctx, command(1)); !errors.Is(err, ErrSkillLevel) {
t.Fatalf("learn level 7 error = %v, want ErrSkillLevel", err)
}
record, _, _ := repos.Skill.Load(ctx, "31")
if got := record.Skills[67].Level; got != 6 {
t.Fatalf("persisted weapon combo level = %d, want 6", got)
}
})
t.Run("five over-skill levels do not cross the next fifteen-level interval", func(t *testing.T) {
ctx := context.Background()
repos, owner := newOwner(t, 90, 0x01, true)
_, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 67, LevelDelta: 7}},
}))
if !errors.Is(err, ErrSkillLevel) {
t.Fatalf("effective level 95 learn level 7 error = %v, want ErrSkillLevel", err)
}
record, _, _ := repos.Skill.Load(ctx, "31")
if got := record.Skills[67].Level; got != 0 {
t.Fatalf("rejected weapon combo level persisted as %d", got)
}
})
t.Run("level 105 opens weapon combo level seven", func(t *testing.T) {
ctx := context.Background()
repos, owner := newOwner(t, 105, 0x01, false)
_, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 67, LevelDelta: 7}},
}))
if err != nil {
t.Fatalf("learn level 7: %v", err)
}
record, _, _ := repos.Skill.Load(ctx, "31")
if got := record.Skills[67].Level; got != 7 {
t.Fatalf("persisted weapon combo level = %d, want 7", got)
}
})
t.Run("legacy skill without an explicit cap remains compatible", func(t *testing.T) {
ctx := context.Background()
repos, owner := newOwner(t, 90, 0x01, false)
_, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 127, LevelDelta: 3}},
}))
if err != nil {
t.Fatalf("learn legacy skill without PVF cap: %v", err)
}
record, _, _ := repos.Skill.Load(ctx, "31")
if got := record.Skills[127].Level; got != 3 {
t.Fatalf("persisted legacy skill level = %d, want 3", got)
}
})
t.Run("second awakening vector caps the packed growth stage", func(t *testing.T) {
ctx := context.Background()
repos, owner := newOwner(t, 90, 0x21, false)
command := func(delta byte) Command {
return NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 126, LevelDelta: delta}},
})
}
if _, err := owner.ApplyBuy(ctx, command(2)); err != nil {
t.Fatalf("learn second-awakening level 2: %v", err)
}
if _, err := owner.ApplyBuy(ctx, command(1)); !errors.Is(err, ErrSkillLevel) {
t.Fatalf("learn second-awakening level 3 error = %v, want ErrSkillLevel", err)
}
record, _, _ := repos.Skill.Load(ctx, "31")
if got := record.Skills[126].Level; got != 2 {
t.Fatalf("persisted second-awakening level = %d, want 2", got)
}
})
}
func TestOwnerApplyBuyRefundConsumesForgetRiverWaterAtomically(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[46] = dnfrepo.SkillState{Level: 2, Enabled: true}
record.Points.RemainingSP = 80
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "31",
Slots: map[string]dnfrepo.ItemStack{"0:5": {ItemID: forgetRiverWaterItemID, Count: 1}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
FinalMode: currentEXELetheWaterMode,
Entries: []BuySkillEntry{{SkillID: 46, RefundFlag: 1, LevelDelta: 1}},
}))
if err != nil {
t.Fatalf("ApplyBuy() error = %v", err)
}
if !result.ConsumedRefundItem || result.ConsumedRefundItemSlot != 5 {
t.Fatalf("result = %+v, want consumed water slot 5", result)
}
if result.Points.RemainingSP != 100 {
t.Fatalf("remaining SP = %d, want refunded 100", result.Points.RemainingSP)
}
skill, _, _ := repos.Skill.Load(ctx, "31")
if skill.Skills[46].Level != 1 {
t.Fatalf("skill 46 level = %d, want 1", skill.Skills[46].Level)
}
inventory, _, _ := repos.Inventory.Load(ctx, "31")
if _, exists := inventory.Slots["0:5"]; exists {
t.Fatalf("water stack still exists: %+v", inventory.Slots)
}
}
func TestOwnerApplyBuyRefundWithoutWaterFailsClosed(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[46] = dnfrepo.SkillState{Level: 2, Enabled: true}
record.Points.RemainingSP = 80
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "31", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
_, err = owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
FinalMode: currentEXELetheWaterMode,
Entries: []BuySkillEntry{{SkillID: 46, RefundFlag: 1, LevelDelta: 1}},
}))
if !errors.Is(err, ErrSkillRefundConsumableRequired) {
t.Fatalf("ApplyBuy() error = %v, want ErrSkillRefundConsumableRequired", err)
}
skill, _, _ := repos.Skill.Load(ctx, "31")
if skill.Skills[46].Level != 2 || skill.Points.RemainingSP != 80 {
t.Fatalf("rejected refund changed skill state: %+v", skill)
}
}
func TestOwnerApplyBuyRefundFreeWhileLetheContractActive(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[46] = dnfrepo.SkillState{Level: 2, Enabled: true}
record.Points.RemainingSP = 80
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
future := time.Now().Add(24 * time.Hour).Unix()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-skill",
Metadata: map[string]string{"premium_expire_33": strconv.FormatInt(future, 10)},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
FinalMode: currentEXELetheWaterMode,
Entries: []BuySkillEntry{{SkillID: 46, RefundFlag: 1, LevelDelta: 1}},
}))
if err != nil {
t.Fatalf("ApplyBuy() error = %v", err)
}
if result.ConsumedRefundItem {
t.Fatalf("lethe contract refund must not consume water: %+v", result)
}
if result.Points.RemainingSP != 100 {
t.Fatalf("remaining SP = %d, want refunded 100", result.Points.RemainingSP)
}
skill, _, _ := repos.Skill.Load(ctx, "31")
if skill.Skills[46].Level != 1 {
t.Fatalf("skill 46 level = %d, want 1", skill.Skills[46].Level)
}
}
func seedOverLevelContractSkills(t *testing.T, ctx context.Context, repos dnfrepo.Group) {
t.Helper()
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[46] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Skills[51] = dnfrepo.SkillState{Level: 2, Enabled: true}
record.Skills[52] = dnfrepo.SkillState{Level: 1, Enabled: true}
// 51 costs 20x2 and 52 costs 30: 100-40-30=30 remaining.
record.Points.RemainingSP = 30
record.Layouts = map[int]dnfrepo.SkillLayout{0: {0: 46, 1: 51, 2: 52}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
func TestOwnerApplyBuySweepsExpiredOverSkillContractSkillsWithCascade(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
seedOverLevelContractSkills(t, ctx, repos)
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
// No active 达人契约: skill 51 (requires 12 > char 10) is swept, and skill
// 52 (depends on 51>=2) cascades out; both refund their SP.
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}},
}))
if err != nil {
t.Fatalf("ApplyBuy() error = %v", err)
}
if !result.ExpiredContractSkillsReset {
t.Fatalf("result = %+v, want ExpiredContractSkillsReset", result)
}
record, _, _ := repos.Skill.Load(ctx, "31")
if _, exists := record.Skills[51]; exists {
t.Fatalf("over-level skill 51 still learned: %+v", record.Skills)
}
if _, exists := record.Skills[52]; exists {
t.Fatalf("broken dependent skill 52 still learned: %+v", record.Skills)
}
if record.Skills[46].Level != 2 {
t.Fatalf("skill 46 level = %d, want 2", record.Skills[46].Level)
}
// 30 + 40 (51) + 30 (52) = 100, then -20 for the buy = 80.
if record.Points.RemainingSP != 80 {
t.Fatalf("remaining SP = %d, want 80", record.Points.RemainingSP)
}
for slot, id := range record.Layouts[0] {
if id == 51 || id == 52 {
t.Fatalf("layout slot %d still holds swept skill %d", slot, id)
}
}
}
func TestOwnerApplyBuyDoesNotLoopOnOverLevelPVFInitialFloor(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
// Skill 51 requires character level 12 while this fixture is level 10,
// but level 1 is a PVF-granted profession floor rather than a level bought
// through the expired over-skill contract. Skill 52 is another permanent
// floor whose declared prerequisite (51>=2) is intentionally unavailable;
// both shapes occur in the active PVF profession audit.
record.Skills[51] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Skills[52] = dnfrepo.SkillState{Level: 1, Enabled: true}
record.Layouts = map[int]dnfrepo.SkillLayout{0: {0: 46, 1: 51, 2: 52}}
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1, 51: 1, 52: 1},
})
if err != nil {
t.Fatal(err)
}
type outcome struct {
result MutationResult
err error
}
done := make(chan outcome, 1)
go func() {
result, applyErr := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}},
}))
done <- outcome{result: result, err: applyErr}
}()
select {
case got := <-done:
if got.err != nil {
t.Fatalf("ApplyBuy() error = %v", got.err)
}
if got.result.ExpiredContractSkillsReset {
t.Fatalf("PVF floor must not be reported as swept: %+v", got.result)
}
case <-time.After(time.Second):
t.Fatal("ApplyBuy() did not return; expired-contract sweep made no progress")
}
persisted, _, err := repos.Skill.Load(ctx, "31")
if err != nil {
t.Fatal(err)
}
if persisted.Skills[51].Level != 1 || persisted.Skills[52].Level != 1 || persisted.Skills[46].Level != 2 {
t.Fatalf("persisted skills = %+v, want floors 51/52=1 and purchased 46=2", persisted.Skills)
}
}
func TestValidateLearnedPrerequisitesExemptsOnlyPVFPermanentFloor(t *testing.T) {
catalog := skillRuleCatalog(t)
initial := map[uint16]int{52: 1}
states := map[int64]dnfrepo.SkillState{
51: {Level: 1, Enabled: true},
52: {Level: 1, Enabled: true},
}
if err := validateLearnedPrerequisites(catalog, 0, states, initial); err != nil {
t.Fatalf("PVF permanent floor prerequisite = %v", err)
}
states[52] = dnfrepo.SkillState{Level: 2, Enabled: true}
if err := validateLearnedPrerequisites(catalog, 0, states, initial); !errors.Is(err, ErrSkillPrerequisite) {
t.Fatalf("purchased level above floor prerequisite = %v, want %v", err, ErrSkillPrerequisite)
}
}
func TestOwnerApplyBuyKeepsOverLevelSkillsWhileOverSkillContractActive(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
seedOverLevelContractSkills(t, ctx, repos)
future := time.Now().Add(24 * time.Hour).Unix()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-skill",
Metadata: map[string]string{"premium_expire_27": strconv.FormatInt(future, 10)},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 51, LevelDelta: 1}},
}))
if err != nil {
t.Fatalf("ApplyBuy() error = %v", err)
}
if result.ExpiredContractSkillsReset {
t.Fatalf("active contract must not sweep: %+v", result)
}
record, _, _ := repos.Skill.Load(ctx, "31")
if record.Skills[51].Level != 3 || record.Skills[52].Level != 1 {
t.Fatalf("contract-learned skills changed: %+v", record.Skills)
}
}
// TestOwnerApplyBuyStillEnforcesTheAwakeningDimension pins the split between the
// two PVF fitness dimensions: the class branch is the client skill tree's call,
// but the awakening stage stays server-enforced because the first awakening is
// granted by its own transition and the second one requires its quest.
func TestOwnerApplyBuyStillEnforcesTheAwakeningDimension(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
character, _, _ := repos.Character.Load(ctx, "31")
// The row is branch-0 fit ([skill fitness growtype] 0) but declares
// [skill fitness second growtype] 2, so awakening stage 1 is still too early.
character.Stats["grow_type"] = 0x10
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos, OwnerOptions{Catalog: secondAwakeningSkillCatalog(t), InitialLevels: map[uint16]int{46: 1}})
if err != nil {
t.Fatal(err)
}
command := NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{Count: 1, Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}}})
if _, err := owner.ApplyBuy(ctx, command); !errors.Is(err, ErrSkillGrowType) {
t.Fatalf("first-awakening purchase error = %v, want ErrSkillGrowType", err)
}
character, _, _ = repos.Character.Load(ctx, "31")
character.Stats["grow_type"] = 0x20
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
result, err := owner.ApplyBuy(ctx, command)
if err != nil {
t.Fatalf("second-awakening purchase error = %v", err)
}
if len(result.Entries) != 1 || result.Entries[0].SkillID != 46 || result.Points.RemainingSP != 80 {
t.Fatalf("second-awakening purchase result = %+v", result)
}
}
// TestOwnerApplyBuyLearnsTreeRowWithoutOwnBranchCap pins the tree-authority rule
// on the profession-branch dimension with the shape that produced the live
// report: the PVF caps only the declared branches, the character's own branch
// row is zero, and the client still offers the row. The purchase must succeed,
// and the declared cap must still bound it.
func TestOwnerApplyBuyLearnsTreeRowWithoutOwnBranchCap(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
owner, err := NewOwner(repos, OwnerOptions{Catalog: growthMaximumSkillCatalog(t), InitialLevels: map[uint16]int{46: 1}})
if err != nil {
t.Fatal(err)
}
command := NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{Count: 1, Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}}})
result, err := owner.ApplyBuy(ctx, command)
if err != nil {
t.Fatalf("base-profession purchase error = %v", err)
}
if len(result.Entries) != 1 || result.Entries[0].SkillID != 46 || result.Points.RemainingSP != 80 {
t.Fatalf("base-profession purchase result = %+v", result)
}
// [growtype maximum level] 0 20 still caps this branch at 20: level 1 + 20
// targets 21.
over := NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{Count: 1, Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 20}}})
if _, err := owner.ApplyBuy(ctx, over); !errors.Is(err, ErrSkillLevel) {
t.Fatalf("over-cap purchase error = %v, want ErrSkillLevel", err)
}
// A class-changed character keeps the same caps.
character, _, _ := repos.Character.Load(ctx, "31")
character.Stats["grow_type"] = 1
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
fitting := NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{Count: 1, Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}}})
if _, err := owner.ApplyBuy(ctx, fitting); err != nil {
t.Fatalf("class-profession purchase error = %v", err)
}
}
func TestOwnerApplyBuySingleRefundModeDoesNotRequireForgetRiverWater(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
record, _, _ := repos.Skill.Load(ctx, "31")
record.Skills[46] = dnfrepo.SkillState{Level: 2, Enabled: true}
record.Points.RemainingSP = 80
if err := repos.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "31", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
result, err := owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 46, RefundFlag: 1, LevelDelta: 1}},
FinalMode: currentEXESingleSkillMode,
}))
if err != nil {
t.Fatalf("ApplyBuy() error = %v", err)
}
if result.ConsumedRefundItem || result.Points.RemainingSP != 100 {
t.Fatalf("result = %+v, want free single-skill refund to 100 SP", result)
}
skill, _, _ := repos.Skill.Load(ctx, "31")
if skill.Skills[46].Level != 1 {
t.Fatalf("skill 46 level = %d, want 1", skill.Skills[46].Level)
}
}
func TestOwnerApplyBuyContractRefundRequiresActiveLetheContract(t *testing.T) {
ctx := context.Background()
repos := seededSkillOwnerRepositories(t, ctx)
owner, err := NewOwner(repos, OwnerOptions{
Catalog: skillRuleCatalog(t),
InitialLevels: map[uint16]int{46: 1},
})
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
_, err = owner.ApplyBuy(ctx, NewBuyCommand(alignedRequest("acc-skill", 31), BuySkillRequest{
Count: 1,
Entries: []BuySkillEntry{{SkillID: 46, RefundFlag: 1, LevelDelta: 1}},
FinalMode: currentEXELetheContractMode,
}))
if !errors.Is(err, ErrSkillRefundContractRequired) {
t.Fatalf("ApplyBuy() error = %v, want ErrSkillRefundContractRequired", err)
}
}