服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
325 lines
10 KiB
Go
325 lines
10 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"pvfine/internal/pvf"
|
|
)
|
|
|
|
// FileGUIService reads GUI documents and applies validated edits to the overlay.
|
|
type FileGUIService struct{ c *core }
|
|
|
|
func NewFileGUIService(c *core) *FileGUIService { return &FileGUIService{c: c} }
|
|
|
|
type ShopCategory struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
type ShopCost struct {
|
|
Kind string `json:"kind"`
|
|
ItemID string `json:"itemId"`
|
|
Quantity string `json:"quantity"`
|
|
Name string `json:"name"`
|
|
Icon *ImageReference `json:"icon"`
|
|
}
|
|
type ShopItem struct {
|
|
FileIndex int32 `json:"fileIndex"`
|
|
Path string `json:"path"`
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Rarity int32 `json:"rarity"`
|
|
Icon *ImageReference `json:"icon"`
|
|
Costs []ShopCost `json:"costs"`
|
|
}
|
|
type ShopEntry struct {
|
|
Item ShopItem `json:"item"`
|
|
SourceStart int `json:"sourceStart"`
|
|
SourceEnd int `json:"sourceEnd"`
|
|
HasLocalCosts bool `json:"hasLocalCosts"`
|
|
localCosts []ShopCost
|
|
localGold, localMaterials bool
|
|
}
|
|
type ShopGroup struct {
|
|
CategoryID string `json:"categoryId"`
|
|
SourceStart int `json:"sourceStart"`
|
|
Items []ShopEntry `json:"items"`
|
|
}
|
|
type ShopTab struct {
|
|
Name string `json:"name"`
|
|
SourceStart int `json:"sourceStart"`
|
|
Groups []ShopGroup `json:"groups"`
|
|
Implicit bool `json:"implicit"`
|
|
location shopLocation
|
|
}
|
|
type ShopDocument struct {
|
|
Revision uint64 `json:"revision"`
|
|
Name string `json:"name"`
|
|
CategoryType string `json:"categoryType"`
|
|
Categories []ShopCategory `json:"categories"`
|
|
Tabs []ShopTab `json:"tabs"`
|
|
Issues []PreviewIssue `json:"issues"`
|
|
}
|
|
|
|
// ReadShop reads structure from the caller's draft, and references from the
|
|
// current archive overlay. Archive reads can populate caches, requiring Lock.
|
|
func (s *FileGUIService) ReadShop(fileIndex int32, text string) (*ShopDocument, error) {
|
|
s.c.mu.Lock()
|
|
defer s.c.mu.Unlock()
|
|
if s.c.archive == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
if err := validateAnnotationIndex(s.c.archive, fileIndex); err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.EqualFold(path.Ext(s.c.archive.Path(fileIndex)), ".shp") {
|
|
return nil, fmt.Errorf("商店 GUI 仅支持 .shp 文件")
|
|
}
|
|
if s.c.annotationEngine == nil {
|
|
return nil, fmt.Errorf("物品关联索引未初始化")
|
|
}
|
|
doc := parseShop(text)
|
|
doc.Revision = s.c.batchRevision
|
|
doc.Name = path.Base(s.c.archive.Path(fileIndex))
|
|
if npc := firstSectionValue(text, "npc"); npc != "" {
|
|
if ref, ok := s.c.resolveAnnotationReferenceLocked("npc", npc); ok && ref.Name != "" {
|
|
doc.Name = resolvePreviewText(s.c.archive, ref.Name)
|
|
}
|
|
}
|
|
if doc.CategoryType != "" {
|
|
categories, err := readShopCategories(s.c.archive, doc.CategoryType)
|
|
if err != nil {
|
|
shopIssue(doc, "use category", err.Error())
|
|
} else {
|
|
doc.Categories = categories
|
|
}
|
|
unclassified := false
|
|
for _, tab := range doc.Tabs {
|
|
for _, group := range tab.Groups {
|
|
if group.CategoryID == "" && len(group.Items) > 0 {
|
|
unclassified = true
|
|
}
|
|
}
|
|
}
|
|
if unclassified {
|
|
doc.Categories = append([]ShopCategory{{ID: "", Name: "未分类"}}, doc.Categories...)
|
|
}
|
|
}
|
|
// Metadata and complete items are separate caches: materials need no costs,
|
|
// and recursive material requirements must never recurse through products.
|
|
metadata := make(map[string]ShopItem)
|
|
resolve := func(id string) (ShopItem, int32, bool) {
|
|
ref, ok := s.c.resolveAnnotationReferenceLocked("物品", id)
|
|
if !ok {
|
|
return ShopItem{ID: id, Name: "未知物品 #" + id, Costs: []ShopCost{}}, -1, false
|
|
}
|
|
if item, exists := metadata[id]; exists {
|
|
return item, ref.FileIndex, true
|
|
}
|
|
item := ShopItem{ID: id, Name: ref.Name, FileIndex: ref.FileIndex, Path: s.c.archive.Path(ref.FileIndex), Rarity: pvf.RarityUnknown, Costs: []ShopCost{}}
|
|
if item.Name == "" {
|
|
item.Name = "物品 #" + id
|
|
}
|
|
m, err := s.c.archive.ScriptMetadata(ref.FileIndex)
|
|
if err == nil {
|
|
item.Icon = imageReferenceFromPVF(m.Icon)
|
|
item.Rarity = m.RarityValue()
|
|
if m.HasName {
|
|
item.Name = markedName(m)
|
|
}
|
|
}
|
|
metadata[id] = item
|
|
return item, ref.FileIndex, true
|
|
}
|
|
items := make(map[string]ShopItem)
|
|
for ti := range doc.Tabs {
|
|
tab := &doc.Tabs[ti]
|
|
tab.Name = resolvePreviewText(s.c.archive, tab.Name)
|
|
for gi := range tab.Groups {
|
|
for ei := range tab.Groups[gi].Items {
|
|
entry := &tab.Groups[gi].Items[ei]
|
|
id := entry.Item.ID
|
|
if cached, ok := items[id]; ok {
|
|
entry.Item = cached
|
|
continue
|
|
}
|
|
item, index, ok := resolve(id)
|
|
if !ok {
|
|
shopIssue(doc, "item list", "找不到商品 #"+id)
|
|
} else {
|
|
itemText, err := s.c.archive.Text(index)
|
|
if err != nil {
|
|
shopIssue(doc, "item list", "无法读取商品 #"+id)
|
|
} else {
|
|
item.Costs = shopCosts(itemText, id, doc)
|
|
for ci := range item.Costs {
|
|
cost := &item.Costs[ci]
|
|
if cost.Kind != "material" {
|
|
continue
|
|
}
|
|
material, _, found := resolve(cost.ItemID)
|
|
cost.Name, cost.Icon = material.Name, material.Icon
|
|
if !found {
|
|
shopIssue(doc, "need material", "找不到兑换道具 #"+cost.ItemID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
items[id], entry.Item = item, item
|
|
}
|
|
}
|
|
}
|
|
for ti := range doc.Tabs {
|
|
for gi := range doc.Tabs[ti].Groups {
|
|
for ei := range doc.Tabs[ti].Groups[gi].Items {
|
|
entry := &doc.Tabs[ti].Groups[gi].Items[ei]
|
|
if !entry.localGold && !entry.localMaterials {
|
|
continue
|
|
}
|
|
costs := []ShopCost{}
|
|
for _, cost := range entry.Item.Costs {
|
|
if cost.Kind == "gold" && entry.localGold || cost.Kind == "material" && entry.localMaterials {
|
|
continue
|
|
}
|
|
costs = append(costs, cost)
|
|
}
|
|
for _, cost := range entry.localCosts {
|
|
if cost.Kind == "material" {
|
|
material, _, found := resolve(cost.ItemID)
|
|
cost.Name, cost.Icon = material.Name, material.Icon
|
|
if !found {
|
|
shopIssue(doc, "need material", "找不到兑换道具 #"+cost.ItemID)
|
|
}
|
|
}
|
|
costs = append(costs, cost)
|
|
}
|
|
entry.Item.Costs = costs
|
|
}
|
|
}
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
func shopIssue(doc *ShopDocument, section, message string) {
|
|
doc.Issues = append(doc.Issues, PreviewIssue{Severity: "warning", Section: section, Message: message})
|
|
}
|
|
|
|
const shopCategoryPath = "etc/itemshop/itemshopcategory.etc"
|
|
|
|
// readShopCategories keeps the order and IDs defined by the active archive.
|
|
func readShopCategories(a *pvf.Archive, categoryType string) ([]ShopCategory, error) {
|
|
index, ok := a.Find(shopCategoryPath)
|
|
if !ok {
|
|
return nil, fmt.Errorf("找不到商店分类文件:%s", shopCategoryPath)
|
|
}
|
|
text, err := a.Text(index)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取商店分类文件失败:%w", err)
|
|
}
|
|
view := pvf.ParseScriptView(text)
|
|
values := make(map[int]string)
|
|
for _, e := range view.Elements {
|
|
if e.Kind == pvf.ScriptElementToken && e.Index == 0 {
|
|
values[e.SectionID] = e.Value
|
|
}
|
|
}
|
|
type categoryBlock struct {
|
|
name string
|
|
entries []ShopCategory
|
|
}
|
|
blocks := []categoryBlock{}
|
|
block, entry := -1, -1
|
|
for _, e := range view.Elements {
|
|
if e.Kind != pvf.ScriptElementSection {
|
|
continue
|
|
}
|
|
switch strings.Join(e.SectionPath, "/") {
|
|
case "itemshop category":
|
|
blocks = append(blocks, categoryBlock{entries: []ShopCategory{}})
|
|
block, entry = len(blocks)-1, -1
|
|
case "itemshop category/category name":
|
|
if block >= 0 {
|
|
blocks[block].name = values[e.SectionID]
|
|
}
|
|
case "itemshop category/category entry":
|
|
if block >= 0 {
|
|
blocks[block].entries = append(blocks[block].entries, ShopCategory{})
|
|
entry = len(blocks[block].entries) - 1
|
|
}
|
|
case "itemshop category/category entry/id":
|
|
if block >= 0 && entry >= 0 {
|
|
blocks[block].entries[entry].ID = values[e.SectionID]
|
|
}
|
|
case "itemshop category/category entry/text":
|
|
if block >= 0 && entry >= 0 {
|
|
blocks[block].entries[entry].Name = resolvePreviewText(a, values[e.SectionID])
|
|
}
|
|
}
|
|
}
|
|
for _, block := range blocks {
|
|
if block.name != categoryType {
|
|
continue
|
|
}
|
|
categories := make([]ShopCategory, 0, len(block.entries))
|
|
for _, category := range block.entries {
|
|
if _, err := strconv.ParseInt(category.ID, 10, 32); err != nil {
|
|
continue
|
|
}
|
|
if category.Name == "" {
|
|
category.Name = "分类 " + category.ID
|
|
}
|
|
categories = append(categories, category)
|
|
}
|
|
return categories, nil
|
|
}
|
|
return nil, fmt.Errorf("商店分类文件中找不到大分类:%s", categoryType)
|
|
}
|
|
|
|
func shopCosts(text, id string, doc *ShopDocument) []ShopCost {
|
|
costs := []ShopCost{}
|
|
view := pvf.ParseScriptView(text)
|
|
sections := make(map[int][]pvf.ScriptElement)
|
|
for _, e := range view.Elements {
|
|
if e.Kind == pvf.ScriptElementToken {
|
|
sections[e.SectionID] = append(sections[e.SectionID], e)
|
|
}
|
|
}
|
|
for _, e := range view.Elements {
|
|
if e.Kind != pvf.ScriptElementSection || len(e.SectionPath) != 1 {
|
|
continue
|
|
}
|
|
costs = append(costs, shopCostValues(e.Section, sections[e.SectionID], id, doc)...)
|
|
}
|
|
return costs
|
|
}
|
|
|
|
func shopCostValues(name string, vs []pvf.ScriptElement, id string, doc *ShopDocument) []ShopCost {
|
|
costs := []ShopCost{}
|
|
validNumber := func(e pvf.ScriptElement) bool {
|
|
n, err := strconv.ParseInt(e.Value, 10, 32)
|
|
return err == nil && n >= 0 && e.TokenType == 0
|
|
}
|
|
switch name {
|
|
case "price":
|
|
if len(vs) != 1 || !validNumber(vs[0]) {
|
|
shopIssue(doc, "price", "商品 #"+id+" 的金币价格无效")
|
|
return costs
|
|
}
|
|
costs = append(costs, ShopCost{Kind: "gold", Quantity: vs[0].Value, Name: "金币"})
|
|
case "need material":
|
|
if len(vs) == 0 || len(vs)%2 != 0 {
|
|
shopIssue(doc, "need material", "商品 #"+id+" 的兑换道具必须按 ID、数量成对配置")
|
|
}
|
|
for i := 0; i+1 < len(vs); i += 2 {
|
|
if !validNumber(vs[i]) || !validNumber(vs[i+1]) {
|
|
shopIssue(doc, "need material", "商品 #"+id+" 的兑换成本无效")
|
|
continue
|
|
}
|
|
costs = append(costs, ShopCost{Kind: "material", ItemID: vs[i].Value, Quantity: vs[i+1].Value})
|
|
}
|
|
}
|
|
return costs
|
|
}
|