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,780 @@
|
||||
// Package ruleconfig manages editable source documents and production overlays.
|
||||
package ruleconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
appconfig "pvfine/config"
|
||||
"pvfine/internal/annotations"
|
||||
"pvfine/internal/rendering"
|
||||
)
|
||||
|
||||
type Document struct {
|
||||
Version int `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Rules []json.RawMessage `json:"rules"`
|
||||
Fields []json.RawMessage `json:"fields,omitempty"`
|
||||
Relations map[string]annotations.RelationSpec `json:"relations,omitempty"`
|
||||
DisabledIDs []string `json:"disabledIds,omitempty"`
|
||||
DisabledFieldIDs []string `json:"disabledFieldIds,omitempty"`
|
||||
// Dependencies retain the provenance of a field's cascading suppression.
|
||||
FieldSuppressions map[string][]string `json:"fieldSuppressions,omitempty"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
JSON string `json:"json"`
|
||||
Source string `json:"source"`
|
||||
Overridden bool `json:"overridden"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Effective bool `json:"effective"`
|
||||
Dependencies []string `json:"dependencies"`
|
||||
}
|
||||
type Snapshot struct {
|
||||
annotationEngine *annotations.Engine
|
||||
renderingEngine *rendering.Engine
|
||||
Revision string `json:"revision"`
|
||||
Development bool `json:"development"`
|
||||
Entries []Entry `json:"entries"`
|
||||
documents map[string]Document
|
||||
base Document
|
||||
merged Document
|
||||
}
|
||||
type Store struct {
|
||||
builtin map[string][]byte
|
||||
mu sync.Mutex
|
||||
Development bool
|
||||
SourceDir string
|
||||
UserDir string
|
||||
// Writer may be replaced in tests to verify persistence failures.
|
||||
Writer func(string, []byte) error
|
||||
}
|
||||
|
||||
func New(development bool, sourceDir, userDir string) *Store {
|
||||
return &Store{Development: development, SourceDir: sourceDir, UserDir: userDir, Writer: atomicWrite}
|
||||
}
|
||||
func decode(data []byte) (Document, error) {
|
||||
var d Document
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&d); err != nil {
|
||||
return d, err
|
||||
}
|
||||
if err := dec.Decode(new(any)); err != io.EOF {
|
||||
return d, fmt.Errorf("只能包含一个 JSON 文档")
|
||||
}
|
||||
if d.Version != 1 {
|
||||
return d, fmt.Errorf("version 必须为 1")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
func identity(raw json.RawMessage) string {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
return r.ID
|
||||
}
|
||||
func fieldRef(raw json.RawMessage) string {
|
||||
if !bytes.Contains(raw, []byte(`"field"`)) {
|
||||
return ""
|
||||
}
|
||||
var r struct {
|
||||
Field string `json:"field"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
return strings.TrimSpace(r.Field)
|
||||
}
|
||||
func contains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func without(xs []string, x string) []string {
|
||||
out := []string{}
|
||||
for _, v := range xs {
|
||||
if v != x {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func upsert(xs []json.RawMessage, raw json.RawMessage) []json.RawMessage {
|
||||
for i, r := range xs {
|
||||
if identity(r) == identity(raw) {
|
||||
xs[i] = raw
|
||||
return xs
|
||||
}
|
||||
}
|
||||
return append(xs, raw)
|
||||
}
|
||||
func remove(xs []json.RawMessage, id string) []json.RawMessage {
|
||||
out := []json.RawMessage{}
|
||||
for _, r := range xs {
|
||||
if identity(r) != id {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func mergeRows(base, user []json.RawMessage, disabled []string) []json.RawMessage {
|
||||
out := []json.RawMessage{}
|
||||
seen := map[string]bool{}
|
||||
for _, r := range base {
|
||||
id := identity(r)
|
||||
seen[id] = true
|
||||
if contains(disabled, id) {
|
||||
continue
|
||||
}
|
||||
for _, u := range user {
|
||||
if identity(u) == id {
|
||||
r = u
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
for _, r := range user {
|
||||
if !seen[identity(r)] && !contains(disabled, identity(r)) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func validateOverlay(d Document, category string) error {
|
||||
for name, rows := range map[string][]json.RawMessage{"rules": d.Rules, "fields": d.Fields} {
|
||||
seen := map[string]bool{}
|
||||
for _, r := range rows {
|
||||
id := identity(r)
|
||||
if strings.TrimSpace(id) == "" || seen[id] {
|
||||
return fmt.Errorf("%s ID 为空或重复: %s", name, id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
if category == "rendering" && (len(d.Fields) > 0 || len(d.Relations) > 0 || len(d.DisabledFieldIDs) > 0 || len(d.FieldSuppressions) > 0) {
|
||||
return fmt.Errorf("渲染配置不支持共享字段或关联列表")
|
||||
}
|
||||
for _, rows := range [][]string{d.DisabledIDs, d.DisabledFieldIDs} {
|
||||
seen := map[string]bool{}
|
||||
for _, id := range rows {
|
||||
if id == "" || seen[id] {
|
||||
return fmt.Errorf("屏蔽 ID 为空或重复")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
for field, ids := range d.FieldSuppressions {
|
||||
if !contains(d.DisabledFieldIDs, field) {
|
||||
return fmt.Errorf("字段依赖屏蔽缺少字段屏蔽: %s", field)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == "" {
|
||||
return fmt.Errorf("依赖规则 ID 不能为空")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Store) read(category string) (*Snapshot, error) {
|
||||
if category != "annotations" && category != "rendering" {
|
||||
return nil, fmt.Errorf("未知规则类别")
|
||||
}
|
||||
if s.Development && s.SourceDir == "" {
|
||||
return nil, fmt.Errorf("开发环境未找到源规则目录")
|
||||
}
|
||||
snap := &Snapshot{Development: s.Development, Entries: []Entry{}, documents: map[string]Document{}}
|
||||
h := sha256.New()
|
||||
sources := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{{category + ".json", appconfig.AnnotationsJSON}}
|
||||
if category == "rendering" {
|
||||
sources[0].data = appconfig.RenderingJSON
|
||||
} else {
|
||||
sources = append(sources, struct {
|
||||
name string
|
||||
data []byte
|
||||
}{"paths.json", appconfig.PathsJSON}, struct {
|
||||
name string
|
||||
data []byte
|
||||
}{"sections.json", appconfig.SectionsJSON})
|
||||
}
|
||||
base := Document{Version: 1, Rules: []json.RawMessage{}}
|
||||
seenRules := map[string]bool{}
|
||||
seenFields := map[string]bool{}
|
||||
for _, src := range sources {
|
||||
data := src.data
|
||||
if override, ok := s.builtin[src.name]; ok {
|
||||
data = override
|
||||
}
|
||||
if s.Development {
|
||||
var err error
|
||||
data, err = os.ReadFile(filepath.Join(s.SourceDir, src.name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 %s: %w", src.name, err)
|
||||
}
|
||||
}
|
||||
h.Write([]byte(src.name))
|
||||
h.Write(data)
|
||||
d, err := decode(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 %s: %w", src.name, err)
|
||||
}
|
||||
snap.documents[src.name] = d
|
||||
for _, r := range d.Rules {
|
||||
id := identity(r)
|
||||
if seenRules[id] {
|
||||
continue
|
||||
}
|
||||
seenRules[id] = true
|
||||
base.Rules = append(base.Rules, r)
|
||||
snap.Entries = append(snap.Entries, Entry{ID: id, Kind: "rule", JSON: string(r), Source: src.name, Dependencies: []string{}})
|
||||
}
|
||||
for _, r := range d.Fields {
|
||||
id := identity(r)
|
||||
if seenFields[id] {
|
||||
return nil, fmt.Errorf("共享字段 ID 重复: %s", id)
|
||||
}
|
||||
seenFields[id] = true
|
||||
base.Fields = append(base.Fields, r)
|
||||
snap.Entries = append(snap.Entries, Entry{ID: id, Kind: "field", JSON: string(r), Source: src.name, Dependencies: []string{}})
|
||||
}
|
||||
}
|
||||
if category == "annotations" {
|
||||
data := appconfig.ListsJSON
|
||||
if override, ok := s.builtin["lists.json"]; ok {
|
||||
data = override
|
||||
}
|
||||
if s.Development {
|
||||
var err error
|
||||
data, err = os.ReadFile(filepath.Join(s.SourceDir, "lists.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
h.Write(data)
|
||||
lists, err := annotations.ParseLists(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base.Relations = lists.Relations
|
||||
}
|
||||
snap.base = base
|
||||
overlayName := category + ".user.json"
|
||||
overlay := Document{Version: 1, Rules: []json.RawMessage{}}
|
||||
if !s.Development {
|
||||
data, err := os.ReadFile(filepath.Join(s.UserDir, overlayName))
|
||||
if err == nil {
|
||||
overlay, err = decode(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("用户规则损坏,请先修复 %s: %w", overlayName, err)
|
||||
}
|
||||
h.Write(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
} else {
|
||||
h.Write([]byte("missing"))
|
||||
}
|
||||
if err := validateOverlay(overlay, category); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap.documents[overlayName] = overlay
|
||||
}
|
||||
merged := base
|
||||
merged.Rules = mergeRows(base.Rules, overlay.Rules, overlay.DisabledIDs)
|
||||
merged.Fields = mergeRows(base.Fields, overlay.Fields, overlay.DisabledFieldIDs)
|
||||
merged.Rules = excludeFieldDependencies(merged.Rules, overlay.DisabledFieldIDs)
|
||||
for _, ids := range overlay.FieldSuppressions {
|
||||
for _, id := range ids {
|
||||
merged.Rules = remove(merged.Rules, id)
|
||||
}
|
||||
}
|
||||
if len(overlay.Relations) > 0 {
|
||||
merged.Relations = map[string]annotations.RelationSpec{}
|
||||
for k, v := range base.Relations {
|
||||
merged.Relations[k] = v
|
||||
}
|
||||
for k, v := range overlay.Relations {
|
||||
merged.Relations[k] = v
|
||||
}
|
||||
}
|
||||
ann, renderer, err := Engines(category, merged)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap.annotationEngine = ann
|
||||
snap.renderingEngine = renderer
|
||||
snap.merged = merged
|
||||
for i := range snap.Entries {
|
||||
e := &snap.Entries[i]
|
||||
rows := overlay.Rules
|
||||
disabled := overlay.DisabledIDs
|
||||
if e.Kind == "field" {
|
||||
rows = overlay.Fields
|
||||
disabled = overlay.DisabledFieldIDs
|
||||
}
|
||||
e.Disabled = contains(disabled, e.ID)
|
||||
if e.Kind == "rule" {
|
||||
for _, ids := range overlay.FieldSuppressions {
|
||||
e.Disabled = e.Disabled || contains(ids, e.ID)
|
||||
}
|
||||
}
|
||||
for _, r := range rows {
|
||||
if identity(r) == e.ID {
|
||||
e.JSON = string(r)
|
||||
e.Overridden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for kind, rows := range map[string][]json.RawMessage{"rule": overlay.Rules, "field": overlay.Fields} {
|
||||
for _, r := range rows {
|
||||
exists := false
|
||||
for _, e := range snap.Entries {
|
||||
if e.Kind == kind && e.ID == identity(r) {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
snap.Entries = append(snap.Entries, Entry{ID: identity(r), Kind: kind, JSON: string(r), Source: overlayName, Dependencies: []string{}})
|
||||
}
|
||||
}
|
||||
}
|
||||
dependencyIDs := map[string][]string{}
|
||||
for _, r := range mergeRows(base.Rules, overlay.Rules, nil) {
|
||||
if field := fieldRef(r); field != "" {
|
||||
dependencyIDs[field] = append(dependencyIDs[field], identity(r))
|
||||
}
|
||||
}
|
||||
for i := range snap.Entries {
|
||||
e := &snap.Entries[i]
|
||||
if e.Kind == "rule" {
|
||||
e.Disabled = contains(overlay.DisabledIDs, e.ID) || contains(overlay.DisabledFieldIDs, fieldRef(json.RawMessage(e.JSON)))
|
||||
for _, ids := range overlay.FieldSuppressions {
|
||||
e.Disabled = e.Disabled || contains(ids, e.ID)
|
||||
}
|
||||
} else {
|
||||
e.Disabled = contains(overlay.DisabledFieldIDs, e.ID)
|
||||
}
|
||||
if e.Kind == "field" {
|
||||
e.Dependencies = append(e.Dependencies, dependencyIDs[e.ID]...)
|
||||
}
|
||||
e.Effective = !e.Disabled
|
||||
}
|
||||
snap.Revision = hex.EncodeToString(h.Sum(nil))
|
||||
return snap, nil
|
||||
}
|
||||
func Compile(category string, d Document) error {
|
||||
_, _, err := Engines(category, d)
|
||||
return err
|
||||
}
|
||||
func Engines(category string, d Document) (*annotations.Engine, *rendering.Engine, error) {
|
||||
if d.Rules == nil {
|
||||
d.Rules = []json.RawMessage{}
|
||||
}
|
||||
data, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if category == "annotations" {
|
||||
doc, err := annotations.Parse(data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
e, err := annotations.Compile(doc)
|
||||
return e, nil, err
|
||||
}
|
||||
e, err := rendering.Parse(data)
|
||||
return nil, e, err
|
||||
}
|
||||
func (s *Store) Load(category string) (*Snapshot, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.read(category)
|
||||
}
|
||||
func (snap *Snapshot) Engines(category string) (*annotations.Engine, *rendering.Engine, error) {
|
||||
if (category == "annotations" && snap.annotationEngine != nil) || (category == "rendering" && snap.renderingEngine != nil) {
|
||||
return snap.annotationEngine, snap.renderingEngine, nil
|
||||
}
|
||||
return Engines(category, snap.merged)
|
||||
}
|
||||
|
||||
type Mutation struct {
|
||||
Category string `json:"category"`
|
||||
Revision string `json:"revision"`
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
JSON string `json:"json"`
|
||||
}
|
||||
|
||||
func (s *Store) Mutate(m Mutation) (*Snapshot, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
snap, err := s.read(m.Category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Revision != snap.Revision {
|
||||
return nil, fmt.Errorf("规则已被外部修改,请重新加载后再保存")
|
||||
}
|
||||
if m.Kind != "rule" && m.Kind != "field" {
|
||||
return nil, fmt.Errorf("未知规则类型")
|
||||
}
|
||||
if m.Category == "rendering" && m.Kind != "rule" {
|
||||
return nil, fmt.Errorf("渲染不支持共享字段")
|
||||
}
|
||||
var entry *Entry
|
||||
for i := range snap.Entries {
|
||||
e := &snap.Entries[i]
|
||||
if e.Kind == m.Kind && e.ID == m.ID {
|
||||
entry = e
|
||||
break
|
||||
}
|
||||
}
|
||||
if m.Action != "save" && entry == nil {
|
||||
return nil, fmt.Errorf("规则不存在")
|
||||
}
|
||||
name := m.Category + ".user.json"
|
||||
if s.Development {
|
||||
name = m.Category + ".json"
|
||||
if entry != nil {
|
||||
name = entry.Source
|
||||
}
|
||||
}
|
||||
d := snap.documents[name]
|
||||
rows := &d.Rules
|
||||
if m.Kind == "field" {
|
||||
rows = &d.Fields
|
||||
}
|
||||
switch m.Action {
|
||||
case "save":
|
||||
raw := json.RawMessage(m.JSON)
|
||||
if !json.Valid(raw) || identity(raw) != m.ID || strings.TrimSpace(m.ID) == "" {
|
||||
return nil, fmt.Errorf("规则 JSON 无效,或 ID 与原规则不一致")
|
||||
}
|
||||
*rows = upsert(*rows, raw)
|
||||
if m.Kind == "field" {
|
||||
d.DisabledFieldIDs = without(d.DisabledFieldIDs, m.ID)
|
||||
delete(d.FieldSuppressions, m.ID)
|
||||
} else {
|
||||
d.DisabledIDs = without(d.DisabledIDs, m.ID)
|
||||
}
|
||||
case "delete":
|
||||
if s.Development {
|
||||
*rows = remove(*rows, m.ID)
|
||||
if m.Kind == "field" {
|
||||
for _, dependency := range snap.Entries {
|
||||
if dependency.Kind == "rule" && fieldRef(json.RawMessage(dependency.JSON)) == m.ID && dependency.Source != name {
|
||||
return nil, fmt.Errorf("共享字段有其他来源的引用,请先解除引用: %s", dependency.ID)
|
||||
}
|
||||
}
|
||||
d.Rules = excludeFieldDependencies(d.Rules, []string{m.ID})
|
||||
}
|
||||
} else if entry.Overridden || entry.Source == name {
|
||||
*rows = remove(*rows, m.ID)
|
||||
if m.Kind == "field" && entry.Source == name {
|
||||
d.Rules = excludeFieldDependencies(d.Rules, []string{m.ID})
|
||||
d.DisabledFieldIDs = without(d.DisabledFieldIDs, m.ID)
|
||||
delete(d.FieldSuppressions, m.ID)
|
||||
} else if m.Kind == "rule" && entry.Source == name {
|
||||
d.DisabledIDs = without(d.DisabledIDs, m.ID)
|
||||
}
|
||||
} else {
|
||||
disable(&d, m, snap)
|
||||
}
|
||||
case "disable":
|
||||
if s.Development {
|
||||
return nil, fmt.Errorf("开发环境请直接删除规则")
|
||||
}
|
||||
disable(&d, m, snap)
|
||||
case "restore":
|
||||
if s.Development {
|
||||
return nil, fmt.Errorf("开发环境不支持恢复内置")
|
||||
}
|
||||
if entry.Source != name {
|
||||
*rows = remove(*rows, m.ID)
|
||||
}
|
||||
if m.Kind == "field" {
|
||||
d.DisabledFieldIDs = without(d.DisabledFieldIDs, m.ID)
|
||||
delete(d.FieldSuppressions, m.ID)
|
||||
} else {
|
||||
d.DisabledIDs = without(d.DisabledIDs, m.ID)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("未知操作")
|
||||
}
|
||||
if d.Rules == nil {
|
||||
d.Rules = []json.RawMessage{}
|
||||
}
|
||||
if err := validateOverlay(d, m.Category); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Validate the prospective configuration without persisting invalid data.
|
||||
candidate := snap.base
|
||||
if s.Development {
|
||||
candidate.Rules = nil
|
||||
candidate.Fields = nil
|
||||
known := map[string]bool{}
|
||||
names := []string{m.Category + ".json"}
|
||||
if m.Category == "annotations" {
|
||||
names = append(names, "paths.json", "sections.json")
|
||||
}
|
||||
for _, src := range names {
|
||||
doc := snap.documents[src]
|
||||
if src == name {
|
||||
doc = d
|
||||
}
|
||||
for _, r := range doc.Rules {
|
||||
if !known[identity(r)] {
|
||||
candidate.Rules = append(candidate.Rules, r)
|
||||
known[identity(r)] = true
|
||||
}
|
||||
}
|
||||
candidate.Fields = append(candidate.Fields, doc.Fields...)
|
||||
}
|
||||
} else {
|
||||
candidate.Rules = mergeRows(candidate.Rules, d.Rules, d.DisabledIDs)
|
||||
candidate.Fields = mergeRows(candidate.Fields, d.Fields, d.DisabledFieldIDs)
|
||||
candidate.Rules = excludeFieldDependencies(candidate.Rules, d.DisabledFieldIDs)
|
||||
for _, ids := range d.FieldSuppressions {
|
||||
for _, id := range ids {
|
||||
candidate.Rules = remove(candidate.Rules, id)
|
||||
}
|
||||
}
|
||||
if len(d.Relations) > 0 {
|
||||
candidate.Relations = map[string]annotations.RelationSpec{}
|
||||
for k, v := range snap.base.Relations {
|
||||
candidate.Relations[k] = v
|
||||
}
|
||||
for k, v := range d.Relations {
|
||||
candidate.Relations[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := Compile(m.Category, candidate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.MarshalIndent(d, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := s.UserDir
|
||||
if s.Development {
|
||||
dir = s.SourceDir
|
||||
}
|
||||
latest, err := s.read(m.Category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if latest.Revision != snap.Revision {
|
||||
return nil, fmt.Errorf("规则已被外部修改,请重新加载后再保存")
|
||||
}
|
||||
if err := s.Writer(filepath.Join(dir, name), append(data, '\n')); err != nil {
|
||||
return nil, fmt.Errorf("保存规则失败: %w", err)
|
||||
}
|
||||
return s.read(m.Category)
|
||||
}
|
||||
func disable(d *Document, m Mutation, snap *Snapshot) {
|
||||
if m.Kind == "field" {
|
||||
if !contains(d.DisabledFieldIDs, m.ID) {
|
||||
d.DisabledFieldIDs = append(d.DisabledFieldIDs, m.ID)
|
||||
}
|
||||
if d.FieldSuppressions == nil {
|
||||
d.FieldSuppressions = map[string][]string{}
|
||||
}
|
||||
ids := []string{}
|
||||
for _, r := range mergeRows(snap.base.Rules, d.Rules, nil) {
|
||||
if fieldRef(r) == m.ID {
|
||||
ids = append(ids, identity(r))
|
||||
}
|
||||
}
|
||||
d.FieldSuppressions[m.ID] = ids
|
||||
} else if !contains(d.DisabledIDs, m.ID) {
|
||||
d.DisabledIDs = append(d.DisabledIDs, m.ID)
|
||||
}
|
||||
}
|
||||
func atomicWrite(filename string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(filename), ".rules-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
mode := os.FileMode(0o600)
|
||||
if info, err := os.Stat(filename); err == nil {
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
if err = f.Chmod(mode); err == nil {
|
||||
_, err = f.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return os.Rename(f.Name(), filename)
|
||||
}
|
||||
|
||||
// Migrate imports only differences; absence in a legacy mirror is never a deletion.
|
||||
func (s *Store) Migrate(category string) error {
|
||||
if s.Development {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
filename := filepath.Join(s.UserDir, category+".user.json")
|
||||
if _, err := os.Stat(filename); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
legacyData, err := os.ReadFile(filepath.Join(s.UserDir, category+".json"))
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacy, err := decode(legacyData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("旧规则迁移失败: %w", err)
|
||||
}
|
||||
snap, err := s.read(category)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
overlay := Document{Version: 1, Rules: []json.RawMessage{}}
|
||||
different := func(rows, base []json.RawMessage) []json.RawMessage {
|
||||
out := []json.RawMessage{}
|
||||
for _, r := range rows {
|
||||
same := false
|
||||
for _, b := range base {
|
||||
if identity(b) == identity(r) {
|
||||
var a, c any
|
||||
_ = json.Unmarshal(r, &a)
|
||||
_ = json.Unmarshal(b, &c)
|
||||
same = reflect.DeepEqual(a, c)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !same {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
overlay.Rules = different(legacy.Rules, snap.base.Rules)
|
||||
overlay.Fields = different(legacy.Fields, snap.base.Fields)
|
||||
if category == "annotations" {
|
||||
relations := legacy.Relations
|
||||
if data, e := os.ReadFile(filepath.Join(s.UserDir, "lists.json")); e == nil {
|
||||
lists, e := annotations.ParseLists(data)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
relations = lists.Relations
|
||||
} else if !os.IsNotExist(e) {
|
||||
return e
|
||||
}
|
||||
for k, v := range relations {
|
||||
if b, ok := snap.base.Relations[k]; !ok || !reflect.DeepEqual(v, b) {
|
||||
if overlay.Relations == nil {
|
||||
overlay.Relations = map[string]annotations.RelationSpec{}
|
||||
}
|
||||
overlay.Relations[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := validateOverlay(overlay, category); err != nil {
|
||||
return err
|
||||
}
|
||||
candidate := snap.base
|
||||
candidate.Rules = mergeRows(candidate.Rules, overlay.Rules, nil)
|
||||
candidate.Fields = mergeRows(candidate.Fields, overlay.Fields, nil)
|
||||
if len(overlay.Relations) > 0 {
|
||||
candidate.Relations = map[string]annotations.RelationSpec{}
|
||||
for k, v := range snap.base.Relations {
|
||||
candidate.Relations[k] = v
|
||||
}
|
||||
for k, v := range overlay.Relations {
|
||||
candidate.Relations[k] = v
|
||||
}
|
||||
}
|
||||
if err := Compile(category, candidate); err != nil {
|
||||
return fmt.Errorf("旧规则迁移失败: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(overlay, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Writer(filename, append(data, '\n'))
|
||||
}
|
||||
|
||||
// Matches follows the archive-relative, case-insensitive glob semantics.
|
||||
func Matches(glob, file string, extensions []string) bool {
|
||||
file = strings.ToLower(strings.Trim(strings.ReplaceAll(file, "\\", "/"), "/"))
|
||||
if len(extensions) > 0 {
|
||||
ok := false
|
||||
for _, e := range extensions {
|
||||
ok = ok || strings.EqualFold(strings.TrimSpace(e), path.Ext(file))
|
||||
}
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(glob) == "" {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(strings.ToLower(strings.Trim(strings.ReplaceAll(glob, "\\", "/"), "/")), "/")
|
||||
segments := strings.Split(file, "/")
|
||||
var match func(int, int) bool
|
||||
match = func(i, j int) bool {
|
||||
if i == len(parts) {
|
||||
return j == len(segments)
|
||||
}
|
||||
if parts[i] == "**" {
|
||||
return match(i+1, j) || (j < len(segments) && match(i, j+1))
|
||||
}
|
||||
if j == len(segments) {
|
||||
return false
|
||||
}
|
||||
ok, _ := path.Match(parts[i], segments[j])
|
||||
return ok && match(i+1, j+1)
|
||||
}
|
||||
return match(0, 0)
|
||||
}
|
||||
|
||||
func excludeFieldDependencies(rows []json.RawMessage, disabled []string) []json.RawMessage {
|
||||
result := []json.RawMessage{}
|
||||
for _, row := range rows {
|
||||
if !contains(disabled, fieldRef(row)) {
|
||||
result = append(result, row)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package ruleconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const annotationFixture = `{"version":1,"fields":[{"id":"shared","match":{"glob":"a/test.etc"},"target":{"kind":"token","section":"name","index":0},"annotation":{"type":"text","title":"shared"},"preview":{"provider":"equ","role":"name","group":"header","format":"text"}}],"rules":[{"id":"note","match":{"glob":"a/test.etc"},"target":{"kind":"path"},"annotation":{"type":"text","title":"builtin"}},{"id":"dependent","field":"shared","match":{},"target":{},"annotation":{"title":"","type":""}}]}`
|
||||
const renderFixture = `{"version":1,"rules":[{"id":"format","match":{},"target":{"kind":"section","section":"name"},"format":{"tokensPerLine":2}},{"id":"other","match":{},"target":{"kind":"section","section":"other"},"format":{"tokensPerLine":1}}]}`
|
||||
|
||||
func fixtureStore(t *testing.T, development bool) *Store {
|
||||
t.Helper()
|
||||
s := New(development, t.TempDir(), t.TempDir())
|
||||
s.builtin = map[string][]byte{"annotations.json": []byte(annotationFixture), "rendering.json": []byte(renderFixture), "paths.json": []byte(`{"version":1,"rules":[{"id":"generated","match":{"glob":"b"},"target":{"kind":"path"},"annotation":{"type":"text","title":"generated"}}]}`), "sections.json": []byte(`{"version":1,"rules":[]}`), "lists.json": []byte(`{"version":1,"relations":{}}`)}
|
||||
for name, data := range s.builtin {
|
||||
if err := os.WriteFile(filepath.Join(s.SourceDir, name), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
func load(t *testing.T, s *Store, category string) *Snapshot {
|
||||
t.Helper()
|
||||
snap, err := s.Load(category)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
func apply(t *testing.T, s *Store, category, kind, id, action, raw string) *Snapshot {
|
||||
t.Helper()
|
||||
snap := load(t, s, category)
|
||||
next, err := s.Mutate(Mutation{Category: category, Revision: snap.Revision, Kind: kind, ID: id, Action: action, JSON: raw})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return next
|
||||
}
|
||||
func row(t *testing.T, snap *Snapshot, id string) Entry {
|
||||
t.Helper()
|
||||
for _, e := range snap.Entries {
|
||||
if e.ID == id {
|
||||
return e
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing entry %s", id)
|
||||
return Entry{}
|
||||
}
|
||||
func TestProductionOverrideOrderDisableRestore(t *testing.T) {
|
||||
s := fixtureStore(t, false)
|
||||
override := `{"id":"format","match":{},"target":{"kind":"section","section":"name"},"format":{"tokensPerLine":5}}`
|
||||
snap := apply(t, s, "rendering", "rule", "format", "save", override)
|
||||
_, engine, err := snap.Engines("rendering")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if engine.SectionFormat("a/test.etc", "name").TokensPerLine != 5 || identity(snap.merged.Rules[0]) != "format" || !row(t, snap, "format").Overridden {
|
||||
t.Fatal("override not in original position")
|
||||
}
|
||||
snap = apply(t, s, "rendering", "rule", "new", "save", strings.Replace(override, `"format"`, `"new"`, 1))
|
||||
if len(snap.merged.Rules) != 3 || identity(snap.merged.Rules[2]) != "new" {
|
||||
t.Fatal("different IDs must coexist")
|
||||
}
|
||||
snap = apply(t, s, "rendering", "rule", "format", "delete", "")
|
||||
if row(t, snap, "format").Overridden {
|
||||
t.Fatal("delete override must restore builtin")
|
||||
}
|
||||
snap = apply(t, s, "rendering", "rule", "format", "disable", "")
|
||||
if !row(t, snap, "format").Disabled || len(snap.merged.Rules) != 2 {
|
||||
t.Fatal("builtin not suppressed")
|
||||
}
|
||||
snap = apply(t, s, "rendering", "rule", "format", "restore", "")
|
||||
if row(t, snap, "format").Disabled {
|
||||
t.Fatal("builtin not restored")
|
||||
}
|
||||
apply(t, s, "rendering", "rule", "new", "delete", "")
|
||||
data, err := os.ReadFile(filepath.Join(s.SourceDir, "rendering.json"))
|
||||
if err != nil || string(data) != renderFixture {
|
||||
t.Fatal("production changed source")
|
||||
}
|
||||
}
|
||||
func TestSharedFieldCascadePreservesIndependentSuppression(t *testing.T) {
|
||||
s := fixtureStore(t, false)
|
||||
apply(t, s, "annotations", "rule", "dependent", "disable", "")
|
||||
snap := apply(t, s, "annotations", "field", "shared", "disable", "")
|
||||
if !row(t, snap, "dependent").Disabled || len(snap.merged.Fields) != 0 {
|
||||
t.Fatal("field/dependency not suppressed")
|
||||
}
|
||||
snap = apply(t, s, "annotations", "field", "shared", "restore", "")
|
||||
if row(t, snap, "shared").Disabled || !row(t, snap, "dependent").Disabled {
|
||||
t.Fatal("restoring field erased independent rule suppression")
|
||||
}
|
||||
apply(t, s, "annotations", "rule", "dependent", "restore", "")
|
||||
apply(t, s, "annotations", "field", "shared", "disable", "")
|
||||
snap = apply(t, s, "annotations", "field", "shared", "restore", "")
|
||||
if row(t, snap, "dependent").Disabled {
|
||||
t.Fatal("cascade not restored")
|
||||
}
|
||||
}
|
||||
func TestDevelopmentWritesOriginalSource(t *testing.T) {
|
||||
s := fixtureStore(t, true)
|
||||
apply(t, s, "annotations", "rule", "generated", "save", `{"id":"generated","match":{"glob":"b"},"target":{"kind":"path"},"annotation":{"type":"text","title":"edited"}}`)
|
||||
data, _ := os.ReadFile(filepath.Join(s.SourceDir, "paths.json"))
|
||||
if !strings.Contains(string(data), "edited") {
|
||||
t.Fatal("did not write generated source")
|
||||
}
|
||||
data, _ = os.ReadFile(filepath.Join(s.SourceDir, "annotations.json"))
|
||||
if string(data) != annotationFixture {
|
||||
t.Fatal("rewrote wrong source")
|
||||
}
|
||||
apply(t, s, "annotations", "rule", "generated", "delete", "")
|
||||
snap := load(t, s, "annotations")
|
||||
for _, e := range snap.Entries {
|
||||
if e.ID == "generated" {
|
||||
t.Fatal("not deleted")
|
||||
}
|
||||
}
|
||||
if files, _ := os.ReadDir(s.UserDir); len(files) != 0 {
|
||||
t.Fatal("development wrote user directory")
|
||||
}
|
||||
s.SourceDir = ""
|
||||
if _, err := s.Load("annotations"); err == nil {
|
||||
t.Fatal("missing source must fail")
|
||||
}
|
||||
}
|
||||
func TestMutationConflictInvalidInputAndWriteFailure(t *testing.T) {
|
||||
s := fixtureStore(t, false)
|
||||
snap := load(t, s, "rendering")
|
||||
invalid := Mutation{Category: "rendering", Revision: snap.Revision, Kind: "rule", ID: "format", Action: "save", JSON: `{"id":"format","match":{},"target":{"kind":"section","section":"name"},"format":{"tokensPerLine":0}}`}
|
||||
if _, err := s.Mutate(invalid); err == nil {
|
||||
t.Fatal("invalid rule accepted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.UserDir, "rendering.user.json")); !os.IsNotExist(err) {
|
||||
t.Fatal("invalid rule persisted")
|
||||
}
|
||||
valid := invalid
|
||||
valid.JSON = strings.Replace(invalid.JSON, `"tokensPerLine":0`, `"tokensPerLine":3`, 1)
|
||||
s.Writer = func(string, []byte) error { return errors.New("disk full") }
|
||||
if _, err := s.Mutate(valid); err == nil {
|
||||
t.Fatal("write failure hidden")
|
||||
}
|
||||
s.Writer = atomicWrite
|
||||
apply(t, s, "rendering", "rule", "format", "save", valid.JSON)
|
||||
if _, err := s.Mutate(valid); err == nil || !strings.Contains(err.Error(), "外部修改") {
|
||||
t.Fatal("stale revision accepted")
|
||||
}
|
||||
path := filepath.Join(s.UserDir, "rendering.user.json")
|
||||
os.WriteFile(path, []byte("{"), 0o600)
|
||||
if _, err := s.Mutate(valid); err == nil {
|
||||
t.Fatal("corrupted overlay overwritten")
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if string(data) != "{" {
|
||||
t.Fatal("damaged file changed")
|
||||
}
|
||||
}
|
||||
func TestLegacyMigrationImportsDifferencesAndIsIdempotent(t *testing.T) {
|
||||
s := fixtureStore(t, false)
|
||||
legacy := strings.Replace(renderFixture, `"tokensPerLine":2`, `"tokensPerLine":7`, 1)
|
||||
legacyPath := filepath.Join(s.UserDir, "rendering.json")
|
||||
os.WriteFile(legacyPath, []byte(legacy), 0o600)
|
||||
if err := s.Migrate("rendering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(s.UserDir, "rendering.user.json"))
|
||||
var overlay Document
|
||||
if err := json.Unmarshal(data, &overlay); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(overlay.Rules) != 1 || len(overlay.DisabledIDs) != 0 {
|
||||
t.Fatal("migration copied unchanged entries")
|
||||
}
|
||||
if err := s.Migrate("rendering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, _ := os.ReadFile(filepath.Join(s.UserDir, "rendering.user.json"))
|
||||
if string(data) != string(next) {
|
||||
t.Fatal("migration not idempotent")
|
||||
}
|
||||
old, _ := os.ReadFile(legacyPath)
|
||||
if string(old) != legacy {
|
||||
t.Fatal("legacy changed")
|
||||
}
|
||||
// An omitted builtin in a legacy mirror is not a deletion.
|
||||
s = fixtureStore(t, false)
|
||||
os.WriteFile(filepath.Join(s.UserDir, "rendering.json"), []byte(`{"version":1,"rules":[]}`), 0o600)
|
||||
if err := s.Migrate("rendering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(load(t, s, "rendering").merged.Rules) != 2 {
|
||||
t.Fatal("missing legacy rows became deletions")
|
||||
}
|
||||
}
|
||||
func TestLegacyRelationsRemainAvailableReadOnly(t *testing.T) {
|
||||
s := fixtureStore(t, false)
|
||||
legacy := `{"version":1,"rules":[{"id":"ref","match":{},"target":{"kind":"token","section":"name","index":0},"annotation":{"type":"reference","title":"ref","relation":"custom"}}]}`
|
||||
os.WriteFile(filepath.Join(s.UserDir, "annotations.json"), []byte(legacy), 0o600)
|
||||
os.WriteFile(filepath.Join(s.UserDir, "lists.json"), []byte(`{"version":1,"relations":{"custom":{"listPath":"test.lst","idToken":0,"pathToken":1,"recordTokens":2,"nameSection":"name"}}}`), 0o600)
|
||||
if err := s.Migrate("annotations"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := load(t, s, "annotations")
|
||||
if _, ok := snap.merged.Relations["custom"]; !ok {
|
||||
t.Fatal("legacy relation lost")
|
||||
}
|
||||
}
|
||||
func TestSuppressedFieldHandlesNewBuiltinDependencies(t *testing.T) {
|
||||
s := fixtureStore(t, false)
|
||||
apply(t, s, "annotations", "field", "shared", "disable", "")
|
||||
var d Document
|
||||
json.Unmarshal(s.builtin["annotations.json"], &d)
|
||||
d.Rules = append(d.Rules, json.RawMessage(`{"id":"new-dependent","field":"shared","match":{},"target":{},"annotation":{"title":"","type":""}}`))
|
||||
data, _ := json.Marshal(d)
|
||||
s.builtin["annotations.json"] = data
|
||||
snap := load(t, s, "annotations")
|
||||
if !row(t, snap, "new-dependent").Disabled {
|
||||
t.Fatal("upgrade dependency not suppressed")
|
||||
}
|
||||
}
|
||||
func TestMatches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
glob, file string
|
||||
ext []string
|
||||
want bool
|
||||
}{{"a/**", "a/deep/x.etc", nil, true}, {"a/**", "a2/x.etc", nil, false}, {"a/*", "a/deep/x.etc", nil, false}, {"", "A/X.ETC", []string{".etc"}, true}, {"a", "a/child", nil, false}} {
|
||||
if got := Matches(tc.glob, tc.file, tc.ext); got != tc.want {
|
||||
t.Fatalf("%q %q: %v", tc.glob, tc.file, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFieldSuppressRestoreAndCascadeDelete(t *testing.T) {
|
||||
s := fixtureStore(t, false)
|
||||
field := `{"id":"custom","match":{},"target":{"kind":"token","section":"custom","index":0},"annotation":{"title":"custom","type":"text"}}`
|
||||
apply(t, s, "annotations", "field", "custom", "save", field)
|
||||
apply(t, s, "annotations", "rule", "custom-ref", "save", `{"id":"custom-ref","field":"custom","match":{},"target":{},"annotation":{"title":"","type":""}}`)
|
||||
snap := apply(t, s, "annotations", "field", "custom", "disable", "")
|
||||
if !row(t, snap, "custom").Disabled || !row(t, snap, "custom-ref").Disabled {
|
||||
t.Fatal("user field/dependency not suppressed")
|
||||
}
|
||||
snap = apply(t, s, "annotations", "field", "custom", "restore", "")
|
||||
if row(t, snap, "custom").Disabled || row(t, snap, "custom-ref").Disabled {
|
||||
t.Fatal("user field/dependency not restored")
|
||||
}
|
||||
snap = apply(t, s, "annotations", "field", "custom", "delete", "")
|
||||
for _, e := range snap.Entries {
|
||||
if e.ID == "custom" || e.ID == "custom-ref" {
|
||||
t.Fatal("user field/dependency not deleted")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestDevelopmentFieldDeletionRemovesSameSourceDependencies(t *testing.T) {
|
||||
s := fixtureStore(t, true)
|
||||
snap := apply(t, s, "annotations", "field", "shared", "delete", "")
|
||||
for _, e := range snap.Entries {
|
||||
if e.ID == "shared" || e.ID == "dependent" {
|
||||
t.Fatal("source field/dependency not deleted")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user