服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
280 lines
7.8 KiB
Go
280 lines
7.8 KiB
Go
package pvf
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
func TestParseSectionsAndListEntries(t *testing.T) {
|
|
doc, err := Parse("equipment/equipment.lst", `
|
|
[equipment]
|
|
1001 `+"`equipment/weapon/sword.equ`"+`
|
|
1002 `+"`equipment/armor/coat.equ`"+`
|
|
|
|
[metadata]
|
|
rarity 3 1.5
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(doc.Sections) != 2 {
|
|
t.Fatalf("expected 2 sections, got %+v", doc.Sections)
|
|
}
|
|
tokens, ok := doc.Section("equipment")
|
|
if !ok || len(tokens) != 4 {
|
|
t.Fatalf("expected equipment section tokens, got ok=%v tokens=%+v", ok, tokens)
|
|
}
|
|
entries := ParseList(doc)
|
|
if len(entries) != 2 {
|
|
t.Fatalf("expected list entries, got %+v", entries)
|
|
}
|
|
if entries[0].ID != 1001 || entries[0].Path != "equipment/weapon/sword.equ" {
|
|
t.Fatalf("unexpected first entry: %+v", entries[0])
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsBadInput(t *testing.T) {
|
|
if _, err := Parse("", ""); !errors.Is(err, ErrPathRequired) {
|
|
t.Fatalf("expected ErrPathRequired, got %v", err)
|
|
}
|
|
_, err := Parse("bad.lst", "`missing")
|
|
if err == nil {
|
|
t.Fatalf("expected unclosed string error")
|
|
}
|
|
}
|
|
|
|
func TestDocumentValueHelpers(t *testing.T) {
|
|
doc, err := Parse("dungeon/test.dgn", `
|
|
[monsters]
|
|
1001 1002 `+"`monster/boss.mob`"+`
|
|
[maps]
|
|
`+"`map/a.map` `map/b.map`"+`
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if got := doc.Ints("monsters"); len(got) != 2 || got[0] != 1001 || got[1] != 1002 {
|
|
t.Fatalf("unexpected ints: %+v", got)
|
|
}
|
|
if got := doc.Texts("maps"); len(got) != 2 || got[0] != "map/a.map" || got[1] != "map/b.map" {
|
|
t.Fatalf("unexpected texts: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestParseMultilineBacktickString(t *testing.T) {
|
|
doc, err := Parse("skill/test.skl", "[explain]\n`first line\nsecond line`\n[required level]\n5\n")
|
|
if err != nil {
|
|
t.Fatalf("parse multiline string: %v", err)
|
|
}
|
|
value, ok := doc.Text("explain")
|
|
if !ok || value != "first line\nsecond line" {
|
|
t.Fatalf("multiline value = %q ok=%t", value, ok)
|
|
}
|
|
level, ok := doc.Int("required level")
|
|
if !ok || level != 5 {
|
|
t.Fatalf("required level = %d ok=%t", level, ok)
|
|
}
|
|
}
|
|
|
|
func TestBuildIndexFromMemorySource(t *testing.T) {
|
|
source := &memSource{
|
|
texts: map[string]string{
|
|
"equipment/equipment.lst": "1001 `equipment/weapon/sword.equ`\n",
|
|
"equipment/weapon/sword.equ": `
|
|
[name]
|
|
` + "`short sword`" + `
|
|
[attack]
|
|
12
|
|
`,
|
|
"skill/skill.lst": "2001 `skill/slash.skl`\n",
|
|
},
|
|
files: []platformpvf.File{
|
|
{ArchivePath: "equipment/equipment.lst"},
|
|
{ArchivePath: "equipment/weapon/sword.equ"},
|
|
{ArchivePath: "skill/skill.lst"},
|
|
},
|
|
reads: make(map[string]int),
|
|
}
|
|
index, err := Build(context.Background(), source, BuildOptions{
|
|
Lists: []string{"equipment/equipment.lst"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build index: %v", err)
|
|
}
|
|
if snapshot := index.Snapshot(); snapshot.Documents != 2 || snapshot.Lists != 1 || snapshot.Refs != 1 {
|
|
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
|
}
|
|
path, ok := index.Resolve("equipment/equipment.lst", 1001)
|
|
if !ok || path != "equipment/weapon/sword.equ" {
|
|
t.Fatalf("unexpected resolved path: %q ok=%v", path, ok)
|
|
}
|
|
doc, ok := index.Document("./EQUIPMENT/weapon/sword.equ")
|
|
if !ok {
|
|
t.Fatalf("expected indexed sword document")
|
|
}
|
|
if _, ok := doc.Section("attack"); !ok {
|
|
t.Fatalf("expected attack section")
|
|
}
|
|
before := source.reads["equipment/weapon/sword.equ"]
|
|
if _, ok := index.Document("equipment/weapon/sword.equ"); !ok {
|
|
t.Fatalf("expected cached document")
|
|
}
|
|
if source.reads["equipment/weapon/sword.equ"] != before {
|
|
t.Fatalf("document lookup should not read source again")
|
|
}
|
|
}
|
|
|
|
func TestBuildIndexResolvesListRelativePaths(t *testing.T) {
|
|
source := &memSource{
|
|
texts: map[string]string{
|
|
"equipment/equipment.lst": "1001 `weapon/sword.equ`\n",
|
|
"equipment/weapon/sword.equ": `
|
|
[name]
|
|
` + "`relative sword`" + `
|
|
`,
|
|
},
|
|
files: []platformpvf.File{
|
|
{ArchivePath: "equipment/equipment.lst"},
|
|
{ArchivePath: "equipment/weapon/sword.equ"},
|
|
},
|
|
reads: make(map[string]int),
|
|
}
|
|
index, err := Build(context.Background(), source, BuildOptions{
|
|
Lists: []string{"equipment/equipment.lst"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build index: %v", err)
|
|
}
|
|
resolved, ok := index.Resolve("equipment/equipment.lst", 1001)
|
|
if !ok || resolved != "equipment/weapon/sword.equ" {
|
|
t.Fatalf("resolved = %q ok=%v", resolved, ok)
|
|
}
|
|
if _, ok := index.Document("equipment/weapon/sword.equ"); !ok {
|
|
t.Fatalf("expected relative document to be indexed")
|
|
}
|
|
}
|
|
|
|
func TestBuildIndexLoadsNestedLists(t *testing.T) {
|
|
source := &memSource{
|
|
texts: map[string]string{
|
|
"skill/skilllist.lst": "0 `SwordmanSkill.lst`\n",
|
|
"skill/SwordmanSkill.lst": "46 `Swordman/UpperSlash.skl`\n",
|
|
"skill/Swordman/UpperSlash.skl": `
|
|
[name]
|
|
` + "`upper slash`" + `
|
|
`,
|
|
},
|
|
files: []platformpvf.File{
|
|
{ArchivePath: "skill/skilllist.lst"},
|
|
{ArchivePath: "skill/SwordmanSkill.lst"},
|
|
{ArchivePath: "skill/Swordman/UpperSlash.skl"},
|
|
},
|
|
reads: make(map[string]int),
|
|
}
|
|
index, err := Build(context.Background(), source, BuildOptions{Lists: []string{"skill/skilllist.lst"}})
|
|
if err != nil {
|
|
t.Fatalf("build nested index: %v", err)
|
|
}
|
|
if snapshot := index.Snapshot(); snapshot.Documents != 3 || snapshot.Lists != 2 || snapshot.Refs != 2 {
|
|
t.Fatalf("unexpected nested snapshot: %+v", snapshot)
|
|
}
|
|
jobList, ok := index.Resolve("skill/skilllist.lst", 0)
|
|
if !ok || jobList != "skill/SwordmanSkill.lst" {
|
|
t.Fatalf("job list = %q ok=%t", jobList, ok)
|
|
}
|
|
skillPath, ok := index.Resolve(jobList, 46)
|
|
if !ok || skillPath != "skill/Swordman/UpperSlash.skl" {
|
|
t.Fatalf("skill path = %q ok=%t", skillPath, ok)
|
|
}
|
|
if _, ok := index.Document(skillPath); !ok {
|
|
t.Fatal("nested skill document was not loaded")
|
|
}
|
|
}
|
|
|
|
func TestBuildIndexRequiresSource(t *testing.T) {
|
|
_, err := Build(context.Background(), nil, BuildOptions{})
|
|
if !errors.Is(err, ErrSourceRequired) {
|
|
t.Fatalf("expected ErrSourceRequired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildIndexHonorsCanceledContext(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, err := Build(ctx, &memSource{
|
|
texts: map[string]string{"a.lst": "1 `a/a.equ`"},
|
|
reads: make(map[string]int),
|
|
}, BuildOptions{Paths: []string{"a.lst"}})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected context canceled, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildIndexUsesDirectLookupWithoutListingArchive(t *testing.T) {
|
|
source := &finderMemSource{memSource: memSource{
|
|
texts: map[string]string{
|
|
"skill/skill.lst": "1 `relative.skl`\n",
|
|
"skill/relative.skl": "[name]\n`direct lookup`\n",
|
|
},
|
|
files: []platformpvf.File{
|
|
{ArchivePath: "skill/skill.lst"},
|
|
{ArchivePath: "skill/relative.skl"},
|
|
},
|
|
reads: make(map[string]int),
|
|
}}
|
|
index, err := Build(context.Background(), source, BuildOptions{Lists: []string{"skill/skill.lst"}})
|
|
if err != nil {
|
|
t.Fatalf("build index: %v", err)
|
|
}
|
|
if source.listCalls != 0 {
|
|
t.Fatalf("Files calls=%d want=0", source.listCalls)
|
|
}
|
|
if _, ok := index.Document("skill/relative.skl"); !ok {
|
|
t.Fatal("relative document was not loaded through direct lookup")
|
|
}
|
|
}
|
|
|
|
type memSource struct {
|
|
texts map[string]string
|
|
files []platformpvf.File
|
|
reads map[string]int
|
|
}
|
|
|
|
type finderMemSource struct {
|
|
memSource
|
|
listCalls int
|
|
}
|
|
|
|
func (s *finderMemSource) Files() []platformpvf.File {
|
|
s.listCalls++
|
|
return s.memSource.Files()
|
|
}
|
|
|
|
func (s *finderMemSource) FindFile(relativePath string) (platformpvf.File, bool) {
|
|
key := pathKey(relativePath)
|
|
for _, file := range s.files {
|
|
if pathKey(file.ArchivePath) == key {
|
|
return file, true
|
|
}
|
|
}
|
|
return platformpvf.File{}, false
|
|
}
|
|
|
|
func (s *memSource) ReadText(relativePath string) (string, error) {
|
|
key := pathKey(relativePath)
|
|
for path, text := range s.texts {
|
|
if pathKey(path) == key {
|
|
s.reads[cleanPath(path)]++
|
|
return text, nil
|
|
}
|
|
}
|
|
return "", platformpvf.ErrFileNotFound
|
|
}
|
|
|
|
func (s *memSource) Files() []platformpvf.File {
|
|
return append([]platformpvf.File(nil), s.files...)
|
|
}
|