DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
// Command pvfrenamecards lists the items the runtime PVF marks as character
// rename cards.
//
// Why it exists: the server used to recognize rename cards by two hard-coded PVF
// paths, so any other card the operator obtained was rejected with "card is
// invalid". The PVF itself carries the contract in the item's action type:
//
// [action type]
// [character name change]
// [/action type]
//
// This tool reads Script.pvf through the same parser the server uses, so its
// output is exactly the set the server can act on.
//
// Usage:
//
// go run ./cmd/devtools/pvfrenamecards -pvf ..\runtime\data\dnf\Script.pvf
// go run ./cmd/devtools/pvfrenamecards -pvf <pvf> -id 2660698 -dump <dir>
// go run ./cmd/devtools/pvfrenamecards -pvf <pvf> -list equipment/equipment.lst
package main
import (
"flag"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func main() {
pvfPath := flag.String("pvf", "", "path to Script.pvf (required)")
listPath := flag.String("list", "stackable/stackable.lst", "item list inside the archive")
itemID := flag.Int64("id", 0, "dump one item id's document instead of scanning the list")
dumpDir := flag.String("dump", "", "write every matching document into this directory")
showAction := flag.Bool("action-types", false, "print each matching item's action type sections")
flag.Parse()
if strings.TrimSpace(*pvfPath) == "" {
fmt.Fprintln(os.Stderr, "use -pvf <path to Script.pvf>")
os.Exit(2)
}
bundle, err := platformpvf.Load(platformpvf.Options{Path: *pvfPath})
if err != nil {
fmt.Fprintf(os.Stderr, "open pvf: %v\n", err)
os.Exit(1)
}
archive, err := platformpvf.OpenArchive(bundle)
if err != nil {
fmt.Fprintf(os.Stderr, "parse pvf: %v\n", err)
os.Exit(1)
}
// The archive is a dnfpvf.Source: same ReadText/Parse path the server takes.
var source dnfpvf.Source = archive
if *itemID > 0 {
dumpOne(source, archive, *itemID, *listPath, *dumpDir)
return
}
started := time.Now()
entries, err := readItemList(source, *listPath)
if err != nil {
fmt.Fprintf(os.Stderr, "read list %s: %v\n", *listPath, err)
os.Exit(1)
}
fmt.Printf("list %s: %d entries\n", *listPath, len(entries))
type hit struct {
id int64
pvfPath string
name string
actions []string
autoHint string
}
var hits []hit
missing := 0
unreadable := 0
for _, entry := range entries {
docPath, text, err := readItemDocument(source, *listPath, entry.Path)
if err != nil {
unreadable++
continue
}
if !strings.Contains(text, "[character name change]") {
continue
}
document, err := dnfpvf.Parse(docPath, text)
if err != nil {
missing++
continue
}
name, _ := document.Text("name")
hits = append(hits, hit{
id: entry.ID,
pvfPath: docPath,
name: name,
actions: actionTypeSections(document),
autoHint: renameAutoUseHint(docPath),
})
if *dumpDir != "" {
target := filepath.Join(*dumpDir, fmt.Sprintf("%d_%s", entry.ID, filepath.Base(docPath)))
if err := os.WriteFile(target, []byte(text), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "write %s: %v\n", target, err)
}
}
}
sort.Slice(hits, func(i, j int) bool { return hits[i].id < hits[j].id })
fmt.Printf("scanned %d entries in %s (unreadable=%d parse-failed=%d)\n\n",
len(entries), time.Since(started).Round(time.Millisecond), unreadable, missing)
fmt.Printf("%-10s %-8s %-46s %s\n", "item_id", "auto_use", "pvf_path", "name")
for _, match := range hits {
fmt.Printf("%-10d %-8s %-46s %s\n", match.id, match.autoHint, match.pvfPath, match.name)
if *showAction {
fmt.Printf("%-10s %-8s action types: %v\n", "", "", match.actions)
}
}
fmt.Printf("\n%d rename card(s) recognized by action type [character name change]\n", len(hits))
}
// dumpOne prints a single item's document so its action-type block can be read
// verbatim.
func dumpOne(source dnfpvf.Source, archive *platformpvf.Archive, itemID int64, listPath, dumpDir string) {
entries, err := readItemList(source, listPath)
if err != nil {
fmt.Fprintf(os.Stderr, "read list %s: %v\n", listPath, err)
os.Exit(1)
}
for _, entry := range entries {
if entry.ID != itemID {
continue
}
docPath, text, err := readItemDocument(source, listPath, entry.Path)
if err != nil {
fmt.Fprintf(os.Stderr, "read item %d path=%s: %v\n", itemID, entry.Path, err)
os.Exit(1)
}
fmt.Printf("item_id=%d\npvf_path=%s\narchive_path=%s\n\n%s\n", itemID, docPath, entry.Path, text)
if dumpDir != "" {
target := filepath.Join(dumpDir, fmt.Sprintf("%d_%s", itemID, filepath.Base(docPath)))
if err := os.WriteFile(target, []byte(text), 0o644); err == nil {
fmt.Printf("\n(dumped to %s)\n", target)
}
}
return
}
fmt.Fprintf(os.Stderr, "item %d is not in %s\n", itemID, listPath)
os.Exit(3)
}
type itemListEntry struct {
ID int64
Path string
}
func readItemList(source dnfpvf.Source, listPath string) ([]itemListEntry, error) {
text, err := source.ReadText(listPath)
if err != nil {
return nil, err
}
document, err := dnfpvf.Parse(listPath, text)
if err != nil {
return nil, err
}
parsed := dnfpvf.ParseList(document)
out := make([]itemListEntry, 0, len(parsed))
seen := make(map[int64]struct{}, len(parsed))
for _, entry := range parsed {
if entry.ID <= 0 {
continue
}
if _, exists := seen[entry.ID]; exists {
continue
}
seen[entry.ID] = struct{}{}
out = append(out, itemListEntry{ID: entry.ID, Path: entry.Path})
}
return out, nil
}
// readItemDocument resolves one list entry to an archive document. The list
// stores paths relative to the list's own directory, so both the joined and the
// bare candidate are tried, exactly like the server's item resolver.
func readItemDocument(source dnfpvf.Source, listPath, listedPath string) (string, string, error) {
clean := strings.TrimSpace(strings.ReplaceAll(listedPath, "\\", "/"))
clean = strings.TrimPrefix(strings.TrimPrefix(clean, "./"), "/")
root := path.Dir(strings.TrimSpace(strings.ReplaceAll(listPath, "\\", "/")))
candidates := []string{path.Join(root, clean), clean}
seen := map[string]struct{}{}
var lastErr error
for _, candidate := range candidates {
if _, exists := seen[candidate]; exists {
continue
}
seen[candidate] = struct{}{}
text, err := source.ReadText(candidate)
if err == nil {
return candidate, text, nil
}
lastErr = err
}
return "", "", lastErr
}
// actionTypeSections lists the values declared inside [action type] blocks.
//
// The block is read as a section: the marker itself is one section and the
// payload is a plain string token, e.g.
//
// [action type]
// `[character name change]`
// [/action type]
//
// so document.Texts("action type") yields `[character name change]`.
func actionTypeSections(document *dnfpvf.Document) []string {
if document == nil {
return nil
}
var out []string
for _, value := range document.Texts("action type") {
if clean := strings.TrimSpace(value); clean != "" {
out = append(out, clean)
}
}
return out
}
// renameAutoUseHint reports the legacy path allowlist so the operator can see
// which of the recognized cards the Cera shop auto-uses on purchase.
func renameAutoUseHint(docPath string) string {
switch strings.ToLower(strings.TrimSpace(docPath)) {
case "stackable/cash/chn_character_rename.stk":
return "yes"
case "stackable/cash/charcter_rename_card.stk":
return "no"
default:
return "no"
}
}