Files
90CNServer/pvf/services/editor.go
T
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

685 lines
20 KiB
Go

package services
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"pvfine/internal/pvf"
)
// EditorService: 文件内容读取、内存编辑、保存/另存为、导出与整包解包。
type EditorService struct {
c *core
settings *SettingsService
}
func NewEditorService(c *core, settings ...*SettingsService) *EditorService {
service := &EditorService{c: c}
if len(settings) > 0 {
service.settings = settings[0]
}
return service
}
// editableByteLimit reads the current setting so changes apply on the next open.
func (s *EditorService) editableByteLimit() int32 {
if s.settings != nil {
return editableByteLimit(s.settings)
}
return s.c.editableByteLimit()
}
func editableByteLimit(service *SettingsService) int32 {
if service != nil {
if settings, err := service.GetSettings(); err == nil {
return int32(settings.MaxEditableSizeMB) << 20
}
}
return DefaultMaxEditableSizeMB << 20
}
// FileMeta 返回给前端的单个文件视图。
type FileMeta struct {
Index int32 `json:"index"`
Path string `json:"path"`
DataType int32 `json:"dataType"`
Size int32 `json:"size"`
Tags []TreeTag `json:"tags,omitempty"`
Editable bool `json:"editable"`
Text string `json:"text"`
Modified bool `json:"modified"`
Annotations []EditorAnnotation `json:"annotations,omitempty"`
Icon *ImageReference `json:"icon,omitempty"`
FieldImage *ImageReference `json:"fieldImage,omitempty"`
}
// GetFile 返回文件的反编译文本(内存编辑视图)。
func (s *EditorService) GetFile(index int32) (*FileMeta, error) {
return s.getFile(index)
}
// GetFileBasic returns the rendered file body without annotations or indexed
// metadata so the editor can display useful content before enrichment loads.
func (s *EditorService) GetFileBasic(index int32) (*FileMeta, error) {
defer debugPhase("GetFileBasic", fmt.Sprintf("file=%d", index))()
wait := time.Now()
diagnoseCoreLock("GetFileBasic", fmt.Sprintf("file=%d", index), s.c.mu.RLock)
debugLockAcquired("GetFileBasic", wait)
defer s.c.mu.RUnlock()
a := s.c.archive
if a == nil {
return nil, ErrNoArchive
}
if err := validateAnnotationIndex(a, index); err != nil {
return nil, err
}
file := a.File(index)
meta := &FileMeta{
Index: index, Path: a.Path(index), DataType: file.DataType,
Size: file.DataSize, Editable: false, Modified: a.IsModified(index),
}
switch file.DataType {
case pvf.TypeScript, pvf.TypeUnicode:
if maxEditableBytes := s.editableByteLimit(); file.DataSize > maxEditableBytes {
meta.Text = fmt.Sprintf("; 文件过大(%d 字节),超过文本编辑上限 %d 字节。可在设置 → 编辑器中调整大文件编辑阈值,然后关闭并重新打开此文件", file.DataSize, maxEditableBytes)
return meta, nil
}
text, ok := s.c.editorText[index]
debugLog("GetFileBasic file=%d edited-text-cache=%t", index, ok)
if !ok {
var err error
finish := debugPhase("GetFileBasic.text", meta.Path)
text, err = a.Text(index)
finish()
if err != nil {
return nil, err
}
}
meta.Editable = a.WriteCapabilities().CanSave
meta.Text = text
default:
if strings.HasSuffix(strings.ToLower(meta.Path), ".ani") {
return s.readBinaryANI(a, index, meta)
}
meta.Text = fmt.Sprintf("; 不支持的类型 %d(v1 仅支持脚本/文本编辑)", file.DataType)
}
return meta, nil
}
func (s *EditorService) getFile(index int32) (*FileMeta, error) {
defer debugPhase("GetFile", fmt.Sprintf("file=%d", index))()
wait := time.Now()
diagnoseCoreLock("GetFile", fmt.Sprintf("file=%d", index), s.c.mu.Lock)
debugLockAcquired("GetFile", wait)
defer s.c.mu.Unlock()
a := s.c.archive
if a == nil {
return nil, ErrNoArchive
}
if err := validateAnnotationIndex(a, index); err != nil {
return nil, err
}
f := a.File(index)
meta := &FileMeta{
Index: index,
Path: a.Path(index),
DataType: f.DataType,
Size: f.DataSize,
Editable: false,
}
finishMetadata := debugPhase("GetFile.metadata", meta.Path)
meta.Tags = cloneTreeTags(s.c.treeTagsByFile[index])
if s.c.diskIndex != nil {
meta.Tags, _ = s.c.diskIndex.tags(index)
}
visuals := s.c.fileVisualsLocked(index)
meta.Icon = cloneImageReference(visuals.icon)
meta.FieldImage = cloneImageReference(visuals.fieldImage)
finishMetadata()
switch f.DataType {
case pvf.TypeScript, pvf.TypeUnicode:
if maxEditableBytes := s.editableByteLimit(); f.DataSize > maxEditableBytes {
meta.Text = fmt.Sprintf("; 文件过大(%d 字节),超过文本编辑上限 %d 字节。可在设置 → 编辑器中调整大文件编辑阈值,然后关闭并重新打开此文件", f.DataSize, maxEditableBytes)
return meta, nil
}
text, ok := s.c.editorText[index]
debugLog("GetFile file=%d edited-text-cache=%t", index, ok)
if !ok {
var err error
finish := debugPhase("GetFile.text", meta.Path)
text, err = a.Text(index)
finish()
if err != nil {
return nil, err
}
}
meta.Editable = a.WriteCapabilities().CanSave
meta.Text = text
if f.DataType == pvf.TypeScript {
annotations, err := s.c.editorAnnotationsLocked(index, text)
if err != nil {
return nil, err
}
meta.Annotations = annotations
}
default:
if strings.HasSuffix(strings.ToLower(meta.Path), ".ani") {
return s.readBinaryANI(a, index, meta)
}
meta.Text = fmt.Sprintf("; 不支持的类型 %d(v1 仅支持脚本/文本编辑)", f.DataType)
}
meta.Modified = a.IsModified(index)
return meta, nil
}
// GetAnnotations recomputes annotations against the exact current editor text.
func (s *EditorService) GetAnnotations(index int32) ([]EditorAnnotation, error) {
defer debugPhase("GetAnnotations", fmt.Sprintf("file=%d", index))()
wait := time.Now()
diagnoseCoreLock("GetAnnotations", fmt.Sprintf("file=%d", index), s.c.mu.Lock)
debugLockAcquired("GetAnnotations", wait)
defer s.c.mu.Unlock()
if s.c.archive == nil {
return nil, ErrNoArchive
}
if err := validateAnnotationIndex(s.c.archive, index); err != nil {
return nil, err
}
if s.c.archive.File(index).DataType != pvf.TypeScript {
return []EditorAnnotation{}, nil
}
text, ok := s.c.editorText[index]
if !ok {
var err error
text, err = s.c.archive.Text(index)
if err != nil {
return nil, err
}
}
return s.c.editorAnnotationsLocked(index, text)
}
// GetAnnotationsForText annotates a frontend draft without committing it to the archive.
func (s *EditorService) GetAnnotationsForText(index int32, text string) ([]EditorAnnotation, error) {
s.c.mu.Lock()
defer s.c.mu.Unlock()
if s.c.archive == nil {
return nil, ErrNoArchive
}
if err := validateAnnotationIndex(s.c.archive, index); err != nil {
return nil, err
}
if s.c.archive.File(index).DataType != pvf.TypeScript {
return []EditorAnnotation{}, nil
}
return s.c.editorAnnotationsLocked(index, text)
}
// SetText 把编辑后的文本写入内存 overlay(不落盘)。
func (s *EditorService) SetText(index int32, text string) error {
_, _, err := s.c.setText(index, text)
return err
}
// SetPlaceholderText rewrites the display text behind one `<table::key>`
// placeholder of a script. index is the script being edited; tableIndex and key
// come from the placeholder annotation. Only the `.str` payload changes — the
// script keeps its placeholder, so no stored script data is altered.
func (s *EditorService) SetPlaceholderText(index int32, tableIndex int32, key string, text string) error {
return s.c.setPlaceholderText(index, tableIndex, key, text)
}
// Save 把全部内存修改写回源文件(原子写:临时文件 + rename)。
func (s *EditorService) Save() (ArchiveInfo, error) {
progress := archiveSaveProgressEmitter()
s.c.mu.Lock()
a := s.c.archive
if a == nil {
s.c.mu.Unlock()
return ArchiveInfo{}, ErrNoArchive
}
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return ArchiveInfo{}, err
}
if a.SourcePath() == "" {
s.c.mu.Unlock()
return ArchiveInfo{}, fmt.Errorf("归档没有源文件,请使用另存为")
}
if s.shouldBackupSource() {
if err := backupSourceFileWithProgress(a.SourcePath(), progress); err != nil {
s.c.mu.Unlock()
return ArchiveInfo{}, err
}
}
if err := a.SaveWithProgress(progress); err != nil {
s.c.mu.Unlock()
return ArchiveInfo{}, err
}
s.c.mcpSourceHash = a.SourceHash()
s.c.mcpSaveSequence++
s.c.markAdvancedSearchStaleLocked(a)
saveMutations := a.MutationsSince(0)
a.ClearMutations()
if err := s.c.markVersionArtifactSavedLocked(a.SourcePath()); err != nil {
s.c.mu.Unlock()
return ArchiveInfo{}, err
}
info := a.Info()
sourcePath := a.SourcePath()
s.c.mu.Unlock()
if err := s.c.scheduleArchiveMutations(a, saveMutations); err != nil {
return ArchiveInfo{}, err
}
s.c.persistCurrentSQLiteIndexAsync()
s.c.persistCurrentSearchIndexCacheAsync()
if s.c.autosave != nil {
// The workspace is on disk now, so the backup slot has nothing left to
// protect. A cache belonging to another archive is kept.
s.c.autosave.DropForSource(sourcePath)
}
emitEvent("archive:saved", info)
return info, nil
}
func (s *EditorService) shouldBackupSource() bool {
if s.settings == nil {
return true
}
settings, err := s.settings.GetSettings()
if err != nil {
// 读取设置失败时使用安全默认值,不要因为配置文件问题阻止保存。
return true
}
return settings.BackupSourceOnSave
}
func backupSourceFile(sourcePath string) error {
return backupSourceFileWithProgress(sourcePath, nil)
}
// Progress events are throttled per operation; phase transitions and the final
// count always arrive, without flooding the webview for every page or chunk.
func archiveSaveProgressEmitter() pvf.SaveProgressFunc {
var lastPhase string
var lastEmit time.Time
return func(progress pvf.SaveProgress) {
now := time.Now()
complete := progress.Total > 0 && progress.Done >= progress.Total
if progress.Phase != lastPhase || complete || now.Sub(lastEmit) >= 100*time.Millisecond {
emitEvent("archive:save-progress", progress)
lastPhase, lastEmit = progress.Phase, now
}
}
}
type backupProgressWriter struct {
writer io.Writer
done, total int64
progress pvf.SaveProgressFunc
}
func (w *backupProgressWriter) Write(p []byte) (int, error) {
n, err := w.writer.Write(p)
w.done += int64(n)
w.progress(pvf.SaveProgress{Phase: "backup", Done: w.done, Total: w.total, Unit: "bytes"})
return n, err
}
func backupSourceFileWithProgress(sourcePath string, progress pvf.SaveProgressFunc) error {
source, err := os.Open(sourcePath)
if err != nil {
return fmt.Errorf("打开源文件以创建备份失败: %w", err)
}
defer source.Close()
info, err := source.Stat()
if err != nil {
return fmt.Errorf("读取源文件信息失败: %w", err)
}
backupPath := sourcePath + ".bak"
temp, err := os.CreateTemp(filepath.Dir(backupPath), "."+filepath.Base(backupPath)+"-*.tmp")
if err != nil {
return fmt.Errorf("创建源文件备份临时文件失败: %w", err)
}
tempPath := temp.Name()
defer os.Remove(tempPath)
mode := info.Mode().Perm()
if mode == 0 {
mode = 0o644
}
if err := temp.Chmod(mode); err != nil {
_ = temp.Close()
return fmt.Errorf("设置源文件备份权限失败: %w", err)
}
var destination io.Writer = temp
if progress != nil {
progress(pvf.SaveProgress{Phase: "backup", Total: info.Size(), Unit: "bytes"})
destination = &backupProgressWriter{writer: temp, total: info.Size(), progress: progress}
}
if _, err := io.CopyBuffer(destination, source, make([]byte, 1<<20)); err != nil {
_ = temp.Close()
return fmt.Errorf("写入源文件备份失败: %w", err)
}
if progress != nil {
progress(pvf.SaveProgress{Phase: "backup-sync"})
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return fmt.Errorf("同步源文件备份失败: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("关闭源文件备份失败: %w", err)
}
if err := os.Rename(tempPath, backupPath); err != nil {
return fmt.Errorf("替换源文件备份失败: %w", err)
}
return nil
}
// SaveAsDialog 弹出保存对话框并另存为新 PVF。返回保存路径。
func (s *EditorService) SaveAsDialog() (string, error) {
finishTask := s.c.archiveTasks.begin()
defer finishTask()
s.c.mu.RLock()
a := s.c.archive
if a == nil {
s.c.mu.RUnlock()
return "", ErrNoArchive
}
s.c.mu.RUnlock()
defName := "Script_new.pvf"
if src := a.SourcePath(); src != "" {
defName = filepath.Base(src)
}
path, err := application.Get().Dialog.SaveFile().
SetFilename(defName).
AddFilter("PVF 归档", "*.pvf").
SetMessage("另存为 PVF").
PromptForSingleSelection()
if err != nil {
return "", err
}
if path == "" {
return "", nil // 用户取消
}
s.c.mu.RLock()
sameArchive := s.c.archive == a
s.c.mu.RUnlock()
if !sameArchive {
return "", ErrNoArchive
}
return s.saveCopy(path, true, "")
}
type exportSelection struct {
index int32
path string
}
// ExportFilesDialog 将选中的文件或目录导出到目标目录,文件内容使用渲染后的 UTF-8 文本。
// 目录会递归展开,并保留归档内的相对路径。
func (s *EditorService) ExportFilesDialog(scopes []string) (string, error) {
finishTask := s.c.archiveTasks.begin()
defer finishTask()
s.c.mu.RLock()
a := s.c.archive
if a == nil {
s.c.mu.RUnlock()
return "", ErrNoArchive
}
var selections []exportSelection
if s.c.diskIndex != nil {
selections = collectDiskExportSelections(a, s.c.diskIndex, scopes)
} else {
selections = collectExportSelections(a, s.c.sortedPaths, scopes)
}
s.c.mu.RUnlock()
if len(selections) == 0 {
return "", fmt.Errorf("没有可导出的文件")
}
dir, err := application.Get().Dialog.OpenFile().
CanChooseFiles(false).
CanChooseDirectories(true).
CanCreateDirectories(true).
SetTitle("选择导出目标目录").
PromptForSingleSelection()
if err != nil {
return "", err
}
if dir == "" {
return "", nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
s.c.mu.RLock()
defer s.c.mu.RUnlock()
if s.c.archive != a {
return "", ErrNoArchive
}
for _, selection := range selections {
text, err := a.Text(selection.index)
if err != nil {
return "", fmt.Errorf("渲染 %q 失败: %w", selection.path, err)
}
dst, err := safeExportPath(dir, selection.path)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(dst, []byte(text), 0o644); err != nil {
return "", err
}
}
return dir, nil
}
func collectDiskExportSelections(a *pvf.Archive, index *sqliteArchiveIndex, scopes []string) []exportSelection {
selections := make([]exportSelection, 0, len(scopes))
seen := make(map[string]struct{}, len(scopes))
add := func(fileIndex int32, filePath string) {
filePath = normalizeExportPath(filePath)
if filePath == "" {
return
}
if _, ok := seen[filePath]; ok {
return
}
seen[filePath] = struct{}{}
selections = append(selections, exportSelection{index: fileIndex, path: filePath})
}
for _, rawScope := range scopes {
scope := normalizeExportPath(rawScope)
if scope == "" {
continue
}
if fileIndex, ok := a.Find(scope); ok {
add(fileIndex, a.Path(fileIndex))
continue
}
nodes, err := index.descendants(scope)
if err != nil {
continue
}
for _, node := range nodes {
add(node.FileIndex, node.Path)
}
}
return selections
}
func collectExportSelections(a *pvf.Archive, sortedPaths []pathEntry, scopes []string) []exportSelection {
selections := make([]exportSelection, 0, len(scopes))
seenPaths := make(map[string]struct{}, len(scopes))
add := func(index int32, path string) {
path = normalizeExportPath(path)
if path == "" {
return
}
if _, exists := seenPaths[path]; exists {
return
}
seenPaths[path] = struct{}{}
selections = append(selections, exportSelection{index: index, path: path})
}
for _, rawScope := range scopes {
scope := normalizeExportPath(rawScope)
if scope == "" {
continue
}
if index, ok := a.Find(scope); ok {
add(index, a.Path(index))
continue
}
prefix := scope + "/"
start := sort.Search(len(sortedPaths), func(i int) bool {
return sortedPaths[i].path >= prefix
})
for _, entry := range sortedPaths[start:] {
if !strings.HasPrefix(entry.path, prefix) {
break
}
add(entry.idx, entry.path)
}
}
return selections
}
func normalizeExportPath(path string) string {
path = strings.ReplaceAll(path, "\\", "/")
return strings.Trim(path, "/")
}
func safeExportPath(base, internal string) (string, error) {
parts := strings.Split(strings.ReplaceAll(internal, "\\", "/"), "/")
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
switch part {
case "", ".":
continue
case "..":
return "", fmt.Errorf("导出路径越界: %q", internal)
}
cleaned = append(cleaned, sanitizeExportName(part))
}
if len(cleaned) == 0 {
return "", fmt.Errorf("导出路径为空: %q", internal)
}
return filepath.Join(append([]string{base}, cleaned...)...), nil
}
func sanitizeExportName(name string) string {
return strings.Map(func(r rune) rune {
switch {
case r < 0x20 || r == 0x7f:
return '_'
case strings.ContainsRune(`<>:"|?*`, r):
return '_'
default:
return r
}
}, name)
}
// UnpackDialog 选择目录后,在后台协程把整包解包到该目录。
// 进度通过事件 "unpack:progress" {done,total} 推送,结束发 "unpack:done"。
func (s *EditorService) UnpackDialog() (bool, error) {
if !s.c.unpackRunning.CompareAndSwap(false, true) {
return false, fmt.Errorf("解包正在进行中")
}
s.c.mu.RLock()
a := s.c.archive
s.c.mu.RUnlock()
if a == nil {
s.c.unpackRunning.Store(false)
return false, ErrNoArchive
}
dir, err := application.Get().Dialog.OpenFile().
CanChooseFiles(false).
CanChooseDirectories(true).
CanCreateDirectories(true).
SetTitle("选择解包目标目录").
PromptForSingleSelection()
if err != nil {
s.c.unpackRunning.Store(false)
return false, err
}
if dir == "" {
s.c.unpackRunning.Store(false)
return false, nil // 用户取消
}
finishTask := s.c.archiveTasks.begin()
s.c.mu.Lock()
if s.c.archive != a || s.c.unpackCancel.Load() {
s.c.mu.Unlock()
finishTask()
s.c.unpackRunning.Store(false)
return false, nil
}
s.c.unpackCancel.Store(false)
s.c.mu.Unlock()
go func() {
defer finishTask()
defer s.c.unpackRunning.Store(false)
emit := emitEvent
total := int(a.FileCount())
progress := func(done, tot int) { emit("unpack:progress", map[string]int{"done": done, "total": tot}) }
cancel := func() bool { return s.c.unpackCancel.Load() }
err := a.ExtractTo(dir, progress, cancel)
if err == pvf.ErrCancelled {
emit("unpack:done", map[string]any{"ok": false, "message": "解包已取消", "dir": dir})
return
}
if err != nil {
emit("unpack:done", map[string]any{"ok": false, "message": "解包失败: " + err.Error(), "dir": dir})
return
}
emit("unpack:done", map[string]any{"ok": true, "message": fmt.Sprintf("已解包 %d 个文件到 %s", total, dir), "dir": dir})
}()
return true, nil
}
// CancelUnpack 请求中止进行中的解包。
func (s *EditorService) CancelUnpack() {
s.c.unpackCancel.Store(true)
}
// IsUnpacking 报告解包是否进行中。
func (s *EditorService) IsUnpacking() bool { return s.c.unpackRunning.Load() }
// Binary ANI is a read-only decoded view; native payload/type remain intact for
// extraction and byte-identical archive saves across all container families.
func (s *EditorService) readBinaryANI(a *pvf.Archive, index int32, meta *FileMeta) (*FileMeta, error) {
meta.Editable = false
meta.Modified = a.IsModified(index)
if meta.Size > s.editableByteLimit() {
meta.Text = "; 二进制 ANI 过大,超过文本查看上限。可在编辑器设置中调整。"
return meta, nil
}
text, err := a.Text(index)
if err != nil {
meta.Text = "; 二进制 ANI 解码失败:" + err.Error()
return meta, nil
}
meta.Text = text
return meta, nil
}