DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,48 @@
package profession
import dnfskill "longheng.io/server/internal/modules/dnf/skill"
// 本文件是支援兵(companion)移植的桥接层。
//
// companion 模块是从 Server-90 移植过来的,它引用两个当前 profession 包
// 没有的名字(SupportsTarget / FreeGrantsAtCharacterLevel)。这两个名字在
// Server-90 里的语义与当前包的 SupportsSelection / FreeGrantsAtLevel 完全一致,
// 只是入参形态不同:
//
// Server-90 : SupportsTarget(job, growType) growType 是打包值
// 当前 : SupportsSelection(job, first, awakening) 已经拆开
//
// 所以这里不做算法改写,只做「拆包 → 转调」,保证两条路径的行为永远一致
// (算法本体仍然只有一份,在 profile.go 里)。
// SupportsTarget 报告当前 PVF 是否暴露了这个打包后的职业目标。
func (p *Profiles) SupportsTarget(job byte, growType byte) bool {
if p == nil {
return false
}
first, awakening := Decode(growType)
return p.SupportsSelection(job, first, awakening)
}
// FreeGrantsAtCharacterLevel 返回该职业进度在 PVF 里的技能下限,
// 并把固定等级技能抬到由角色等级推导出的档位。
//
// 与 FreeGrantsAtLevel 的区别:后者按完整 growType 反查到受保护技能集;
// 这里接受已经拆好的 firstGrowType/awakeningStage,供支援兵按
// 「源角色 branch + 觉醒阶段」直接取用。
func (p *Profiles) FreeGrantsAtCharacterLevel(
catalog *dnfskill.Table,
job byte,
firstGrowType byte,
awakeningStage byte,
characterLevel int,
) ([]Grant, bool) {
if p == nil || catalog == nil {
return nil, false
}
grants, ok := p.FreeGrants(job, firstGrowType, awakeningStage)
if !ok {
return nil, false
}
return grantsAtCharacterLevel(catalog, job, characterLevel, grants), true
}
@@ -0,0 +1,264 @@
package profession
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
)
// Growup Change(转职体验)实现。
//
// 规则与 86JP GrowupChangeApplicationService 保持一致:
// - 等级必须落在 [GrowupChangeMinLevel, GrowupChangeMaxLevel](86 growup.etc
// "grow up change lv" 为 15~49,即"限制 49 级以下角色开启")。
// - 角色必须已转职(grow_type 低 4 位 > 0)且未觉醒(高 4 位 == 0)。
// - 目标职业分支不能等于当前分支,且必须被当前 job 的 PVF profile 支持。
// - 金币成本按历史转职次数(changeCount)从金币表取值,达到表尾后封顶。
//
// 成功后在同一事务内扣金币、改写 grow_type、递增 changeCount 并重建目标
// 职业的技能基线(复用 ApplySkillTransition 的 KindClassChange 分支)。
var (
ErrGrowupChangeUnavailable = errors.New("dnf growup change is unavailable")
ErrGrowupChangeOwnerRequired = errors.New("dnf growup change owner is required")
ErrGrowupChangeTimeRequired = errors.New("dnf growup change commit time is required")
ErrGrowupChangeTargetRange = errors.New("dnf growup change target grow type is out of range")
ErrGrowupChangeSkillTransition = errors.New("dnf growup change skill transition failed")
)
const (
GrowupChangeMinLevel = 15
GrowupChangeMaxLevel = 49
)
// GrowupChangeAckResultCode 与 86 GrowupChangeAckBuilder/ResultCode 对齐:
// 客户端只识别 0(成功)、19(状态不合法)、22(金币不足)三个值。
const (
GrowupChangeAckResultCodeSuccess = 0
GrowupChangeAckResultCodeInvalidState = 19
GrowupChangeAckResultCodeInsufficientGold = 22
)
// growupChangeGoldCosts 是 86 真实 PVF character/growup.etc 的
// "grow up change gold" 表(含 86 SelfTest 已验证的 0 / 100113 / 133484 /
// index 9 == 1000000 约束)。index 0 免费,>= 表尾后取最后一项封顶。
var growupChangeGoldCosts = []int64{
0, 100113, 133484, 166856, 200227, 233599, 266970, 300342, 333713, 1000000,
}
// ResolveGrowupChangeGoldCost 按 changeCount 解析转职体验金币成本。
func ResolveGrowupChangeGoldCost(changeCount int) int64 {
if changeCount < 0 {
changeCount = 0
}
if changeCount >= len(growupChangeGoldCosts) {
changeCount = len(growupChangeGoldCosts) - 1
}
return growupChangeGoldCosts[changeCount]
}
// GrowupChangeInput 是一次转职体验请求的入参。
type GrowupChangeInput struct {
AccountID string
CharacterID string
TargetGrowType byte
Profiles *Profiles
SkillCatalog *dnfskill.Table
CommittedAt time.Time
}
// GrowupChangeResult 是转职体验的结果。Success 为 true 时角色与技能记录为
// 提交后的快照,可直接用于客户端刷新。
type GrowupChangeResult struct {
Success bool
ResultCode int
TargetGrowType byte
NewGrowType byte
PreviousChangeCount int
NewChangeCount int
GoldCost int64
UpdatedGold int64
Character dnfrepo.CharacterRecord
Skill dnfrepo.SkillRecord
}
// ApplyGrowupChange 在一个 character-scoped 事务内执行转职体验。
func ApplyGrowupChange(
ctx context.Context,
repositories dnfrepo.Group,
input GrowupChangeInput,
) (GrowupChangeResult, error) {
rejected := func(code int) GrowupChangeResult {
return GrowupChangeResult{
Success: false,
ResultCode: code,
TargetGrowType: input.TargetGrowType,
NewGrowType: input.TargetGrowType,
}
}
if repositories.CharacterSettlement == nil || input.Profiles == nil || input.SkillCatalog == nil {
return rejected(GrowupChangeAckResultCodeInvalidState), ErrGrowupChangeUnavailable
}
if ctx == nil {
ctx = context.Background()
}
input.AccountID = strings.TrimSpace(input.AccountID)
input.CharacterID = strings.TrimSpace(input.CharacterID)
if input.AccountID == "" || input.CharacterID == "" {
return rejected(GrowupChangeAckResultCodeInvalidState), ErrGrowupChangeOwnerRequired
}
if input.TargetGrowType > 0x0f {
return rejected(GrowupChangeAckResultCodeInvalidState), ErrGrowupChangeTargetRange
}
if input.CommittedAt.IsZero() {
return rejected(GrowupChangeAckResultCodeInvalidState), ErrGrowupChangeTimeRequired
}
var candidate GrowupChangeResult
err := repositories.CharacterSettlement.WithinCharacterSettlement(ctx, input.CharacterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Skill == nil {
return ErrGrowupChangeUnavailable
}
character, found, err := tx.Character.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.CharacterID) != input.CharacterID ||
strings.TrimSpace(character.AccountID) != input.AccountID {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
character = dnfrepo.CloneCharacter(character)
skill, found, err := tx.Skill.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(skill.CharacterID) != input.CharacterID {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
skill = dnfrepo.CloneSkill(skill)
jobValue, err := strconv.ParseUint(strings.TrimSpace(character.Job), 10, 8)
if err != nil {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
firstGrow, awakening := Decode(byte(character.Stats["grow_type"]))
// 等级范围(限制 49 级以下角色开启,86 "grow up change lv" 15~49)。
if character.Level < GrowupChangeMinLevel || character.Level > GrowupChangeMaxLevel {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
// 必须已转职且未觉醒。
if firstGrow <= 0 || awakening != 0 {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
// 目标不能与当前职业相同。
if input.TargetGrowType == firstGrow {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
// 目标职业必须被当前 job 的 PVF profile 支持。
if !input.Profiles.SupportsSelection(byte(jobValue), input.TargetGrowType, 0) {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
previousChangeCount := 0
if raw := character.Stats["growup_change_count"]; raw > 0 && raw <= math.MaxInt32 {
previousChangeCount = int(raw)
}
goldCost := ResolveGrowupChangeGoldCost(previousChangeCount)
currentGold := character.Stats["gold"]
if currentGold < 0 {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
if currentGold < goldCost {
candidate = GrowupChangeResult{
Success: false,
ResultCode: GrowupChangeAckResultCodeInsufficientGold,
TargetGrowType: input.TargetGrowType,
NewGrowType: input.TargetGrowType,
PreviousChangeCount: previousChangeCount,
NewChangeCount: previousChangeCount,
GoldCost: goldCost,
UpdatedGold: currentGold,
}
return nil
}
now := input.CommittedAt.UTC()
newChangeCount := previousChangeCount
if previousChangeCount != math.MaxInt32 {
newChangeCount = previousChangeCount + 1
}
previousGrowType := byte(character.Stats["grow_type"])
if character.Stats == nil {
character.Stats = make(map[string]int64, 3)
}
character.Stats["gold"] = currentGold - goldCost
character.Stats["grow_type"] = int64(input.TargetGrowType)
character.Stats["growup_change_count"] = int64(newChangeCount)
character.UpdatedAt = now
transition := Transition{
Kind: KindClassChange,
ChainType: 1,
GrowNumber: input.TargetGrowType,
PreviousGrowType: previousGrowType,
NewGrowType: input.TargetGrowType,
FirstGrowType: input.TargetGrowType,
AwakeningStage: 0,
}
skill, err = input.Profiles.ApplySkillTransition(
input.SkillCatalog,
byte(jobValue),
character.Level,
transition,
skill,
skill.Points,
)
if err != nil {
return fmt.Errorf("%w: job=%d target=%d", ErrGrowupChangeUnavailable, jobValue, input.TargetGrowType)
}
skill.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldBase, dnfrepo.CharacterFieldStats); err != nil {
return err
}
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, skill,
dnfrepo.SkillFieldSkills, dnfrepo.SkillFieldLayouts, dnfrepo.SkillFieldCooldowns, dnfrepo.SkillFieldPoints,
); err != nil {
return err
}
candidate = GrowupChangeResult{
Success: true,
ResultCode: GrowupChangeAckResultCodeSuccess,
TargetGrowType: input.TargetGrowType,
NewGrowType: input.TargetGrowType,
PreviousChangeCount: previousChangeCount,
NewChangeCount: newChangeCount,
GoldCost: goldCost,
UpdatedGold: currentGold - goldCost,
Character: character,
Skill: skill,
}
return nil
})
if err != nil {
return GrowupChangeResult{}, err
}
return candidate, nil
}
@@ -0,0 +1,183 @@
package profession
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Growup Experience(转职体验)实现。
//
// 90CN 客户端"转职体验"界面的"体验"按钮发送 REQUEST_ITEM_GROWTYPE_EXPERIENCE
// 请求。与转职(ApplyGrowupChange)相同的等级/状态校验,但体验免费:
// - 不扣除金币(GoldCost 恒为 0,UpdatedGold 保持当前值)。
// - 不递增 growup_change_count(体验不消耗正式转职次数)。
//
// 成功后在同一事务内改写 grow_type 并重建目标职业的技能基线(复用
// ApplySkillTransition 的 KindClassChange 分支),结果快照与转职一致。
var (
ErrGrowupExperienceUnavailable = errors.New("dnf growup experience is unavailable")
ErrGrowupExperienceOwnerRequired = errors.New("dnf growup experience owner is required")
ErrGrowupExperienceTimeRequired = errors.New("dnf growup experience commit time is required")
ErrGrowupExperienceTargetRange = errors.New("dnf growup experience target grow type is out of range")
)
// ApplyGrowupExperience 在一个 character-scoped 事务内执行免费转职体验。
func ApplyGrowupExperience(
ctx context.Context,
repositories dnfrepo.Group,
input GrowupChangeInput,
) (GrowupChangeResult, error) {
rejected := func(code int) GrowupChangeResult {
return GrowupChangeResult{
Success: false,
ResultCode: code,
TargetGrowType: input.TargetGrowType,
NewGrowType: input.TargetGrowType,
}
}
if repositories.CharacterSettlement == nil || input.Profiles == nil || input.SkillCatalog == nil {
return rejected(GrowupChangeAckResultCodeInvalidState), ErrGrowupExperienceUnavailable
}
if ctx == nil {
ctx = context.Background()
}
input.AccountID = strings.TrimSpace(input.AccountID)
input.CharacterID = strings.TrimSpace(input.CharacterID)
if input.AccountID == "" || input.CharacterID == "" {
return rejected(GrowupChangeAckResultCodeInvalidState), ErrGrowupExperienceOwnerRequired
}
if input.TargetGrowType > 0x0f {
return rejected(GrowupChangeAckResultCodeInvalidState), ErrGrowupExperienceTargetRange
}
if input.CommittedAt.IsZero() {
return rejected(GrowupChangeAckResultCodeInvalidState), ErrGrowupExperienceTimeRequired
}
var candidate GrowupChangeResult
err := repositories.CharacterSettlement.WithinCharacterSettlement(ctx, input.CharacterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Skill == nil {
return ErrGrowupExperienceUnavailable
}
character, found, err := tx.Character.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.CharacterID) != input.CharacterID ||
strings.TrimSpace(character.AccountID) != input.AccountID {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
character = dnfrepo.CloneCharacter(character)
skill, found, err := tx.Skill.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(skill.CharacterID) != input.CharacterID {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
skill = dnfrepo.CloneSkill(skill)
jobValue, err := strconv.ParseUint(strings.TrimSpace(character.Job), 10, 8)
if err != nil {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
firstGrow, awakening := Decode(byte(character.Stats["grow_type"]))
// 等级范围(限制 49 级以下角色开启,与转职一致 15~49)。
if character.Level < GrowupChangeMinLevel || character.Level > GrowupChangeMaxLevel {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
// 必须已转职且未觉醒。
if firstGrow <= 0 || awakening != 0 {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
// 目标不能与当前职业相同。
if input.TargetGrowType == firstGrow {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
// 目标职业必须被当前 job 的 PVF profile 支持。
if !input.Profiles.SupportsSelection(byte(jobValue), input.TargetGrowType, 0) {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
previousChangeCount := 0
if raw := character.Stats["growup_change_count"]; raw > 0 && raw <= math.MaxInt32 {
previousChangeCount = int(raw)
}
currentGold := character.Stats["gold"]
if currentGold < 0 {
candidate = rejected(GrowupChangeAckResultCodeInvalidState)
return nil
}
now := input.CommittedAt.UTC()
previousGrowType := byte(character.Stats["grow_type"])
if character.Stats == nil {
character.Stats = make(map[string]int64, 2)
}
character.Stats["grow_type"] = int64(input.TargetGrowType)
character.UpdatedAt = now
transition := Transition{
Kind: KindClassChange,
ChainType: 1,
GrowNumber: input.TargetGrowType,
PreviousGrowType: previousGrowType,
NewGrowType: input.TargetGrowType,
FirstGrowType: input.TargetGrowType,
AwakeningStage: 0,
}
skill, err = input.Profiles.ApplySkillTransition(
input.SkillCatalog,
byte(jobValue),
character.Level,
transition,
skill,
skill.Points,
)
if err != nil {
return fmt.Errorf("%w: job=%d target=%d", ErrGrowupExperienceUnavailable, jobValue, input.TargetGrowType)
}
skill.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldBase, dnfrepo.CharacterFieldStats); err != nil {
return err
}
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, skill,
dnfrepo.SkillFieldSkills, dnfrepo.SkillFieldLayouts, dnfrepo.SkillFieldCooldowns, dnfrepo.SkillFieldPoints,
); err != nil {
return err
}
candidate = GrowupChangeResult{
Success: true,
ResultCode: GrowupChangeAckResultCodeSuccess,
TargetGrowType: input.TargetGrowType,
NewGrowType: input.TargetGrowType,
PreviousChangeCount: previousChangeCount,
NewChangeCount: previousChangeCount,
GoldCost: 0,
UpdatedGold: currentGold,
Character: character,
Skill: skill,
}
return nil
})
if err != nil {
return GrowupChangeResult{}, err
}
return candidate, nil
}
@@ -0,0 +1,85 @@
package profession
import (
"context"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestApplyGrowupExperienceFreeAndNoCountAdvance(t *testing.T) {
ctx := context.Background()
profiles, catalog := growupChangeProfessionResources(t)
if profiles == nil || catalog == nil {
t.Fatal("profession resources unexpectedly nil")
}
repos := growupChangeSeed(t, ctx, func(r *dnfrepo.CharacterRecord) {
r.Level = 20
r.Stats = map[string]int64{"gold": 50000, "grow_type": 1, "growup_change_count": 1}
})
result, err := ApplyGrowupExperience(ctx, repos, growupChangeInput(2))
if err != nil {
t.Fatal(err)
}
if !result.Success || result.ResultCode != GrowupChangeAckResultCodeSuccess {
t.Fatalf("result=%+v", result)
}
if result.NewGrowType != 2 {
t.Fatalf("new grow type=%d want 2", result.NewGrowType)
}
if result.GoldCost != 0 || result.UpdatedGold != 50000 {
t.Fatalf("experience must be free: result=%+v", result)
}
if result.NewChangeCount != 1 || result.PreviousChangeCount != 1 {
t.Fatalf("experience must not advance change count: result=%+v", result)
}
persisted, ok, err := repos.Character.Load(ctx, "19")
if err != nil || !ok {
t.Fatalf("load persisted ok=%t err=%v", ok, err)
}
if persisted.Stats["grow_type"] != 2 || persisted.Stats["growup_change_count"] != 1 || persisted.Stats["gold"] != 50000 {
t.Fatalf("persisted stats=%+v", persisted.Stats)
}
}
func TestApplyGrowupExperienceRejectsByState(t *testing.T) {
ctx := context.Background()
cases := []struct {
name string
mutate func(r *dnfrepo.CharacterRecord)
target byte
wantAck int
wantGrowType int64
wantGrowCount int64
}{
{name: "level-below-min", mutate: func(r *dnfrepo.CharacterRecord) { r.Level = 14 }, target: 2, wantAck: GrowupChangeAckResultCodeInvalidState, wantGrowType: 1},
{name: "level-above-max", mutate: func(r *dnfrepo.CharacterRecord) { r.Level = 50 }, target: 2, wantAck: GrowupChangeAckResultCodeInvalidState, wantGrowType: 1},
{name: "not-transferred", mutate: func(r *dnfrepo.CharacterRecord) { r.Stats = map[string]int64{"gold": 200000, "grow_type": 0} }, target: 2, wantAck: GrowupChangeAckResultCodeInvalidState, wantGrowType: 0},
{name: "already-awakened", mutate: func(r *dnfrepo.CharacterRecord) { r.Stats = map[string]int64{"gold": 200000, "grow_type": 0x11} }, target: 2, wantAck: GrowupChangeAckResultCodeInvalidState, wantGrowType: 0x11},
{name: "target-unchanged", mutate: func(r *dnfrepo.CharacterRecord) { r.Stats = map[string]int64{"gold": 200000, "grow_type": 1} }, target: 1, wantAck: GrowupChangeAckResultCodeInvalidState, wantGrowType: 1},
{name: "target-unsupported", mutate: func(r *dnfrepo.CharacterRecord) { r.Stats = map[string]int64{"gold": 200000, "grow_type": 1} }, target: 5, wantAck: GrowupChangeAckResultCodeInvalidState, wantGrowType: 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repos := growupChangeSeed(t, ctx, tc.mutate)
result, err := ApplyGrowupExperience(ctx, repos, growupChangeInput(tc.target))
if err != nil {
t.Fatal(err)
}
if result.Success || result.ResultCode != tc.wantAck {
t.Fatalf("result=%+v", result)
}
persisted, ok, err := repos.Character.Load(ctx, "19")
if err != nil || !ok {
t.Fatalf("load persisted ok=%t err=%v", ok, err)
}
if persisted.Stats["grow_type"] != tc.wantGrowType {
t.Fatalf("rejected experience mutated grow_type stats=%+v", persisted.Stats)
}
if persisted.Stats["growup_change_count"] != tc.wantGrowCount {
t.Fatalf("rejected experience advanced change count stats=%+v", persisted.Stats)
}
})
}
}
@@ -0,0 +1,235 @@
package profession
import (
"context"
"fmt"
"testing"
"time"
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 TestResolveGrowupChangeGoldCost(t *testing.T) {
cases := []struct {
name string
changeCount int
wantCost int64
}{
{name: "first-free", changeCount: 0, wantCost: 0},
{name: "second-100113", changeCount: 1, wantCost: 100113},
{name: "third-133484", changeCount: 2, wantCost: 133484},
{name: "penultimate-333713", changeCount: 8, wantCost: 333713},
{name: "cap-index-9", changeCount: 9, wantCost: 1000000},
{name: "cap-beyond-table", changeCount: 15, wantCost: 1000000},
{name: "negative-clamps-zero", changeCount: -3, wantCost: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ResolveGrowupChangeGoldCost(tc.changeCount); got != tc.wantCost {
t.Fatalf("ResolveGrowupChangeGoldCost(%d) = %d want %d", tc.changeCount, got, tc.wantCost)
}
})
}
}
func TestApplyGrowupChangeSuccessAndDeduction(t *testing.T) {
ctx := context.Background()
profiles, catalog := growupChangeProfessionResources(t)
repos := growupChangeSeed(t, ctx, func(r *dnfrepo.CharacterRecord) {
r.Level = 20
r.Stats = map[string]int64{"gold": 200000, "grow_type": 1}
})
result, err := ApplyGrowupChange(ctx, repos, growupChangeInput(2))
if err != nil {
t.Fatal(err)
}
if !result.Success || result.ResultCode != GrowupChangeAckResultCodeSuccess {
t.Fatalf("result=%+v", result)
}
if result.NewGrowType != 2 || result.PreviousChangeCount != 0 || result.NewChangeCount != 1 {
t.Fatalf("transition=%+v", result)
}
if result.GoldCost != 0 || result.UpdatedGold != 200000 {
t.Fatalf("first change should be free: result=%+v", result)
}
persisted, ok, err := repos.Character.Load(ctx, "19")
if err != nil || !ok || persisted.Stats["grow_type"] != 2 || persisted.Stats["growup_change_count"] != 1 {
t.Fatalf("persisted grow state ok=%t err=%v stats=%+v", ok, err, persisted.Stats)
}
if profiles == nil || catalog == nil {
t.Fatal("profession resources unexpectedly nil")
}
// 第二次转职收费 100113。
result, err = ApplyGrowupChange(ctx, repos, growupChangeInput(1))
if err != nil {
t.Fatal(err)
}
if !result.Success || result.NewChangeCount != 2 || result.GoldCost != 100113 || result.UpdatedGold != 200000-100113 {
t.Fatalf("second change result=%+v", result)
}
}
func TestApplyGrowupChangeRejectsByState(t *testing.T) {
ctx := context.Background()
profiles, catalog := growupChangeProfessionResources(t)
if profiles == nil || catalog == nil {
t.Fatal("profession resources unexpectedly nil")
}
cases := []struct {
name string
mutate func(r *dnfrepo.CharacterRecord)
target byte
wantAck int
}{
{name: "level-below-min", mutate: func(r *dnfrepo.CharacterRecord) { r.Level = 14 }, target: 2, wantAck: GrowupChangeAckResultCodeInvalidState},
{name: "level-above-max", mutate: func(r *dnfrepo.CharacterRecord) { r.Level = 50 }, target: 2, wantAck: GrowupChangeAckResultCodeInvalidState},
{name: "not-transferred", mutate: func(r *dnfrepo.CharacterRecord) { r.Stats = map[string]int64{"gold": 200000, "grow_type": 0} }, target: 2, wantAck: GrowupChangeAckResultCodeInvalidState},
{name: "already-awakened", mutate: func(r *dnfrepo.CharacterRecord) { r.Stats = map[string]int64{"gold": 200000, "grow_type": 0x11} }, target: 2, wantAck: GrowupChangeAckResultCodeInvalidState},
{name: "target-unchanged", mutate: func(r *dnfrepo.CharacterRecord) { r.Stats = map[string]int64{"gold": 200000, "grow_type": 1} }, target: 1, wantAck: GrowupChangeAckResultCodeInvalidState},
{name: "target-unsupported", mutate: func(r *dnfrepo.CharacterRecord) { r.Stats = map[string]int64{"gold": 200000, "grow_type": 1} }, target: 5, wantAck: GrowupChangeAckResultCodeInvalidState},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repos := growupChangeSeed(t, ctx, tc.mutate)
result, err := ApplyGrowupChange(ctx, repos, growupChangeInput(tc.target))
if err != nil {
t.Fatal(err)
}
if result.Success || result.ResultCode != tc.wantAck {
t.Fatalf("result=%+v", result)
}
persisted, ok, err := repos.Character.Load(ctx, "19")
if err != nil || !ok {
t.Fatalf("load persisted ok=%t err=%v", ok, err)
}
if persisted.Stats["growup_change_count"] != 0 {
t.Fatalf("rejected change advanced change count stats=%+v", persisted.Stats)
}
})
}
}
func TestApplyGrowupChangeRejectsInsufficientGold(t *testing.T) {
ctx := context.Background()
repos := growupChangeSeed(t, ctx, func(r *dnfrepo.CharacterRecord) {
r.Level = 20
r.Stats = map[string]int64{"gold": 50000, "grow_type": 1, "growup_change_count": 1}
})
result, err := ApplyGrowupChange(ctx, repos, growupChangeInput(2))
if err != nil {
t.Fatal(err)
}
if result.Success || result.ResultCode != GrowupChangeAckResultCodeInsufficientGold {
t.Fatalf("result=%+v", result)
}
if result.GoldCost != 100113 {
t.Fatalf("insufficient-gold cost=%+v", result)
}
}
func growupChangeSeed(t *testing.T, ctx context.Context, mutate func(r *dnfrepo.CharacterRecord)) dnfrepo.Group {
t.Helper()
repos := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "acc", Job: "2", Level: 20,
Stats: map[string]int64{"gold": 200000, "grow_type": 1},
}
if mutate != nil {
mutate(&character)
}
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{CharacterID: "19", Points: dnfrepo.SkillPointState{SyncedLevel: character.Level}}); err != nil {
t.Fatal(err)
}
return repos
}
func growupChangeInput(target byte) GrowupChangeInput {
return GrowupChangeInput{
AccountID: "acc",
CharacterID: "19",
TargetGrowType: target,
Profiles: growupChangeProfilesCache,
SkillCatalog: growupChangeCatalogCache,
CommittedAt: time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC),
}
}
// growupChangeProfilesCache / growupChangeCatalogCache 由
// growupChangeProfessionResources 惰性初始化一次,供多次 ApplyGrowupChange 复用。
var (
growupChangeProfilesCache *Profiles
growupChangeCatalogCache *dnfskill.Table
)
type growupChangeSource map[string]string
func (s growupChangeSource) ReadText(path string) (string, error) {
text, ok := s[path]
if !ok {
return "", fmt.Errorf("missing %s", path)
}
return text, nil
}
func growupChangeProfessionResources(t *testing.T) (*Profiles, *dnfskill.Table) {
t.Helper()
if growupChangeProfilesCache != nil && growupChangeCatalogCache != nil {
return growupChangeProfilesCache, growupChangeCatalogCache
}
source := growupChangeSource{
DefaultCharacterList: "2 `Gunner/Gunner.chr`\n",
"character/Gunner/Gunner.chr": `[initial value]
[skill]
1 1
[/skill]
[growtype 1]
[skill]
2 1
197 1
[/skill]
[growtype 2]
[skill]
3 1
[/skill]
[growtype 3]
[skill]
4 1
[/skill]
[awakening 1]
[awakening skill]
5 1
[/awakening skill]
`,
dnfskill.DefaultList: "2 `GunnerSkill.lst`\n",
"skill/GunnerSkill.lst": "1 `Gunner/a.skl` 2 `Gunner/b.skl` 3 `Gunner/c.skl` 4 `Gunner/d.skl` 5 `Gunner/e.skl` 197 `Gunner/mastery.skl`\n",
"skill/Gunner/a.skl": "[name]\n`a`\n[type]\n`[active]`\n",
"skill/Gunner/b.skl": "[name]\n`b`\n[type]\n`[active]`\n",
"skill/Gunner/c.skl": "[name]\n`c`\n[type]\n`[active]`\n",
"skill/Gunner/d.skl": "[name]\n`d`\n[type]\n`[active]`\n",
"skill/Gunner/e.skl": "[name]\n`e`\n[type]\n`[passive]`\n",
"skill/Gunner/mastery.skl": "[name]\n`mastery`\n[type]\n`[passive]`\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)
}
profiles, err := LoadProfiles(context.Background(), source, catalog)
if err != nil {
t.Fatal(err)
}
growupChangeProfilesCache = profiles
growupChangeCatalogCache = catalog
return profiles, catalog
}
@@ -0,0 +1,762 @@
package profession
import (
"context"
"errors"
"fmt"
"math"
"path"
"regexp"
"sort"
"strconv"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
"longheng.io/server/internal/modules/dnf/skillcmd"
)
const DefaultCharacterList = "character/character.lst"
var (
ErrSourceRequired = errors.New("dnf profession PVF source is required")
ErrProfileMissing = errors.New("dnf profession profile is missing")
ErrSkillMissing = errors.New("dnf profession grant skill is missing")
ErrSkillGrant = errors.New("dnf profession skill grant is malformed")
)
var (
growTypeSectionRE = regexp.MustCompile(`(?i)\[\s*growtype\s+(\d+)\s*\]`)
awakeningSectionRE = regexp.MustCompile(`(?i)\[\s*awakening\s+(\d+)\s*\]`)
integerRE = regexp.MustCompile(`-?\d+`)
)
type Source interface {
ReadText(relativePath string) (string, error)
}
type Grant struct {
SkillID uint16 `json:"skill_id"`
Level int `json:"level"`
}
type jobProfile struct {
initial []Grant
grow map[byte][]Grant
growSupported map[byte]bool
awakening map[byte]map[byte][]Grant
awakeningSupported map[byte]map[byte]bool
displayNames map[byte]string
awakeningNames map[byte]map[byte]string
}
type Profiles struct {
jobs map[byte]jobProfile
missingSkillGrants int
}
type Snapshot struct {
Jobs int
InitialGrants int
ClassGrants int
AwakeningGrants int
MissingSkills int
}
// Option is one PVF-backed base job and all class/awakening selections the
// local GM surface can safely apply.
type Option struct {
Job byte `json:"job"`
Name string `json:"name"`
Branches []BranchOption `json:"branches"`
}
type BranchOption struct {
Value byte `json:"value"`
Name string `json:"name"`
Awakenings []AwakeningOption `json:"awakenings"`
}
type AwakeningOption struct {
Value byte `json:"value"`
Name string `json:"name"`
}
func LoadProfiles(ctx context.Context, source Source, catalog *dnfskill.Table) (*Profiles, error) {
if source == nil || catalog == nil {
return nil, ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
listText, err := source.ReadText(DefaultCharacterList)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(DefaultCharacterList, listText)
if err != nil {
return nil, err
}
entries := dnfpvf.ParseList(doc)
if len(entries) == 0 {
return nil, fmt.Errorf("%w: %s", ErrProfileMissing, DefaultCharacterList)
}
profiles := &Profiles{jobs: make(map[byte]jobProfile, len(entries))}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
if entry.ID < 0 || entry.ID > math.MaxUint8 || strings.TrimSpace(entry.Path) == "" {
continue
}
job := byte(entry.ID)
charPath := path.Clean(path.Join(path.Dir(DefaultCharacterList), strings.ReplaceAll(entry.Path, "\\", "/")))
text, err := source.ReadText(charPath)
if err != nil {
return nil, fmt.Errorf("read profession profile job=%d path=%s: %w", job, charPath, err)
}
profile, err := parseJobProfile(text)
if err != nil {
return nil, fmt.Errorf("parse profession profile job=%d path=%s: %w", job, charPath, err)
}
profiles.missingSkillGrants += countMissingProfileSkills(catalog, job, profile)
profiles.jobs[job] = profile
}
if len(profiles.jobs) == 0 {
return nil, ErrProfileMissing
}
return profiles, nil
}
func (p *Profiles) Snapshot() Snapshot {
if p == nil {
return Snapshot{}
}
out := Snapshot{Jobs: len(p.jobs), MissingSkills: p.missingSkillGrants}
for _, profile := range p.jobs {
out.InitialGrants += len(profile.initial)
for _, grants := range profile.grow {
out.ClassGrants += len(grants)
}
for _, stages := range profile.awakening {
for _, grants := range stages {
out.AwakeningGrants += len(grants)
}
}
}
return out
}
// Options returns the administrative profession choices in stable numeric
// order, derived exclusively from the loaded Script.pvf.
func (p *Profiles) Options() []Option {
if p == nil {
return nil
}
jobs := make([]int, 0, len(p.jobs))
for job := range p.jobs {
jobs = append(jobs, int(job))
}
sort.Ints(jobs)
out := make([]Option, 0, len(jobs))
for _, rawJob := range jobs {
job := byte(rawJob)
profile := p.jobs[job]
option := Option{Job: job, Name: strings.TrimSpace(profile.displayNames[0])}
branches := make(map[byte]struct{}, len(profile.growSupported)+1)
branches[0] = struct{}{}
for branch, supported := range profile.growSupported {
if supported {
branches[branch] = struct{}{}
}
}
branchValues := make([]int, 0, len(branches))
for branch := range branches {
branchValues = append(branchValues, int(branch))
}
sort.Ints(branchValues)
for _, rawBranch := range branchValues {
branch := byte(rawBranch)
branchOption := BranchOption{Value: branch, Name: strings.TrimSpace(profile.displayNames[branch])}
stages := make([]int, 0, len(profile.awakeningSupported[branch]))
for stage, supported := range profile.awakeningSupported[branch] {
if supported {
stages = append(stages, int(stage))
}
}
sort.Ints(stages)
for _, rawStage := range stages {
stage := byte(rawStage)
branchOption.Awakenings = append(branchOption.Awakenings, AwakeningOption{
Value: stage,
Name: strings.TrimSpace(profile.awakeningNames[branch][stage]),
})
}
option.Branches = append(option.Branches, branchOption)
}
out = append(out, option)
}
return out
}
// SupportsSelection validates an administrative final profession state
// against the sections actually present in the loaded PVF.
func (p *Profiles) SupportsSelection(job byte, first byte, awakening byte) bool {
profile, ok := p.profile(job)
if !ok {
return false
}
if first != 0 && !profile.growSupported[first] {
return false
}
if awakening == 0 {
return true
}
return profile.awakeningSupported[first][awakening]
}
func (p *Profiles) Initial(job byte) ([]Grant, bool) {
profile, ok := p.profile(job)
if !ok {
return nil, false
}
return cloneGrants(profile.initial), true
}
// DisplayName returns the profession name declared by the current Script.pvf.
// The low grow-type nibble selects the class branch and the high nibble
// selects its awakening stage. No client-side profession table is guessed.
func (p *Profiles) DisplayName(job byte, growType byte) (string, bool) {
profile, ok := p.profile(job)
if !ok {
return "", false
}
first, awakening := Decode(growType)
if awakening > 0 {
if name := strings.TrimSpace(profile.awakeningNames[byte(first)][byte(awakening)]); name != "" {
return name, true
}
}
name := strings.TrimSpace(profile.displayNames[byte(first)])
return name, name != ""
}
// FreeGrants returns the raw cumulative PVF profession declaration for the
// selected branch. It intentionally preserves paid [awakening skill] rows for
// audits and transition compatibility; reset callers use FreeGrantsAtLevel.
func (p *Profiles) FreeGrants(job byte, firstGrowType byte, awakeningStage byte) ([]Grant, bool) {
profile, ok := p.profile(job)
if !ok {
return nil, false
}
merged := make(map[uint16]int)
mergeGrantLevels(merged, profile.initial)
mergeGrantLevels(merged, profile.grow[firstGrowType])
for stage := byte(1); stage <= awakeningStage; stage++ {
mergeGrantLevels(merged, profile.awakening[firstGrowType][stage])
}
return sortedGrants(merged), true
}
// FreeGrantsAtLevel returns the current profession's cumulative PVF free
// baseline after applying fixed-level skill growth for the character level.
// Skill reset callers use this instead of falling back to base-job grants.
func (p *Profiles) FreeGrantsAtLevel(catalog *dnfskill.Table, job byte, growType byte, characterLevel int) ([]Grant, bool) {
if p == nil || catalog == nil {
return nil, false
}
firstGrowType, awakeningStage := Decode(growType)
grants, ok := p.protectedGrants(catalog, job, firstGrowType, awakeningStage)
if !ok {
return nil, false
}
grants = grantsAtCharacterLevel(catalog, job, characterLevel, grants)
return mergeCatalogSystemGrants(catalog, job, firstGrowType, awakeningStage, characterLevel, grants), true
}
// protectedGrants separates PVF profession ownership from the levels that are
// actually free and must survive a skill reset. Initial and class-change
// [skill] rows are explicit grants. [awakening skill], however, mixes free
// awakening actives with SP-purchased passives in the current PVF. A declared
// positive purchase cost therefore makes only the awakening row learnable; it
// must not become an automatic/protected level.
//
// Missing catalog definitions and rows without an explicit purchase table are
// deliberately retained. The existing missing-grant audit and the mutation
// owner's catalog validation must continue to expose incomplete PVF data
// instead of silently turning it into a smaller profession baseline.
func (p *Profiles) protectedGrants(
catalog *dnfskill.Table,
job byte,
firstGrowType byte,
awakeningStage byte,
) ([]Grant, bool) {
profile, ok := p.profile(job)
if !ok {
return nil, false
}
merged := make(map[uint16]int)
mergeGrantLevels(merged, profile.initial)
mergeGrantLevels(merged, profile.grow[firstGrowType])
for stage := byte(1); stage <= awakeningStage; stage++ {
for _, grant := range profile.awakening[firstGrowType][stage] {
definition, found := catalog.Find(job, grant.SkillID)
if found && len(definition.PurchaseCost) > 0 && definition.LevelCost(0) > 0 {
continue
}
if grant.Level > merged[grant.SkillID] {
merged[grant.SkillID] = grant.Level
}
}
}
return sortedGrants(merged), true
}
func (p *Profiles) profile(job byte) (jobProfile, bool) {
if p == nil {
return jobProfile{}, false
}
profile, ok := p.jobs[job]
return profile, ok
}
// ApplySkillTransition mutates only the detached SkillRecord. The caller owns
// the surrounding repository transaction and saves the returned fields.
func (p *Profiles) ApplySkillTransition(catalog *dnfskill.Table, job byte, characterLevel int, transition Transition, record dnfrepo.SkillRecord, points dnfrepo.SkillPointState) (dnfrepo.SkillRecord, error) {
if p == nil || catalog == nil {
return dnfrepo.SkillRecord{}, ErrSourceRequired
}
growType, err := Encode(transition.FirstGrowType, transition.AwakeningStage)
if err != nil {
return dnfrepo.SkillRecord{}, err
}
grants, ok := p.FreeGrantsAtLevel(catalog, job, growType, characterLevel)
if !ok {
return dnfrepo.SkillRecord{}, fmt.Errorf("%w: job=%d", ErrProfileMissing, job)
}
record = dnfrepo.CloneSkill(record)
switch transition.Kind {
case KindClassChange:
// A class change refunds every purchased SP/TP, so the learned levels are
// re-seeded from the PVF free baseline and bought skills must not survive.
// The skill UI layout is a different matter: it is the arrangement the
// player made, and BuildInitialSkillLayout is documented as the *first*
// persisted layout for a brand-new character. Re-seeding it here silently
// moved skills the player had placed (live 2026-09-15: 上挑/后跳 were
// bumped off the asdfgh row and 三段斩/刀魂之卡赞 put in their cells) and
// left the free cells 3..5 empty instead of filling them with the newly
// granted actives. Keep every previous assignment whose skill is still in
// the new baseline and let the same preserving allocator the login/backfill
// path uses place the newly granted skills.
previousLayouts := record.Layouts
record.Cooldowns = nil
record.Trees = nil
record.Layouts = make(map[int]dnfrepo.SkillLayout, 2)
points.RemainingSP = points.TotalSP
points.RemainingTP = points.TotalTP
for tree := 0; tree < 2; tree++ {
state := dnfrepo.SkillTreeState{Skills: grantStates(grants), Points: points}
layout, buildErr := skillcmd.BuildCurrentSkillLayout(catalog, job, tree, state.Skills, previousLayouts[tree])
if buildErr != nil {
return dnfrepo.SkillRecord{}, buildErr
}
dnfrepo.SetSkillTree(&record, tree, state)
record.Layouts[tree] = layout
}
case KindAwakening:
if record.Layouts == nil {
record.Layouts = make(map[int]dnfrepo.SkillLayout, 2)
}
for tree := 0; tree < 2; tree++ {
state, found := dnfrepo.SkillTree(record, tree)
if !found {
state, found = dnfrepo.SkillTree(record, 0)
}
if !found {
return dnfrepo.SkillRecord{}, fmt.Errorf("%w: job=%d tree=%d", ErrSkillGrant, job, tree)
}
if state.Skills == nil {
state.Skills = make(map[int64]dnfrepo.SkillState)
}
for _, grant := range grants {
current := state.Skills[int64(grant.SkillID)]
if current.Level < grant.Level {
current.Level = grant.Level
}
current.Enabled = true
state.Skills[int64(grant.SkillID)] = current
}
layout, buildErr := skillcmd.BuildCurrentSkillLayout(catalog, job, tree, state.Skills, record.Layouts[tree])
if buildErr != nil {
return dnfrepo.SkillRecord{}, buildErr
}
dnfrepo.SetSkillTree(&record, tree, state)
record.Layouts[tree] = layout
}
default:
return dnfrepo.SkillRecord{}, fmt.Errorf("%w: kind=%d", ErrTransitionInvalid, transition.Kind)
}
return record, nil
}
// RebuildSkillRecordForGrowType performs an explicit administrative
// profession rebase. Unlike the forward-only quest transition above, it
// replaces both skill plans with the exact cumulative PVF free baseline for
// targetGrowType, refunds all purchased SP/TP, and drops commands/cooldowns
// that belonged to the previous branch. The caller must commit the returned
// record and grow_type in one repository transaction.
func (p *Profiles) RebuildSkillRecordForGrowType(catalog *dnfskill.Table, job byte, characterLevel int, targetGrowType byte, record dnfrepo.SkillRecord, points dnfrepo.SkillPointState) (dnfrepo.SkillRecord, error) {
if p == nil || catalog == nil {
return dnfrepo.SkillRecord{}, ErrSourceRequired
}
profile, ok := p.profile(job)
if !ok {
return dnfrepo.SkillRecord{}, fmt.Errorf("%w: job=%d", ErrProfileMissing, job)
}
firstGrowType, awakeningStage := Decode(targetGrowType)
supported := firstGrowType == 0 || profile.growSupported[firstGrowType]
if awakeningStage > 0 {
supported = supported && profile.awakeningSupported[firstGrowType][awakeningStage]
}
if !supported {
return dnfrepo.SkillRecord{}, fmt.Errorf("%w: job=%d grow_type=0x%02x", ErrTransitionInvalid, job, targetGrowType)
}
grants, ok := p.FreeGrantsAtLevel(catalog, job, targetGrowType, characterLevel)
if !ok {
return dnfrepo.SkillRecord{}, fmt.Errorf("%w: job=%d grow_type=0x%02x", ErrProfileMissing, job, targetGrowType)
}
record = dnfrepo.CloneSkill(record)
record.Commands = nil
record.Cooldowns = nil
record.Trees = nil
record.Layouts = make(map[int]dnfrepo.SkillLayout, 2)
points.RemainingSP = points.TotalSP
points.RemainingTP = points.TotalTP
for tree := 0; tree < 2; tree++ {
state := dnfrepo.SkillTreeState{Skills: grantStates(grants), Points: points}
layout, err := skillcmd.BuildInitialSkillLayout(catalog, job, tree, state.Skills)
if err != nil {
return dnfrepo.SkillRecord{}, err
}
dnfrepo.SetSkillTree(&record, tree, state)
record.Layouts[tree] = layout
}
return record, nil
}
func grantsAtCharacterLevel(catalog *dnfskill.Table, job byte, characterLevel int, grants []Grant) []Grant {
out := cloneGrants(grants)
for index := range out {
definition, ok := catalog.Find(job, out[index].SkillID)
if !ok {
continue
}
if fixedLevel := definition.FixedLevelForCharacter(characterLevel); fixedLevel > out[index].Level {
out[index].Level = fixedLevel
}
}
return out
}
// mergeCatalogSystemGrants closes the intentional split between the
// character profile and zero-cost profession skills. Most automatic skills
// are listed in character/*.chr, but the current PVF keeps a small set (for
// example male Slayer skill 248, Burning Blood) only in the .skl catalog.
// Their authoritative markers are a profession-specific grow-type maximum,
// a zero purchase cost, no prerequisite purchase chain and an ordinary combat
// skill class. The prerequisite distinction is material for ordinary
// first-grow rows: this PVF also uses a zero first-level cost for optional
// one-level toggles such as Mage skill 167 (Clear Flapper). Protecting one of
// those rows as a system grant makes the native refund request fail at the
// permanent floor. The small mapped awakening-helper set below may retain its
// authored prerequisite chain because branch and stage are independently
// proved there. Skill class 4 is
// deliberately excluded because it also contains guild and expert-job
// abilities which are unlocked by their own systems.
//
// Eight zero-cost awakening helpers in this compatibility PVF omit both the
// character-profile grant and a branch-bearing growtype maximum. Their
// branches are nevertheless fixed by their PVF awakening names and required
// skill chains (for example Star Pattern Explosion requires Transform
// Bellatrix). Keep that small evidence-backed mapping explicit instead of
// guessing a branch for every second-growtype skill.
type awakeningSystemGrantRule struct {
FirstGrowType byte
}
var awakeningSystemGrantRules = map[dnfskill.Key]awakeningSystemGrantRule{
{Job: 1, ID: 114}: {FirstGrowType: 3}, // 禁技 : 万毒蛇窟
{Job: 3, ID: 33}: {FirstGrowType: 1}, // 圣灵符文
{Job: 3, ID: 195}: {FirstGrowType: 4}, // 魔道学助手
{Job: 3, ID: 300}: {FirstGrowType: 3}, // 使徒封印
{Job: 3, ID: 301}: {FirstGrowType: 3}, // 贝亚娜之秘
{Job: 3, ID: 305}: {FirstGrowType: 3}, // 星纹陨爆
{Job: 3, ID: 314}: {FirstGrowType: 2}, // 咒令 : 逆月之蚀
{Job: 10, ID: 268}: {FirstGrowType: 0}, // 自我觉醒
}
func mergeCatalogSystemGrants(
catalog *dnfskill.Table,
job byte,
firstGrowType byte,
awakeningStage byte,
characterLevel int,
grants []Grant,
) []Grant {
levels := make(map[uint16]int, len(grants))
mergeGrantLevels(levels, grants)
if catalog == nil || characterLevel <= 0 {
return sortedGrants(levels)
}
for _, definition := range catalog.SkillsForJob(job) {
if definition.RequiredLevel > characterLevel || definition.MaximumLevel <= 0 ||
definition.SkillClass < 1 || definition.SkillClass > 3 ||
len(definition.PurchaseCost) == 0 || definition.LevelCost(0) != 0 ||
(len(definition.Prerequisites) != 0 && len(definition.SecondGrowTypes) == 0) ||
!catalogSystemGrantMatchesGrowth(definition, firstGrowType, awakeningStage) {
continue
}
if levels[definition.ID] < 1 {
levels[definition.ID] = 1
}
}
return sortedGrants(levels)
}
func catalogSystemGrantMatchesGrowth(definition dnfskill.Skill, firstGrowType byte, awakeningStage byte) bool {
if len(definition.SecondGrowTypes) == 0 {
return firstGrowType != 0 &&
int(firstGrowType) < len(definition.GrowTypeMaximumLevels) &&
definition.GrowTypeMaximumLevels[firstGrowType] > 0
}
rule, found := awakeningSystemGrantRules[definition.Key()]
if !found || rule.FirstGrowType != firstGrowType {
return false
}
minimumStage := byte(0xff)
for _, stage := range definition.SecondGrowTypes {
if stage > 0 && stage < int(minimumStage) {
minimumStage = byte(stage)
}
}
return minimumStage != 0xff && awakeningStage >= minimumStage
}
// PlanTransition applies one detached quest reward to the transaction's
// current packed grow_type. Section presence in character/*.chr is the source
// of truth, including branch-zero awakenings whose free-grant lists are empty.
func (p *Profiles) PlanTransition(job byte, current byte, request Request) (Transition, error) {
profile, ok := p.profile(job)
if !ok {
return Transition{}, fmt.Errorf("%w: job=%d", ErrProfileMissing, job)
}
first, awakening := Decode(current)
switch request.Kind {
case KindClassChange:
if request.ChainType != 1 || request.GrowNumber == 0 || first != 0 || awakening != 0 {
return Transition{}, fmt.Errorf("%w: class change current=0x%02x target=%d", ErrTransitionOutOfStep, current, request.GrowNumber)
}
if !profile.growSupported[request.GrowNumber] {
return Transition{}, fmt.Errorf("%w: job=%d class=%d", ErrTransitionInvalid, job, request.GrowNumber)
}
return Transition{
Kind: KindClassChange, ChainType: request.ChainType, GrowNumber: request.GrowNumber,
PreviousGrowType: current, NewGrowType: request.GrowNumber,
FirstGrowType: request.GrowNumber, AwakeningStage: 0,
}, nil
case KindAwakening:
target := request.GrowNumber
if request.ChainType != 2 || target == 0 || target > 2 || target != awakening+1 {
return Transition{}, fmt.Errorf("%w: awakening current=0x%02x target=%d", ErrTransitionOutOfStep, current, target)
}
if !profile.awakeningSupported[first][target] {
return Transition{}, fmt.Errorf("%w: job=%d first=%d awakening=%d", ErrTransitionInvalid, job, first, target)
}
packed, err := Encode(first, target)
if err != nil {
return Transition{}, err
}
return Transition{
Kind: KindAwakening, ChainType: request.ChainType, GrowNumber: target,
PreviousGrowType: current, NewGrowType: packed,
FirstGrowType: first, AwakeningStage: target,
}, nil
default:
return Transition{}, fmt.Errorf("%w: kind=%d", ErrTransitionInvalid, request.Kind)
}
}
func parseJobProfile(text string) (jobProfile, error) {
profile := jobProfile{
grow: make(map[byte][]Grant),
growSupported: make(map[byte]bool),
awakening: make(map[byte]map[byte][]Grant),
awakeningSupported: make(map[byte]map[byte]bool),
displayNames: make(map[byte]string),
awakeningNames: make(map[byte]map[byte]string),
}
doc, err := dnfpvf.Parse("character/profile.chr", text)
if err != nil {
return jobProfile{}, err
}
for index, name := range doc.Texts("growtype name") {
if index > math.MaxUint8 {
break
}
if name = strings.TrimSpace(name); name != "" && !strings.HasPrefix(name, "//") {
profile.displayNames[byte(index)] = name
}
}
growMatches := growTypeSectionRE.FindAllStringSubmatchIndex(text, -1)
initialEnd := len(text)
if len(growMatches) > 0 {
initialEnd = growMatches[0][0]
}
profile.initial = parseGrantSection(text[:initialEnd], "skill")
for index, match := range growMatches {
n, err := strconv.Atoi(text[match[2]:match[3]])
if err != nil || n < 1 || n > 16 {
continue
}
firstGrowType := byte(n - 1)
profile.growSupported[firstGrowType] = true
segmentEnd := len(text)
if index+1 < len(growMatches) {
segmentEnd = growMatches[index+1][0]
}
segment := text[match[1]:segmentEnd]
segmentDoc, parseErr := dnfpvf.Parse("character/profile-grow.chr", segment)
if parseErr != nil {
return jobProfile{}, parseErr
}
for awakeningIndex, name := range segmentDoc.Texts("awakening name") {
stage := awakeningIndex + 1
if stage > math.MaxUint8 {
break
}
name = strings.TrimSpace(name)
if name == "" || strings.HasPrefix(name, "//") {
continue
}
if profile.awakeningNames[firstGrowType] == nil {
profile.awakeningNames[firstGrowType] = make(map[byte]string)
}
profile.awakeningNames[firstGrowType][byte(stage)] = name
}
awakeningMatches := awakeningSectionRE.FindAllStringSubmatchIndex(segment, -1)
growEnd := len(segment)
if len(awakeningMatches) > 0 {
growEnd = awakeningMatches[0][0]
}
if grants := parseGrantSection(segment[:growEnd], "skill"); n >= 2 && len(grants) > 0 {
profile.grow[firstGrowType] = grants
}
for stageIndex, awakeningMatch := range awakeningMatches {
stageValue, err := strconv.Atoi(segment[awakeningMatch[2]:awakeningMatch[3]])
if err != nil || stageValue < 1 || stageValue > 15 {
continue
}
if profile.awakeningSupported[firstGrowType] == nil {
profile.awakeningSupported[firstGrowType] = make(map[byte]bool)
}
profile.awakeningSupported[firstGrowType][byte(stageValue)] = true
awakeningEnd := len(segment)
if stageIndex+1 < len(awakeningMatches) {
awakeningEnd = awakeningMatches[stageIndex+1][0]
}
grants := parseGrantSection(segment[awakeningMatch[1]:awakeningEnd], "awakening skill")
if len(grants) == 0 {
continue
}
if profile.awakening[firstGrowType] == nil {
profile.awakening[firstGrowType] = make(map[byte][]Grant)
}
profile.awakening[firstGrowType][byte(stageValue)] = grants
}
}
if len(profile.initial) == 0 {
return jobProfile{}, fmt.Errorf("%w: missing [initial value] [skill] grants", ErrSkillGrant)
}
return profile, nil
}
func parseGrantSection(text string, name string) []Grant {
open := regexp.MustCompile(`(?i)\[\s*` + regexp.QuoteMeta(name) + `\s*\]`).FindStringIndex(text)
if open == nil {
return nil
}
close := regexp.MustCompile(`(?i)\[\s*/\s*` + regexp.QuoteMeta(name) + `\s*\]`).FindStringIndex(text[open[1]:])
bodyEnd := len(text)
if close != nil {
bodyEnd = open[1] + close[0]
}
values := integerRE.FindAllString(text[open[1]:bodyEnd], -1)
levels := make(map[uint16]int)
for index := 0; index+1 < len(values); index += 2 {
skillID, skillErr := strconv.ParseInt(values[index], 10, 64)
level, levelErr := strconv.Atoi(values[index+1])
if skillErr != nil || levelErr != nil || skillID <= 0 || skillID > math.MaxUint16 || level <= 0 {
continue
}
id := uint16(skillID)
if level > levels[id] {
levels[id] = level
}
}
return sortedGrants(levels)
}
func countMissingProfileSkills(catalog *dnfskill.Table, job byte, profile jobProfile) int {
missing := make(map[uint16]struct{})
check := func(grants []Grant) {
for _, grant := range grants {
if _, ok := catalog.Find(job, grant.SkillID); !ok {
missing[grant.SkillID] = struct{}{}
}
}
}
check(profile.initial)
for _, grants := range profile.grow {
check(grants)
}
for _, stages := range profile.awakening {
for _, grants := range stages {
check(grants)
}
}
return len(missing)
}
func grantStates(grants []Grant) map[int64]dnfrepo.SkillState {
states := make(map[int64]dnfrepo.SkillState, len(grants))
for _, grant := range grants {
states[int64(grant.SkillID)] = dnfrepo.SkillState{Level: grant.Level, Enabled: true}
}
return states
}
func mergeGrantLevels(target map[uint16]int, grants []Grant) {
for _, grant := range grants {
if grant.Level > target[grant.SkillID] {
target[grant.SkillID] = grant.Level
}
}
}
func sortedGrants(levels map[uint16]int) []Grant {
ids := make([]int, 0, len(levels))
for id := range levels {
ids = append(ids, int(id))
}
sort.Ints(ids)
out := make([]Grant, 0, len(ids))
for _, id := range ids {
out = append(out, Grant{SkillID: uint16(id), Level: levels[uint16(id)]})
}
return out
}
func cloneGrants(grants []Grant) []Grant {
return append([]Grant(nil), grants...)
}
@@ -0,0 +1,464 @@
package profession
import (
"context"
"fmt"
"os"
"reflect"
"sort"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFProfessionProfiles(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify profession profiles")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, 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)
}
explicitClassPreviewSkills := 0
contradictoryFitness := make(map[dnfskill.Key]int)
for _, definition := range catalog.Skills() {
explicit := make(map[int]struct{}, len(definition.GrowTypes))
for _, branch := range definition.GrowTypes {
explicit[branch] = struct{}{}
}
if len(explicit) == 0 {
continue
}
if _, allowsBase := explicit[0]; !allowsBase && len(definition.GrowTypeMaximumLevels) > 0 && definition.GrowTypeMaximumLevels[0] > 0 {
explicitClassPreviewSkills++
}
intersects := false
for branch := range explicit {
if branch >= 0 && branch < len(definition.GrowTypeMaximumLevels) && definition.GrowTypeMaximumLevels[branch] > 0 {
intersects = true
break
}
}
exclusive := -1
for branch := 1; branch < len(definition.GrowTypeMaximumLevels); branch++ {
if definition.GrowTypeMaximumLevels[branch] <= 0 {
continue
}
if exclusive >= 0 {
exclusive = -2
break
}
exclusive = branch
}
if !intersects && exclusive >= 0 {
contradictoryFitness[definition.Key()] = exclusive
}
}
if explicitClassPreviewSkills != 98 {
t.Fatalf("explicit class skills with client base preview=%d want=98", explicitClassPreviewSkills)
}
wantContradictory := map[dnfskill.Key]int{
{Job: 5, ID: 16}: 4,
{Job: 7, ID: 236}: 2,
{Job: 7, ID: 237}: 2,
{Job: 12, ID: 126}: 4,
}
if !reflect.DeepEqual(contradictoryFitness, wantContradictory) {
t.Fatalf("exclusive contradictory fitness=%v want=%v", contradictoryFitness, wantContradictory)
}
for _, skillID := range []uint16{236, 237} {
definition, found := catalog.Find(7, skillID)
if !found || definition.SupportsCharacterGrowth(0x21) || !definition.SupportsCharacterGrowth(0x22) {
t.Fatalf("male Striker contradictory-fitness skill=%d definition=%+v found=%t", skillID, definition, found)
}
}
for _, example := range []struct {
job byte
id uint16
branch int
path string
}{
{job: 2, id: 11, branch: 1, path: "skill/gunner/fastshoot.skl"},
{job: 13, id: 90, branch: 4, path: "skill/demoniclancer/doublethrow.skl"},
} {
definition, ok := catalog.Find(example.job, example.id)
if !ok || !definition.SupportsGrowType(example.branch) || definition.SupportsGrowType(0) || definition.Path != example.path {
t.Fatalf("strict profession ownership mismatch for %+v: definition=%+v found=%t", example, definition, ok)
}
}
profiles, err := LoadProfiles(context.Background(), archive, catalog)
if err != nil {
t.Fatal(err)
}
berserkerGrants, berserkerSupported := profiles.FreeGrantsAtLevel(catalog, 0, 0x03, 15)
if !berserkerSupported || grantLevel(berserkerGrants, 10) != 1 || grantLevel(berserkerGrants, 248) != 1 {
t.Fatalf("berserker level15 system grants=%v supported=%t want skills 10 and 248", berserkerGrants, berserkerSupported)
}
snakeSword, snakeSwordFound := catalog.Find(11, 31)
swordDemonGrants, swordDemonSupported := profiles.FreeGrantsAtLevel(catalog, 11, 0x03, 15)
if !snakeSwordFound || snakeSword.Active || snakeSword.SkillClass != 3 ||
!snakeSword.SupportsCharacterGrowth(0x03) ||
!swordDemonSupported || grantLevel(swordDemonGrants, 31) != 1 {
t.Fatalf(
"Sword Demon Snake Sword contract definition=%+v found=%t grants=%v supported=%t",
snakeSword, snakeSwordFound, swordDemonGrants, swordDemonSupported,
)
}
battleMageClass, battleMageClassSupported := profiles.FreeGrantsAtLevel(catalog, 3, 0x03, 90)
battleMageFirst, battleMageFirstSupported := profiles.FreeGrantsAtLevel(catalog, 3, 0x13, 90)
battleMageSecond, battleMageSecondSupported := profiles.FreeGrantsAtLevel(catalog, 3, 0x23, 90)
if !battleMageClassSupported || len(battleMageClass) != 25 ||
grantLevel(battleMageClass, 5) != 1 || grantLevel(battleMageClass, 6) != 1 ||
grantLevel(battleMageClass, 18) != 1 || grantLevel(battleMageClass, 197) != 1 ||
containsGrant(battleMageClass, 83) || containsGrant(battleMageClass, 84) {
t.Fatalf("battle-mage class-change baseline=%v supported=%t", battleMageClass, battleMageClassSupported)
}
if !battleMageFirstSupported || len(battleMageFirst) != 28 ||
grantLevel(battleMageFirst, 5) != 1 || grantLevel(battleMageFirst, 6) != 1 ||
grantLevel(battleMageFirst, 83) != 9 || containsGrant(battleMageFirst, 84) ||
grantLevel(battleMageFirst, 301) != 1 || grantLevel(battleMageFirst, 305) != 1 ||
containsGrant(battleMageFirst, 300) {
t.Fatalf("battle-mage first-awakening baseline=%v supported=%t", battleMageFirst, battleMageFirstSupported)
}
if !battleMageSecondSupported || len(battleMageSecond) != 29 ||
grantLevel(battleMageSecond, 5) != 1 || grantLevel(battleMageSecond, 6) != 1 ||
grantLevel(battleMageSecond, 83) != 9 || containsGrant(battleMageSecond, 84) || containsGrant(battleMageSecond, 126) ||
grantLevel(battleMageSecond, 300) != 1 || grantLevel(battleMageSecond, 301) != 1 || grantLevel(battleMageSecond, 305) != 1 {
t.Fatalf("battle-mage second-awakening baseline=%v supported=%t", battleMageSecond, battleMageSecondSupported)
}
paidAwakeningSkills := 0
freeAwakeningSkills := 0
for job := byte(0); job <= 15; job++ {
firstBranch, lastBranch := byte(1), byte(4)
if job == 8 {
lastBranch = 5
}
if job == 9 || job == 10 {
firstBranch, lastBranch = 0, 0
}
for branch := firstBranch; branch <= lastBranch; branch++ {
previous, previousOK := profiles.FreeGrants(job, branch, 0)
if !previousOK {
continue
}
previousLevels := make(map[uint16]int, len(previous))
for _, grant := range previous {
previousLevels[grant.SkillID] = grant.Level
}
for stage := byte(1); stage <= 2; stage++ {
declared, declaredOK := profiles.FreeGrants(job, branch, stage)
protected, protectedOK := profiles.FreeGrantsAtLevel(catalog, job, stage<<4|branch, 90)
if !declaredOK || !protectedOK {
t.Fatalf("job=%d branch=%d stage=%d declared=%t protected=%t", job, branch, stage, declaredOK, protectedOK)
}
for _, grant := range declared {
if grant.Level <= previousLevels[grant.SkillID] {
continue
}
definition, found := catalog.Find(job, grant.SkillID)
if !found {
t.Fatalf("awakening grant has no skill definition: job=%d branch=%d stage=%d skill=%d", job, branch, stage, grant.SkillID)
}
if len(definition.PurchaseCost) == 0 {
t.Fatalf("awakening grant has no purchase table: job=%d branch=%d stage=%d skill=%d", job, branch, stage, grant.SkillID)
}
if definition.LevelCost(0) > 0 {
paidAwakeningSkills++
if containsGrant(protected, grant.SkillID) {
t.Fatalf("paid awakening skill entered protected baseline: job=%d branch=%d stage=%d skill=%d cost=%d", job, branch, stage, grant.SkillID, definition.LevelCost(0))
}
} else {
freeAwakeningSkills++
if grantLevel(protected, grant.SkillID) < grant.Level {
t.Fatalf("free awakening skill missing from protected baseline: job=%d branch=%d stage=%d skill=%d", job, branch, stage, grant.SkillID)
}
}
}
previousLevels = make(map[uint16]int, len(declared))
for _, grant := range declared {
previousLevels[grant.SkillID] = grant.Level
}
}
}
}
if paidAwakeningSkills != 57 || freeAwakeningSkills != 61 {
t.Fatalf("awakening purchase classification paid=%d free=%d want paid=57 free=61", paidAwakeningSkills, freeAwakeningSkills)
}
expectedSupplements := map[string]struct{}{
"0/3/10": {}, "0/3/248": {}, "1/3/33": {},
"1/3/114": {},
"2/3/256": {}, "3/3/319": {},
"3/1/33": {}, "3/2/314": {}, "3/3/300": {}, "3/3/301": {},
"3/3/305": {}, "3/4/195": {}, "3/4/199": {}, "4/3/136": {},
"4/3/257": {}, "7/3/33": {},
"7/3/110": {}, "12/1/31": {}, "12/1/45": {}, "12/1/46": {},
"12/1/47": {}, "12/1/48": {}, "12/1/49": {}, "12/1/77": {},
"12/1/106": {}, "12/3/13": {}, "12/3/77": {}, "12/4/13": {},
"12/4/77": {}, "14/1/84": {}, "14/2/84": {},
"14/4/84": {}, "10/0/268": {},
}
foundSupplements := make(map[string]struct{}, len(expectedSupplements))
for job := byte(0); job <= 15; job++ {
firstBranch := byte(1)
lastBranch := byte(4)
if job == 9 || job == 10 {
firstBranch, lastBranch = 0, 0
} else if job == 8 {
lastBranch = 5
}
for branch := firstBranch; branch <= lastBranch; branch++ {
raw, rawOK := profiles.FreeGrants(job, branch, 2)
augmented, augmentedOK := profiles.FreeGrantsAtLevel(catalog, job, 0x20|branch, 90)
if !rawOK || !augmentedOK {
continue
}
rawIDs := make(map[uint16]struct{}, len(raw))
for _, grant := range raw {
rawIDs[grant.SkillID] = struct{}{}
}
for _, grant := range augmented {
if _, found := rawIDs[grant.SkillID]; found {
continue
}
key := fmt.Sprintf("%d/%d/%d", job, branch, grant.SkillID)
if _, expected := expectedSupplements[key]; !expected {
t.Fatalf("unexpected profession system supplement %s", key)
}
foundSupplements[key] = struct{}{}
}
}
}
if len(foundSupplements) != len(expectedSupplements) {
for key := range expectedSupplements {
if _, found := foundSupplements[key]; !found {
t.Errorf("missing profession system supplement %s", key)
}
}
t.Fatalf("profession system supplements=%d want=%d", len(foundSupplements), len(expectedSupplements))
}
witchToggle, found := catalog.Find(3, 167)
witchBaseline, witchSupported := profiles.FreeGrantsAtLevel(catalog, 3, 0x04, 90)
if !found || witchToggle.Path != "skill/mage/clearflapper.skl" || len(witchToggle.Prerequisites) != 1 ||
witchToggle.Prerequisites[0].SkillID != 95 || witchToggle.Prerequisites[0].Level != 1 ||
!witchSupported || containsGrant(witchBaseline, 167) {
t.Fatalf("witch clear-flapper optional toggle definition=%+v found=%t baseline=%v supported=%t", witchToggle, found, witchBaseline, witchSupported)
}
snapshot := profiles.Snapshot()
if snapshot.Jobs != 16 || snapshot.InitialGrants != 116 || snapshot.ClassGrants != 453 || snapshot.AwakeningGrants != 118 || snapshot.MissingSkills != 3 {
t.Fatalf("profession snapshot = %+v", snapshot)
}
if name, ok := profiles.DisplayName(11, 0x21); !ok || name != "剑皇" {
t.Fatalf("female-slayer second-awakening display=%q found=%t want=剑皇", name, ok)
}
if name, ok := profiles.DisplayName(2, 0x01); !ok || name != "漫游枪手" {
t.Fatalf("gunner class display=%q found=%t want=漫游枪手", name, ok)
}
fixedLevelSkills := 0
fixedLevelAt90 := 0
for _, definition := range catalog.Skills() {
if !definition.FixedLevelSkill {
continue
}
fixedLevelSkills++
if definition.FixedLevelForCharacter(90) > 0 {
fixedLevelAt90++
}
}
gunbladerFirst, ok := profiles.FreeGrants(15, 1, 1)
if !ok || len(gunbladerFirst) == 0 {
t.Fatalf("gunblader class1 awakening1 grants = %v, found=%t", gunbladerFirst, ok)
}
gunbladerSecond, ok := profiles.FreeGrants(15, 1, 2)
if !ok || !sameGrants(gunbladerFirst, gunbladerSecond) {
t.Fatalf("gunblader second awakening must not fabricate free grants: first=%v second=%v", gunbladerFirst, gunbladerSecond)
}
thiefFirst, _ := profiles.FreeGrants(6, 3, 1)
thiefSecond, _ := profiles.FreeGrants(6, 3, 2)
if containsGrant(thiefFirst, 73) || !containsGrant(thiefSecond, 73) {
t.Fatalf("thief branch3 second-awakening grant mismatch: first=%v second=%v", thiefFirst, thiefSecond)
}
atFighterFirst, _ := profiles.FreeGrants(7, 1, 1)
atFighterSecond, _ := profiles.FreeGrants(7, 1, 2)
if containsGrant(atFighterFirst, 242) || !containsGrant(atFighterSecond, 242) {
t.Fatalf("at-fighter branch1 second-awakening grant mismatch: first=%v second=%v", atFighterFirst, atFighterSecond)
}
atSwordMaster, ok := profiles.FreeGrantsAtLevel(catalog, 11, 0x21, 90)
if !ok || containsGrant(atSwordMaster, 52) || grantLevel(atSwordMaster, 54) <= 0 {
t.Fatalf("female-slayer sword-master level90 free baseline=%v found=%t", atSwordMaster, ok)
}
professionMasteryBranches := 0
for job := byte(0); job <= 15; job++ {
if job == 9 || job == 10 { // Dark Knight and Creator are external professions.
continue
}
base, supported := profiles.FreeGrants(job, 0, 0)
if !supported || containsGrant(base, 197) {
t.Fatalf("ordinary job=%d base must not own profession mastery skill 197: grants=%v supported=%t", job, base, supported)
}
lastBranch := byte(4)
if job == 8 { // Female mage owns the fifth ordinary class branch in this PVF.
lastBranch = 5
}
for branch := byte(1); branch <= lastBranch; branch++ {
grants, branchSupported := profiles.FreeGrants(job, branch, 0)
if !branchSupported || !containsGrant(grants, 197) {
t.Fatalf("ordinary job=%d branch=%d must grant profession mastery skill 197: grants=%v supported=%t", job, branch, grants, branchSupported)
}
professionMasteryBranches++
}
}
if professionMasteryBranches != 57 {
t.Fatalf("ordinary profession mastery branch count=%d want=57", professionMasteryBranches)
}
branchZeroFirstRequest, _ := ParseReward(2, "[awakening type]", []int64{1})
branchZeroFirst, err := profiles.PlanTransition(9, 0x00, branchZeroFirstRequest)
if err != nil || branchZeroFirst.NewGrowType != 0x10 {
t.Fatalf("real PVF branch-zero first awakening = %+v, %v", branchZeroFirst, err)
}
branchZeroSecondRequest, _ := ParseReward(3, "[awakening type]", []int64{2})
branchZeroSecond, err := profiles.PlanTransition(10, 0x10, branchZeroSecondRequest)
if err != nil || branchZeroSecond.NewGrowType != 0x20 {
t.Fatalf("real PVF branch-zero second awakening = %+v, %v", branchZeroSecond, err)
}
if _, err := profiles.PlanTransition(8, 0x00, branchZeroFirstRequest); err == nil {
t.Fatal("ordinary branch-zero job unexpectedly exposes awakening capability")
}
t.Logf("profession snapshot=%+v fixed_level_skills=%d active_at_90=%d gunblader_class1_awaken1=%v", snapshot, fixedLevelSkills, fixedLevelAt90, gunbladerFirst)
}
func TestRealScriptPVFProfessionGrantFloorsAboveTransitionLevel(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify profession grant floors")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, 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)
}
profiles, err := LoadProfiles(context.Background(), archive, catalog)
if err != nil {
t.Fatal(err)
}
type professionBoundary struct {
growType byte
level int
}
risky := make([]string, 0)
boundaries := 0
levelRisks := 0
prerequisiteRisks := 0
for rawJob, profile := range profiles.jobs {
states := []professionBoundary{{growType: 0, level: 1}}
firstBranch, lastBranch := byte(1), byte(4)
if rawJob == 9 || rawJob == 10 {
firstBranch, lastBranch = 0, 0
} else if rawJob == 8 {
lastBranch = 5
}
for branch := firstBranch; branch <= lastBranch; branch++ {
if branch > 0 {
states = append(states, professionBoundary{growType: branch, level: 15})
}
for stage := byte(1); stage <= 2; stage++ {
if !profile.awakeningSupported[branch][stage] {
continue
}
level := 50
if stage == 2 {
level = 75
}
states = append(states, professionBoundary{growType: stage<<4 | branch, level: level})
}
}
for _, state := range states {
boundaries++
grants, ok := profiles.FreeGrantsAtLevel(catalog, rawJob, state.growType, state.level)
if !ok {
t.Fatalf("job=%d grow_type=0x%02x boundary=%d has no free baseline", rawJob, state.growType, state.level)
}
grantLevels := make(map[uint16]int, len(grants))
for _, grant := range grants {
if grant.Level > grantLevels[grant.SkillID] {
grantLevels[grant.SkillID] = grant.Level
}
}
for _, grant := range grants {
definition, found := catalog.Find(rawJob, grant.SkillID)
if !found {
continue // MissingSkills is audited by TestRealScriptPVFProfessionProfiles.
}
if int(definition.RequiredLevel) > state.level {
levelRisks++
risky = append(risky, fmt.Sprintf(
"level:%d/0x%02x/%d:floor%d:req%d:level%d",
rawJob, state.growType, grant.SkillID, grant.Level, definition.RequiredLevel, state.level,
))
}
for _, prerequisite := range definition.Prerequisites {
if grantLevels[prerequisite.SkillID] >= prerequisite.Level {
continue
}
prerequisiteRisks++
risky = append(risky, fmt.Sprintf(
"prerequisite:%d/0x%02x/%d:floor%d:needs%d@%d:has%d",
rawJob, state.growType, grant.SkillID, grant.Level,
prerequisite.SkillID, prerequisite.Level, grantLevels[prerequisite.SkillID],
))
break
}
}
}
}
sort.Strings(risky)
if boundaries != 191 || levelRisks != 63 || prerequisiteRisks != 1 || len(risky) != 64 {
t.Fatalf(
"profession floor audit boundaries=%d level_risks=%d prerequisite_risks=%d total=%d risks=%v",
boundaries, levelRisks, prerequisiteRisks, len(risky), risky,
)
}
t.Logf(
"profession boundaries=%d permanent-floor risks=%d (level=%d prerequisite=%d): %v",
boundaries, len(risky), levelRisks, prerequisiteRisks, risky,
)
}
func containsGrant(grants []Grant, skillID uint16) bool {
for _, grant := range grants {
if grant.SkillID == skillID {
return true
}
}
return false
}
func sameGrants(left []Grant, right []Grant) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
@@ -0,0 +1,437 @@
package profession
import (
"context"
"errors"
"testing"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
)
type profileSource map[string]string
func (s profileSource) ReadText(relativePath string) (string, error) {
value, ok := s[relativePath]
if !ok {
return "", errors.New("missing " + relativePath)
}
return value, nil
}
func TestProfilesApplyClassChangeAndCumulativeAwakening(t *testing.T) {
source := profileSource{
DefaultCharacterList: "2 `Gunner/Gunner.chr`\n",
"character/Gunner/Gunner.chr": `[initial value]
[skill]
1 1
[/skill]
[growtype 2]
[skill]
2 1
[/skill]
[awakening 1]
[awakening skill]
3 1
[/awakening skill]
[awakening 2]
[awakening skill]
4 1
[/awakening skill]
[growtype 3]
[skill]
10 1
[/skill]
`,
dnfskill.DefaultList: "2 `GunnerSkill.lst`\n",
"skill/GunnerSkill.lst": "1 `Gunner/a.skl` 2 `Gunner/b.skl` 3 `Gunner/c.skl` 4 `Gunner/d.skl` 5 `Gunner/system.skl` 6 `Gunner/expert.skl` 7 `Gunner/paid.skl` 8 `Gunner/future.skl` 9 `Gunner/awakening-only.skl` 10 `Gunner/alternate.skl` 11 `Gunner/optional-zero-cost.skl`\n",
"skill/Gunner/a.skl": "[name]\n`a`\n[skill type]\n`[active]`\n",
"skill/Gunner/b.skl": "[name]\n`b`\n[skill type]\n`[passive]`\n",
"skill/Gunner/c.skl": "[name]\n`c`\n[skill type]\n`[passive]`\n",
"skill/Gunner/d.skl": "[name]\n`d`\n[skill type]\n`[passive]`\n",
"skill/Gunner/alternate.skl": "[name]\n`alternate`\n[skill type]\n`[active]`\n",
"skill/Gunner/system.skl": `[name]
` + "`system`" + `
[required level]
15
[maximum level]
1
[skill class]
2
[growtype maximum level]
0 1 0
[/growtype maximum level]
[purchase cost]
0
[/purchase cost]
`,
"skill/Gunner/expert.skl": `[name]
` + "`expert`" + `
[required level]
15
[maximum level]
1
[skill class]
4
[growtype maximum level]
0 1 0
[/growtype maximum level]
[purchase cost]
0
[/purchase cost]
`,
"skill/Gunner/paid.skl": `[name]
` + "`paid`" + `
[required level]
15
[maximum level]
1
[skill class]
2
[growtype maximum level]
0 1 0
[/growtype maximum level]
[purchase cost]
20
[/purchase cost]
`,
"skill/Gunner/future.skl": `[name]
` + "`future`" + `
[required level]
60
[maximum level]
1
[skill class]
2
[growtype maximum level]
0 1 0
[/growtype maximum level]
[purchase cost]
0
[/purchase cost]
`,
"skill/Gunner/awakening-only.skl": `[name]
` + "`awakening-only`" + `
[required level]
15
[maximum level]
1
[skill class]
2
[growtype maximum level]
0 1 0
[/growtype maximum level]
[skill fitness second growtype]
1
[/skill fitness second growtype]
[purchase cost]
0
[/purchase cost]
`,
"skill/Gunner/optional-zero-cost.skl": `[name]
` + "`optional zero-cost toggle`" + `
[required level]
30
[maximum level]
1
[skill class]
3
[growtype maximum level]
0 1 0
[/growtype maximum level]
[skill fitness growtype]
1
[/skill fitness growtype]
[pre required skill]
1 1
[/pre required skill]
[purchase cost]
0
[/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)
}
profiles, err := LoadProfiles(context.Background(), source, catalog)
if err != nil {
t.Fatal(err)
}
points := dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 70, TotalTP: 10, RemainingTP: 5, SyncedLevel: 50}
record := dnfrepo.SkillRecord{CharacterID: "9", Skills: map[int64]dnfrepo.SkillState{99: {Level: 2, Enabled: true}}}
changed, err := profiles.ApplySkillTransition(catalog, 2, 50, Transition{Kind: KindClassChange, FirstGrowType: 1}, record, points)
if err != nil {
t.Fatal(err)
}
if len(changed.Skills) != 3 || changed.Skills[1].Level != 1 || changed.Skills[2].Level != 1 || changed.Skills[5].Level != 1 ||
changed.Skills[6].Level != 0 || changed.Skills[7].Level != 0 || changed.Skills[8].Level != 0 || changed.Skills[9].Level != 0 ||
changed.Skills[11].Level != 0 ||
changed.Points.RemainingSP != 100 || changed.Points.RemainingTP != 10 {
t.Fatalf("changed = %+v", changed)
}
alternateChanged, found := dnfrepo.SkillTree(changed, 1)
if !found || len(alternateChanged.Skills) != 3 || alternateChanged.Skills[5].Level != 1 || alternateChanged.Points != changed.Points {
t.Fatalf("alternate class-change tree = %+v found=%t", alternateChanged, found)
}
awakened, err := profiles.ApplySkillTransition(catalog, 2, 50, Transition{Kind: KindAwakening, FirstGrowType: 1, AwakeningStage: 2}, changed, changed.Points)
if err != nil {
t.Fatal(err)
}
if len(awakened.Skills) != 5 || awakened.Skills[3].Level != 1 || awakened.Skills[4].Level != 1 || awakened.Skills[5].Level != 1 {
t.Fatalf("awakened skills = %+v", awakened.Skills)
}
alternateAwakened, found := dnfrepo.SkillTree(awakened, 1)
if !found || len(alternateAwakened.Skills) != 5 || alternateAwakened.Skills[3].Level != 1 || alternateAwakened.Skills[4].Level != 1 || alternateAwakened.Skills[5].Level != 1 {
t.Fatalf("alternate awakened tree = %+v found=%t", alternateAwakened, found)
}
freeAtLevel, ok := profiles.FreeGrantsAtLevel(catalog, 2, 0x21, 50)
if !ok || len(freeAtLevel) != 5 || !containsGrant(freeAtLevel, 1) || !containsGrant(freeAtLevel, 2) || !containsGrant(freeAtLevel, 3) || !containsGrant(freeAtLevel, 4) || !containsGrant(freeAtLevel, 5) {
t.Fatalf("second-awakening free baseline = %v, found=%t", freeAtLevel, ok)
}
purchased := record
purchased.Skills = map[int64]dnfrepo.SkillState{99: {Level: 2, Enabled: true}}
purchased.Points = points
preserved, err := profiles.ApplySkillTransition(catalog, 2, 50, Transition{Kind: KindAwakening, FirstGrowType: 1, AwakeningStage: 1}, purchased, points)
if err != nil {
t.Fatal(err)
}
if preserved.Points != points || preserved.Skills[99].Level != 2 || preserved.Skills[3].Level != 1 {
t.Fatalf("awakening changed learned skills or point ledger = %+v", preserved)
}
preservedAlternate, found := dnfrepo.SkillTree(preserved, 1)
if !found || preservedAlternate.Points != points || preservedAlternate.Skills[99].Level != 2 || preservedAlternate.Skills[3].Level != 1 || preservedAlternate.Skills[5].Level != 1 {
t.Fatalf("awakening alternate tree = %+v found=%t", preservedAlternate, found)
}
rebaseSource := preserved
rebaseSource.Commands = map[int64][]byte{3: {1, 2, 3}}
rebaseSource.Cooldowns = map[int64]time.Time{3: time.Unix(123, 0).UTC()}
rebased, err := profiles.RebuildSkillRecordForGrowType(catalog, 2, 50, 0x02, rebaseSource, points)
if err != nil {
t.Fatal(err)
}
if len(rebased.Skills) != 2 || rebased.Skills[1].Level != 1 || rebased.Skills[10].Level != 1 ||
rebased.Skills[2].Level != 0 || rebased.Skills[3].Level != 0 || len(rebased.Commands) != 0 || len(rebased.Cooldowns) != 0 ||
rebased.Points.RemainingSP != rebased.Points.TotalSP || rebased.Points.RemainingTP != rebased.Points.TotalTP {
t.Fatalf("administrative profession rebase = %+v", rebased)
}
rebasedAlternate, found := dnfrepo.SkillTree(rebased, 1)
if !found || len(rebasedAlternate.Skills) != 2 || rebasedAlternate.Skills[10].Level != 1 || rebasedAlternate.Points != rebased.Points {
t.Fatalf("administrative alternate tree = %+v found=%t", rebasedAlternate, found)
}
if _, err := profiles.RebuildSkillRecordForGrowType(catalog, 2, 50, 0x04, rebaseSource, points); !errors.Is(err, ErrTransitionInvalid) {
t.Fatalf("unsupported administrative branch error = %v, want %v", err, ErrTransitionInvalid)
}
if _, err := profiles.RebuildSkillRecordForGrowType(catalog, 2, 50, 0x13, rebaseSource, points); !errors.Is(err, ErrTransitionInvalid) {
t.Fatalf("unsupported administrative awakening error = %v, want %v", err, ErrTransitionInvalid)
}
}
func TestProfilesAddsMappedZeroCostAwakeningHelperOnlyAtItsStageAndBranch(t *testing.T) {
source := profileSource{
DefaultCharacterList: "3 `Mage/Mage.chr`\n",
"character/Mage/Mage.chr": `[initial value]
[skill]
1 1
[/skill]
[growtype 3]
[awakening 1]
[awakening skill]
[/awakening skill]
[growtype 4]
[awakening 1]
[awakening skill]
[/awakening skill]
`,
dnfskill.DefaultList: "3 `MageSkill.lst`\n",
"skill/MageSkill.lst": "1 `Mage/base.skl` 301 `Mage/bellatrixconversion.skl`\n",
"skill/Mage/base.skl": "[name]\n`base`\n[skill type]\n`[active]`\n",
"skill/Mage/bellatrixconversion.skl": `[name]
` + "`bellatrix conversion`" + `
[required level]
50
[maximum level]
1
[skill class]
2
[growtype maximum level]
0 0 0 0 0 0
[/growtype maximum level]
[skill fitness second growtype]
1
[/skill fitness second growtype]
[purchase cost]
0
[/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)
}
profiles, err := LoadProfiles(context.Background(), source, catalog)
if err != nil {
t.Fatal(err)
}
beforeAwakening, _ := profiles.FreeGrantsAtLevel(catalog, 3, 0x03, 50)
wrongBranch, _ := profiles.FreeGrantsAtLevel(catalog, 3, 0x12, 50)
firstAwakening, _ := profiles.FreeGrantsAtLevel(catalog, 3, 0x13, 50)
if containsGrant(beforeAwakening, 301) || containsGrant(wrongBranch, 301) || grantLevel(firstAwakening, 301) != 1 {
t.Fatalf("mapped awakening helper before=%v wrong_branch=%v first=%v", beforeAwakening, wrongBranch, firstAwakening)
}
}
func TestFreeGrantsAtLevelRetainsMissingAwakeningDefinitionsForAudit(t *testing.T) {
source := profileSource{
DefaultCharacterList: "3 `Mage/Mage.chr`\n",
"character/Mage/Mage.chr": "[initial value]\n[skill]\n1 1\n[/skill]\n" +
"[growtype 4]\n[awakening 1]\n[awakening skill]\n777 1\n[/awakening skill]\n",
dnfskill.DefaultList: "3 `MageSkill.lst`\n",
"skill/MageSkill.lst": "1 `Mage/base.skl`\n",
"skill/Mage/base.skl": "[name]\n`base`\n[type]\n`[active]`\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)
}
profiles, err := LoadProfiles(context.Background(), source, catalog)
if err != nil {
t.Fatal(err)
}
raw, rawOK := profiles.FreeGrants(3, 3, 1)
protected, protectedOK := profiles.FreeGrantsAtLevel(catalog, 3, 0x13, 90)
if !rawOK || !protectedOK || !containsGrant(raw, 777) || !containsGrant(protected, 777) {
t.Fatalf("missing awakening definition raw=%v found=%t protected=%v found=%t", raw, rawOK, protected, protectedOK)
}
if snapshot := profiles.Snapshot(); snapshot.MissingSkills != 1 {
t.Fatalf("profession snapshot=%+v want one observable missing skill", snapshot)
}
}
func TestProfilesDisplayNameUsesPVFGrowAndAwakeningNames(t *testing.T) {
profile, err := parseJobProfile(`[growtype name]
` + "`鬼剑士` `驭剑士`" + `
[initial value]
[skill]
1 1
[/skill]
[growtype 1]
[awakening name]
` + "`黑暗武士` `时空主宰`" + `
[growtype 2]
[skill]
2 1
[/skill]
[awakening name]
` + "`剑宗` `剑皇`" + `
`)
if err != nil {
t.Fatal(err)
}
profiles := &Profiles{jobs: map[byte]jobProfile{11: profile}}
for _, test := range []struct {
grow byte
want string
}{
{grow: 0x00, want: "鬼剑士"},
{grow: 0x01, want: "驭剑士"},
{grow: 0x11, want: "剑宗"},
{grow: 0x21, want: "剑皇"},
} {
got, ok := profiles.DisplayName(11, test.grow)
if !ok || got != test.want {
t.Fatalf("grow=0x%02x display=%q found=%t want=%q", test.grow, got, ok, test.want)
}
}
}
func TestProfilesApplyPVFFixedLevelSkillAtCharacterLevel(t *testing.T) {
source := profileSource{
DefaultCharacterList: "2 `Gunner/Gunner.chr`\n",
"character/Gunner/Gunner.chr": "[initial value]\n[skill]\n1 1\n[/skill]\n[growtype 2]\n[skill]\n2 1\n[/skill]\n",
dnfskill.DefaultList: "2 `GunnerSkill.lst`\n",
"skill/GunnerSkill.lst": "1 `Gunner/a.skl` 2 `Gunner/b.skl`\n",
"skill/Gunner/a.skl": "[name]\n`a`\n[required level]\n1\n[maximum level]\n10\n[fixed level skill]\n1\n[interval level]\n10\n[add level per interval]\n1\n",
"skill/Gunner/b.skl": "[name]\n`b`\n[type]\n`[passive]`\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)
}
profiles, err := LoadProfiles(context.Background(), source, catalog)
if err != nil {
t.Fatal(err)
}
changed, err := profiles.ApplySkillTransition(
catalog, 2, 21,
Transition{Kind: KindClassChange, FirstGrowType: 1},
dnfrepo.SkillRecord{CharacterID: "9"},
dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 70},
)
if err != nil {
t.Fatal(err)
}
if got := changed.Skills[1].Level; got != 3 {
t.Fatalf("fixed-level initial skill=%d want=3", got)
}
freeAtLevel, ok := profiles.FreeGrantsAtLevel(catalog, 2, 0x01, 21)
if !ok || grantLevel(freeAtLevel, 1) != 3 {
t.Fatalf("fixed-level free baseline=%v found=%t want skill1 level3", freeAtLevel, ok)
}
}
func grantLevel(grants []Grant, skillID uint16) int {
for _, grant := range grants {
if grant.SkillID == skillID {
return grant.Level
}
}
return 0
}
func TestParseJobProfileRecordsEmptyBranchZeroAwakeningCapabilities(t *testing.T) {
profile, err := parseJobProfile(`[initial value]
[skill]
1 1
[/skill]
[growtype 1]
[awakening 1]
[awakening skill]
[/awakening skill]
[awakening 2]
[awakening skill]
[/awakening skill]
`)
if err != nil {
t.Fatal(err)
}
profiles := &Profiles{jobs: map[byte]jobProfile{9: profile}}
firstRequest, _ := ParseReward(2, "[awakening type]", []int64{1})
first, err := profiles.PlanTransition(9, 0x00, firstRequest)
if err != nil || first.NewGrowType != 0x10 {
t.Fatalf("empty branch-zero awakening1 capability = %+v, %v", first, err)
}
secondRequest, _ := ParseReward(3, "[awakening type]", []int64{2})
second, err := profiles.PlanTransition(9, first.NewGrowType, secondRequest)
if err != nil || second.NewGrowType != 0x20 {
t.Fatalf("empty branch-zero awakening2 capability = %+v, %v", second, err)
}
}
@@ -0,0 +1,105 @@
// Package profession owns the PVF-backed character advancement state machine.
// It contains no network opcodes and performs no repository I/O.
package profession
import (
"errors"
"fmt"
"math"
"strings"
)
var (
ErrRewardUnsupported = errors.New("dnf profession reward type is unsupported")
ErrRewardMalformed = errors.New("dnf profession reward data is malformed")
ErrTransitionInvalid = errors.New("dnf profession transition is invalid")
ErrTransitionOutOfStep = errors.New("dnf profession transition is out of sequence")
)
type Kind uint8
const (
KindNone Kind = iota
KindClassChange
KindAwakening
)
// Transition is the detached result of applying one final profession quest
// reward to the packed grow_type byte. Low four bits select the class branch;
// high four bits select the awakening stage.
type Transition struct {
Kind Kind
ChainType byte
GrowNumber byte
PreviousGrowType byte
NewGrowType byte
FirstGrowType byte
AwakeningStage byte
}
// Request is the detached profession reward encoded by one quest definition.
// It contains no character state; the repository owner must resolve it through
// the job's PVF profile inside the same transaction as the quest completion.
type Request struct {
Kind Kind
ChainType byte
GrowNumber byte
JobChangeQuest int
}
func Decode(growType byte) (first byte, awakening byte) {
return growType & 0x0f, (growType >> 4) & 0x0f
}
func Encode(first byte, awakening byte) (byte, error) {
if first > 0x0f || awakening > 0x0f {
return 0, fmt.Errorf("%w: first=%d awakening=%d", ErrTransitionInvalid, first, awakening)
}
return awakening<<4 | first, nil
}
// ParseReward accepts only the two profession reward tags used by the runtime
// PVF. It deliberately does not inspect or change grow_type.
func ParseReward(jobChangeQuest int, rewardType string, rewardData []int64) (Request, error) {
tag := normalizeTag(rewardType)
if tag != "grow type" && tag != "awakening type" {
return Request{}, fmt.Errorf("%w: type=%q", ErrRewardUnsupported, rewardType)
}
if len(rewardData) == 0 || rewardData[0] <= 0 || rewardData[0] > math.MaxUint8 {
return Request{}, fmt.Errorf("%w: type=%q values=%v", ErrRewardMalformed, rewardType, rewardData)
}
value := byte(rewardData[0])
switch tag {
case "grow type":
if value > 0x0f {
return Request{}, fmt.Errorf("%w: class=%d", ErrTransitionInvalid, value)
}
if jobChangeQuest != 0 && jobChangeQuest != 1 {
return Request{}, fmt.Errorf("%w: class change quest marker=%d", ErrTransitionInvalid, jobChangeQuest)
}
return Request{
Kind: KindClassChange, ChainType: 1, GrowNumber: value, JobChangeQuest: jobChangeQuest,
}, nil
case "awakening type":
if value > 2 {
return Request{}, fmt.Errorf("%w: awakening target=%d", ErrTransitionInvalid, value)
}
if jobChangeQuest != 0 && jobChangeQuest != int(value)+1 {
return Request{}, fmt.Errorf("%w: awakening target=%d quest marker=%d", ErrTransitionInvalid, value, jobChangeQuest)
}
return Request{
Kind: KindAwakening, ChainType: 2, GrowNumber: value, JobChangeQuest: jobChangeQuest,
}, nil
default:
return Request{}, fmt.Errorf("%w: type=%q", ErrRewardUnsupported, rewardType)
}
}
func normalizeTag(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if len(value) >= 2 && value[0] == '[' && value[len(value)-1] == ']' {
value = strings.TrimSpace(value[1 : len(value)-1])
}
return value
}
@@ -0,0 +1,83 @@
package profession
import (
"errors"
"testing"
)
func TestPlanRewardClassChangeAndAwakenings(t *testing.T) {
profiles := transitionTestProfiles()
changeRequest, err := ParseReward(1, "[grow type]", []int64{3})
if err != nil {
t.Fatal(err)
}
changed, err := profiles.PlanTransition(2, 0, changeRequest)
if err != nil || changed.Kind != KindClassChange || changed.ChainType != 1 || changed.NewGrowType != 0x03 {
t.Fatalf("class change = %+v, %v", changed, err)
}
firstRequest, err := ParseReward(2, "[awakening type]", []int64{1})
if err != nil {
t.Fatal(err)
}
first, err := profiles.PlanTransition(2, changed.NewGrowType, firstRequest)
if err != nil || first.Kind != KindAwakening || first.ChainType != 2 || first.NewGrowType != 0x13 {
t.Fatalf("first awakening = %+v, %v", first, err)
}
secondRequest, err := ParseReward(3, "[awakening type]", []int64{2})
if err != nil {
t.Fatal(err)
}
second, err := profiles.PlanTransition(2, first.NewGrowType, secondRequest)
if err != nil || second.NewGrowType != 0x23 || second.GrowNumber != 2 {
t.Fatalf("second awakening = %+v, %v", second, err)
}
}
func TestPlanRewardRejectsSkippedOrMixedTransitions(t *testing.T) {
profiles := transitionTestProfiles()
changeRequest, _ := ParseReward(1, "[grow type]", []int64{2})
if _, err := profiles.PlanTransition(2, 0x03, changeRequest); !errors.Is(err, ErrTransitionOutOfStep) {
t.Fatalf("reclass error = %v", err)
}
secondRequest, _ := ParseReward(3, "[awakening type]", []int64{2})
if _, err := profiles.PlanTransition(2, 0x03, secondRequest); !errors.Is(err, ErrTransitionOutOfStep) {
t.Fatalf("skipped awakening error = %v", err)
}
firstRequest, _ := ParseReward(2, "[awakening type]", []int64{1})
if _, err := profiles.PlanTransition(2, 0x13, firstRequest); !errors.Is(err, ErrTransitionOutOfStep) {
t.Fatalf("replayed awakening error = %v", err)
}
}
func TestPlanRewardUsesPVFBranchZeroAwakeningCapabilities(t *testing.T) {
profiles := transitionTestProfiles()
firstRequest, _ := ParseReward(2, "[awakening type]", []int64{1})
first, err := profiles.PlanTransition(9, 0, firstRequest)
if err != nil || first.NewGrowType != 0x10 || first.FirstGrowType != 0 || first.AwakeningStage != 1 {
t.Fatalf("branch-zero first awakening = %+v, %v", first, err)
}
secondRequest, _ := ParseReward(3, "[awakening type]", []int64{2})
second, err := profiles.PlanTransition(9, first.NewGrowType, secondRequest)
if err != nil || second.NewGrowType != 0x20 || second.AwakeningStage != 2 {
t.Fatalf("branch-zero second awakening = %+v, %v", second, err)
}
if _, err := profiles.PlanTransition(2, 0, firstRequest); !errors.Is(err, ErrTransitionInvalid) {
t.Fatalf("ordinary job branch-zero awakening error = %v", err)
}
if _, err := profiles.PlanTransition(9, 0, secondRequest); !errors.Is(err, ErrTransitionOutOfStep) {
t.Fatalf("branch-zero skipped awakening error = %v", err)
}
}
func transitionTestProfiles() *Profiles {
return &Profiles{jobs: map[byte]jobProfile{
2: {
growSupported: map[byte]bool{3: true},
awakeningSupported: map[byte]map[byte]bool{3: {1: true, 2: true}},
},
9: {
growSupported: map[byte]bool{0: true},
awakeningSupported: map[byte]map[byte]bool{0: {1: true, 2: true}},
},
}}
}
@@ -0,0 +1,74 @@
package profession
import (
"context"
"fmt"
"os"
"sort"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestZZProbeAwakeningStages(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, 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)
}
profiles, err := LoadProfiles(context.Background(), archive, catalog)
if err != nil {
t.Fatal(err)
}
maxStage := byte(0)
for job, profile := range profiles.jobs {
for branch, stages := range profile.awakeningSupported {
for stage := range stages {
if stage > maxStage {
maxStage = stage
}
}
_ = branch
}
fmt.Printf("PROBE job=%d branchesWithAwakening=%d\n", job, len(profile.awakeningSupported))
keys := make([]int, 0, len(profile.awakeningSupported))
for branch := range profile.awakeningSupported {
keys = append(keys, int(branch))
}
sort.Ints(keys)
for _, branch := range keys {
stages := make([]int, 0, 4)
for stage := range profile.awakeningSupported[byte(branch)] {
stages = append(stages, int(stage))
}
sort.Ints(stages)
fmt.Printf("PROBE job=%d branch=%d stages=%v\n", job, branch, stages)
}
}
fmt.Printf("PROBE maxSupportedAwakeningStage=%d\n", maxStage)
stageHistogram := map[int]int{}
globalMax := 0
for _, definition := range catalog.Skills() {
for _, stage := range definition.SecondGrowTypes {
stageHistogram[stage]++
if stage > globalMax {
globalMax = stage
}
}
}
fmt.Printf("PROBE skillSecondGrowType(max=%d) histogram=%v\n", globalMax, stageHistogram)
}