Files
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

419 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"fmt"
"path"
"sort"
"strconv"
"strings"
"pvfine/internal/pvf"
)
// SkillTreeRegistration retains the job token: skill IDs are local to this job.
type SkillTreeRegistration struct {
Job string `json:"job"`
Kind string `json:"kind"`
Path string `json:"path"`
}
type SkillTreeNode struct {
SourceStart int `json:"sourceStart"`
SourceClose int `json:"sourceClose"`
PositionTokens []SkillNumber `json:"positionTokens"`
NextTokens []SkillNumber `json:"nextTokens"`
ID int `json:"id"`
X int `json:"x"`
Y int `json:"y"`
PositionKind string `json:"positionKind"`
Position []int `json:"position"`
Next []int `json:"next"`
Name string `json:"name"`
Explain string `json:"explain"`
Path string `json:"path"`
FileIndex int32 `json:"fileIndex"`
Icon *ImageReference `json:"icon"`
RequiredLevel *int `json:"requiredLevel"`
}
type SkillTreeBranch struct {
InsertOffset int `json:"insertOffset"`
Job string `json:"job"`
Name string `json:"name"`
Nodes []SkillTreeNode `json:"nodes"`
}
type SkillTreeDocument struct {
Text string `json:"text"`
FileIndex int32 `json:"fileIndex"`
Path string `json:"path"`
Common []SkillTreeNode `json:"common"`
Branches []SkillTreeBranch `json:"branches"`
Issues []string `json:"issues"`
}
func skillTreeText(a *pvf.Archive, p string) (string, error) {
i, ok := a.Find(p)
if !ok {
return "", fmt.Errorf("找不到文件:%s", p)
}
return a.Text(i)
}
func skillTreeRegistrations(a *pvf.Archive) ([]SkillTreeRegistration, error) {
result := []SkillTreeRegistration{}
var failures []string
for _, kind := range []string{"sp", "tp"} {
p := "clientonly/skillshoptree" + kind + "index.co"
text, err := skillTreeText(a, p)
if err != nil {
failures = append(failures, err.Error())
continue
}
values := []string{}
for _, e := range pvf.ParseScriptView(text).Elements {
if e.Kind == pvf.ScriptElementToken && e.Section == "skill tree" {
values = append(values, e.Value)
}
}
if len(values)%2 != 0 {
return nil, fmt.Errorf("%s 的注册记录不完整", p)
}
for i := 0; i+1 < len(values); i += 2 {
result = append(result, SkillTreeRegistration{Job: values[i], Kind: kind, Path: path.Join("clientonly", strings.ReplaceAll(values[i+1], "\\", "/"))})
}
}
if len(result) == 0 && len(failures) > 0 {
return nil, fmt.Errorf("%s", strings.Join(failures, ";"))
}
return result, nil
}
func (s *FileGUIService) ListSkillTrees() ([]SkillTreeRegistration, error) {
s.c.mu.Lock()
defer s.c.mu.Unlock()
if s.c.archive == nil {
return nil, ErrNoArchive
}
return skillTreeRegistrations(s.c.archive)
}
// ReadSkillTree resolves only registered trees, using the current archive overlay.
func (s *FileGUIService) ReadSkillTree(job, kind string) (*SkillTreeDocument, error) {
s.c.mu.Lock()
defer s.c.mu.Unlock()
return s.readSkillTreeLocked(job, kind, nil)
}
// ReadSkillTreeDraft projects the editor draft without changing the archive.
func (s *FileGUIService) ReadSkillTreeDraft(job, kind, text string) (*SkillTreeDocument, error) {
s.c.mu.Lock()
defer s.c.mu.Unlock()
if len(text) > int(s.c.editableByteLimit()) {
return nil, fmt.Errorf("技能树文本过大")
}
return s.readSkillTreeLocked(job, kind, &text)
}
func (s *FileGUIService) readSkillTreeLocked(job, kind string, draft *string) (*SkillTreeDocument, error) {
a := s.c.archive
if a == nil {
return nil, ErrNoArchive
}
registrations, err := skillTreeRegistrations(a)
if err != nil {
return nil, err
}
var p string
for _, r := range registrations {
if r.Job == job && r.Kind == kind {
p = r.Path
break
}
}
if p == "" {
return nil, fmt.Errorf("该职业未注册 %s 技能树", kind)
}
text, err := skillTreeText(a, p)
if err != nil {
return nil, err
}
if draft != nil {
text = *draft
}
doc := parseSkillTree(text)
// Use the archive identity, not the registration spelling (often mixed case).
treeIndex, _ := a.Find(p) // skillTreeText above already validated this lookup.
doc.FileIndex = treeIndex
doc.Path = a.Path(treeIndex)
paths, err := skillTreeSkillPaths(a, job)
if err != nil {
return nil, err
}
cache := map[int]SkillTreeNode{}
resolve := func(n *SkillTreeNode) {
meta, exists := cache[n.ID]
if !exists {
meta = SkillTreeNode{ID: n.ID, Name: fmt.Sprintf("技能 #%d", n.ID), FileIndex: -1}
meta.Path = paths[n.ID]
if idx, found := a.Find(meta.Path); found && meta.Path != "" {
meta.FileIndex = idx
if body, err := a.Text(idx); err == nil {
meta.Explain = normalizeExplain(resolvePreviewDescription(a, firstSectionValue(body, "explain")))
if level, err := strconv.Atoi(firstSectionValue(body, "required level")); err == nil {
meta.RequiredLevel = &level
}
} else {
doc.Issues = append(doc.Issues, fmt.Sprintf("技能 #%d 描述和学习等级读取失败:%s", n.ID, err))
}
if m, e := a.ScriptMetadata(idx); e == nil {
if m.HasName {
meta.Name = resolvePreviewText(a, m.Name)
}
meta.Icon = imageReferenceFromPVF(m.Icon)
} else {
doc.Issues = append(doc.Issues, fmt.Sprintf("技能 #%d 信息读取失败:%s", n.ID, e))
}
} else {
doc.Issues = append(doc.Issues, fmt.Sprintf("职业 %s 的技能 #%d 未找到定义", job, n.ID))
}
cache[n.ID] = meta
}
n.Name, n.Path, n.FileIndex, n.Icon, n.RequiredLevel, n.Explain = meta.Name, meta.Path, meta.FileIndex, meta.Icon, meta.RequiredLevel, meta.Explain
}
commonText, e := skillTreeText(a, "clientonly/commonskilllist.co")
if e != nil {
doc.Issues = append(doc.Issues, e.Error())
} else {
for _, id := range parseCommonSkillIDs(commonText, job) {
n := SkillTreeNode{ID: id, SourceStart: -1, Next: []int{}, Position: []int{}, PositionTokens: []SkillNumber{}}
resolve(&n)
doc.Common = append(doc.Common, n)
}
}
for bi := range doc.Branches {
b := &doc.Branches[bi]
if b.Job != job {
continue
}
ids := map[int]bool{}
for ni := range b.Nodes {
n := &b.Nodes[ni]
resolve(n)
ids[n.ID] = true
}
for _, n := range b.Nodes {
for _, id := range n.Next {
if !ids[id] {
doc.Issues = append(doc.Issues, fmt.Sprintf("%s:技能 #%d 的后继 #%d 不在当前分支", b.Name, n.ID, id))
}
}
}
}
filtered := []SkillTreeBranch{}
for _, b := range doc.Branches {
if b.Job == job {
filtered = append(filtered, b)
}
}
doc.Branches = filtered
return doc, nil
}
func parseCommonSkillIDs(text, job string) []int {
ids := []int{}
excluded := map[int]bool{}
matching := false
for _, e := range pvf.ParseScriptView(text).Elements {
if e.Kind != pvf.ScriptElementToken {
continue
}
if e.Section == "common skill" {
if id, err := strconv.Atoi(e.Value); err == nil {
ids = append(ids, id)
}
}
if e.Section == "info" {
if e.Index == 0 {
matching = e.Value == job
} else if matching {
if id, err := strconv.Atoi(e.Value); err == nil {
excluded[id] = true
}
}
}
}
result := []int{}
for _, id := range ids {
if !excluded[id] {
result = append(result, id)
}
}
return result
}
func parseSkillTree(text string) *SkillTreeDocument {
doc := &SkillTreeDocument{Text: text, Common: []SkillTreeNode{}, Branches: []SkillTreeBranch{}, Issues: []string{}}
closingOffsets := pvf.ScriptSectionClosingOffsets(text, "character job")
skillClosingOffsets := pvf.ScriptSectionClosingOffsets(text, "skill info")
var branch *SkillTreeBranch
var node *SkillTreeNode
for _, e := range pvf.ParseScriptView(text).Elements {
if e.Kind == pvf.ScriptElementSection {
if e.Section == "character job" {
offset, closed := closingOffsets[e.Start]
if !closed {
offset = -1
}
doc.Branches = append(doc.Branches, SkillTreeBranch{InsertOffset: offset, Nodes: []SkillTreeNode{}})
branch = &doc.Branches[len(doc.Branches)-1]
node = nil
}
if e.Section == "skill info" && branch != nil {
closeOffset, closed := skillClosingOffsets[e.Start]
if !closed {
closeOffset = -1
}
branch.Nodes = append(branch.Nodes, SkillTreeNode{SourceStart: e.Start, SourceClose: closeOffset, ID: -1, FileIndex: -1, Position: []int{}, PositionTokens: []SkillNumber{}, NextTokens: []SkillNumber{}, Next: []int{}})
node = &branch.Nodes[len(branch.Nodes)-1]
}
continue
}
if branch == nil {
continue
}
if e.Section == "character job" {
if e.Index == 0 {
branch.Job = e.Value
}
if e.Index == 1 {
branch.Name = e.Value
}
continue
}
if node == nil {
continue
}
v, err := strconv.Atoi(e.Value)
if err != nil {
continue
}
switch e.Section {
case "index":
node.ID = v
case "next skill":
node.Next = append(node.Next, v)
node.NextTokens = append(node.NextTokens, SkillNumber{Value: float64(v), Start: e.Start, End: e.End, TokenType: e.TokenType})
case "cell pos", "icon pos":
if e.Index == 0 {
node.PositionKind = e.Section
node.Position = []int{v}
node.PositionTokens = []SkillNumber{}
} else if e.Index == 1 {
node.Position = append(node.Position, v)
}
if e.Index <= 1 {
node.PositionTokens = append(node.PositionTokens, SkillNumber{Value: float64(v), Start: e.Start, End: e.End, TokenType: e.TokenType})
}
}
}
for bi := range doc.Branches {
for ni := range doc.Branches[bi].Nodes {
n := &doc.Branches[bi].Nodes[ni]
if n.ID < 0 || len(n.Position) != 2 || n.Position[0] < 0 || n.Position[1] < 0 {
doc.Issues = append(doc.Issues, fmt.Sprintf("%s:技能 #%d 的编号或坐标无效,未渲染", doc.Branches[bi].Name, n.ID))
continue
}
n.X, n.Y = n.Position[0], n.Position[1]
// 110US mixes both formats within one branch, with half-cell icon offsets.
if n.PositionKind == "cell pos" {
n.X *= 47
n.Y *= 67
}
}
valid := []SkillTreeNode{}
for _, n := range doc.Branches[bi].Nodes {
if n.ID >= 0 && len(n.Position) == 2 && n.X >= 0 && n.Y >= 0 && n.Position[0] >= 0 && n.Position[1] >= 0 {
valid = append(valid, n)
}
}
doc.Branches[bi].Nodes = valid
}
return doc
}
func skillTreeSkillPaths(a *pvf.Archive, job string) (map[int]string, error) {
// Discover the job list through skilllist.lst, including newer jobs.
listIndex, ok := a.Find("skill/skilllist.lst")
if !ok {
return nil, fmt.Errorf("找不到职业技能列表 skill/skilllist.lst")
}
lists, err := a.ListPairs(listIndex)
if err != nil {
return nil, err
}
jobKey := strings.ReplaceAll(strings.Trim(job, "[]"), " ", "")
if jobKey == "demonicswordman" {
jobKey = "dsswordman"
}
var listPath string
for _, pair := range lists {
key := strings.ToLower(strings.TrimSuffix(path.Base(strings.ReplaceAll(pair.Path, "\\", "/")), path.Ext(pair.Path)))
key = strings.TrimSuffix(key, "skill")
if key == jobKey {
listPath = path.Join("skill", strings.ReplaceAll(pair.Path, "\\", "/"))
break
}
}
if listPath == "" {
return nil, fmt.Errorf("未找到职业 %s 对应的技能列表", job)
}
li, ok := a.Find(listPath)
if !ok {
return nil, fmt.Errorf("找不到职业技能列表:%s", listPath)
}
pairs, err := a.ListPairs(li)
if err != nil {
return nil, err
}
paths := map[int]string{}
for _, pair := range pairs {
if id, err := strconv.Atoi(pair.ID); err == nil {
paths[id] = path.Join("skill", strings.ReplaceAll(pair.Path, "\\", "/"))
}
}
return paths, nil
}
type SkillTreeSkill struct {
ID int `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
FileIndex int32 `json:"fileIndex"`
}
// ListSkillTreeSkills lists registered IDs in the current job, including common skills.
func (s *FileGUIService) ListSkillTreeSkills(job string) ([]SkillTreeSkill, error) {
s.c.mu.Lock()
defer s.c.mu.Unlock()
a := s.c.archive
if a == nil {
return nil, ErrNoArchive
}
paths, err := skillTreeSkillPaths(a, job)
if err != nil {
return nil, err
}
result := []SkillTreeSkill{}
for id, p := range paths {
index, found := a.Find(p)
if !found {
continue
}
name := path.Base(p)
if meta, err := a.ScriptMetadata(index); err == nil && meta.HasName {
name = resolvePreviewText(a, meta.Name)
}
result = append(result, SkillTreeSkill{ID: id, Name: name, Path: a.Path(index), FileIndex: index})
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result, nil
}