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()
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAnnotationEditorGroupingFields verifies the standalone editor exposes
|
||||
// both grouping controls on rules and shared fields.
|
||||
func TestAnnotationEditorGroupingFields(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "tools", "annotation-editor.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{
|
||||
"target-group-offset", "target-standalone-values",
|
||||
"field-target-group-offset", "field-target-standalone-values",
|
||||
"target-offset", "target-tokens-per-line",
|
||||
} {
|
||||
if !bytes.Contains(data, []byte(`"`+id+`"`)) {
|
||||
t.Errorf("missing editor input %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotationEditorGroupingSaveAndPreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "annotations.json")
|
||||
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &editorServer{rulesPath: rulesPath, runtimePath: filepath.Join(dir, "runtime.json"), token: "test-token"}
|
||||
document := []byte(`{
|
||||
"version":1,
|
||||
"rules":[{
|
||||
"id":"world.drop",
|
||||
"target":{"kind":"token","section":"world drop","index":0,"offset":1,"groupOffset":1,"recordTokens":2,"standaloneValues":[-1]},
|
||||
"annotation":{"title":"掉落","type":"text"}
|
||||
}],
|
||||
"fields":[{
|
||||
"id":"shared.drop",
|
||||
"target":{"kind":"token","section":"shared drop","index":0,"groupOffset":1,"recordTokens":2,"standaloneValues":[-1]},
|
||||
"annotation":{"title":"共享掉落","type":"text"}
|
||||
}]
|
||||
}`)
|
||||
call := func(method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
request.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
if response := call(http.MethodPut, "/api/rules", document); response.Code != http.StatusOK {
|
||||
t.Fatalf("save = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
saved := call(http.MethodGet, "/api/rules", nil)
|
||||
if saved.Code != http.StatusOK {
|
||||
t.Fatalf("read = %d: %s", saved.Code, saved.Body.String())
|
||||
}
|
||||
var decoded struct {
|
||||
Rules []struct {
|
||||
Target struct {
|
||||
GroupOffset int `json:"groupOffset"`
|
||||
StandaloneValues []int32 `json:"standaloneValues"`
|
||||
} `json:"target"`
|
||||
} `json:"rules"`
|
||||
Fields []struct {
|
||||
Target struct {
|
||||
GroupOffset int `json:"groupOffset"`
|
||||
StandaloneValues []int32 `json:"standaloneValues"`
|
||||
} `json:"target"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal(saved.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decoded.Rules) != 1 || len(decoded.Fields) != 1 ||
|
||||
decoded.Rules[0].Target.GroupOffset != 1 || decoded.Fields[0].Target.GroupOffset != 1 ||
|
||||
len(decoded.Rules[0].Target.StandaloneValues) != 1 || decoded.Rules[0].Target.StandaloneValues[0] != -1 ||
|
||||
len(decoded.Fields[0].Target.StandaloneValues) != 1 || decoded.Fields[0].Target.StandaloneValues[0] != -1 {
|
||||
t.Fatalf("grouping fields not preserved: %s", saved.Body.String())
|
||||
}
|
||||
text := "[world drop]\n999 10 100 200 -1 20 300 400"
|
||||
payload, err := json.Marshal(map[string]any{"path": "item/a.etc", "text": text})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := call(http.MethodPost, "/api/preview", payload)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("preview = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var preview struct {
|
||||
Annotations []struct {
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
} `json:"annotations"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(preview.Annotations) != 2 {
|
||||
t.Fatalf("preview annotation count = %d: %s", len(preview.Annotations), response.Body.String())
|
||||
}
|
||||
for i, want := range []string{"100", "300"} {
|
||||
got := text[preview.Annotations[i].Start:preview.Annotations[i].End]
|
||||
if got != want {
|
||||
t.Errorf("annotation[%d] = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestRulesAPIWritesBackupAndRejectsInvalidRules(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "default.json")
|
||||
runtimePath := filepath.Join(dir, "config", "annotations.json")
|
||||
original := []byte("{\"version\":1,\"relations\":{},\"rules\":[]}\n")
|
||||
if err := os.WriteFile(rulesPath, original, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &editorServer{rulesPath: rulesPath, runtimePath: runtimePath, token: "test-token"}
|
||||
|
||||
updated := []byte(`{"version":1,"description":"updated","relations":{},"fields":[{"id":"equ.rarity","match":{"extensions":[".equ"]},"target":{"kind":"token","section":"rarity","index":0},"annotation":{"title":"稀有度","type":"enum","values":{"4":"史诗"}},"preview":{"provider":"equ","role":"rarity","group":"summary","order":10,"format":"enum"}}],"rules":[]}`)
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/rules", bytes.NewReader(updated))
|
||||
request.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
backup, err := os.ReadFile(rulesPath + ".bak")
|
||||
if err != nil || !bytes.Equal(backup, original) {
|
||||
t.Fatalf("backup = %q, err = %v", backup, err)
|
||||
}
|
||||
written, err := os.ReadFile(rulesPath)
|
||||
if err != nil || !bytes.Contains(written, []byte("updated")) || !bytes.Contains(written, []byte("equ.rarity")) {
|
||||
t.Fatalf("written = %q, err = %v", written, err)
|
||||
}
|
||||
runtimeRules, err := os.ReadFile(runtimePath)
|
||||
if err != nil || !bytes.Equal(runtimeRules, written) {
|
||||
t.Fatalf("runtime rules = %q, err = %v", runtimeRules, err)
|
||||
}
|
||||
|
||||
invalid := httptest.NewRequest(http.MethodPut, "/api/rules", bytes.NewReader([]byte("{}")))
|
||||
invalid.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
invalidResponse := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(invalidResponse, invalid)
|
||||
if invalidResponse.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid status = %d", invalidResponse.Code)
|
||||
}
|
||||
afterInvalid, err := os.ReadFile(rulesPath)
|
||||
if err != nil || !bytes.Equal(afterInvalid, written) {
|
||||
t.Fatalf("invalid request changed rules: %q, err = %v", afterInvalid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewAPIUsesSubmittedRulesAndIncludesPathAnnotations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "default.json")
|
||||
if err := os.WriteFile(rulesPath, []byte("{\"version\":1,\"relations\":{},\"rules\":[]}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &editorServer{rulesPath: rulesPath, token: "test-token"}
|
||||
body := []byte(`{
|
||||
"path":"equipment/example.equ",
|
||||
"text":"[rarity]\n4",
|
||||
"document":{
|
||||
"version":1,
|
||||
"relations":{},
|
||||
"rules":[
|
||||
{"id":"rarity","match":{"extensions":[".equ"]},"target":{"kind":"token","section":"rarity","index":0},"annotation":{"title":"装备品级","type":"enum","values":{"4":"史诗"}}},
|
||||
{"id":"path","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"装备配置","type":"text"}}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/preview", bytes.NewReader(body))
|
||||
request.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var result struct {
|
||||
Annotations []map[string]any `json:"annotations"`
|
||||
PathAnnotations []map[string]any `json:"pathAnnotations"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Annotations) != 1 || result.Annotations[0]["title"] != "史诗" {
|
||||
t.Fatalf("annotations = %#v", result.Annotations)
|
||||
}
|
||||
if len(result.PathAnnotations) != 1 || result.PathAnnotations[0]["title"] != "装备配置" {
|
||||
t.Fatalf("path annotations = %#v", result.PathAnnotations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionedRulesSaveAndPreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "annotations.json")
|
||||
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &editorServer{rulesPath: rulesPath, runtimePath: filepath.Join(dir, "runtime.json"), token: "test-token"}
|
||||
document := json.RawMessage(`{"version":1,"fields":[{"id":"name","pvfVersions":["90US","90CN","115US"],"target":{"kind":"section","section":"name"},"annotation":{"title":"name","type":"text"}}],"rules":[{"id":"path","pvfVersions":["90US","110US"],"target":{"kind":"path"},"annotation":{"title":"path","type":"text"}}]}`)
|
||||
call := func(method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
request.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
if response := call(http.MethodPut, "/api/rules", document); response.Code != http.StatusOK {
|
||||
t.Fatalf("save: %s", response.Body.String())
|
||||
}
|
||||
response := call(http.MethodGet, "/api/rules", nil)
|
||||
var saved struct {
|
||||
Fields []struct {
|
||||
PVFVersions []string `json:"pvfVersions"`
|
||||
} `json:"fields"`
|
||||
Rules []struct {
|
||||
PVFVersions []string `json:"pvfVersions"`
|
||||
} `json:"rules"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(saved.Fields) != 1 || len(saved.Fields[0].PVFVersions) != 3 || len(saved.Rules) != 1 || len(saved.Rules[0].PVFVersions) != 2 {
|
||||
t.Fatalf("versions lost: %s", response.Body.String())
|
||||
}
|
||||
for _, submitted := range []bool{false, true} {
|
||||
for _, tc := range []struct {
|
||||
version string
|
||||
annotations, paths int
|
||||
}{{"90US", 1, 1}, {"90CN", 1, 0}, {"110US", 0, 1}, {"115US", 1, 0}, {"", 0, 0}} {
|
||||
payload := map[string]any{"path": "a.equ", "text": "[name]\n`example`"}
|
||||
if tc.version != "" {
|
||||
payload["pvfVersion"] = tc.version
|
||||
}
|
||||
if submitted {
|
||||
payload["document"] = document
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := call(http.MethodPost, "/api/preview", body)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("preview: %s", response.Body.String())
|
||||
}
|
||||
var result struct {
|
||||
Annotations []any `json:"annotations"`
|
||||
PathAnnotations []any `json:"pathAnnotations"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Annotations) != tc.annotations || len(result.PathAnnotations) != tc.paths {
|
||||
t.Fatalf("%s submitted=%v: %s", tc.version, submitted, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if response := call(http.MethodPost, "/api/preview", []byte(`{"pvfVersion":"invalid"}`)); response.Code != http.StatusBadRequest {
|
||||
t.Fatal("invalid preview version accepted")
|
||||
}
|
||||
invalid := bytes.ReplaceAll(document, []byte("90US"), []byte("invalid"))
|
||||
if response := call(http.MethodPut, "/api/rules", invalid); response.Code != http.StatusBadRequest {
|
||||
t.Fatal("invalid config version accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRulesAPIIgnoresGeneratedPathNotes keeps the editor on the hand-maintained
|
||||
// rules: a sibling paths.json must never round-trip into annotations.json.
|
||||
func TestRulesAPIIgnoresGeneratedPathNotes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "annotations.json")
|
||||
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generated := []byte(`{"version":1,"rules":[{"id":"path.dir","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"装备","type":"text"}}]}`)
|
||||
if err := os.WriteFile(filepath.Join(dir, "paths.json"), generated, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &editorServer{rulesPath: rulesPath, runtimePath: filepath.Join(dir, "runtime.json"), token: "test-token"}
|
||||
call := func(method string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, "/api/rules", bytes.NewReader(body))
|
||||
request.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
if read := call(http.MethodGet, nil); read.Code != http.StatusOK || bytes.Contains(read.Body.Bytes(), []byte("path.dir")) {
|
||||
t.Fatalf("GET /api/rules returned generated path notes: %s", read.Body.String())
|
||||
}
|
||||
saved := call(http.MethodPut, []byte(`{"version":1,"rules":[{"id":"hand","target":{"kind":"section","section":"name"},"annotation":{"title":"名称","type":"text"}}]}`))
|
||||
if saved.Code != http.StatusOK {
|
||||
t.Fatalf("save = %d: %s", saved.Code, saved.Body.String())
|
||||
}
|
||||
for _, path := range []string{rulesPath, filepath.Join(dir, "runtime.json")} {
|
||||
written, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(written, []byte("path.dir")) || bytes.Contains(written, []byte("equipment/**")) {
|
||||
t.Fatalf("%s contains generated path rules: %s", path, written)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Command pvfexport extracts an item_id -> display-name map from a DNF
|
||||
// Script.pvf archive. It reads the stackable.lst and equipment.lst indexes,
|
||||
// resolves each item file, and pulls the [name] section via ScriptMetadata.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// pvfexport -pvf Script.pvf -out items.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
type itemEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Rarity int32 `json:"rarity"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
type exportResult struct {
|
||||
PVFPath string `json:"pvf_path"`
|
||||
Total int `json:"total"`
|
||||
Items []itemEntry `json:"items"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
pvfPath := flag.String("pvf", "Script.pvf", "path to Script.pvf")
|
||||
outPath := flag.String("out", "items.json", "output JSON path")
|
||||
compact := flag.Bool("compact", false, "输出紧凑的 {item_id: name} 映射(仅保留有名称的项)")
|
||||
flag.Parse()
|
||||
|
||||
archive, err := pvf.Open(*pvfPath)
|
||||
if err != nil {
|
||||
log.Fatalf("打开 PVF 失败: %v", err)
|
||||
}
|
||||
defer archive.Release()
|
||||
|
||||
items := make([]itemEntry, 0, 200000)
|
||||
seen := make(map[int64]bool)
|
||||
|
||||
// 顺序处理两类物品列表,堆叠道具优先。
|
||||
for _, list := range []struct {
|
||||
root string
|
||||
path string
|
||||
kind string
|
||||
}{
|
||||
{"stackable", "stackable/stackable.lst", "stackable"},
|
||||
{"equipment", "equipment/equipment.lst", "equipment"},
|
||||
} {
|
||||
count, err := exportList(archive, list.root, list.path, list.kind, seen, &items)
|
||||
if err != nil {
|
||||
log.Printf("警告: 处理 %s 失败: %v", list.path, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("%s: 解析 %d 条", list.path, count)
|
||||
}
|
||||
|
||||
result := exportResult{
|
||||
PVFPath: *pvfPath,
|
||||
Total: len(items),
|
||||
Items: items,
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if *compact {
|
||||
// 紧凑映射:{item_id: name},仅保留有名称的项
|
||||
compactMap := make(map[string]string, len(items))
|
||||
for _, it := range items {
|
||||
name := strings.TrimSpace(it.Name)
|
||||
if name != "" {
|
||||
compactMap[strconv.FormatInt(it.ID, 10)] = name
|
||||
}
|
||||
}
|
||||
data, err = json.Marshal(compactMap)
|
||||
} else {
|
||||
data, err = json.MarshalIndent(result, "", " ")
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("序列化 JSON 失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*outPath, data, 0o644); err != nil {
|
||||
log.Fatalf("写入 %s 失败: %v", *outPath, err)
|
||||
}
|
||||
fmt.Printf("导出完成: %d 个物品 -> %s\n", len(items), *outPath)
|
||||
}
|
||||
|
||||
func exportList(archive *pvf.Archive, root, listPath, kind string, seen map[int64]bool, items *[]itemEntry) (int, error) {
|
||||
idx, found := archive.Find(listPath)
|
||||
if !found {
|
||||
return 0, fmt.Errorf("未找到 %s", listPath)
|
||||
}
|
||||
text, err := archive.Text(idx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读取 %s: %w", listPath, err)
|
||||
}
|
||||
pairs, err := pvf.ParseListText(text)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("解析 %s: %w", listPath, err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, pair := range pairs {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(pair.ID), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
path := cleanPath(pair.Path)
|
||||
itemIdx, ok := resolveItemFile(archive, root, path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta, err := archive.ScriptMetadata(itemIdx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
count++
|
||||
*items = append(*items, itemEntry{
|
||||
ID: id,
|
||||
Name: meta.Name,
|
||||
Rarity: meta.Rarity,
|
||||
Path: path,
|
||||
Kind: kind,
|
||||
})
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// resolveItemFile 复刻服务端 readDungeonDropText 的候选路径策略:
|
||||
// 先直接用 lst 里的路径,再尝试 root/ 前缀,最后尝试 (r) 修订文件名。
|
||||
func resolveItemFile(archive *pvf.Archive, root, listedPath string) (int32, bool) {
|
||||
clean := cleanPath(listedPath)
|
||||
candidates := []string{clean}
|
||||
root = cleanPath(root)
|
||||
if root != "" && !strings.HasPrefix(strings.ToLower(clean), strings.ToLower(root)+"/") {
|
||||
candidates = append(candidates, root+"/"+clean)
|
||||
}
|
||||
for _, candidate := range append([]string(nil), candidates...) {
|
||||
dir, base := splitPath(candidate)
|
||||
if base == "" || strings.HasPrefix(strings.ToLower(base), "(r)") {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, dir+"(r)"+base)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if idx, found := archive.Find(candidate); found {
|
||||
return idx, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func cleanPath(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
value = strings.TrimPrefix(value, "./")
|
||||
return strings.TrimPrefix(value, "/")
|
||||
}
|
||||
|
||||
func splitPath(value string) (string, string) {
|
||||
idx := strings.LastIndex(value, "/")
|
||||
if idx < 0 {
|
||||
return "", value
|
||||
}
|
||||
return value[:idx+1], value[idx+1:]
|
||||
}
|
||||
Reference in new issue
Block a user