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

302 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 (
"os"
"reflect"
"strconv"
"strings"
"testing"
"unicode/utf16"
"pvfine/internal/pvf"
)
func TestSkillTreeMixedCoordinatesAndEdges(t *testing.T) {
doc := parseSkillTree("[character job]\n`[swordman]` `none`\n[skill info]\n[index]\n5\n[cell pos]\n2 3\n[next skill]\n7\n[next skill]\n8\n[/skill info]\n[skill info]\n[index]\n7\n[icon pos]\n23 67\n[/skill info]\n[/character job]\n[character job]\n`[swordman]` `weaponmaster`\n[skill info]\n[index]\n8\n[cell pos]\n0 0\n[/skill info]\n[/character job]")
if len(doc.Branches) != 2 || len(doc.Branches[0].Nodes) != 2 || len(doc.Branches[1].Nodes) != 1 {
t.Fatalf("分支解析错误:%+v", doc)
}
n := doc.Branches[0].Nodes[0]
if n.X != 94 || n.Y != 201 || !reflect.DeepEqual(n.Next, []int{7, 8}) {
t.Fatalf("坐标或顺序错误:%+v", n)
}
half := doc.Branches[0].Nodes[1]
if len(n.PositionTokens) != 2 || n.PositionTokens[0].Value != 2 || n.PositionTokens[1].Value != 3 || n.SourceStart <= 0 {
t.Fatalf("缺少可编辑坐标位置:%+v", n)
}
if half.X != 23 || half.Y != 67 || half.PositionKind != "icon pos" {
t.Fatalf("半格坐标错误:%+v", half)
}
}
func TestCommonSkillsJobExclusions(t *testing.T) {
text := "[common skill]\n169 190 197\n[/common skill]\n[except skill info]\n[info]\n`[creator mage]` 197\n[/info]\n[info]\n`[swordman]` 169 190\n[/info]\n[info]\n`[creator mage]` 190\n[/info]\n[/except skill info]"
if got := parseCommonSkillIDs(text, "[creator mage]"); !reflect.DeepEqual(got, []int{169}) {
t.Fatal(got)
}
if got := parseCommonSkillIDs(text, "[swordman]"); !reflect.DeepEqual(got, []int{197}) {
t.Fatal(got)
}
if got := parseCommonSkillIDs(text, "[mage]"); !reflect.DeepEqual(got, []int{169, 190, 197}) {
t.Fatal(got)
}
}
func TestSkillTreeInvalidPositions(t *testing.T) {
doc := parseSkillTree("[character job]\n`[mage]` `none`\n[skill info]\n[index]\n1\n[/skill info]\n[skill info]\n[index]\n2\n[cell pos]\n-1 2\n[/skill info]\n[/character job]")
if len(doc.Issues) != 2 || len(doc.Branches[0].Nodes) != 0 {
t.Fatalf("无效坐标未提示:%+v", doc)
}
}
func TestSkillTreeReadJobScopedAndOverlay(t *testing.T) {
a := pvf.New()
defer a.Release()
files := map[string]string{
"clientonly/skillshoptreespindex.co": "[skill tree]\n`[swordman]` `SkillTree/Test.co` `[mage]` `SkillTree/Test.co`\n[/skill tree]",
"clientonly/skilltree/test.co": "[character job]\n`[swordman]` `none`\n[skill info]\n[index]\n169\n[cell pos]\n1 2\n[/skill info]\n[/character job]\n[character job]\n`[mage]` `none`\n[skill info]\n[index]\n169\n[icon pos]\n23 0\n[/skill info]\n[/character job]",
"clientonly/commonskilllist.co": "[common skill]\n169\n[/common skill]",
"skill/skilllist.lst": "0 `SwordmanSkill.lst` 3 `MageSkill.lst`",
"skill/swordmanskill.lst": "169 `Swordman/BackStep.skl` 7 `Swordman/New.skl`",
"skill/swordman/new.skl": "[name]\n`新增技能`",
"skill/swordman/ignored.skl": "[name]\n`未注册技能`",
"skill/mageskill.lst": "169 `Mage/BackStep.skl`",
"skill/swordman/backstep.skl": "[name]\n`剑士后跳`\n[required level]\n0\n[explain]\n`向后跳跃\\n回避攻击,成功率100%%`",
"skill/mage/backstep.skl": "[name]\n`法师后跳`\n[explain]\n{8=`<1::description>`}",
"list/n_string.lst": "1 `String/SkillTree.uv.str`",
}
for p, text := range files {
if _, e := a.AddFileText(p, text, pvf.TypeScript); e != nil {
t.Fatal(e)
}
}
table := []byte{}
for _, unit := range utf16.Encode([]rune("description>法师技能描述\\n第二行\r\n")) {
table = append(table, byte(unit), byte(unit>>8))
}
a.AddFile("String/SkillTree.uv.str", table, pvf.TypeScript)
s := NewFileGUIService(&core{archive: a})
for job, count := range map[string]int{"[swordman]": 2, "[mage]": 1} {
catalog, err := s.ListSkillTreeSkills(job)
if err != nil || len(catalog) != count {
t.Fatalf("职业技能列表错误:%s %+v %v", job, catalog, err)
}
expectedName := "剑士后跳"
if job == "[mage]" {
expectedName = "法师后跳"
}
for _, skill := range catalog {
if skill.ID == 169 && skill.Name != expectedName {
t.Fatal("同ID应按职业解析")
}
}
}
for job, name := range map[string]string{"[swordman]": "剑士后跳", "[mage]": "法师后跳"} {
doc, e := s.ReadSkillTree(job, "sp")
if e != nil {
t.Fatal(e)
}
treeIndex, found := a.Find("clientonly/skilltree/test.co")
if !found || doc.FileIndex != treeIndex || doc.Path != a.Path(treeIndex) {
t.Fatalf("混合大小写注册路径应返回归档的实际文件身份:%d %s", doc.FileIndex, doc.Path)
}
if len(doc.Common) != 1 || doc.Common[0].Name != name || len(doc.Branches) != 1 || doc.Branches[0].Nodes[0].Name != name {
t.Fatalf("技能必须按职业解析:%+v", doc)
}
}
for job, want := range map[string]string{"[swordman]": "向后跳跃\n回避攻击,成功率100%", "[mage]": "法师技能描述\n第二行"} {
doc, err := s.ReadSkillTree(job, "sp")
if err != nil {
t.Fatal(err)
}
if doc.Common[0].Explain != want || doc.Branches[0].Nodes[0].Explain != want {
t.Fatalf("%s 描述应解析引用并保留换行:%q", job, doc.Common[0].Explain)
}
}
treeIndex, _ := a.Find("clientonly/skilltree/test.co")
before, err := a.Text(treeIndex)
if err != nil {
t.Fatal(err)
}
// Match the renderer's whitespace without assuming one particular style.
view := pvf.ParseScriptView(before)
var coordinates []pvf.ScriptElement
for _, element := range view.Elements {
if element.Kind == pvf.ScriptElementToken && element.Section == "cell pos" {
coordinates = append(coordinates, element)
}
}
if len(coordinates) != 2 {
t.Fatal("缺少合成坐标")
}
units := utf16.Encode([]rune(before))
units = append(append(append([]uint16{}, units[:coordinates[1].Start]...), uint16('1')), units[coordinates[1].End:]...)
units = append(append(append([]uint16{}, units[:coordinates[0].Start]...), uint16('3')), units[coordinates[0].End:]...)
draft := string(utf16.Decode(units))
preview, err := s.ReadSkillTreeDraft("[swordman]", "sp", draft)
if err != nil {
t.Fatal(err)
}
if preview.Text != draft || preview.Branches[0].Nodes[0].X != 141 || preview.Branches[0].Nodes[0].Y != 67 {
t.Fatal("未读取拖拽后的草稿坐标")
}
after, err := a.Text(treeIndex)
if err != nil || after != before {
t.Fatal("读取草稿不应提前修改归档")
}
if err := a.SetText(treeIndex, draft); err != nil {
t.Fatal(err)
}
saved, err := s.ReadSkillTree("[swordman]", "sp")
if err != nil {
t.Fatal(err)
}
if saved.Branches[0].Nodes[0].X != 141 || saved.Branches[0].Nodes[0].Y != 67 {
t.Fatal("应用草稿后坐标未持久化到覆盖层")
}
mageTree, err := s.ReadSkillTree("[mage]", "sp")
if err != nil || mageTree.Branches[0].Nodes[0].X != 23 {
t.Fatal("应用坐标不应影响其他职业分支")
}
swordman, err := s.ReadSkillTree("[swordman]", "sp")
if err != nil {
t.Fatal(err)
}
if swordman.Common[0].RequiredLevel == nil || *swordman.Common[0].RequiredLevel != 0 || swordman.Branches[0].Nodes[0].RequiredLevel == nil {
t.Fatal("应保留等级0,不与缺失等级混淆")
}
mage, err := s.ReadSkillTree("[mage]", "sp")
if err != nil {
t.Fatal(err)
}
if mage.Common[0].RequiredLevel != nil {
t.Fatal("缺失等级不应虚构为0")
}
if _, e := s.ReadSkillTree("[swordman]", "unknown"); e == nil {
t.Fatal("不应读取未注册类型")
}
// Changes to skill names and learning levels must both follow the overlay.
idx, _ := a.Find("skill/swordman/backstep.skl")
if e := a.SetText(idx, "[name]\n`已更新`\n[required level]\n20\n[explain]\n`更新后的描述`"); e != nil {
t.Fatal(e)
}
doc, e := s.ReadSkillTree("[swordman]", "sp")
if e != nil {
t.Fatal(e)
}
if doc.Common[0].Name != "已更新" || doc.Common[0].Explain != "更新后的描述" || doc.Common[0].RequiredLevel == nil || *doc.Common[0].RequiredLevel != 20 {
t.Fatalf("未读取当前覆盖层:%s", doc.Common[0].Name)
}
branch := doc.Branches[0]
if branch.InsertOffset < 0 {
t.Fatal("缺少新增技能插入位置")
}
source := utf16.Encode([]rune(doc.Text))
entry := utf16.Encode([]rune("\n[skill info]\n[index]\n7\n[cell pos]\n4 3\n[/skill info]\n"))
inserted := string(utf16.Decode(append(append(append([]uint16{}, source[:branch.InsertOffset]...), entry...), source[branch.InsertOffset:]...)))
added, err := s.ReadSkillTreeDraft("[swordman]", "sp", inserted)
if err != nil || len(added.Branches[0].Nodes) != 2 {
t.Fatal("新增技能草稿未读回", err)
}
if added.Branches[0].Nodes[1].ID != 7 || added.Branches[0].Nodes[1].Name != "新增技能" || added.Branches[0].Nodes[1].X != 188 {
t.Fatal("新增技能解析错误")
}
if err := a.SetText(treeIndex, inserted); err != nil {
t.Fatal(err)
}
added, err = s.ReadSkillTree("[swordman]", "sp")
if err != nil || len(added.Branches[0].Nodes) != 2 {
t.Fatal("新增技能未保存到覆盖层", err)
}
}
func TestSkillTreeReal110US(t *testing.T) {
filename := os.Getenv("PVF_TESTFILE")
if filename == "" {
t.Skip("PVF_TESTFILE 未设置")
}
a, err := pvf.Open(filename)
if err != nil {
t.Fatal(err)
}
defer a.Release()
if a.ClientVersion() != "110US" {
t.Skip("使用 110US 真实样例验证")
}
s := NewFileGUIService(&core{archive: a})
registrations, err := s.ListSkillTrees()
if err != nil {
t.Fatal(err)
}
if len(registrations) != 34 {
t.Fatalf("注册数:%d", len(registrations))
}
for _, r := range registrations {
t.Run(r.Job+"/"+r.Kind, func(t *testing.T) {
doc, e := s.ReadSkillTree(r.Job, r.Kind)
if e != nil {
t.Fatal(e)
}
index, found := a.Find(r.Path)
if !found || doc.FileIndex != index || doc.Path != a.Path(index) {
t.Fatalf("注册路径 %s 应关联实际文件索引和路径:%d %s", r.Path, doc.FileIndex, doc.Path)
}
if len(doc.Branches) == 0 {
t.Fatal("缺少转职分支")
}
if r.Job == "[creator mage]" {
for _, n := range doc.Common {
if n.ID == 197 {
t.Fatal("缔造者应排除197")
}
}
}
for _, b := range doc.Branches {
if b.InsertOffset < 0 {
t.Errorf("分支 %s 缺少新增技能插入位置", b.Name)
}
for _, n := range b.Nodes {
if r.Job == "[swordman]" && r.Kind == "sp" && n.ID == 1 && (n.RequiredLevel == nil || *n.RequiredLevel != 5) {
t.Errorf("格挡学习等级应为5:%v", n.RequiredLevel)
}
if len(n.PositionTokens) != 2 || n.SourceStart < 0 {
t.Errorf("技能 #%d 缺少编辑位置", n.ID)
}
if n.SourceClose <= n.SourceStart || len(n.NextTokens) != len(n.Next) {
t.Errorf("技能 #%d 缺少删除记录或关联的编辑位置", n.ID)
}
if n.FileIndex < 0 {
t.Errorf("%s 技能 #%d 无法解析", b.Name, n.ID)
}
}
}
t.Logf("%d 分支,%d 通用技能,%d 条提示", len(doc.Branches), len(doc.Common), len(doc.Issues))
})
}
}
func TestSkillTreeDeletionOffsets(t *testing.T) {
text := "// 😀\r\n[character job]\r\n`[swordman]` `none`\r\n[skill info]\r\n[index]\r\n7\r\n[cell pos]\r\n0 0\r\n// [/skill info]\r\n[/skill info]\r\n[skill info]\r\n[index]\r\n8\r\n[cell pos]\r\n0 0\r\n[next skill]\r\n7 9 7\r\n[/skill info]\r\n[/character job]"
doc := parseSkillTree(text)
nodes := doc.Branches[0].Nodes
normalized := strings.ReplaceAll(text, "\r\n", "\n")
units := utf16.Encode([]rune(normalized))
for _, n := range nodes {
if n.SourceClose <= n.SourceStart || string(utf16.Decode(units[n.SourceClose:n.SourceClose+13])) != "[/skill info]" {
t.Fatalf("incorrect skill record range: %#v", n)
}
}
if len(nodes[1].NextTokens) != 3 {
t.Fatal("missing incoming link offsets")
}
for i, token := range nodes[1].NextTokens {
if string(utf16.Decode(units[token.Start:token.End])) != strconv.Itoa(nodes[1].Next[i]) {
t.Fatalf("incorrect next skill token: %#v", token)
}
}
broken := parseSkillTree("[character job]\n`[swordman]` `none`\n[skill info]\n[index]\n7\n[cell pos]\n0 0")
if broken.Branches[0].Nodes[0].SourceClose != -1 {
t.Fatal("unclosed skill record should not be deletable")
}
}