package services import ( "bytes" "fmt" "sort" "strconv" "strings" "pvfine/internal/pvf" pvfversion "pvfine/internal/version" ) type ShopMaterialInput struct { ItemID string `json:"itemId"` Quantity string `json:"quantity"` } type ShopDraft struct { FileIndex int32 `json:"fileIndex"` Path string `json:"path"` Text string `json:"text"` } type ShopEditRequest struct { FileIndex int32 `json:"fileIndex"` Path string `json:"path"` Text string `json:"text"` Revision uint64 `json:"revision"` Action string `json:"action"` TabIndex int `json:"tabIndex"` SourceStart int `json:"sourceStart"` CategoryID string `json:"categoryId"` ItemID string `json:"itemId"` Name string `json:"name"` SetGold bool `json:"setGold"` Gold string `json:"gold"` // empty removes [price], "0" is an explicit zero SetMaterials bool `json:"setMaterials"` Materials []ShopMaterialInput `json:"materials"` Drafts []ShopDraft `json:"drafts"` } type ShopEditedFile struct { FileIndex int32 `json:"fileIndex"` Path string `json:"path"` BeforeText string `json:"beforeText"` Text string `json:"text"` } type ShopEditResult struct { Revision uint64 `json:"revision"` Files []ShopEditedFile `json:"files"` AffectedItems int `json:"affectedItems"` ModifiedCount int `json:"modifiedCount"` } // ReadItem resolves the same item union used by shop lists and search pickers. func (s *FileGUIService) ReadItem(id string, drafts []ShopDraft) (*ShopItem, error) { s.c.mu.Lock() defer s.c.mu.Unlock() if s.c.archive == nil { return nil, ErrNoArchive } if s.c.annotationEngine == nil { return nil, fmt.Errorf("物品索引未初始化") } ref, ok := s.c.resolveAnnotationReferenceLocked("物品", id) if !ok { return nil, fmt.Errorf("找不到物品 ID %s", id) } for _, draft := range drafts { if draft.FileIndex == ref.FileIndex { if !sameSearchPath(draft.Path, s.c.archive.Path(ref.FileIndex)) { return nil, fmt.Errorf("物品草稿已变化") } return s.readItemLocked(id, &draft.Text) } } return s.readItemLocked(id, nil) } func (s *FileGUIService) readItemLocked(id string, draftText *string) (*ShopItem, error) { if s.c.annotationEngine == nil { return nil, fmt.Errorf("物品索引未初始化") } ref, ok := s.c.resolveAnnotationReferenceLocked("物品", id) if !ok { return nil, fmt.Errorf("找不到物品 ID %s", id) } a := s.c.archive meta, err := a.ScriptMetadata(ref.FileIndex) if err != nil { return nil, err } text, err := a.Text(ref.FileIndex) if err != nil { return nil, err } if draftText != nil { text = *draftText } doc := &ShopDocument{} item := &ShopItem{ID: id, FileIndex: ref.FileIndex, Path: a.Path(ref.FileIndex), Name: markedName(meta), Rarity: meta.RarityValue(), Icon: imageReferenceFromPVF(meta.Icon), Costs: shopCosts(text, id, doc)} if len(doc.Issues) > 0 { return nil, fmt.Errorf("物品成本格式异常,请先在文本模式修正:%s", doc.Issues[0].Message) } for i := range item.Costs { cost := &item.Costs[i] if cost.Kind != "material" { continue } if m, ok := s.c.resolveAnnotationReferenceLocked("物品", cost.ItemID); ok { cost.Name = m.Name if metadata, err := a.ScriptMetadata(m.FileIndex); err == nil { cost.Name = markedName(metadata) cost.Icon = imageReferenceFromPVF(metadata.Icon) } } else { cost.Name = "未知物品 #" + cost.ItemID } } return item, nil } // ApplyShopEdit stages every touched file before publishing one overlay change. // The caller sends dirty drafts, but only files touched by this operation are // consumed. Revision and path checks reject another archive or stale dialog. func (s *FileGUIService) ApplyShopEdit(req ShopEditRequest) (*ShopEditResult, error) { s.c.mu.Lock() result, a, summary, err := s.applyShopEditLocked(req) s.c.mu.Unlock() if err != nil { return nil, err } if len(result.Files) > 0 { if err := s.c.scheduleArchiveMutations(a, summary); err != nil { return nil, err } indexes := make([]int32, 0, len(result.Files)) for _, f := range result.Files { indexes = append(indexes, f.FileIndex) } emitEvent("archive:batch-applied", map[string]any{"source": "shop-gui", "structural": false, "fileIndexes": indexes, "revision": result.Revision, "modifiedCount": result.ModifiedCount}) emitEvent("archive:advanced-search-stale", map[string]any{"shop": true}) emitVersionState(s.c, "shop-edited") } return result, nil } func (s *FileGUIService) applyShopEditLocked(req ShopEditRequest) (*ShopEditResult, *pvf.Archive, pvf.MutationSummary, error) { fail := func(err error) (*ShopEditResult, *pvf.Archive, pvf.MutationSummary, error) { return nil, nil, pvf.MutationSummary{}, err } a := s.c.archive if a == nil { return fail(ErrNoArchive) } if req.Revision != s.c.batchRevision { return fail(fmt.Errorf("商店数据已变化,请关闭表单并重新加载后再操作")) } if err := s.c.ensureVersionReadyLocked(); err != nil { return fail(err) } if err := validateAnnotationIndex(a, req.FileIndex); err != nil { return fail(err) } if !sameSearchPath(req.Path, a.Path(req.FileIndex)) || !strings.HasSuffix(strings.ToLower(req.Path), ".shp") { return fail(fmt.Errorf("商店文件已变化")) } limit := int(s.c.editableByteLimit()) if len(req.Text) > limit { return fail(fmt.Errorf("商店文本过大")) } parsed := parseShop(req.Text) if len(parsed.Issues) > 0 && !(req.Action == "add-tab" && len(parsed.Tabs) == 0 && len(parsed.Issues) == 1) { return fail(fmt.Errorf("商店结构异常,请先在文本模式修正:%s", parsed.Issues[0].Message)) } if parsed.CategoryType != "" { categories, categoryErr := readShopCategories(a, parsed.CategoryType) if categoryErr != nil { return fail(categoryErr) } parsed.Categories = categories } stage := a.CloneForBatch() drafts := make(map[int32]ShopDraft) for _, d := range req.Drafts { if d.FileIndex < 0 || d.FileIndex >= a.FileCount() || !sameSearchPath(d.Path, a.Path(d.FileIndex)) { return fail(fmt.Errorf("草稿文件已变化")) } if _, ok := drafts[d.FileIndex]; ok { return fail(fmt.Errorf("重复的文件草稿")) } drafts[d.FileIndex] = d } drafts[req.FileIndex] = ShopDraft{FileIndex: req.FileIndex, Path: req.Path, Text: req.Text} loaded := make(map[int32]bool) beforeTexts := make(map[int32]string) load := func(index int32) (*pvf.ScriptDocument, error) { if !loaded[index] { text, err := a.Text(index) if err != nil { return nil, err } if cached, ok := s.c.editorText[index]; ok { text = cached } if draft, ok := drafts[index]; ok { text = draft.Text } if len(text) > limit { return nil, fmt.Errorf("待修改文件超过编辑大小限制") } beforeTexts[index] = text rawText, err := a.Text(index) if err != nil { return nil, err } if text != rawText { if err := stage.SetText(index, text); err != nil { return nil, err } } loaded[index] = true } raw, err := stage.RawBytes(index) if err != nil { return nil, err } return stage.ParseScriptDocument(raw) } document, err := load(req.FileIndex) if err != nil { return fail(err) } if len(document.Warnings()) > 0 { return fail(fmt.Errorf("商店区块未正确闭合,请先在文本模式修正")) } sell := document.Sections([]string{"sell info"}) var tab *pvf.ScriptSection if req.Action != "add-tab" { if req.TabIndex < 0 || req.TabIndex >= len(parsed.Tabs) { return fail(fmt.Errorf("分页已变化,请重新加载")) } location := parsed.Tabs[req.TabIndex].location matches := document.Sections(location.path) if location.occurrence >= len(matches) { return fail(fmt.Errorf("分页结构已变化")) } tab = matches[location.occurrence] if location.implicit && (req.Action == "rename-tab" || req.Action == "delete-tab") { return fail(fmt.Errorf("该商店没有显式分页,不能重命名或删除分页;可直接编辑商品")) } } if req.SetGold || req.SetMaterials { for ti, page := range parsed.Tabs { if ti != req.TabIndex { continue } for _, group := range page.Groups { for _, entry := range group.Items { if req.Action == "batch-costs" || req.Action == "edit-item" && entry.SourceStart == req.SourceStart { if entry.localGold || entry.localMaterials { return fail(fmt.Errorf("该商品有商店局部价格或兑换材料,请在文本模式修改局部成本;未修改共享物品成本")) } } } } } } itemIDs := []string{} shopChanged := false switch req.Action { case "add-tab", "rename-tab": name := strings.TrimSpace(req.Name) if name == "" || strings.ContainsAny(name, "`\r\n") || len([]rune(name)) > 100 { return fail(fmt.Errorf("分页名称需为 1–100 个字符,不能包含换行或反引号")) } value, err := pvf.NewScriptValue(pvf.ScriptTokenQuoted, name, pvf.ScriptPoolUTF16) if err != nil { return fail(err) } if req.Action == "rename-tab" { err = tab.SetValues([]pvf.ScriptValue{value}) } else { for _, page := range parsed.Tabs { if page.location.implicit { return fail(fmt.Errorf("该商店没有显式分页,新增分页需先在文本模式调整结构")) } } var lists []*pvf.ScriptSection for _, page := range parsed.Tabs { matches := document.Sections(page.location.path) if page.location.occurrence >= len(matches) { return fail(fmt.Errorf("分页结构已变化")) } lists = append(lists, shopItemLists(matches[page.location.occurrence])...) } listName := shopListName(lists, a.ClientVersion()) if len(parsed.Tabs) > 0 && len(parsed.Tabs[0].location.path) == 1 { tab, err = document.AppendSection("tab", []pvf.ScriptValue{value}, true) } else if len(sell) == 1 && sell[0].HasEndTag() { tab, err = sell[0].AppendSection("tab", []pvf.ScriptValue{value}, true) } else { return fail(fmt.Errorf("无法确定新增分页所属的完整商店区块")) } if err == nil { if parsed.CategoryType == "" { _, err = tab.AppendSection(listName, nil, true) } else { if len(parsed.Categories) == 0 { return fail(fmt.Errorf("商店没有可用分类,请检查商店分类文件")) } for _, category := range parsed.Categories { group, e := tab.AppendSection("category entry", nil, true) if e != nil { return fail(e) } id, e := shopInteger(category.ID) if e != nil { return fail(e) } if _, e = group.AppendSection("id", []pvf.ScriptValue{id}, false); e != nil { return fail(e) } if _, e = group.AppendSection(listName, nil, true); e != nil { return fail(e) } } } } } if err != nil { return fail(err) } shopChanged = true case "delete-tab": if err := tab.Delete(); err != nil { return fail(err) } shopChanged = true case "edit-item", "delete-item": ordinal := -1 count := 0 for _, group := range parsed.Tabs[req.TabIndex].Groups { for _, entry := range group.Items { if entry.SourceStart == req.SourceStart { ordinal = count } count++ } } if ordinal < 0 { return fail(fmt.Errorf("商品位置已变化,请重新打开表单")) } records := shopRecords(tab) if ordinal >= len(records) { return fail(fmt.Errorf("无法定位商品记录")) } record := records[ordinal] target, offset := record.index, record.offset values := target.Values() if req.Action == "delete-item" { if record.block != nil { if err := record.block.Delete(); err != nil { return fail(err) } } else { values = append(values[:offset], values[offset+1:]...) } } else { value, err := shopInteger(req.ItemID) if err != nil { return fail(err) } if _, err = s.readItemLocked(req.ItemID, nil); err != nil { return fail(err) } values[offset] = value itemIDs = append(itemIDs, req.ItemID) } if req.Action != "delete-item" || record.block == nil { if err := target.SetValues(values); err != nil { return fail(err) } } shopChanged = true case "add-item": value, err := shopInteger(req.ItemID) if err != nil { return fail(err) } if _, err = s.readItemLocked(req.ItemID, nil); err != nil { return fail(err) } var target *pvf.ScriptSection listName := shopListName(shopItemLists(tab), a.ClientVersion()) if parsed.CategoryType == "" { for _, child := range tab.Children() { if child.Name() == "item list" || child.Name() == "sell item list" { target = child break } } if target == nil { target, err = tab.AppendSection(listName, nil, true) } } else { for _, group := range tab.Children() { if group.Name() != "category entry" { continue } id := "" for _, child := range group.Children() { if child.Name() == "id" { if v, ok := child.Get(0); ok { id = fmt.Sprint(v) } } } if id != req.CategoryID { continue } for _, child := range group.Children() { if child.Name() == "item list" || child.Name() == "sell item list" { target = child break } } if target == nil { target, err = group.AppendSection(listName, nil, true) } break } } if err != nil { return fail(err) } if target == nil { known := false for _, category := range parsed.Categories { if category.ID == req.CategoryID { known = true } } if !known { return fail(fmt.Errorf("当前商店没有该分类,无法添加商品")) } group, e := tab.AppendSection("category entry", nil, true) if e != nil { return fail(e) } id, e := shopInteger(req.CategoryID) if e != nil { return fail(e) } if _, e = group.AppendSection("id", []pvf.ScriptValue{id}, false); e != nil { return fail(e) } target, e = group.AppendSection(listName, nil, true) if e != nil { return fail(e) } } if err := appendShopRecord(document, target, value); err != nil { return fail(err) } itemIDs = append(itemIDs, req.ItemID) shopChanged = true case "batch-costs": if !req.SetGold && !req.SetMaterials { return fail(fmt.Errorf("请选择至少一种需要批量设置的成本")) } for _, record := range shopRecords(tab) { itemIDs = append(itemIDs, fmt.Sprint(record.index.Values()[record.offset].Value)) } if len(itemIDs) == 0 { return fail(fmt.Errorf("当前分页没有商品")) } default: return fail(fmt.Errorf("未知商店操作")) } if shopChanged { raw, err := stage.EncodeScriptDocument(document) if err != nil { return fail(err) } if err := stage.SetRawBytes(req.FileIndex, raw); err != nil { return fail(err) } } var gold []pvf.ScriptValue if req.SetGold && req.Gold != "" { v, err := shopInteger(req.Gold) if err != nil { return fail(fmt.Errorf("金币价格无效:%w", err)) } gold = []pvf.ScriptValue{v} } materials := []pvf.ScriptValue{} if req.SetMaterials { for _, m := range req.Materials { id, err := shopInteger(m.ItemID) if err != nil { return fail(err) } qty, err := shopInteger(m.Quantity) if err != nil { return fail(err) } if _, ok := s.c.resolveAnnotationReferenceLocked("物品", m.ItemID); !ok { return fail(fmt.Errorf("找不到兑换道具 %s", m.ItemID)) } materials = append(materials, id, qty) } } seen := make(map[int32]bool) for _, id := range itemIDs { ref, ok := s.c.resolveAnnotationReferenceLocked("物品", id) if !ok { return fail(fmt.Errorf("找不到商品 %s,未应用任何修改", id)) } if seen[ref.FileIndex] { continue } seen[ref.FileIndex] = true if !req.SetGold && !req.SetMaterials { continue } itemDocument, err := load(ref.FileIndex) if err != nil { return fail(err) } if req.SetGold { if err := replaceShopCostSection(itemDocument, "price", gold, false); err != nil { return fail(err) } } if req.SetMaterials { if err := replaceShopCostSection(itemDocument, "need material", materials, true); err != nil { return fail(err) } } raw, err := stage.EncodeScriptDocument(itemDocument) if err != nil { return fail(err) } if err := stage.SetRawBytes(ref.FileIndex, raw); err != nil { return fail(err) } } indexes := make([]int32, 0, len(loaded)) for i := range loaded { indexes = append(indexes, i) } sort.Slice(indexes, func(i, j int) bool { return indexes[i] < indexes[j] }) changes := []pvf.ScriptChange{} paths := []string{} result := &ShopEditResult{Files: []ShopEditedFile{}, AffectedItems: len(seen)} for _, i := range indexes { before, err := a.RawBytes(i) if err != nil { return fail(err) } after, err := stage.RawBytes(i) if err != nil { return fail(err) } if bytes.Equal(before, after) { continue } text, err := stage.Text(i) if err != nil { return fail(err) } changes = append(changes, pvf.ScriptChange{Kind: pvf.ChangeKindChanged, Path: a.Path(i), Raw: after, DataType: a.File(i).DataType}) paths = append(paths, a.Path(i)) result.Files = append(result.Files, ShopEditedFile{FileIndex: i, Path: a.Path(i), BeforeText: beforeTexts[i], Text: text}) } var before, after pvfversion.ContentSnapshot if len(changes) > 0 && s.c.versionRepo != nil { before, err = pvfversion.ContentSnapshotFromArchive(a, paths) if err != nil { return fail(err) } after, err = pvfversion.ContentSnapshotFromArchive(stage, paths) if err != nil { return fail(err) } } checkpoint := a.MutationCheckpoint() if len(changes) > 0 { changedIndexes := make(map[int32]struct{}, len(result.Files)) for _, file := range result.Files { changedIndexes[file.FileIndex] = struct{}{} } if s.c.diskIndex != nil { if err := s.c.diskIndex.refreshFileMetadata(stage, changedIndexes); err != nil { return fail(err) } } if err := a.ApplyScriptChanges(stage, changes); err != nil { if s.c.diskIndex != nil { _ = s.c.diskIndex.refreshFileMetadata(a, changedIndexes) } return fail(err) } if s.c.diskIndex == nil { refreshArchiveIndexMetadataLocked(s.c, changedIndexes) } if err := s.c.recordVersionMutationLocked("商店编辑", before, after); err != nil { return fail(err) } s.c.batchRevision++ s.c.batchPlan = nil s.c.invalidateScriptLocked() s.c.markAdvancedSearchStaleLocked(s.c.archive) s.c.editorAnnotation = editorAnnotationCache{} s.c.annotationRelations = make(map[string]map[string]*relationTarget) if s.c.editorText == nil { s.c.editorText = make(map[int32]string) } for _, f := range result.Files { s.c.editorText[f.FileIndex] = f.Text delete(s.c.visualsByFile, f.FileIndex) } } summary := a.MutationsSince(checkpoint) a.ClearMutations() result.Revision = s.c.batchRevision result.ModifiedCount = a.ModifiedCount() return result, a, summary, nil } func shopInteger(text string) (pvf.ScriptValue, error) { n, err := strconv.ParseInt(text, 10, 32) if err != nil || n < 0 { return pvf.ScriptValue{}, fmt.Errorf("需要 0–2147483647 的整数:%s", text) } return pvf.NewScriptValue(pvf.ScriptTokenInteger, n, "") } func shopItemLists(tab *pvf.ScriptSection) []*pvf.ScriptSection { lists := []*pvf.ScriptSection{} for _, child := range tab.Children() { switch child.Name() { case "item list", "sell item list": lists = append(lists, child) case "category entry": for _, list := range child.Children() { if list.Name() == "item list" || list.Name() == "sell item list" { lists = append(lists, list) } } } } return lists } func shopListName(lists []*pvf.ScriptSection, version string) string { for _, list := range lists { if list.Name() == "sell item list" { return "sell item list" } } if len(lists) == 0 && version == "115CN" { return "sell item list" } return "item list" } type shopRecord struct { index *pvf.ScriptSection offset int block *pvf.ScriptSection } func shopRecords(tab *pvf.ScriptSection) []shopRecord { records := []shopRecord{} for _, list := range shopItemLists(tab) { if list.Name() == "item list" { for i := range list.Values() { records = append(records, shopRecord{index: list, offset: i}) } continue } for _, block := range list.Children() { if block.Name() != "item" { continue } for _, field := range block.Children() { if field.Name() == "index" { values := field.Values() if len(values) == 1 && values[0].Type == pvf.ScriptTokenInteger && fmt.Sprint(values[0].Value) == "-1" { continue } records = append(records, shopRecord{index: field, block: block}) } } } } return records } func appendShopRecord(document *pvf.ScriptDocument, list *pvf.ScriptSection, value pvf.ScriptValue) error { if list.Name() == "item list" { return list.Append(value) } // The direct item integer is the shop entry key, not the product ID. // Allocate above existing keys without renumbering records or copying overrides. var next int64 var blocks []*pvf.ScriptSection var collect func([]*pvf.ScriptSection) collect = func(sections []*pvf.ScriptSection) { for _, section := range sections { if section.Name() == "sell item list" { for _, block := range section.Children() { if block.Name() == "item" { blocks = append(blocks, block) } } } else { collect(section.Children()) } } } collect(document.Sections(nil)) for _, block := range blocks { values := block.Values() if len(values) != 1 { return fmt.Errorf("商品记录序号结构异常,无法新增") } key, err := strconv.ParseInt(fmt.Sprint(values[0].Value), 10, 32) if err != nil || key < 0 || values[0].Type != pvf.ScriptTokenInteger { return fmt.Errorf("商品记录序号无效") } if key >= next { next = key + 1 } } key, err := shopInteger(strconv.FormatInt(next, 10)) if err != nil { return err } block, err := list.AppendSection("item", []pvf.ScriptValue{key}, true) if err != nil { return err } _, err = block.AppendSection("index", []pvf.ScriptValue{value}, false) return err } func replaceShopCostSection(doc *pvf.ScriptDocument, name string, values []pvf.ScriptValue, paired bool) error { sections := doc.Sections([]string{name}) if len(values) == 0 { for _, section := range sections { if err := section.Delete(); err != nil { return err } } return nil } if len(sections) == 0 { _, err := doc.AppendSection(name, values, paired) return err } if err := sections[0].SetValues(values); err != nil { return err } for _, section := range sections[1:] { if err := section.Delete(); err != nil { return err } } return nil }