DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+648
View File
@@ -0,0 +1,648 @@
package services
import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
modpkg "pvfine/internal/mod"
"pvfine/internal/pvf"
pvfversion "pvfine/internal/version"
)
var ErrExportStale = errors.New("导出预览已过期,请重新准备导出")
var exportSequence atomic.Uint64
type ExportRequest struct {
Source string `json:"source"`
DiffID string `json:"diffId,omitempty"`
Reverse bool `json:"reverse,omitempty"`
Scopes []string `json:"scopes"`
CommitID string `json:"commitId"`
Mode string `json:"mode"`
Format string `json:"format"`
Name string `json:"name"`
Version string `json:"version"`
ModID string `json:"modId"`
IncludeDependencies bool `json:"includeDependencies"`
MergePath string `json:"mergePath,omitempty"`
}
type ExportPreview struct {
ID string `json:"id"`
FileCount int `json:"fileCount"`
DependencyCount int `json:"dependencyCount"`
SkippedDeletes int `json:"skippedDeletes"`
Warnings []string `json:"warnings"`
Files []ExportPreviewFile `json:"files"`
}
type ExportPreviewFile struct {
Path string `json:"path"`
Required bool `json:"required"`
DependencyCount int `json:"dependencyCount"`
}
type exportFile struct {
path string
data []byte
}
type exportPlan struct {
mcpOwned bool
preview ExportPreview
request ExportRequest
archive *pvf.Archive
repo *pvfversion.Repository
revision uint64
generation uint64
loadID uint64
headID string
created time.Time
files []exportFile
pkg modpkg.Package
}
// ExportService owns one short-lived immutable preview, shared by all export
// entry points. Source adaptation and format serialization remain independent.
type ExportService struct {
c *core
mu sync.Mutex
plan *exportPlan
registry *modpkg.Registry
expiry *time.Timer
}
func NewExportService(c *core) *ExportService {
return &ExportService{c: c, registry: modpkg.DefaultRegistry()}
}
func (s *ExportService) Formats() []modpkg.Descriptor { return s.registry.List() }
func (s *ExportService) Prepare(request ExportRequest) (*ExportPreview, error) {
return s.prepare(request, false)
}
func (s *ExportService) prepare(request ExportRequest, mcpOwned bool) (result *ExportPreview, resultErr error) {
started := time.Now()
defer func() {
count := 0
if result != nil {
count = result.FileCount
}
developmentOrErrorLog(resultErr, "[pvfine:export] prepare finished: source=%s mode=%s dependencies=%t files=%d elapsed=%s error=%v",
request.Source, request.Mode, request.IncludeDependencies, count, time.Since(started).Round(time.Millisecond), resultErr)
}()
finish := s.c.archiveTasks.begin()
defer finish()
s.mu.Lock()
defer s.mu.Unlock()
if s.plan != nil && s.plan.mcpOwned && !mcpOwned {
return nil, fmt.Errorf("EXPORT_PLAN_PENDING: MCP 导出预览待处理")
}
if request.Mode != "direct" && request.Mode != "mod" {
return nil, fmt.Errorf("未知导出模式: %s", request.Mode)
}
if request.Mode == "mod" {
if err := modpkg.ValidateName(request.Name); err != nil {
return nil, err
}
if err := modpkg.ValidateVersion(request.Version); err != nil {
return nil, err
}
f, err := s.registry.Get(request.Format)
if err != nil {
return nil, err
}
if f.Writer == nil {
return nil, fmt.Errorf("该 mod 格式不支持导出")
}
_, zipOutput := f.Writer.(modpkg.ArchiveWriter)
if _, err := modOutputName(modpkg.Metadata{Name: request.Name, Version: request.Version}, zipOutput); err != nil {
return nil, err
}
if request.Format == modpkg.CN115Format {
if err := modpkg.ValidateID(request.ModID); err != nil {
return nil, err
}
}
}
s.c.mu.RLock()
defer s.c.mu.RUnlock()
developmentLog("[pvfine:export] prepare phase=lock elapsed=%s", time.Since(started).Round(time.Millisecond))
if s.c.archive == nil {
return nil, ErrNoArchive
}
// Metadata never affects scope, content or dependencies. Reuse only a
// still-valid snapshot; every response gets its own task identity.
if old := s.plan; request.MergePath == "" && s.validLocked(old) && sameExportContentRequest(old.request, request) {
next := *old
next.request = request
next.pkg.Metadata = modpkg.Metadata{ID: request.ModID, Name: request.Name, Version: request.Version}
if request.Mode == "mod" {
format, _ := s.registry.Get(request.Format)
if err := format.Writer.Validate(next.pkg); err != nil {
return nil, err
}
}
next.preview.ID = fmt.Sprint(exportSequence.Add(1))
s.storePlan(&next)
developmentLog("[pvfine:export] prepare phase=content cache=hit")
preview := next.preview
return &preview, nil
}
s.plan = nil
if s.expiry != nil {
s.expiry.Stop()
s.expiry = nil
}
phaseStarted := time.Now()
source, changes, err := s.exportSourceLocked(request)
if err != nil {
return nil, err
}
developmentLog("[pvfine:export] prepare phase=source files=%d skipped_tables=%d elapsed=%s",
len(changes), source.skippedTables, time.Since(phaseStarted).Round(time.Millisecond))
p := &exportPlan{
mcpOwned: mcpOwned, request: request, archive: s.c.archive, repo: s.c.versionRepo,
revision: s.c.batchRevision, generation: s.c.archiveGeneration,
loadID: s.c.versionLoadID, headID: s.c.versionHead.ID, created: time.Now(),
preview: ExportPreview{ID: fmt.Sprint(exportSequence.Add(1)), Warnings: []string{}},
pkg: modpkg.Package{Metadata: modpkg.Metadata{ID: request.ModID, Name: request.Name, Version: request.Version}},
}
format, _ := s.registry.Get(request.Format)
supportsDelete := false
for _, operation := range format.Descriptor.Operations {
if operation == modpkg.DeleteFile {
supportsDelete = true
}
}
phaseStarted = time.Now()
for _, change := range changes {
after, before := change.after, change.before
if request.Mode == "direct" {
content := after
if content == nil {
content = before
}
if content != nil {
p.files = append(p.files, exportFile{path: content.path, data: content.data})
}
continue
}
if after == nil {
if supportsDelete && before != nil {
p.pkg.Entries = append(p.pkg.Entries, modpkg.Entry{Path: before.path, Operation: modpkg.DeleteFile})
} else {
p.preview.SkippedDeletes++
}
continue
}
entry, removed, err := exportModEntryReported(*after, before, &source.diagnostics)
if err != nil {
if isExportMergeTable(after.path) {
source.diagnostics.skip("无法提取合并表改动 %s: %v", after.path, err)
continue
}
return nil, err
}
p.preview.SkippedDeletes += removed
if entry != nil {
p.pkg.Entries = append(p.pkg.Entries, *entry)
}
}
developmentLog("[pvfine:export] prepare phase=diff elapsed=%s", time.Since(phaseStarted).Round(time.Millisecond))
if request.Mode == "mod" {
explicitPairs := make(map[string]int)
for _, entry := range p.pkg.Entries {
explicitPairs[entry.Path] = len(entry.Pairs)
}
if request.IncludeDependencies {
phaseStarted = time.Now()
count, err := s.addExportDependenciesLocked(source, &p.pkg, format.Writer)
if err != nil {
return nil, err
}
p.preview.DependencyCount = count
developmentLog("[pvfine:export] prepare phase=dependencies entries=%d elapsed=%s", count, time.Since(phaseStarted).Round(time.Millisecond))
}
if source.skippedTables > 0 {
p.preview.Warnings = append(p.preview.Warnings, fmt.Sprintf("缺少准确的改动基线,跳过 %d 个 lst/str 文件;补齐依赖仅添加匹配条目", source.skippedTables))
}
if source.diagnostics.skipped > 0 {
p.preview.Warnings = append(p.preview.Warnings, fmt.Sprintf("跳过 %d 处无法解析或补齐的内容,详情已记录到日志;导出内容或依赖可能不完整", source.diagnostics.skipped))
}
dependencies := make(map[string]int)
for _, entry := range p.pkg.Entries {
dependencies[entry.Path] = len(entry.Pairs) - explicitPairs[entry.Path]
}
if request.MergePath != "" {
base, err := s.readMergePackage(request.MergePath, request.Format)
if err != nil {
return nil, err
}
p.pkg, err = mergeExportPackages(base, p.pkg)
if err != nil {
return nil, err
}
p.preview.Warnings = append(p.preview.Warnings, base.Warnings...)
}
p.preview.FileCount = len(p.pkg.Entries)
if p.preview.SkippedDeletes > 0 {
p.preview.Warnings = append(p.preview.Warnings, fmt.Sprintf("%s 无法应用删除,跳过 %d 个文件或表条目删除", format.Descriptor.Name, p.preview.SkippedDeletes))
}
if p.preview.FileCount == 0 && p.preview.SkippedDeletes > 0 {
return nil, fmt.Errorf("没有可应用的 mod 内容;跳过 %d 个文件或表条目删除", p.preview.SkippedDeletes)
}
if p.preview.FileCount == 0 {
return nil, fmt.Errorf("没有可应用的 mod 内容;跳过 %d 个无改动基线的 lst/str 文件、%d 处无法解析或补齐的内容",
source.skippedTables, source.diagnostics.skipped)
}
if err := format.Writer.Validate(p.pkg); err != nil {
return nil, err
}
for _, file := range format.Writer.Files(p.pkg) {
p.preview.Files = append(p.preview.Files, ExportPreviewFile{
Path: file.Path, Required: file.EntryPath == "", DependencyCount: dependencies[file.EntryPath],
})
}
} else {
p.preview.FileCount = len(p.files)
for _, file := range p.files {
p.preview.Files = append(p.preview.Files, ExportPreviewFile{Path: file.path})
}
}
if p.preview.FileCount == 0 {
return nil, errors.New("没有可导出的文件或可应用的 mod 内容")
}
// The legacy UI selection exporter renders unknown types as empty text.
// MCP direct exports preserve binary bytes so a directory export is usable.
if mcpOwned && request.Mode == "direct" && (request.Source == "selection" || request.Source == "memory") {
for index := range p.files {
fileIndex, ok := s.c.archive.Find(p.files[index].path)
if !ok {
continue
}
dataType := s.c.archive.File(fileIndex).DataType
if dataType != pvf.TypeScript && dataType != pvf.TypeUnicode {
raw, err := s.c.archive.RawBytes(fileIndex)
if err != nil {
return nil, err
}
p.files[index].data = append([]byte(nil), raw...)
}
}
}
if request.Source == "archive-diff" && !s.validLocked(p) {
return nil, ErrExportStale
}
s.storePlan(p)
preview := p.preview
return &preview, nil
}
func sameExportContentRequest(a, b ExportRequest) bool {
return a.Source == b.Source && a.CommitID == b.CommitID && a.DiffID == b.DiffID && a.Reverse == b.Reverse && a.Mode == b.Mode &&
a.Format == b.Format && a.MergePath == b.MergePath && a.IncludeDependencies == b.IncludeDependencies &&
slices.Equal(a.Scopes, b.Scopes)
}
// The service mutex is held by the caller.
func (s *ExportService) storePlan(p *exportPlan) {
if s.expiry != nil {
s.expiry.Stop()
}
s.plan = p
id := p.preview.ID
remaining := 10*time.Minute - time.Since(p.created)
s.expiry = time.AfterFunc(remaining, func() { s.Release(id) })
}
func (s *ExportService) Release(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.plan != nil && s.plan.preview.ID == id {
s.plan = nil
if s.expiry != nil {
s.expiry.Stop()
s.expiry = nil
}
}
}
func (s *ExportService) validLocked(p *exportPlan) bool {
if p != nil && p.request.Source == "archive-diff" {
if time.Since(p.created) >= 10*time.Minute || p.archive != s.c.archive || p.generation != s.c.archiveGeneration || s.c.archiveDiff == nil {
return false
}
diff := s.c.archiveDiff
diff.mu.Lock()
defer diff.mu.Unlock()
_, err := diff.findLocked(p.request.DiffID, true)
return err == nil
}
return p != nil && time.Since(p.created) < 10*time.Minute &&
p.archive == s.c.archive && p.repo == s.c.versionRepo &&
p.revision == s.c.batchRevision && p.generation == s.c.archiveGeneration &&
p.loadID == s.c.versionLoadID && p.headID == s.c.versionHead.ID
}
// ExecuteDialog picks the parent directory only after the frontend confirms.
func (s *ExportService) ExecuteDialog(id string) (string, error) {
return s.ExecuteSelectionDialog(id, nil)
}
// ExecuteSelectionDialog applies exclusions to the frozen preview. Dependencies
// are not resolved again, so an explicitly excluded file cannot reappear.
func (s *ExportService) ExecuteSelectionDialog(id string, excluded []string) (string, error) {
s.mu.Lock()
s.c.mu.RLock()
valid := s.plan != nil && s.plan.preview.ID == id && s.validLocked(s.plan)
var selectionErr error
if valid {
_, selectionErr = s.selectedPlan(s.plan, excluded)
}
s.c.mu.RUnlock()
s.mu.Unlock()
if !valid {
return "", ErrExportStale
}
if selectionErr != nil {
return "", selectionErr
}
dir, err := application.Get().Dialog.OpenFile().
CanChooseFiles(false).CanChooseDirectories(true).CanCreateDirectories(true).
SetTitle("选择导出目标目录").PromptForSingleSelection()
if err != nil || dir == "" {
return "", err
}
return s.executeSelectedTo(id, dir, excluded)
}
func (s *ExportService) executeTo(id, dir string) (string, error) {
return s.executeSelectedTo(id, dir, nil)
}
func (s *ExportService) selectedPlan(original *exportPlan, excluded []string) (*exportPlan, error) {
known := make(map[string]bool)
for _, file := range original.preview.Files {
known[file.Path] = file.Required
}
removed := make(map[string]bool)
for _, p := range excluded {
required, exists := known[p]
if !exists || required {
return nil, fmt.Errorf("不能移除未知文件或必需文件: %s", p)
}
removed[p] = true
}
plan := *original
if plan.request.Mode == "mod" {
format, err := s.registry.Get(plan.request.Format)
if err != nil {
return nil, err
}
removedEntries := make(map[string]bool)
for _, file := range format.Writer.Files(plan.pkg) {
if removed[file.Path] {
removedEntries[file.EntryPath] = true
}
}
plan.pkg.Entries = nil
for _, entry := range original.pkg.Entries {
if !removedEntries[entry.Path] {
plan.pkg.Entries = append(plan.pkg.Entries, entry)
}
}
if err := format.Writer.Validate(plan.pkg); err != nil {
return nil, err
}
} else {
plan.files = nil
for _, file := range original.files {
if !removed[file.path] {
plan.files = append(plan.files, file)
}
}
if len(plan.files) == 0 {
return nil, errors.New("没有可导出的文件")
}
}
return &plan, nil
}
func (s *ExportService) executeSelectedTo(id, dir string, excluded []string) (string, error) {
finish := s.c.archiveTasks.begin()
defer finish()
s.mu.Lock()
defer s.mu.Unlock()
s.c.mu.RLock()
defer s.c.mu.RUnlock()
p := s.plan
if p == nil || p.preview.ID != id || !s.validLocked(p) {
return "", ErrExportStale
}
p, err := s.selectedPlan(p, excluded)
if err != nil {
return "", err
}
defer func() {
if s.plan == nil && s.expiry != nil {
s.expiry.Stop()
s.expiry = nil
}
}()
if p.request.Mode == "mod" {
f, err := s.registry.Get(p.request.Format)
if err != nil {
return "", err
}
result, err := writeModDirectory(dir, p.pkg, f.Writer)
if err == nil {
s.plan = nil
}
return result, err
}
if err := writeDirectDirectory(dir, p.files); err != nil {
return "", err
}
s.plan = nil
return dir, nil
}
// writeDirectDirectory 将文件列表安全、高效地写入目标目录。
// 预先去重创建目录并利用并发 I/O 加速海量文件落地。
// @author flobit
func writeDirectDirectory(dir string, files []exportFile) error {
destinations := make(map[string]bool, len(files))
fileDests := make([]string, len(files))
uniqueDirs := make(map[string]struct{})
for i, file := range files {
dest, err := safeExportPath(dir, file.path)
if err != nil {
return err
}
canon := canonicalExportDestination(dest)
if destinations[canon] {
return fmt.Errorf("导出路径冲突: %s", file.path)
}
destinations[canon] = true
fileDests[i] = dest
uniqueDirs[filepath.Dir(dest)] = struct{}{}
}
for parentDir := range uniqueDirs {
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return err
}
}
workers := min(len(files), 16)
if len(files) <= 32 || workers <= 1 {
for i, file := range files {
if err := os.WriteFile(fileDests[i], file.data, 0o644); err != nil {
return err
}
}
return nil
}
var firstErr atomic.Pointer[error]
var wg sync.WaitGroup
chunkSize := (len(files) + workers - 1) / workers
for w := 0; w < workers; w++ {
start := w * chunkSize
end := min(start+chunkSize, len(files))
if start >= end {
break
}
wg.Add(1)
go func(s, e int) {
defer wg.Done()
for i := s; i < e; i++ {
if firstErr.Load() != nil {
return
}
if err := os.WriteFile(fileDests[i], files[i].data, 0o644); err != nil {
firstErr.CompareAndSwap(nil, &err)
return
}
}
}(start, end)
}
wg.Wait()
if errPtr := firstErr.Load(); errPtr != nil {
return *errPtr
}
return nil
}
// Use the same default version as the format writer while leaving manifest metadata intact.
func modOutputName(metadata modpkg.Metadata, zipOutput bool) (string, error) {
version := metadata.Version
if version == "" {
version = "1.0"
if zipOutput {
version = "1.0.0"
}
}
name := metadata.Name + "-" + version
if zipOutput {
name += ".zip"
}
if err := modpkg.ValidateName(name); err != nil {
return "", fmt.Errorf("MOD 包名无效,请调整名称或版本号: %w", err)
}
return name, nil
}
func writeModDirectory(parent string, pkg modpkg.Package, writer modpkg.Writer) (string, error) {
if err := writer.Validate(pkg); err != nil {
return "", err
}
if zipWriter, ok := writer.(modpkg.ArchiveWriter); ok {
return writeModZIP(parent, pkg, zipWriter)
}
name, err := modOutputName(pkg.Metadata, false)
if err != nil {
return "", err
}
dest := filepath.Join(parent, name)
// Windows directory identity is case-insensitive, including when tests
// run on another host filesystem.
siblings, err := os.ReadDir(parent)
if err != nil {
return "", err
}
for _, sibling := range siblings {
if strings.EqualFold(sibling.Name(), name) {
return "", fmt.Errorf("同名 mod 文件夹已存在,请修改名称或版本号或目标目录: %s", name)
}
}
if _, err := os.Lstat(dest); err == nil {
return "", fmt.Errorf("同名 mod 文件夹已存在,请修改名称或版本号或目标目录: %s", name)
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
temp, err := os.MkdirTemp(parent, ".pvfine-mod-")
if err != nil {
return "", err
}
// Only the exact directory returned by MkdirTemp is ever cleaned up.
defer os.RemoveAll(temp)
if err := writer.Write(temp, pkg); err != nil {
return "", err
}
if err := publishModDirectory(temp, dest); err != nil {
return "", err
}
return dest, nil
}
func writeModZIP(parent string, pkg modpkg.Package, writer modpkg.ArchiveWriter) (string, error) {
name, err := modOutputName(pkg.Metadata, true)
if err != nil {
return "", err
}
siblings, err := os.ReadDir(parent)
if err != nil {
return "", err
}
for _, sibling := range siblings {
if strings.EqualFold(sibling.Name(), name) {
return "", fmt.Errorf("同名 MOD ZIP 已存在,请修改名称或目标目录: %s", name)
}
}
temp, err := os.CreateTemp(parent, ".pvfine-mod-*.zip")
if err != nil {
return "", err
}
defer os.Remove(temp.Name())
defer temp.Close()
if err := writer.WriteArchive(temp, pkg); err != nil {
return "", err
}
if err := temp.Sync(); err != nil {
return "", err
}
if err := temp.Close(); err != nil {
return "", err
}
dest := filepath.Join(parent, name)
// Link publishes the completed file atomically and refuses existing targets.
if err := os.Link(temp.Name(), dest); err != nil {
return "", err
}
return dest, nil
}