服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
182 lines
5.1 KiB
Go
182 lines
5.1 KiB
Go
// statpotioncensus 只读:按 [stackable type] 关键字普查可叠加道具,并打印
|
||
// 决定它们"怎么用、用完要不要服务端记状态"的那几个字段。
|
||
//
|
||
// 现场 2026-10-03(服主报):史诗Buff药剂(例如 490002458)使用后没反应。
|
||
// 服务端日志给出的拒绝原因是 "requires PVF-proven [waste] provenance",
|
||
// 而它的 [stackable type] 是 [expert town potion]。要判断"放宽判据"的影响面,
|
||
// 必须先把同一族的道具全部列出来。
|
||
//
|
||
// 跑法:
|
||
//
|
||
// go run ./cmd/scratch/statpotioncensus -pvf "...\Script.pvf" -type "expert town potion"
|
||
package main
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"path"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||
)
|
||
|
||
type entry struct {
|
||
id int64
|
||
doc string
|
||
path string
|
||
lines map[string]string
|
||
}
|
||
|
||
func main() {
|
||
pvfPath := flag.String("pvf", "", "Script.pvf (required)")
|
||
typeFilter := flag.String("type", "expert town potion", "substring of [stackable type] to select")
|
||
max := flag.Int("max", 200, "max entries to print")
|
||
flag.Parse()
|
||
if *pvfPath == "" {
|
||
fmt.Fprintln(os.Stderr, "FAILED: -pvf is required")
|
||
os.Exit(2)
|
||
}
|
||
archive, err := platformpvf.Open(*pvfPath)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "FAILED: open pvf:", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
entries := collect(archive, *typeFilter)
|
||
fmt.Printf("匹配 [stackable type] 含 %q 的道具:%d 件\n\n", *typeFilter, len(entries))
|
||
|
||
// 按 (stackable type, action type, 有无 stat change duration) 分组统计。
|
||
groups := map[string]int{}
|
||
for _, item := range entries {
|
||
groups[groupKey(item)]++
|
||
}
|
||
keys := make([]string, 0, len(groups))
|
||
for key := range groups {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
fmt.Printf("按 (stackable type / action type / cooltime maintenance / stat change duration) 分组:\n")
|
||
for _, key := range keys {
|
||
fmt.Printf(" %-110s x%d\n", key, groups[key])
|
||
}
|
||
fmt.Println()
|
||
|
||
limit := *max
|
||
if len(entries) < limit {
|
||
limit = len(entries)
|
||
}
|
||
for _, item := range entries[:limit] {
|
||
fmt.Printf("#%d %s\n", item.id, item.path)
|
||
for _, key := range []string{
|
||
"name", "stackable type", "action type", "action usable place",
|
||
"stat change duration", "effect maintenance", "cool time",
|
||
"cooltime maintenance", "total usable count", "expiration date",
|
||
"usable event", "minimum level", "attach type",
|
||
} {
|
||
if value, ok := item.lines[key]; ok {
|
||
fmt.Printf(" %-22s %s\n", "["+key+"]", value)
|
||
}
|
||
}
|
||
fmt.Println()
|
||
}
|
||
}
|
||
|
||
// collect 扫所有 stackable .lst,读每个文档,取 [stackable type] 命中的那些。
|
||
func collect(archive *platformpvf.Archive, typeFilter string) []entry {
|
||
want := strings.ToLower(strings.TrimSpace(typeFilter))
|
||
out := make([]entry, 0)
|
||
seen := map[int64]bool{}
|
||
for _, file := range archive.Files() {
|
||
full := file.ArchivePath
|
||
if strings.TrimSpace(full) == "" {
|
||
full = file.Path
|
||
}
|
||
lower := strings.ToLower(full)
|
||
if !strings.HasSuffix(lower, ".lst") || !strings.Contains(lower, "stackable") {
|
||
continue
|
||
}
|
||
text, err := archive.ReadText(full)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
fields := strings.Fields(text)
|
||
for i := 0; i+1 < len(fields); i++ {
|
||
id, parseErr := strconv.ParseInt(fields[i], 10, 64)
|
||
if parseErr != nil || seen[id] {
|
||
continue
|
||
}
|
||
rel := strings.Trim(fields[i+1], "`")
|
||
docPath := path.Clean(path.Join(path.Dir(full), strings.ReplaceAll(rel, "\\", "/")))
|
||
body, readErr := archive.ReadText(docPath)
|
||
if readErr != nil {
|
||
continue
|
||
}
|
||
lines := indexSections(body)
|
||
stackableType := strings.ToLower(lines["stackable type"])
|
||
if !strings.Contains(stackableType, want) {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
out = append(out, entry{id: id, doc: body, path: docPath, lines: lines})
|
||
}
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
|
||
return out
|
||
}
|
||
|
||
var sectionStart = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`)
|
||
|
||
// indexSections 把文档按 [字段] 切成 key -> 值文本(值取到下一个字段之前)。
|
||
func indexSections(body string) map[string]string {
|
||
out := map[string]string{}
|
||
lines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n")
|
||
current := ""
|
||
var buffer []string
|
||
flush := func() {
|
||
if current == "" {
|
||
return
|
||
}
|
||
out[current] = strings.Join(buffer, " ")
|
||
}
|
||
for _, line := range lines {
|
||
trimmed := strings.TrimSpace(line)
|
||
if match := sectionStart.FindStringSubmatch(trimmed); match != nil {
|
||
flush()
|
||
current = strings.ToLower(strings.TrimSpace(match[1]))
|
||
buffer = buffer[:0]
|
||
continue
|
||
}
|
||
if current == "" {
|
||
continue
|
||
}
|
||
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
|
||
continue
|
||
}
|
||
buffer = append(buffer, trimmed)
|
||
}
|
||
flush()
|
||
return out
|
||
}
|
||
|
||
func groupKey(item entry) string {
|
||
parts := []string{
|
||
"type=" + strings.TrimSpace(item.lines["stackable type"]),
|
||
"action=" + orDash(item.lines["action type"]),
|
||
"coolmaint=" + orDash(item.lines["cooltime maintenance"]),
|
||
"statdur=" + orDash(item.lines["stat change duration"]),
|
||
"effectmaint=" + orDash(item.lines["effect maintenance"]),
|
||
}
|
||
return strings.Join(parts, " | ")
|
||
}
|
||
|
||
func orDash(value string) string {
|
||
if strings.TrimSpace(value) == "" {
|
||
return "-"
|
||
}
|
||
return strings.TrimSpace(value)
|
||
}
|