服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
293 lines
9.1 KiB
Go
293 lines
9.1 KiB
Go
// pvfsurvey 是一次性 PVF 数据勘察程序(不进入生产路径)。
|
|
// 加载真实 Script.pvf,回答关于"事件怪物位类遭遇"的五个问题:
|
|
//
|
|
// Q1 含 EventMonsterPositions 的 dungeon 地图总量与分布
|
|
// Q2 SpecialPassiveObject 生成 monster 的 (ObjectID, monsterCode) 组合
|
|
// Q3 被动对象定义文件是否声明行为类型/事件脚本
|
|
// Q4 [destroy object] clear 条件与特殊/普通被动对象的关联
|
|
// Q5 电梯型结构(≥1 EventMonsterPositions + special 生成 monster)候选清单
|
|
//
|
|
// 用法:
|
|
//
|
|
// go run ./cmd/scratch/pvfsurvey -phase worldmap # Q1/Q2/Q4/Q5
|
|
// go run ./cmd/scratch/pvfsurvey -phase objects # Q3(读取全部被动对象定义)
|
|
// go run ./cmd/scratch/pvfsurvey -phase all # 全部
|
|
//
|
|
// 结果写入 cmd/scratch/pvfsurvey/out/。
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
const (
|
|
defaultPVFPath = `D:/DNF/runtime/data/dnf/Script.pvf`
|
|
outDir = `D:/DNF/go-server/cmd/scratch/pvfsurvey/out`
|
|
)
|
|
|
|
var lstEntryPattern = regexp.MustCompile("([0-9]+)\\s+`([^`]+)`")
|
|
|
|
func normSymbol(value string) string {
|
|
return strings.ToLower(strings.Trim(value, "[] \t\r\n"))
|
|
}
|
|
|
|
func normPath(value string) string {
|
|
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"))
|
|
}
|
|
|
|
// ------------------------- 数据结构 -------------------------
|
|
|
|
type mapUsage struct {
|
|
DungeonID int64 `json:"dungeon_id"`
|
|
MazeIndex int `json:"maze_index"`
|
|
X int64 `json:"x,omitempty"`
|
|
Y int64 `json:"y,omitempty"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
func (u mapUsage) String() string {
|
|
if u.Source == "ownership" {
|
|
return fmt.Sprintf("dungeon=%d(ownership)", u.DungeonID)
|
|
}
|
|
return fmt.Sprintf("dungeon=%d maze=%d (%d,%d) %s", u.DungeonID, u.MazeIndex, u.X, u.Y, u.Source)
|
|
}
|
|
|
|
type q1Map struct {
|
|
MapID int64 `json:"map_id"`
|
|
Path string `json:"path"`
|
|
Name string `json:"name,omitempty"`
|
|
Positions int `json:"positions"`
|
|
Dungeon []string `json:"dungeons,omitempty"`
|
|
}
|
|
|
|
type comboKey struct {
|
|
ObjectID int64
|
|
Code int64
|
|
}
|
|
|
|
type comboInfo struct {
|
|
ObjectID int64 `json:"object_id"`
|
|
Code int64 `json:"monster_code"`
|
|
Count int `json:"count"`
|
|
Maps map[int64]bool `json:"-"`
|
|
Dungeons map[int64]bool `json:"-"`
|
|
ObjRef string `json:"obj_ref,omitempty"`
|
|
MapList []int64 `json:"maps"`
|
|
DgList []int64 `json:"dungeons"`
|
|
Levels map[int64]bool `json:"-"`
|
|
LevelVal []int64 `json:"levels,omitempty"`
|
|
RawKinds map[string]bool `json:"-"`
|
|
}
|
|
|
|
type q4Condition struct {
|
|
DungeonID int64 `json:"dungeon_id"`
|
|
DungeonName string `json:"dungeon_name,omitempty"`
|
|
MazeIndex int `json:"maze_index"`
|
|
TargetID int64 `json:"target_id"`
|
|
Count int64 `json:"count"`
|
|
ObjRef string `json:"obj_ref,omitempty"`
|
|
Placements []string `json:"placements"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type q5Candidate struct {
|
|
MapID int64 `json:"map_id"`
|
|
Path string `json:"path"`
|
|
Name string `json:"name,omitempty"`
|
|
Positions int `json:"positions"`
|
|
PassiveIDs []int64 `json:"passive_object_ids"`
|
|
SpecialObjects []string `json:"special_objects"`
|
|
ObjectClasses string `json:"object_classes"`
|
|
Dungeons []string `json:"dungeons,omitempty"`
|
|
IsDungeon53 bool `json:"is_known_dungeon_53"`
|
|
}
|
|
|
|
// ------------------------- 主流程 -------------------------
|
|
|
|
func main() {
|
|
pvfPath := flag.String("pvf", defaultPVFPath, "Script.pvf 路径")
|
|
phase := flag.String("phase", "all", "worldmap|objects|all")
|
|
objLimit := flag.Int("obj-limit", 0, "Q3 最多读取多少个 obj 定义(0=不限)")
|
|
flag.Parse()
|
|
|
|
started := time.Now()
|
|
archive, err := platformpvf.Open(*pvfPath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
snap := archive.Snapshot()
|
|
fmt.Printf("[survey] pvf opened: format=%s files=%d size=%d checksum=%s (%.1fs)\n",
|
|
snap.Format, snap.FileCount, snap.Size, snap.Checksum[:16], time.Since(started).Seconds())
|
|
|
|
fmt.Printf("[survey] loading worldmap table ...\n")
|
|
loadStart := time.Now()
|
|
table, err := worldmap.LoadSource(context.Background(), archive, worldmap.Options{})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
tableSnap := table.Snapshot()
|
|
fmt.Printf("[survey] worldmap loaded: %+v (%.1fs)\n", tableSnap, time.Since(loadStart).Seconds())
|
|
|
|
// 基础索引
|
|
dungeonNames := map[int64]string{}
|
|
for _, d := range table.Dungeons() {
|
|
dungeonNames[d.ID] = d.Metadata.Name
|
|
}
|
|
mapUsages := buildMapUsages(table)
|
|
|
|
// passiveobject.lst 索引(Q2/Q3/Q4/Q5 共用)
|
|
objRefs := loadPassiveObjectList(archive)
|
|
fmt.Printf("[survey] passiveobject.lst entries=%d\n", len(objRefs))
|
|
|
|
// 地图实际摆放对象集合与行为画像(Q3/Q5 共用)
|
|
usedOnMaps, usedOnDungeonMaps := collectUsedObjects(table, mapUsages)
|
|
profileStart := time.Now()
|
|
profiles := buildObjProfiles(archive, objRefs, usedOnMaps)
|
|
fmt.Printf("[survey] obj profiles built: used_on_maps=%d used_on_dungeon_maps=%d (%.1fs)\n",
|
|
len(usedOnMaps), len(usedOnDungeonMaps), time.Since(profileStart).Seconds())
|
|
|
|
report := map[string]any{
|
|
"pvf": map[string]any{
|
|
"path": *pvfPath, "format": string(snap.Format), "file_count": snap.FileCount,
|
|
"size": snap.Size, "checksum": snap.Checksum,
|
|
},
|
|
"worldmap_snapshot": tableSnap,
|
|
"generated_at": time.Now().Format(time.RFC3339),
|
|
}
|
|
|
|
if *phase == "worldmap" || *phase == "all" {
|
|
report["q1"] = runQ1(table, mapUsages, dungeonNames)
|
|
report["q2"] = runQ2(table, mapUsages, dungeonNames, objRefs)
|
|
report["q4"] = runQ4(table, mapUsages, dungeonNames, objRefs)
|
|
report["q5"] = runQ5(table, mapUsages, dungeonNames, objRefs, profiles)
|
|
}
|
|
if *phase == "objects" || *phase == "all" {
|
|
report["q3"] = runQ3(archive, table, mapUsages, objRefs, profiles, usedOnMaps, usedOnDungeonMaps, *objLimit)
|
|
}
|
|
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
panic(err)
|
|
}
|
|
data, err := json.MarshalIndent(report, "", " ")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
reportPath := path.Join(outDir, "survey_report.json")
|
|
if *phase != "all" {
|
|
reportPath = path.Join(outDir, fmt.Sprintf("survey_report_%s.json", *phase))
|
|
}
|
|
if err := os.WriteFile(reportPath, data, 0o644); err != nil {
|
|
panic(err)
|
|
}
|
|
fmt.Printf("[survey] report written: %s (%.1fs total)\n", reportPath, time.Since(started).Seconds())
|
|
}
|
|
|
|
func collectUsedObjects(table *worldmap.Table, usages map[int64][]mapUsage) (map[int64]bool, map[int64]bool) {
|
|
usedAll := map[int64]bool{}
|
|
usedDungeon := map[int64]bool{}
|
|
for _, m := range table.Maps() {
|
|
placed := make([]int64, 0, len(m.PassiveObjects)+len(m.SpecialPassiveObjects))
|
|
for _, o := range m.PassiveObjects {
|
|
placed = append(placed, o.ObjectID)
|
|
}
|
|
for _, o := range m.SpecialPassiveObjects {
|
|
placed = append(placed, o.ObjectID)
|
|
}
|
|
for _, id := range placed {
|
|
usedAll[id] = true
|
|
if len(usages[m.ID]) > 0 {
|
|
usedDungeon[id] = true
|
|
}
|
|
}
|
|
}
|
|
return usedAll, usedDungeon
|
|
}
|
|
|
|
func buildMapUsages(table *worldmap.Table) map[int64][]mapUsage {
|
|
usages := map[int64][]mapUsage{}
|
|
add := func(mapID, dungeonID int64, mazeIndex int, x, y int64, source string) {
|
|
key := mapUsage{DungeonID: dungeonID, MazeIndex: mazeIndex, X: x, Y: y, Source: source}
|
|
for _, existing := range usages[mapID] {
|
|
if existing == key {
|
|
return
|
|
}
|
|
}
|
|
usages[mapID] = append(usages[mapID], key)
|
|
}
|
|
for _, d := range table.Dungeons() {
|
|
for _, maze := range d.Mazes {
|
|
for _, spec := range maze.MapSpecifications {
|
|
for _, id := range spec.MapIDs {
|
|
add(id, d.ID, maze.Index, spec.Coordinate.X, spec.Coordinate.Y, "spec:"+spec.Type)
|
|
}
|
|
}
|
|
for _, spec := range maze.BossSpecifications {
|
|
for _, id := range spec.MapIDs {
|
|
add(id, d.ID, maze.Index, spec.Coordinate.X, spec.Coordinate.Y, "boss-spec:"+spec.Type)
|
|
}
|
|
}
|
|
for _, spec := range maze.LayeredSpecifications {
|
|
for _, id := range spec.MapIDs {
|
|
add(id, d.ID, maze.Index, spec.Coordinate.X, spec.Coordinate.Y, "layered-spec:"+spec.Type)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, m := range table.Maps() {
|
|
for _, id := range m.DungeonIDs {
|
|
add(m.ID, id, -1, 0, 0, "ownership")
|
|
}
|
|
}
|
|
return usages
|
|
}
|
|
|
|
func loadPassiveObjectList(archive *platformpvf.Archive) map[int64]string {
|
|
text, err := archive.ReadText("passiveobject/passiveobject.lst")
|
|
if err != nil {
|
|
fmt.Printf("[survey] WARN: passiveobject.lst unreadable: %v\n", err)
|
|
return map[int64]string{}
|
|
}
|
|
out := map[int64]string{}
|
|
for _, m := range lstEntryPattern.FindAllStringSubmatch(text, -1) {
|
|
var id int64
|
|
fmt.Sscan(m[1], &id)
|
|
if _, exists := out[id]; !exists {
|
|
out[id] = strings.ReplaceAll(m[2], "\\", "/")
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func usageStrings(usages []mapUsage) []string {
|
|
if len(usages) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(usages))
|
|
for _, u := range usages {
|
|
out = append(out, u.String())
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func sortedIDs(set map[int64]bool) []int64 {
|
|
out := make([]int64, 0, len(set))
|
|
for id := range set {
|
|
out = append(out, id)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out
|
|
}
|