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

506 lines
15 KiB
Go

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