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