DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
package rendering
import (
"path"
"strings"
)
// Engine is an immutable, compiled set of script rendering rules.
type Engine struct {
document Document
rules []compiledRule
}
type compiledRule struct {
rule Rule
extensions map[string]struct{}
specificity int
order int
}
// Compile validates and compiles a rendering rule document.
func Compile(document Document) (*Engine, error) {
if document.Rules == nil {
document.Rules = make([]Rule, 0)
}
if err := Validate(document); err != nil {
return nil, err
}
rules := make([]compiledRule, 0, len(document.Rules))
for order, rule := range document.Rules {
extensions := make(map[string]struct{}, len(rule.Match.Extensions))
for _, extension := range rule.Match.Extensions {
extensions[strings.ToLower(strings.TrimSpace(extension))] = struct{}{}
}
specificity := 0
if len(rule.Match.Extensions) > 0 {
specificity++
}
if strings.TrimSpace(rule.Match.Glob) != "" {
specificity++
}
rules = append(rules, compiledRule{
rule: rule,
extensions: extensions,
specificity: specificity,
order: order,
})
}
return &Engine{document: document, rules: rules}, nil
}
// Document returns the source document used to build the engine.
func (e *Engine) Document() Document {
if e == nil {
return Document{}
}
document := e.document
document.Rules = append([]Rule(nil), e.document.Rules...)
for index := range document.Rules {
if widths := document.Rules[index].Format.TokensPerLineByVersion; widths != nil {
cloned := make(map[string]int, len(widths))
for version, width := range widths {
cloned[version] = width
}
document.Rules[index].Format.TokensPerLineByVersion = cloned
}
document.Rules[index].Match.Extensions = append([]string(nil), document.Rules[index].Match.Extensions...)
document.Rules[index].Format.StandaloneValues = append([]int32(nil), document.Rules[index].Format.StandaloneValues...)
document.Rules[index].Format.NestedSections = append([]string(nil), document.Rules[index].Format.NestedSections...)
if value := document.Rules[index].Format.TokensPerLineIndex; value != nil {
copy := *value
document.Rules[index].Format.TokensPerLineIndex = &copy
}
}
return document
}
// FileFormat resolves the best file-scoped rule for filePath. A zero value
// means that the decoder should use its ordinary layout.
func (e *Engine) FileFormat(filePath string) FormatSpec {
return e.resolve(filePath, "", "file")
}
// SectionFormat resolves the best section-scoped rule for a section name.
// Section names are intentionally matched without considering nesting or
// occurrence, matching the annotation engine's V1 semantics.
func (e *Engine) SectionFormat(filePath, section string) FormatSpec {
return e.resolve(filePath, section, "section")
}
func (e *Engine) resolve(filePath, section, kind string) FormatSpec {
if e == nil {
return FormatSpec{}
}
bestSpecificity := -1
bestOrder := -1
var format FormatSpec
for _, compiled := range e.rules {
rule := compiled.rule
if rule.Target.Kind != kind || !compiled.matches(filePath) {
continue
}
if kind == "section" && !strings.EqualFold(strings.TrimSpace(rule.Target.Section), strings.TrimSpace(section)) {
continue
}
if compiled.specificity < bestSpecificity ||
(compiled.specificity == bestSpecificity && compiled.order < bestOrder) {
continue
}
bestSpecificity = compiled.specificity
bestOrder = compiled.order
format = compiled.rule.Format
}
return format
}
func (r compiledRule) matches(filePath string) bool {
filePath = normalizePath(filePath)
if len(r.extensions) > 0 {
if _, ok := r.extensions[strings.ToLower(path.Ext(filePath))]; !ok {
return false
}
}
glob := strings.TrimSpace(r.rule.Match.Glob)
return glob == "" || globMatch(glob, filePath)
}
func normalizePath(value string) string {
return strings.ToLower(strings.Trim(strings.ReplaceAll(value, "\\", "/"), "/"))
}
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(patternIndex, valueIndex int) bool {
key := state{pattern: patternIndex, value: valueIndex}
if seen[key] {
return memo[key]
}
seen[key] = true
if patternIndex == len(patternParts) {
memo[key] = valueIndex == len(valueParts)
return memo[key]
}
if patternParts[patternIndex] == "**" {
if match(patternIndex+1, valueIndex) ||
(valueIndex < len(valueParts) && match(patternIndex, valueIndex+1)) {
memo[key] = true
}
return memo[key]
}
if valueIndex >= len(valueParts) {
return false
}
matched, err := path.Match(patternParts[patternIndex], valueParts[valueIndex])
if err == nil && matched {
memo[key] = match(patternIndex+1, valueIndex+1)
}
return memo[key]
}
return match(0, 0)
}
func splitPath(value string) []string {
if value == "" {
return nil
}
return strings.Split(value, "/")
}
// EffectiveRuleID reports the selected rule for management UI diagnostics.
func (e *Engine) EffectiveRuleID(filePath, section, kind string) string {
if e == nil {
return ""
}
bestSpecificity, bestOrder := -1, -1
id := ""
for _, compiled := range e.rules {
r := compiled.rule
if r.Target.Kind != kind || !compiled.matches(filePath) {
continue
}
if kind == "section" && !strings.EqualFold(strings.TrimSpace(r.Target.Section), strings.TrimSpace(section)) {
continue
}
if compiled.specificity < bestSpecificity || (compiled.specificity == bestSpecificity && compiled.order < bestOrder) {
continue
}
bestSpecificity, bestOrder, id = compiled.specificity, compiled.order, r.ID
}
return id
}
+85
View File
@@ -0,0 +1,85 @@
package rendering
import "testing"
func TestFormatMatchingUsesExtensionSectionAndGlob(t *testing.T) {
engine, err := Compile(Document{
Version: 1,
Rules: []Rule{
{
ID: "file.lst",
Match: MatchSpec{Extensions: []string{".LST"}},
Target: TargetSpec{Kind: "file"},
Format: FormatSpec{TokensPerLine: 2},
},
{
ID: "file.skl",
Match: MatchSpec{Extensions: []string{".skl"}},
Target: TargetSpec{Kind: "file"},
Format: FormatSpec{TokensPerLine: 2},
},
{
ID: "section.global",
Target: TargetSpec{Kind: "section", Section: "Records"},
Format: FormatSpec{TokensPerLine: 3},
},
{
ID: "section.skl",
Match: MatchSpec{Extensions: []string{".skl"}},
Target: TargetSpec{Kind: "section", Section: "records"},
Format: FormatSpec{TokensPerLine: 4},
},
{
ID: "section.skl.glob",
Match: MatchSpec{Extensions: []string{".SKL"}, Glob: "skills/**/*.skl"},
Target: TargetSpec{Kind: "section", Section: "RECORDS"},
Format: FormatSpec{TokensPerLine: 5},
},
},
})
if err != nil {
t.Fatal(err)
}
if got := engine.FileFormat("foo/INDEX.LST").TokensPerLine; got != 2 {
t.Fatalf("lst file format = %d, want 2", got)
}
if got := engine.FileFormat("skills/test.SKL").TokensPerLine; got != 2 {
t.Fatalf("skl file format = %d, want 2", got)
}
if got := engine.SectionFormat("other/item.skl", "records").TokensPerLine; got != 4 {
t.Fatalf("skl section format = %d, want 4", got)
}
if got := engine.SectionFormat("skills/advanced/item.SKL", "RECORDS").TokensPerLine; got != 5 {
t.Fatalf("glob section format = %d, want 5", got)
}
if got := engine.SectionFormat("item.equ", "records").TokensPerLine; got != 3 {
t.Fatalf("global section format = %d, want 3", got)
}
}
func TestFormatMatchingUsesLaterRuleForEqualSpecificity(t *testing.T) {
engine, err := Compile(Document{
Version: 1,
Rules: []Rule{
{
ID: "first",
Match: MatchSpec{Extensions: []string{".equ"}},
Target: TargetSpec{Kind: "file"},
Format: FormatSpec{TokensPerLine: 2},
},
{
ID: "second",
Match: MatchSpec{Extensions: []string{".EQU"}},
Target: TargetSpec{Kind: "file"},
Format: FormatSpec{TokensPerLine: 7},
},
},
})
if err != nil {
t.Fatal(err)
}
if got := engine.FileFormat("item.equ").TokensPerLine; got != 7 {
t.Fatalf("equal-specificity format = %d, want 7", got)
}
}
+208
View File
@@ -0,0 +1,208 @@
package rendering
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
appconfig "pvfine/config"
)
const sourceRelativePath = "config/rendering.json"
// LoadDefault loads the embedded rendering rules used at application start.
func LoadDefault() (*Engine, error) {
return Parse(appconfig.RenderingJSON)
}
// LoadFile loads and validates a rendering rule document from disk.
func LoadFile(filePath string) (*Engine, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("读取渲染规则失败: %w", err)
}
engine, err := Parse(data)
if err != nil {
return nil, err
}
return engine, nil
}
// Parse parses and validates one complete JSON rendering document.
func Parse(data []byte) (*Engine, error) {
document, err := parseDocument(data)
if err != nil {
return nil, err
}
return Compile(document)
}
// ParseDocument parses one rendering document without compiling its rules.
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)
}
return document, nil
}
// Marshal validates and formats a rendering document.
func Marshal(document Document) ([]byte, error) {
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
}
// Validate checks the V1 rendering schema and all matching constraints.
func Validate(document Document) error {
problems := make([]string, 0)
if document.Version != 1 {
problems = append(problems, fmt.Sprintf("version 必须为 1,当前为 %d", document.Version))
}
seenIDs := make(map[string]bool, len(document.Rules))
for index, rule := range document.Rules {
prefix := fmt.Sprintf("rules[%d]", index)
id := strings.TrimSpace(rule.ID)
if id == "" {
problems = append(problems, prefix+".id 不能为空")
} else if seenIDs[id] {
problems = append(problems, prefix+".id 重复: "+rule.ID)
}
seenIDs[id] = true
for _, rawExtension := range rule.Match.Extensions {
extension := strings.ToLower(strings.TrimSpace(rawExtension))
if extension == "" || !strings.HasPrefix(extension, ".") || strings.ContainsAny(extension, "/\\") {
problems = append(problems, prefix+".match.extensions 必须是以点开头的文件后缀")
break
}
}
if strings.TrimSpace(rule.Match.Glob) != "" {
if err := validateGlob(rule.Match.Glob); err != nil {
problems = append(problems, prefix+".match.glob 无效: "+err.Error())
}
}
switch rule.Target.Kind {
case "file":
if len(rule.Format.NestedSections) > 0 {
problems = append(problems, prefix+".format.nestedSections 只允许用于 section 规则")
}
if strings.TrimSpace(rule.Target.Section) != "" {
problems = append(problems, prefix+".target.section 仅允许用于 section 规则")
}
if rule.Format.TokensPerLineIndex != nil {
problems = append(problems, prefix+".format.tokensPerLineIndex 只允许用于 section 规则")
}
case "section":
if strings.TrimSpace(rule.Target.Section) == "" {
problems = append(problems, prefix+".target.section 不能为空")
}
default:
problems = append(problems, prefix+".target.kind 必须是 file 或 section")
}
for _, child := range rule.Format.NestedSections {
if strings.TrimSpace(child) == "" || strings.ContainsAny(child, "[]/") {
problems = append(problems, prefix+".format.nestedSections 必须包含有效的子 Section 名称")
break
}
}
if rule.Format.Offset < 0 {
problems = append(problems, prefix+".format.offset 不能为负数")
}
if rule.Format.TokensPerLine <= 0 {
problems = append(problems, prefix+".format.tokensPerLine 必须是正数")
}
for version, width := range rule.Format.TokensPerLineByVersion {
switch version {
case "90US", "90CN", "TW", "100CN", "110US", "115US", "115CN":
default:
problems = append(problems, prefix+".format.tokensPerLineByVersion 包含无效版本 "+version)
}
if width <= 0 {
problems = append(problems, prefix+".format.tokensPerLineByVersion 的行宽必须是正数")
}
}
if rule.Format.TokensPerLineIndex != nil && *rule.Format.TokensPerLineIndex < 0 {
problems = append(problems, prefix+".format.tokensPerLineIndex 不能为负数")
}
}
if len(problems) > 0 {
return fmt.Errorf("渲染规则校验失败:\n- %s", strings.Join(problems, "\n- "))
}
return 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
}
// RuntimePath is the writable rule location 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", "rendering.json"), nil
}
// FindSourcePath locates the repository rendering file during 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(filePath string) bool {
info, err := os.Stat(filePath)
return err == nil && !info.IsDir()
}
+132
View File
@@ -0,0 +1,132 @@
package rendering
import (
"strings"
"testing"
)
func TestLoadDefault(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatalf("LoadDefault() error = %v", err)
}
rules := engine.Document().Rules
if len(rules) < 3 {
t.Fatalf("default rule count = %d, want at least 3", len(rules))
}
seen := make(map[string]bool, len(rules))
for _, rule := range rules {
seen[rule.ID] = true
}
for _, id := range []string{"file.lst", "section.independent-drop", "section.list", "section.skill-data-up", "section.skill-levelup"} {
if !seen[id] {
t.Fatalf("default rules missing %q", id)
}
}
}
func TestParseRejectsInvalidDocuments(t *testing.T) {
tests := []struct {
name string
json string
want string
}{
{
name: "version",
json: `{"version":2,"rules":[]}`,
want: "version 必须为 1",
},
{
name: "duplicate id",
json: `{"version":1,"rules":[{"id":"same","match":{},"target":{"kind":"file"},"format":{"tokensPerLine":2}},{"id":"same","match":{},"target":{"kind":"file"},"format":{"tokensPerLine":3}}]}`,
want: "id 重复",
},
{
name: "extension",
json: `{"version":1,"rules":[{"id":"bad","match":{"extensions":["lst"]},"target":{"kind":"file"},"format":{"tokensPerLine":2}}]}`,
want: "必须是以点开头的文件后缀",
},
{
name: "target",
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"section"},"format":{"tokensPerLine":2}}]}`,
want: "target.section 不能为空",
},
{
name: "format",
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"file"},"format":{"tokensPerLine":0}}]}`,
want: "tokensPerLine 必须是正数",
},
{
name: "offset",
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"file"},"format":{"offset":-1,"tokensPerLine":2}}]}`,
want: "format.offset 不能为负数",
},
{
name: "dynamic file rule",
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"file"},"format":{"tokensPerLine":2,"tokensPerLineIndex":0}}]}`,
want: "tokensPerLineIndex 只允许用于 section 规则",
},
{
name: "dynamic index",
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"section","section":"records"},"format":{"tokensPerLine":2,"tokensPerLineIndex":-1}}]}`,
want: "tokensPerLineIndex 不能为负数",
},
{
name: "nested sections on file",
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"file"},"format":{"tokensPerLine":2,"nestedSections":["list"]}}]}`,
want: "nestedSections 只允许用于 section 规则",
},
{
name: "invalid nested section",
json: `{"version":1,"rules":[{"id":"bad","match":{},"target":{"kind":"section","section":"records"},"format":{"tokensPerLine":2,"nestedSections":["[/list]"]}}]}`,
want: "nestedSections 必须包含有效的子 Section 名称",
},
{
name: "glob",
json: `{"version":1,"rules":[{"id":"bad","match":{"glob":"["},"target":{"kind":"file"},"format":{"tokensPerLine":2}}]}`,
want: "match.glob 无效",
},
{
name: "unknown field",
json: `{"version":1,"rules":[],"extra":true}`,
want: "解析渲染规则失败",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse([]byte(tt.json))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Parse() error = %v, want substring %q", err, tt.want)
}
})
}
}
func TestParseRejectsTrailingJSON(t *testing.T) {
_, err := Parse([]byte(`{"version":1,"rules":[]} {}`))
if err == nil || !strings.Contains(err.Error(), "只能包含一个 JSON 文档") {
t.Fatalf("Parse() error = %v", err)
}
}
func TestVersionWidthsValidateAndClone(t *testing.T) {
for _, spec := range []string{`{"TW":0}`, `{"unknown":6}`, `{"":6}`} {
raw := `{"version":1,"rules":[{"id":"width","match":{},"target":{"kind":"section","section":"records"},"format":{"tokensPerLine":7,"tokensPerLineByVersion":` + spec + `}}]}`
if _, err := Parse([]byte(raw)); err == nil {
t.Fatal("accepted invalid version widths", spec)
}
}
engine, err := Parse([]byte(`{"version":1,"rules":[{"id":"width","match":{},"target":{"kind":"section","section":"records"},"format":{"tokensPerLine":7,"tokensPerLineByVersion":{"TW":6,"110US":9,"100CN":8}}}]}`))
if err != nil {
t.Fatal(err)
}
document := engine.Document()
document.Rules[0].Format.TokensPerLineByVersion["TW"] = 99
if got := engine.SectionFormat("etc/test.etc", "records").ForVersion("TW").TokensPerLine; got != 6 {
t.Fatal("document mutation changed engine", got)
}
if got := engine.SectionFormat("etc/test.etc", "records").ForVersion("unknown").TokensPerLine; got != 7 {
t.Fatal("unknown version did not use fallback", got)
}
}
+60
View File
@@ -0,0 +1,60 @@
package rendering
// Document is the JSON document that describes TypeScript display rules.
type Document struct {
Version int `json:"version"`
Description string `json:"description,omitempty"`
Rules []Rule `json:"rules"`
}
// Rule describes one file- or section-scoped rendering format.
type Rule struct {
ID string `json:"id"`
Description string `json:"description,omitempty"`
Match MatchSpec `json:"match"`
Target TargetSpec `json:"target"`
Format FormatSpec `json:"format"`
}
// MatchSpec limits a rule to selected file extensions and/or archive paths.
// Matching is case-insensitive.
type MatchSpec struct {
Extensions []string `json:"extensions,omitempty"`
Glob string `json:"glob,omitempty"`
}
// TargetSpec selects whether a rule applies to a complete file or to all
// sections with one name.
type TargetSpec struct {
Kind string `json:"kind"`
Section string `json:"section,omitempty"`
}
// FormatSpec contains the display formatting knobs supported by V1.
type FormatSpec struct {
// Offset places the first Offset value tokens on separate lines before
// TokensPerLine grouping begins.
Offset int `json:"offset,omitempty"`
// TokensPerLine wraps value tokens after this many tokens. It is also the
// fallback when TokensPerLineIndex is configured but its value is missing
// or invalid.
TokensPerLine int `json:"tokensPerLine"`
// TokensPerLineByVersion overrides the fallback width for known clients.
TokensPerLineByVersion map[string]int `json:"tokensPerLineByVersion,omitempty"`
// TokensPerLineIndex is a zero-based direct token index inside a section;
// its positive integer value overrides TokensPerLine for that section.
TokensPerLineIndex *int `json:"tokensPerLineIndex,omitempty"`
// StandaloneValues are integer tokens rendered on their own line. Each
// occurrence resets the tokens-per-line counter for subsequent values.
StandaloneValues []int32 `json:"standaloneValues,omitempty"`
// NestedSections are paired child sections which preserve an unpaired parent.
NestedSections []string `json:"nestedSections,omitempty"`
}
// ForVersion resolves the configured width before any token-index override.
func (f FormatSpec) ForVersion(version string) FormatSpec {
if width := f.TokensPerLineByVersion[version]; width > 0 {
f.TokensPerLine = width
}
return f
}