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

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

763 lines
26 KiB
Go

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