服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
345 lines
10 KiB
Go
345 lines
10 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf16"
|
|
|
|
"pvfine/internal/pvf"
|
|
)
|
|
|
|
const independentDropPath = "etc/independent_drop.etc"
|
|
|
|
type IndependentDropList struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
FileIndex int32 `json:"fileIndex"`
|
|
}
|
|
type IndependentDropItem struct {
|
|
Fields []SkillNumber `json:"fields"`
|
|
Name string `json:"name"`
|
|
}
|
|
type IndependentDropRow struct {
|
|
ListTagStart int `json:"listTagStart"`
|
|
Fields []SkillNumber `json:"fields"`
|
|
Items []IndependentDropItem `json:"items"`
|
|
MonsterName string `json:"monsterName"`
|
|
ItemName string `json:"itemName"`
|
|
Start int `json:"start"`
|
|
End int `json:"end"`
|
|
ListStart int `json:"listStart"`
|
|
ListEnd int `json:"listEnd"`
|
|
HasExtensions bool `json:"hasExtensions"`
|
|
}
|
|
type IndependentDropDocument struct {
|
|
Version string `json:"version"`
|
|
Rows []IndependentDropRow `json:"rows"`
|
|
Lists []IndependentDropList `json:"lists"`
|
|
Balance []SkillNumber `json:"balance"`
|
|
BodyEnd int `json:"bodyEnd"`
|
|
IsList bool `json:"isList"`
|
|
Issues []string `json:"issues"`
|
|
}
|
|
|
|
// Positions use the editor's normalized UTF-16 coordinate system. Unknown
|
|
// sections are never reserialized; the GUI changes only selected source spans.
|
|
func parseIndependentDrop(text string, listOnly bool) (*IndependentDropDocument, error) {
|
|
view := pvf.ParseScriptViewWithNestedSections(text, func(parent, child string) bool {
|
|
return parent == "independent drop" && (child == "list" || child == "replace list")
|
|
})
|
|
normalized := strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")
|
|
units := utf16.Encode([]rune(normalized))
|
|
closing := func(start int, tag string) (int, error) {
|
|
needle := utf16.Encode([]rune(tag))
|
|
for at := start; at+len(needle) <= len(units); at++ {
|
|
if units[at] == '#' {
|
|
for at < len(units) && units[at] != '\n' {
|
|
at++
|
|
}
|
|
continue
|
|
}
|
|
if units[at] == '`' {
|
|
at++
|
|
for at < len(units) {
|
|
if units[at] == '`' {
|
|
if at+1 < len(units) && units[at+1] == '`' {
|
|
at += 2
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
at++
|
|
}
|
|
continue
|
|
}
|
|
|
|
if units[at] != '[' {
|
|
continue
|
|
}
|
|
match := true
|
|
for j, want := range needle {
|
|
got := units[at+j]
|
|
if got >= 'A' && got <= 'Z' {
|
|
got += 'a' - 'A'
|
|
}
|
|
if got != want {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
return at, nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("缺少 %s,无法安全编辑", tag)
|
|
}
|
|
|
|
doc := &IndependentDropDocument{Rows: []IndependentDropRow{}, Lists: []IndependentDropList{}, Balance: []SkillNumber{}, Issues: []string{}, IsList: listOnly, BodyEnd: -1}
|
|
pending := []SkillNumber{}
|
|
listTokens := []SkillNumber{}
|
|
currentList := -1
|
|
found := false
|
|
flushList := func() error {
|
|
if currentList < 0 {
|
|
return nil
|
|
}
|
|
r := &doc.Rows[currentList]
|
|
mode := float64(1)
|
|
if !listOnly {
|
|
mode = r.Fields[16].Value
|
|
}
|
|
if mode == 2 {
|
|
if len(listTokens) != 1 {
|
|
return fmt.Errorf("外部列表引用需要一个列表 ID")
|
|
}
|
|
r.Items = append(r.Items, IndependentDropItem{Fields: listTokens})
|
|
return nil
|
|
}
|
|
if len(listTokens)%2 != 0 {
|
|
return fmt.Errorf("[list] 的物品 ID 与权重不成对")
|
|
}
|
|
for i := 0; i < len(listTokens); i += 2 {
|
|
r.Items = append(r.Items, IndependentDropItem{Fields: append([]SkillNumber(nil), listTokens[i:i+2]...)})
|
|
}
|
|
return nil
|
|
}
|
|
for _, e := range view.Elements {
|
|
if e.Kind == pvf.ScriptElementSection {
|
|
if e.Section == "independent drop" && len(e.SectionPath) == 1 && !listOnly {
|
|
if found {
|
|
return nil, fmt.Errorf("重复 [independent drop]")
|
|
}
|
|
found = true
|
|
end, err := closing(e.End, "[/independent drop]")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc.BodyEnd = end
|
|
}
|
|
if e.Section == "list" && ((listOnly && len(e.SectionPath) == 1) || (!listOnly && len(e.SectionPath) == 2 && e.SectionPath[0] == "independent drop")) {
|
|
if len(pending) > 0 {
|
|
return nil, fmt.Errorf("独立掉落记录不足 17 项")
|
|
}
|
|
if err := flushList(); err != nil {
|
|
return nil, err
|
|
}
|
|
if listOnly {
|
|
if found {
|
|
return nil, fmt.Errorf("重复 [list]")
|
|
}
|
|
found = true
|
|
doc.Rows = append(doc.Rows, IndependentDropRow{Fields: []SkillNumber{}, Items: []IndependentDropItem{}, ListStart: -1, ListEnd: -1})
|
|
}
|
|
if len(doc.Rows) == 0 {
|
|
return nil, fmt.Errorf("[list] 前没有独立掉落记录")
|
|
}
|
|
currentList = len(doc.Rows) - 1
|
|
listTokens = []SkillNumber{}
|
|
r := &doc.Rows[currentList]
|
|
if r.ListStart >= 0 {
|
|
return nil, fmt.Errorf("同一记录包含多个 [list]")
|
|
}
|
|
r.ListTagStart = e.Start
|
|
r.ListStart = e.End
|
|
end, err := closing(e.End, "[/list]")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.ListEnd = end
|
|
if listOnly {
|
|
doc.BodyEnd = end
|
|
}
|
|
} else if len(e.SectionPath) > 1 && e.SectionPath[0] == "independent drop" && len(doc.Rows) > 0 {
|
|
doc.Rows[len(doc.Rows)-1].HasExtensions = true
|
|
}
|
|
continue
|
|
}
|
|
if e.Kind != pvf.ScriptElementToken {
|
|
continue
|
|
}
|
|
isMain := !listOnly && len(e.SectionPath) == 1 && e.Section == "independent drop"
|
|
isList := e.Section == "list" && ((listOnly && len(e.SectionPath) == 1) || (!listOnly && len(e.SectionPath) == 2 && e.SectionPath[0] == "independent drop"))
|
|
isBalance := !listOnly && e.Section == "dungeon drop rate balance" && len(e.SectionPath) == 1
|
|
if !isMain && !isList && !isBalance {
|
|
continue
|
|
}
|
|
n, err := strconv.ParseFloat(e.Value, 64)
|
|
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) || (e.TokenType != 0 && e.TokenType != 2) {
|
|
return nil, fmt.Errorf("[%s] 包含非数值字段", e.Section)
|
|
}
|
|
field := SkillNumber{Value: n, Start: e.Start, End: e.End, TokenType: e.TokenType}
|
|
if isBalance {
|
|
doc.Balance = append(doc.Balance, field)
|
|
continue
|
|
}
|
|
if isList {
|
|
if currentList < 0 {
|
|
return nil, fmt.Errorf("列表结构无效")
|
|
}
|
|
listTokens = append(listTokens, field)
|
|
continue
|
|
}
|
|
if currentList >= 0 {
|
|
if err := flushList(); err != nil {
|
|
return nil, err
|
|
}
|
|
currentList = -1
|
|
listTokens = nil
|
|
}
|
|
pending = append(pending, field)
|
|
if len(pending) == 17 {
|
|
doc.Rows = append(doc.Rows, IndependentDropRow{Fields: pending, Items: []IndependentDropItem{}, Start: pending[0].Start, ListStart: -1, ListEnd: -1})
|
|
pending = []SkillNumber{}
|
|
}
|
|
}
|
|
if err := flushList(); err != nil {
|
|
return nil, err
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("未找到可编辑的独立掉落 section")
|
|
}
|
|
if len(pending) > 0 {
|
|
return nil, fmt.Errorf("独立掉落记录不足 17 项(余 %d 项)", len(pending))
|
|
}
|
|
for i := range doc.Rows {
|
|
r := &doc.Rows[i]
|
|
r.End = doc.BodyEnd
|
|
if i+1 < len(doc.Rows) {
|
|
r.End = doc.Rows[i+1].Start
|
|
}
|
|
if listOnly {
|
|
continue
|
|
}
|
|
mode := r.Fields[16].Value
|
|
if mode == 0 && r.ListStart >= 0 {
|
|
return nil, fmt.Errorf("第 %d 条直接掉落记录包含列表", i+1)
|
|
}
|
|
if (mode == 1 || mode == 2) && r.ListStart < 0 {
|
|
return nil, fmt.Errorf("第 %d 条记录缺少 [list]", i+1)
|
|
}
|
|
if mode != 0 && mode != 1 && mode != 2 {
|
|
doc.Issues = append(doc.Issues, fmt.Sprintf("第 %d 条使用扩展模式 %g;保留原始模式与扩展条件,仅开放已确认字段", i+1, mode))
|
|
}
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
func (s *FileGUIService) ReadIndependentDrop(fileIndex int32, text string) (*IndependentDropDocument, error) {
|
|
s.c.mu.Lock()
|
|
defer s.c.mu.Unlock()
|
|
a := s.c.archive
|
|
if a == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
if err := validateAnnotationIndex(a, fileIndex); err != nil {
|
|
return nil, err
|
|
}
|
|
p := strings.ToLower(strings.ReplaceAll(a.Path(fileIndex), "\\", "/"))
|
|
listOnly := p != independentDropPath
|
|
if listOnly && !strings.HasPrefix(p, "etc/independentdrop/") {
|
|
return nil, fmt.Errorf("独立掉落 GUI 不支持此路径")
|
|
}
|
|
if len(text) > int(s.c.editableByteLimit()) {
|
|
return nil, fmt.Errorf("独立掉落文本过大")
|
|
}
|
|
doc, err := parseIndependentDrop(text, listOnly)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc.Version = a.ClientVersion()
|
|
if i, ok := a.Find("etc/independentdrop.lst"); ok {
|
|
lst, err := a.Text(i)
|
|
if err != nil {
|
|
doc.Issues = append(doc.Issues, "外部列表索引读取失败")
|
|
} else if pairs, err := pvf.ParseListText(lst); err != nil {
|
|
doc.Issues = append(doc.Issues, "外部列表索引解析失败")
|
|
} else {
|
|
for _, pair := range pairs {
|
|
p := path.Join("etc", strings.ReplaceAll(pair.Path, "\\", "/"))
|
|
idx, exists := a.Find(p)
|
|
if !exists {
|
|
idx = -1
|
|
}
|
|
doc.Lists = append(doc.Lists, IndependentDropList{ID: pair.ID, Path: p, FileIndex: idx})
|
|
}
|
|
}
|
|
}
|
|
// Resolve APCs through their list because not every client's semantic index
|
|
// registers an APC relation. Cache IDs and names once per document.
|
|
apcs := map[string]int32{}
|
|
if index, ok := a.FindList("aicharacter/aicharacter.lst"); ok {
|
|
if pairs, err := a.ListPairs(index); err == nil {
|
|
for _, pair := range pairs {
|
|
if _, index, ok := findListTargetInArchive(a, a.Path(index), pair.Path); ok {
|
|
apcs[pair.ID] = index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
names := map[string]string{}
|
|
resolve := func(relation string, value float64) string {
|
|
id := strconv.FormatInt(int64(value), 10)
|
|
key := relation + ":" + id
|
|
if name, ok := names[key]; ok {
|
|
return name
|
|
}
|
|
name := ""
|
|
if s.c.annotationEngine != nil {
|
|
if ref, ok := s.c.resolveAnnotationReferenceLocked(relation, id); ok {
|
|
name = resolvePreviewText(a, ref.Name)
|
|
}
|
|
}
|
|
if name == "" && relation == "APC" {
|
|
if index, ok := apcs[id]; ok {
|
|
if metadata, err := a.ScriptMetadata(index); err == nil && metadata.HasName {
|
|
name = resolvePreviewText(a, markedName(metadata))
|
|
}
|
|
}
|
|
}
|
|
names[key] = name
|
|
return name
|
|
}
|
|
for i := range doc.Rows {
|
|
r := &doc.Rows[i]
|
|
if !listOnly {
|
|
relation := "怪物"
|
|
if r.Fields[0].Value == 1 {
|
|
relation = "APC"
|
|
}
|
|
r.MonsterName = resolve(relation, r.Fields[1].Value)
|
|
if r.Fields[16].Value == 0 {
|
|
r.ItemName = resolve("物品", r.Fields[2].Value)
|
|
}
|
|
}
|
|
for j := range r.Items {
|
|
if len(r.Items[j].Fields) == 2 {
|
|
r.Items[j].Name = resolve("物品", r.Items[j].Fields[0].Value)
|
|
}
|
|
}
|
|
}
|
|
return doc, nil
|
|
}
|