DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,54 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestArchiveSmoke(t *testing.T) {
|
||||
archivePath := os.Getenv("DNF_SKILL_PVF")
|
||||
if archivePath == "" {
|
||||
t.Skip("set DNF_SKILL_PVF to run the real archive smoke")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: archivePath})
|
||||
if err != nil {
|
||||
t.Fatalf("open archive: %v", err)
|
||||
}
|
||||
index, err := dnfpvf.Build(context.Background(), archive, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
|
||||
if err != nil {
|
||||
t.Fatalf("build skill index: %v", err)
|
||||
}
|
||||
table, err := Load(context.Background(), index, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("load skill catalog: %v", err)
|
||||
}
|
||||
snapshot := table.Snapshot()
|
||||
t.Logf("real skill catalog: jobs=%d skills=%d", snapshot.Jobs, snapshot.Skills)
|
||||
if snapshot.Jobs != 16 || snapshot.Skills == 0 {
|
||||
t.Fatalf("unexpected real catalog snapshot: %+v", snapshot)
|
||||
}
|
||||
upperSlash, ok := table.Find(0, 46)
|
||||
if !ok || upperSlash.RequiredLevel != 1 || upperSlash.MaximumLevel <= 0 || !upperSlash.Active {
|
||||
t.Fatalf("unexpected swordman skill 46: %+v ok=%t", upperSlash, ok)
|
||||
}
|
||||
weaponCombo, ok := table.Find(0, 67)
|
||||
if !ok || weaponCombo.RequiredLevel != 15 || weaponCombo.RequiredLevelRange != 15 ||
|
||||
weaponCombo.MaximumLearnableLevel(90, 0x21) != 6 {
|
||||
t.Fatalf("unexpected swordman skill 67: %+v ok=%t", weaponCombo, ok)
|
||||
}
|
||||
battleMageAwakening, ok := table.Find(3, 83)
|
||||
if !ok || !battleMageAwakening.FixedLevelSkill || battleMageAwakening.FixedLevelBase != 1 ||
|
||||
battleMageAwakening.FixedLevelInterval != 5 || battleMageAwakening.FixedLevelIncrement != 1 ||
|
||||
battleMageAwakening.RequiredLevelRange != 5 || battleMageAwakening.FixedLevelForCharacter(90) != 9 {
|
||||
t.Fatalf("unexpected battle-mage awakening skill 83: %+v ok=%t", battleMageAwakening, ok)
|
||||
}
|
||||
battleGroove, ok := table.Find(3, 84)
|
||||
if !ok || len(battleGroove.SecondGrowTypeMaximumLevels) <= 6 ||
|
||||
battleGroove.SecondGrowTypeMaximumLevels[6] != 30 || battleGroove.MaximumLearnableLevel(90, 0x13) != 15 {
|
||||
t.Fatalf("unexpected battle-mage passive skill 84: %+v ok=%t", battleGroove, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package skill
|
||||
|
||||
// 本文件是支援兵(companion)移植的桥接层。
|
||||
//
|
||||
// companion 模块从 Server-90 移植,引用 Skill.MaximumLevelForCharacterGrowth。
|
||||
// 当前包的 MaximumLearnableLevel 会**同时**按角色等级收窄上限,而支援兵要的是
|
||||
// 「只按成长类型算出的技能上限」——两者语义不同,不能互换,所以这个方法按
|
||||
// Server-90 的原语义移植,只把它的两个内部辅助函数换成本包已有的等价写法。
|
||||
//
|
||||
// MaximumLevelForCharacterGrowth resolves the packed grow_type's branch and
|
||||
// cumulative awakening stage. A positive awakening slot takes precedence;
|
||||
// otherwise the branch table applies. Missing tables use [maximum level].
|
||||
func (s Skill) MaximumLevelForCharacterGrowth(growType int) int {
|
||||
first := growType & 0x0f
|
||||
awakening := (growType >> 4) & 0x0f
|
||||
maximum := s.MaximumLevel
|
||||
hasDirectionTables := len(s.GrowTypeMaximumLevels) > 0 || len(s.SecondGrowTypeMaximumLevels) > 0
|
||||
if !hasDirectionTables {
|
||||
// Focused fixtures and older PVF rows may omit the maximum-level
|
||||
// tables. Preserve their established fitness semantics until a table is
|
||||
// present; a zero scalar means the old owner imposed no upper cap.
|
||||
if !s.SupportsGrowType(first) {
|
||||
return 0
|
||||
}
|
||||
if len(s.SecondGrowTypes) > 0 {
|
||||
allowed := false
|
||||
for _, minimumStage := range s.SecondGrowTypes {
|
||||
if minimumStage >= 0 && awakening >= minimumStage {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
if maximum > 0 {
|
||||
return maximum
|
||||
}
|
||||
return -1
|
||||
}
|
||||
// 当前 Skill 类型没有 Server-90 的那个 MaximumLevels 数组 —— 那一维在本树
|
||||
// 由标量 MaximumLevel + GrowTypeMaximumLevels / SecondGrowTypeMaximumLevels
|
||||
// 表达。所以这里跳过那一步覆盖,直接用标量作为基准。
|
||||
directionMaximum := maximum
|
||||
if awakening > 0 && len(s.SecondGrowTypeMaximumLevels) > 0 {
|
||||
directionMaximum = companionIndexedMaximum(s.SecondGrowTypeMaximumLevels, first*2+awakening-1)
|
||||
if directionMaximum > 0 {
|
||||
return companionMinimumMaximum(maximum, directionMaximum)
|
||||
}
|
||||
}
|
||||
if len(s.GrowTypeMaximumLevels) > 0 {
|
||||
directionMaximum = companionIndexedMaximum(s.GrowTypeMaximumLevels, first)
|
||||
}
|
||||
return companionMinimumMaximum(maximum, directionMaximum)
|
||||
}
|
||||
|
||||
// companionIndexedMaximum 按下标取值;越界返回 0(与 Server-90 的
|
||||
// indexedMaximum 同义:0 表示"这张表没给这一档定上限")。
|
||||
func companionIndexedMaximum(values []int, index int) int {
|
||||
if index < 0 || index >= len(values) {
|
||||
return 0
|
||||
}
|
||||
return values[index]
|
||||
}
|
||||
|
||||
// companionMinimumMaximum 取两个上限里更严的那个,0 视为"无上限"。
|
||||
// 与 Server-90 的 minimumMaximum 同义。
|
||||
func companionMinimumMaximum(left, right int) int {
|
||||
if left <= 0 {
|
||||
return right
|
||||
}
|
||||
if right <= 0 {
|
||||
return left
|
||||
}
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package skill 负责 DNF 技能 PVF 的项目层强类型加载。
|
||||
//
|
||||
// 本包只解析静态技能配置,技能释放、CD 消耗和战斗判定由 skill owner 与 battle room 处理。
|
||||
package skill
|
||||
@@ -0,0 +1,113 @@
|
||||
// This file loads the job-scoped skill catalog from an in-memory PVF index.
|
||||
package skill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
func Load(ctx context.Context, index *dnfpvf.Index, options Options) (*Table, error) {
|
||||
if index == nil {
|
||||
return nil, ErrIndexRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
listPath := options.ListPath
|
||||
if listPath == "" {
|
||||
listPath = DefaultList
|
||||
}
|
||||
entries := index.List(listPath)
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
|
||||
}
|
||||
|
||||
table := &Table{
|
||||
skills: make([]Skill, 0),
|
||||
byKey: make(map[Key]int),
|
||||
byPath: make(map[jobPathKey]int),
|
||||
byJob: make(map[byte][]int),
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.EqualFold(path.Ext(entry.Path), ".lst") {
|
||||
job, err := skillJob(entry.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobEntries := index.List(entry.Path)
|
||||
if len(jobEntries) == 0 {
|
||||
return nil, fmt.Errorf("%w: job=%d list=%s", ErrListEmpty, job, entry.Path)
|
||||
}
|
||||
if err := loadJob(ctx, table, index, job, jobEntries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Explicit flat lists remain supported for focused tools and fixtures. They
|
||||
// live in job zero and never collapse IDs from multiple job lists.
|
||||
if err := loadJob(ctx, table, index, 0, []dnfpvf.ListEntry{entry}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(table.skills) == 0 {
|
||||
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
func loadJob(ctx context.Context, table *Table, index *dnfpvf.Index, job byte, entries []dnfpvf.ListEntry) error {
|
||||
for _, entry := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := skillID(entry.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job=%d path=%s: %w", job, entry.Path, err)
|
||||
}
|
||||
key := Key{Job: job, ID: id}
|
||||
if _, exists := table.byKey[key]; exists {
|
||||
continue
|
||||
}
|
||||
doc, ok := index.Document(entry.Path)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: job=%d skill=%d path=%s", ErrDocMissing, job, id, entry.Path)
|
||||
}
|
||||
definition, err := parseSkill(job, id, entry.Path, doc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job=%d skill=%d path=%s: %w", job, id, entry.Path, err)
|
||||
}
|
||||
idx := len(table.skills)
|
||||
table.skills = append(table.skills, definition)
|
||||
table.byKey[key] = idx
|
||||
table.byJob[job] = append(table.byJob[job], idx)
|
||||
pathKey := jobPathKey{Job: job, Path: pathKey(definition.Path)}
|
||||
if pathKey.Path != "" {
|
||||
if _, exists := table.byPath[pathKey]; !exists {
|
||||
table.byPath[pathKey] = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillJob(value int64) (byte, error) {
|
||||
if value < 0 || value > math.MaxUint8 {
|
||||
return 0, fmt.Errorf("%w: %d", ErrJobOutOfRange, value)
|
||||
}
|
||||
return byte(value), nil
|
||||
}
|
||||
|
||||
func skillID(value int64) (uint16, error) {
|
||||
if value < 0 || value > math.MaxUint16 {
|
||||
return 0, fmt.Errorf("%w: %d", ErrIDOutOfRange, value)
|
||||
}
|
||||
return uint16(value), nil
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
func TestLoadBuildsJobScopedSkillTable(t *testing.T) {
|
||||
index := testIndex(t)
|
||||
table, err := Load(context.Background(), index, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("load skill: %v", err)
|
||||
}
|
||||
if snapshot := table.Snapshot(); snapshot.Jobs != 2 || snapshot.Skills != 2 {
|
||||
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
||||
}
|
||||
|
||||
swordman, ok := table.Find(0, 46)
|
||||
if !ok {
|
||||
t.Fatal("expected swordman skill")
|
||||
}
|
||||
if swordman.Name != "upper slash" || swordman.Job != 0 || swordman.Kind != "[active]" || !swordman.Active {
|
||||
t.Fatalf("unexpected swordman skill: %+v", swordman)
|
||||
}
|
||||
if swordman.RequiredLevel != 1 || swordman.RequiredLevelRange != 5 || swordman.MaximumLevel != 20 || swordman.SkillClass != 1 {
|
||||
t.Fatalf("unexpected level metadata: %+v", swordman)
|
||||
}
|
||||
if swordman.FeatureSkillIndex != 217 {
|
||||
t.Fatalf("unexpected feature skill index: %+v", swordman)
|
||||
}
|
||||
if !swordman.FixedLevelSkill || swordman.FixedLevelBase != 1 || swordman.FixedLevelInterval != 5 || swordman.FixedLevelIncrement != 2 || swordman.FixedLevelForCharacter(11) != 5 {
|
||||
t.Fatalf("unexpected fixed-level metadata: %+v", swordman)
|
||||
}
|
||||
if len(swordman.GrowTypes) != 3 || len(swordman.GrowTypeMaximumLevels) != 3 || swordman.GrowTypeMaximumLevels[2] != 20 || len(swordman.SecondGrowTypes) != 1 || swordman.SecondGrowTypes[0] != 2 || len(swordman.SecondGrowTypeMaximumLevels) != 6 || swordman.SecondGrowTypeMaximumLevels[5] != 20 || len(swordman.PurchaseCost) != 1 || swordman.PurchaseCost[0] != 20 {
|
||||
t.Fatalf("unexpected rule arrays: %+v", swordman)
|
||||
}
|
||||
if len(swordman.Prerequisites) != 1 || swordman.Prerequisites[0] != (Prerequisite{SkillID: 20, Level: 1}) {
|
||||
t.Fatalf("unexpected prerequisites: %+v", swordman.Prerequisites)
|
||||
}
|
||||
if !swordman.SupportsGrowType(0) || !swordman.SupportsGrowType(1) || !swordman.SupportsGrowType(2) || swordman.SupportsGrowType(9) || !swordman.IsTPSkill() || swordman.LevelCost(10) != 2 {
|
||||
t.Fatalf("unexpected domain rules: %+v", swordman)
|
||||
}
|
||||
if swordman.SupportsCharacterGrowth(0x12) || !swordman.SupportsCharacterGrowth(0x22) {
|
||||
t.Fatalf("second growtype fitness was not applied to packed grow_type: %+v", swordman)
|
||||
}
|
||||
if got := swordman.MaximumLearnableLevel(11, 0x22); got != 3 {
|
||||
t.Fatalf("maximum learnable level=%d want 3: %+v", got, swordman)
|
||||
}
|
||||
if swordman.CoolTime != 2000 || swordman.MPCost != 12 || swordman.Scalars["power"] != 120 {
|
||||
t.Fatalf("unexpected scalar fields: %+v", swordman)
|
||||
}
|
||||
|
||||
fighter, ok := table.Find(1, 46)
|
||||
if !ok || fighter.Name != "muse upper" {
|
||||
t.Fatalf("same skill ID must remain job-scoped: %+v ok=%t", fighter, ok)
|
||||
}
|
||||
if _, ok := table.Find(2, 46); ok {
|
||||
t.Fatal("skill leaked into an unrelated job")
|
||||
}
|
||||
|
||||
swordman.GrowTypes[0] = 99
|
||||
swordman.SecondGrowTypeMaximumLevels[5] = 99
|
||||
swordman.Prerequisites[0].Level = 99
|
||||
swordman.PurchaseCost[0] = 99
|
||||
swordman.Scalars["power"] = 1
|
||||
again, _ := table.FindPath(0, "./SKILL/Swordman/UpperSlash.skl")
|
||||
if again.GrowTypes[0] != 0 || again.SecondGrowTypeMaximumLevels[5] != 20 || again.Prerequisites[0].Level != 1 || again.PurchaseCost[0] != 20 || again.Scalars["power"] != 120 {
|
||||
t.Fatalf("table returned mutable fields: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillExplicitGrowTypeOwnershipOverridesClientPreviewMaximum(t *testing.T) {
|
||||
definition := Skill{
|
||||
GrowTypes: []int{4},
|
||||
GrowTypeMaximumLevels: []int{50, 0, 0, 0, 50, 0},
|
||||
}
|
||||
if definition.SupportsGrowType(0) || !definition.SupportsGrowType(4) {
|
||||
t.Fatalf("explicit class ownership was not authoritative: %+v", definition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillExclusiveClassMaximumRepairsContradictoryFitnessMarker(t *testing.T) {
|
||||
definition := Skill{
|
||||
GrowTypes: []int{1},
|
||||
GrowTypeMaximumLevels: []int{0, 0, 1, 0, 0, 0},
|
||||
}
|
||||
if definition.SupportsGrowType(1) || !definition.SupportsGrowType(2) || !definition.SupportsCharacterGrowth(0x22) {
|
||||
t.Fatalf("exclusive class maximum did not repair contradictory fitness marker: %+v", definition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAmbiguousClassMaximumDoesNotOverrideFitnessMarker(t *testing.T) {
|
||||
definition := Skill{
|
||||
GrowTypes: []int{1},
|
||||
GrowTypeMaximumLevels: []int{0, 0, 1, 1, 0, 0},
|
||||
}
|
||||
if !definition.SupportsGrowType(1) || definition.SupportsGrowType(2) || definition.SupportsGrowType(3) {
|
||||
t.Fatalf("ambiguous maximum vector overrode explicit fitness marker: %+v", definition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalProfessionBranchZeroUsesPositivePVFMaximum(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
definition Skill
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "creator quick standing",
|
||||
definition: Skill{
|
||||
Job: 10,
|
||||
GrowTypes: []int{1},
|
||||
GrowTypeMaximumLevels: []int{1, 1, 1, 1, 1, 1},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dark knight tp",
|
||||
definition: Skill{
|
||||
Job: 9,
|
||||
GrowTypes: []int{1, 2, 3, 4},
|
||||
GrowTypeMaximumLevels: []int{5, 5, 5, 5, 5, 5},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ordinary profession preview remains rejected",
|
||||
definition: Skill{
|
||||
Job: 7,
|
||||
GrowTypes: []int{1},
|
||||
GrowTypeMaximumLevels: []int{1, 1, 0, 0, 0, 0},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "external profession without branch zero maximum remains rejected",
|
||||
definition: Skill{
|
||||
Job: 9,
|
||||
GrowTypes: []int{1},
|
||||
GrowTypeMaximumLevels: []int{0, 1, 0, 0, 0, 0},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.definition.SupportsCharacterGrowth(0); got != test.want {
|
||||
t.Fatalf("SupportsCharacterGrowth(0)=%t want %t: %+v", got, test.want, test.definition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillMaximumLearnableLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
definition Skill
|
||||
characterLevel int
|
||||
growType int
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "weapon combo level 90",
|
||||
definition: Skill{
|
||||
RequiredLevel: 15,
|
||||
RequiredLevelRange: 15,
|
||||
MaximumLevel: 15,
|
||||
GrowTypes: []int{1},
|
||||
GrowTypeMaximumLevels: []int{0, 15, 0, 0, 0, 0},
|
||||
},
|
||||
characterLevel: 90,
|
||||
growType: 0x21,
|
||||
want: 6,
|
||||
},
|
||||
{
|
||||
name: "awakening interval and second-grow maximum",
|
||||
definition: Skill{
|
||||
RequiredLevel: 48,
|
||||
RequiredLevelRange: 3,
|
||||
MaximumLevel: 40,
|
||||
GrowTypeMaximumLevels: []int{0, 0, 0, 0, 0, 0},
|
||||
SecondGrowTypes: []int{1},
|
||||
SecondGrowTypeMaximumLevels: []int{0, 0, 0, 0, 0, 0, 30, 30, 0, 0, 0, 0},
|
||||
},
|
||||
characterLevel: 90,
|
||||
growType: 0x13,
|
||||
want: 15,
|
||||
},
|
||||
{
|
||||
name: "awakening stage zero is unsupported",
|
||||
definition: Skill{
|
||||
RequiredLevel: 48,
|
||||
RequiredLevelRange: 3,
|
||||
MaximumLevel: 40,
|
||||
SecondGrowTypes: []int{1},
|
||||
SecondGrowTypeMaximumLevels: []int{0, 0, 0, 0, 0, 0, 30, 30, 0, 0, 0, 0},
|
||||
},
|
||||
characterLevel: 90,
|
||||
growType: 0x03,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "missing level range leaves static limits authoritative",
|
||||
definition: Skill{
|
||||
RequiredLevel: 15,
|
||||
MaximumLevel: 40,
|
||||
GrowTypes: []int{1},
|
||||
GrowTypeMaximumLevels: []int{0, 30, 0, 0, 0, 0},
|
||||
},
|
||||
characterLevel: 15,
|
||||
growType: 0x01,
|
||||
want: 30,
|
||||
},
|
||||
{
|
||||
name: "below required level",
|
||||
definition: Skill{
|
||||
RequiredLevel: 15,
|
||||
RequiredLevelRange: 15,
|
||||
MaximumLevel: 15,
|
||||
},
|
||||
characterLevel: 14,
|
||||
growType: 0,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.definition.MaximumLearnableLevel(test.characterLevel, test.growType); got != test.want {
|
||||
t.Fatalf("MaximumLearnableLevel(%d, %#x)=%d want %d: %+v", test.characterLevel, test.growType, got, test.want, test.definition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillMaximumLearnableLevelForTree(t *testing.T) {
|
||||
// A tree row is learnable regardless of PVF profession fitness, but the
|
||||
// document's caps still bound it. When the character's own branch carries no
|
||||
// positive `[growtype maximum level]` row the largest declared cap applies.
|
||||
tests := []struct {
|
||||
name string
|
||||
definition Skill
|
||||
characterLevel int
|
||||
growType int
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "own branch row is zero falls back to the declared class cap",
|
||||
// Ez-8自爆者 shape: [skill fitness growtype] 3 with
|
||||
// [growtype maximum level] 50 0 0 50 0 0 on a base Gunner.
|
||||
definition: Skill{
|
||||
RequiredLevel: 15,
|
||||
MaximumLevel: 60,
|
||||
GrowTypes: []int{3},
|
||||
GrowTypeMaximumLevels: []int{50, 0, 0, 50, 0, 0},
|
||||
},
|
||||
characterLevel: 50,
|
||||
growType: 0x00,
|
||||
want: 50,
|
||||
},
|
||||
{
|
||||
name: "declared branch keeps its own cap",
|
||||
definition: Skill{
|
||||
RequiredLevel: 15,
|
||||
MaximumLevel: 60,
|
||||
GrowTypes: []int{3},
|
||||
GrowTypeMaximumLevels: []int{50, 0, 0, 50, 0, 0},
|
||||
},
|
||||
characterLevel: 50,
|
||||
growType: 0x03,
|
||||
want: 50,
|
||||
},
|
||||
{
|
||||
name: "no own-branch row at all still yields the declared cap",
|
||||
definition: Skill{
|
||||
RequiredLevel: 1,
|
||||
MaximumLevel: 20,
|
||||
GrowTypeMaximumLevels: []int{0, 20},
|
||||
},
|
||||
characterLevel: 10,
|
||||
growType: 0x00,
|
||||
want: 20,
|
||||
},
|
||||
{
|
||||
name: "global maximum still wins over the fallback",
|
||||
definition: Skill{
|
||||
RequiredLevel: 1,
|
||||
MaximumLevel: 5,
|
||||
GrowTypeMaximumLevels: []int{0, 20},
|
||||
},
|
||||
characterLevel: 10,
|
||||
growType: 0x00,
|
||||
want: 5,
|
||||
},
|
||||
{
|
||||
name: "below the declared awakening stage keeps the remaining limits",
|
||||
definition: Skill{
|
||||
RequiredLevel: 48,
|
||||
RequiredLevelRange: 3,
|
||||
MaximumLevel: 40,
|
||||
SecondGrowTypes: []int{1},
|
||||
SecondGrowTypeMaximumLevels: []int{0, 0, 0, 0, 0, 0, 30, 30, 0, 0, 0, 0},
|
||||
},
|
||||
characterLevel: 90,
|
||||
growType: 0x03,
|
||||
want: 15,
|
||||
},
|
||||
{
|
||||
name: "fitting awakening stage matches the fitness-aware maximum",
|
||||
definition: Skill{
|
||||
RequiredLevel: 48,
|
||||
RequiredLevelRange: 3,
|
||||
MaximumLevel: 40,
|
||||
SecondGrowTypes: []int{1},
|
||||
SecondGrowTypeMaximumLevels: []int{0, 0, 0, 0, 0, 0, 30, 30, 0, 0, 0, 0},
|
||||
},
|
||||
characterLevel: 90,
|
||||
growType: 0x13,
|
||||
want: 15,
|
||||
},
|
||||
{
|
||||
name: "below required level stays unlearnable",
|
||||
definition: Skill{
|
||||
RequiredLevel: 15,
|
||||
MaximumLevel: 60,
|
||||
GrowTypeMaximumLevels: []int{50, 50, 50, 50, 50, 50},
|
||||
},
|
||||
characterLevel: 14,
|
||||
growType: 0x00,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.definition.MaximumLearnableLevelForTree(test.characterLevel, test.growType); got != test.want {
|
||||
t.Fatalf("MaximumLearnableLevelForTree(%d, %#x)=%d want %d: %+v", test.characterLevel, test.growType, got, test.want, test.definition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSplitsClassBranchFromAwakeningDimension pins the two PVF fitness
|
||||
// dimensions apart. Only the class branch is relaxed for a purchase (the client
|
||||
// tree renders every row of the JOB); the awakening stage keeps blocking,
|
||||
// because the first awakening is granted by its own transition and the second
|
||||
// one requires its quest.
|
||||
func TestSkillSplitsClassBranchFromAwakeningDimension(t *testing.T) {
|
||||
branchLocked := Skill{
|
||||
// Ez-8自爆者 shape: another branch only, no awakening rule.
|
||||
GrowTypes: []int{3},
|
||||
GrowTypeMaximumLevels: []int{50, 0, 0, 50, 0, 0},
|
||||
}
|
||||
awakeningLocked := Skill{
|
||||
SecondGrowTypes: []int{1},
|
||||
SecondGrowTypeMaximumLevels: []int{0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0},
|
||||
}
|
||||
bothLocked := Skill{
|
||||
GrowTypes: []int{3},
|
||||
GrowTypeMaximumLevels: []int{50, 0, 0, 50, 0, 0},
|
||||
SecondGrowTypes: []int{1},
|
||||
SecondGrowTypeMaximumLevels: []int{0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
definition Skill
|
||||
growType int
|
||||
wantBranch bool
|
||||
wantAwakening bool
|
||||
wantCharacterGrow bool
|
||||
}{
|
||||
{name: "branch locked, base class", definition: branchLocked, growType: 0x00, wantBranch: false, wantAwakening: true, wantCharacterGrow: false},
|
||||
{name: "branch locked, own branch", definition: branchLocked, growType: 0x03, wantBranch: true, wantAwakening: true, wantCharacterGrow: true},
|
||||
{name: "awakening locked, unawakened", definition: awakeningLocked, growType: 0x00, wantBranch: true, wantAwakening: false, wantCharacterGrow: false},
|
||||
{name: "awakening locked, awakened", definition: awakeningLocked, growType: 0x10, wantBranch: true, wantAwakening: true, wantCharacterGrow: true},
|
||||
{name: "both locked, base class", definition: bothLocked, growType: 0x00, wantBranch: false, wantAwakening: false, wantCharacterGrow: false},
|
||||
{name: "both locked, own branch unawakened", definition: bothLocked, growType: 0x03, wantBranch: true, wantAwakening: false, wantCharacterGrow: false},
|
||||
{name: "both locked, own branch awakened", definition: bothLocked, growType: 0x13, wantBranch: true, wantAwakening: true, wantCharacterGrow: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.definition.SupportsGrowType(test.growType & 0x0f); got != test.wantBranch {
|
||||
t.Fatalf("SupportsGrowType = %t, want %t", got, test.wantBranch)
|
||||
}
|
||||
if got := test.definition.SupportsCharacterAwakening(test.growType); got != test.wantAwakening {
|
||||
t.Fatalf("SupportsCharacterAwakening = %t, want %t", got, test.wantAwakening)
|
||||
}
|
||||
if got := test.definition.SupportsCharacterGrowth(test.growType); got != test.wantCharacterGrow {
|
||||
t.Fatalf("SupportsCharacterGrowth = %t, want %t", got, test.wantCharacterGrow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresIndex(t *testing.T) {
|
||||
_, err := Load(context.Background(), nil, Options{})
|
||||
if !errors.Is(err, ErrIndexRequired) {
|
||||
t.Fatalf("expected ErrIndexRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTreatsUntypedExecutableSkillAsActive(t *testing.T) {
|
||||
doc, err := dnfpvf.Parse("skill/ATMage/SpiralPress.skl", `
|
||||
[name]
|
||||
`+"`"+`wind dragon strike`+"`"+`
|
||||
[executable states]
|
||||
`+"`"+`[stand]`+"`"+` `+"`"+`[dash]`+"`"+`
|
||||
[/executable states]
|
||||
[consume MP]
|
||||
580 4500
|
||||
[/consume MP]
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, err := parseSkill(8, 138, doc.Path, doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !definition.Active {
|
||||
t.Fatalf("untyped executable skill must be quick-slot eligible: %+v", definition)
|
||||
}
|
||||
|
||||
passiveDoc, err := dnfpvf.Parse("skill/ATMage/WindMaster.skl", `
|
||||
[type]
|
||||
`+"`"+`[passive]`+"`"+`
|
||||
[executable states]
|
||||
`+"`"+`[stand]`+"`"+`
|
||||
[/executable states]
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passive, err := parseSkill(8, 75, passiveDoc.Path, passiveDoc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if passive.Active {
|
||||
t.Fatalf("explicit passive type must remain non-active: %+v", passive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTreatsEmptyFixedLevelSectionAsAutomatic(t *testing.T) {
|
||||
doc, err := dnfpvf.Parse("skill/Mage/Bellatrix.skl", `
|
||||
[name]
|
||||
`+"`"+`Teana Transformation`+"`"+`
|
||||
[required level]
|
||||
50
|
||||
[required level range]
|
||||
5
|
||||
[maximum level]
|
||||
40
|
||||
[fixed level skill]
|
||||
[interval level]
|
||||
5
|
||||
[add level per interval]
|
||||
1
|
||||
[/fixed level skill]
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, err := parseSkill(3, 83, doc.Path, doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !definition.FixedLevelSkill || definition.FixedLevelBase != 1 ||
|
||||
definition.FixedLevelInterval != 5 || definition.FixedLevelIncrement != 1 ||
|
||||
definition.RequiredLevelRange != 5 || definition.FixedLevelForCharacter(90) != 9 {
|
||||
t.Fatalf("unexpected container-style fixed-level metadata: %+v", definition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReportsEmptyList(t *testing.T) {
|
||||
index, err := dnfpvf.Build(context.Background(), textSource{
|
||||
"skill/skilllist.lst": "0 `SwordmanSkill.lst`\n",
|
||||
}, dnfpvf.BuildOptions{Paths: []string{"skill/skilllist.lst"}})
|
||||
if err != nil {
|
||||
t.Fatalf("build index: %v", err)
|
||||
}
|
||||
_, err = Load(context.Background(), index, Options{})
|
||||
if !errors.Is(err, ErrListEmpty) {
|
||||
t.Fatalf("expected ErrListEmpty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testIndex(t *testing.T) *dnfpvf.Index {
|
||||
t.Helper()
|
||||
index, err := dnfpvf.Build(context.Background(), textSource{
|
||||
"skill/skilllist.lst": "0 `SwordmanSkill.lst`\n1 `FighterSkill.lst`\n",
|
||||
"skill/SwordmanSkill.lst": "46 `Swordman/UpperSlash.skl`\n",
|
||||
"skill/FighterSkill.lst": "46 `Fighter/MuseUpper.skl`\n",
|
||||
"skill/Swordman/UpperSlash.skl": `
|
||||
[name]
|
||||
` + "`upper slash`" + `
|
||||
[type]
|
||||
` + "`[active]`" + `
|
||||
[required level]
|
||||
1
|
||||
[required level range]
|
||||
5
|
||||
[maximum level]
|
||||
20
|
||||
[fixed level skill]
|
||||
1
|
||||
[interval level]
|
||||
5
|
||||
[add level per interval]
|
||||
2
|
||||
[skill class]
|
||||
1
|
||||
[skill fitness growtype]
|
||||
0 1 2
|
||||
[/skill fitness growtype]
|
||||
[growtype maximum level]
|
||||
0 0 20
|
||||
[/growtype maximum level]
|
||||
[second growtype maximum level]
|
||||
0 0 0 0 0 20
|
||||
[/second growtype maximum level]
|
||||
[skill fitness second growtype]
|
||||
2
|
||||
[/skill fitness second growtype]
|
||||
[pre required skill]
|
||||
20 1
|
||||
[/pre required skill]
|
||||
[purchase cost]
|
||||
20
|
||||
[/purchase cost]
|
||||
[special purchase cost]
|
||||
2
|
||||
[/special purchase cost]
|
||||
[feature skill type]
|
||||
1
|
||||
[feature skill index]
|
||||
217
|
||||
[cool time]
|
||||
2000
|
||||
[consume MP]
|
||||
12
|
||||
[power]
|
||||
120
|
||||
`,
|
||||
"skill/Fighter/MuseUpper.skl": `
|
||||
[name]
|
||||
` + "`muse upper`" + `
|
||||
[type]
|
||||
` + "`[passive]`" + `
|
||||
`,
|
||||
}, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
|
||||
if err != nil {
|
||||
t.Fatalf("build index: %v", err)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
type textSource map[string]string
|
||||
|
||||
func (s textSource) ReadText(relativePath string) (string, error) {
|
||||
for key, value := range s {
|
||||
if pathKey(key) == pathKey(relativePath) {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return "", dnfpvf.ErrDocNotFound
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// This file maps one current PVF .skl document into domain fields.
|
||||
package skill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
func parseSkill(job byte, id uint16, skillPath string, doc *dnfpvf.Document) (Skill, error) {
|
||||
kind := firstText(doc, "skill type", "type", "kind")
|
||||
fixedLevelBase, fixedLevelValue := doc.Int("fixed level skill")
|
||||
_, fixedLevelSection := doc.Section("fixed level skill")
|
||||
fixedLevelSkill := fixedLevelValue || fixedLevelSection
|
||||
if fixedLevelSection && !fixedLevelValue {
|
||||
fixedLevelBase = 1
|
||||
}
|
||||
fixedLevelInterval := firstIntValue(doc, "interval level")
|
||||
if fixedLevelInterval <= 0 {
|
||||
fixedLevelInterval = 1
|
||||
}
|
||||
fixedLevelIncrement := firstIntValue(doc, "add level per interval")
|
||||
if fixedLevelIncrement <= 0 {
|
||||
fixedLevelIncrement = 1
|
||||
}
|
||||
prerequisites, err := parsePrerequisites(doc)
|
||||
if err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
definition := Skill{
|
||||
Job: job,
|
||||
ID: id,
|
||||
Path: skillPath,
|
||||
Name: firstText(doc, "name", "display name"),
|
||||
Kind: kind,
|
||||
Active: skillIsActive(kind, doc),
|
||||
RequiredLevel: firstIntValue(doc, "required level", "minimum level", "level"),
|
||||
RequiredLevelRange: firstIntValue(doc, "required level range"),
|
||||
MaximumLevel: firstIntValue(doc, "maximum level", "max level"),
|
||||
FixedLevelSkill: fixedLevelSkill,
|
||||
FixedLevelBase: int(fixedLevelBase),
|
||||
FixedLevelInterval: fixedLevelInterval,
|
||||
FixedLevelIncrement: fixedLevelIncrement,
|
||||
SkillClass: firstIntValue(doc, "skill class"),
|
||||
GrowTypes: intValues(doc, "skill fitness growtype"),
|
||||
GrowTypeMaximumLevels: intValues(doc, "growtype maximum level"),
|
||||
SecondGrowTypes: intValues(doc, "skill fitness second growtype"),
|
||||
SecondGrowTypeMaximumLevels: intValues(doc, "second growtype maximum level"),
|
||||
FeatureSkillType: firstIntValue(doc, "feature skill type"),
|
||||
FeatureSkillIndex: firstIntValue(doc, "feature skill index"),
|
||||
Prerequisites: prerequisites,
|
||||
PurchaseCost: intValues(doc, "purchase cost"),
|
||||
SpecialPurchaseCost: intValues(doc, "special purchase cost"),
|
||||
CoolTime: firstNumberOrZero(doc, "cool time", "cooltime", "cool down time"),
|
||||
MPCost: firstInt(doc, "mp consume", "mp cost", "consume mp"),
|
||||
CastTime: firstNumberOrZero(doc, "casting time", "cast time"),
|
||||
Command: firstText(doc, "command", "input command"),
|
||||
Icon: firstText(doc, "icon", "icon path"),
|
||||
Scalars: scalars(doc),
|
||||
}
|
||||
if definition.Name == "" {
|
||||
definition.Name = definition.Path
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
// skillIsActive keeps the explicit [type] declaration authoritative, while
|
||||
// accepting the two 90-era Wind Mage high-level skills whose PVF documents
|
||||
// intentionally omit [type]. Those documents still have an executable-state
|
||||
// section, which is the client-facing marker that the skill can be cast. A
|
||||
// passive skill has an explicit non-active type or no executable states, so it
|
||||
// cannot be promoted merely because it has a cost/stat section.
|
||||
func skillIsActive(kind string, doc *dnfpvf.Document) bool {
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
if strings.Contains(kind, "active") {
|
||||
return true
|
||||
}
|
||||
if kind != "" || doc == nil {
|
||||
return false
|
||||
}
|
||||
states := doc.Texts("executable states")
|
||||
return len(states) != 0
|
||||
}
|
||||
|
||||
func parsePrerequisites(doc *dnfpvf.Document) ([]Prerequisite, error) {
|
||||
values := doc.Ints("pre required skill")
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(values)%2 != 0 {
|
||||
return nil, fmt.Errorf("%w: odd value count %d", ErrPrerequisite, len(values))
|
||||
}
|
||||
out := make([]Prerequisite, 0, len(values)/2)
|
||||
for idx := 0; idx < len(values); idx += 2 {
|
||||
if values[idx] < 0 || values[idx] > math.MaxUint16 || values[idx+1] < 0 {
|
||||
return nil, fmt.Errorf("%w: skill=%d level=%d", ErrPrerequisite, values[idx], values[idx+1])
|
||||
}
|
||||
out = append(out, Prerequisite{SkillID: uint16(values[idx]), Level: int(values[idx+1])})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scalars(doc *dnfpvf.Document) map[string]float64 {
|
||||
names := map[string][]string{
|
||||
"power": {"power", "attack power"},
|
||||
"hit_count": {"hit count", "multi hit count"},
|
||||
"range": {"range", "attack range"},
|
||||
"duration": {"duration", "buff duration"},
|
||||
"cool_time": {"cool time", "cooltime"},
|
||||
"cast_time": {"casting time", "cast time"},
|
||||
"consume_mp": {"mp consume", "mp cost", "consume mp"},
|
||||
"required_sp": {"required sp", "sp cost"},
|
||||
}
|
||||
out := make(map[string]float64)
|
||||
for key, aliases := range names {
|
||||
if value, ok := firstNumber(doc, aliases...); ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstText(doc *dnfpvf.Document, names ...string) string {
|
||||
for _, name := range names {
|
||||
if value, ok := doc.Text(name); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstInt(doc *dnfpvf.Document, names ...string) int64 {
|
||||
for _, name := range names {
|
||||
if value, ok := doc.Int(name); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstIntValue(doc *dnfpvf.Document, names ...string) int {
|
||||
return int(firstInt(doc, names...))
|
||||
}
|
||||
|
||||
func intValues(doc *dnfpvf.Document, name string) []int {
|
||||
values := doc.Ints(name)
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, len(values))
|
||||
for idx, value := range values {
|
||||
out[idx] = int(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNumber(doc *dnfpvf.Document, names ...string) (float64, bool) {
|
||||
for _, name := range names {
|
||||
if value, ok := doc.Number(name); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func firstNumberOrZero(doc *dnfpvf.Document, names ...string) float64 {
|
||||
value, _ := firstNumber(doc, names...)
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func pathKey(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
for strings.HasPrefix(value, "./") || strings.HasPrefix(value, "/") {
|
||||
value = strings.TrimPrefix(value, "./")
|
||||
value = strings.TrimPrefix(value, "/")
|
||||
}
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
clean := strings.TrimSuffix(path.Clean(value), "/")
|
||||
if clean == "." {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(clean)
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSceneOwnerUnavailable = errors.New("scene skill owner is unavailable")
|
||||
ErrCharacterRequired = errors.New("scene skill character id is required")
|
||||
ErrSkillRecordMissing = errors.New("scene skill record is missing")
|
||||
ErrSeedInvalid = errors.New("scene skill seed is invalid")
|
||||
ErrLayoutBuilderRequired = errors.New("scene skill layout builder is required")
|
||||
)
|
||||
|
||||
type BackfillCommand struct {
|
||||
CharacterID string
|
||||
Seed dnfrepo.SkillRecord
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type SyncPointsCommand struct {
|
||||
CharacterID string
|
||||
TreeIndex int
|
||||
Target dnfrepo.SkillPointState
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type EnsureTreeCommand struct {
|
||||
CharacterID string
|
||||
TreeIndex int
|
||||
State dnfrepo.SkillTreeState
|
||||
Layout dnfrepo.SkillLayout
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type EnsureBaselineCommand struct {
|
||||
CharacterID string
|
||||
TreeCount int
|
||||
Levels map[uint16]int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type LayoutBuilder func(dnfrepo.SkillRecord) (dnfrepo.SkillLayout, error)
|
||||
|
||||
type EnsureLayoutCommand struct {
|
||||
CharacterID string
|
||||
TreeIndex int
|
||||
UpdatedAt time.Time
|
||||
Build LayoutBuilder
|
||||
}
|
||||
|
||||
// SceneOwner owns missing-record backfill, SP/TP ledger repair, and missing
|
||||
// quick-layout persistence. Current-client protobuf projection and PVF layout
|
||||
// selection remain outside this repository boundary.
|
||||
type SceneOwner struct {
|
||||
skills dnfrepo.CharacterSkillUnitOfWork
|
||||
}
|
||||
|
||||
func NewSceneOwner(repositories dnfrepo.Group) (*SceneOwner, error) {
|
||||
if repositories.CharacterSkills == nil {
|
||||
return nil, ErrSceneOwnerUnavailable
|
||||
}
|
||||
return &SceneOwner{skills: repositories.CharacterSkills}, nil
|
||||
}
|
||||
|
||||
func (o *SceneOwner) Backfill(ctx context.Context, command BackfillCommand) (dnfrepo.SkillRecord, bool, error) {
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return dnfrepo.SkillRecord{}, false, ErrCharacterRequired
|
||||
}
|
||||
if strings.TrimSpace(command.Seed.CharacterID) != characterID || len(command.Seed.Skills) == 0 {
|
||||
return dnfrepo.SkillRecord{}, false, ErrSeedInvalid
|
||||
}
|
||||
if o == nil || o.skills == nil {
|
||||
return dnfrepo.SkillRecord{}, false, ErrSceneOwnerUnavailable
|
||||
}
|
||||
ctx = normalizedSceneContext(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return dnfrepo.SkillRecord{}, false, err
|
||||
}
|
||||
seed := dnfrepo.CloneSkill(command.Seed)
|
||||
seed.UpdatedAt = normalizedSceneTime(command.UpdatedAt)
|
||||
selected := dnfrepo.SkillRecord{}
|
||||
persisted := false
|
||||
err := o.skills.WithinCharacterSkill(ctx, characterID, func(skills dnfrepo.SkillRepository) error {
|
||||
current, found, err := skills.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found && len(current.Skills) > 0 {
|
||||
selected = dnfrepo.CloneSkill(current)
|
||||
return nil
|
||||
}
|
||||
if found {
|
||||
seed.Cooldowns = current.Cooldowns
|
||||
}
|
||||
if err := dnfrepo.SaveSkillFields(
|
||||
ctx,
|
||||
skills,
|
||||
seed,
|
||||
dnfrepo.SkillFieldSkills,
|
||||
dnfrepo.SkillFieldPoints,
|
||||
dnfrepo.SkillFieldTrees,
|
||||
dnfrepo.SkillFieldLayouts,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
selected = dnfrepo.CloneSkill(seed)
|
||||
persisted = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return dnfrepo.SkillRecord{}, false, err
|
||||
}
|
||||
if len(selected.Skills) == 0 {
|
||||
return dnfrepo.SkillRecord{}, false, ErrSkillRecordMissing
|
||||
}
|
||||
return selected, persisted, nil
|
||||
}
|
||||
|
||||
func (o *SceneOwner) SyncPoints(ctx context.Context, command SyncPointsCommand) (dnfrepo.SkillRecord, bool, error) {
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return dnfrepo.SkillRecord{}, false, ErrCharacterRequired
|
||||
}
|
||||
if o == nil || o.skills == nil {
|
||||
return dnfrepo.SkillRecord{}, false, ErrSceneOwnerUnavailable
|
||||
}
|
||||
ctx = normalizedSceneContext(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return dnfrepo.SkillRecord{}, false, err
|
||||
}
|
||||
selected := dnfrepo.SkillRecord{}
|
||||
persisted := false
|
||||
err := o.skills.WithinCharacterSkill(ctx, characterID, func(skills dnfrepo.SkillRepository) error {
|
||||
current, found, err := skills.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || len(current.Skills) == 0 {
|
||||
return fmt.Errorf("%w: character=%s", ErrSkillRecordMissing, characterID)
|
||||
}
|
||||
state, treeFound := dnfrepo.SkillTree(current, command.TreeIndex)
|
||||
if !treeFound {
|
||||
return fmt.Errorf("%w: character=%s tree=%d", ErrSkillRecordMissing, characterID, command.TreeIndex)
|
||||
}
|
||||
state.Points, persisted, err = SyncPointState(state.Points, command.Target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if persisted {
|
||||
dnfrepo.SetSkillTree(¤t, command.TreeIndex, state)
|
||||
current.UpdatedAt = normalizedSceneTime(command.UpdatedAt)
|
||||
field := dnfrepo.SkillFieldPoints
|
||||
if command.TreeIndex != 0 {
|
||||
field = dnfrepo.SkillFieldTrees
|
||||
}
|
||||
if err := dnfrepo.SaveSkillFields(ctx, skills, current, field); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
selected = dnfrepo.CloneSkill(current)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return dnfrepo.SkillRecord{}, false, err
|
||||
}
|
||||
return selected, persisted, nil
|
||||
}
|
||||
|
||||
// EnsureTree creates only a missing alternate plan. Existing learned levels,
|
||||
// point ledgers, and layouts are left untouched.
|
||||
func (o *SceneOwner) EnsureTree(ctx context.Context, command EnsureTreeCommand) (dnfrepo.SkillRecord, bool, error) {
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return dnfrepo.SkillRecord{}, false, ErrCharacterRequired
|
||||
}
|
||||
if command.TreeIndex <= 0 || len(command.State.Skills) == 0 || len(command.Layout) == 0 {
|
||||
return dnfrepo.SkillRecord{}, false, ErrSeedInvalid
|
||||
}
|
||||
if o == nil || o.skills == nil {
|
||||
return dnfrepo.SkillRecord{}, false, ErrSceneOwnerUnavailable
|
||||
}
|
||||
ctx = normalizedSceneContext(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return dnfrepo.SkillRecord{}, false, err
|
||||
}
|
||||
selected := dnfrepo.SkillRecord{}
|
||||
persisted := false
|
||||
err := o.skills.WithinCharacterSkill(ctx, characterID, func(skills dnfrepo.SkillRepository) error {
|
||||
current, found, err := skills.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || len(current.Skills) == 0 {
|
||||
return fmt.Errorf("%w: character=%s", ErrSkillRecordMissing, characterID)
|
||||
}
|
||||
if _, exists := dnfrepo.SkillTree(current, command.TreeIndex); exists {
|
||||
selected = dnfrepo.CloneSkill(current)
|
||||
return nil
|
||||
}
|
||||
current = dnfrepo.CloneSkill(current)
|
||||
dnfrepo.SetSkillTree(¤t, command.TreeIndex, command.State)
|
||||
if current.Layouts == nil {
|
||||
current.Layouts = make(map[int]dnfrepo.SkillLayout)
|
||||
}
|
||||
current.Layouts[command.TreeIndex] = cloneSceneLayout(command.Layout)
|
||||
current.UpdatedAt = normalizedSceneTime(command.UpdatedAt)
|
||||
if err := dnfrepo.SaveSkillFields(ctx, skills, current, dnfrepo.SkillFieldTrees, dnfrepo.SkillFieldLayouts); err != nil {
|
||||
return err
|
||||
}
|
||||
selected = dnfrepo.CloneSkill(current)
|
||||
persisted = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return dnfrepo.SkillRecord{}, false, err
|
||||
}
|
||||
return selected, persisted, nil
|
||||
}
|
||||
|
||||
// EnsureBaseline idempotently restores PVF-owned free skills in every current
|
||||
// EXE skill plan. It only raises missing/lower free levels and never spends or
|
||||
// refunds SP/TP, removes learned skills, or changes quick-slot layouts.
|
||||
func (o *SceneOwner) EnsureBaseline(ctx context.Context, command EnsureBaselineCommand) (dnfrepo.SkillRecord, bool, error) {
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return dnfrepo.SkillRecord{}, false, ErrCharacterRequired
|
||||
}
|
||||
if command.TreeCount <= 0 || len(command.Levels) == 0 {
|
||||
return dnfrepo.SkillRecord{}, false, ErrSeedInvalid
|
||||
}
|
||||
for skillID, level := range command.Levels {
|
||||
if skillID == 0 || level <= 0 {
|
||||
return dnfrepo.SkillRecord{}, false, ErrSeedInvalid
|
||||
}
|
||||
}
|
||||
if o == nil || o.skills == nil {
|
||||
return dnfrepo.SkillRecord{}, false, ErrSceneOwnerUnavailable
|
||||
}
|
||||
ctx = normalizedSceneContext(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return dnfrepo.SkillRecord{}, false, err
|
||||
}
|
||||
selected := dnfrepo.SkillRecord{}
|
||||
persisted := false
|
||||
err := o.skills.WithinCharacterSkill(ctx, characterID, func(skills dnfrepo.SkillRepository) error {
|
||||
current, found, err := skills.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || len(current.Skills) == 0 {
|
||||
return fmt.Errorf("%w: character=%s", ErrSkillRecordMissing, characterID)
|
||||
}
|
||||
current = dnfrepo.CloneSkill(current)
|
||||
for tree := 0; tree < command.TreeCount; tree++ {
|
||||
state, treeFound := dnfrepo.SkillTree(current, tree)
|
||||
if !treeFound {
|
||||
return fmt.Errorf("%w: character=%s tree=%d", ErrSkillRecordMissing, characterID, tree)
|
||||
}
|
||||
if state.Skills == nil {
|
||||
state.Skills = make(map[int64]dnfrepo.SkillState)
|
||||
}
|
||||
treeChanged := false
|
||||
for skillID, level := range command.Levels {
|
||||
stateEntry := state.Skills[int64(skillID)]
|
||||
if stateEntry.Enabled && stateEntry.Level >= level {
|
||||
continue
|
||||
}
|
||||
if stateEntry.Level < level {
|
||||
stateEntry.Level = level
|
||||
}
|
||||
stateEntry.Enabled = true
|
||||
state.Skills[int64(skillID)] = stateEntry
|
||||
treeChanged = true
|
||||
}
|
||||
if treeChanged {
|
||||
dnfrepo.SetSkillTree(¤t, tree, state)
|
||||
persisted = true
|
||||
}
|
||||
}
|
||||
if persisted {
|
||||
current.UpdatedAt = normalizedSceneTime(command.UpdatedAt)
|
||||
if err := dnfrepo.SaveSkillFields(
|
||||
ctx,
|
||||
skills,
|
||||
current,
|
||||
dnfrepo.SkillFieldSkills,
|
||||
dnfrepo.SkillFieldTrees,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
selected = dnfrepo.CloneSkill(current)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return dnfrepo.SkillRecord{}, false, err
|
||||
}
|
||||
return selected, persisted, nil
|
||||
}
|
||||
|
||||
func (o *SceneOwner) EnsureLayout(ctx context.Context, command EnsureLayoutCommand) (dnfrepo.SkillRecord, dnfrepo.SkillLayout, bool, error) {
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return dnfrepo.SkillRecord{}, nil, false, ErrCharacterRequired
|
||||
}
|
||||
if command.Build == nil {
|
||||
return dnfrepo.SkillRecord{}, nil, false, ErrLayoutBuilderRequired
|
||||
}
|
||||
if o == nil || o.skills == nil {
|
||||
return dnfrepo.SkillRecord{}, nil, false, ErrSceneOwnerUnavailable
|
||||
}
|
||||
ctx = normalizedSceneContext(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return dnfrepo.SkillRecord{}, nil, false, err
|
||||
}
|
||||
selected := dnfrepo.SkillRecord{}
|
||||
var selectedLayout dnfrepo.SkillLayout
|
||||
persisted := false
|
||||
err := o.skills.WithinCharacterSkill(ctx, characterID, func(skills dnfrepo.SkillRepository) error {
|
||||
current, found, err := skills.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || len(current.Skills) == 0 {
|
||||
return fmt.Errorf("%w: character=%s", ErrSkillRecordMissing, characterID)
|
||||
}
|
||||
layout, err := command.Build(dnfrepo.CloneSkill(current))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selected = dnfrepo.CloneSkill(current)
|
||||
selectedLayout = cloneSceneLayout(layout)
|
||||
if existing := current.Layouts[command.TreeIndex]; reflect.DeepEqual(existing, layout) {
|
||||
return nil
|
||||
}
|
||||
if current.Layouts == nil {
|
||||
current.Layouts = make(map[int]dnfrepo.SkillLayout)
|
||||
}
|
||||
current.Layouts[command.TreeIndex] = cloneSceneLayout(layout)
|
||||
current.UpdatedAt = normalizedSceneTime(command.UpdatedAt)
|
||||
if err := dnfrepo.SaveSkillFields(ctx, skills, current, dnfrepo.SkillFieldLayouts); err != nil {
|
||||
return err
|
||||
}
|
||||
selected = dnfrepo.CloneSkill(current)
|
||||
persisted = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return dnfrepo.SkillRecord{}, nil, false, err
|
||||
}
|
||||
return selected, selectedLayout, persisted, nil
|
||||
}
|
||||
|
||||
func SyncPointState(current dnfrepo.SkillPointState, target dnfrepo.SkillPointState) (dnfrepo.SkillPointState, bool, error) {
|
||||
if current.TotalSP < 0 || current.RemainingSP < 0 || current.RemainingSP > current.TotalSP ||
|
||||
current.TotalTP < 0 || current.RemainingTP < 0 || current.RemainingTP > current.TotalTP ||
|
||||
target.TotalSP < 0 || target.TotalTP < 0 || target.SyncedLevel <= 0 {
|
||||
return dnfrepo.SkillPointState{}, false, fmt.Errorf(
|
||||
"invalid SP/TP ledger sync current=%+v target=%+v",
|
||||
current,
|
||||
target,
|
||||
)
|
||||
}
|
||||
spentSP := current.TotalSP - current.RemainingSP
|
||||
spentTP := current.TotalTP - current.RemainingTP
|
||||
next := target
|
||||
next.RemainingSP = next.TotalSP - spentSP
|
||||
if next.RemainingSP < 0 {
|
||||
next.RemainingSP = 0
|
||||
}
|
||||
next.RemainingTP = next.TotalTP - spentTP
|
||||
if next.RemainingTP < 0 {
|
||||
next.RemainingTP = 0
|
||||
}
|
||||
return next, next != current, nil
|
||||
}
|
||||
|
||||
func normalizedSceneContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func normalizedSceneTime(value time.Time) time.Time {
|
||||
value = value.UTC()
|
||||
if value.IsZero() {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func cloneSceneLayout(layout dnfrepo.SkillLayout) dnfrepo.SkillLayout {
|
||||
if layout == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(dnfrepo.SkillLayout, len(layout))
|
||||
for slot, skillID := range layout {
|
||||
cloned[slot] = skillID
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestSceneOwnerBackfillPersistsSeedAndPreservesCooldowns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Skill.Save(ctx, dnfrepo.SkillRecord{
|
||||
CharacterID: "77",
|
||||
Cooldowns: map[int64]time.Time{46: time.Unix(1234, 0).UTC()},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewSceneOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSceneOwner error = %v", err)
|
||||
}
|
||||
seed := dnfrepo.SkillRecord{
|
||||
CharacterID: "77",
|
||||
Skills: map[int64]dnfrepo.SkillState{46: {Level: 1, Enabled: true}},
|
||||
Points: dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, SyncedLevel: 1},
|
||||
Layouts: map[int]dnfrepo.SkillLayout{0: {0: 46}},
|
||||
}
|
||||
result, persisted, err := owner.Backfill(ctx, BackfillCommand{CharacterID: "77", Seed: seed})
|
||||
if err != nil || !persisted {
|
||||
t.Fatalf("Backfill result=%+v persisted=%t error=%v", result, persisted, err)
|
||||
}
|
||||
if result.Skills[46].Level != 1 || result.Cooldowns[46].Unix() != 1234 || result.Layouts[0][0] != 46 {
|
||||
t.Fatalf("Backfill result = %+v", result)
|
||||
}
|
||||
saved, found, err := repositories.Skill.Load(ctx, "77")
|
||||
if err != nil || !found || saved.Cooldowns[46].Unix() != 1234 || saved.Skills[46].Level != 1 {
|
||||
t.Fatalf("saved=%+v found=%t error=%v", saved, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneOwnerSyncPointsPreservesSpentLedger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
seedSceneSkillRecord(t, ctx, repositories)
|
||||
owner, err := NewSceneOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, persisted, err := owner.SyncPoints(ctx, SyncPointsCommand{
|
||||
CharacterID: "77",
|
||||
Target: dnfrepo.SkillPointState{
|
||||
TotalSP: 150,
|
||||
TotalTP: 30,
|
||||
SyncedLevel: 20,
|
||||
},
|
||||
})
|
||||
if err != nil || !persisted {
|
||||
t.Fatalf("SyncPoints result=%+v persisted=%t error=%v", result, persisted, err)
|
||||
}
|
||||
want := dnfrepo.SkillPointState{
|
||||
TotalSP: 150,
|
||||
RemainingSP: 110,
|
||||
TotalTP: 30,
|
||||
RemainingTP: 20,
|
||||
SyncedLevel: 20,
|
||||
}
|
||||
if result.Points != want {
|
||||
t.Fatalf("points=%+v want=%+v", result.Points, want)
|
||||
}
|
||||
saved, _, _ := repositories.Skill.Load(ctx, "77")
|
||||
if saved.Points != want {
|
||||
t.Fatalf("saved points=%+v want=%+v", saved.Points, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneOwnerEnsureAndSyncAlternateTreePreservesPrimary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
seedSceneSkillRecord(t, ctx, repositories)
|
||||
owner, err := NewSceneOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _, _ := repositories.Skill.Load(ctx, "77")
|
||||
primaryBefore, _ := dnfrepo.SkillTree(before, 0)
|
||||
|
||||
created, persisted, err := owner.EnsureTree(ctx, EnsureTreeCommand{
|
||||
CharacterID: "77",
|
||||
TreeIndex: 1,
|
||||
State: dnfrepo.SkillTreeState{
|
||||
Skills: map[int64]dnfrepo.SkillState{46: {Level: 1, Enabled: true}},
|
||||
Points: dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 90, TotalTP: 20, RemainingTP: 20, SyncedLevel: 10},
|
||||
},
|
||||
Layout: dnfrepo.SkillLayout{150: 46},
|
||||
})
|
||||
if err != nil || !persisted {
|
||||
t.Fatalf("EnsureTree result=%+v persisted=%t error=%v", created, persisted, err)
|
||||
}
|
||||
updated, synced, err := owner.SyncPoints(ctx, SyncPointsCommand{
|
||||
CharacterID: "77",
|
||||
TreeIndex: 1,
|
||||
Target: dnfrepo.SkillPointState{TotalSP: 150, TotalTP: 30, SyncedLevel: 20},
|
||||
})
|
||||
if err != nil || !synced {
|
||||
t.Fatalf("SyncPoints(tree 1) result=%+v persisted=%t error=%v", updated, synced, err)
|
||||
}
|
||||
primaryAfter, _ := dnfrepo.SkillTree(updated, 0)
|
||||
alternate, found := dnfrepo.SkillTree(updated, 1)
|
||||
if !reflect.DeepEqual(primaryAfter, primaryBefore) {
|
||||
t.Fatalf("alternate-tree operations changed primary: before=%+v after=%+v", primaryBefore, primaryAfter)
|
||||
}
|
||||
if !found || alternate.Points.RemainingSP != 140 || alternate.Points.RemainingTP != 30 || updated.Layouts[1][150] != 46 {
|
||||
t.Fatalf("alternate tree=%+v layout=%+v", alternate, updated.Layouts[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneOwnerEnsureBaselineRepairsEveryTreeIdempotently(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
seedSceneSkillRecord(t, ctx, repositories)
|
||||
record, _, _ := repositories.Skill.Load(ctx, "77")
|
||||
record.Skills[49] = dnfrepo.SkillState{Level: 2}
|
||||
dnfrepo.SetSkillTree(&record, 1, dnfrepo.SkillTreeState{
|
||||
Skills: map[int64]dnfrepo.SkillState{
|
||||
46: {Level: 1, Enabled: true},
|
||||
49: {Level: 3},
|
||||
},
|
||||
Points: dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 25, TotalTP: 20, RemainingTP: 5, SyncedLevel: 10},
|
||||
})
|
||||
record.Layouts[1] = dnfrepo.SkillLayout{7: 49}
|
||||
if err := repositories.Skill.Save(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewSceneOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updated, persisted, err := owner.EnsureBaseline(ctx, EnsureBaselineCommand{
|
||||
CharacterID: "77",
|
||||
TreeCount: 2,
|
||||
Levels: map[uint16]int{46: 1, 48: 1, 49: 1},
|
||||
})
|
||||
if err != nil || !persisted {
|
||||
t.Fatalf("EnsureBaseline result=%+v persisted=%t error=%v", updated, persisted, err)
|
||||
}
|
||||
primary, primaryFound := dnfrepo.SkillTree(updated, 0)
|
||||
alternate, alternateFound := dnfrepo.SkillTree(updated, 1)
|
||||
if !primaryFound || !alternateFound || primary.Skills[48].Level != 1 || !primary.Skills[48].Enabled ||
|
||||
primary.Skills[49].Level != 2 || !primary.Skills[49].Enabled ||
|
||||
alternate.Skills[48].Level != 1 || !alternate.Skills[48].Enabled ||
|
||||
alternate.Skills[49].Level != 3 || !alternate.Skills[49].Enabled {
|
||||
t.Fatalf("baseline trees primary=%+v alternate=%+v", primary, alternate)
|
||||
}
|
||||
if primary.Points != record.Points || alternate.Points.RemainingSP != 25 || !reflect.DeepEqual(updated.Layouts[1], dnfrepo.SkillLayout{7: 49}) {
|
||||
t.Fatalf("baseline changed points/layouts: primary=%+v alternate=%+v layouts=%+v", primary.Points, alternate.Points, updated.Layouts)
|
||||
}
|
||||
|
||||
again, persisted, err := owner.EnsureBaseline(ctx, EnsureBaselineCommand{
|
||||
CharacterID: "77",
|
||||
TreeCount: 2,
|
||||
Levels: map[uint16]int{46: 1, 48: 1, 49: 1},
|
||||
})
|
||||
if err != nil || persisted || !reflect.DeepEqual(again, updated) {
|
||||
t.Fatalf("idempotent EnsureBaseline result=%+v persisted=%t error=%v", again, persisted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneOwnerEnsureLayoutPersistsOnceAndKeepsConcurrentExistingLayout(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
seedSceneSkillRecord(t, ctx, repositories)
|
||||
owner, err := NewSceneOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := dnfrepo.SkillLayout{0: 46, 4: 47}
|
||||
result, layout, persisted, err := owner.EnsureLayout(ctx, EnsureLayoutCommand{
|
||||
CharacterID: "77",
|
||||
TreeIndex: 0,
|
||||
Build: func(dnfrepo.SkillRecord) (dnfrepo.SkillLayout, error) {
|
||||
return want, nil
|
||||
},
|
||||
})
|
||||
if err != nil || !persisted || !reflect.DeepEqual(layout, want) || !reflect.DeepEqual(result.Layouts[0], want) {
|
||||
t.Fatalf("EnsureLayout result=%+v layout=%v persisted=%t error=%v", result, layout, persisted, err)
|
||||
}
|
||||
|
||||
existing := dnfrepo.SkillLayout{2: 47}
|
||||
saved, _, _ := repositories.Skill.Load(ctx, "77")
|
||||
saved.Layouts[0] = existing
|
||||
if err := repositories.Skill.Save(ctx, saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, layout, persisted, err = owner.EnsureLayout(ctx, EnsureLayoutCommand{
|
||||
CharacterID: "77",
|
||||
TreeIndex: 0,
|
||||
Build: func(record dnfrepo.SkillRecord) (dnfrepo.SkillLayout, error) {
|
||||
return record.Layouts[0], nil
|
||||
},
|
||||
})
|
||||
if err != nil || persisted || !reflect.DeepEqual(layout, existing) || !reflect.DeepEqual(result.Layouts[0], existing) {
|
||||
t.Fatalf("existing result=%+v layout=%v persisted=%t error=%v", result, layout, persisted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneOwnerEnsureLayoutBuilderFailureDoesNotPersist(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
seedSceneSkillRecord(t, ctx, repositories)
|
||||
owner, err := NewSceneOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("reject scene layout")
|
||||
_, _, persisted, err := owner.EnsureLayout(ctx, EnsureLayoutCommand{
|
||||
CharacterID: "77",
|
||||
TreeIndex: 0,
|
||||
Build: func(dnfrepo.SkillRecord) (dnfrepo.SkillLayout, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, wantErr) || persisted {
|
||||
t.Fatalf("EnsureLayout persisted=%t error=%v", persisted, err)
|
||||
}
|
||||
saved, _, _ := repositories.Skill.Load(ctx, "77")
|
||||
if len(saved.Layouts) != 0 {
|
||||
t.Fatalf("failed layout persisted: %+v", saved.Layouts)
|
||||
}
|
||||
}
|
||||
|
||||
func seedSceneSkillRecord(t *testing.T, ctx context.Context, repositories dnfrepo.Group) {
|
||||
t.Helper()
|
||||
if err := repositories.Skill.Save(ctx, dnfrepo.SkillRecord{
|
||||
CharacterID: "77",
|
||||
Skills: map[int64]dnfrepo.SkillState{
|
||||
46: {Level: 1, Enabled: true},
|
||||
47: {Level: 1, Enabled: true},
|
||||
},
|
||||
Points: dnfrepo.SkillPointState{
|
||||
TotalSP: 100,
|
||||
RemainingSP: 60,
|
||||
TotalTP: 20,
|
||||
RemainingTP: 10,
|
||||
SyncedLevel: 10,
|
||||
},
|
||||
Layouts: map[int]dnfrepo.SkillLayout{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// This file defines the immutable, job-scoped PVF skill catalog.
|
||||
package skill
|
||||
|
||||
import "errors"
|
||||
|
||||
const DefaultList = "skill/skilllist.lst"
|
||||
|
||||
var (
|
||||
ErrIndexRequired = errors.New("dnf skill index is required")
|
||||
ErrListEmpty = errors.New("dnf skill list is empty")
|
||||
ErrDocMissing = errors.New("dnf skill document is missing")
|
||||
ErrJobOutOfRange = errors.New("dnf skill job id is out of range")
|
||||
ErrIDOutOfRange = errors.New("dnf skill id is out of range")
|
||||
ErrPrerequisite = errors.New("dnf skill prerequisite is invalid")
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
ListPath string
|
||||
}
|
||||
|
||||
// Key identifies a skill in the namespace used by the current EXE and PVF.
|
||||
// Skill IDs are only unique within one job list.
|
||||
type Key struct {
|
||||
Job byte `json:"job"`
|
||||
ID uint16 `json:"id"`
|
||||
}
|
||||
|
||||
type Prerequisite struct {
|
||||
SkillID uint16 `json:"skill_id"`
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
type Skill struct {
|
||||
Job byte `json:"job"`
|
||||
ID uint16 `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Active bool `json:"active,omitempty"`
|
||||
RequiredLevel int `json:"required_level,omitempty"`
|
||||
RequiredLevelRange int `json:"required_level_range,omitempty"`
|
||||
MaximumLevel int `json:"maximum_level,omitempty"`
|
||||
FixedLevelSkill bool `json:"fixed_level_skill,omitempty"`
|
||||
FixedLevelBase int `json:"fixed_level_base,omitempty"`
|
||||
FixedLevelInterval int `json:"fixed_level_interval,omitempty"`
|
||||
FixedLevelIncrement int `json:"fixed_level_increment,omitempty"`
|
||||
SkillClass int `json:"skill_class,omitempty"`
|
||||
GrowTypes []int `json:"grow_types,omitempty"`
|
||||
GrowTypeMaximumLevels []int `json:"grow_type_maximum_levels,omitempty"`
|
||||
SecondGrowTypes []int `json:"second_grow_types,omitempty"`
|
||||
SecondGrowTypeMaximumLevels []int `json:"second_grow_type_maximum_levels,omitempty"`
|
||||
FeatureSkillType int `json:"feature_skill_type,omitempty"`
|
||||
FeatureSkillIndex int `json:"feature_skill_index,omitempty"`
|
||||
Prerequisites []Prerequisite `json:"prerequisites,omitempty"`
|
||||
PurchaseCost []int `json:"purchase_cost,omitempty"`
|
||||
SpecialPurchaseCost []int `json:"special_purchase_cost,omitempty"`
|
||||
CoolTime float64 `json:"cool_time,omitempty"`
|
||||
MPCost int64 `json:"mp_cost,omitempty"`
|
||||
CastTime float64 `json:"cast_time,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Scalars map[string]float64 `json:"scalars,omitempty"`
|
||||
}
|
||||
|
||||
func (s Skill) Key() Key {
|
||||
return Key{Job: s.Job, ID: s.ID}
|
||||
}
|
||||
|
||||
func (s Skill) SupportsGrowType(growType int) bool {
|
||||
// Explicit [skill fitness growtype] is the profession ownership marker.
|
||||
// The current PVF also gives 98 class-owned skills a positive base entry in
|
||||
// [growtype maximum level] so the client can display/preview them before a
|
||||
// class change. That preview entry must not grant server-side learnability.
|
||||
//
|
||||
// The current and both reference 2019 PVFs also contain four passives whose
|
||||
// explicit marker does not intersect their maximum vector at all: female
|
||||
// Spitfire Nitro Motor (job 5/16), male-Striker Shine Step and Strength Fire
|
||||
// Hand Strike (job 7/236,237), and Dragon Knight Dragonian Weapon
|
||||
// (job 12/126). The client tree is driven by each exclusive maximum. Treat
|
||||
// this exact, structurally provable contradiction as a malformed marker
|
||||
// instead of rejecting the class which actually owns the skill. Ambiguous
|
||||
// vectors and ordinary preview rows continue to use the explicit marker.
|
||||
if len(s.GrowTypes) != 0 {
|
||||
// Dark Knight and Creator are external professions. Their character
|
||||
// records deliberately keep first-grow branch zero, while the current
|
||||
// PVF reuses ordinary-profession fitness markers on several skills that
|
||||
// still have a real branch-zero maximum. Creator quick standing is
|
||||
// declared fit for branch 1, and Dark Knight TP rows commonly declare
|
||||
// branches 1..4; treating those markers as the packed character branch
|
||||
// makes both skill families impossible to learn. Limit the compatibility
|
||||
// alias to the two external jobs and require a positive branch-zero
|
||||
// maximum, so ordinary profession previews remain non-learnable.
|
||||
if growType == 0 && isExternalProfessionJob(s.Job) &&
|
||||
len(s.GrowTypeMaximumLevels) > 0 && s.GrowTypeMaximumLevels[0] > 0 {
|
||||
return true
|
||||
}
|
||||
if corrected, ok := s.exclusiveMaximumGrowTypeWhenFitnessContradicts(); ok {
|
||||
return growType == corrected
|
||||
}
|
||||
for _, candidate := range s.GrowTypes {
|
||||
if candidate == growType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
hasGrowthMaximum := false
|
||||
for _, maximum := range s.GrowTypeMaximumLevels {
|
||||
if maximum > 0 {
|
||||
hasGrowthMaximum = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasGrowthMaximum {
|
||||
return growType >= 0 && growType < len(s.GrowTypeMaximumLevels) && s.GrowTypeMaximumLevels[growType] > 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isExternalProfessionJob(job byte) bool {
|
||||
return job == 9 || job == 10
|
||||
}
|
||||
|
||||
func (s Skill) exclusiveMaximumGrowTypeWhenFitnessContradicts() (int, bool) {
|
||||
for _, declared := range s.GrowTypes {
|
||||
if declared >= 0 && declared < len(s.GrowTypeMaximumLevels) && s.GrowTypeMaximumLevels[declared] > 0 {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
exclusive := -1
|
||||
// Index zero is the unclassed/base preview row, never a profession repair.
|
||||
for branch := 1; branch < len(s.GrowTypeMaximumLevels); branch++ {
|
||||
if s.GrowTypeMaximumLevels[branch] <= 0 {
|
||||
continue
|
||||
}
|
||||
if exclusive >= 0 {
|
||||
return 0, false
|
||||
}
|
||||
exclusive = branch
|
||||
}
|
||||
return exclusive, exclusive >= 0
|
||||
}
|
||||
|
||||
// SupportsCharacterGrowth applies both PVF fitness dimensions to the packed
|
||||
// character grow_type byte. Low four bits select the class branch and high
|
||||
// four bits select the cumulative awakening stage.
|
||||
func (s Skill) SupportsCharacterGrowth(growType int) bool {
|
||||
if !s.SupportsGrowType(growType & 0x0f) {
|
||||
return false
|
||||
}
|
||||
return s.awakeningStageFits((growType >> 4) & 0x0f)
|
||||
}
|
||||
|
||||
func (s Skill) awakeningStageFits(awakeningStage int) bool {
|
||||
if len(s.SecondGrowTypes) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, minimumStage := range s.SecondGrowTypes {
|
||||
if minimumStage >= 0 && awakeningStage >= minimumStage {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SupportsCharacterAwakening applies ONLY the awakening dimension of the PVF
|
||||
// fitness pair (`[skill fitness second growtype]`) to the packed grow type.
|
||||
//
|
||||
// The two dimensions answer different questions and must not be relaxed
|
||||
// together:
|
||||
//
|
||||
// - the class branch (`[skill fitness growtype]`) decides which of the job's
|
||||
// rows the character owns. The client renders every row of the JOB, so a row
|
||||
// the player can click is learnable - see MaximumLearnableLevelForTree.
|
||||
// - the awakening stage decides whether the character has awakened at all.
|
||||
// The first awakening is granted by the awakening transition and the second
|
||||
// one requires its own quest, so this dimension keeps blocking purchases:
|
||||
// an unawakened character must not be able to buy an awakening skill simply
|
||||
// because the tree draws the row.
|
||||
func (s Skill) SupportsCharacterAwakening(growType int) bool {
|
||||
return s.awakeningStageFits((growType >> 4) & 0x0f)
|
||||
}
|
||||
|
||||
func (s Skill) IsTPSkill() bool {
|
||||
return s.FeatureSkillType != 0 || len(s.SpecialPurchaseCost) != 0
|
||||
}
|
||||
|
||||
// FixedLevelForCharacter derives the free PVF level of an automatically
|
||||
// growing skill. These levels are part of the profession baseline and never
|
||||
// consume SP/TP.
|
||||
func (s Skill) FixedLevelForCharacter(characterLevel int) int {
|
||||
if !s.FixedLevelSkill || characterLevel < s.RequiredLevel {
|
||||
return 0
|
||||
}
|
||||
interval := s.FixedLevelInterval
|
||||
if interval <= 0 {
|
||||
interval = 1
|
||||
}
|
||||
increment := s.FixedLevelIncrement
|
||||
if increment <= 0 {
|
||||
increment = 1
|
||||
}
|
||||
level := s.FixedLevelBase + (characterLevel-s.RequiredLevel)/interval*increment
|
||||
if level < 0 {
|
||||
return 0
|
||||
}
|
||||
if s.MaximumLevel > 0 && level > s.MaximumLevel {
|
||||
return s.MaximumLevel
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// MaximumLearnableLevel derives the highest purchased skill level allowed by
|
||||
// the character's effective level and packed profession growth. Positive PVF
|
||||
// limits are cumulative: the global maximum, first-grow maximum, awakening
|
||||
// maximum and required-level interval all constrain the result. A missing or
|
||||
// zero optional limit does not override another positive limit.
|
||||
func (s Skill) MaximumLearnableLevel(characterLevel int, growType int) int {
|
||||
if characterLevel < s.RequiredLevel || !s.SupportsCharacterGrowth(growType) {
|
||||
return 0
|
||||
}
|
||||
maximum := 0
|
||||
maximum = minimumPositive(maximum, s.MaximumLevel)
|
||||
|
||||
firstGrowType := growType & 0x0f
|
||||
if firstGrowType >= 0 && firstGrowType < len(s.GrowTypeMaximumLevels) {
|
||||
maximum = minimumPositive(maximum, s.GrowTypeMaximumLevels[firstGrowType])
|
||||
}
|
||||
|
||||
awakeningStage := (growType >> 4) & 0x0f
|
||||
if awakeningStage > 0 {
|
||||
index := firstGrowType*2 + awakeningStage - 1
|
||||
if index >= 0 && index < len(s.SecondGrowTypeMaximumLevels) {
|
||||
maximum = minimumPositive(maximum, s.SecondGrowTypeMaximumLevels[index])
|
||||
}
|
||||
}
|
||||
|
||||
if s.RequiredLevelRange > 0 {
|
||||
levelMaximum := 1 + (characterLevel-s.RequiredLevel)/s.RequiredLevelRange
|
||||
maximum = minimumPositive(maximum, levelMaximum)
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
// MaximumLearnableLevelForTree derives the highest purchased level for a skill
|
||||
// the client's own skill tree already offers.
|
||||
//
|
||||
// The tree is the authority on what may be learned. The current client renders
|
||||
// the skill rows of the character's JOB - including the rows that belong to the
|
||||
// other profession branches - and lets the player buy them, so a purchase must
|
||||
// not be refused for PVF profession fitness alone. The caps below still bound
|
||||
// it, and they stay meaningful for a character whose own branch carries no
|
||||
// positive `[growtype maximum level]` row: that case falls back to the largest
|
||||
// cap the document declares instead of dropping the bound entirely.
|
||||
//
|
||||
// Live evidence (2026-09-13): a level-50 base Gunner (grow_type 0) was offered
|
||||
// `Ez-8自爆者` (skill 28, `[skill fitness growtype] 3`,
|
||||
// `[growtype maximum level] 50 0 0 50 0 0`) and the purchase was refused, which
|
||||
// the player sees as "the tree shows it but learning does nothing".
|
||||
func (s Skill) MaximumLearnableLevelForTree(characterLevel int, growType int) int {
|
||||
if characterLevel < s.RequiredLevel {
|
||||
return 0
|
||||
}
|
||||
maximum := 0
|
||||
maximum = minimumPositive(maximum, s.MaximumLevel)
|
||||
|
||||
firstGrowType := growType & 0x0f
|
||||
maximum = minimumPositive(maximum, s.growTypeMaximumLevelForBranch(firstGrowType))
|
||||
|
||||
awakeningStage := (growType >> 4) & 0x0f
|
||||
maximum = minimumPositive(maximum, s.awakeningMaximumLevelForBranch(firstGrowType, awakeningStage))
|
||||
|
||||
if s.RequiredLevelRange > 0 {
|
||||
levelMaximum := 1 + (characterLevel-s.RequiredLevel)/s.RequiredLevelRange
|
||||
maximum = minimumPositive(maximum, levelMaximum)
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
// growTypeMaximumLevelForBranch returns the `[growtype maximum level]` entry for
|
||||
// the character's own branch, or the largest declared entry when that branch has
|
||||
// no positive cap of its own (the PVF only fills the branches it declares fit).
|
||||
func (s Skill) growTypeMaximumLevelForBranch(branch int) int {
|
||||
if branch >= 0 && branch < len(s.GrowTypeMaximumLevels) && s.GrowTypeMaximumLevels[branch] > 0 {
|
||||
return s.GrowTypeMaximumLevels[branch]
|
||||
}
|
||||
return largestPositive(s.GrowTypeMaximumLevels)
|
||||
}
|
||||
|
||||
// awakeningMaximumLevelForBranch returns the `[skill fitness second growtype]`
|
||||
// cap that applies to the character's awakening stage. It mirrors the
|
||||
// fitness-aware maximum: a stage the document does not cap contributes no bound,
|
||||
// because the awakening dimension is enforced as a purchase gate (see
|
||||
// SupportsCharacterAwakening) rather than through a zero maximum.
|
||||
func (s Skill) awakeningMaximumLevelForBranch(branch int, stage int) int {
|
||||
if stage <= 0 {
|
||||
return 0
|
||||
}
|
||||
index := branch*2 + stage - 1
|
||||
if index >= 0 && index < len(s.SecondGrowTypeMaximumLevels) {
|
||||
return s.SecondGrowTypeMaximumLevels[index]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func largestPositive(values []int) int {
|
||||
largest := 0
|
||||
for _, value := range values {
|
||||
if value > largest {
|
||||
largest = value
|
||||
}
|
||||
}
|
||||
return largest
|
||||
}
|
||||
|
||||
func minimumPositive(current int, candidate int) int {
|
||||
if candidate <= 0 {
|
||||
return current
|
||||
}
|
||||
if current <= 0 || candidate < current {
|
||||
return candidate
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func (s Skill) LevelCost(level int) int {
|
||||
costs := s.PurchaseCost
|
||||
if s.IsTPSkill() {
|
||||
costs = s.SpecialPurchaseCost
|
||||
}
|
||||
return costAtLevel(costs, level)
|
||||
}
|
||||
|
||||
func costAtLevel(costs []int, level int) int {
|
||||
if len(costs) == 0 || level < 0 {
|
||||
return 0
|
||||
}
|
||||
if level >= len(costs) {
|
||||
return costs[len(costs)-1]
|
||||
}
|
||||
return costs[level]
|
||||
}
|
||||
|
||||
type jobPathKey struct {
|
||||
Job byte
|
||||
Path string
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
skills []Skill
|
||||
byKey map[Key]int
|
||||
byPath map[jobPathKey]int
|
||||
byJob map[byte][]int
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Jobs int `json:"jobs"`
|
||||
Skills int `json:"skills"`
|
||||
}
|
||||
|
||||
// Skills returns a deep copy of the complete catalog.
|
||||
func (t *Table) Skills() []Skill {
|
||||
if t == nil || len(t.skills) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Skill, len(t.skills))
|
||||
for idx, definition := range t.skills {
|
||||
out[idx] = cloneSkill(definition)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SkillsForJob returns a deep copy of one job's definitions.
|
||||
func (t *Table) SkillsForJob(job byte) []Skill {
|
||||
if t == nil || len(t.byJob[job]) == 0 {
|
||||
return nil
|
||||
}
|
||||
indexes := t.byJob[job]
|
||||
out := make([]Skill, 0, len(indexes))
|
||||
for _, idx := range indexes {
|
||||
out = append(out, cloneSkill(t.skills[idx]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Find resolves a skill by its job-scoped PVF key.
|
||||
func (t *Table) Find(job byte, id uint16) (Skill, bool) {
|
||||
if t == nil {
|
||||
return Skill{}, false
|
||||
}
|
||||
idx, ok := t.byKey[Key{Job: job, ID: id}]
|
||||
if !ok {
|
||||
return Skill{}, false
|
||||
}
|
||||
return cloneSkill(t.skills[idx]), true
|
||||
}
|
||||
|
||||
// FindPath resolves a skill path inside one job namespace.
|
||||
func (t *Table) FindPath(job byte, skillPath string) (Skill, bool) {
|
||||
if t == nil {
|
||||
return Skill{}, false
|
||||
}
|
||||
idx, ok := t.byPath[jobPathKey{Job: job, Path: pathKey(skillPath)}]
|
||||
if !ok {
|
||||
return Skill{}, false
|
||||
}
|
||||
return cloneSkill(t.skills[idx]), true
|
||||
}
|
||||
|
||||
func (t *Table) Snapshot() Snapshot {
|
||||
if t == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
return Snapshot{Jobs: len(t.byJob), Skills: len(t.skills)}
|
||||
}
|
||||
|
||||
func cloneSkill(definition Skill) Skill {
|
||||
definition.GrowTypes = append([]int(nil), definition.GrowTypes...)
|
||||
definition.GrowTypeMaximumLevels = append([]int(nil), definition.GrowTypeMaximumLevels...)
|
||||
definition.SecondGrowTypes = append([]int(nil), definition.SecondGrowTypes...)
|
||||
definition.SecondGrowTypeMaximumLevels = append([]int(nil), definition.SecondGrowTypeMaximumLevels...)
|
||||
definition.Prerequisites = append([]Prerequisite(nil), definition.Prerequisites...)
|
||||
definition.PurchaseCost = append([]int(nil), definition.PurchaseCost...)
|
||||
definition.SpecialPurchaseCost = append([]int(nil), definition.SpecialPurchaseCost...)
|
||||
if len(definition.Scalars) > 0 {
|
||||
scalars := make(map[string]float64, len(definition.Scalars))
|
||||
for key, value := range definition.Scalars {
|
||||
scalars[key] = value
|
||||
}
|
||||
definition.Scalars = scalars
|
||||
}
|
||||
return definition
|
||||
}
|
||||
Reference in new issue
Block a user