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

537 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
ann "pvfine/internal/annotations"
"pvfine/internal/mcpserver"
"pvfine/internal/ruleconfig"
)
type annotationArgs struct {
Query string `json:"query"`
FilePath string `json:"filePath"`
PathKind string `json:"pathKind"`
Extensions []string `json:"extensions"`
Section string `json:"section"`
Kinds []string `json:"kinds"`
TargetKinds []string `json:"targetKinds"`
AnnotationTypes []string `json:"annotationTypes"`
Relation string `json:"relation"`
PVFVersion string `json:"pvfVersion"`
WorkspaceID string `json:"workspaceId"`
Status string `json:"status"`
Cursor string `json:"cursor"`
Limit int `json:"limit"`
DocumentID string `json:"documentId"`
RulesRevision string `json:"rulesRevision"`
Value *string `json:"value"`
ValuesCursor string `json:"valuesCursor"`
ValuesLimit int `json:"valuesLimit"`
ExpectedRevision uint64 `json:"expectedRevision"`
Occurrence *int `json:"occurrence"`
TokenIndex *int `json:"tokenIndex"`
StartLine *int `json:"startLine"`
EndLine *int `json:"endLine"`
}
func (s *MCPService) annotationTools() []mcpserver.Tool {
props := map[string]any{}
for _, k := range strings.Fields("query filePath pathKind section relation pvfVersion workspaceId status cursor documentId rulesRevision value valuesCursor") {
props[k] = map[string]any{"type": "string", "maxLength": 4096}
}
for _, k := range strings.Fields("extensions kinds targetKinds annotationTypes") {
props[k] = map[string]any{"type": "array", "maxItems": 100, "items": map[string]any{"type": "string", "maxLength": 256}}
}
for _, k := range strings.Fields("expectedRevision occurrence tokenIndex startLine endLine") {
min := 0
if k == "startLine" || k == "endLine" {
min = 1
}
props[k] = map[string]any{"type": "integer", "minimum": min}
}
for _, k := range []string{"limit", "valuesLimit"} {
props[k] = map[string]any{"type": "integer", "minimum": 1, "maximum": 100}
}
for k, values := range map[string][]string{"pathKind": {"file", "directory"}, "status": {"effective", "all"}} {
props[k] = map[string]any{"type": "string", "enum": values}
}
result := []mcpserver.Tool{}
for _, d := range []struct{ name, description, fields, required string }{
{"search", "搜索已激活的 PVF 标注规则与共享字段说明;section 留空检索全部类别。版本默认 current(需 workspaceId),all 可离线浏览。返回文档身份与分页摘要,不扫描归档。", "query filePath pathKind extensions section kinds targetKinds annotationTypes relation pvfVersion workspaceId status cursor limit", ""},
{"get", "读取标注文档完整布局、整块继承后的定义、分页枚举和关联定义。需搜索返回的 documentId/rulesRevision;value 按字符串精确查找。", "documentId rulesRevision value valuesCursor valuesLimit", "documentId rulesRevision"},
{"explain", "解释当前工作区已提交脚本的实际标注。位置为 UTF-16;occurrence/tokenIndex 从 0 开始。与 read_file 同源,不包含前端草稿,不冻结界面。", "workspaceId expectedRevision filePath rulesRevision section occurrence tokenIndex startLine endLine cursor limit", "workspaceId expectedRevision filePath"},
} {
d := d
properties := map[string]any{}
for _, k := range strings.Fields(d.fields) {
properties[k] = props[k]
}
result = append(result, mcpserver.Tool{Name: "pvf_annotation_" + d.name, Description: d.description, ReadOnly: true, Schema: map[string]any{"type": "object", "properties": properties, "required": strings.Fields(d.required), "additionalProperties": false}, Call: func(ctx context.Context, raw json.RawMessage) (any, error) {
var a annotationArgs
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("INVALID_ARGUMENT")
}
s.c.mu.Lock()
defer s.c.mu.Unlock()
switch d.name {
case "search":
return s.annotationSearch(a)
case "get":
return s.annotationGet(a)
default:
return s.annotationExplain(a)
}
}})
}
return result
}
type annotationDocument struct {
Entry ruleconfig.Entry
Rule ann.Rule
Original any
Preview *ann.PreviewSpec
DocumentID string
ResolutionError string
}
type annotationCatalog struct {
Revision string
Document ann.Document
Items []annotationDocument
}
func annotationHash(v any) string {
b, _ := json.Marshal(v)
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func (c *core) annotationCatalogLocked() (annotationCatalog, error) {
if c.annotationErr != nil {
return annotationCatalog{}, c.annotationErr
}
if c.annotationEngine == nil {
return annotationCatalog{}, fmt.Errorf("RULES_UNAVAILABLE")
}
if c.annotationDocumentation != nil && c.annotationDocumentationEngine == c.annotationEngine && c.annotationDocumentationSnapshot == c.annotationSnapshot {
return *c.annotationDocumentation, nil
}
doc := c.annotationEngine.Document()
entries := []ruleconfig.Entry{}
revision := annotationHash(doc)
if c.annotationSnapshot != nil {
revision = c.annotationSnapshot.Revision
entries = c.annotationSnapshot.Entries
}
if len(entries) == 0 {
for _, r := range doc.Rules {
b, _ := json.Marshal(r)
entries = append(entries, ruleconfig.Entry{ID: r.ID, Kind: "rule", JSON: string(b), Source: "unknown", Effective: true})
}
for _, f := range doc.Fields {
b, _ := json.Marshal(f)
entries = append(entries, ruleconfig.Entry{ID: f.ID, Kind: "field", JSON: string(b), Source: "unknown", Effective: true})
}
}
out := annotationCatalog{Revision: revision, Document: doc, Items: []annotationDocument{}}
for i, e := range entries {
d := annotationDocument{Entry: e, DocumentID: annotationHash([]any{revision, e.Kind, e.ID, e.Source, i})}
if e.Kind == "field" {
var f ann.FieldDefinition
if json.Unmarshal([]byte(e.JSON), &f) != nil {
continue
}
d.Original = f
d.Preview = f.Preview
d.Rule = ann.Rule{ID: f.ID, PVFVersions: f.PVFVersions, Match: f.Match, Target: f.Target, Annotation: f.Annotation}
} else if e.Kind == "rule" {
var r ann.Rule
if json.Unmarshal([]byte(e.JSON), &r) != nil {
continue
}
d.Original = r
d.Rule = r
if resolved, err := ann.ResolveDocumentationRule(r, doc.Fields); err == nil {
d.Rule = resolved
} else {
d.ResolutionError = err.Error()
}
for _, f := range doc.Fields {
if f.ID == r.Field {
d.Preview = f.Preview
break
}
}
} else {
continue
}
out.Items = append(out.Items, d)
}
c.annotationDocumentation = &out
c.annotationDocumentationEngine = c.annotationEngine
c.annotationDocumentationSnapshot = c.annotationSnapshot
return out, nil
}
// Read-only cursors bind all filters and the activated snapshots; no filesystem state is read.
type annotationCursor struct {
Revision string `json:"r"`
Binding string `json:"b"`
Offset int `json:"o"`
Workspace string `json:"w,omitempty"`
Text string `json:"t,omitempty"`
}
func annotationPage(cursor, revision, binding, workspace, text string, n, limit int) (int, int, error) {
start := 0
if cursor != "" {
b, err := base64.RawURLEncoding.DecodeString(cursor)
var c annotationCursor
if err != nil || json.Unmarshal(b, &c) != nil {
return 0, 0, fmt.Errorf("INVALID_CURSOR")
}
if c.Revision != revision {
return 0, 0, fmt.Errorf("STALE_RULES")
}
if c.Workspace != workspace {
return 0, 0, fmt.Errorf("STALE_WORKSPACE")
}
if c.Text != text {
return 0, 0, fmt.Errorf("STALE_TEXT")
}
if c.Binding != binding || c.Offset < 0 || c.Offset > n {
return 0, 0, fmt.Errorf("INVALID_CURSOR")
}
start = c.Offset
}
return start, min(start+limit, n), nil
}
func annotationNext(end, n int, revision, binding, workspace, text string) any {
if end >= n {
return nil
}
b, _ := json.Marshal(annotationCursor{revision, binding, end, workspace, text})
return base64.RawURLEncoding.EncodeToString(b)
}
func annotationSection(s string) string {
return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(s), "["), "]")))
}
func annotationIn(xs []string, s string) bool {
if len(xs) == 0 {
return true
}
for _, x := range xs {
if strings.EqualFold(x, s) {
return true
}
}
return false
}
func annotationSource(source string) string {
switch source {
case "annotations.user.json", "user":
return "user"
case "annotations.json", "builtin":
return "builtin"
case "paths.json", "sections.json", "generated":
return "generated"
default:
return "unknown"
}
}
func annotationTerms(values []string) []string {
unique := map[string]bool{}
out := []string{}
for _, v := range values {
v = strings.ToLower(strings.TrimSpace(v))
if !unique[v] {
unique[v] = true
out = append(out, v)
}
}
sort.Strings(out)
return out
}
func annotationShort(s string) (string, bool) {
r := []rune(s)
if len(r) > 512 {
return string(r[:512]), true
}
return s, false
}
func annotationVersion(d annotationDocument, doc ann.Document, v string) bool {
if v == "all" {
return true
}
if !annotationIn(d.Rule.PVFVersions, v) {
return false
}
if d.Rule.Field != "" {
for _, f := range doc.Fields {
if f.ID == d.Rule.Field {
return annotationIn(f.PVFVersions, v)
}
}
return false
}
return true
}
func (s *MCPService) annotationSearch(a annotationArgs) (any, error) {
cat, err := s.c.annotationCatalogLocked()
if err != nil {
return nil, err
}
if a.PVFVersion == "" {
a.PVFVersion = "current"
}
if a.Status == "" {
a.Status = "effective"
}
if a.PathKind == "" {
a.PathKind = "file"
}
if a.Limit == 0 {
a.Limit = 20
}
if a.PVFVersion == "current" {
if s.c.archive == nil {
return nil, fmt.Errorf("NO_ARCHIVE: 请指定 pvfVersion 或 all")
}
if a.WorkspaceID == "" || a.WorkspaceID != s.c.mcpWorkspaceID {
return nil, fmt.Errorf("STALE_WORKSPACE")
}
a.PVFVersion = s.c.archive.ClientVersion()
} else if a.PVFVersion != "all" && !ann.ValidPVFVersion(a.PVFVersion) {
return nil, fmt.Errorf("INVALID_VERSION: 支持 90US,90CN,110US,115US,115CN,100CN,TW")
}
cursor := a.Cursor
a.Cursor = ""
a.Limit = max(1, min(a.Limit, 100))
a.Section = annotationSection(a.Section)
for _, p := range []*[]string{&a.Extensions, &a.Kinds, &a.TargetKinds, &a.AnnotationTypes} {
*p = annotationTerms(*p)
}
a.Query = strings.Join(annotationTerms(strings.Fields(a.Query)), " ")
a.FilePath = strings.ToLower(strings.Trim(strings.ReplaceAll(a.FilePath, "\\", "/"), "/"))
binding := annotationHash(a)
type hit struct {
item map[string]any
score int
key string
}
hits := []hit{}
terms := strings.Fields(strings.ToLower(a.Query))
for _, d := range cat.Items {
r := d.Rule
effective := d.Entry.Effective && annotationVersion(d, cat.Document, a.PVFVersion)
if a.Status != "all" && !effective {
continue
}
if !annotationIn(a.Kinds, d.Entry.Kind) || !annotationIn(a.TargetKinds, r.Target.Kind) || !annotationIn(a.AnnotationTypes, r.Annotation.Type) {
continue
}
if a.Section != "" && a.Section != annotationSection(r.Target.Section) {
continue
}
if a.Relation != "" && r.Annotation.Relation != a.Relation {
continue
}
applicability := "unknown"
reasons := []string{}
if a.FilePath != "" {
if !ann.DocumentationMatches(r.Match, a.FilePath, a.PathKind == "directory") {
continue
}
applicability = "matched"
reasons = append(reasons, "filePath")
}
if a.PathKind == "directory" && r.Target.Kind != "path" {
continue
}
if len(a.Extensions) > 0 && len(r.Match.Extensions) > 0 {
matched := false
for _, ext := range a.Extensions {
if annotationIn(r.Match.Extensions, ext) {
matched = true
}
}
if !matched {
continue
}
}
if a.PVFVersion == "all" || !effective {
applicability = "unknown"
}
raw, _ := json.Marshal([]any{r.ID, r.Description, r.Field, r.Target.Section, r.Annotation.Relation, r.Annotation})
hay := strings.ToLower(string(raw))
matched := true
score := 0
for _, term := range terms {
if !strings.Contains(hay, term) {
matched = false
break
}
score++
for _, v := range []string{r.ID, r.Target.Section, r.Annotation.Title} {
if strings.EqualFold(v, term) {
score += 10
}
}
}
if !matched {
continue
}
summary, truncated := annotationShort(strings.TrimSpace(r.Description + "\n" + r.Annotation.Content))
why := []string{}
if d.Entry.Disabled {
why = append(why, "disabled")
}
if d.Entry.Overridden {
why = append(why, "overridden")
}
if !annotationVersion(d, cat.Document, a.PVFVersion) {
why = append(why, "version_mismatch")
}
if len(terms) > 0 {
reasons = append(reasons, "query")
}
if a.Section != "" {
reasons = append(reasons, "section")
}
values := []map[string]string{}
keys := []string{}
for k := range r.Annotation.Values {
keys = append(keys, k)
}
sort.Strings(keys)
if len(terms) > 0 {
for _, k := range keys {
label := r.Annotation.Values[k]
for _, term := range terms {
if strings.Contains(strings.ToLower(k+" "+label), term) {
short, _ := annotationShort(label)
values = append(values, map[string]string{"value": k, "label": short})
break
}
}
if len(values) >= 5 {
break
}
}
}
item := map[string]any{"documentId": d.DocumentID, "kind": d.Entry.Kind, "ruleId": r.ID, "fieldId": r.Field, "title": r.Annotation.Title, "summary": summary, "summaryTruncated": truncated, "section": r.Target.Section, "targetKind": r.Target.Kind, "annotationType": r.Annotation.Type, "pvfVersions": r.PVFVersions, "effective": effective, "applicability": applicability, "matchReasons": reasons, "inactiveReasons": why, "source": annotationSource(d.Entry.Source), "valueCount": len(r.Annotation.Values), "matchedValues": values, "relation": r.Annotation.Relation}
hits = append(hits, hit{item, score, d.Entry.Kind + "\x00" + r.ID + "\x00" + d.Entry.Source + d.DocumentID})
}
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
return hits[i].key < hits[j].key
})
start, end, err := annotationPage(cursor, cat.Revision, binding, a.WorkspaceID, "", len(hits), a.Limit)
if err != nil {
return nil, err
}
items := []map[string]any{}
for _, h := range hits[start:end] {
items = append(items, h.item)
}
return map[string]any{"schemaVersion": 1, "rulesRevision": cat.Revision, "context": map[string]any{"workspaceId": a.WorkspaceID, "pvfVersion": a.PVFVersion, "filePath": a.FilePath}, "items": items, "totalMatches": len(hits), "nextCursor": annotationNext(end, len(hits), cat.Revision, binding, a.WorkspaceID, "")}, nil
}
func (s *MCPService) annotationGet(a annotationArgs) (any, error) {
cat, err := s.c.annotationCatalogLocked()
if err != nil {
return nil, err
}
if a.RulesRevision != cat.Revision {
return nil, fmt.Errorf("STALE_RULES")
}
var selected *annotationDocument
for i := range cat.Items {
if cat.Items[i].DocumentID == a.DocumentID {
selected = &cat.Items[i]
break
}
}
if selected == nil {
return nil, fmt.Errorf("DOCUMENT_NOT_FOUND")
}
d := *selected
r := d.Rule
if a.ValuesLimit == 0 {
a.ValuesLimit = 50
}
a.ValuesLimit = max(1, min(a.ValuesLimit, 100))
cursor := a.ValuesCursor
a.ValuesCursor = ""
binding := annotationHash(a)
keys := []string{}
for k := range r.Annotation.Values {
keys = append(keys, k)
}
sort.Strings(keys)
start, end, err := annotationPage(cursor, cat.Revision, binding, "", "", len(keys), a.ValuesLimit)
if err != nil {
return nil, err
}
values := []map[string]string{}
for _, k := range keys[start:end] {
values = append(values, map[string]string{"value": k, "label": r.Annotation.Values[k]})
}
// Enum maps are paged separately, never duplicated inside raw definitions.
raw, _ := json.Marshal(d.Original)
var original map[string]any
_ = json.Unmarshal(raw, &original)
if annotation, ok := original["annotation"].(map[string]any); ok {
delete(annotation, "values")
}
resolved := r
resolved.Annotation.Values = nil
relations := map[string]ann.RelationSpec{}
warnings := []string{}
if d.ResolutionError != "" {
warnings = append(warnings, d.ResolutionError)
}
visiting := map[string]bool{}
var visit func(string)
visit = func(name string) {
if name == "" {
return
}
if visiting[name] {
warnings = append(warnings, "relation_cycle:"+name)
return
}
if _, ok := relations[name]; ok {
return
}
spec, ok := cat.Document.Relations[name]
if !ok {
warnings = append(warnings, "missing_relation:"+name)
return
}
visiting[name] = true
relations[name] = spec
for _, child := range spec.Relations {
visit(child)
}
visiting[name] = false
}
visit(r.Annotation.Relation)
result := map[string]any{"schemaVersion": 1, "rulesRevision": cat.Revision, "documentId": d.DocumentID, "kind": d.Entry.Kind, "ruleId": r.ID, "source": annotationSource(d.Entry.Source), "effective": d.Entry.Effective, "disabled": d.Entry.Disabled, "overridden": d.Entry.Overridden, "originalDefinition": original, "resolvedDefinition": resolved, "preview": d.Preview, "valueCount": len(keys), "values": values, "definitionsOmitValues": true, "nextValuesCursor": annotationNext(end, len(keys), cat.Revision, binding, "", ""), "relations": relations, "warnings": warnings}
if a.Value != nil {
label, found := r.Annotation.Values[*a.Value]
result["value"] = map[string]any{"value": *a.Value, "found": found, "label": label}
}
return result, nil
}