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

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

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()
}