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

997 lines
35 KiB
Go

package services
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
annotationrules "pvfine/internal/annotations"
"pvfine/internal/pvf"
)
func TestAnnotationServiceReloadRulesKeepsOldEngineOnFailure(t *testing.T) {
emptyEngine, err := annotationrules.Compile(annotationrules.Document{
Version: 1, Relations: map[string]annotationrules.RelationSpec{}, Rules: []annotationrules.Rule{},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
fileIndex := mustAddText(t, a, "equipment/item.equ", "[rarity]\n4", pvf.TypeScript)
c := &core{annotationEngine: emptyEngine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
rulesPath := isolatedRulesPath(t)
valid := []byte(`{
"version": 1,
"relations": {},
"rules": [
{"id":"path","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"装备路径","type":"text"}},
{"id":"rarity","match":{"extensions":[".equ"]},"target":{"kind":"token","section":"rarity","index":0},"annotation":{"title":"装备品级","type":"enum","values":{"4":"史诗"}}}
]
}`)
if err := os.WriteFile(rulesPath, valid, 0o600); err != nil {
t.Fatal(err)
}
service := newAnnotationService(c, rulesPath)
result, err := service.ReloadRules()
if err != nil {
t.Fatal(err)
}
if result.RuleCount != 2 || result.RelationCount != 0 {
t.Fatalf("reload result = %#v", result)
}
root, err := NewArchiveService(c).ListChildren("")
if err != nil {
t.Fatal(err)
}
equipment := findTreeNode(root, "equipment")
if equipment == nil || len(equipment.Annotations) != 1 {
t.Fatalf("equipment annotations = %#v", equipment)
}
meta, err := NewEditorService(c).GetFile(fileIndex)
if err != nil {
t.Fatal(err)
}
if len(meta.Annotations) != 1 || meta.Annotations[0].Title != "史诗" {
t.Fatalf("editor annotations = %#v", meta.Annotations)
}
var basic *FileMeta
var basicErr error
c.mu.RLock()
basic, basicErr = NewEditorService(c).GetFileBasic(fileIndex)
c.mu.RUnlock()
err = basicErr
if err != nil {
t.Fatal(err)
}
if basic.Text != meta.Text || len(basic.Annotations) != 0 {
t.Fatalf("basic editor file = text %q, annotations %#v", basic.Text, basic.Annotations)
}
if err := os.WriteFile(rulesPath, []byte(`{"version":2,"relations":{},"rules":[]}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := service.ReloadRules(); err == nil {
t.Fatal("invalid reload should fail")
}
meta, err = NewEditorService(c).GetFile(fileIndex)
if err != nil {
t.Fatal(err)
}
if len(meta.Annotations) != 1 || meta.Annotations[0].Title != "史诗" {
t.Fatalf("old engine was not preserved: %#v", meta.Annotations)
}
}
func TestPathTokenAnnotationOpensArchiveFile(t *testing.T) {
index := 0
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Rules: []annotationrules.Rule{
{ID: "root", Target: annotationrules.TargetSpec{Kind: "token", Section: "root", Index: &index}, Annotation: annotationrules.AnnotationSpec{Title: "根目录路径", Type: "path"}},
{ID: "relative", Target: annotationrules.TargetSpec{Kind: "token", Section: "relative", Index: &index}, Annotation: annotationrules.AnnotationSpec{Title: "", Type: "path", PathRoot: "equipment/character"}},
{ID: "missing", Target: annotationrules.TargetSpec{Kind: "token", Section: "missing", Index: &index}, Annotation: annotationrules.AnnotationSpec{Title: "不存在", Type: "path", PathRoot: "equipment"}},
},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
targetIndex := mustAddText(t, a, "equipment/character/item.equ", "[name]\n`目标`", pvf.TypeScript)
sourceIndex := mustAddText(t, a, "source.equ", "[root]\n`equipment\\character\\item.equ`\n[relative]\n`ITEM.EQU`\n[missing]\n`none.equ`", pvf.TypeScript)
c := &core{annotationEngine: engine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
meta, err := NewEditorService(c).GetFile(sourceIndex)
if err != nil {
t.Fatal(err)
}
if len(meta.Annotations) != 3 {
t.Fatalf("path annotations = %#v", meta.Annotations)
}
for _, annotation := range meta.Annotations {
want := int32(-1)
if annotation.RuleIDs[0] != "missing" {
want = targetIndex
}
if annotation.Type != "path" || annotation.TargetFileIndex != want {
t.Fatalf("path annotation = %#v, want index %d", annotation, want)
}
if annotation.RuleIDs[0] == "relative" && (annotation.Title != "" || !strings.HasPrefix(annotation.Content, "路径: ITEM.EQU")) {
t.Fatalf("empty title path annotation = %#v", annotation)
}
}
}
func TestAnnotationServicesAndCacheInvalidation(t *testing.T) {
index := 0
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Relations: map[string]annotationrules.RelationSpec{
"equipment": {
ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1,
RecordTokens: 2, NameSection: "name",
},
},
Rules: []annotationrules.Rule{
{
ID: "path.directory", Match: annotationrules.MatchSpec{Glob: "equipment/**"},
Target: annotationrules.TargetSpec{Kind: "path"},
Annotation: annotationrules.AnnotationSpec{Title: "装备路径", Type: "text"},
},
{
ID: "path.file", Match: annotationrules.MatchSpec{Extensions: []string{".equ"}},
Target: annotationrules.TargetSpec{Kind: "path"},
Annotation: annotationrules.AnnotationSpec{Title: "装备文件", Type: "text"},
},
{
ID: "rarity", Match: annotationrules.MatchSpec{Extensions: []string{".equ"}},
Target: annotationrules.TargetSpec{Kind: "token", Section: "rarity", Index: &index},
Annotation: annotationrules.AnnotationSpec{
Title: "装备品级", Type: "enum", Values: map[string]string{"3": "神器", "4": "史诗"},
},
},
{
ID: "related", Match: annotationrules.MatchSpec{Extensions: []string{".equ"}},
Target: annotationrules.TargetSpec{Kind: "token", Section: "related", Index: &index},
Annotation: annotationrules.AnnotationSpec{Title: "关联装备", Type: "reference", Relation: "equipment"},
},
},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
listIndex := mustAddText(t, a, "equipment/equipment.lst", "1008 `character/item.equ` 1008 `character/other.equ` 404 `character/missing.equ`", pvf.TypeScript)
_ = listIndex
targetIndex := mustAddText(t, a, "equipment/character/item.equ", "[name]\n`旧名称`", pvf.TypeScript)
mustAddText(t, a, "equipment/character/other.equ", "[name]\n`重复 ID 文件`", pvf.TypeScript)
sourceIndex := mustAddText(t, a, "equipment/character/source.equ", "[rarity]\n4\n[related]\n1008", pvf.TypeScript)
unicodeIndex := mustAddText(t, a, "equipment/character/labels.str", "[rarity]\n4", pvf.TypeUnicode)
c := &core{annotationEngine: engine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
archiveService := NewArchiveService(c)
editorService := NewEditorService(c)
root, err := archiveService.ListChildren("")
if err != nil {
t.Fatal(err)
}
equipment := findTreeNode(root, "equipment")
if equipment == nil || len(equipment.Annotations) != 1 || equipment.Annotations[0].Title != "装备路径" {
t.Fatalf("equipment annotations = %#v", equipment)
}
files, err := archiveService.ListChildren("equipment/character")
if err != nil {
t.Fatal(err)
}
sourceNode := findTreeNode(files, "source.equ")
if sourceNode == nil || len(sourceNode.Annotations) != 1 || len(sourceNode.Annotations[0].RuleIDs) != 2 {
t.Fatalf("source path annotations = %#v", sourceNode)
}
meta, err := editorService.GetFile(sourceIndex)
if err != nil {
t.Fatal(err)
}
if len(meta.Annotations) != 2 {
t.Fatalf("annotations = %#v", meta.Annotations)
}
rarity := findEditorAnnotation(meta.Annotations, "史诗")
reference := findEditorAnnotation(meta.Annotations, "旧名称")
if rarity == nil || !strings.Contains(rarity.Content, "4 - 史诗") {
t.Fatalf("rarity = %#v", rarity)
}
if reference == nil || reference.TargetFileIndex != targetIndex || !strings.Contains(reference.Content, "旧名称") {
t.Fatalf("reference = %#v", reference)
}
if err := editorService.SetText(sourceIndex, "[rarity]\n3\n[related]\n1008"); err != nil {
t.Fatal(err)
}
updated, err := editorService.GetAnnotations(sourceIndex)
if err != nil {
t.Fatal(err)
}
if rarity = findEditorAnnotation(updated, "神器"); rarity == nil || !strings.Contains(rarity.Content, "3 - 神器") {
t.Fatalf("updated rarity = %#v", rarity)
}
if err := editorService.SetText(targetIndex, "[name]\n`新名称`"); err != nil {
t.Fatal(err)
}
updated, err = editorService.GetAnnotations(sourceIndex)
if err != nil {
t.Fatal(err)
}
if reference = findEditorAnnotation(updated, "新名称"); reference == nil || !strings.Contains(reference.Content, "新名称") {
t.Fatalf("updated reference = %#v", reference)
}
// A rarity-only edit must invalidate the cached source annotation, including
// ordinary rarity 0 and removal of the rarity section.
for _, value := range []int32{0, 4, 6, pvf.RarityUnknown} {
text := "[name]\n`新名称`"
if value != pvf.RarityUnknown {
text += fmt.Sprintf("\n[rarity]\n%d", value)
}
if err := editorService.SetText(targetIndex, text); err != nil {
t.Fatal(err)
}
updated, err = editorService.GetAnnotations(sourceIndex)
if err != nil {
t.Fatal(err)
}
if reference = findEditorAnnotation(updated, "新名称"); reference == nil || reference.Rarity != value {
t.Fatalf("reference rarity = %#v, want %d", reference, value)
}
}
unicodeMeta, err := editorService.GetFile(unicodeIndex)
if err != nil {
t.Fatal(err)
}
if len(unicodeMeta.Annotations) != 0 {
t.Fatalf("TypeUnicode annotations = %#v", unicodeMeta.Annotations)
}
}
func TestRepeatedReferencesDoNotFloodLogsOnReopen(t *testing.T) {
index := 0
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Relations: map[string]annotationrules.RelationSpec{
"equipment": {ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1, RecordTokens: 2, NameSection: "name"},
},
Rules: []annotationrules.Rule{{
ID: "items", Match: annotationrules.MatchSpec{Extensions: []string{".shp"}},
Target: annotationrules.TargetSpec{Kind: "token", Section: "items", Index: &index, RecordTokens: 1},
Annotation: annotationrules.AnnotationSpec{Type: "reference", Relation: "equipment"},
}},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
mustAddText(t, a, "equipment/equipment.lst", "1 `item.equ`", pvf.TypeScript)
target := mustAddText(t, a, "equipment/item.equ", "[name]\n`装备名称`", pvf.TypeScript)
source := mustAddText(t, a, "itemshop/large.shp", "[items]\n"+strings.Repeat("1\n", 1000), pvf.TypeScript)
other := mustAddText(t, a, "equipment/other.equ", "[name]\n`其他文件`", pvf.TypeScript)
c := &core{annotationEngine: engine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
editor := NewEditorService(c)
for pass := 0; pass < 2; pass++ {
before := applicationLogs.snapshot()
lastID := uint64(0)
if len(before) > 0 {
lastID = before[len(before)-1].ID
}
meta, err := editor.GetFile(source)
if err != nil {
t.Fatal(err)
}
if len(meta.Annotations) != 1000 || meta.Annotations[999].TargetFileIndex != target {
t.Fatalf("pass %d: annotations=%d", pass, len(meta.Annotations))
}
count := 0
for _, entry := range applicationLogs.snapshot() {
if entry.ID > lastID {
count++
}
}
if count > 50 {
t.Fatalf("pass %d: 1000 references emitted %d logs", pass, count)
}
// 切换文件替换单文件标注缓存,重开时必须重新解析关联。
if _, err := editor.GetFile(other); err != nil {
t.Fatal(err)
}
}
}
func TestListFileAnnotationsResolveNamesAndTargets(t *testing.T) {
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Relations: map[string]annotationrules.RelationSpec{
"equipment": {
ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1,
RecordTokens: 2, NameSection: "name",
},
},
Rules: []annotationrules.Rule{},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
listIndex := mustAddText(t, a, "equipment/equipment.lst", "1008 `character/item.equ` 1008 `character/other.equ` 404 `character/missing.equ`", pvf.TypeScript)
targetIndex := mustAddText(t, a, "equipment/character/item.equ", "[name]\n`测试装备`", pvf.TypeScript)
otherIndex := mustAddText(t, a, "equipment/character/other.equ", "[name]\n`重复 ID 装备`", pvf.TypeScript)
c := &core{annotationEngine: engine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
meta, err := NewEditorService(c).GetFile(listIndex)
if err != nil {
t.Fatal(err)
}
if len(meta.Annotations) != 2 {
t.Fatalf("list annotations = %#v", meta.Annotations)
}
annotation := meta.Annotations[0]
pathStart := strings.Index(meta.Text, "`character/item.equ`")
if pathStart < 0 {
t.Fatalf("list text = %q", meta.Text)
}
if annotation.Start != int32(pathStart) || annotation.End != int32(pathStart+len("`character/item.equ`")) {
t.Fatalf("list annotation range = [%d,%d), want [%d,%d)", annotation.Start, annotation.End, pathStart, pathStart+len("`character/item.equ`"))
}
if annotation.Title != "测试装备" || annotation.TargetFileIndex != targetIndex {
t.Fatalf("list annotation = %#v", annotation)
}
if !strings.Contains(annotation.Content, "ID: 1008") {
t.Fatalf("list annotation content = %q", annotation.Content)
}
other := findEditorAnnotation(meta.Annotations, "重复 ID 装备")
if other == nil || other.TargetFileIndex != otherIndex {
t.Fatalf("duplicate ID list annotation = %#v", meta.Annotations)
}
if err := NewEditorService(c).SetText(targetIndex, "[name]\n`新装备名`"); err != nil {
t.Fatal(err)
}
updated, err := NewEditorService(c).GetAnnotations(listIndex)
if err != nil {
t.Fatal(err)
}
if len(updated) != 2 || findEditorAnnotation(updated, "新装备名") == nil {
t.Fatalf("updated list annotations = %#v", updated)
}
}
func TestListFileAnnotationsIgnoreNestedNameSection(t *testing.T) {
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Relations: map[string]annotationrules.RelationSpec{
"equipment": {
ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1,
RecordTokens: 2, NameSection: "name",
},
},
Rules: []annotationrules.Rule{},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
listIndex := mustAddText(t, a, "equipment/equipment.lst", "1008 `character/nested-first.equ` 1009 `character/nested-only.equ`", pvf.TypeScript)
mustAddText(t, a, "equipment/character/nested-first.equ", "[info]\n[name]\n`嵌套名`\n[/info]\n[name]\n`顶层名`", pvf.TypeScript)
mustAddText(t, a, "equipment/character/nested-only.equ", "[info]\n[name]\n`仅嵌套名`\n[/info]", pvf.TypeScript)
c := &core{annotationEngine: engine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
meta, err := NewEditorService(c).GetFile(listIndex)
if err != nil {
t.Fatal(err)
}
if len(meta.Annotations) != 2 {
t.Fatalf("list annotations = %#v", meta.Annotations)
}
if meta.Annotations[0].Title != "顶层名" {
t.Fatalf("nested-first title = %q, want 顶层名", meta.Annotations[0].Title)
}
// Without a top-level [name] the link stays useful via the path fallback
// instead of borrowing the nested section's value.
if title := meta.Annotations[1].Title; !strings.Contains(title, "nested-only.equ") {
t.Fatalf("nested-only title = %q, want path fallback", title)
}
}
func TestUnindexedListFileLinksResolveRelativePaths(t *testing.T) {
emptyEngine, err := annotationrules.Compile(annotationrules.Document{
Version: 1, Relations: map[string]annotationrules.RelationSpec{}, Rules: []annotationrules.Rule{},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
listIndex := mustAddText(t, a, "custom/unindexed.lst", "1 `entry/item.equ` 2 `entry/missing.equ`", pvf.TypeScript)
targetIndex := mustAddText(t, a, "custom/entry/item.equ", "[name]\n`未索引目标`", pvf.TypeScript)
c := &core{annotationEngine: emptyEngine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
meta, err := NewEditorService(c).GetFile(listIndex)
if err != nil {
t.Fatal(err)
}
if len(meta.Annotations) != 1 {
t.Fatalf("unindexed list links = %#v", meta.Annotations)
}
link := meta.Annotations[0]
pathStart := strings.Index(meta.Text, "`entry/item.equ`")
if pathStart < 0 {
t.Fatalf("list text = %q", meta.Text)
}
if link.Title != "" || link.Content != "" || link.TargetFileIndex != targetIndex {
t.Fatalf("unindexed list link = %#v", link)
}
if link.Start != int32(pathStart) || link.End != int32(pathStart+len("`entry/item.equ`")) {
t.Fatalf("unindexed link range = [%d,%d), want [%d,%d)", link.Start, link.End, pathStart, pathStart+len("`entry/item.equ`"))
}
}
func TestEditorAnnotationsUseNormalizedLineEndingOffsets(t *testing.T) {
index := 0
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Relations: map[string]annotationrules.RelationSpec{},
Rules: []annotationrules.Rule{
{
ID: "rarity.section", Target: annotationrules.TargetSpec{Kind: "section", Section: "rarity"},
Annotation: annotationrules.AnnotationSpec{Title: "稀有度区域", Type: "text"},
},
{
ID: "rarity.value", Target: annotationrules.TargetSpec{Kind: "token", Section: "rarity", Index: &index},
Annotation: annotationrules.AnnotationSpec{Title: "稀有度", Type: "enum", Values: map[string]string{"3": "神器"}},
},
},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
source := mustAddText(t, a, "equipment/line-ending.equ", "[name]\n`line1\r\nline2`\r[rarity]\r\n3", pvf.TypeScript)
c := &core{annotationEngine: engine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
meta, err := NewEditorService(c).GetFile(source)
if err != nil {
t.Fatal(err)
}
normalized := strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(meta.Text)
wantSectionStart := strings.Index(normalized, "[rarity]")
wantTokenStart := strings.LastIndex(normalized, "3")
section := findEditorAnnotation(meta.Annotations, "稀有度区域")
token := findEditorAnnotation(meta.Annotations, "神器")
if section == nil || token == nil {
t.Fatalf("annotations = %#v", meta.Annotations)
}
if section.Start != int32(wantSectionStart) || section.End != int32(wantSectionStart+len("[rarity]")) {
t.Fatalf("section range = [%d,%d), want [%d,%d)", section.Start, section.End, wantSectionStart, wantSectionStart+len("[rarity]"))
}
if token.Start != int32(wantTokenStart) || token.End != int32(wantTokenStart+1) {
t.Fatalf("token range = [%d,%d), want [%d,%d)", token.Start, token.End, wantTokenStart, wantTokenStart+1)
}
}
func TestSearchIncludesAncestorPathAnnotations(t *testing.T) {
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Relations: map[string]annotationrules.RelationSpec{},
Rules: []annotationrules.Rule{{
ID: "path", Match: annotationrules.MatchSpec{Glob: "equipment/**"},
Target: annotationrules.TargetSpec{Kind: "path"},
Annotation: annotationrules.AnnotationSpec{Title: "装备路径", Type: "text"},
}},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
mustAddText(t, a, "equipment/character/item.equ", "[name]\n`测试装备`", pvf.TypeScript)
c := &core{annotationEngine: engine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
c.startSearchIndex()
waitForSearchIndex(t, c)
result, err := NewArchiveService(c).Search("item.equ", 0, 20)
if err != nil {
t.Fatal(err)
}
if len(result.Hits) != 1 {
t.Fatalf("hits = %#v", result.Hits)
}
hit := result.Hits[0]
for _, path := range []string{"equipment", "equipment/character", "equipment/character/item.equ"} {
if len(hit.PathAnnotations[path]) != 1 {
t.Fatalf("path %q annotations = %#v", path, hit.PathAnnotations)
}
}
}
func TestContextualSkillReferenceAnnotations(t *testing.T) {
targetTokenIndex := 1
contextTokenIndex := 0
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Relations: map[string]annotationrules.RelationSpec{
"skill": {
Kind: "contextual", ContextToken: 0, IDToken: 0, PathToken: 1,
RecordTokens: 2, NameSection: "name",
ContextPaths: map[string]string{
"[fighter]": "skill/fighterskill.lst",
"[at fighter]": "skill/atfighterskill.lst",
},
},
},
Rules: []annotationrules.Rule{{
ID: "skill.levelup", Target: annotationrules.TargetSpec{
Kind: "token", Section: "skill levelup", Index: &targetTokenIndex, RecordTokens: 3, ContextIndex: &contextTokenIndex,
},
Annotation: annotationrules.AnnotationSpec{Title: "技能", Type: "reference", Relation: "skill"},
}},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
mustAddText(t, a, "skill/fighterskill.lst", "20 `Fighter/Skill20.skl` 19 `Fighter/Skill19.skl`", pvf.TypeScript)
mustAddText(t, a, "skill/atfighterskill.lst", "20 `ATFighter/At20.skl`", pvf.TypeScript)
fighter20 := mustAddText(t, a, "skill/fighter/skill20.skl", "[name]\n`男格斗技能 20`", pvf.TypeScript)
fighter19 := mustAddText(t, a, "skill/fighter/skill19.skl", "[name]\n`男格斗技能 19`", pvf.TypeScript)
at20 := mustAddText(t, a, "skill/atfighter/at20.skl", "[name]\n`女格斗技能 20`", pvf.TypeScript)
source := mustAddText(t, a, "skill/test.skl", "[skill levelup]\n`[fighter]` 20 1\n`[fighter]` 19 1\n`[at fighter]` 20 1\n[/skill levelup]", pvf.TypeScript)
c := &core{annotationEngine: engine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
annotations, err := NewEditorService(c).GetAnnotations(source)
if err != nil {
t.Fatal(err)
}
if len(annotations) != 3 {
t.Fatalf("annotations = %#v", annotations)
}
want := []struct {
title string
index int32
}{
{"男格斗技能 20", fighter20},
{"男格斗技能 19", fighter19},
{"女格斗技能 20", at20},
}
for i, item := range want {
if annotations[i].Title != item.title || annotations[i].TargetFileIndex != item.index {
t.Fatalf("annotation[%d] = %#v, want title=%q index=%d", i, annotations[i], item.title, item.index)
}
}
}
func TestSkillReferencesUseSourceListContext(t *testing.T) {
index := 0
relation := annotationrules.RelationSpec{
Kind: "contextual", IDToken: 0, PathToken: 1, RecordTokens: 2, NameSection: "name",
ContextPaths: map[string]string{
"[fighter]": "skill/fighterskill.lst",
"[at fighter]": "skill/atfighterskill.lst",
},
}
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Relations: map[string]annotationrules.RelationSpec{"技能": relation},
Rules: []annotationrules.Rule{
{
ID: "skl.feature.skill.index.ref",
Match: annotationrules.MatchSpec{Extensions: []string{".skl"}},
Target: annotationrules.TargetSpec{Kind: "token", Section: "feature skill index", Index: &index, RecordTokens: 1},
Annotation: annotationrules.AnnotationSpec{Title: "特性技能", Type: "reference", Relation: "技能"},
},
{
ID: "skl.pre.required.skill.ref",
Match: annotationrules.MatchSpec{Extensions: []string{".skl"}},
Target: annotationrules.TargetSpec{Kind: "token", Section: "pre required skill", Index: &index, RecordTokens: 2},
Annotation: annotationrules.AnnotationSpec{Title: "前置技能", Type: "reference", Relation: "技能"},
},
},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
mustAddText(t, a, "skill/fighterskill.lst", "20 `fighter/20.skl` 19 `fighter/19.skl`", pvf.TypeScript)
mustAddText(t, a, "skill/atfighterskill.lst", "20 `atfighter/20.skl` 19 `atfighter/19.skl`", pvf.TypeScript)
fighter := mustAddText(t, a, "skill/fighter/20.skl", "[name]\n`男格斗技能`", pvf.TypeScript)
atFighter := mustAddText(t, a, "skill/atfighter/20.skl", "[name]\n`女格斗技能`", pvf.TypeScript)
fighterReference := mustAddText(t, a, "skill/fighter/19.skl", "[feature skill index]\n20\n[pre required skill]\n20 3", pvf.TypeScript)
atFighterReference := mustAddText(t, a, "skill/atfighter/19.skl", "[feature skill index]\n20\n[pre required skill]\n20 3", pvf.TypeScript)
unlisted := mustAddText(t, a, "skill/unknown.skl", "[feature skill index]\n20", pvf.TypeScript)
c := &core{annotationEngine: engine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
for _, test := range []struct {
file int32
target int32
name string
}{
{fighterReference, fighter, "男格斗技能"},
{atFighterReference, atFighter, "女格斗技能"},
} {
annotations, err := NewEditorService(c).GetAnnotations(test.file)
if err != nil {
t.Fatal(err)
}
if len(annotations) != 2 {
t.Fatalf("file %d: annotations = %#v", test.file, annotations)
}
for _, annotation := range annotations {
if annotation.TargetFileIndex != test.target || annotation.Title != test.name {
t.Fatalf("file %d: annotation = %#v", test.file, annotation)
}
}
}
annotations, err := NewEditorService(c).GetAnnotations(unlisted)
if err != nil {
t.Fatal(err)
}
if len(annotations) != 1 || annotations[0].TargetFileIndex != -1 {
t.Fatalf("unlisted skill annotations = %#v", annotations)
}
}
func TestUnionReferenceResolvesEquipmentAndItemIDs(t *testing.T) {
index := 0
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Relations: map[string]annotationrules.RelationSpec{
"装备": {ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1, RecordTokens: 2, NameSection: "name"},
"道具": {ListPath: "stackable/stackable.lst", IDToken: 0, PathToken: 1, RecordTokens: 2, NameSection: "name"},
"物品": {Kind: "union", Relations: []string{"装备", "道具"}},
},
Rules: []annotationrules.Rule{{
ID: "related-item", Target: annotationrules.TargetSpec{Kind: "token", Section: "related", Index: &index},
Annotation: annotationrules.AnnotationSpec{Title: "关联物品", Type: "reference", Relation: "物品"},
}},
})
if err != nil {
t.Fatal(err)
}
a := pvf.New()
mustAddText(t, a, "equipment/equipment.lst", "1008 `character/equipment.equ`", pvf.TypeScript)
mustAddText(t, a, "stackable/stackable.lst", "2008 `consumable/item.stk`", pvf.TypeScript)
mustAddText(t, a, "equipment/character/equipment.equ", "[name]\n`测试装备`", pvf.TypeScript)
itemIndex := mustAddText(t, a, "stackable/consumable/item.stk", "[name]\n`测试道具`", pvf.TypeScript)
source := mustAddText(t, a, "misc/source.equ", "[related]\n2008", pvf.TypeScript)
c := &core{annotationEngine: engine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
annotations, err := NewEditorService(c).GetAnnotations(source)
if err != nil {
t.Fatal(err)
}
if len(annotations) != 1 || annotations[0].Title != "测试道具" || annotations[0].TargetFileIndex != itemIndex {
t.Fatalf("union annotations = %#v", annotations)
}
}
func mustAddText(t *testing.T, archive *pvf.Archive, path, text string, dataType int32) int32 {
t.Helper()
index, err := archive.AddFileText(path, text, dataType)
if err != nil {
t.Fatal(err)
}
return index
}
func findTreeNode(nodes []*TreeNode, name string) *TreeNode {
for _, node := range nodes {
if node != nil && node.Name == name {
return node
}
}
return nil
}
func findEditorAnnotation(annotations []EditorAnnotation, title string) *EditorAnnotation {
for i := range annotations {
if annotations[i].Title == title {
return &annotations[i]
}
}
return nil
}
func TestAnnotationVersionLifecycle(t *testing.T) {
document := annotationrules.Document{Version: 1, Rules: []annotationrules.Rule{
{ID: "path", PVFVersions: []string{"90US"}, Match: annotationrules.MatchSpec{Glob: "equipment/**"}, Target: annotationrules.TargetSpec{Kind: "path"}, Annotation: annotationrules.AnnotationSpec{Title: "path", Type: "text"}},
{ID: "name", PVFVersions: []string{"90US"}, Target: annotationrules.TargetSpec{Kind: "section", Section: "name"}, Annotation: annotationrules.AnnotationSpec{Title: "name", Type: "text"}},
}}
engine, err := annotationrules.Compile(document)
if err != nil {
t.Fatal(err)
}
c := &core{annotationEngine: engine}
a := pvf.New()
index := mustAddText(t, a, "equipment/a.equ", "[name]\n`example`", pvf.TypeScript)
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
check := func(want int) {
t.Helper()
result, err := NewEditorService(c).GetFile(index)
if err != nil {
t.Fatal(err)
}
if len(result.Annotations) != want {
t.Fatalf("editor annotations = %#v", result.Annotations)
}
nodes, err := NewArchiveService(c).ListChildren("")
if err != nil {
t.Fatal(err)
}
node := findTreeNode(nodes, "equipment")
if node == nil || len(node.Annotations) != want {
t.Fatalf("path annotations = %#v", node)
}
}
check(1)
// A zero-value archive has no recognized format; switching back must restore
// source rules rather than re-filter an already reduced document.
if err := c.setArchive(&pvf.Archive{}); err != nil {
t.Fatal(err)
}
if got := c.annotationEngine.AnnotatePath("equipment/a.equ", false); len(got) != 0 {
t.Fatalf("unknown version: %#v", got)
}
if c.editorAnnotation.valid {
t.Fatal("archive switch retained editor cache")
}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
check(1)
rulesPath := isolatedRulesPath(t)
service := newAnnotationService(c, rulesPath)
for _, version := range []string{"110US", "115US", "90US"} {
for i := range document.Rules {
document.Rules[i].PVFVersions = []string{version}
}
data, err := annotationrules.Marshal(document)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(rulesPath, data, 0o600); err != nil {
t.Fatal(err)
}
result, err := service.ReloadRules()
if err != nil {
t.Fatal(err)
}
if result.RuleCount != 2 {
t.Fatalf("reload must count full source: %#v", result)
}
if c.editorAnnotation.valid {
t.Fatal("reload retained editor cache")
}
want := 0
if version == "90US" {
want = 1
}
check(want)
}
c.mu.Lock()
err = c.replaceArchivePayloadLocked(a.CloneForBatch(), map[int32]struct{}{index: {}})
c.mu.Unlock()
if err != nil {
t.Fatal(err)
}
check(1)
}
func TestAnnotationVersionPreviewReload(t *testing.T) {
index := 0
document := annotationrules.Document{Version: 1, Fields: []annotationrules.FieldDefinition{{
ID: "name", PVFVersions: []string{"90US"}, Target: annotationrules.TargetSpec{Kind: "token", Section: "name", Index: &index},
Annotation: annotationrules.AnnotationSpec{Title: "name", Type: "text"}, Preview: &annotationrules.PreviewSpec{Provider: "equ", Role: "name", Group: "header", Format: "text"},
}}}
engine, err := annotationrules.Compile(document)
if err != nil {
t.Fatal(err)
}
c := &core{annotationEngine: engine}
a := pvf.New()
fileIndex := mustAddText(t, a, "equipment/a.equ", "[name]\n`example`", pvf.TypeScript)
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
preview := NewPreviewService(c)
check := func(want string) {
t.Helper()
result, err := preview.ParseEQU(fileIndex, "[name]\n`example`")
if err != nil {
t.Fatal(err)
}
if result.Name != want {
t.Fatalf("name = %q, want %q", result.Name, want)
}
}
check("example")
rulesPath := filepath.Join(t.TempDir(), "annotations.json")
service := newAnnotationService(c, rulesPath)
for _, version := range []string{"110US", "115US", "90US"} {
document.Fields[0].PVFVersions = []string{version}
data, err := annotationrules.Marshal(document)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(rulesPath, data, 0o600); err != nil {
t.Fatal(err)
}
if _, err := service.ReloadRules(); err != nil {
t.Fatal(err)
}
want := ""
if version == "90US" {
want = "example"
}
check(want)
}
}
func TestAnnotationVersionDiskReload(t *testing.T) {
document := annotationrules.Document{Version: 1, Rules: []annotationrules.Rule{{
ID: "path", PVFVersions: []string{"90US"}, Match: annotationrules.MatchSpec{Glob: "equipment/**"}, Target: annotationrules.TargetSpec{Kind: "path"}, Annotation: annotationrules.AnnotationSpec{Title: "equipment", Type: "text"},
}}}
engine, err := annotationrules.Compile(document)
if err != nil {
t.Fatal(err)
}
a := pvf.New()
mustAddText(t, a, "equipment/a.equ", "[name]\n`example`", pvf.TypeScript)
disk, _, err := openSQLiteArchiveIndex(a)
if err != nil {
t.Fatal(err)
}
c := &core{annotationEngine: engine.ForVersion("110US"), diskIndex: disk}
c.mu.Lock()
c.installDiskArchiveIndexesLocked(a)
c.mu.Unlock()
t.Cleanup(c.closeArchive)
check := func(want int) {
t.Helper()
if c.pathAnnotations != nil {
t.Fatal("disk archive must retain lazy path annotations")
}
nodes, err := NewArchiveService(c).ListChildren("")
if err != nil {
t.Fatal(err)
}
node := findTreeNode(nodes, "equipment")
if node == nil || len(node.Annotations) != want {
t.Fatalf("path annotations = %#v", node)
}
}
check(1)
rulesPath := isolatedRulesPath(t)
for _, version := range []string{"110US", "115US", "90US"} {
document.Rules[0].PVFVersions = []string{version}
data, err := annotationrules.Marshal(document)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(rulesPath, data, 0o600); err != nil {
t.Fatal(err)
}
if _, err := newAnnotationService(c, rulesPath).ReloadRules(); err != nil {
t.Fatal(err)
}
want := 0
if version == "90US" {
want = 1
}
check(want)
}
}
// isolatedRulesPath creates empty sibling paths.json and sections.json files so
// a temporary rule file stays the complete rule set instead of inheriting the
// built-in generated rules.
func isolatedRulesPath(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for _, name := range []string{"paths.json", "sections.json"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
t.Fatal(err)
}
}
return filepath.Join(dir, "annotations.json")
}
func TestAnnotationServiceReloadMergesBuiltinPathNotes(t *testing.T) {
a := pvf.New()
mustAddText(t, a, "equipment/equipment.lst", "`a` `b`", pvf.TypeScript)
emptyEngine, err := annotationrules.Compile(annotationrules.Document{
Version: 1, Relations: map[string]annotationrules.RelationSpec{}, Rules: []annotationrules.Rule{},
})
if err != nil {
t.Fatal(err)
}
c := &core{annotationEngine: emptyEngine}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
dir := t.TempDir()
rulesPath := filepath.Join(dir, "annotations.json")
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
t.Fatal(err)
}
service := newAnnotationService(c, rulesPath)
result, err := service.ReloadRules()
if err != nil {
t.Fatal(err)
}
if result.RuleCount < 1000 {
t.Fatalf("reload dropped the built-in path notes: %#v", result)
}
nodes, err := NewArchiveService(c).ListChildren("equipment")
if err != nil {
t.Fatal(err)
}
node := findTreeNode(nodes, "equipment.lst")
if node == nil || len(node.Annotations) == 0 || node.Annotations[0].Title != "装备列表" {
t.Fatalf("path annotations = %#v", node)
}
}