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,375 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
pvfversion "pvfine/internal/version"
|
||||
)
|
||||
|
||||
func (s *MCPService) workspace() map[string]any {
|
||||
s.c.mu.Lock()
|
||||
defer s.c.mu.Unlock()
|
||||
c := s.c
|
||||
if c.archive != nil && c.mcpWorkspaceID == "" {
|
||||
c.mcpWorkspaceID = mcpID()
|
||||
c.mcpEpoch++
|
||||
}
|
||||
s.mu.Lock()
|
||||
windowStates := map[string]any{}
|
||||
for id, w := range s.windows {
|
||||
windowStates[id] = map[string]any{"dirty": w.dirty, "busy": w.busy, "stale": time.Since(w.seen) > 12*time.Second}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
result := map[string]any{"windows": windowStates, "open": c.archive != nil, "workspaceEpoch": c.mcpEpoch, "workspaceId": c.mcpWorkspaceID, "revision": c.batchRevision, "versionLoading": c.versionLoading, "indexStatus": c.indexStatus, "capabilityVersion": 1}
|
||||
if c.archive != nil {
|
||||
result["archive"] = c.archive.Info()
|
||||
result["sourcePath"] = c.archive.SourcePath()
|
||||
result["sourceDirty"] = c.archive.Modified() || len(c.versionArtifactDirty) > 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (s *MCPService) checkLocked(a mcpArgs) error {
|
||||
if s.c.archive == nil {
|
||||
return ErrNoArchive
|
||||
}
|
||||
if a.WorkspaceID == "" || a.WorkspaceID != s.c.mcpWorkspaceID {
|
||||
return fmt.Errorf("STALE_WORKSPACE: 请重新获取工作区")
|
||||
}
|
||||
if a.ExpectedRevision != s.c.batchRevision {
|
||||
return fmt.Errorf("STALE_WORKSPACE: 修订号已变化")
|
||||
}
|
||||
return s.c.ensureVersionReadyLocked()
|
||||
}
|
||||
func (s *MCPService) check(a mcpArgs) error {
|
||||
s.c.mu.RLock()
|
||||
defer s.c.mu.RUnlock()
|
||||
return s.checkLocked(a)
|
||||
}
|
||||
func (s *MCPService) noPlans() error {
|
||||
if s.script.IsRunning() {
|
||||
return fmt.Errorf("TASK_BUSY: 脚本运行中")
|
||||
}
|
||||
s.c.mu.RLock()
|
||||
pending := s.c.scriptPlan != nil || s.c.batchPlan != nil || s.c.mcpImportPlan != ""
|
||||
s.c.mu.RUnlock()
|
||||
if pending {
|
||||
return fmt.Errorf("PLAN_PENDING: 请先应用或丢弃待处理预览")
|
||||
}
|
||||
s.export.mu.Lock()
|
||||
pending = s.export.plan != nil && s.export.validLockedSafe()
|
||||
s.export.mu.Unlock()
|
||||
if pending {
|
||||
return fmt.Errorf("EXPORT_PLAN_PENDING: 请先释放导出预览")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *ExportService) validLockedSafe() bool {
|
||||
s.c.mu.RLock()
|
||||
defer s.c.mu.RUnlock()
|
||||
return s.validLocked(s.plan)
|
||||
}
|
||||
func (s *MCPService) dirty(a mcpArgs) error {
|
||||
if a.DirtyPolicy != "" && a.DirtyPolicy != "reject" && a.DirtyPolicy != "save" && a.DirtyPolicy != "discard" {
|
||||
return fmt.Errorf("INVALID_ARGUMENT: dirtyPolicy")
|
||||
}
|
||||
s.c.mu.RLock()
|
||||
dirty := s.c.archive != nil && (s.c.archive.Modified() || len(s.c.versionArtifactDirty) > 0)
|
||||
s.c.mu.RUnlock()
|
||||
if !dirty {
|
||||
return nil
|
||||
}
|
||||
switch a.DirtyPolicy {
|
||||
case "discard":
|
||||
return nil
|
||||
case "save":
|
||||
_, err := s.save(a, false)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("UNSAVED_CHANGES: 请指定 save 或 discard,或先保存")
|
||||
}
|
||||
}
|
||||
func absolutePath(path string) error {
|
||||
if !filepath.IsAbs(path) || strings.TrimSpace(path) == "" || protectedImportPath(path) {
|
||||
return fmt.Errorf("INVALID_PATH: 需要本机绝对路径")
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
parent, e := filepath.EvalSymlinks(filepath.Dir(path))
|
||||
if e == nil {
|
||||
resolved = filepath.Join(parent, filepath.Base(path))
|
||||
}
|
||||
}
|
||||
if protectedImportPath(resolved) {
|
||||
return fmt.Errorf("PROTECTED_PATH")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *MCPService) open(a mcpArgs) (any, error) {
|
||||
if err := absolutePath(a.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(a.Path), ".pvf") {
|
||||
return nil, fmt.Errorf("INVALID_PATH: 归档路径必须以 .pvf 结尾")
|
||||
}
|
||||
s.c.mu.RLock()
|
||||
epoch := s.c.mcpEpoch
|
||||
loaded := s.c.archive != nil
|
||||
s.c.mu.RUnlock()
|
||||
if epoch != a.ExpectedWorkspaceEpoch {
|
||||
return nil, fmt.Errorf("STALE_WORKSPACE: 工作区已切换")
|
||||
}
|
||||
if loaded {
|
||||
if err := s.check(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.noPlans(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Parse the candidate before any save/discard of the old workspace.
|
||||
candidate, err := s.c.openArchiveWithRememberedTextEncoding(a.Path, a.Path, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
installed := false
|
||||
defer func() {
|
||||
if !installed {
|
||||
candidate.Release()
|
||||
}
|
||||
}()
|
||||
if err = prepareArchiveDerivedIndexesForMCP(candidate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.dirty(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if loaded {
|
||||
if err = s.check(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// The UI is frozen for this task; Open performs the normal archive setup.
|
||||
if _, err = s.c.installOpenedArchive(a.Path, candidate, time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
installed = true
|
||||
return s.workspace(), nil
|
||||
}
|
||||
func prepareArchiveDerivedIndexesForMCP(a *pvf.Archive) error {
|
||||
p, err := prepareArchiveDerivedIndexes(a)
|
||||
if p != nil {
|
||||
closePreparedArchiveIndexes(p)
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (s *MCPService) close(a mcpArgs) (any, error) {
|
||||
if err := s.check(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.noPlans(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.dirty(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.check(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.archive.Close()
|
||||
return s.workspace(), nil
|
||||
}
|
||||
func (s *MCPService) save(a mcpArgs, copyOnly bool) (any, error) {
|
||||
if err := s.check(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.noPlans(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if copyOnly {
|
||||
if err := absolutePath(a.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(a.Path), ".pvf") {
|
||||
return nil, fmt.Errorf("INVALID_PATH: 目标必须以 .pvf 结尾")
|
||||
}
|
||||
s.c.mu.RLock()
|
||||
source := s.c.archive.SourcePath()
|
||||
s.c.mu.RUnlock()
|
||||
if sameDiskPath(source, a.Path) {
|
||||
return nil, fmt.Errorf("SAME_AS_SOURCE: 请使用 archive_save")
|
||||
}
|
||||
if info, err := os.Stat(a.Path); err == nil {
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("INVALID_PATH: 目标为目录")
|
||||
}
|
||||
digest, err := pvfversion.HashFile(a.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !a.Overwrite {
|
||||
return nil, fmt.Errorf("TARGET_EXISTS: targetDigest=%s", digest)
|
||||
}
|
||||
if a.TargetDigest == "" || a.TargetDigest != digest {
|
||||
return nil, fmt.Errorf("TARGET_CHANGED")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
} else if a.Overwrite {
|
||||
return nil, fmt.Errorf("TARGET_CHANGED: 原目标已不存在")
|
||||
}
|
||||
path, err := s.editor.saveCopy(a.Path, a.Overwrite, a.TargetDigest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"writtenPath": path, "workspace": s.workspace()}, nil
|
||||
}
|
||||
s.c.mu.RLock()
|
||||
source := s.c.archive.SourcePath()
|
||||
baseline := s.c.mcpSourceHash
|
||||
if baseline == "" {
|
||||
baseline = s.c.archive.SourceHash()
|
||||
}
|
||||
s.c.mu.RUnlock()
|
||||
if source == "" {
|
||||
return nil, fmt.Errorf("SAVE_AS_REQUIRED")
|
||||
}
|
||||
if err := absolutePath(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest, err := pvfversion.HashFile(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if digest != baseline {
|
||||
return nil, fmt.Errorf("SOURCE_CHANGED: 源文件已在外部修改")
|
||||
}
|
||||
s.c.mu.RLock()
|
||||
savedSequence := s.c.mcpSaveSequence
|
||||
s.c.mu.RUnlock()
|
||||
info, err := s.editor.Save()
|
||||
if err != nil {
|
||||
s.c.mu.RLock()
|
||||
committed := s.c.mcpSaveSequence != savedSequence
|
||||
s.c.mu.RUnlock()
|
||||
return map[string]any{"diskCommitted": committed, "workspace": s.workspace()}, err
|
||||
}
|
||||
return map[string]any{"diskCommitted": true, "writtenPath": source, "archive": info, "workspace": s.workspace()}, nil
|
||||
}
|
||||
func sameDiskPath(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
aa, _ := filepath.Abs(a)
|
||||
bb, _ := filepath.Abs(b)
|
||||
if aa == bb {
|
||||
return true
|
||||
}
|
||||
ai, ae := os.Stat(a)
|
||||
bi, be := os.Stat(b)
|
||||
return ae == nil && be == nil && os.SameFile(ai, bi)
|
||||
}
|
||||
func (s *MCPService) ownScript(id string) error {
|
||||
s.c.mu.RLock()
|
||||
defer s.c.mu.RUnlock()
|
||||
if id == "" || s.c.mcpScriptPlan != id {
|
||||
return fmt.Errorf("PLAN_NOT_FOUND: 非当前 MCP 脚本预览")
|
||||
}
|
||||
_, err := s.script.currentScriptPlanLocked(id)
|
||||
return err
|
||||
}
|
||||
func (s *MCPService) run(ctx context.Context, a mcpArgs) (any, error) {
|
||||
if err := s.check(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.noPlans(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.script.Run(ctx, ScriptRunRequest{Name: a.Label, Source: a.Source})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if result.PlanID != "" && result.ModifiedFiles == 0 && result.FileSetChanges == 0 {
|
||||
s.script.Discard(result.PlanID)
|
||||
result.PlanID = ""
|
||||
}
|
||||
if result.PlanID != "" {
|
||||
s.c.mu.Lock()
|
||||
plan := s.c.scriptPlan
|
||||
if plan != nil && plan.id == result.PlanID && len(plan.fileSetRows) > 0 {
|
||||
s.c.invalidateScriptPlanLocked()
|
||||
s.c.mu.Unlock()
|
||||
return nil, fmt.Errorf("UNSUPPORTED_CHANGE: MCP 不支持文件集修改")
|
||||
}
|
||||
s.c.mcpScriptPlan = result.PlanID
|
||||
s.c.mu.Unlock()
|
||||
id := result.PlanID
|
||||
time.AfterFunc(mcpTTL, func() {
|
||||
s.c.mu.Lock()
|
||||
if s.c.mcpScriptPlan == id {
|
||||
s.c.invalidateScriptPlanLocked()
|
||||
s.c.mcpScriptPlan = ""
|
||||
}
|
||||
s.c.mu.Unlock()
|
||||
})
|
||||
}
|
||||
emitEvent("mcp:script-result", result)
|
||||
return result, nil
|
||||
}
|
||||
func (s *MCPService) search(a mcpArgs) (any, error) {
|
||||
if err := s.check(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mode := "quick"
|
||||
if a.Mode == "content" {
|
||||
mode = "string"
|
||||
} else if a.Mode != "" && a.Mode != "path" {
|
||||
return nil, fmt.Errorf("INVALID_ARGUMENT: mode=path/content")
|
||||
}
|
||||
r, err := s.archive.AdvancedSearch(mode, a.Query, a.Scope, false, a.Cursor, a.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.check(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"revision": a.ExpectedRevision, "page": r}, nil
|
||||
}
|
||||
func (s *MCPService) readFile(a mcpArgs) (any, error) {
|
||||
s.c.mu.Lock()
|
||||
defer s.c.mu.Unlock()
|
||||
if err := s.checkLocked(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index, ok := s.c.archive.Find(a.Path)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("FILE_NOT_FOUND")
|
||||
}
|
||||
if s.c.archive.File(index).DataSize > 8<<20 {
|
||||
return nil, fmt.Errorf("FILE_TOO_LARGE: 请使用限定范围的脚本查询")
|
||||
}
|
||||
text, err := s.c.archive.Text(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
start := max(0, a.StartLine-1)
|
||||
if start > len(lines) {
|
||||
start = len(lines)
|
||||
}
|
||||
end := min(len(lines), start+a.Limit)
|
||||
part := strings.Join(lines[start:end], "\n")
|
||||
if len(part) > 256<<10 {
|
||||
return nil, fmt.Errorf("RESULT_TOO_LARGE: 单行或所选范围过大")
|
||||
}
|
||||
next := -1
|
||||
if end < len(lines) {
|
||||
next = end + 1
|
||||
}
|
||||
return map[string]any{"path": a.Path, "revision": a.ExpectedRevision, "text": part, "totalLines": len(lines), "startLine": start + 1, "nextLine": next}, nil
|
||||
}
|
||||
Reference in new issue
Block a user