服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
363 lines
10 KiB
Go
363 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
annotationrules "pvfine/internal/annotations"
|
|
"pvfine/internal/pvf"
|
|
)
|
|
|
|
const maxRequestBytes = 4 << 20
|
|
|
|
type editorServer struct {
|
|
mu sync.Mutex
|
|
rulesPath string
|
|
runtimePath string
|
|
listsPath string
|
|
runtimeListsPath string
|
|
htmlPath string
|
|
token string
|
|
}
|
|
|
|
type previewRequest struct {
|
|
PVFVersion string `json:"pvfVersion,omitempty"`
|
|
Path string `json:"path"`
|
|
Text string `json:"text"`
|
|
Document *annotationrules.Document `json:"document,omitempty"`
|
|
}
|
|
|
|
func main() {
|
|
root, err := findRepoRoot()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
server, err := newEditorServer(root)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
httpServer := &http.Server{
|
|
Handler: server.routes(),
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
url := "http://" + listener.Addr().String() + "/"
|
|
fmt.Printf("标注规则编辑器已启动: %s\n", url)
|
|
if err := openBrowser(url); err != nil {
|
|
fmt.Printf("无法自动打开浏览器,请手动访问上面的地址: %v\n", err)
|
|
}
|
|
|
|
go func() {
|
|
if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Printf("规则编辑器服务异常: %v", err)
|
|
}
|
|
}()
|
|
|
|
stop := make(chan os.Signal, 1)
|
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
|
<-stop
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
_ = httpServer.Shutdown(ctx)
|
|
}
|
|
|
|
func newEditorServer(root string) (*editorServer, error) {
|
|
tokenBytes := make([]byte, 24)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
return nil, err
|
|
}
|
|
runtimePath, err := annotationrules.RuntimePath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
runtimeListsPath, err := annotationrules.RuntimeListsPath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &editorServer{
|
|
rulesPath: filepath.Join(root, "config", "annotations.json"),
|
|
runtimePath: runtimePath,
|
|
listsPath: filepath.Join(root, "config", "lists.json"),
|
|
runtimeListsPath: runtimeListsPath,
|
|
htmlPath: filepath.Join(root, "tools", "annotation-editor.html"),
|
|
token: hex.EncodeToString(tokenBytes),
|
|
}, nil
|
|
}
|
|
|
|
func (s *editorServer) routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", s.serveHTML)
|
|
mux.Handle("/api/rules", s.authorized(http.HandlerFunc(s.handleRules)))
|
|
mux.Handle("/api/preview", s.authorized(http.HandlerFunc(s.handlePreview)))
|
|
return mux
|
|
}
|
|
|
|
func (s *editorServer) authorized(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("X-Annotation-Editor-Token") != s.token {
|
|
writeAPIError(w, http.StatusForbidden, "无效的编辑器会话")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *editorServer) serveHTML(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
data, err := os.ReadFile(s.htmlPath)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
page := strings.ReplaceAll(string(data), "__EDITOR_TOKEN__", s.token)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = io.WriteString(w, page)
|
|
}
|
|
|
|
func (s *editorServer) handleRules(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
// The editor owns the hand-maintained rules only. config/paths.json is
|
|
// generated bulk data that must never round-trip into annotations.json.
|
|
document, err := annotationrules.LoadRulesFile(s.rulesPath)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
data, err := annotationrules.Marshal(document)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(data)
|
|
case http.MethodPut:
|
|
data, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBytes+1))
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if len(data) > maxRequestBytes {
|
|
writeAPIError(w, http.StatusRequestEntityTooLarge, "规则文件超过 4MB")
|
|
return
|
|
}
|
|
document, err := annotationrules.ParseRules(data)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
current, err := annotationrules.LoadRulesFile(s.rulesPath)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
document.Relations = current.Relations
|
|
if err := annotationrules.Validate(document); err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
formatted, err := annotationrules.MarshalRules(document)
|
|
if s.listsPath == "" {
|
|
formatted, err = annotationrules.Marshal(document)
|
|
}
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := writeRulesWithBackup(s.rulesPath, formatted); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if s.runtimePath != "" {
|
|
if err := atomicWrite(s.runtimePath, formatted, 0o600); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "同步运行时规则失败: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
if s.runtimeListsPath != "" && s.listsPath != "" {
|
|
listsData, listsErr := os.ReadFile(s.listsPath)
|
|
if listsErr != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "读取列表配置失败: "+listsErr.Error())
|
|
return
|
|
}
|
|
if err := atomicWrite(s.runtimeListsPath, listsData, 0o600); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "同步运行时列表配置失败: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": len(formatted)})
|
|
default:
|
|
w.Header().Set("Allow", "GET, PUT")
|
|
writeAPIError(w, http.StatusMethodNotAllowed, "不支持的请求方法")
|
|
}
|
|
}
|
|
|
|
func (s *editorServer) handlePreview(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
writeAPIError(w, http.StatusMethodNotAllowed, "不支持的请求方法")
|
|
return
|
|
}
|
|
var request previewRequest
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, maxRequestBytes))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&request); err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "预览参数无效: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if request.PVFVersion != "" && !annotationrules.ValidPVFVersion(request.PVFVersion) {
|
|
writeAPIError(w, http.StatusBadRequest, "pvfVersion 只允许 90US、90CN、110US、115US、100CN、TW 或空值")
|
|
return
|
|
}
|
|
|
|
var document annotationrules.Document
|
|
if request.Document != nil {
|
|
document = *request.Document
|
|
if len(document.Relations) == 0 {
|
|
current, err := annotationrules.LoadRulesFile(s.rulesPath)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
document.Relations = current.Relations
|
|
}
|
|
if err := annotationrules.Validate(document); err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
} else {
|
|
// Preview the rules the editor owns, not the generated bulk rule sets.
|
|
current, loadErr := annotationrules.LoadRulesFile(s.rulesPath)
|
|
if loadErr != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, loadErr.Error())
|
|
return
|
|
}
|
|
document = current
|
|
}
|
|
engine, err := annotationrules.Compile(document)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
engine = engine.ForVersion(request.PVFVersion)
|
|
results := engine.Annotate(request.Path, pvf.ParseScriptView(request.Text), nil)
|
|
pathResults := engine.AnnotatePath(request.Path, false)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"annotations": results,
|
|
"pathAnnotations": pathResults,
|
|
})
|
|
}
|
|
|
|
func writeRulesWithBackup(path string, data []byte) error {
|
|
previous, err := os.ReadFile(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
if err == nil {
|
|
if err := atomicWrite(path+".bak", previous, 0o644); err != nil {
|
|
return fmt.Errorf("写入规则备份失败: %w", err)
|
|
}
|
|
}
|
|
if err := atomicWrite(path, data, 0o644); err != nil {
|
|
return fmt.Errorf("写入规则失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func atomicWrite(path string, data []byte, mode os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
temp, err := os.CreateTemp(filepath.Dir(path), ".annotation-rules-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempPath := temp.Name()
|
|
defer os.Remove(tempPath)
|
|
if err := temp.Chmod(mode); err != nil {
|
|
temp.Close()
|
|
return err
|
|
}
|
|
if _, err := temp.Write(data); err != nil {
|
|
temp.Close()
|
|
return err
|
|
}
|
|
if err := temp.Sync(); err != nil {
|
|
temp.Close()
|
|
return err
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tempPath, path)
|
|
}
|
|
|
|
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
|
writeJSON(w, status, map[string]string{"error": message})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|
|
|
|
func findRepoRoot() (string, error) {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
return dir, nil
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return "", fmt.Errorf("未找到包含 go.mod 的项目根目录")
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
func openBrowser(url string) error {
|
|
var command *exec.Cmd
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
command = exec.Command("open", url)
|
|
case "windows":
|
|
command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
|
default:
|
|
command = exec.Command("xdg-open", url)
|
|
}
|
|
return command.Start()
|
|
}
|