DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

+301
View File
@@ -0,0 +1,301 @@
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")
}
}