Files
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

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
}