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

592 lines
18 KiB
Go

package services
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"pvfine/internal/pvf"
scriptengine "pvfine/internal/script"
)
type blockingScriptRuntime struct {
started chan struct{}
once sync.Once
}
func (r *blockingScriptRuntime) Compile(string) scriptengine.CompileResult {
return scriptengine.CompileResult{Valid: true}
}
func (r *blockingScriptRuntime) Run(ctx context.Context, _ string, _ *scriptengine.BatchAPI) (scriptengine.RunResult, error) {
r.once.Do(func() { close(r.started) })
<-ctx.Done()
return scriptengine.RunResult{
Status: scriptengine.RunStatusCancelled,
Error: &scriptengine.Diagnostic{
Kind: scriptengine.ErrorKindCancelled,
Message: ctx.Err().Error(),
},
}, nil
}
func newScriptTestService(t *testing.T) (*core, *ScriptService, int32, int32) {
t.Helper()
c := NewCore()
a := pvf.New()
first, err := a.AddFileText("equipment/first.equ", "[price]\n1\n[name]\n`first`", pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
second, err := a.AddFileText("equipment/second.equ", "[price]\n20\n[name]\n`second`", pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
svc := newScriptService(c, scriptengine.NewGojaRuntime(), t.TempDir(), newFileSetService(filepath.Join(t.TempDir(), "file-sets.json")))
t.Cleanup(c.closeArchive)
return c, svc, first, second
}
func TestScriptServiceRunPreviewAndApplySelected(t *testing.T) {
c, svc, first, second := newScriptTestService(t)
liveFirstBefore, err := c.archive.Text(first)
if err != nil {
t.Fatal(err)
}
liveSecondBefore, err := c.archive.Text(second)
if err != nil {
t.Fatal(err)
}
request := ScriptRunRequest{
Name: "raise-prices",
Source: `for (const file of pvf.glob("equipment/**/*.equ")) {
const document = file.parse();
const price = document.section("price");
if (price && price.get() < 10) {
price.set(10);
file.write(document);
}
}`,
}
result, err := svc.Run(nil, request)
if err != nil {
t.Fatal(err)
}
if result.Status != ScriptRunCompleted || result.PlanID == "" || result.ModifiedFiles != 1 {
t.Fatalf("run result = %#v", result)
}
beforeFirst, err := c.archive.Text(first)
if err != nil {
t.Fatal(err)
}
if beforeFirst != liveFirstBefore {
t.Fatalf("preview changed live archive: before=%q after=%q", liveFirstBefore, beforeFirst)
}
beforeSecond, err := c.archive.Text(second)
if err != nil {
t.Fatal(err)
}
page, err := svc.PreviewPage(result.PlanID, "", 0, 100)
if err != nil {
t.Fatal(err)
}
if page.ModifiedFiles != 1 || page.MatchedFiles != 1 || len(page.Rows) != 1 || page.Rows[0].FileIndex != first {
t.Fatalf("preview page = %#v", page)
}
if page.Rows[0].ChangeKey == "" || page.Rows[0].Status != ScriptFileChanged {
t.Fatalf("preview row identity = %#v", page.Rows[0])
}
if len(page.Rows[0].Diff) == 0 {
t.Fatal("preview diff is empty")
}
apply, err := svc.Apply(result.PlanID, []string{page.Rows[0].ChangeKey})
if err != nil {
t.Fatal(err)
}
if apply.AppliedFiles != 1 || len(apply.FileIndexes) != 1 || apply.FileIndexes[0] != first {
t.Fatalf("apply result = %#v", apply)
}
afterFirst, err := c.archive.Text(first)
if err != nil {
t.Fatal(err)
}
if afterFirst == beforeFirst {
t.Fatal("selected script change was not applied")
}
afterSecond, err := c.archive.Text(second)
if err != nil {
t.Fatal(err)
}
if afterSecond != beforeSecond || beforeSecond != liveSecondBefore {
t.Fatalf("unselected file changed: before=%q after=%q", beforeSecond, afterSecond)
}
if _, err := svc.PreviewPage(result.PlanID, "", 0, 10); !errors.Is(err, ErrScriptPlanStale) {
t.Fatalf("applied plan error = %v, want ErrScriptPlanStale", err)
}
}
func TestScriptServicePreviewPageFiltersByPath(t *testing.T) {
c := NewCore()
a := pvf.New()
// Three matching files and two non-matching ones, spread across paths.
for _, path := range []string{
"equipment/alpha.equ",
"equipment/beta.equ",
"equipment/nested/gamma.equ",
"text/name.str",
"text/other.str",
} {
if _, err := a.AddFileText(path, "[price]\n1\n[name]\n`x`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
svc := newScriptService(c, scriptengine.NewGojaRuntime(), t.TempDir(), newFileSetService(filepath.Join(t.TempDir(), "file-sets.json")))
result, err := svc.Run(nil, ScriptRunRequest{Source: `
for (const file of pvf.glob("**/*")) {
file.setText("[price]\n2\n[name]\n` + "`x`" + `");
}
`})
if err != nil {
t.Fatal(err)
}
if result.ModifiedFiles != 5 {
t.Fatalf("modified files = %d, want 5", result.ModifiedFiles)
}
// An empty filter returns everything and reports the full total.
all, err := svc.PreviewPage(result.PlanID, "", 0, 100)
if err != nil {
t.Fatal(err)
}
if all.MatchedFiles != 5 || len(all.Rows) != 5 {
t.Fatalf("unfiltered page = %d rows, matched %d", len(all.Rows), all.MatchedFiles)
}
// A filter narrows rows and the matched total together.
filtered, err := svc.PreviewPage(result.PlanID, "equipment/", 0, 100)
if err != nil {
t.Fatal(err)
}
if filtered.MatchedFiles != 3 || len(filtered.Rows) != 3 {
t.Fatalf("filtered page = %d rows, matched %d, want 3", len(filtered.Rows), filtered.MatchedFiles)
}
if filtered.Filter != "equipment/" {
t.Fatalf("filter echo = %q", filtered.Filter)
}
for _, row := range filtered.Rows {
if !strings.Contains(row.Path, "equipment/") {
t.Fatalf("row %q does not match the filter", row.Path)
}
}
// Filtering is case-insensitive.
upper, err := svc.PreviewPage(result.PlanID, "EQUIPMENT/", 0, 100)
if err != nil {
t.Fatal(err)
}
if upper.MatchedFiles != 3 {
t.Fatalf("case-insensitive matched = %d, want 3", upper.MatchedFiles)
}
// Pagination walks only the filtered rows, so the total stays consistent.
firstPage, err := svc.PreviewPage(result.PlanID, "equipment/", 0, 2)
if err != nil {
t.Fatal(err)
}
if len(firstPage.Rows) != 2 || firstPage.NextCursor != 2 || firstPage.MatchedFiles != 3 {
t.Fatalf("first page = %d rows cursor %d matched %d", len(firstPage.Rows), firstPage.NextCursor, firstPage.MatchedFiles)
}
secondPage, err := svc.PreviewPage(result.PlanID, "equipment/", firstPage.NextCursor, 2)
if err != nil {
t.Fatal(err)
}
if len(secondPage.Rows) != 1 || secondPage.NextCursor != -1 {
t.Fatalf("second page = %d rows cursor %d", len(secondPage.Rows), secondPage.NextCursor)
}
// A filter matching nothing reports zero rather than falling back to all.
empty, err := svc.PreviewPage(result.PlanID, "no-such-path", 0, 100)
if err != nil {
t.Fatal(err)
}
if empty.MatchedFiles != 0 || len(empty.Rows) != 0 {
t.Fatalf("empty filter page = %d rows, matched %d", len(empty.Rows), empty.MatchedFiles)
}
}
func TestScriptServiceSelectableChangeKeysRespectsFilter(t *testing.T) {
c := NewCore()
a := pvf.New()
for _, path := range []string{"equipment/alpha.equ", "equipment/beta.equ", "text/name.str"} {
if _, err := a.AddFileText(path, "[price]\n1", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
svc := newScriptService(c, scriptengine.NewGojaRuntime(), t.TempDir(), newFileSetService(filepath.Join(t.TempDir(), "file-sets.json")))
result, err := svc.Run(nil, ScriptRunRequest{Source: `
for (const file of pvf.glob("**/*")) file.setText("[price]\n9");
`})
if err != nil {
t.Fatal(err)
}
// The keys must cover every matching row, not just one loaded page, so the
// UI can apply a filtered set without having paged through all of it.
keys, err := svc.SelectableChangeKeys(result.PlanID, "equipment/")
if err != nil {
t.Fatal(err)
}
if len(keys) != 2 {
t.Fatalf("filtered keys = %#v, want 2", keys)
}
for _, key := range keys {
if !strings.Contains(key, "equipment/") {
t.Fatalf("key %q does not match the filter", key)
}
}
all, err := svc.SelectableChangeKeys(result.PlanID, "")
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("unfiltered keys = %#v, want 3", all)
}
}
func TestScriptServiceFailureDoesNotMutateLiveArchive(t *testing.T) {
c, svc, first, _ := newScriptTestService(t)
before, err := c.archive.Text(first)
if err != nil {
t.Fatal(err)
}
result, err := svc.Run(nil, ScriptRunRequest{Source: `
const file = pvf.find("equipment/first.equ");
file.setText("[price]\n999");
throw new Error("abort");
`})
if err != nil {
t.Fatal(err)
}
if result.Status != ScriptRunFailed || result.PlanID != "" || result.Error == nil {
t.Fatalf("failure result = %#v", result)
}
after, err := c.archive.Text(first)
if err != nil {
t.Fatal(err)
}
if after != before {
t.Fatalf("failed script mutated live archive: before=%q after=%q", before, after)
}
}
func TestScriptServicePreviewAndApplyStructuralChanges(t *testing.T) {
c, svc, _, second := newScriptTestService(t)
beforeSecond, err := c.archive.Text(second)
if err != nil {
t.Fatal(err)
}
result, err := svc.Run(nil, ScriptRunRequest{Name: "restructure", Source: `
pvf.createFile("equipment/added.equ", pvf.types.script, "[price]\n7");
pvf.deleteFile("equipment/second.equ");
`})
if err != nil {
t.Fatal(err)
}
if result.Status != ScriptRunCompleted || result.PlanID == "" || result.ModifiedFiles != 2 {
t.Fatalf("run result = %#v", result)
}
if _, ok := c.archive.Find("equipment/added.equ"); ok {
t.Fatal("preview created a live entry")
}
if _, ok := c.archive.Find("equipment/second.equ"); !ok {
t.Fatal("preview deleted a live entry")
}
page, err := svc.PreviewPage(result.PlanID, "", 0, 100)
if err != nil {
t.Fatal(err)
}
statusByKey := make(map[string]string, len(page.Rows))
for _, row := range page.Rows {
statusByKey[row.ChangeKey] = row.Status
}
if statusByKey["equipment/added.equ"] != ScriptFileAdded {
t.Fatalf("added row status = %q rows=%#v", statusByKey["equipment/added.equ"], page.Rows)
}
if statusByKey["equipment/second.equ"] != ScriptFileDeleted {
t.Fatalf("deleted row status = %q rows=%#v", statusByKey["equipment/second.equ"], page.Rows)
}
// Applying only the creation must leave the deletion staged, not applied.
apply, err := svc.Apply(result.PlanID, []string{"equipment/added.equ"})
if err != nil {
t.Fatal(err)
}
if apply.AppliedFiles != 1 {
t.Fatalf("apply result = %#v", apply)
}
added, ok := c.archive.Find("equipment/added.equ")
if !ok {
t.Fatal("selected creation was not applied")
}
if text, textErr := c.archive.Text(added); textErr != nil || !strings.Contains(text, "7") {
t.Fatalf("created text = %q err=%v", text, textErr)
}
if _, ok := c.archive.Find("equipment/second.equ"); !ok {
t.Fatal("unselected deletion was applied")
}
if text, textErr := c.archive.Text(second); textErr != nil || text != beforeSecond {
t.Fatalf("unselected file changed: %q err=%v", text, textErr)
}
}
func TestScriptServiceListEditFlowsThroughPreviewAndApply(t *testing.T) {
c := NewCore()
a := pvf.New()
if _, err := a.AddFileText(
"equipment/equipment.lst",
"1008 `character/a.equ`",
pvf.TypeScript,
); err != nil {
t.Fatal(err)
}
for _, path := range []string{"equipment/character/a.equ", "equipment/character/b.equ"} {
if _, err := a.AddFileText(path, "[name]\n`x`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
svc := newScriptService(c, scriptengine.NewGojaRuntime(), t.TempDir(), newFileSetService(filepath.Join(t.TempDir(), "file-sets.json")))
listIndex, _ := c.archive.Find("equipment/equipment.lst")
before, err := c.archive.RawBytes(listIndex)
if err != nil {
t.Fatal(err)
}
result, err := svc.Run(nil, ScriptRunRequest{Name: "register", Source: `
const lst = pvf.lst("equipment/equipment.lst");
lst.set("1009", "character/b.equ");
`})
if err != nil {
t.Fatal(err)
}
if result.Status != ScriptRunCompleted || result.PlanID == "" || result.ModifiedFiles != 1 {
t.Fatalf("run result = %#v", result)
}
// The preview must not touch the live list.
afterRun, err := c.archive.RawBytes(listIndex)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, afterRun) {
t.Fatal("preview mutated the live list")
}
page, err := svc.PreviewPage(result.PlanID, "", 0, 100)
if err != nil {
t.Fatal(err)
}
if len(page.Rows) != 1 || page.Rows[0].Path != "equipment/equipment.lst" {
t.Fatalf("preview rows = %#v", page.Rows)
}
// A list edit is an ordinary payload change, not a structural one.
if page.Rows[0].Status != ScriptFileChanged {
t.Fatalf("row status = %q", page.Rows[0].Status)
}
apply, err := svc.Apply(result.PlanID, []string{page.Rows[0].ChangeKey})
if err != nil {
t.Fatal(err)
}
if apply.Structural {
t.Fatal("a list edit should not be reported as structural")
}
applied, ok := c.archive.Find("equipment/equipment.lst")
if !ok {
t.Fatal("list file disappeared")
}
pairs, err := c.archive.ListPairs(applied)
if err != nil {
t.Fatal(err)
}
if len(pairs) != 2 || pairs[1].ID != "1009" || pairs[1].Path != "character/b.equ" {
t.Fatalf("applied pairs = %#v", pairs)
}
}
func TestScriptServiceApplyDeleteRemovesEntryAndRebuildsTree(t *testing.T) {
c, svc, first, second := newScriptTestService(t)
result, err := svc.Run(nil, ScriptRunRequest{Source: `
pvf.deleteFile("equipment/second.equ");
pvf.find("equipment/first.equ").setText("[price]\n42\n[name]\n` + "`first`" + `");
`})
if err != nil {
t.Fatal(err)
}
if result.PlanID == "" {
t.Fatalf("result = %#v", result)
}
if _, err := svc.Apply(result.PlanID, []string{"equipment/second.equ", "equipment/first.equ"}); err != nil {
t.Fatal(err)
}
if _, ok := c.archive.Find("equipment/second.equ"); ok {
t.Fatal("deleted entry still resolves in the live archive")
}
// The payload edit must land on the surviving entry even though the
// deletion renumbered it.
remaining, ok := c.archive.Find("equipment/first.equ")
if !ok {
t.Fatal("surviving entry disappeared")
}
if text, textErr := c.archive.Text(remaining); textErr != nil || !strings.Contains(text, "42") {
t.Fatalf("surviving text = %q err=%v", text, textErr)
}
if c.editorText[remaining] == "" {
t.Fatal("editor text cache was not refreshed for the surviving entry")
}
// The tree index must reflect the removal.
if _, stillThere := c.dirChildren["equipment"]; stillThere {
for _, node := range c.dirChildren["equipment"] {
if node.FileIndex == second {
t.Fatal("removed entry is still present in the tree index")
}
}
}
_ = first
}
func TestScriptPlanInvalidatedByEditorEdit(t *testing.T) {
c, svc, first, _ := newScriptTestService(t)
result, err := svc.Run(nil, ScriptRunRequest{Source: `
const file = pvf.find("equipment/first.equ");
file.setText("[price]\n10");
`})
if err != nil {
t.Fatal(err)
}
if result.PlanID == "" {
t.Fatalf("result = %#v", result)
}
if _, _, err := c.setText(first, "[price]\n2"); err != nil {
t.Fatal(err)
}
if _, err := svc.PreviewPage(result.PlanID, "", 0, 10); !errors.Is(err, ErrScriptPlanStale) {
t.Fatalf("stale preview error = %v, want ErrScriptPlanStale", err)
}
}
func TestScriptServiceCancelsOnArchiveCloseAndRejectsConcurrentRun(t *testing.T) {
c := NewCore()
a := pvf.New()
if _, err := a.AddFileText("equipment/first.equ", "[price]\n1", pvf.TypeScript); err != nil {
t.Fatal(err)
}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
runtime := &blockingScriptRuntime{started: make(chan struct{})}
svc := newScriptService(c, runtime, t.TempDir(), newFileSetService(filepath.Join(t.TempDir(), "file-sets.json")))
done := make(chan ScriptRunResult, 1)
errDone := make(chan error, 1)
go func() {
result, err := svc.Run(context.Background(), ScriptRunRequest{Source: "void 0"})
done <- result
errDone <- err
}()
select {
case <-runtime.started:
case <-time.After(time.Second):
t.Fatal("script runtime did not start")
}
if _, err := svc.Run(context.Background(), ScriptRunRequest{Source: "void 0"}); !errors.Is(err, ErrScriptBusy) {
t.Fatalf("concurrent run error = %v, want ErrScriptBusy", err)
}
c.closeArchive()
select {
case result := <-done:
if result.Status != ScriptRunCancelled {
t.Fatalf("cancelled result = %#v", result)
}
case <-time.After(time.Second):
t.Fatal("script did not stop after archive close")
}
if err := <-errDone; err != nil {
t.Fatal(err)
}
}
func TestScriptDirectoryValidationAndAtomicSave(t *testing.T) {
directory := t.TempDir()
svc := newScriptService(NewCore(), scriptengine.NewGojaRuntime(), directory, newFileSetService(filepath.Join(directory, "file-sets.json")))
if err := svc.SaveScript("z.pvf.js", "z"); err != nil {
t.Fatal(err)
}
if err := svc.SaveScript("a", "a"); err != nil {
t.Fatal(err)
}
external := filepath.Join(t.TempDir(), "external.pvf.js")
if err := os.WriteFile(external, []byte("external"), 0o600); err != nil {
t.Fatal(err)
}
linkCreated := os.Symlink(external, filepath.Join(directory, "link.pvf.js")) == nil
if _, err := svc.LoadScript("../escape.pvf.js"); err == nil {
t.Fatal("path traversal was accepted")
}
if _, err := svc.LoadScript("nested/name.pvf.js"); err == nil {
t.Fatal("nested script path was accepted")
}
if linkCreated {
if _, err := svc.LoadScript("link.pvf.js"); err == nil {
t.Fatal("script symlink was accepted")
}
}
files, err := svc.ListScripts()
if err != nil {
t.Fatal(err)
}
if len(files) != 2 || files[0].Name != "a.pvf.js" || files[1].Name != "z.pvf.js" {
t.Fatalf("scripts = %#v", files)
}
source, err := svc.LoadScript("a.pvf.js")
if err != nil || source != "a" {
t.Fatalf("loaded source = %q, err = %v", source, err)
}
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if filepath.Ext(entry.Name()) == ".tmp" {
t.Fatalf("temporary save file left behind: %s", entry.Name())
}
}
}