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,362 @@
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user