服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
302 lines
12 KiB
Go
302 lines
12 KiB
Go
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")
|
||
}
|
||
}
|