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,505 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
const autosaveScriptPath = "test/example.equ"
|
||||
const autosaveKeepPath = "test/keep.equ"
|
||||
|
||||
// newAutosaveFixture writes a synthetic source archive and settings that point
|
||||
// the backup slot at a nested temporary directory (so the directory creation
|
||||
// path is covered too).
|
||||
func newAutosaveFixture(t *testing.T) (sourcePath string, settings *SettingsService, cachePath string) {
|
||||
t.Helper()
|
||||
built := pvf.New()
|
||||
if _, err := built.AddFileText(autosaveScriptPath, "[name]\n`before`", pvf.TypeScript); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := built.AddFileText(autosaveKeepPath, "[name]\n`keep`", pvf.TypeScript); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := built.SaveTo(&encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourcePath = filepath.Join(t.TempDir(), "Script.pvf")
|
||||
if err := os.WriteFile(sourcePath, encoded.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
settings = newSettingsService(filepath.Join(t.TempDir(), "settings.json"))
|
||||
appSettings := DefaultAppSettings()
|
||||
appSettings.AutosaveEnabled = true
|
||||
cachePath = filepath.Join(t.TempDir(), "backup", "autosave.pvf")
|
||||
appSettings.AutosavePath = cachePath
|
||||
if err := settings.SaveSettings(appSettings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sourcePath, settings, cachePath
|
||||
}
|
||||
|
||||
func openAutosaveCore(t *testing.T, sourcePath string) *core {
|
||||
t.Helper()
|
||||
core := NewCore()
|
||||
t.Cleanup(core.closeArchive)
|
||||
if _, err := core.openArchive(sourcePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return core
|
||||
}
|
||||
|
||||
func fixtureIndex(t *testing.T, c *core, path string) int32 {
|
||||
t.Helper()
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.archive == nil {
|
||||
t.Fatal("no archive loaded")
|
||||
}
|
||||
index, ok := c.archive.Find(path)
|
||||
if !ok {
|
||||
t.Fatalf("path %q missing from the archive", path)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func fixtureInfo(t *testing.T, c *core) ArchiveInfo {
|
||||
t.Helper()
|
||||
info, ok := c.archiveInfo()
|
||||
if !ok {
|
||||
t.Fatal("no archive loaded")
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func readFixtureBytes(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestAutosaveSnapshotKeepsWorkspaceDirty(t *testing.T) {
|
||||
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
||||
core := openAutosaveCore(t, sourcePath)
|
||||
service := NewAutosaveService(core, settings)
|
||||
editor := NewEditorService(core)
|
||||
|
||||
if err := editor.SetText(fixtureIndex(t, core, autosaveScriptPath), "[name]\n`after`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := readFixtureBytes(t, sourcePath)
|
||||
|
||||
if err := service.CreateSnapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info := fixtureInfo(t, core); info.ModifiedCount == 0 {
|
||||
t.Fatal("workspace lost its unsaved state after the snapshot")
|
||||
}
|
||||
if !bytes.Equal(original, readFixtureBytes(t, sourcePath)) {
|
||||
t.Fatal("snapshot wrote to the source file")
|
||||
}
|
||||
|
||||
backup, err := pvf.Open(cachePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open backup: %v", err)
|
||||
}
|
||||
index, ok := backup.Find(autosaveScriptPath)
|
||||
if !ok {
|
||||
t.Fatal("backup lost the edited file")
|
||||
}
|
||||
text, err := backup.Text(index)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(text, "after") {
|
||||
t.Fatalf("backup text = %q", text)
|
||||
}
|
||||
|
||||
meta, ok := readAutosaveMeta(cachePath)
|
||||
if !ok {
|
||||
t.Fatal("backup metadata missing")
|
||||
}
|
||||
if meta.SourcePath != sourcePath {
|
||||
t.Fatalf("metadata source = %q, want %q", meta.SourcePath, sourcePath)
|
||||
}
|
||||
if len(meta.ModifiedPaths) != 1 || meta.ModifiedPaths[0] != autosaveScriptPath {
|
||||
t.Fatalf("metadata modified paths = %v", meta.ModifiedPaths)
|
||||
}
|
||||
if len(meta.AddedPaths) != 0 {
|
||||
t.Fatalf("metadata added paths = %v, want none", meta.AddedPaths)
|
||||
}
|
||||
if meta.SourceSize == 0 || meta.SourceModified == 0 || meta.CachedAt == 0 {
|
||||
t.Fatalf("metadata source identity = %#v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutosaveSkipsCleanWorkspace(t *testing.T) {
|
||||
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
||||
core := openAutosaveCore(t, sourcePath)
|
||||
service := NewAutosaveService(core, settings)
|
||||
|
||||
if err := service.CreateSnapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("clean workspace wrote a backup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutosaveRestoreReplaysInterruptedSession(t *testing.T) {
|
||||
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
||||
|
||||
// First session: edit one file, add one, delete one, then snapshot. The
|
||||
// process is "killed" by dropping the core without saving.
|
||||
first := openAutosaveCore(t, sourcePath)
|
||||
firstService := NewAutosaveService(first, settings)
|
||||
if err := NewEditorService(first).SetText(fixtureIndex(t, first, autosaveScriptPath), "[name]\n`recovered`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archives := NewArchiveService(first)
|
||||
added, err := archives.CreateFile("custom/new.stk", pvf.TypeScript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewEditorService(first).SetText(added.FileIndex, "[name]\n`新增文件`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := archives.DeleteFiles([]int32{fixtureIndex(t, first, autosaveKeepPath)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := firstService.CreateSnapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.closeArchive()
|
||||
|
||||
// Second session: nothing is open yet, exactly like a fresh start.
|
||||
second := NewCore()
|
||||
t.Cleanup(second.closeArchive)
|
||||
recovery := NewAutosaveService(second, settings)
|
||||
pending, err := recovery.PendingRecovery()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending == nil {
|
||||
t.Fatal("no pending recovery reported")
|
||||
}
|
||||
if pending.SourcePath != sourcePath || !pending.SourceExists {
|
||||
t.Fatalf("pending = %#v", pending)
|
||||
}
|
||||
if pending.SourceChanged {
|
||||
t.Fatal("source reported as changed right after the snapshot")
|
||||
}
|
||||
if pending.PendingFiles != 2 {
|
||||
t.Fatalf("pending files = %d, want 2", pending.PendingFiles)
|
||||
}
|
||||
|
||||
info, err := recovery.Restore()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Path != sourcePath {
|
||||
t.Fatalf("restored path = %q, want %q", info.Path, sourcePath)
|
||||
}
|
||||
if info.ModifiedCount == 0 {
|
||||
t.Fatal("restored workspace is reported as saved")
|
||||
}
|
||||
|
||||
meta, _ := NewEditorService(second).GetFile(fixtureIndex(t, second, autosaveScriptPath))
|
||||
if !strings.Contains(meta.Text, "recovered") || !meta.Modified {
|
||||
t.Fatalf("restored file = %#v", meta)
|
||||
}
|
||||
if _, ok := second.archive.Find("custom/new.stk"); !ok {
|
||||
t.Fatal("restored workspace lost the added file")
|
||||
}
|
||||
if _, ok := second.archive.Find(autosaveKeepPath); ok {
|
||||
t.Fatal("restored workspace kept a file deleted before the snapshot")
|
||||
}
|
||||
|
||||
// The backup survives a successful restore: only a save clears it.
|
||||
if _, err := os.Stat(cachePath); err != nil {
|
||||
t.Fatalf("backup should be kept after restore: %v", err)
|
||||
}
|
||||
|
||||
// Saving writes the recovered edits back to the source and drops the slot.
|
||||
if _, err := NewEditorService(second).Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, err := pvf.Open(sourcePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
savedIndex, ok := saved.Find(autosaveScriptPath)
|
||||
if !ok {
|
||||
t.Fatal("saved archive lost the edited file")
|
||||
}
|
||||
text, err := saved.Text(savedIndex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(text, "recovered") {
|
||||
t.Fatalf("saved text = %q", text)
|
||||
}
|
||||
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("backup survived the save: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(cachePath + ".json"); !os.IsNotExist(err) {
|
||||
t.Fatalf("backup metadata survived the save: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The recovery has to land on an attached version session, otherwise the
|
||||
// panel would keep reporting a clean workspace while the overlay holds edits.
|
||||
func TestAutosaveRestoreFeedsVersionSession(t *testing.T) {
|
||||
sourcePath := writeSearchFixture(t, "autosave-version.pvf")
|
||||
settings := newSettingsService(filepath.Join(t.TempDir(), "settings.json"))
|
||||
appSettings := DefaultAppSettings()
|
||||
appSettings.AutosaveEnabled = true
|
||||
cachePath := filepath.Join(t.TempDir(), "autosave.pvf")
|
||||
appSettings.AutosavePath = cachePath
|
||||
if err := settings.SaveSettings(appSettings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first := openAutosaveCore(t, sourcePath)
|
||||
versions := NewVersionService(first)
|
||||
if _, err := versions.Initialize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index := fixtureIndex(t, first, "equipment/character/common/amulet/1008.equ")
|
||||
if err := NewEditorService(first).SetText(index, "[name]\n`缓存恢复的项链`\n[grade]\n9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewAutosaveService(first, settings).CreateSnapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.closeArchive()
|
||||
|
||||
second := NewCore()
|
||||
t.Cleanup(second.closeArchive)
|
||||
recovery := NewAutosaveService(second, settings)
|
||||
if _, err := recovery.Restore(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := NewVersionService(second).Status()
|
||||
if !status.Enabled || status.Loading {
|
||||
t.Fatalf("version status after restore = %#v", status)
|
||||
}
|
||||
if status.ChangedFiles != 1 || !status.NeedsSave {
|
||||
t.Fatalf("version status lost the recovered edit: %#v", status)
|
||||
}
|
||||
if status.PendingChangeSets != 1 {
|
||||
t.Fatalf("recovery should be undoable once: %#v", status)
|
||||
}
|
||||
}
|
||||
|
||||
// A commit without saving the PVF leaves HEAD ahead of the file on disk. The
|
||||
// recovery opens that file (the session materializes HEAD into it) and then has
|
||||
// to keep the backed-up edit, both in the overlay and in the version state.
|
||||
func TestAutosaveRestoreReplaysCommittedUnsavedEdit(t *testing.T) {
|
||||
sourcePath := writeSearchFixture(t, "autosave-committed.pvf")
|
||||
settings := newSettingsService(filepath.Join(t.TempDir(), "settings.json"))
|
||||
appSettings := DefaultAppSettings()
|
||||
appSettings.AutosaveEnabled = true
|
||||
cachePath := filepath.Join(t.TempDir(), "autosave.pvf")
|
||||
appSettings.AutosavePath = cachePath
|
||||
if err := settings.SaveSettings(appSettings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const target = "equipment/character/common/amulet/1008.equ"
|
||||
const edited = "[name]\n`缓存恢复的项链`\n[grade]\n9"
|
||||
|
||||
first := openAutosaveCore(t, sourcePath)
|
||||
versions := NewVersionService(first)
|
||||
if _, err := versions.Initialize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewEditorService(first).SetText(fixtureIndex(t, first, target), edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := versions.Commit("编辑项链"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The PVF itself is still the old revision on disk while HEAD already holds
|
||||
// the edit, exactly what a crash before saving leaves behind.
|
||||
if err := NewAutosaveService(first, settings).CreateSnapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.closeArchive()
|
||||
|
||||
second := NewCore()
|
||||
t.Cleanup(second.closeArchive)
|
||||
recovery := NewAutosaveService(second, settings)
|
||||
if _, err := recovery.Restore(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := NewEditorService(second).GetFile(fixtureIndex(t, second, target))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(meta.Text, "缓存恢复的项链") || !meta.Modified {
|
||||
t.Fatalf("restored file = %#v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutosaveRestoreRefusesDirtyWorkspace(t *testing.T) {
|
||||
sourcePath, settings, _ := newAutosaveFixture(t)
|
||||
core := openAutosaveCore(t, sourcePath)
|
||||
service := NewAutosaveService(core, settings)
|
||||
if err := NewEditorService(core).SetText(fixtureIndex(t, core, autosaveScriptPath), "[name]\n`still editing`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.CreateSnapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Restore(); err == nil {
|
||||
t.Fatal("expected restore to refuse a workspace with unsaved edits")
|
||||
}
|
||||
if info := fixtureInfo(t, core); info.ModifiedCount == 0 {
|
||||
t.Fatal("workspace lost its edits to a refused restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutosaveRestoreKeepsBackupWhenSourceMissing(t *testing.T) {
|
||||
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
||||
first := openAutosaveCore(t, sourcePath)
|
||||
snapshotter := NewAutosaveService(first, settings)
|
||||
if err := NewEditorService(first).SetText(fixtureIndex(t, first, autosaveScriptPath), "[name]\n`gone`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := snapshotter.CreateSnapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.closeArchive()
|
||||
if err := os.Remove(sourcePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fresh := NewCore()
|
||||
t.Cleanup(fresh.closeArchive)
|
||||
recovery := NewAutosaveService(fresh, settings)
|
||||
pending, err := recovery.PendingRecovery()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending == nil || pending.SourceExists {
|
||||
t.Fatalf("pending = %#v, want a backup with a missing source", pending)
|
||||
}
|
||||
if _, err := recovery.Restore(); err == nil {
|
||||
t.Fatal("expected restore to fail without the source file")
|
||||
}
|
||||
if _, err := os.Stat(cachePath); err != nil {
|
||||
t.Fatalf("backup should be kept for a later retry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutosaveDiscardRemovesSlot(t *testing.T) {
|
||||
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
||||
core := openAutosaveCore(t, sourcePath)
|
||||
service := NewAutosaveService(core, settings)
|
||||
if err := NewEditorService(core).SetText(fixtureIndex(t, core, autosaveScriptPath), "[name]\n`discarded`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.CreateSnapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
removed, err := service.Discard()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !removed {
|
||||
t.Fatal("discard reported nothing removed")
|
||||
}
|
||||
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("backup survived the discard: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(cachePath + ".json"); !os.IsNotExist(err) {
|
||||
t.Fatalf("metadata survived the discard: %v", err)
|
||||
}
|
||||
if service.Status().Exists {
|
||||
t.Fatal("status still reports a backup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutosaveDropForSourceIgnoresOtherArchives(t *testing.T) {
|
||||
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
||||
core := openAutosaveCore(t, sourcePath)
|
||||
service := NewAutosaveService(core, settings)
|
||||
if err := NewEditorService(core).SetText(fixtureIndex(t, core, autosaveScriptPath), "[name]\n`kept`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.CreateSnapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service.DropForSource(filepath.Join(t.TempDir(), "Other.pvf"))
|
||||
if _, err := os.Stat(cachePath); err != nil {
|
||||
t.Fatalf("backup of another archive was dropped: %v", err)
|
||||
}
|
||||
|
||||
service.DropForSource(sourcePath)
|
||||
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("backup survived the source drop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutosaveStatusReportsSlot(t *testing.T) {
|
||||
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
||||
core := openAutosaveCore(t, sourcePath)
|
||||
service := NewAutosaveService(core, settings)
|
||||
|
||||
status := service.Status()
|
||||
if !status.Enabled {
|
||||
t.Fatal("status lost the enabled flag")
|
||||
}
|
||||
if status.Path != cachePath {
|
||||
t.Fatalf("status path = %q, want %q", status.Path, cachePath)
|
||||
}
|
||||
if status.Exists {
|
||||
t.Fatal("status reports a backup before the first snapshot")
|
||||
}
|
||||
if status.DefaultPath == "" {
|
||||
t.Fatal("status is missing the default backup path")
|
||||
}
|
||||
|
||||
if err := NewEditorService(core).SetText(fixtureIndex(t, core, autosaveScriptPath), "[name]\n`status`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.CreateSnapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status = service.Status()
|
||||
if !status.Exists || status.CachedAt == 0 || status.SizeBytes == 0 {
|
||||
t.Fatalf("status after snapshot = %#v", status)
|
||||
}
|
||||
if status.SourcePath != sourcePath {
|
||||
t.Fatalf("status source = %q, want %q", status.SourcePath, sourcePath)
|
||||
}
|
||||
if status.LastError != "" {
|
||||
t.Fatalf("status error = %q, want none", status.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutosaveSnapshotRequiresArchive(t *testing.T) {
|
||||
_, settings, cachePath := newAutosaveFixture(t)
|
||||
core := NewCore()
|
||||
t.Cleanup(core.closeArchive)
|
||||
service := NewAutosaveService(core, settings)
|
||||
|
||||
if err := service.CreateSnapshot(); err == nil {
|
||||
t.Fatal("expected an error without an open archive")
|
||||
}
|
||||
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("backup written without an archive: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user