DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,5 @@
|
||||
// Package pvf 提供 DNF 项目层 PVF 文本解析和内存索引。
|
||||
//
|
||||
// 平台层 internal/platform/pvf 只负责 PVF 文件归档、解密、解压和按路径读文本。
|
||||
// 本包负责把这些文本解析成 DNF 项目可消费的通用节点、section 和 lst 引用索引。
|
||||
package pvf
|
||||
@@ -0,0 +1,326 @@
|
||||
package pvf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
// Source 表示可以按路径读取 PVF 文本的内存来源。
|
||||
type Source interface {
|
||||
ReadText(relativePath string) (string, error)
|
||||
}
|
||||
|
||||
// BuildOptions 控制 DNF PVF 内存索引的预加载范围。
|
||||
type BuildOptions struct {
|
||||
All bool
|
||||
Paths []string
|
||||
Lists []string
|
||||
Prefixes []string
|
||||
}
|
||||
|
||||
// Index 保存已解析的 DNF PVF 文档和 `.lst` 引用索引。
|
||||
type Index struct {
|
||||
docs map[string]*Document
|
||||
lists map[string][]ListEntry
|
||||
refs map[string]map[int64]string
|
||||
paths []string
|
||||
}
|
||||
|
||||
type fileLister interface {
|
||||
Files() []platformpvf.File
|
||||
}
|
||||
|
||||
type fileFinder interface {
|
||||
FindFile(relativePath string) (platformpvf.File, bool)
|
||||
}
|
||||
|
||||
type filePathRanger interface {
|
||||
RangePaths(func(string) bool)
|
||||
}
|
||||
|
||||
type sourcePathIndex struct {
|
||||
paths map[string]string
|
||||
finder fileFinder
|
||||
}
|
||||
|
||||
func (i sourcePathIndex) find(value string) (string, bool) {
|
||||
clean := cleanPath(value)
|
||||
if clean == "" {
|
||||
return "", false
|
||||
}
|
||||
if actual, ok := i.paths[pathKey(clean)]; ok {
|
||||
return actual, true
|
||||
}
|
||||
if i.finder == nil {
|
||||
return "", false
|
||||
}
|
||||
file, ok := i.finder.FindFile(clean)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
actual := cleanPath(file.ArchivePath)
|
||||
return actual, actual != ""
|
||||
}
|
||||
|
||||
// Build 从已加载的 PVF source 中构建项目层内存索引。
|
||||
func Build(ctx context.Context, source Source, options BuildOptions) (*Index, error) {
|
||||
if source == nil {
|
||||
return nil, ErrSourceRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
sourcePaths := sourcePathSet(source)
|
||||
paths := collectPaths(source, options, sourcePaths)
|
||||
listKeys := listKeySet(options.Lists)
|
||||
listPaths := append([]string(nil), options.Lists...)
|
||||
index := &Index{
|
||||
docs: make(map[string]*Document, len(paths)),
|
||||
lists: make(map[string][]ListEntry, len(options.Lists)),
|
||||
refs: make(map[string]map[int64]string, len(options.Lists)),
|
||||
paths: make([]string, 0, len(paths)),
|
||||
}
|
||||
for pos := 0; pos < len(paths); pos++ {
|
||||
docPath := paths[pos]
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text, err := source.ReadText(docPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read dnf pvf %q: %w", docPath, err)
|
||||
}
|
||||
doc, err := Parse(docPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dnf pvf %q: %w", docPath, err)
|
||||
}
|
||||
key := pathKey(docPath)
|
||||
index.docs[key] = doc
|
||||
index.paths = append(index.paths, doc.Path)
|
||||
if _, ok := listKeys[key]; ok {
|
||||
for _, entry := range ParseList(doc) {
|
||||
resolved := resolveListRef(doc.Path, entry.Path, sourcePaths)
|
||||
addPath(&paths, index.docs, resolved)
|
||||
if strings.EqualFold(path.Ext(resolved), ".lst") {
|
||||
resolvedKey := pathKey(resolved)
|
||||
if _, exists := listKeys[resolvedKey]; !exists {
|
||||
listKeys[resolvedKey] = struct{}{}
|
||||
listPaths = append(listPaths, resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(index.paths)
|
||||
index.buildLists(listPaths, sourcePaths)
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// Document 按路径返回已解析文档,不会重新读取 PVF。
|
||||
func (i *Index) Document(docPath string) (*Document, bool) {
|
||||
if i == nil {
|
||||
return nil, false
|
||||
}
|
||||
doc, ok := i.docs[pathKey(docPath)]
|
||||
return doc, ok
|
||||
}
|
||||
|
||||
// Paths 返回当前索引中的文档路径副本。
|
||||
func (i *Index) Paths() []string {
|
||||
if i == nil || len(i.paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(i.paths))
|
||||
copy(out, i.paths)
|
||||
return out
|
||||
}
|
||||
|
||||
// List 返回指定 `.lst` 文档的引用列表副本。
|
||||
func (i *Index) List(listPath string) []ListEntry {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
entries := i.lists[pathKey(listPath)]
|
||||
out := make([]ListEntry, len(entries))
|
||||
copy(out, entries)
|
||||
return out
|
||||
}
|
||||
|
||||
// Resolve 从指定 `.lst` 索引中按 id 查找 PVF 路径。
|
||||
func (i *Index) Resolve(listPath string, id int64) (string, bool) {
|
||||
if i == nil {
|
||||
return "", false
|
||||
}
|
||||
refs := i.refs[pathKey(listPath)]
|
||||
if refs == nil {
|
||||
return "", false
|
||||
}
|
||||
value, ok := refs[id]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
// Snapshot 返回当前内存索引的文档、列表和引用数量。
|
||||
func (i *Index) Snapshot() Snapshot {
|
||||
if i == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
refs := 0
|
||||
for _, entries := range i.lists {
|
||||
refs += len(entries)
|
||||
}
|
||||
return Snapshot{
|
||||
Documents: len(i.docs),
|
||||
Lists: len(i.lists),
|
||||
Refs: refs,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Index) buildLists(lists []string, sourcePaths sourcePathIndex) {
|
||||
for _, listPath := range lists {
|
||||
key := pathKey(listPath)
|
||||
doc := i.docs[key]
|
||||
if doc == nil {
|
||||
continue
|
||||
}
|
||||
entries := ParseList(doc)
|
||||
for idx := range entries {
|
||||
entries[idx].Path = resolveListRef(doc.Path, entries[idx].Path, sourcePaths)
|
||||
}
|
||||
i.lists[key] = entries
|
||||
refMap := make(map[int64]string, len(entries))
|
||||
for _, entry := range entries {
|
||||
if _, exists := refMap[entry.ID]; !exists {
|
||||
refMap[entry.ID] = entry.Path
|
||||
}
|
||||
}
|
||||
i.refs[key] = refMap
|
||||
}
|
||||
}
|
||||
|
||||
func collectPaths(source Source, options BuildOptions, sourcePaths sourcePathIndex) []string {
|
||||
pathSet := make(map[string]struct{})
|
||||
paths := make([]string, 0, len(options.Paths)+len(options.Lists))
|
||||
addPath := func(value string) {
|
||||
clean := cleanPath(value)
|
||||
if clean == "" {
|
||||
return
|
||||
}
|
||||
key := pathKey(clean)
|
||||
if _, exists := pathSet[key]; exists {
|
||||
return
|
||||
}
|
||||
pathSet[key] = struct{}{}
|
||||
if actual, ok := sourcePaths.find(clean); ok {
|
||||
paths = append(paths, actual)
|
||||
return
|
||||
}
|
||||
paths = append(paths, clean)
|
||||
}
|
||||
for _, value := range options.Paths {
|
||||
addPath(value)
|
||||
}
|
||||
for _, value := range options.Lists {
|
||||
addPath(value)
|
||||
}
|
||||
if ranger, ok := source.(filePathRanger); ok && (options.All || len(options.Prefixes) > 0) {
|
||||
ranger.RangePaths(func(archivePath string) bool {
|
||||
if options.All || matchPrefix(archivePath, options.Prefixes) {
|
||||
addPath(archivePath)
|
||||
}
|
||||
return true
|
||||
})
|
||||
} else if lister, ok := source.(fileLister); ok && (options.All || len(options.Prefixes) > 0) {
|
||||
for _, file := range lister.Files() {
|
||||
if options.All || matchPrefix(file.ArchivePath, options.Prefixes) {
|
||||
addPath(file.ArchivePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func sourcePathSet(source Source) sourcePathIndex {
|
||||
if finder, ok := source.(fileFinder); ok {
|
||||
return sourcePathIndex{finder: finder}
|
||||
}
|
||||
lister, ok := source.(fileLister)
|
||||
if !ok {
|
||||
return sourcePathIndex{}
|
||||
}
|
||||
files := lister.Files()
|
||||
if len(files) == 0 {
|
||||
return sourcePathIndex{}
|
||||
}
|
||||
out := make(map[string]string, len(files))
|
||||
for _, file := range files {
|
||||
clean := cleanPath(file.ArchivePath)
|
||||
key := pathKey(clean)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := out[key]; !exists {
|
||||
out[key] = clean
|
||||
}
|
||||
}
|
||||
return sourcePathIndex{paths: out}
|
||||
}
|
||||
|
||||
func resolveListRef(listPath, refPath string, sourcePaths sourcePathIndex) string {
|
||||
ref := cleanPath(refPath)
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
if actual, ok := sourcePaths.find(ref); ok {
|
||||
return actual
|
||||
}
|
||||
listDir := cleanPath(path.Dir(cleanPath(listPath)))
|
||||
if listDir == "." {
|
||||
listDir = ""
|
||||
}
|
||||
if listDir != "" {
|
||||
candidate := cleanPath(path.Join(listDir, ref))
|
||||
if actual, ok := sourcePaths.find(candidate); ok {
|
||||
return actual
|
||||
}
|
||||
listDirKey := pathKey(listDir)
|
||||
refKey := pathKey(ref)
|
||||
if listDirKey != "" && strings.HasPrefix(refKey, listDirKey+"/") {
|
||||
return ref
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func addPath(paths *[]string, docs map[string]*Document, value string) {
|
||||
clean := cleanPath(value)
|
||||
if clean == "" {
|
||||
return
|
||||
}
|
||||
key := pathKey(clean)
|
||||
if _, exists := docs[key]; exists {
|
||||
return
|
||||
}
|
||||
for _, existing := range *paths {
|
||||
if pathKey(existing) == key {
|
||||
return
|
||||
}
|
||||
}
|
||||
*paths = append(*paths, clean)
|
||||
}
|
||||
|
||||
func listKeySet(lists []string) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(lists))
|
||||
for _, value := range lists {
|
||||
key := pathKey(value)
|
||||
if key != "" {
|
||||
out[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package pvf
|
||||
|
||||
// ParseList 从 `.lst` 文档中提取 id 到 PVF 路径的引用。
|
||||
func ParseList(doc *Document) []ListEntry {
|
||||
if doc == nil || len(doc.Tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
entries := make([]ListEntry, 0)
|
||||
for idx := 0; idx+1 < len(doc.Tokens); idx++ {
|
||||
id := doc.Tokens[idx]
|
||||
if id.Kind != TokenInt {
|
||||
continue
|
||||
}
|
||||
ref := doc.Tokens[idx+1]
|
||||
if ref.Kind != TokenString && ref.Kind != TokenIdent {
|
||||
continue
|
||||
}
|
||||
refPath := cleanPath(ref.Value)
|
||||
if refPath == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, ListEntry{
|
||||
ID: id.Int,
|
||||
Path: refPath,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package pvf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Parse 把单个 PVF 文本文档解析成通用 token 和 section。
|
||||
func Parse(docPath, text string) (*Document, error) {
|
||||
docPath = cleanPath(docPath)
|
||||
if docPath == "" {
|
||||
return nil, ErrPathRequired
|
||||
}
|
||||
parser := docParser{
|
||||
doc: Document{Path: docPath},
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
for idx, line := range lines {
|
||||
if err := parser.parseLine(idx+1, line); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if parser.pending != nil {
|
||||
return nil, fmt.Errorf("dnf pvf string at %d:%d is not closed", parser.pending.line, parser.pending.column)
|
||||
}
|
||||
parser.closeSection()
|
||||
return &parser.doc, nil
|
||||
}
|
||||
|
||||
type docParser struct {
|
||||
doc Document
|
||||
pending *pendingString
|
||||
}
|
||||
|
||||
type pendingString struct {
|
||||
quote byte
|
||||
line int
|
||||
column int
|
||||
raw strings.Builder
|
||||
value strings.Builder
|
||||
}
|
||||
|
||||
func (p *docParser) parseLine(lineNo int, line string) error {
|
||||
if p.pending != nil {
|
||||
return p.continueString(lineNo, line)
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
if end := strings.IndexByte(trimmed, ']'); end > 0 {
|
||||
column := strings.Index(line, "[") + 1
|
||||
name := strings.TrimSpace(trimmed[1:end])
|
||||
p.addToken(Token{
|
||||
Kind: TokenSection,
|
||||
Raw: trimmed[:end+1],
|
||||
Value: name,
|
||||
Line: lineNo,
|
||||
Column: column,
|
||||
})
|
||||
return p.scanValues(lineNo, column+end+1, trimmed[end+1:])
|
||||
}
|
||||
}
|
||||
column := len(line) - len(strings.TrimLeftFunc(line, unicode.IsSpace)) + 1
|
||||
return p.scanValues(lineNo, column, trimmed)
|
||||
}
|
||||
|
||||
func (p *docParser) addToken(token Token) {
|
||||
if token.Kind == TokenSection {
|
||||
p.closeSection()
|
||||
p.doc.Sections = append(p.doc.Sections, Section{
|
||||
Name: token.Value,
|
||||
Start: len(p.doc.Tokens) + 1,
|
||||
})
|
||||
}
|
||||
p.doc.Tokens = append(p.doc.Tokens, token)
|
||||
}
|
||||
|
||||
func (p *docParser) closeSection() {
|
||||
if len(p.doc.Sections) == 0 {
|
||||
return
|
||||
}
|
||||
idx := len(p.doc.Sections) - 1
|
||||
if p.doc.Sections[idx].End == 0 {
|
||||
p.doc.Sections[idx].End = len(p.doc.Tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *docParser) scanValues(lineNo, baseColumn int, line string) error {
|
||||
for pos := 0; pos < len(line); {
|
||||
if isSpace(line[pos]) {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if pos+1 < len(line) && line[pos] == '/' && line[pos+1] == '/' {
|
||||
return nil
|
||||
}
|
||||
if line[pos] == '#' {
|
||||
return nil
|
||||
}
|
||||
column := baseColumn + pos
|
||||
switch line[pos] {
|
||||
case '`', '\'', '"':
|
||||
token, next, complete := p.beginString(lineNo, column, line, pos)
|
||||
if !complete {
|
||||
return nil
|
||||
}
|
||||
p.addToken(token)
|
||||
pos = next
|
||||
case '{', '}', '=', ',', ':':
|
||||
p.addToken(Token{Kind: TokenSymbol, Raw: line[pos : pos+1], Value: line[pos : pos+1], Line: lineNo, Column: column})
|
||||
pos++
|
||||
default:
|
||||
raw, next := scanBare(line, pos)
|
||||
if raw == "" {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
p.addToken(parseBare(lineNo, column, raw))
|
||||
pos = next
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *docParser) beginString(lineNo, column int, line string, start int) (Token, int, bool) {
|
||||
pending := &pendingString{quote: line[start], line: lineNo, column: column}
|
||||
pending.raw.WriteByte(line[start])
|
||||
next, complete := appendStringSegment(pending, line, start+1)
|
||||
if !complete {
|
||||
p.pending = pending
|
||||
return Token{}, 0, false
|
||||
}
|
||||
return pending.token(), next, true
|
||||
}
|
||||
|
||||
func (p *docParser) continueString(lineNo int, line string) error {
|
||||
p.pending.raw.WriteByte('\n')
|
||||
p.pending.value.WriteByte('\n')
|
||||
next, complete := appendStringSegment(p.pending, line, 0)
|
||||
if !complete {
|
||||
return nil
|
||||
}
|
||||
token := p.pending.token()
|
||||
p.pending = nil
|
||||
p.addToken(token)
|
||||
return p.scanValues(lineNo, next+1, line[next:])
|
||||
}
|
||||
|
||||
func appendStringSegment(pending *pendingString, line string, start int) (int, bool) {
|
||||
for pos := start; pos < len(line); pos++ {
|
||||
value := line[pos]
|
||||
pending.raw.WriteByte(value)
|
||||
if value == pending.quote {
|
||||
return pos + 1, true
|
||||
}
|
||||
if pending.quote != '`' && value == '\\' && pos+1 < len(line) {
|
||||
pos++
|
||||
pending.raw.WriteByte(line[pos])
|
||||
pending.value.WriteByte(line[pos])
|
||||
continue
|
||||
}
|
||||
pending.value.WriteByte(value)
|
||||
}
|
||||
return len(line), false
|
||||
}
|
||||
|
||||
func (p *pendingString) token() Token {
|
||||
return Token{
|
||||
Kind: TokenString,
|
||||
Raw: p.raw.String(),
|
||||
Value: p.value.String(),
|
||||
Line: p.line,
|
||||
Column: p.column,
|
||||
}
|
||||
}
|
||||
|
||||
func scanBare(line string, start int) (string, int) {
|
||||
pos := start
|
||||
for pos < len(line) {
|
||||
if isSpace(line[pos]) || strings.ContainsRune("{},:=", rune(line[pos])) {
|
||||
break
|
||||
}
|
||||
if pos+1 < len(line) && line[pos] == '/' && line[pos+1] == '/' {
|
||||
break
|
||||
}
|
||||
pos++
|
||||
}
|
||||
return line[start:pos], pos
|
||||
}
|
||||
|
||||
func parseBare(lineNo, column int, raw string) Token {
|
||||
if value, ok := parseInt(raw); ok {
|
||||
return Token{Kind: TokenInt, Raw: raw, Value: raw, Int: value, Line: lineNo, Column: column}
|
||||
}
|
||||
if value, ok := parseFloat(raw); ok {
|
||||
return Token{Kind: TokenFloat, Raw: raw, Value: raw, Float: value, Line: lineNo, Column: column}
|
||||
}
|
||||
return Token{Kind: TokenIdent, Raw: raw, Value: raw, Line: lineNo, Column: column}
|
||||
}
|
||||
|
||||
func parseInt(raw string) (int64, bool) {
|
||||
if strings.ContainsAny(raw, ".eE") {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func parseFloat(raw string) (float64, bool) {
|
||||
if !strings.ContainsAny(raw, ".eE") {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseFloat(raw, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func isSpace(value byte) bool {
|
||||
return value == ' ' || value == '\t' || value == '\r'
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
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...)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package pvf
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func cleanPath(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
for strings.HasPrefix(value, "./") || strings.HasPrefix(value, "/") {
|
||||
value = strings.TrimPrefix(value, "./")
|
||||
value = strings.TrimPrefix(value, "/")
|
||||
}
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
clean := strings.TrimSuffix(path.Clean(value), "/")
|
||||
if clean == "." {
|
||||
return ""
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func pathKey(value string) string {
|
||||
return strings.ToLower(cleanPath(value))
|
||||
}
|
||||
|
||||
func sectionKey(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func matchPrefix(filePath string, prefixes []string) bool {
|
||||
if len(prefixes) == 0 {
|
||||
return false
|
||||
}
|
||||
key := pathKey(filePath)
|
||||
for _, prefix := range prefixes {
|
||||
prefixKey := pathKey(prefix)
|
||||
if prefixKey == "" {
|
||||
continue
|
||||
}
|
||||
if key == prefixKey || strings.HasPrefix(key, prefixKey+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package pvf
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrSourceRequired = errors.New("dnf pvf source is required")
|
||||
ErrPathRequired = errors.New("dnf pvf path is required")
|
||||
ErrDocNotFound = errors.New("dnf pvf document not found")
|
||||
)
|
||||
|
||||
type TokenKind string
|
||||
|
||||
const (
|
||||
// TokenSection 表示 PVF 文本中的 `[section]` 段落标记。
|
||||
TokenSection TokenKind = "section"
|
||||
// TokenString 表示反引号、单引号或双引号包裹的文本值。
|
||||
TokenString TokenKind = "string"
|
||||
// TokenInt 表示整数值。
|
||||
TokenInt TokenKind = "int"
|
||||
// TokenFloat 表示浮点值。
|
||||
TokenFloat TokenKind = "float"
|
||||
// TokenIdent 表示未加引号的普通标识符。
|
||||
TokenIdent TokenKind = "ident"
|
||||
// TokenSymbol 表示结构符号,例如 `{`、`}`、`=`。
|
||||
TokenSymbol TokenKind = "symbol"
|
||||
)
|
||||
|
||||
// Token 表示 PVF 文本解析后的最小节点。
|
||||
type Token struct {
|
||||
Kind TokenKind `json:"kind"`
|
||||
Raw string `json:"raw"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Int int64 `json:"int,omitempty"`
|
||||
Float float64 `json:"float,omitempty"`
|
||||
Line int `json:"line"`
|
||||
Column int `json:"column"`
|
||||
}
|
||||
|
||||
// Section 表示一个 `[section]` 段及其 token 范围。
|
||||
type Section struct {
|
||||
Name string `json:"name"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
// Document 表示单个 PVF 文本文档的解析结果。
|
||||
type Document struct {
|
||||
Path string `json:"path"`
|
||||
Tokens []Token `json:"tokens"`
|
||||
Sections []Section `json:"sections"`
|
||||
}
|
||||
|
||||
// ListEntry 表示 `.lst` 中的 id 到 PVF 路径引用。
|
||||
type ListEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// Snapshot 表示 DNF PVF 内存索引的简要状态。
|
||||
type Snapshot struct {
|
||||
Documents int `json:"documents"`
|
||||
Lists int `json:"lists"`
|
||||
Refs int `json:"refs"`
|
||||
}
|
||||
|
||||
// Section 返回指定段落内的 token 副本。
|
||||
func (d *Document) Section(name string) ([]Token, bool) {
|
||||
if d == nil {
|
||||
return nil, false
|
||||
}
|
||||
key := sectionKey(name)
|
||||
for _, section := range d.Sections {
|
||||
if sectionKey(section.Name) != key {
|
||||
continue
|
||||
}
|
||||
if section.Start < 0 || section.End > len(d.Tokens) || section.Start > section.End {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]Token, section.End-section.Start)
|
||||
copy(out, d.Tokens[section.Start:section.End])
|
||||
return out, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package pvf
|
||||
|
||||
// Text 返回指定 section 的第一个文本或标识符值。
|
||||
func (d *Document) Text(name string) (string, bool) {
|
||||
values := d.Texts(name)
|
||||
if len(values) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return values[0], true
|
||||
}
|
||||
|
||||
// Int 返回指定 section 的第一个整数值。
|
||||
func (d *Document) Int(name string) (int64, bool) {
|
||||
values := d.Ints(name)
|
||||
if len(values) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return values[0], true
|
||||
}
|
||||
|
||||
// Number 返回指定 section 的第一个数字值。
|
||||
func (d *Document) Number(name string) (float64, bool) {
|
||||
values := d.Numbers(name)
|
||||
if len(values) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return values[0], true
|
||||
}
|
||||
|
||||
// Texts 返回指定 section 内所有文本和标识符值。
|
||||
func (d *Document) Texts(name string) []string {
|
||||
tokens, ok := d.Section(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if token.Kind == TokenString || token.Kind == TokenIdent {
|
||||
out = append(out, token.Value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Ints 返回指定 section 内所有整数值。
|
||||
func (d *Document) Ints(name string) []int64 {
|
||||
tokens, ok := d.Section(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]int64, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if token.Kind == TokenInt {
|
||||
out = append(out, token.Int)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Numbers 返回指定 section 内所有数字值。
|
||||
func (d *Document) Numbers(name string) []float64 {
|
||||
tokens, ok := d.Section(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]float64, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
switch token.Kind {
|
||||
case TokenInt:
|
||||
out = append(out, float64(token.Int))
|
||||
case TokenFloat:
|
||||
out = append(out, token.Float)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user