服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
209 lines
6.2 KiB
Go
209 lines
6.2 KiB
Go
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()
|
|
}
|