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,75 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
func BenchmarkAnnotateManyRules(b *testing.B) {
|
||||
for _, count := range []int{100, 1000, 10000} {
|
||||
b.Run(fmt.Sprint(count), func(b *testing.B) {
|
||||
rules := make([]Rule, count)
|
||||
for i := range rules {
|
||||
rules[i] = Rule{
|
||||
ID: fmt.Sprint(i), Match: MatchSpec{Extensions: []string{".equ"}},
|
||||
Target: TargetSpec{Kind: "token", Section: fmt.Sprintf("section%d", i), Index: intPtr(0)},
|
||||
Annotation: AnnotationSpec{Title: "说明", Type: "text"},
|
||||
}
|
||||
}
|
||||
engine, err := Compile(Document{Version: 1, Rules: rules})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
var text strings.Builder
|
||||
for i := 0; i < 100; i++ {
|
||||
fmt.Fprintf(&text, "[section%d]\n1 2 3 4 5\n", i)
|
||||
}
|
||||
view := pvf.ParseScriptView(text.String())
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
engine.Annotate("a.equ", view, nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkAnnotatePathManyRules guards the bulk path note lookup: without the
|
||||
// literal glob and "<dir>/**" buckets every tree node would scan all rules.
|
||||
func BenchmarkAnnotatePathManyRules(b *testing.B) {
|
||||
const files, directories = 15000, 2000
|
||||
rules := make([]Rule, 0, files+directories+4)
|
||||
for i := 0; i < files; i++ {
|
||||
rules = append(rules, Rule{
|
||||
ID: fmt.Sprintf("file.%d", i), Match: MatchSpec{Glob: fmt.Sprintf("dir%02d/sub%02d/file%05d.ani", i%40, i%50, i)},
|
||||
Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "文件说明", Type: "text"},
|
||||
})
|
||||
}
|
||||
for i := 0; i < directories; i++ {
|
||||
rules = append(rules, Rule{
|
||||
ID: fmt.Sprintf("dir.%d", i), Match: MatchSpec{Glob: fmt.Sprintf("dir%02d/sub%02d/**", i%40, i%50)},
|
||||
Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "目录说明", Type: "text"},
|
||||
})
|
||||
}
|
||||
rules = append(rules,
|
||||
Rule{ID: "star", Match: MatchSpec{Glob: "**/*.ani"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "通配", Type: "text"}},
|
||||
Rule{ID: "all", Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "全部", Type: "text"}},
|
||||
)
|
||||
engine, err := Compile(Document{Version: 1, Rules: rules})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
paths := []string{
|
||||
"dir01/sub01/file00001.ani",
|
||||
"dir01/sub01/file00001.ani.bak",
|
||||
"dir39/sub49/deep/deeper/1.ani",
|
||||
"unknown/path.dat",
|
||||
"dir01/sub01",
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
engine.AnnotatePath(paths[i%len(paths)], false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"pvfine/internal/pvf"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveDocumentationRule shares the engine's whole-block field inheritance.
|
||||
func ResolveDocumentationRule(rule Rule, fields []FieldDefinition) (Rule, error) {
|
||||
return resolveFieldRule(rule, fields)
|
||||
}
|
||||
|
||||
// DocumentationMatches uses the same glob and extension semantics as annotations.
|
||||
func DocumentationMatches(match MatchSpec, filePath string, directory bool) bool {
|
||||
extensions := make(map[string]struct{}, len(match.Extensions))
|
||||
for _, ext := range match.Extensions {
|
||||
extensions[strings.ToLower(ext)] = struct{}{}
|
||||
}
|
||||
return (compiledRule{rule: Rule{Match: match}, extensions: extensions}).matches(filePath, directory)
|
||||
}
|
||||
|
||||
type DocumentationAnchor struct {
|
||||
Rule Rule
|
||||
Start, End int
|
||||
Value, Context string
|
||||
}
|
||||
|
||||
// DocumentationAnchors projects compiled rules through the runtime target engine,
|
||||
// including implicit fields, repeated records, separators and dynamic widths.
|
||||
func (e *Engine) DocumentationAnchors(filePath string, view pvf.ScriptView) []DocumentationAnchor {
|
||||
result := []DocumentationAnchor{}
|
||||
if e == nil {
|
||||
return result
|
||||
}
|
||||
for _, r := range e.rules {
|
||||
if !r.matches(filePath, false) {
|
||||
continue
|
||||
}
|
||||
for _, a := range e.editorAnchors(r.rule, view) {
|
||||
result = append(result, DocumentationAnchor{r.rule, a.start, a.end, a.value, a.context})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
type Engine struct {
|
||||
source *Document
|
||||
version string
|
||||
scoped bool
|
||||
document Document
|
||||
rules []compiledRule
|
||||
// Path rules resolve once per tree node. Bulk path notes would otherwise
|
||||
// force a full glob scan per node, so they are bucketed by literal glob and
|
||||
// by the literal "<dir>/**" prefix at compile time.
|
||||
pathExact map[string][]int
|
||||
pathPrefix map[string][]int
|
||||
pathGeneral []int
|
||||
// scriptRules lists the non-path rules that annotate script tokens.
|
||||
scriptRules []int
|
||||
}
|
||||
|
||||
// ContextResolver resolves an ID using an optional value from the same
|
||||
// repeated record. It extends Resolver for relations whose list path depends
|
||||
// on another token, such as skill IDs grouped by profession.
|
||||
type ContextResolver func(relation, id, context string) (Reference, bool)
|
||||
|
||||
// ListResolver resolves one concrete list row. Unlike ContextResolver, it
|
||||
// receives the row's relative path as well, so duplicate IDs in a list still
|
||||
// navigate to the file represented by that particular row.
|
||||
type ListResolver func(relation, id, context, listPath, relativePath string) (Reference, bool)
|
||||
|
||||
// PathResolver looks up a token path in the archive, using an optional rule root.
|
||||
type PathResolver func(root, value string) (int32, bool)
|
||||
|
||||
type compiledRule struct {
|
||||
rule Rule
|
||||
extensions map[string]struct{}
|
||||
}
|
||||
|
||||
type matchedItem struct {
|
||||
ruleID string
|
||||
title string
|
||||
content string
|
||||
typ string
|
||||
targetFileIndex int32
|
||||
image *ImageReference
|
||||
inlineImage bool
|
||||
}
|
||||
|
||||
func Compile(document Document) (*Engine, error) {
|
||||
normalizeImageTargets(&document)
|
||||
if document.Relations == nil {
|
||||
document.Relations = make(map[string]RelationSpec)
|
||||
}
|
||||
if document.Rules == nil {
|
||||
document.Rules = make([]Rule, 0)
|
||||
}
|
||||
if err := Validate(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return compileValidated(document), nil
|
||||
}
|
||||
|
||||
// compileValidated only receives validated documents with valid field references.
|
||||
func compileValidated(document Document) *Engine {
|
||||
rules := make([]compiledRule, 0, len(document.Rules)+len(document.Fields))
|
||||
appendRule := func(rule Rule) {
|
||||
extensions := make(map[string]struct{}, len(rule.Match.Extensions))
|
||||
for _, extension := range rule.Match.Extensions {
|
||||
extensions[strings.ToLower(extension)] = struct{}{}
|
||||
}
|
||||
rules = append(rules, compiledRule{rule: rule, extensions: extensions})
|
||||
}
|
||||
resolvedRules := make([]Rule, 0, len(document.Rules))
|
||||
for _, originalRule := range document.Rules {
|
||||
rule, _ := resolveFieldRule(originalRule, document.Fields)
|
||||
resolvedRules = append(resolvedRules, rule)
|
||||
appendRule(rule)
|
||||
}
|
||||
// A shared field is itself an annotation definition. Explicit rules may
|
||||
// still reference a field to override its presentation; in that case the
|
||||
// explicit rule takes precedence and the implicit copy is skipped.
|
||||
for _, field := range document.Fields {
|
||||
fieldRule := Rule{
|
||||
ID: "field:" + field.ID,
|
||||
Match: field.Match,
|
||||
Target: field.Target,
|
||||
Annotation: field.Annotation,
|
||||
Group: "field",
|
||||
}
|
||||
covered := false
|
||||
for _, rule := range resolvedRules {
|
||||
if rule.Field == field.ID || annotationRuleCoversField(rule, fieldRule) {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
appendRule(fieldRule)
|
||||
}
|
||||
}
|
||||
engine := &Engine{document: document, rules: rules}
|
||||
return engine.indexRules()
|
||||
}
|
||||
|
||||
// indexRules buckets path rules so AnnotatePath only evaluates plausible
|
||||
// candidates, and records the script rules that Annotate walks. Rule order is
|
||||
// preserved because it decides the primary title of a path.
|
||||
func (e *Engine) indexRules() *Engine {
|
||||
for index := range e.rules {
|
||||
rule := &e.rules[index]
|
||||
if rule.rule.Target.Kind != "path" {
|
||||
// Token, section and range rules never take part in path lookups.
|
||||
e.scriptRules = append(e.scriptRules, index)
|
||||
continue
|
||||
}
|
||||
glob := normalizePath(rule.rule.Match.Glob)
|
||||
if prefix, ok := literalDirPrefix(glob); ok {
|
||||
e.pathPrefix = appendRuleIndex(e.pathPrefix, prefix, index)
|
||||
continue
|
||||
}
|
||||
if glob != "" && isLiteralGlob(glob) {
|
||||
e.pathExact = appendRuleIndex(e.pathExact, glob, index)
|
||||
continue
|
||||
}
|
||||
// An empty glob matches every path, and wildcard globs need a real match.
|
||||
e.pathGeneral = append(e.pathGeneral, index)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func appendRuleIndex(buckets map[string][]int, key string, index int) map[string][]int {
|
||||
if buckets == nil {
|
||||
buckets = make(map[string][]int)
|
||||
}
|
||||
buckets[key] = append(buckets[key], index)
|
||||
return buckets
|
||||
}
|
||||
|
||||
// isLiteralGlob reports whether a normalized glob matches exactly one path.
|
||||
func isLiteralGlob(glob string) bool {
|
||||
return !strings.ContainsAny(glob, `*?[]\`)
|
||||
}
|
||||
|
||||
// literalDirPrefix returns the literal directory of a "<dir>/**" pattern. Such
|
||||
// a pattern matches the directory itself and every path below it.
|
||||
func literalDirPrefix(glob string) (string, bool) {
|
||||
if !strings.HasSuffix(glob, "/**") {
|
||||
return "", false
|
||||
}
|
||||
prefix := strings.TrimSuffix(glob, "/**")
|
||||
if prefix == "" || !isLiteralGlob(prefix) {
|
||||
return "", false
|
||||
}
|
||||
return prefix, true
|
||||
}
|
||||
|
||||
// pathCandidateIndexes lists the rules worth evaluating for one path, in rule
|
||||
// order. It is a superset filter; callers still verify each rule with matches.
|
||||
func (e *Engine) pathCandidateIndexes(filePath string) []int {
|
||||
key := normalizePath(filePath)
|
||||
indexes := make([]int, 0, len(e.pathGeneral)+4)
|
||||
indexes = append(indexes, e.pathExact[key]...)
|
||||
for position := len(key); position > 0; position-- {
|
||||
if key[position-1] != '/' {
|
||||
continue
|
||||
}
|
||||
indexes = append(indexes, e.pathPrefix[key[:position-1]]...)
|
||||
}
|
||||
indexes = append(indexes, e.pathPrefix[key]...)
|
||||
indexes = append(indexes, e.pathGeneral...)
|
||||
sort.Ints(indexes)
|
||||
return indexes
|
||||
}
|
||||
|
||||
func annotationRuleCoversField(rule, field Rule) bool {
|
||||
if rule.Annotation.Type != field.Annotation.Type || rule.Annotation.Title != field.Annotation.Title ||
|
||||
rule.Annotation.Relation != field.Annotation.Relation || rule.Annotation.PathRoot != field.Annotation.PathRoot || !sameTarget(rule.Target, field.Target) {
|
||||
return false
|
||||
}
|
||||
return len(rule.Match.Extensions) == 0 && strings.TrimSpace(rule.Match.Glob) == "" || sameMatch(rule.Match, field.Match)
|
||||
}
|
||||
|
||||
func sameMatch(left, right MatchSpec) bool {
|
||||
if !strings.EqualFold(strings.TrimSpace(left.Glob), strings.TrimSpace(right.Glob)) || len(left.Extensions) != len(right.Extensions) {
|
||||
return false
|
||||
}
|
||||
used := make([]bool, len(right.Extensions))
|
||||
for _, extension := range left.Extensions {
|
||||
found := false
|
||||
for index, candidate := range right.Extensions {
|
||||
if !used[index] && strings.EqualFold(strings.TrimSpace(extension), strings.TrimSpace(candidate)) {
|
||||
used[index] = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sameTarget(left, right TargetSpec) bool {
|
||||
if left.Kind != right.Kind || !strings.EqualFold(left.Section, right.Section) || left.Offset != right.Offset ||
|
||||
left.RecordTokens != right.RecordTokens || left.GroupOffset != right.GroupOffset || !sameInt32Slice(left.StandaloneValues, right.StandaloneValues) || !sameIntPtr(left.Index, right.Index) ||
|
||||
!sameIntPtr(left.TokensPerLineIndex, right.TokensPerLineIndex) || !sameIntPtr(left.ContextIndex, right.ContextIndex) ||
|
||||
!sameIntPtr(left.ImagePathToken, right.ImagePathToken) {
|
||||
return false
|
||||
}
|
||||
if (left.Range == nil) != (right.Range == nil) {
|
||||
return false
|
||||
}
|
||||
return left.Range == nil || (left.Range.Start == right.Range.Start && left.Range.EndExclusive == right.Range.EndExclusive)
|
||||
}
|
||||
|
||||
func sameInt32Slice(left, right []int32) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for i := range left {
|
||||
if left[i] != right[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sameIntPtr(left, right *int) bool {
|
||||
if (left == nil) != (right == nil) {
|
||||
return false
|
||||
}
|
||||
return left == nil || *left == *right
|
||||
}
|
||||
|
||||
func (e *Engine) Document() Document {
|
||||
if e.source != nil {
|
||||
return *e.source
|
||||
}
|
||||
return e.document
|
||||
}
|
||||
|
||||
func (e *Engine) Annotate(filePath string, view pvf.ScriptView, resolver Resolver) []Result {
|
||||
return e.annotate(filePath, view, func(relation, id, _ string) (Reference, bool) {
|
||||
if resolver == nil {
|
||||
return Reference{}, false
|
||||
}
|
||||
return resolver(relation, id)
|
||||
}, nil, nil)
|
||||
}
|
||||
|
||||
func (e *Engine) AnnotateWithContextResolver(filePath string, view pvf.ScriptView, resolver ContextResolver) []Result {
|
||||
return e.annotate(filePath, view, resolver, nil, nil)
|
||||
}
|
||||
|
||||
// AnnotateWithContextAndListResolver resolves ID and list annotations.
|
||||
func (e *Engine) AnnotateWithContextAndListResolver(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver) []Result {
|
||||
return e.annotate(filePath, view, resolver, listResolver, nil)
|
||||
}
|
||||
|
||||
// AnnotateWithResolvers also resolves path tokens to archive file indexes.
|
||||
func (e *Engine) AnnotateWithResolvers(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver, pathResolver PathResolver) []Result {
|
||||
return e.annotate(filePath, view, resolver, listResolver, pathResolver)
|
||||
}
|
||||
|
||||
func (e *Engine) annotate(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver, pathResolver PathResolver) []Result {
|
||||
results := make([]Result, 0)
|
||||
resultByAnchor := make(map[string]int)
|
||||
appendResult := func(anchor editorAnchor, item matchedItem) {
|
||||
key := fmt.Sprintf("%d:%d", anchor.start, anchor.end)
|
||||
if index, ok := resultByAnchor[key]; ok {
|
||||
result := &results[index]
|
||||
result.Content = appendTooltip(result.Content, item.title, item.content)
|
||||
result.RuleIDs = append(result.RuleIDs, item.ruleID)
|
||||
if result.TargetFileIndex < 0 && item.targetFileIndex >= 0 {
|
||||
result.TargetFileIndex = item.targetFileIndex
|
||||
}
|
||||
if result.Image == nil && item.image != nil {
|
||||
result.Image = item.image
|
||||
}
|
||||
if item.image != nil && item.inlineImage {
|
||||
result.InlineImage = true
|
||||
}
|
||||
return
|
||||
}
|
||||
resultByAnchor[key] = len(results)
|
||||
results = append(results, Result{
|
||||
Start: anchor.start,
|
||||
End: anchor.end,
|
||||
Title: item.title,
|
||||
Content: appendTooltip("", item.title, item.content),
|
||||
Type: item.typ,
|
||||
TargetFileIndex: item.targetFileIndex,
|
||||
RuleIDs: []string{item.ruleID},
|
||||
Image: item.image,
|
||||
InlineImage: item.image != nil && item.inlineImage,
|
||||
})
|
||||
}
|
||||
for _, index := range e.scriptRules {
|
||||
compiled := e.rules[index]
|
||||
rule := compiled.rule
|
||||
if !compiled.matches(filePath, false) {
|
||||
continue
|
||||
}
|
||||
anchors := e.editorAnchors(rule, view)
|
||||
for _, anchor := range anchors {
|
||||
value := anchor.value
|
||||
if rule.Annotation.Type == "image" {
|
||||
value = anchor.imagePathValue
|
||||
}
|
||||
item := annotationItem(rule, value, anchor.imageIndexValue, anchor.context, resolver, pathResolver)
|
||||
appendResult(anchor, item)
|
||||
}
|
||||
}
|
||||
for _, item := range e.listAnnotations(filePath, view, resolver, listResolver) {
|
||||
appendResult(item.anchor, item.item)
|
||||
}
|
||||
sort.SliceStable(results, func(i, j int) bool {
|
||||
if results[i].Start != results[j].Start {
|
||||
return results[i].Start < results[j].Start
|
||||
}
|
||||
return results[i].End < results[j].End
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
type listAnnotation struct {
|
||||
anchor editorAnchor
|
||||
item matchedItem
|
||||
}
|
||||
|
||||
type listBinding struct {
|
||||
relationName string
|
||||
context string
|
||||
listPath string
|
||||
relation RelationSpec
|
||||
}
|
||||
|
||||
// listAnnotations adds the implicit name/link annotations for a list file.
|
||||
// List files are formatted as fixed-size records, so the path token is the
|
||||
// natural anchor for both the displayed name and Cmd/Ctrl-click navigation.
|
||||
func (e *Engine) listAnnotations(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver) []listAnnotation {
|
||||
if resolver == nil && listResolver == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
bindings := make([]listBinding, 0)
|
||||
seenBindings := make(map[string]struct{})
|
||||
appendBinding := func(binding listBinding) {
|
||||
key := binding.relationName + "\x00" + normalizePath(binding.listPath)
|
||||
if _, exists := seenBindings[key]; exists {
|
||||
return
|
||||
}
|
||||
seenBindings[key] = struct{}{}
|
||||
bindings = append(bindings, binding)
|
||||
}
|
||||
for relationName, relation := range e.document.Relations {
|
||||
kind := relation.Kind
|
||||
if kind == "" {
|
||||
kind = "list"
|
||||
}
|
||||
switch kind {
|
||||
case "list":
|
||||
if sameListPath(relation.ListPath, filePath) {
|
||||
appendBinding(listBinding{
|
||||
relationName: relationName,
|
||||
listPath: relation.ListPath,
|
||||
relation: e.relationWithDefaults(relation),
|
||||
})
|
||||
}
|
||||
case "contextual":
|
||||
for context, listPath := range relation.ContextPaths {
|
||||
if !sameListPath(listPath, filePath) {
|
||||
continue
|
||||
}
|
||||
appendBinding(listBinding{
|
||||
relationName: relationName,
|
||||
context: context,
|
||||
listPath: listPath,
|
||||
relation: e.relationWithDefaults(relation),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.SliceStable(bindings, func(i, j int) bool {
|
||||
if bindings[i].relationName != bindings[j].relationName {
|
||||
return bindings[i].relationName < bindings[j].relationName
|
||||
}
|
||||
if bindings[i].listPath != bindings[j].listPath {
|
||||
return bindings[i].listPath < bindings[j].listPath
|
||||
}
|
||||
return bindings[i].context < bindings[j].context
|
||||
})
|
||||
|
||||
tokens := make([]pvf.ScriptElement, 0, len(view.Elements))
|
||||
for _, element := range view.Elements {
|
||||
if element.Kind == pvf.ScriptElementToken {
|
||||
tokens = append(tokens, element)
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]listAnnotation, 0)
|
||||
for _, binding := range bindings {
|
||||
relation := binding.relation
|
||||
for offset := 0; offset+relation.RecordTokens <= len(tokens); offset += relation.RecordTokens {
|
||||
id := tokens[offset+relation.IDToken].Value
|
||||
pathToken := tokens[offset+relation.PathToken]
|
||||
if id == "" || pathToken.Value == "" {
|
||||
continue
|
||||
}
|
||||
var reference Reference
|
||||
var ok bool
|
||||
if listResolver != nil {
|
||||
reference, ok = listResolver(binding.relationName, id, binding.context, binding.listPath, pathToken.Value)
|
||||
} else if resolver != nil {
|
||||
reference, ok = resolver(binding.relationName, id, binding.context)
|
||||
}
|
||||
if !ok || reference.FileIndex < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(reference.Name)
|
||||
if title == "" {
|
||||
// A valid indexed file can still omit [name]. Keep the
|
||||
// annotation useful and, importantly, keep the path clickable.
|
||||
title = reference.Path
|
||||
}
|
||||
if title == "" {
|
||||
title = id
|
||||
}
|
||||
content := "ID: " + id
|
||||
if reference.Path != "" {
|
||||
content += "\n路径: " + reference.Path
|
||||
}
|
||||
results = append(results, listAnnotation{
|
||||
anchor: editorAnchor{start: pathToken.Start, end: pathToken.End},
|
||||
item: matchedItem{
|
||||
ruleID: "list:" + binding.relationName,
|
||||
title: title,
|
||||
content: content,
|
||||
typ: "reference",
|
||||
targetFileIndex: reference.FileIndex,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (e *Engine) relationWithDefaults(relation RelationSpec) RelationSpec {
|
||||
if relation.RecordTokens == 0 {
|
||||
relation.RecordTokens = max(relation.IDToken, relation.PathToken) + 1
|
||||
}
|
||||
if relation.Kind == "" {
|
||||
relation.Kind = "list"
|
||||
}
|
||||
return relation
|
||||
}
|
||||
|
||||
func (e *Engine) AnnotatePath(filePath string, isDir bool) []Result {
|
||||
items := make([]matchedItem, 0)
|
||||
for _, index := range e.pathCandidateIndexes(filePath) {
|
||||
compiled := e.rules[index]
|
||||
if !compiled.matches(filePath, isDir) {
|
||||
continue
|
||||
}
|
||||
items = append(items, annotationItem(compiled.rule, "", "", "", nil, nil))
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
first := items[0]
|
||||
result := Result{
|
||||
Title: first.title,
|
||||
Type: first.typ,
|
||||
TargetFileIndex: -1,
|
||||
RuleIDs: make([]string, 0, len(items)),
|
||||
}
|
||||
for _, item := range items {
|
||||
result.Content = appendTooltip(result.Content, item.title, item.content)
|
||||
result.RuleIDs = append(result.RuleIDs, item.ruleID)
|
||||
}
|
||||
return []Result{result}
|
||||
}
|
||||
|
||||
func (e *Engine) Relation(name string) (RelationSpec, bool) {
|
||||
relation, ok := e.document.Relations[name]
|
||||
if ok && relation.RecordTokens == 0 {
|
||||
relation.RecordTokens = max(relation.IDToken, relation.PathToken) + 1
|
||||
}
|
||||
if ok && relation.Kind == "" {
|
||||
relation.Kind = "list"
|
||||
}
|
||||
return relation, ok
|
||||
}
|
||||
|
||||
func (e *Engine) RelationNames() []string {
|
||||
names := make([]string, 0, len(e.document.Relations))
|
||||
for name := range e.document.Relations {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
type editorAnchor struct {
|
||||
start int
|
||||
end int
|
||||
value string
|
||||
imagePathValue string
|
||||
imageIndexValue string
|
||||
context string
|
||||
}
|
||||
|
||||
func (e *Engine) editorAnchors(rule Rule, view pvf.ScriptView) []editorAnchor {
|
||||
if rule.Target.Kind == "section" {
|
||||
anchors := make([]editorAnchor, 0)
|
||||
for _, element := range view.Elements {
|
||||
if element.Kind == pvf.ScriptElementSection && strings.EqualFold(element.Section, rule.Target.Section) {
|
||||
anchors = append(anchors, editorAnchor{start: element.Start, end: element.End})
|
||||
}
|
||||
}
|
||||
return anchors
|
||||
}
|
||||
if rule.Target.Kind != "token" {
|
||||
return nil
|
||||
}
|
||||
if rule.Target.Index != nil && rule.Target.RecordTokens > 0 {
|
||||
contextIndex := rule.Target.ContextIndex
|
||||
if contextIndex == nil && rule.Annotation.Type == "reference" {
|
||||
if relation, ok := e.Relation(rule.Annotation.Relation); ok && relation.Kind == "contextual" {
|
||||
value := relation.ContextToken
|
||||
contextIndex = &value
|
||||
}
|
||||
}
|
||||
return repeatedTokenAnchors(rule, view, contextIndex)
|
||||
}
|
||||
if rule.Target.Index != nil {
|
||||
anchors := make([]editorAnchor, 0)
|
||||
for _, element := range view.Elements {
|
||||
if element.Kind == pvf.ScriptElementToken && strings.EqualFold(element.Section, rule.Target.Section) && element.Index == *rule.Target.Index {
|
||||
anchors = append(anchors, editorAnchor{start: element.Start, end: element.End, value: element.Value})
|
||||
}
|
||||
}
|
||||
return anchors
|
||||
}
|
||||
|
||||
ranges := make(map[int][]pvf.ScriptElement)
|
||||
sectionOrder := make([]int, 0)
|
||||
for _, element := range view.Elements {
|
||||
if element.Kind != pvf.ScriptElementToken || !strings.EqualFold(element.Section, rule.Target.Section) {
|
||||
continue
|
||||
}
|
||||
if _, ok := ranges[element.SectionID]; !ok {
|
||||
sectionOrder = append(sectionOrder, element.SectionID)
|
||||
}
|
||||
ranges[element.SectionID] = append(ranges[element.SectionID], element)
|
||||
}
|
||||
anchors := make([]editorAnchor, 0, len(sectionOrder))
|
||||
for _, sectionID := range sectionOrder {
|
||||
var first, last *pvf.ScriptElement
|
||||
for i := range ranges[sectionID] {
|
||||
element := &ranges[sectionID][i]
|
||||
if element.Index < rule.Target.Range.Start || element.Index >= rule.Target.Range.EndExclusive {
|
||||
continue
|
||||
}
|
||||
if first == nil {
|
||||
first = element
|
||||
}
|
||||
last = element
|
||||
}
|
||||
if first != nil && last != nil {
|
||||
anchors = append(anchors, editorAnchor{start: first.Start, end: last.End})
|
||||
}
|
||||
}
|
||||
return anchors
|
||||
}
|
||||
|
||||
func repeatedTokenAnchors(rule Rule, view pvf.ScriptView, contextIndex *int) []editorAnchor {
|
||||
ranges := make(map[int][]pvf.ScriptElement)
|
||||
sectionOrder := make([]int, 0)
|
||||
for _, element := range view.Elements {
|
||||
if element.Kind != pvf.ScriptElementToken || !strings.EqualFold(element.Section, rule.Target.Section) {
|
||||
continue
|
||||
}
|
||||
if _, ok := ranges[element.SectionID]; !ok {
|
||||
sectionOrder = append(sectionOrder, element.SectionID)
|
||||
}
|
||||
ranges[element.SectionID] = append(ranges[element.SectionID], element)
|
||||
}
|
||||
anchors := make([]editorAnchor, 0)
|
||||
for _, sectionID := range sectionOrder {
|
||||
tokens := ranges[sectionID]
|
||||
recordTokens := repeatedRecordTokens(rule.Target, tokens)
|
||||
if recordTokens <= 0 {
|
||||
continue
|
||||
}
|
||||
for _, group := range repeatedTokenGroups(rule.Target, tokens) {
|
||||
for start := group.start; start+recordTokens <= group.end; start += recordTokens {
|
||||
targetOffset := *rule.Target.Index
|
||||
if targetOffset < 0 || targetOffset >= recordTokens {
|
||||
continue
|
||||
}
|
||||
target := tokens[start+targetOffset]
|
||||
anchor := editorAnchor{start: target.Start, end: target.End, value: target.Value}
|
||||
if rule.Annotation.Type == "image" && rule.Target.ImagePathToken != nil {
|
||||
imagePathOffset := *rule.Target.ImagePathToken
|
||||
if imagePathOffset < 0 || imagePathOffset >= recordTokens {
|
||||
continue
|
||||
}
|
||||
anchor.imagePathValue = tokens[start+imagePathOffset].Value
|
||||
anchor.imageIndexValue = target.Value
|
||||
}
|
||||
if contextIndex != nil {
|
||||
contextOffset := *contextIndex
|
||||
if contextOffset < 0 || contextOffset >= recordTokens {
|
||||
continue
|
||||
}
|
||||
anchor.context = tokens[start+contextOffset].Value
|
||||
}
|
||||
anchors = append(anchors, anchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
return anchors
|
||||
}
|
||||
|
||||
type tokenGroup struct{ start, end int }
|
||||
|
||||
// repeatedTokenGroups applies the section-wide offset once, then splits at
|
||||
// standalone integer tokens. Each group's own offset is applied independently.
|
||||
func repeatedTokenGroups(target TargetSpec, tokens []pvf.ScriptElement) []tokenGroup {
|
||||
begin := min(target.Offset, len(tokens))
|
||||
groups := make([]tokenGroup, 0, 1)
|
||||
appendGroup := func(end int) {
|
||||
start := min(begin+target.GroupOffset, end)
|
||||
groups = append(groups, tokenGroup{start: start, end: end})
|
||||
}
|
||||
for index := begin; index < len(tokens); index++ {
|
||||
token := tokens[index]
|
||||
if token.TokenType != 0 {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(token.Value), 10, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, delimiter := range target.StandaloneValues {
|
||||
if int32(value) == delimiter {
|
||||
appendGroup(index)
|
||||
begin = index + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
appendGroup(len(tokens))
|
||||
return groups
|
||||
}
|
||||
|
||||
// repeatedRecordTokens returns the fixed record width unless a section token
|
||||
// supplies a valid positive override. The dynamic index is relative to the
|
||||
// section's direct token sequence, just like rendering rules.
|
||||
func repeatedRecordTokens(target TargetSpec, tokens []pvf.ScriptElement) int {
|
||||
recordTokens := target.RecordTokens
|
||||
if target.TokensPerLineIndex == nil {
|
||||
return recordTokens
|
||||
}
|
||||
index := *target.TokensPerLineIndex
|
||||
if index < 0 || index >= len(tokens) {
|
||||
return recordTokens
|
||||
}
|
||||
value, err := strconv.Atoi(strings.TrimSpace(tokens[index].Value))
|
||||
if err != nil || value <= 0 {
|
||||
return recordTokens
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func annotationItem(rule Rule, value, imageIndexValue, context string, resolver ContextResolver, pathResolver PathResolver) matchedItem {
|
||||
item := matchedItem{
|
||||
ruleID: rule.ID,
|
||||
title: rule.Annotation.Title,
|
||||
content: strings.TrimSpace(rule.Annotation.Content),
|
||||
typ: rule.Annotation.Type,
|
||||
targetFileIndex: -1,
|
||||
inlineImage: rule.Annotation.InlineImage,
|
||||
}
|
||||
switch rule.Annotation.Type {
|
||||
case "path":
|
||||
item.content = joinContent(item.content, "路径: "+strings.TrimSpace(value))
|
||||
if pathResolver != nil {
|
||||
if index, ok := pathResolver(rule.Annotation.PathRoot, value); ok {
|
||||
item.targetFileIndex = index
|
||||
}
|
||||
}
|
||||
case "image":
|
||||
imageIndex, err := strconv.ParseInt(strings.TrimSpace(imageIndexValue), 10, 32)
|
||||
item.content = joinContent(item.content, fmt.Sprintf("图片: %s[%s]", value, strings.TrimSpace(imageIndexValue)))
|
||||
if strings.TrimSpace(value) != "" && err == nil && imageIndex >= 0 {
|
||||
item.image = &ImageReference{Path: strings.TrimSpace(value), Index: int32(imageIndex)}
|
||||
}
|
||||
case "enum":
|
||||
label, ok := rule.Annotation.Values[value]
|
||||
detail := "当前值: " + value
|
||||
if ok {
|
||||
item.title = label
|
||||
detail = value + " - " + label
|
||||
}
|
||||
item.content = joinContent(item.content, detail)
|
||||
keys := make([]string, 0, len(rule.Annotation.Values))
|
||||
for key := range rule.Annotation.Values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
entries := []string{"所有枚举值:"}
|
||||
for _, key := range keys {
|
||||
entries = append(entries, key+" - "+rule.Annotation.Values[key])
|
||||
}
|
||||
item.content = joinContent(item.content, strings.Join(entries, "\n"))
|
||||
case "reference":
|
||||
detail := "ID: " + value + "(未找到关联文件)"
|
||||
if resolver != nil {
|
||||
if reference, ok := resolver(rule.Annotation.Relation, value, context); ok {
|
||||
if reference.Name != "" {
|
||||
item.title = reference.Name
|
||||
}
|
||||
detail = reference.Name
|
||||
if detail == "" {
|
||||
detail = reference.Path
|
||||
}
|
||||
if detail == "" {
|
||||
detail = "ID: " + value
|
||||
} else {
|
||||
detail += " (ID: " + value + ")"
|
||||
}
|
||||
item.targetFileIndex = reference.FileIndex
|
||||
}
|
||||
}
|
||||
item.content = joinContent(item.content, detail)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func appendTooltip(current, title, content string) string {
|
||||
item := strings.TrimSpace(title)
|
||||
if detail := strings.TrimSpace(content); detail != "" {
|
||||
item = joinContent(item, detail)
|
||||
}
|
||||
if item == "" {
|
||||
return current
|
||||
}
|
||||
if current == "" {
|
||||
return item
|
||||
}
|
||||
return current + "\n\n" + item
|
||||
}
|
||||
|
||||
func joinContent(first, second string) string {
|
||||
if first == "" {
|
||||
return second
|
||||
}
|
||||
if second == "" {
|
||||
return first
|
||||
}
|
||||
return first + "\n" + second
|
||||
}
|
||||
|
||||
func (r compiledRule) matches(filePath string, isDir bool) bool {
|
||||
filePath = normalizePath(filePath)
|
||||
if len(r.extensions) > 0 {
|
||||
if isDir {
|
||||
return false
|
||||
}
|
||||
if _, ok := r.extensions[strings.ToLower(path.Ext(filePath))]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return r.rule.Match.Glob == "" || globMatch(r.rule.Match.Glob, filePath)
|
||||
}
|
||||
|
||||
func normalizePath(value string) string {
|
||||
return strings.ToLower(strings.Trim(strings.ReplaceAll(value, "\\", "/"), "/"))
|
||||
}
|
||||
|
||||
// sameListPath accepts the list/ location used by newer clients for a
|
||||
// relation configured with the older category directory layout.
|
||||
func sameListPath(configured, actual string) bool {
|
||||
configured, actual = normalizePath(configured), normalizePath(actual)
|
||||
if configured == actual {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(actual, "list/") && strings.Contains(configured, "/") {
|
||||
return path.Base(configured) == path.Base(actual)
|
||||
}
|
||||
if strings.HasPrefix(configured, "list/") && strings.Contains(actual, "/") {
|
||||
return path.Base(configured) == path.Base(actual)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func globMatch(pattern, value string) bool {
|
||||
patternParts := splitPath(normalizePath(pattern))
|
||||
valueParts := splitPath(normalizePath(value))
|
||||
type state struct{ pattern, value int }
|
||||
memo := make(map[state]bool)
|
||||
seen := make(map[state]bool)
|
||||
var match func(int, int) bool
|
||||
match = func(pi, vi int) bool {
|
||||
key := state{pi, vi}
|
||||
if seen[key] {
|
||||
return memo[key]
|
||||
}
|
||||
seen[key] = true
|
||||
if pi == len(patternParts) {
|
||||
memo[key] = vi == len(valueParts)
|
||||
return memo[key]
|
||||
}
|
||||
if patternParts[pi] == "**" {
|
||||
if match(pi+1, vi) || (vi < len(valueParts) && match(pi, vi+1)) {
|
||||
memo[key] = true
|
||||
}
|
||||
return memo[key]
|
||||
}
|
||||
if vi >= len(valueParts) {
|
||||
return false
|
||||
}
|
||||
matched, err := path.Match(patternParts[pi], valueParts[vi])
|
||||
if err == nil && matched {
|
||||
memo[key] = match(pi+1, vi+1)
|
||||
}
|
||||
return memo[key]
|
||||
}
|
||||
return match(0, 0)
|
||||
}
|
||||
|
||||
func splitPath(value string) []string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(value, "/")
|
||||
}
|
||||
|
||||
// ApplicableRuleIDs exposes resolved annotation applicability for the rule editor.
|
||||
// Token existence is evaluated by Annotate; here the file/section scope is reported.
|
||||
func (e *Engine) ApplicableRuleIDs(filePath, section string, isDir bool) map[string]bool {
|
||||
ids := map[string]bool{}
|
||||
if e == nil {
|
||||
return ids
|
||||
}
|
||||
for _, r := range e.rules {
|
||||
if !r.matches(filePath, isDir) {
|
||||
continue
|
||||
}
|
||||
if section != "" {
|
||||
if !strings.EqualFold(strings.TrimSpace(r.rule.Target.Section), strings.TrimSpace(section)) {
|
||||
continue
|
||||
}
|
||||
} else if r.rule.Target.Kind != "path" {
|
||||
continue
|
||||
}
|
||||
ids[r.rule.ID] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
"pvfine/internal/rendering"
|
||||
)
|
||||
|
||||
func intPtr(value int) *int { return &value }
|
||||
|
||||
func testEngine(t *testing.T, rules ...Rule) *Engine {
|
||||
t.Helper()
|
||||
engine, err := Compile(Document{Version: 1, Relations: map[string]RelationSpec{}, Rules: rules})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestValidateRejectsDuplicateRuleID(t *testing.T) {
|
||||
rule := Rule{
|
||||
ID: "same", Target: TargetSpec{Kind: "section", Section: "name"},
|
||||
Annotation: AnnotationSpec{Title: "名称", Type: "text"},
|
||||
}
|
||||
err := Validate(Document{Version: 1, Relations: map[string]RelationSpec{}, Rules: []Rule{rule, rule}})
|
||||
if err == nil || !strings.Contains(err.Error(), "重复") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePathAnnotationRoot(t *testing.T) {
|
||||
rule := Rule{ID: "path", Target: TargetSpec{Kind: "token", Section: "file", Index: intPtr(0)}, Annotation: AnnotationSpec{Title: "文件", Type: "path", PathRoot: "equipment/character"}}
|
||||
if err := Validate(Document{Version: 1, Rules: []Rule{rule}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule.Annotation.PathRoot = "../outside"
|
||||
if err := Validate(Document{Version: 1, Rules: []Rule{rule}}); err == nil || !strings.Contains(err.Error(), "pathRoot") {
|
||||
t.Fatalf("invalid root error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateDuplicateSectionsAndConflict(t *testing.T) {
|
||||
first := Rule{
|
||||
ID: "first", Match: MatchSpec{Extensions: []string{".equ"}},
|
||||
Target: TargetSpec{Kind: "token", Section: "rarity", Index: intPtr(0)},
|
||||
Annotation: AnnotationSpec{Title: "装备品级", Type: "enum", Values: map[string]string{"3": "神器", "4": "史诗"}},
|
||||
}
|
||||
second := first
|
||||
second.ID = "second"
|
||||
second.Annotation = AnnotationSpec{Title: "第二说明", Type: "text", Content: "补充内容"}
|
||||
engine := testEngine(t, first, second)
|
||||
results := engine.Annotate("equipment/a.equ", pvf.ParseScriptView("[rarity]\n3\n[rarity]\n4"), nil)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
for i, result := range results {
|
||||
if result.Title != []string{"神器", "史诗"}[i] || len(result.RuleIDs) != 2 || !strings.Contains(result.Content, "第二说明") {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateTokenRangeStaysWithinSection(t *testing.T) {
|
||||
rangeTarget := &TokenRange{Start: 0, EndExclusive: 2}
|
||||
engine := testEngine(t, Rule{
|
||||
ID: "range", Target: TargetSpec{Kind: "token", Section: "pair", Range: rangeTarget},
|
||||
Annotation: AnnotationSpec{Title: "一组数据", Type: "text"},
|
||||
})
|
||||
results := engine.Annotate("a.equ", pvf.ParseScriptView("[pair]\n1 2\n[pair]\n3 4"), nil)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateReference(t *testing.T) {
|
||||
engine, err := Compile(Document{
|
||||
Version: 1,
|
||||
Relations: map[string]RelationSpec{
|
||||
"equipment": {ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1, NameSection: "name"},
|
||||
},
|
||||
Rules: []Rule{{
|
||||
ID: "ref", Target: TargetSpec{Kind: "token", Section: "ref", Index: intPtr(0)},
|
||||
Annotation: AnnotationSpec{Title: "关联装备", Type: "reference", Relation: "equipment"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := engine.Annotate("a.equ", pvf.ParseScriptView("[ref]\n1008"), func(_, id string) (Reference, bool) {
|
||||
return Reference{ID: id, Name: "烈火之心", FileIndex: 9}, true
|
||||
})
|
||||
if len(results) != 1 || results[0].Title != "烈火之心" || results[0].TargetFileIndex != 9 || !strings.Contains(results[0].Content, "烈火之心") {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateMissingReferenceDegradesGracefully(t *testing.T) {
|
||||
engine, err := Compile(Document{
|
||||
Version: 1,
|
||||
Relations: map[string]RelationSpec{
|
||||
"equipment": {ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1, NameSection: "name"},
|
||||
},
|
||||
Rules: []Rule{{
|
||||
ID: "ref", Target: TargetSpec{Kind: "token", Section: "ref", Index: intPtr(0)},
|
||||
Annotation: AnnotationSpec{Title: "关联装备", Type: "reference", Relation: "equipment"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := engine.Annotate("a.equ", pvf.ParseScriptView("[ref]\n404"), nil)
|
||||
if len(results) != 1 || results[0].Title != "关联装备" || results[0].TargetFileIndex != -1 || !strings.Contains(results[0].Content, "未找到关联文件") {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateImageAnnotationTarget(t *testing.T) {
|
||||
valid := Rule{
|
||||
ID: "icon", Target: TargetSpec{Kind: "token", Section: "icon", Index: intPtr(1), RecordTokens: 2, ImagePathToken: intPtr(0)},
|
||||
Annotation: AnnotationSpec{Title: "图标", Type: "image"},
|
||||
}
|
||||
if err := Validate(Document{Version: 1, Rules: []Rule{valid}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalid := valid
|
||||
invalid.ID = "invalid"
|
||||
invalid.Target.ImagePathToken = intPtr(1)
|
||||
if err := Validate(Document{Version: 1, Rules: []Rule{invalid}}); err == nil || !strings.Contains(err.Error(), "不能与图片索引 token 相同") {
|
||||
t.Fatalf("invalid image target error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateImageAnnotation(t *testing.T) {
|
||||
engine := testEngine(t, Rule{
|
||||
ID: "icon", Target: TargetSpec{Kind: "token", Section: "icon", Index: intPtr(1), RecordTokens: 2, ImagePathToken: intPtr(0)},
|
||||
Annotation: AnnotationSpec{Title: "图标", Type: "image", Content: "装备图标", InlineImage: true},
|
||||
})
|
||||
text := "[icon]\n`Item/test.img` 3"
|
||||
results := engine.Annotate("a.equ", pvf.ParseScriptView(text), nil)
|
||||
if len(results) != 1 || results[0].Image == nil {
|
||||
t.Fatalf("image annotation = %#v", results)
|
||||
}
|
||||
if !results[0].InlineImage {
|
||||
t.Fatalf("inline image flag was not propagated: %#v", results[0])
|
||||
}
|
||||
indexStart := strings.Index(text, "3")
|
||||
if results[0].Start != indexStart || results[0].End != indexStart+1 {
|
||||
t.Fatalf("image annotation anchor = [%d, %d), want numeric token", results[0].Start, results[0].End)
|
||||
}
|
||||
if results[0].Image.Path != "Item/test.img" || results[0].Image.Index != 3 || !strings.Contains(results[0].Content, "图片: Item/test.img[3]") {
|
||||
t.Fatalf("image annotation result = %#v", results[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateContextualReferenceByRepeatedRecord(t *testing.T) {
|
||||
engine, err := Compile(Document{
|
||||
Version: 1,
|
||||
Relations: map[string]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: []Rule{{
|
||||
ID: "skill.levelup", Target: TargetSpec{
|
||||
Kind: "token", Section: "skill levelup", Index: intPtr(1), RecordTokens: 3,
|
||||
},
|
||||
Annotation: AnnotationSpec{Title: "技能", Type: "reference", Relation: "skill"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view := pvf.ParseScriptView("[skill levelup]\n`[fighter]` 20 1\n`[fighter]` 19 1\n`[at fighter]` 20 1\n[/skill levelup]")
|
||||
results := engine.AnnotateWithContextResolver("skills/test.skl", view, func(relation, id, context string) (Reference, bool) {
|
||||
if relation != "skill" {
|
||||
t.Fatalf("relation = %q", relation)
|
||||
}
|
||||
return Reference{ID: context + ":" + id, Name: context + " skill " + id, FileIndex: 7}, true
|
||||
})
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if results[0].Title != "[fighter] skill 20" || results[1].Title != "[fighter] skill 19" || results[2].Title != "[at fighter] skill 20" {
|
||||
t.Fatalf("titles = %#v", results)
|
||||
}
|
||||
if results[0].Start >= results[1].Start || results[1].Start >= results[2].Start {
|
||||
t.Fatalf("anchors are not ordered: %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateRepeatedTokenWithOffset(t *testing.T) {
|
||||
engine := testEngine(t, Rule{
|
||||
ID: "records.offset",
|
||||
Target: TargetSpec{
|
||||
Kind: "token", Section: "records", Index: intPtr(0),
|
||||
Offset: 1, RecordTokens: 2,
|
||||
},
|
||||
Annotation: AnnotationSpec{Title: "记录", Type: "text"},
|
||||
})
|
||||
text := "[records]\n10 100 200 300 400 500\n[/records]"
|
||||
results := engine.Annotate("a.equ", pvf.ParseScriptView(text), nil)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
for i, want := range []string{"100", "300"} {
|
||||
if got := text[results[i].Start:results[i].End]; got != want {
|
||||
t.Fatalf("result[%d] = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateRepeatedTokenUsesDynamicRecordWidth(t *testing.T) {
|
||||
dynamicIndex := 0
|
||||
engine := testEngine(t, Rule{
|
||||
ID: "records.dynamic",
|
||||
Target: TargetSpec{
|
||||
Kind: "token", Section: "records", Index: intPtr(1),
|
||||
Offset: 1, RecordTokens: 3, TokensPerLineIndex: &dynamicIndex,
|
||||
},
|
||||
Annotation: AnnotationSpec{Title: "记录", Type: "text"},
|
||||
})
|
||||
text := "[records]\n2 100 200 300 400 500\n[/records]"
|
||||
results := engine.Annotate("a.equ", pvf.ParseScriptView(text), nil)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
for i, want := range []string{"200", "400"} {
|
||||
if got := text[results[i].Start:results[i].End]; got != want {
|
||||
t.Fatalf("result[%d] = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateStandaloneGroupsWithGroupOffset(t *testing.T) {
|
||||
engine := testEngine(t, Rule{
|
||||
ID: "world.drop",
|
||||
Target: TargetSpec{
|
||||
Kind: "token", Section: "world drop", Index: intPtr(0),
|
||||
Offset: 1, GroupOffset: 1, RecordTokens: 2,
|
||||
StandaloneValues: []int32{-1},
|
||||
},
|
||||
Annotation: AnnotationSpec{Title: "掉落", Type: "text"},
|
||||
})
|
||||
// Offset applies only once; groupOffset skips the first token of every
|
||||
// group. Trailing incomplete records and the delimiters are not annotated.
|
||||
text := "[world drop]\n999 10 100 200 300 -1 20 400 500 -1 -1 30 600 700 800\n[next]"
|
||||
results := engine.Annotate("a.etc", pvf.ParseScriptView(text), nil)
|
||||
for i, want := range []string{"100", "400", "600"} {
|
||||
if i >= len(results) {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if got := text[results[i].Start:results[i].End]; got != want {
|
||||
t.Fatalf("result[%d] = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateStandaloneGroupsDoNotMatchQuotedSentinel(t *testing.T) {
|
||||
engine := testEngine(t, Rule{
|
||||
ID: "records", Target: TargetSpec{
|
||||
Kind: "token", Section: "records", Index: intPtr(0),
|
||||
RecordTokens: 2, StandaloneValues: []int32{-1},
|
||||
},
|
||||
Annotation: AnnotationSpec{Title: "record", Type: "text"},
|
||||
})
|
||||
text := "[records]\n`-1` 5 -1 10 20"
|
||||
results := engine.Annotate("a.etc", pvf.ParseScriptView(text), nil)
|
||||
if len(results) != 2 || text[results[0].Start:results[0].End] != "`-1`" ||
|
||||
text[results[1].Start:results[1].End] != "10" {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultIndependentDropReferencesAcrossOptionalList(t *testing.T) {
|
||||
// Only the curated rules: generated section notes would add extra section
|
||||
// level results that this token reference test does not exercise.
|
||||
engine := handWrittenEngine(t)
|
||||
renderer, err := rendering.LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := "[independent drop]\n" +
|
||||
"1 101 201 4 5 6 7 8 9 10 11 12 13 14 15 16 17\n" +
|
||||
"[list]\n901 902\n903 904\n[/list]\n" +
|
||||
"18 102 202 21 22 23 24 25 26 27 28 29 30 31 32 33 34\n" +
|
||||
"35 103 203 38 39 40 41 42 43 44 45 46 47 48 49 50 51\n" +
|
||||
"[next]\n1"
|
||||
var resolved []string
|
||||
view := pvf.ParseScriptViewWithNestedSections(text, func(parent, child string) bool {
|
||||
for _, nested := range renderer.SectionFormat("etc/test.etc", parent).NestedSections {
|
||||
if nested == child {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
results := engine.Annotate("etc/test.etc", view, func(relation, id string) (Reference, bool) {
|
||||
resolved = append(resolved, relation+":"+id)
|
||||
return Reference{ID: id, Name: relation + id, FileIndex: 7}, true
|
||||
})
|
||||
want := []string{"怪物:101", "物品:201", "怪物:102", "物品:202", "怪物:103", "物品:203"}
|
||||
wantResolved := []string{"怪物:101", "怪物:102", "怪物:103", "物品:201", "物品:202", "物品:203"}
|
||||
if strings.Join(resolved, ",") != strings.Join(wantResolved, ",") {
|
||||
t.Fatalf("resolved = %#v, want %#v", resolved, wantResolved)
|
||||
}
|
||||
if len(results) != len(want) {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
for i, result := range results {
|
||||
if result.TargetFileIndex != 7 || !strings.HasSuffix(want[i], ":"+text[result.Start:result.End]) {
|
||||
t.Fatalf("result[%d] = %#v", i, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotatePathSupportsRecursiveGlobAndDirectories(t *testing.T) {
|
||||
engine := testEngine(t, Rule{
|
||||
ID: "path", Match: MatchSpec{Glob: "equipment/**"}, Target: TargetSpec{Kind: "path"},
|
||||
Annotation: AnnotationSpec{Title: "装备目录", Type: "text"},
|
||||
})
|
||||
if len(engine.AnnotatePath("equipment/character", true)) != 1 {
|
||||
t.Fatal("directory should match recursive glob")
|
||||
}
|
||||
if len(engine.AnnotatePath("stackable/item.stk", false)) != 0 {
|
||||
t.Fatal("unrelated file should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotatePathIndexMatchesFullScanInRuleOrder(t *testing.T) {
|
||||
engine := testEngine(t,
|
||||
Rule{ID: "deep", Match: MatchSpec{Glob: "a/b/**"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "深层目录", Type: "text"}},
|
||||
Rule{ID: "exact", Match: MatchSpec{Glob: "a/b/c.d"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "精确文件", Type: "text"}},
|
||||
Rule{ID: "star", Match: MatchSpec{Glob: "**/*.d"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "通配文件", Type: "text"}},
|
||||
Rule{ID: "all", Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "全部", Type: "text"}},
|
||||
Rule{ID: "top", Match: MatchSpec{Glob: "a/**"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "顶层目录", Type: "text"}},
|
||||
Rule{ID: "sibling", Match: MatchSpec{Glob: "a2/**"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "同名前缀目录", Type: "text"}},
|
||||
)
|
||||
tests := []struct {
|
||||
path string
|
||||
isDir bool
|
||||
want []string
|
||||
title string
|
||||
}{
|
||||
{"a/b/c.d", false, []string{"deep", "exact", "star", "all", "top"}, "深层目录"},
|
||||
{"a/b/c/e.f", false, []string{"deep", "all", "top"}, "深层目录"},
|
||||
{"a", true, []string{"all", "top"}, "全部"},
|
||||
{"a2/x.d", false, []string{"star", "all", "sibling"}, "通配文件"},
|
||||
{"stackable/x.d", false, []string{"star", "all"}, "通配文件"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
results := engine.AnnotatePath(tc.path, tc.isDir)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("%s: results = %#v", tc.path, results)
|
||||
}
|
||||
if !reflect.DeepEqual(results[0].RuleIDs, tc.want) {
|
||||
t.Fatalf("%s: ruleIDs = %v, want %v", tc.path, results[0].RuleIDs, tc.want)
|
||||
}
|
||||
if results[0].Title != tc.title {
|
||||
t.Fatalf("%s: title = %q, want %q", tc.path, results[0].Title, tc.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotatePathIndexHonorsExtensionsAndCase(t *testing.T) {
|
||||
engine := testEngine(t, Rule{
|
||||
ID: "stk", Match: MatchSpec{Extensions: []string{".stk"}, Glob: "stackable/**"},
|
||||
Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "堆叠目录", Type: "text"},
|
||||
})
|
||||
if len(engine.AnnotatePath("stackable/a.equ", false)) != 0 {
|
||||
t.Fatal("extension mismatch should not match")
|
||||
}
|
||||
if len(engine.AnnotatePath("stackable/a.stk", true)) != 0 {
|
||||
t.Fatal("extension rule should not match a directory")
|
||||
}
|
||||
results := engine.AnnotatePath("Stackable\\Nested\\A.STK", false)
|
||||
if len(results) != 1 || results[0].Title != "堆叠目录" {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateUnknownEnumKeepsRuleTitle(t *testing.T) {
|
||||
engine := testEngine(t, Rule{
|
||||
ID: "rarity", Target: TargetSpec{Kind: "token", Section: "rarity", Index: intPtr(0)},
|
||||
Annotation: AnnotationSpec{Title: "装备品级", Type: "enum", Values: map[string]string{"4": "史诗"}},
|
||||
})
|
||||
results := engine.Annotate("a.equ", pvf.ParseScriptView("[rarity]\n99"), nil)
|
||||
if len(results) != 1 || results[0].Title != "装备品级" || !strings.Contains(results[0].Content, "当前值: 99") {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnumTooltipListsAllValues(t *testing.T) {
|
||||
engine := testEngine(t, Rule{
|
||||
ID: "rarity", Target: TargetSpec{Kind: "token", Section: "rarity", Index: intPtr(0)},
|
||||
Annotation: AnnotationSpec{
|
||||
Title: "装备品级", Type: "enum", Content: "品级说明",
|
||||
Values: map[string]string{"4": "史诗", "0": "普通", "3": "神器"},
|
||||
},
|
||||
})
|
||||
for _, value := range []string{"4", "99"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
results := engine.Annotate("a.equ", pvf.ParseScriptView("[rarity]\n"+value), nil)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if !strings.Contains(results[0].Content, "品级说明") ||
|
||||
!strings.Contains(results[0].Content, "所有枚举值:\n0 - 普通\n3 - 神器\n4 - 史诗") {
|
||||
t.Fatalf("tooltip = %q", results[0].Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
// PreviewFieldValue is one occurrence of a shared field in an editor view.
|
||||
// Values contains the selected token(s). For a repeated record target it
|
||||
// contains the complete record so consumers can use context and neighboring
|
||||
// values (for example, skill context + skill id + level).
|
||||
type PreviewFieldValue struct {
|
||||
Field FieldDefinition
|
||||
Values []string
|
||||
Context string
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
// resolveFieldRule lets legacy rules opt into the shared field definition
|
||||
// without changing the existing rule JSON shape. A rule's id, description and
|
||||
// group remain local; non-empty target/match/annotation values override the
|
||||
// field when supplied.
|
||||
func resolveFieldRule(rule Rule, fields []FieldDefinition) (Rule, error) {
|
||||
fieldID := strings.TrimSpace(rule.Field)
|
||||
if fieldID == "" {
|
||||
return rule, nil
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field.ID != fieldID {
|
||||
continue
|
||||
}
|
||||
resolved := Rule{
|
||||
ID: rule.ID,
|
||||
PVFVersions: rule.PVFVersions,
|
||||
Description: rule.Description,
|
||||
Field: rule.Field,
|
||||
Match: field.Match,
|
||||
Target: field.Target,
|
||||
Annotation: field.Annotation,
|
||||
Group: rule.Group,
|
||||
}
|
||||
if len(rule.Match.Extensions) > 0 || rule.Match.Glob != "" {
|
||||
resolved.Match = rule.Match
|
||||
}
|
||||
if rule.Target.Kind != "" {
|
||||
resolved.Target = rule.Target
|
||||
}
|
||||
if rule.Annotation.Title != "" || rule.Annotation.Type != "" ||
|
||||
rule.Annotation.Content != "" || len(rule.Annotation.Values) > 0 ||
|
||||
rule.Annotation.Relation != "" || rule.Annotation.PathRoot != "" || rule.Annotation.InlineImage {
|
||||
resolved.Annotation = rule.Annotation
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
return Rule{}, fmt.Errorf("引用了不存在的共享字段: %s", fieldID)
|
||||
}
|
||||
|
||||
// PreviewFields returns the provider-specific shared fields that match a
|
||||
// path, in stable display order. The returned definitions are copies of the
|
||||
// immutable engine document and are safe for callers to retain.
|
||||
func (e *Engine) PreviewFields(filePath, provider string) []FieldDefinition {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
items := make([]FieldDefinition, 0)
|
||||
for _, field := range e.document.Fields {
|
||||
if field.Preview == nil || !previewSupportsProvider(field.Preview, provider) {
|
||||
continue
|
||||
}
|
||||
if !fieldMatches(field.Match, filePath, false) {
|
||||
continue
|
||||
}
|
||||
items = append(items, field)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
left, right := items[i].Preview, items[j].Preview
|
||||
if left.Order != right.Order {
|
||||
return left.Order < right.Order
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func previewSupportsProvider(preview *PreviewSpec, provider string) bool {
|
||||
provider = strings.TrimSpace(provider)
|
||||
if preview == nil || provider == "" {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range preview.providerNames() {
|
||||
if strings.EqualFold(candidate, provider) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractPreviewFields projects a ScriptView through provider field targets.
|
||||
// It deliberately shares the same target semantics as annotations, including
|
||||
// repeated records, offsets and dynamic record widths.
|
||||
func (e *Engine) ExtractPreviewFields(filePath string, view pvf.ScriptView, provider string) []PreviewFieldValue {
|
||||
fields := e.PreviewFields(filePath, provider)
|
||||
values := make([]PreviewFieldValue, 0)
|
||||
for _, field := range fields {
|
||||
for _, occurrence := range extractField(field, view) {
|
||||
values = append(values, occurrence)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func extractField(field FieldDefinition, view pvf.ScriptView) []PreviewFieldValue {
|
||||
target := field.Target
|
||||
if target.Kind == "section" {
|
||||
result := make([]PreviewFieldValue, 0)
|
||||
for _, sectionID := range sectionIDs(view, target.Section) {
|
||||
tokens := sectionTokens(view, target.Section, sectionID)
|
||||
if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
result = append(result, PreviewFieldValue{
|
||||
Field: field, Values: valuesOf(tokens),
|
||||
Start: tokens[0].Start, End: tokens[len(tokens)-1].End,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
if target.Kind != "token" {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]PreviewFieldValue, 0)
|
||||
for _, sectionID := range sectionIDs(view, target.Section) {
|
||||
tokens := sectionTokens(view, target.Section, sectionID)
|
||||
if target.Index != nil && target.RecordTokens > 0 {
|
||||
recordTokens := target.RecordTokens
|
||||
if target.TokensPerLineIndex != nil {
|
||||
recordTokens = repeatedRecordTokens(target, tokens)
|
||||
}
|
||||
if recordTokens <= 0 {
|
||||
continue
|
||||
}
|
||||
for _, group := range repeatedTokenGroups(target, tokens) {
|
||||
for start := group.start; start+recordTokens <= group.end; start += recordTokens {
|
||||
offset := *target.Index
|
||||
if offset < 0 || offset >= recordTokens {
|
||||
continue
|
||||
}
|
||||
record := tokens[start : start+recordTokens]
|
||||
context := ""
|
||||
if target.ContextIndex != nil && *target.ContextIndex >= 0 && *target.ContextIndex < len(record) {
|
||||
context = record[*target.ContextIndex].Value
|
||||
}
|
||||
result = append(result, PreviewFieldValue{
|
||||
Field: field, Values: valuesOf(record), Context: context,
|
||||
Start: record[offset].Start, End: record[offset].End,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if target.Index != nil {
|
||||
index := *target.Index
|
||||
if index >= 0 && index < len(tokens) {
|
||||
token := tokens[index]
|
||||
result = append(result, PreviewFieldValue{Field: field, Values: []string{token.Value}, Start: token.Start, End: token.End})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if target.Range == nil {
|
||||
continue
|
||||
}
|
||||
start := maxInt(0, target.Range.Start)
|
||||
end := minInt(len(tokens), target.Range.EndExclusive)
|
||||
if start >= end {
|
||||
continue
|
||||
}
|
||||
result = append(result, PreviewFieldValue{
|
||||
Field: field, Values: valuesOf(tokens[start:end]),
|
||||
Start: tokens[start].Start, End: tokens[end-1].End,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fieldMatches(match MatchSpec, filePath string, isDir bool) bool {
|
||||
filePath = normalizeFieldPath(filePath)
|
||||
if len(match.Extensions) > 0 {
|
||||
if isDir {
|
||||
return false
|
||||
}
|
||||
matched := false
|
||||
for _, extension := range match.Extensions {
|
||||
if strings.EqualFold(path.Ext(filePath), strings.TrimSpace(extension)) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if match.Glob == "" {
|
||||
return true
|
||||
}
|
||||
return globFieldMatch(match.Glob, filePath)
|
||||
}
|
||||
|
||||
func globFieldMatch(pattern, value string) bool {
|
||||
patternParts := strings.Split(normalizeFieldPath(pattern), "/")
|
||||
valueParts := strings.Split(normalizeFieldPath(value), "/")
|
||||
type state struct{ pattern, value int }
|
||||
memo := make(map[state]bool)
|
||||
seen := make(map[state]bool)
|
||||
var match func(int, int) bool
|
||||
match = func(pi, vi int) bool {
|
||||
key := state{pi, vi}
|
||||
if seen[key] {
|
||||
return memo[key]
|
||||
}
|
||||
seen[key] = true
|
||||
if pi == len(patternParts) {
|
||||
memo[key] = vi == len(valueParts)
|
||||
return memo[key]
|
||||
}
|
||||
if patternParts[pi] == "**" {
|
||||
memo[key] = match(pi+1, vi) || (vi < len(valueParts) && match(pi, vi+1))
|
||||
return memo[key]
|
||||
}
|
||||
if vi >= len(valueParts) {
|
||||
return false
|
||||
}
|
||||
matched, err := path.Match(patternParts[pi], valueParts[vi])
|
||||
if err == nil && matched {
|
||||
memo[key] = match(pi+1, vi+1)
|
||||
}
|
||||
return memo[key]
|
||||
}
|
||||
return match(0, 0)
|
||||
}
|
||||
|
||||
func normalizeFieldPath(value string) string {
|
||||
return strings.ToLower(strings.Trim(strings.ReplaceAll(value, "\\", "/"), "/"))
|
||||
}
|
||||
|
||||
func sectionIDs(view pvf.ScriptView, name string) []int {
|
||||
result := make([]int, 0)
|
||||
seen := make(map[int]struct{})
|
||||
for _, element := range view.Elements {
|
||||
if element.Kind != pvf.ScriptElementSection || !strings.EqualFold(element.Section, name) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[element.SectionID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[element.SectionID] = struct{}{}
|
||||
result = append(result, element.SectionID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sectionTokens(view pvf.ScriptView, name string, sectionID int) []pvf.ScriptElement {
|
||||
result := make([]pvf.ScriptElement, 0)
|
||||
for _, element := range view.Elements {
|
||||
if element.Kind == pvf.ScriptElementToken && element.SectionID == sectionID && strings.EqualFold(element.Section, name) {
|
||||
result = append(result, element)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func valuesOf(elements []pvf.ScriptElement) []string {
|
||||
result := make([]string, len(elements))
|
||||
for i, element := range elements {
|
||||
result[i] = element.Value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
func TestExtractPreviewFieldsUsesSharedTargets(t *testing.T) {
|
||||
index := 1
|
||||
context := 0
|
||||
engine, err := Compile(Document{
|
||||
Version: 1,
|
||||
Fields: []FieldDefinition{
|
||||
{
|
||||
ID: "equ.name",
|
||||
Match: MatchSpec{Extensions: []string{".equ"}},
|
||||
Target: TargetSpec{Kind: "token", Section: "name", Index: intPtr(0)},
|
||||
Annotation: AnnotationSpec{Title: "名称", Type: "text"},
|
||||
Preview: &PreviewSpec{Provider: "equ", Role: "name", Group: "header", Format: "text"},
|
||||
},
|
||||
{
|
||||
ID: "equ.skill",
|
||||
Match: MatchSpec{Extensions: []string{".equ"}},
|
||||
Target: TargetSpec{Kind: "token", Section: "skill levelup", Index: &index, RecordTokens: 3, ContextIndex: &context},
|
||||
Annotation: AnnotationSpec{Title: "技能", Type: "text"},
|
||||
Preview: &PreviewSpec{Provider: "equ", Role: "skill-levelup", Group: "skills", Format: "skill-levelup"},
|
||||
},
|
||||
},
|
||||
Rules: []Rule{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values := engine.ExtractPreviewFields("equipment/a.equ", pvf.ParseScriptView("[name]\n`测试`\n[skill levelup]\n`[swordman]` 38 2\n`[knight]` 14 1\n[/skill levelup]"), "equ")
|
||||
if len(values) != 3 || values[0].Values[0] != "测试" {
|
||||
t.Fatalf("values = %#v", values)
|
||||
}
|
||||
if values[1].Context != "[swordman]" || len(values[1].Values) != 3 || values[2].Values[1] != "14" {
|
||||
t.Fatalf("repeated values = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewFieldsUseStandaloneGroupsAndGroupOffset(t *testing.T) {
|
||||
engine := testEngine(t)
|
||||
engine, err := Compile(Document{
|
||||
Version: 1,
|
||||
Fields: []FieldDefinition{{
|
||||
ID: "world.drop", Match: MatchSpec{Extensions: []string{".etc"}},
|
||||
Target: TargetSpec{
|
||||
Kind: "token", Section: "world drop", Index: intPtr(0), RecordTokens: 2,
|
||||
Offset: 1, GroupOffset: 1, StandaloneValues: []int32{-1},
|
||||
},
|
||||
Annotation: AnnotationSpec{Title: "掉落", Type: "text"},
|
||||
Preview: &PreviewSpec{Provider: "etc", Role: "drop", Group: "drops", Format: "text"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view := pvf.ParseScriptView("[world drop]\n999 10 100 200 -1 20 300 400 -1 30 500 600")
|
||||
values := engine.ExtractPreviewFields("item/a.etc", view, "etc")
|
||||
if len(values) != 3 {
|
||||
t.Fatalf("values = %#v", values)
|
||||
}
|
||||
for i, want := range []string{"100", "300", "500"} {
|
||||
if values[i].Values[0] != want || len(values[i].Values) != 2 {
|
||||
t.Fatalf("values[%d] = %#v, want %s", i, values[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldRuleReferencesSharedDefinition(t *testing.T) {
|
||||
engine, err := Compile(Document{
|
||||
Version: 1,
|
||||
Fields: []FieldDefinition{{
|
||||
ID: "equ.rarity",
|
||||
Match: MatchSpec{Extensions: []string{".equ"}},
|
||||
Target: TargetSpec{Kind: "token", Section: "rarity", Index: intPtr(0)},
|
||||
Annotation: AnnotationSpec{Title: "稀有度", Type: "enum", Values: map[string]string{"4": "史诗"}},
|
||||
}},
|
||||
Rules: []Rule{{ID: "rarity.rule", Field: "equ.rarity"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := engine.Annotate("equipment/a.equ", pvf.ParseScriptView("[rarity]\n4"), nil)
|
||||
if len(results) != 1 || results[0].Title != "史诗" {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedFieldLoadsAsAnnotationAndSupportsMultipleProviders(t *testing.T) {
|
||||
engine, err := Compile(Document{
|
||||
Version: 1,
|
||||
Fields: []FieldDefinition{{
|
||||
ID: "global.cool-time",
|
||||
Match: MatchSpec{Extensions: []string{".equ", ".stk"}},
|
||||
Target: TargetSpec{Kind: "token", Section: "cool time", Index: intPtr(0)},
|
||||
Annotation: AnnotationSpec{Title: "冷却时间", Type: "text"},
|
||||
Preview: &PreviewSpec{Providers: []string{"equ", "stk"}, Role: "cool-time", Group: "summary", Format: "text"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, extension := range []string{".equ", ".stk"} {
|
||||
results := engine.Annotate("item/test"+extension, pvf.ParseScriptView("[cool time]\n100"), nil)
|
||||
if len(results) != 1 || results[0].Title != "冷却时间" {
|
||||
t.Fatalf("extension %s results = %#v", extension, results)
|
||||
}
|
||||
}
|
||||
if len(engine.PreviewFields("item/test.equ", "equ")) != 1 || len(engine.PreviewFields("item/test.stk", "stk")) != 1 {
|
||||
t.Fatal("multiple preview providers were not matched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultIncludesEquipmentPreviewFields(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields := engine.PreviewFields("equipment/a.equ", "equ")
|
||||
if len(fields) < 20 {
|
||||
t.Fatalf("equipment fields = %d", len(fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultNewRarityAnnotations(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine = engine.ForVersion("110US")
|
||||
for _, path := range []string{"equipment/test.equ", "stackable/test.stk"} {
|
||||
for value, label := range map[string]string{"7": "神话", "8": "太初"} {
|
||||
results := engine.Annotate(path, pvf.ParseScriptView("[rarity]\n"+value), nil)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("%s rarity %s annotations = %#v", path, value, results)
|
||||
}
|
||||
for _, result := range results {
|
||||
if len(result.RuleIDs) != 1 || result.RuleIDs[0] != "field:*.rarity" || strings.Count(result.Content, "所有枚举值:") != 1 {
|
||||
t.Fatalf("%s rarity %s duplicated tooltip = %#v", path, value, result)
|
||||
}
|
||||
if result.Title != label {
|
||||
t.Fatalf("%s rarity %s = %q, want %q", path, value, result.Title, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultEquipmentRarityPreviewUsesGlobalField(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values := engine.ForVersion("110US").ExtractPreviewFields("equipment/test.equ", pvf.ParseScriptView("[rarity]\n8"), "equ")
|
||||
if len(values) != 1 || values[0].Field.ID != "*.rarity" || values[0].Field.Preview.Role != "rarity" || values[0].Values[0] != "8" {
|
||||
t.Fatalf("rarity preview fields = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultFameSections(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine = engine.ForVersion("110US")
|
||||
for _, section := range []string{"unique option fame value", "fame value"} {
|
||||
t.Run(section, func(t *testing.T) {
|
||||
view := pvf.ParseScriptView("[" + section + "]\n748")
|
||||
results := engine.Annotate("equipment/test.equ", view, nil)
|
||||
if len(results) != 1 || results[0].Title != "冒险家名望" {
|
||||
t.Fatalf("annotations = %#v", results)
|
||||
}
|
||||
values := engine.ExtractPreviewFields("equipment/test.equ", view, "equ")
|
||||
if len(values) != 1 || len(values[0].Values) != 1 || values[0].Values[0] != "748" {
|
||||
t.Fatalf("preview fields = %#v", values)
|
||||
}
|
||||
if results := engine.Annotate("item/test.stk", view, nil); len(results) != 0 {
|
||||
t.Fatalf("non-equ annotations = %#v", results)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBuffBasicExplainSection(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine = engine.ForVersion("110US")
|
||||
view := pvf.ParseScriptView("[buff basic explain]\n{10=`<1::buff>`}")
|
||||
results := engine.Annotate("equipment/test.equ", view, nil)
|
||||
if len(results) != 1 || results[0].Title != "辅助职业效果" {
|
||||
t.Fatalf("annotations = %#v", results)
|
||||
}
|
||||
values := engine.ExtractPreviewFields("equipment/test.equ", view, "equ")
|
||||
if len(values) != 1 || values[0].Field.ID != "equ.buff-basic-explain" || values[0].Field.Preview.Role != "buff-basic-explain" {
|
||||
t.Fatalf("preview fields = %#v", values)
|
||||
}
|
||||
if results := engine.Annotate("item/test.stk", view, nil); len(results) != 0 {
|
||||
t.Fatalf("non-equ annotations = %#v", results)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
appconfig "pvfine/config"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultEngineOnce sync.Once
|
||||
defaultEngine *Engine
|
||||
defaultEngineErr error
|
||||
)
|
||||
|
||||
// LoadDefault parses the embedded rule set once and reuses the immutable
|
||||
// engine. The generated path notes make the document large enough that a parse
|
||||
// per preview or auxiliary core would be measurable.
|
||||
func LoadDefault() (*Engine, error) {
|
||||
defaultEngineOnce.Do(func() {
|
||||
defaultEngine, defaultEngineErr = loadDefault()
|
||||
})
|
||||
return defaultEngine, defaultEngineErr
|
||||
}
|
||||
|
||||
// generatedRuleFile is one bulk rule set that ships next to annotations.json.
|
||||
type generatedRuleFile struct {
|
||||
// name is the sibling file that overrides the embedded copy at runtime.
|
||||
name string
|
||||
label string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// generatedRuleFiles lists the generated rule sets merged after the hand-written
|
||||
// rules. Order only decides the primary title of a path or section, so the
|
||||
// curated rules in annotations.json always stay first.
|
||||
func generatedRuleFiles() []generatedRuleFile {
|
||||
return []generatedRuleFile{
|
||||
{name: "paths.json", label: "path 标注", data: appconfig.PathsJSON},
|
||||
{name: "sections.json", label: "section 标注", data: appconfig.SectionsJSON},
|
||||
}
|
||||
}
|
||||
|
||||
func loadDefault() (*Engine, error) {
|
||||
document, err := parseDocument(appconfig.AnnotationsJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, generated := range generatedRuleFiles() {
|
||||
if err := mergeGeneratedRules(&document, generated.data, generated.label); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
lists, err := ParseLists(appconfig.ListsJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document.Relations = lists.Relations
|
||||
if err := Validate(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Compile(document)
|
||||
}
|
||||
|
||||
// mergeGeneratedRules appends a generated rule set after the hand-written rules,
|
||||
// so an explicit rule keeps the primary title for a path or section. Rules whose
|
||||
// ID is already present are skipped, which keeps merging idempotent when a user
|
||||
// mirrors a generated file by hand.
|
||||
func mergeGeneratedRules(document *Document, data []byte, label string) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
generated, err := parseDocument(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 %s 失败: %w", label, err)
|
||||
}
|
||||
known := make(map[string]struct{}, len(document.Rules))
|
||||
for _, rule := range document.Rules {
|
||||
known[rule.ID] = struct{}{}
|
||||
}
|
||||
for _, rule := range generated.Rules {
|
||||
if _, exists := known[rule.ID]; exists {
|
||||
continue
|
||||
}
|
||||
known[rule.ID] = struct{}{}
|
||||
document.Rules = append(document.Rules, rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Parse(data []byte) (Document, error) {
|
||||
document, err := parseDocument(data)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
if err := Validate(document); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func ParseRules(data []byte) (Document, error) {
|
||||
return parseDocument(data)
|
||||
}
|
||||
|
||||
func parseDocument(data []byte) (Document, error) {
|
||||
var document Document
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return Document{}, fmt.Errorf("解析标注规则失败: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return Document{}, fmt.Errorf("标注规则只能包含一个 JSON 文档")
|
||||
}
|
||||
return Document{}, fmt.Errorf("解析标注规则失败: %w", err)
|
||||
}
|
||||
normalizeImageTargets(&document)
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func ParseLists(data []byte) (ListDocument, error) {
|
||||
var lists ListDocument
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&lists); err != nil {
|
||||
return ListDocument{}, fmt.Errorf("解析列表配置失败: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return ListDocument{}, fmt.Errorf("列表配置只能包含一个 JSON 文档")
|
||||
}
|
||||
return ListDocument{}, fmt.Errorf("解析列表配置失败: %w", err)
|
||||
}
|
||||
document := Document{Version: lists.Version, Relations: lists.Relations}
|
||||
if err := Validate(document); err != nil {
|
||||
return ListDocument{}, err
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
func Marshal(document Document) ([]byte, error) {
|
||||
normalizeImageTargets(&document)
|
||||
if err := Validate(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.MarshalIndent(document, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
func MarshalRules(document Document) ([]byte, error) {
|
||||
normalizeImageTargets(&document)
|
||||
if err := Validate(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document.Relations = nil
|
||||
data, err := json.MarshalIndent(document, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
func MarshalLists(lists ListDocument) ([]byte, error) {
|
||||
document := Document{Version: lists.Version, Relations: lists.Relations}
|
||||
if err := Validate(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.MarshalIndent(lists, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
func Validate(document Document) error {
|
||||
normalizeImageTargets(&document)
|
||||
problems := make([]string, 0)
|
||||
validateVersions := func(prefix string, versions []string) {
|
||||
for _, version := range versions {
|
||||
if !ValidPVFVersion(version) {
|
||||
problems = append(problems, fmt.Sprintf("%s.pvfVersions 包含无效版本 %q,只允许 90US、90CN、110US、115US、115CN、100CN、TW", prefix, version))
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, rule := range document.Rules {
|
||||
validateVersions(fmt.Sprintf("rules[%d]", i), rule.PVFVersions)
|
||||
}
|
||||
for i, field := range document.Fields {
|
||||
validateVersions(fmt.Sprintf("fields[%d]", i), field.PVFVersions)
|
||||
}
|
||||
|
||||
if document.Version != 1 {
|
||||
problems = append(problems, fmt.Sprintf("version 必须为 1,当前为 %d", document.Version))
|
||||
}
|
||||
|
||||
relationNames := make([]string, 0, len(document.Relations))
|
||||
for name := range document.Relations {
|
||||
relationNames = append(relationNames, name)
|
||||
}
|
||||
sort.Strings(relationNames)
|
||||
for _, name := range relationNames {
|
||||
relation := document.Relations[name]
|
||||
prefix := fmt.Sprintf("relations.%s", name)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
problems = append(problems, "relation 名称不能为空")
|
||||
}
|
||||
kind := relation.Kind
|
||||
if kind == "" {
|
||||
kind = "list"
|
||||
}
|
||||
if kind != "list" && kind != "contextual" && kind != "union" {
|
||||
problems = append(problems, prefix+".kind 必须是 list、contextual 或 union")
|
||||
}
|
||||
if kind == "union" {
|
||||
if len(relation.Relations) == 0 {
|
||||
problems = append(problems, prefix+".relations 不能为空")
|
||||
}
|
||||
for _, member := range relation.Relations {
|
||||
if strings.TrimSpace(member) == "" || member == name {
|
||||
problems = append(problems, prefix+".relations 包含无效关联类型")
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if kind == "list" && strings.TrimSpace(relation.ListPath) == "" {
|
||||
problems = append(problems, prefix+".listPath 不能为空")
|
||||
}
|
||||
if relation.IDToken < 0 || relation.PathToken < 0 || relation.ContextToken < 0 {
|
||||
problems = append(problems, prefix+" 的 token 下标不能为负数")
|
||||
}
|
||||
if kind == "contextual" {
|
||||
if len(relation.ContextPaths) == 0 {
|
||||
problems = append(problems, prefix+".contextPaths 不能为空")
|
||||
}
|
||||
for context, listPath := range relation.ContextPaths {
|
||||
if strings.TrimSpace(context) == "" || strings.TrimSpace(listPath) == "" {
|
||||
problems = append(problems, prefix+".contextPaths 的键和值不能为空")
|
||||
break
|
||||
}
|
||||
}
|
||||
if relation.RecordTokens <= relation.IDToken || relation.RecordTokens <= relation.PathToken {
|
||||
problems = append(problems, prefix+".recordTokens 必须覆盖 idToken 和 pathToken")
|
||||
}
|
||||
}
|
||||
recordTokens := relation.RecordTokens
|
||||
if recordTokens == 0 {
|
||||
recordTokens = max(relation.IDToken, relation.PathToken) + 1
|
||||
}
|
||||
if recordTokens <= relation.IDToken || recordTokens <= relation.PathToken {
|
||||
problems = append(problems, prefix+".recordTokens 必须覆盖 idToken 和 pathToken")
|
||||
}
|
||||
if strings.TrimSpace(relation.NameSection) == "" {
|
||||
problems = append(problems, prefix+".nameSection 不能为空")
|
||||
}
|
||||
}
|
||||
unionState := make(map[string]uint8)
|
||||
var visitUnion func(string)
|
||||
visitUnion = func(name string) {
|
||||
if unionState[name] == 2 {
|
||||
return
|
||||
}
|
||||
if unionState[name] == 1 {
|
||||
problems = append(problems, "relations."+name+" 存在循环引用")
|
||||
return
|
||||
}
|
||||
relation, ok := document.Relations[name]
|
||||
if !ok || relation.Kind != "union" {
|
||||
unionState[name] = 2
|
||||
return
|
||||
}
|
||||
unionState[name] = 1
|
||||
for _, member := range relation.Relations {
|
||||
if _, ok := document.Relations[member]; !ok {
|
||||
problems = append(problems, fmt.Sprintf("relations.%s 引用了不存在的 relation: %s", name, member))
|
||||
continue
|
||||
}
|
||||
visitUnion(member)
|
||||
}
|
||||
unionState[name] = 2
|
||||
}
|
||||
for _, name := range relationNames {
|
||||
visitUnion(name)
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]bool)
|
||||
for i, originalRule := range document.Rules {
|
||||
rule := originalRule
|
||||
if strings.TrimSpace(originalRule.Field) != "" {
|
||||
resolved, err := resolveFieldRule(originalRule, document.Fields)
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Sprintf("rules[%d]: %s", i, err))
|
||||
continue
|
||||
}
|
||||
rule = resolved
|
||||
}
|
||||
prefix := fmt.Sprintf("rules[%d]", i)
|
||||
if strings.TrimSpace(rule.ID) == "" {
|
||||
problems = append(problems, prefix+".id 不能为空")
|
||||
} else if seenIDs[rule.ID] {
|
||||
problems = append(problems, prefix+".id 重复: "+rule.ID)
|
||||
}
|
||||
seenIDs[rule.ID] = true
|
||||
|
||||
for _, extension := range rule.Match.Extensions {
|
||||
if extension == "" || !strings.HasPrefix(extension, ".") || strings.ContainsAny(extension, "/\\") {
|
||||
problems = append(problems, prefix+".match.extensions 必须是以点开头的文件后缀")
|
||||
break
|
||||
}
|
||||
}
|
||||
if rule.Match.Glob != "" {
|
||||
if err := validateGlob(rule.Match.Glob); err != nil {
|
||||
problems = append(problems, prefix+".match.glob 无效: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
switch rule.Target.Kind {
|
||||
case "path":
|
||||
if rule.Target.Offset != 0 || rule.Target.GroupOffset != 0 || len(rule.Target.StandaloneValues) > 0 || rule.Target.TokensPerLineIndex != nil {
|
||||
problems = append(problems, prefix+" 的 offset/tokensPerLineIndex 只允许用于 token 标注")
|
||||
}
|
||||
if rule.Annotation.Type != "text" {
|
||||
problems = append(problems, prefix+" 的 path 标注只支持 text 类型")
|
||||
}
|
||||
case "section":
|
||||
if rule.Target.Offset != 0 || rule.Target.GroupOffset != 0 || len(rule.Target.StandaloneValues) > 0 || rule.Target.TokensPerLineIndex != nil {
|
||||
problems = append(problems, prefix+" 的 offset/tokensPerLineIndex 只允许用于 token 标注")
|
||||
}
|
||||
if strings.TrimSpace(rule.Target.Section) == "" {
|
||||
problems = append(problems, prefix+".target.section 不能为空")
|
||||
}
|
||||
if rule.Annotation.Type != "text" {
|
||||
problems = append(problems, prefix+" 的 section 标注只支持 text 类型")
|
||||
}
|
||||
case "token":
|
||||
if rule.Annotation.Type != "image" && rule.Target.ImagePathToken != nil {
|
||||
problems = append(problems, prefix+".target.imagePathToken 只允许用于 image 标注")
|
||||
}
|
||||
if strings.TrimSpace(rule.Target.Section) == "" {
|
||||
problems = append(problems, prefix+".target.section 不能为空")
|
||||
}
|
||||
if (rule.Target.Index == nil) == (rule.Target.Range == nil) {
|
||||
problems = append(problems, prefix+" 的 token 目标必须且只能配置 index 或 range")
|
||||
}
|
||||
if rule.Target.Index != nil && *rule.Target.Index < 0 {
|
||||
problems = append(problems, prefix+".target.index 不能为负数")
|
||||
}
|
||||
if rule.Target.Offset < 0 {
|
||||
problems = append(problems, prefix+".target.offset 不能为负数")
|
||||
}
|
||||
if rule.Target.GroupOffset < 0 {
|
||||
problems = append(problems, prefix+".target.groupOffset 不能为负数")
|
||||
}
|
||||
if rule.Target.TokensPerLineIndex != nil && *rule.Target.TokensPerLineIndex < 0 {
|
||||
problems = append(problems, prefix+".target.tokensPerLineIndex 不能为负数")
|
||||
}
|
||||
if rule.Target.RecordTokens < 0 {
|
||||
problems = append(problems, prefix+".target.recordTokens 不能为负数")
|
||||
}
|
||||
if rule.Target.RecordTokens == 0 && rule.Target.ContextIndex != nil {
|
||||
problems = append(problems, prefix+".target.contextIndex 需要同时配置 recordTokens")
|
||||
}
|
||||
if rule.Target.RecordTokens > 0 {
|
||||
if rule.Target.Index == nil {
|
||||
problems = append(problems, prefix+".target.recordTokens 需要配合单个 index 使用")
|
||||
} else if *rule.Target.Index >= rule.Target.RecordTokens {
|
||||
problems = append(problems, prefix+".target.index 必须小于 recordTokens")
|
||||
}
|
||||
if rule.Target.ContextIndex != nil && (*rule.Target.ContextIndex < 0 || *rule.Target.ContextIndex >= rule.Target.RecordTokens) {
|
||||
problems = append(problems, prefix+".target.contextIndex 必须位于 recordTokens 范围内")
|
||||
}
|
||||
}
|
||||
if rule.Target.Offset != 0 || rule.Target.GroupOffset != 0 || len(rule.Target.StandaloneValues) > 0 || rule.Target.TokensPerLineIndex != nil {
|
||||
if rule.Target.RecordTokens <= 0 {
|
||||
problems = append(problems, prefix+".target.offset/tokensPerLineIndex 需要配置正数 recordTokens 作为回退值")
|
||||
}
|
||||
if rule.Target.Index == nil || rule.Target.Range != nil {
|
||||
problems = append(problems, prefix+".target.offset/tokensPerLineIndex 需要配合单个 index 使用")
|
||||
}
|
||||
}
|
||||
if rule.Target.Range != nil {
|
||||
if rule.Target.Range.Start < 0 || rule.Target.Range.EndExclusive <= rule.Target.Range.Start {
|
||||
problems = append(problems, prefix+".target.range 必须满足 0 <= start < endExclusive")
|
||||
}
|
||||
if rule.Annotation.Type != "text" {
|
||||
problems = append(problems, prefix+" 的 token 范围只支持 text 类型")
|
||||
}
|
||||
}
|
||||
if rule.Annotation.Type == "image" {
|
||||
if rule.Target.Index == nil || rule.Target.Range != nil {
|
||||
problems = append(problems, prefix+" 的 image 标注必须使用单个路径 token")
|
||||
}
|
||||
if rule.Target.RecordTokens <= 0 {
|
||||
problems = append(problems, prefix+" 的 image 标注必须配置正数 target.recordTokens")
|
||||
}
|
||||
if rule.Target.ImagePathToken == nil {
|
||||
problems = append(problems, prefix+" 的 image 标注必须配置 target.imagePathToken")
|
||||
} else if rule.Target.RecordTokens > 0 && (*rule.Target.ImagePathToken < 0 || *rule.Target.ImagePathToken >= rule.Target.RecordTokens) {
|
||||
problems = append(problems, prefix+".target.imagePathToken 必须位于 recordTokens 范围内")
|
||||
}
|
||||
if rule.Target.Index != nil && rule.Target.ImagePathToken != nil && *rule.Target.Index == *rule.Target.ImagePathToken {
|
||||
problems = append(problems, prefix+" 的 imagePathToken 不能与图片索引 token 相同")
|
||||
}
|
||||
if rule.Target.ContextIndex != nil {
|
||||
problems = append(problems, prefix+" 的 image 标注不支持 contextIndex")
|
||||
}
|
||||
}
|
||||
default:
|
||||
problems = append(problems, prefix+".target.kind 必须是 path、section 或 token")
|
||||
}
|
||||
|
||||
switch rule.Annotation.Type {
|
||||
case "text":
|
||||
case "image":
|
||||
case "path":
|
||||
if root := strings.TrimSpace(strings.ReplaceAll(rule.Annotation.PathRoot, "\\", "/")); root != "" && (strings.HasPrefix(root, "/") || root == ".." || strings.HasPrefix(root, "../") || strings.Contains(root, "/../")) {
|
||||
problems = append(problems, prefix+".annotation.pathRoot 必须是归档内的相对目录")
|
||||
}
|
||||
case "enum":
|
||||
if len(rule.Annotation.Values) == 0 {
|
||||
problems = append(problems, prefix+".annotation.values 不能为空")
|
||||
}
|
||||
case "reference":
|
||||
relation, ok := document.Relations[rule.Annotation.Relation]
|
||||
if !ok {
|
||||
problems = append(problems, prefix+" 引用了不存在的 relation: "+rule.Annotation.Relation)
|
||||
} else if relation.Kind == "contextual" {
|
||||
if rule.Target.RecordTokens == 0 {
|
||||
problems = append(problems, prefix+" 的 contextual relation 需要配置 target.recordTokens")
|
||||
}
|
||||
contextIndex := relation.ContextToken
|
||||
if rule.Target.ContextIndex != nil {
|
||||
contextIndex = *rule.Target.ContextIndex
|
||||
}
|
||||
if rule.Target.RecordTokens > 0 && (contextIndex < 0 || contextIndex >= rule.Target.RecordTokens) {
|
||||
problems = append(problems, prefix+" 的 contextual relation 上下文 token 下标必须位于 target.recordTokens 范围内")
|
||||
}
|
||||
}
|
||||
default:
|
||||
problems = append(problems, prefix+".annotation.type 必须是 text、image、enum、reference 或 path")
|
||||
}
|
||||
if rule.Annotation.Type != "path" && rule.Annotation.PathRoot != "" {
|
||||
problems = append(problems, prefix+".annotation.pathRoot 只允许用于 path 标注")
|
||||
}
|
||||
if rule.Annotation.InlineImage && rule.Annotation.Type != "image" {
|
||||
problems = append(problems, prefix+".annotation.inlineImage 只允许用于 image 标注")
|
||||
}
|
||||
}
|
||||
|
||||
seenFieldIDs := make(map[string]bool, len(document.Fields))
|
||||
for i, field := range document.Fields {
|
||||
prefix := fmt.Sprintf("fields[%d]", i)
|
||||
if strings.TrimSpace(field.ID) == "" {
|
||||
problems = append(problems, prefix+".id 不能为空")
|
||||
} else if seenFieldIDs[field.ID] {
|
||||
problems = append(problems, prefix+".id 重复: "+field.ID)
|
||||
}
|
||||
seenFieldIDs[field.ID] = true
|
||||
// Reuse the mature rule validator for the shared target and annotation
|
||||
// schema. Fields are not rules at runtime, so this temporary document
|
||||
// cannot recurse into the fields collection.
|
||||
if err := Validate(Document{
|
||||
Version: 1, Relations: document.Relations,
|
||||
Rules: []Rule{{ID: field.ID, Match: field.Match, Target: field.Target, Annotation: field.Annotation}},
|
||||
}); err != nil {
|
||||
problems = append(problems, prefix+": "+err.Error())
|
||||
}
|
||||
if field.Preview != nil {
|
||||
if len(field.Preview.providerNames()) == 0 {
|
||||
problems = append(problems, prefix+".preview.provider 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(field.Preview.Role) == "" {
|
||||
problems = append(problems, prefix+".preview.role 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(field.Preview.Group) == "" {
|
||||
problems = append(problems, prefix+".preview.group 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(field.Preview.Format) == "" {
|
||||
problems = append(problems, prefix+".preview.format 不能为空")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("标注规则校验失败:\n- %s", strings.Join(problems, "\n- "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeImageTargets migrates the original image rule shape. Originally
|
||||
// target.index was the IMG path token and target.imageIndexToken was the
|
||||
// numeric image index token. Image annotations now anchor on the numeric
|
||||
// index and use imagePathToken for the associated IMG path token.
|
||||
func normalizeImageTargets(document *Document) {
|
||||
if document == nil {
|
||||
return
|
||||
}
|
||||
document.Rules = append([]Rule(nil), document.Rules...)
|
||||
for i := range document.Rules {
|
||||
normalizeImageTarget(&document.Rules[i].Target, document.Rules[i].Annotation.Type)
|
||||
}
|
||||
document.Fields = append([]FieldDefinition(nil), document.Fields...)
|
||||
for i := range document.Fields {
|
||||
normalizeImageTarget(&document.Fields[i].Target, document.Fields[i].Annotation.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeImageTarget(target *TargetSpec, annotationType string) {
|
||||
if target == nil || annotationType != "image" || target.ImageIndexToken == nil {
|
||||
return
|
||||
}
|
||||
if target.ImagePathToken == nil {
|
||||
pathToken := target.Index
|
||||
target.Index = target.ImageIndexToken
|
||||
target.ImagePathToken = pathToken
|
||||
}
|
||||
target.ImageIndexToken = nil
|
||||
}
|
||||
|
||||
func validateGlob(pattern string) error {
|
||||
for _, segment := range strings.Split(normalizePath(pattern), "/") {
|
||||
if segment == "**" {
|
||||
continue
|
||||
}
|
||||
if _, err := path.Match(segment, segment); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
appconfig "pvfine/config"
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
func TestParseRejectsUnsupportedVersionAndUnknownFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "version",
|
||||
json: `{"version":2,"relations":{},"rules":[]}`,
|
||||
want: "version 必须为 1",
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
json: `{"version":1,"relations":{},"rules":[],"extra":true}`,
|
||||
want: "unknown field",
|
||||
},
|
||||
{
|
||||
name: "multiple documents",
|
||||
json: `{"version":1,"relations":{},"rules":[]} {}`,
|
||||
want: "只能包含一个 JSON 文档",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := Parse([]byte(test.json))
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidRuleFields(t *testing.T) {
|
||||
index := -1
|
||||
document := Document{
|
||||
Version: 1,
|
||||
Relations: map[string]RelationSpec{},
|
||||
Rules: []Rule{{
|
||||
ID: "invalid",
|
||||
Match: MatchSpec{Extensions: []string{"equ"}},
|
||||
Target: TargetSpec{Kind: "token", Section: "rarity", Index: &index},
|
||||
Annotation: AnnotationSpec{Title: "", Type: "reference", Relation: "missing"},
|
||||
}},
|
||||
}
|
||||
err := Validate(document)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
for _, want := range []string{"以点开头", "不能为负数", "不存在的 relation"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenGroupingFields(t *testing.T) {
|
||||
dynamicIndex := 0
|
||||
valid := Rule{
|
||||
ID: "records",
|
||||
Target: TargetSpec{
|
||||
Kind: "token", Section: "records", Index: intPtr(1),
|
||||
Offset: 1, RecordTokens: 3, TokensPerLineIndex: &dynamicIndex,
|
||||
},
|
||||
Annotation: AnnotationSpec{Title: "记录", Type: "text"},
|
||||
}
|
||||
if err := Validate(Document{Version: 1, Rules: []Rule{valid}}); err != nil {
|
||||
t.Fatalf("valid grouping target rejected: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*Rule)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "negative offset",
|
||||
edit: func(rule *Rule) { rule.Target.Offset = -1 },
|
||||
want: "offset 不能为负数",
|
||||
},
|
||||
{
|
||||
name: "negative group offset",
|
||||
edit: func(rule *Rule) { rule.Target.GroupOffset = -1 },
|
||||
want: "groupOffset 不能为负数",
|
||||
},
|
||||
{
|
||||
name: "negative dynamic index",
|
||||
edit: func(rule *Rule) {
|
||||
index := -1
|
||||
rule.Target.TokensPerLineIndex = &index
|
||||
},
|
||||
want: "tokensPerLineIndex 不能为负数",
|
||||
},
|
||||
{
|
||||
name: "standalone values need record width",
|
||||
edit: func(rule *Rule) {
|
||||
rule.Target.Offset = 0
|
||||
rule.Target.TokensPerLineIndex = nil
|
||||
rule.Target.RecordTokens = 0
|
||||
rule.Target.StandaloneValues = []int32{-1}
|
||||
},
|
||||
want: "需要配置正数 recordTokens",
|
||||
},
|
||||
{
|
||||
name: "missing fallback",
|
||||
edit: func(rule *Rule) { rule.Target.RecordTokens = 0 },
|
||||
want: "需要配置正数 recordTokens",
|
||||
},
|
||||
{
|
||||
name: "range target",
|
||||
edit: func(rule *Rule) {
|
||||
rule.Target.Index = nil
|
||||
rule.Target.Range = &TokenRange{Start: 0, EndExclusive: 1}
|
||||
},
|
||||
want: "需要配合单个 index 使用",
|
||||
},
|
||||
{
|
||||
name: "non-token target",
|
||||
edit: func(rule *Rule) {
|
||||
rule.Target.Kind = "section"
|
||||
rule.Target.Section = "records"
|
||||
},
|
||||
want: "只允许用于 token 标注",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rule := valid
|
||||
test.edit(&rule)
|
||||
err := Validate(Document{Version: 1, Rules: []Rule{rule}})
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleGroupRoundTrip(t *testing.T) {
|
||||
for _, group := range []string{"", "装备"} {
|
||||
document := Document{Version: 1, Rules: []Rule{{
|
||||
ID: "name", Group: group, Target: TargetSpec{Kind: "section", Section: "name"},
|
||||
Annotation: AnnotationSpec{Title: "名称", Type: "text"},
|
||||
}}}
|
||||
data, err := Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Rules[0].Group != group {
|
||||
t.Fatalf("group = %q", parsed.Rules[0].Group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInlineImageRequiresImageAnnotation(t *testing.T) {
|
||||
document := Document{Version: 1, Rules: []Rule{{
|
||||
ID: "text",
|
||||
Target: TargetSpec{Kind: "section", Section: "name"},
|
||||
Annotation: AnnotationSpec{Title: "名称", Type: "text", InlineImage: true},
|
||||
}}}
|
||||
err := Validate(document)
|
||||
if err == nil || !strings.Contains(err.Error(), "inlineImage 只允许用于 image 标注") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMigratesLegacyImageTokenOrder(t *testing.T) {
|
||||
data := []byte(`{"version":1,"rules":[{"id":"icon","target":{"kind":"token","section":"icon","index":0,"recordTokens":2,"imageIndexToken":1},"annotation":{"title":"图标","type":"image"}}]}`)
|
||||
document, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := document.Rules[0].Target
|
||||
if target.Index == nil || *target.Index != 1 || target.ImagePathToken == nil || *target.ImagePathToken != 0 || target.ImageIndexToken != nil {
|
||||
t.Fatalf("migrated target = %#v", target)
|
||||
}
|
||||
formatted, err := MarshalRules(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(formatted), "imagePathToken") || strings.Contains(string(formatted), "imageIndexToken") {
|
||||
t.Fatalf("formatted target = %s", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultIncludesContextualSkillRelation(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relation, ok := engine.Relation("技能")
|
||||
if !ok || relation.Kind != "contextual" || relation.ContextPaths["[fighter]"] == "" {
|
||||
t.Fatalf("skill relation = %#v, found=%v", relation, ok)
|
||||
}
|
||||
union, ok := engine.Relation("物品")
|
||||
if !ok || union.Kind != "union" || len(union.Relations) != 2 {
|
||||
t.Fatalf("item union relation = %#v, found=%v", union, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalRulesSeparatesRelations(t *testing.T) {
|
||||
document := Document{
|
||||
Version: 1,
|
||||
Relations: map[string]RelationSpec{
|
||||
"equipment": {ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1, RecordTokens: 2, NameSection: "name"},
|
||||
},
|
||||
Rules: []Rule{{
|
||||
ID: "name", Target: TargetSpec{Kind: "section", Section: "name"},
|
||||
Annotation: AnnotationSpec{Title: "名称", Type: "text"},
|
||||
}},
|
||||
}
|
||||
data, err := MarshalRules(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "relations") {
|
||||
t.Fatalf("rules JSON contains relations: %s", data)
|
||||
}
|
||||
parsed, err := ParseRules(data)
|
||||
if err != nil || parsed.Relations != nil {
|
||||
t.Fatalf("parsed rules = %#v, err = %v", parsed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLists(t *testing.T) {
|
||||
lists, err := ParseLists([]byte(`{"version":1,"relations":{"equipment":{"listPath":"equipment/equipment.lst","idToken":0,"pathToken":1,"recordTokens":2,"nameSection":"name"},"物品":{"kind":"union","relations":["装备","道具"]},"装备":{"listPath":"equipment/equipment.lst","idToken":0,"pathToken":1,"recordTokens":2,"nameSection":"name"},"道具":{"listPath":"stackable/stackable.lst","idToken":0,"pathToken":1,"recordTokens":2,"nameSection":"name"}}}`))
|
||||
if err != nil || lists.Relations["equipment"].ListPath != "equipment/equipment.lst" || lists.Relations["物品"].Kind != "union" {
|
||||
t.Fatalf("lists = %#v, err = %v", lists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileMergesSiblingLists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "annotations.json")
|
||||
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[{"id":"ref","target":{"kind":"token","section":"related","index":0},"annotation":{"title":"关联","type":"reference","relation":"equipment"}}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "lists.json"), []byte(`{"version":1,"relations":{"equipment":{"listPath":"equipment/equipment.lst","idToken":0,"pathToken":1,"recordTokens":2,"nameSection":"name"}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := LoadFile(rulesPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := engine.Relation("equipment"); !ok {
|
||||
t.Fatal("sibling lists.json was not merged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultMergesPathNotes(t *testing.T) {
|
||||
paths, err := ParseRules(appconfig.PathsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(paths.Rules) == 0 {
|
||||
t.Fatal("config/paths.json has no rules")
|
||||
}
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged := make(map[string]bool)
|
||||
for _, rule := range engine.Document().Rules {
|
||||
if rule.Target.Kind == "path" {
|
||||
merged[rule.ID] = true
|
||||
}
|
||||
}
|
||||
for _, rule := range paths.Rules {
|
||||
if !merged[rule.ID] {
|
||||
t.Fatalf("path rule %s was not merged", rule.ID)
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
path string
|
||||
isDir bool
|
||||
title string
|
||||
}{
|
||||
{"equipment/equipment.lst", false, "装备列表"},
|
||||
{"equipment", true, "装备"},
|
||||
{"list/npc.lst", false, "NPC列表"},
|
||||
{"string", true, "字符串表目录"},
|
||||
{"etc", true, "杂项文件"},
|
||||
{"etc/advancealtar", true, "守护者祭坛"},
|
||||
// A generated directory note describes that directory only: paths without
|
||||
// a rule of their own stay unannotated instead of inheriting an ancestor.
|
||||
{"etc/100lvfarming", true, ""},
|
||||
{"etc/100lvfarming/anything.dat", false, ""},
|
||||
{"etc/advancealtar/unknown.dat", false, ""},
|
||||
// The hand-maintained rule in annotations.json keeps the primary title.
|
||||
{"etc/itemdropinfo_monster_hell.etc", false, "深渊掉落"},
|
||||
{"unknown/path.dat", false, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
results := engine.AnnotatePath(tc.path, tc.isDir)
|
||||
title := ""
|
||||
if len(results) > 0 {
|
||||
title = results[0].Title
|
||||
}
|
||||
if title != tc.title {
|
||||
t.Fatalf("%s: title = %q, want %q", tc.path, title, tc.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFilePrefersSiblingPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "annotations.json")
|
||||
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "paths.json"), []byte(`{"version":1,"rules":[{"id":"path.sibling","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"本地目录说明","type":"text"},"group":"path"}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sections.json"), []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := LoadFile(rulesPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := engine.AnnotatePath("equipment/equipment.lst", false)
|
||||
if len(results) != 1 || results[0].Title != "本地目录说明" {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if len(engine.Document().Rules) != 1 {
|
||||
t.Fatalf("sibling paths.json should replace the built-in path notes, got %d rules", len(engine.Document().Rules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileFallsBackToBuiltinGeneratedRules(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "annotations.json")
|
||||
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := LoadFile(rulesPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if results := engine.AnnotatePath("equipment/equipment.lst", false); len(results) != 1 || results[0].Title != "装备列表" {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if results := engine.Annotate("a.act", pvf.ParseScriptView("[accelerate]\n100 5"), nil); len(results) != 1 || results[0].Title != "加速参数" {
|
||||
t.Fatalf("section results = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultMergesSectionNotes(t *testing.T) {
|
||||
sections, err := ParseRules(appconfig.SectionsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sections.Rules) == 0 {
|
||||
t.Fatal("config/sections.json has no rules")
|
||||
}
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged := make(map[string]bool)
|
||||
for _, rule := range engine.Document().Rules {
|
||||
merged[rule.ID] = true
|
||||
}
|
||||
for _, rule := range sections.Rules {
|
||||
if !merged[rule.ID] {
|
||||
t.Fatalf("section rule %s was not merged", rule.ID)
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
file string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
// pvfUtility PvfComments notes reach the section tag.
|
||||
{"a.act", "[accelerate]\n100 5", "加速参数"},
|
||||
// The rule is extension scoped, so another extension stays untouched.
|
||||
{"a.equ", "[accelerate]\n100 5", ""},
|
||||
// A hand-maintained rule keeps the primary title for its section.
|
||||
{"a.act", "[attack]\n1", "攻击"},
|
||||
{"a.act", "[unknown section]\n1", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
results := engine.Annotate(tc.file, pvf.ParseScriptView(tc.text), nil)
|
||||
title := ""
|
||||
if len(results) > 0 {
|
||||
title = results[0].Title
|
||||
}
|
||||
if title != tc.want {
|
||||
t.Fatalf("%s %q: title = %q, want %q", tc.file, tc.text, title, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDefaultExplainFieldFeedsPreviewWithoutDuplicateTag checks that the
|
||||
// shared [explain] preview field neither adds a second section tag nor stays
|
||||
// invisible to the equipment preview.
|
||||
func TestLoadDefaultExplainFieldFeedsPreviewWithoutDuplicateTag(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view := pvf.ParseScriptView("[explain]\n`主要说明`")
|
||||
results := engine.Annotate("equipment/test.equ", view, nil)
|
||||
if len(results) != 1 || results[0].Title != "装备说明" {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
found := false
|
||||
for _, field := range engine.PreviewFields("equipment/test.equ", "equ") {
|
||||
if field.ID == "equ.explain" && field.Preview != nil && field.Preview.Role == "base-explain" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("equ.explain preview field is missing")
|
||||
}
|
||||
if values := engine.ExtractPreviewFields("equipment/test.equ", view, "equ"); len(values) == 0 {
|
||||
t.Fatal("[explain] produced no preview value")
|
||||
}
|
||||
}
|
||||
|
||||
// handWrittenEngine compiles config/annotations.json with its relations but
|
||||
// without the generated path and section rule sets, for tests that assert the
|
||||
// behaviour of the curated rules alone.
|
||||
func handWrittenEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
document, err := ParseRules(appconfig.AnnotationsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lists, err := ParseLists(appconfig.ListsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document.Relations = lists.Relations
|
||||
engine, err := Compile(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestMergeGeneratedRulesSkipsExistingIDs(t *testing.T) {
|
||||
document := Document{Version: 1, Rules: []Rule{{
|
||||
ID: "path.keep", Match: MatchSpec{Glob: "equipment/**"}, Target: TargetSpec{Kind: "path"},
|
||||
Annotation: AnnotationSpec{Title: "保留", Type: "text"},
|
||||
}}}
|
||||
data := []byte(`{"version":1,"rules":[
|
||||
{"id":"path.keep","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"重复","type":"text"}},
|
||||
{"id":"path.new","match":{"glob":"stackable/**"},"target":{"kind":"path"},"annotation":{"title":"新增","type":"text"}}
|
||||
]}`)
|
||||
if err := mergeGeneratedRules(&document, data, "path 标注"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(document.Rules) != 2 || document.Rules[0].Annotation.Title != "保留" || document.Rules[1].ID != "path.new" {
|
||||
t.Fatalf("rules = %#v", document.Rules)
|
||||
}
|
||||
if err := mergeGeneratedRules(&document, data, "path 标注"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(document.Rules) != 2 {
|
||||
t.Fatalf("merging twice duplicated rules: %#v", document.Rules)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package annotations
|
||||
|
||||
import "strings"
|
||||
|
||||
type Document struct {
|
||||
Version int `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Relations map[string]RelationSpec `json:"relations,omitempty"`
|
||||
Fields []FieldDefinition `json:"fields,omitempty"`
|
||||
Rules []Rule `json:"rules"`
|
||||
}
|
||||
|
||||
type ListDocument struct {
|
||||
Version int `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Relations map[string]RelationSpec `json:"relations"`
|
||||
}
|
||||
|
||||
type RelationSpec struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
ListPath string `json:"listPath"`
|
||||
IDToken int `json:"idToken"`
|
||||
PathToken int `json:"pathToken"`
|
||||
RecordTokens int `json:"recordTokens,omitempty"`
|
||||
NameSection string `json:"nameSection"`
|
||||
ContextToken int `json:"contextToken,omitempty"`
|
||||
ContextPaths map[string]string `json:"contextPaths,omitempty"`
|
||||
Relations []string `json:"relations,omitempty"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
PVFVersions []string `json:"pvfVersions,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Match MatchSpec `json:"match"`
|
||||
Target TargetSpec `json:"target"`
|
||||
Annotation AnnotationSpec `json:"annotation"`
|
||||
Group string `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
// FieldDefinition is a reusable field description shared by annotation rules
|
||||
// and structured previews. A field may optionally expose a PreviewSpec; fields
|
||||
// without it are still useful to annotations and remain out of previews.
|
||||
type FieldDefinition struct {
|
||||
PVFVersions []string `json:"pvfVersions,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Match MatchSpec `json:"match"`
|
||||
Target TargetSpec `json:"target"`
|
||||
Annotation AnnotationSpec `json:"annotation"`
|
||||
Preview *PreviewSpec `json:"preview,omitempty"`
|
||||
}
|
||||
|
||||
// PreviewSpec describes how a shared field participates in a preview. The
|
||||
// parser deliberately treats Group, Role and Format as opaque strings so new
|
||||
// preview providers can add roles without changing the annotation schema.
|
||||
type PreviewSpec struct {
|
||||
// Provider is the backwards-compatible single-provider form.
|
||||
Provider string `json:"provider,omitempty"`
|
||||
// Providers allows one shared field to feed multiple preview components.
|
||||
Providers []string `json:"providers,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Group string `json:"group"`
|
||||
Order int `json:"order"`
|
||||
Format string `json:"format"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
func (preview *PreviewSpec) providerNames() []string {
|
||||
if preview == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(preview.Providers)+1)
|
||||
if provider := strings.TrimSpace(preview.Provider); provider != "" {
|
||||
result = append(result, provider)
|
||||
}
|
||||
for _, provider := range preview.Providers {
|
||||
provider = strings.TrimSpace(provider)
|
||||
if provider == "" {
|
||||
continue
|
||||
}
|
||||
duplicate := false
|
||||
for _, existing := range result {
|
||||
if strings.EqualFold(existing, provider) {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !duplicate {
|
||||
result = append(result, provider)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type MatchSpec struct {
|
||||
Extensions []string `json:"extensions,omitempty"`
|
||||
Glob string `json:"glob,omitempty"`
|
||||
}
|
||||
|
||||
type TargetSpec struct {
|
||||
Kind string `json:"kind"`
|
||||
Section string `json:"section,omitempty"`
|
||||
Index *int `json:"index,omitempty"`
|
||||
Range *TokenRange `json:"range,omitempty"`
|
||||
// Offset skips the first Offset direct tokens in a section before
|
||||
// repeated records are matched. It mirrors rendering rules where a
|
||||
// section header is kept outside the repeated token groups.
|
||||
Offset int `json:"offset,omitempty"`
|
||||
RecordTokens int `json:"recordTokens,omitempty"`
|
||||
// GroupOffset skips this many tokens at the start of every delimiter-separated group.
|
||||
GroupOffset int `json:"groupOffset,omitempty"`
|
||||
// StandaloneValues are integer delimiters, excluded from repeated records.
|
||||
StandaloneValues []int32 `json:"standaloneValues,omitempty"`
|
||||
// TokensPerLineIndex is a zero-based direct token index whose positive
|
||||
// integer value overrides RecordTokens for that section. RecordTokens is
|
||||
// retained as the fallback when the token is missing or invalid.
|
||||
TokensPerLineIndex *int `json:"tokensPerLineIndex,omitempty"`
|
||||
ContextIndex *int `json:"contextIndex,omitempty"`
|
||||
ImagePathToken *int `json:"imagePathToken,omitempty"`
|
||||
// ImageIndexToken is kept only to migrate rules written by the previous
|
||||
// schema, where target.index pointed to the IMG path and this field pointed
|
||||
// to the numeric image index.
|
||||
ImageIndexToken *int `json:"imageIndexToken,omitempty"`
|
||||
}
|
||||
|
||||
type TokenRange struct {
|
||||
Start int `json:"start"`
|
||||
EndExclusive int `json:"endExclusive"`
|
||||
}
|
||||
|
||||
type AnnotationSpec struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Values map[string]string `json:"values,omitempty"`
|
||||
Relation string `json:"relation,omitempty"`
|
||||
PathRoot string `json:"pathRoot,omitempty"`
|
||||
InlineImage bool `json:"inlineImage,omitempty"`
|
||||
}
|
||||
|
||||
type Reference struct {
|
||||
ID string
|
||||
Name string
|
||||
Path string
|
||||
FileIndex int32
|
||||
}
|
||||
|
||||
type Resolver func(relation, id string) (Reference, bool)
|
||||
|
||||
type Result struct {
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
TargetFileIndex int32 `json:"targetFileIndex"`
|
||||
RuleIDs []string `json:"ruleIds,omitempty"`
|
||||
Image *ImageReference `json:"image,omitempty"`
|
||||
InlineImage bool `json:"inlineImage,omitempty"`
|
||||
}
|
||||
|
||||
type ImageReference struct {
|
||||
Path string `json:"path"`
|
||||
Index int32 `json:"index"`
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
appconfig "pvfine/config"
|
||||
)
|
||||
|
||||
const sourceRelativePath = "config/annotations.json"
|
||||
|
||||
// LoadRulesFile reads and validates a hand-maintained rule file together with
|
||||
// its sibling lists.json, without merging the generated rule sets. The
|
||||
// annotation editor uses it so a save never writes the bulk path or section
|
||||
// rules back into annotations.json.
|
||||
func LoadRulesFile(path string) (Document, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Document{}, fmt.Errorf("读取标注规则失败: %w", err)
|
||||
}
|
||||
document, err := parseDocument(data)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
listsPath := filepath.Join(filepath.Dir(path), "lists.json")
|
||||
if listsData, listsErr := os.ReadFile(listsPath); listsErr == nil {
|
||||
lists, err := ParseLists(listsData)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
document.Relations = lists.Relations
|
||||
} else if !os.IsNotExist(listsErr) {
|
||||
return Document{}, fmt.Errorf("读取列表配置失败: %w", listsErr)
|
||||
} else if document.Relations == nil {
|
||||
lists, err := ParseLists(appconfig.ListsJSON)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
document.Relations = lists.Relations
|
||||
}
|
||||
if err := Validate(document); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
// LoadFile validates and compiles a rule document from disk. A paths.json or
|
||||
// sections.json next to the rule file overrides the built-in generated rules, so
|
||||
// a runtime mirror can ship its own notes.
|
||||
func LoadFile(path string) (*Engine, error) {
|
||||
document, err := LoadRulesFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, generated := range generatedRuleFiles() {
|
||||
data := generated.data
|
||||
sibling := filepath.Join(filepath.Dir(path), generated.name)
|
||||
if override, readErr := os.ReadFile(sibling); readErr == nil {
|
||||
data = override
|
||||
} else if !os.IsNotExist(readErr) {
|
||||
return nil, fmt.Errorf("读取 %s 失败: %w", generated.label, readErr)
|
||||
}
|
||||
if err := mergeGeneratedRules(&document, data, generated.label); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := Validate(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Compile(document)
|
||||
}
|
||||
|
||||
// RuntimePath is the writable rule mirror used by packaged applications.
|
||||
func RuntimePath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取用户配置目录失败: %w", err)
|
||||
}
|
||||
return filepath.Join(configDir, "pvfine", "annotations.json"), nil
|
||||
}
|
||||
|
||||
func RuntimeListsPath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取用户配置目录失败: %w", err)
|
||||
}
|
||||
return filepath.Join(configDir, "pvfine", "lists.json"), nil
|
||||
}
|
||||
|
||||
// FindSourcePath locates the repository rule file for local development.
|
||||
func FindSourcePath() (string, bool) {
|
||||
starts := make([]string, 0, 2)
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
starts = append(starts, cwd)
|
||||
}
|
||||
if executable, err := os.Executable(); err == nil {
|
||||
starts = append(starts, filepath.Dir(executable))
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, start := range starts {
|
||||
for dir := filepath.Clean(start); ; dir = filepath.Dir(dir) {
|
||||
if _, ok := seen[dir]; !ok {
|
||||
seen[dir] = struct{}{}
|
||||
candidate := filepath.Join(dir, filepath.FromSlash(sourceRelativePath))
|
||||
if fileExists(filepath.Join(dir, "go.mod")) && fileExists(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"pvfine/internal/pvf"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const upgradeRuleRow = "2.0 0 0 0 0 0.6 0 0.828 20000 30000 2 3 1 0 3037 50 0.6 0.09 0.5 0.169 0.83 0.684 0 0.7866"
|
||||
|
||||
func TestDefaultUpgradeRulePositions(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct{ path, prefix, kind string }{{"etc/upgrade.etc", "etc.upgrade", "normal"}, {"etc/amplifyupgrade.etc", "etc.amplify", "amplify"}} {
|
||||
text := "[table]\n[table type]\n`" + tc.kind + "` " + upgradeRuleRow + " " + upgradeRuleRow + " 999\n[/table]\n[table]\n[table type]\n`" + tc.kind + "` " + upgradeRuleRow + "\n[/table]\n[safe upgrade]\n0 1 94000 1000 2 10327281 40 124448 1 1 20000 5000 1 10327281 41 124448\n[/safe upgrade]"
|
||||
view := pvf.ParseScriptView(text)
|
||||
results := engine.Annotate(tc.path, view, func(relation, id string) (Reference, bool) {
|
||||
return Reference{ID: id, Name: "材料 " + id, FileIndex: 9}, relation == "物品"
|
||||
})
|
||||
checks := map[string]struct {
|
||||
value, title string
|
||||
count int
|
||||
}{
|
||||
tc.prefix + ".table-type.8": {"20000", "武器失败率", 3},
|
||||
tc.prefix + ".table-type.9": {"30000", "防具失败率", 3},
|
||||
tc.prefix + ".table-type.10": {"2", "降级", 3},
|
||||
tc.prefix + ".table-type.14": {"3037", "材料 3037", 3},
|
||||
tc.prefix + ".table-type.15": {"50", "消耗数量", 3},
|
||||
tc.prefix + ".safe-upgrade.5": {"10327281", "材料 10327281", 2},
|
||||
}
|
||||
for id, check := range checks {
|
||||
count := 0
|
||||
for _, r := range results {
|
||||
for _, ruleID := range r.RuleIDs {
|
||||
if ruleID == id {
|
||||
count++
|
||||
if text[r.Start:r.End] != check.value || r.Title != check.title {
|
||||
t.Fatalf("%s = %+v", id, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != check.count {
|
||||
t.Fatalf("%s matches %d, want %d", id, count, check.count)
|
||||
}
|
||||
}
|
||||
for _, r := range engine.Annotate("etc/unrelated.etc", view, nil) {
|
||||
for _, id := range r.RuleIDs {
|
||||
if strings.HasPrefix(id, "etc.upgrade.") || strings.HasPrefix(id, "etc.amplify.") {
|
||||
t.Fatal("rule leaked to unrelated file")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultForgeAndLegacyRules(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := "[table]\n2 1 10000 1 3 2 5000 1.11 999\n[/table]\n[item weights by grade]\n1 3326 1 2 3326 1 3 3326\n[/item weights by grade]\n[item weights by grade 100lv]\n96 3326 51\n[/item weights by grade 100lv]"
|
||||
results := engine.Annotate("etc/upgrade_separate.etc", pvf.ParseScriptView(text), nil)
|
||||
counts := map[string]int{}
|
||||
for _, r := range results {
|
||||
for _, id := range r.RuleIDs {
|
||||
counts[id]++
|
||||
if id == "etc.forge.table.2" && r.Title != "锻造成功率" {
|
||||
t.Fatal("wrong forge probability meaning")
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, want := range map[string]int{"etc.forge.table.2": 2, "etc.forge.item-weights-by-grade.1": 2, "etc.forge.item-weights-by-grade-100lv.1": 1} {
|
||||
if counts[id] != want {
|
||||
t.Fatalf("%s matches %d, want %d", id, counts[id], want)
|
||||
}
|
||||
}
|
||||
legacy := "[table]\n2 0 0 0 0 0 12500 2 3 3037 10 1 0 0 0 0 0\n[/table]"
|
||||
results = engine.Annotate("etc/upgrade.etc", pvf.ParseScriptView(legacy), nil)
|
||||
found := false
|
||||
for _, r := range results {
|
||||
for _, id := range r.RuleIDs {
|
||||
if id == "etc.upgrade.table.6" {
|
||||
found = true
|
||||
if legacy[r.Start:r.End] != "12500" {
|
||||
t.Fatal("legacy position changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing legacy annotation")
|
||||
}
|
||||
}
|
||||
|
||||
func Test115CNForgeRulePositions(t *testing.T) {
|
||||
engine, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := "[table]\n2 1 2.6 1 10000 1 3 2 3.6 2 5000 1.11\n[/table]"
|
||||
results := engine.ForVersion("115CN").Annotate("etc/upgrade_separate.etc", pvf.ParseScriptView(text), nil)
|
||||
probabilities := 0
|
||||
for _, result := range results {
|
||||
for _, id := range result.RuleIDs {
|
||||
if strings.HasPrefix(id, "etc.forge.table.") {
|
||||
t.Fatal("legacy four-column annotation leaked into 115CN")
|
||||
}
|
||||
if id == "etc.forge.115cn.table.4" {
|
||||
value := text[result.Start:result.End]
|
||||
if value != "10000" && value != "5000" {
|
||||
t.Fatal("probability annotation points at a coefficient")
|
||||
}
|
||||
probabilities++
|
||||
}
|
||||
}
|
||||
}
|
||||
if probabilities != 2 {
|
||||
t.Fatal("six-column probability annotations missing")
|
||||
}
|
||||
for _, result := range engine.ForVersion("115US").Annotate("etc/upgrade_separate.etc", pvf.ParseScriptView(text), nil) {
|
||||
for _, id := range result.RuleIDs {
|
||||
if strings.HasPrefix(id, "etc.forge.115cn.") {
|
||||
t.Fatal("115CN annotations leaked into 115US")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package annotations
|
||||
|
||||
import "strings"
|
||||
|
||||
// ValidPVFVersion reports whether a value is a supported configuration version.
|
||||
func ValidPVFVersion(version string) bool {
|
||||
return version == "90US" || version == "90CN" || version == "110US" || version == "115US" || version == "115CN" || version == "100CN" || version == "TW"
|
||||
}
|
||||
|
||||
func supportsPVFVersion(versions []string, version string) bool {
|
||||
if len(versions) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range versions {
|
||||
if candidate == version {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ForVersion builds an immutable runtime view while retaining the full source
|
||||
// document for serialization and subsequent archive switches. Unknown versions
|
||||
// only enable unrestricted definitions. Filtering precedes implicit field rules.
|
||||
func (e *Engine) ForVersion(version string) *Engine {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
if !ValidPVFVersion(version) {
|
||||
version = ""
|
||||
}
|
||||
if e.scoped && e.version == version {
|
||||
return e
|
||||
}
|
||||
source := e.Document()
|
||||
document := source
|
||||
document.Fields = make([]FieldDefinition, 0, len(source.Fields))
|
||||
activeFields := make(map[string]bool, len(source.Fields))
|
||||
for _, field := range source.Fields {
|
||||
if supportsPVFVersion(field.PVFVersions, version) {
|
||||
document.Fields = append(document.Fields, field)
|
||||
activeFields[field.ID] = true
|
||||
}
|
||||
}
|
||||
document.Rules = make([]Rule, 0, len(source.Rules))
|
||||
for _, rule := range source.Rules {
|
||||
if !supportsPVFVersion(rule.PVFVersions, version) {
|
||||
continue
|
||||
}
|
||||
if id := strings.TrimSpace(rule.Field); id != "" && !activeFields[id] {
|
||||
continue
|
||||
}
|
||||
document.Rules = append(document.Rules, rule)
|
||||
}
|
||||
result := compileValidated(document)
|
||||
result.source, result.version, result.scoped = &source, version, true
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
func TestPVFVersionRulesAndSourceRoundTrip(t *testing.T) {
|
||||
document := Document{Version: 1}
|
||||
for i, versions := range [][]string{nil, {}, {"90US"}, {"90CN", "110US"}, {"115US"}, {"100CN"}, {"TW"}, {"115CN"}} {
|
||||
document.Rules = append(document.Rules, Rule{ID: string(rune('a' + i)), PVFVersions: versions,
|
||||
Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: string(rune('a' + i)), Type: "text"}})
|
||||
}
|
||||
engine, err := Compile(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
version string
|
||||
want int
|
||||
}{{"90US", 3}, {"90CN", 3}, {"110US", 3}, {"115US", 3}, {"115CN", 3}, {"", 2}, {"future", 2}, {"100CN", 3}, {"TW", 3}} {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
runtime := engine.ForVersion(tc.version)
|
||||
if got := len(runtime.AnnotatePath("a.equ", false)[0].RuleIDs); got != tc.want {
|
||||
t.Fatalf("got %d, want %d", got, tc.want)
|
||||
}
|
||||
if !reflect.DeepEqual(runtime.Document(), engine.Document()) {
|
||||
t.Fatal("runtime lost original document")
|
||||
}
|
||||
data, err := Marshal(runtime.Document())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(restored.Rules[3].PVFVersions, []string{"90CN", "110US"}) {
|
||||
t.Fatal("serialization lost versions")
|
||||
}
|
||||
if !reflect.DeepEqual(restored.Rules[4].PVFVersions, []string{"115US"}) {
|
||||
t.Fatal("serialization lost 115US scope")
|
||||
}
|
||||
if got := runtime.ForVersion("115US").AnnotatePath("a.equ", false)[0].RuleIDs; !reflect.DeepEqual(got, []string{"a", "b", "e"}) {
|
||||
t.Fatalf("115US enabled rules from a different version: %v", got)
|
||||
}
|
||||
if got := runtime.ForVersion("100CN").AnnotatePath("a.equ", false)[0].RuleIDs; !reflect.DeepEqual(got, []string{"a", "b", "f"}) {
|
||||
t.Fatalf("100CN enabled rules from a different version: %v", got)
|
||||
}
|
||||
if got := len(runtime.ForVersion("90US").AnnotatePath("a.equ", false)[0].RuleIDs); got != 3 {
|
||||
t.Fatal("switching versions lost rules")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFVersionFieldIntersectionAndImplicitFallback(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
field, rule []string
|
||||
version string
|
||||
want string
|
||||
previews int
|
||||
}{
|
||||
{"intersection", []string{"90US", "90CN"}, []string{"90CN", "110US"}, "90CN", "explicit", 1},
|
||||
{"field excludes rule", []string{"90US", "90CN"}, []string{"90CN", "110US"}, "110US", "", 0},
|
||||
{"rule excludes field falls back", []string{"90US", "90CN"}, []string{"90CN", "110US"}, "90US", "field:shared", 1},
|
||||
{"empty intersection", []string{"90US"}, []string{"110US"}, "110US", "", 0},
|
||||
{"115US intersection", []string{"110US", "115US"}, []string{"115US"}, "115US", "explicit", 1},
|
||||
{"110US field excludes 115US", []string{"110US"}, []string{"115US"}, "115US", "", 0},
|
||||
{"115US field excludes 110US", []string{"115US"}, []string{"110US"}, "110US", "", 0},
|
||||
{"unrestricted rule restricted field", []string{"90CN"}, nil, "90US", "", 0},
|
||||
{"restricted rule unrestricted field", nil, []string{"110US"}, "90CN", "field:shared", 1},
|
||||
{"unknown restricted field", []string{"90US"}, nil, "", "", 0},
|
||||
{"unknown unrestricted field", nil, []string{"90US"}, "", "field:shared", 1},
|
||||
{"all unrestricted", nil, nil, "", "explicit", 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
engine, err := Compile(Document{Version: 1,
|
||||
Fields: []FieldDefinition{{ID: "shared", PVFVersions: tc.field, Target: TargetSpec{Kind: "token", Section: "name", Index: intPtr(0)}, Annotation: AnnotationSpec{Title: "name", Type: "text"}, Preview: &PreviewSpec{Provider: "equ", Role: "name", Group: "header", Format: "text"}}},
|
||||
Rules: []Rule{{ID: "explicit", Field: "shared", PVFVersions: tc.rule}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := engine.ForVersion(tc.version)
|
||||
view := pvf.ParseScriptView("[name]\n`example`")
|
||||
results := runtime.Annotate("a.equ", view, nil)
|
||||
if tc.want == "" {
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("unexpected results: %#v", results)
|
||||
}
|
||||
} else if len(results) != 1 || !reflect.DeepEqual(results[0].RuleIDs, []string{tc.want}) {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if got := len(runtime.ExtractPreviewFields("a.equ", view, "equ")); got != tc.previews {
|
||||
t.Fatalf("preview fields = %d", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFVersionNonReferenceRuleDoesNotSuppressField(t *testing.T) {
|
||||
field := FieldDefinition{ID: "shared", Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "shared", Type: "text"}}
|
||||
engine, err := Compile(Document{Version: 1, Fields: []FieldDefinition{field}, Rules: []Rule{{ID: "explicit", PVFVersions: []string{"110US"}, Target: field.Target, Annotation: field.Annotation}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, version := range []string{"90US", "110US"} {
|
||||
results := engine.ForVersion(version).AnnotatePath("a.equ", false)
|
||||
want := "explicit"
|
||||
if version == "90US" {
|
||||
want = "field:shared"
|
||||
}
|
||||
if len(results) != 1 || !reflect.DeepEqual(results[0].RuleIDs, []string{want}) {
|
||||
t.Fatalf("%s: %#v", version, results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFVersionValidation(t *testing.T) {
|
||||
for _, value := range []string{"90us", "unknown", "", "120US"} {
|
||||
for _, field := range []bool{false, true} {
|
||||
document := Document{Version: 1}
|
||||
if field {
|
||||
document.Fields = []FieldDefinition{{ID: "f", PVFVersions: []string{value}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "f", Type: "text"}}}
|
||||
} else {
|
||||
document.Rules = []Rule{{ID: "r", PVFVersions: []string{value}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "r", Type: "text"}}}
|
||||
}
|
||||
if _, err := Compile(document); err == nil || !strings.Contains(err.Error(), "pvfVersions") {
|
||||
t.Fatalf("invalid version accepted: %q, field=%v, err=%v", value, field, err)
|
||||
}
|
||||
if _, err := Marshal(document); err == nil {
|
||||
t.Fatal("invalid version serialized")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user