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