Files
90CNServer/pvf/services/mcp_workspace.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

376 lines
10 KiB
Go

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
}