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,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)
|
||||
}
|
||||
Reference in new issue
Block a user