服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
276 lines
9.3 KiB
Go
276 lines
9.3 KiB
Go
// Command pvfjoustentry prepares a candidate Script.pvf that places the
|
|
// existing 2017 Chinese joust NPC at a map-owned legacy event-NPC slot. The
|
|
// server event catalog owns all-day activation; the NPC's native event gates
|
|
// must stay present so the current client enables its joust role action. It
|
|
// never edits the active PVF in place.
|
|
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
const (
|
|
sourceNPCPath = "npc/chn_masang_2017.npc"
|
|
targetNPCPath = "npc/chn_event_lions10th_2.npc"
|
|
sourceShopPath = "itemshop/chn_masang_shop_2017.shp"
|
|
targetShopPath = "itemshop/chn_10th_lions_2.shp"
|
|
itemShopListPath = "itemshop/itemshop.lst"
|
|
mapPath = "map/cataclysm/town/hendonmyre/new_hendon_main.map"
|
|
)
|
|
|
|
type report struct {
|
|
InputPath string `json:"input_path"`
|
|
OutputPath string `json:"output_path"`
|
|
SourceNPC string `json:"source_npc"`
|
|
TargetNPC string `json:"target_npc"`
|
|
TargetShop string `json:"target_shop"`
|
|
TownMap string `json:"town_map"`
|
|
TownNPCID int `json:"town_npc_id"`
|
|
TownX int `json:"town_x"`
|
|
TownY int `json:"town_y"`
|
|
PreservedSections []string `json:"preserved_sections"`
|
|
InputSize int64 `json:"input_size"`
|
|
OutputSize int `json:"output_size"`
|
|
OutputSHA256 string `json:"output_sha256"`
|
|
}
|
|
|
|
func main() {
|
|
pvfPath := flag.String("pvf", `D:/DNF/runtime/data/dnf/Script.pvf`, "source Script.pvf")
|
|
outPath := flag.String("out", `D:/DNF/runtime/tmp/Script-joust-entry-candidate.pvf`, "candidate output path")
|
|
flag.Parse()
|
|
|
|
archive, err := platformpvf.Open(*pvfPath)
|
|
must(err)
|
|
sourceText, err := archive.ReadText(sourceNPCPath)
|
|
must(err)
|
|
sourceShopText, err := archive.ReadText(sourceShopPath)
|
|
must(err)
|
|
itemShopListText, err := archive.ReadText(itemShopListPath)
|
|
must(err)
|
|
mapText, err := archive.ReadText(mapPath)
|
|
must(err)
|
|
|
|
requireContains(sourceNPCPath, sourceText,
|
|
"`[joust event]` 0",
|
|
"`[item shop]` 101262",
|
|
"[open visible event]\n2365",
|
|
"[event open]\n2365 -1 -1",
|
|
)
|
|
requireContains(sourceShopPath, sourceShopText,
|
|
"490005585", "490005586", "490005588", "490005589",
|
|
"490005590", "490005591", "490005592",
|
|
)
|
|
requireContains(itemShopListPath, itemShopListText, "400000022", "chn_10th_lions_2.shp")
|
|
if !strings.Contains(mapText, "20449 `[left]` 1478 354 0") &&
|
|
!strings.Contains(mapText, "20449 `[left]` 2183 156 0") {
|
|
panic(fmt.Errorf("%s has neither the original nor midpoint joust NPC tuple", mapPath))
|
|
}
|
|
|
|
sourceRaw, err := archive.ReadRaw(sourceNPCPath)
|
|
must(err)
|
|
placedNPCRaw, err := replaceSectionIntValue(archive, sourceRaw, "item shop", 101262, 400000022)
|
|
must(err)
|
|
sourceShopRaw, err := archive.ReadRaw(sourceShopPath)
|
|
must(err)
|
|
placedShopRaw, err := replaceSectionIntValue(archive, sourceShopRaw, "npc", 20325, 20449)
|
|
must(err)
|
|
mapRaw, err := archive.ReadRaw(mapPath)
|
|
must(err)
|
|
placedMapRaw, err := ensureTownNPCPlacement(archive, mapRaw, 20449, 1478, 354, 2183, 156)
|
|
must(err)
|
|
|
|
patched, err := archive.RepackRaw([]platformpvf.RawReplacement{
|
|
{Path: targetNPCPath, Data: placedNPCRaw},
|
|
{Path: targetShopPath, Data: placedShopRaw},
|
|
{Path: mapPath, Data: placedMapRaw},
|
|
})
|
|
must(err)
|
|
verified, err := platformpvf.OpenBytes(patched)
|
|
must(err)
|
|
verifiedText, err := verified.ReadText(targetNPCPath)
|
|
must(err)
|
|
requireContains(targetNPCPath, verifiedText,
|
|
"`[joust event]` 0",
|
|
"`[item shop]` 400000022",
|
|
"[open visible event]\n2365",
|
|
"[event open]\n2365 -1 -1",
|
|
)
|
|
for _, forbidden := range []string{"101262"} {
|
|
if strings.Contains(verifiedText, forbidden) {
|
|
panic(fmt.Errorf("verified target %s still contains %q", targetNPCPath, forbidden))
|
|
}
|
|
}
|
|
verifiedSource, err := verified.ReadText(sourceNPCPath)
|
|
must(err)
|
|
if verifiedSource != sourceText {
|
|
panic(fmt.Errorf("source NPC %s changed", sourceNPCPath))
|
|
}
|
|
verifiedShop, err := verified.ReadText(targetShopPath)
|
|
must(err)
|
|
requireContains(targetShopPath, verifiedShop,
|
|
"490005585", "490005586", "490005588", "490005589",
|
|
"490005590", "490005591", "490005592",
|
|
)
|
|
must(requireSectionIntValue(verified, targetShopPath, "npc", 20449))
|
|
verifiedItemShopList, err := verified.ReadText(itemShopListPath)
|
|
must(err)
|
|
if verifiedItemShopList != itemShopListText {
|
|
panic(fmt.Errorf("indexed item-shop list %s changed", itemShopListPath))
|
|
}
|
|
verifiedMap, err := verified.ReadText(mapPath)
|
|
must(err)
|
|
requireContains(mapPath, verifiedMap,
|
|
"159 `[left]` 1896 156 0",
|
|
"20449 `[left]` 2183 156 0",
|
|
"45 `[right]` 2470 156 0",
|
|
)
|
|
if strings.Contains(verifiedMap, "20449 `[left]` 1478 354 0") {
|
|
panic(fmt.Errorf("verified town map retained the old joust NPC placement"))
|
|
}
|
|
|
|
must(os.MkdirAll(filepath.Dir(*outPath), 0o755))
|
|
must(os.WriteFile(*outPath, patched, 0o644))
|
|
inputInfo, err := os.Stat(*pvfPath)
|
|
must(err)
|
|
sum := sha256.Sum256(patched)
|
|
result := report{
|
|
InputPath: *pvfPath,
|
|
OutputPath: *outPath,
|
|
SourceNPC: sourceNPCPath,
|
|
TargetNPC: targetNPCPath,
|
|
TargetShop: targetShopPath,
|
|
TownMap: mapPath,
|
|
TownNPCID: 20449,
|
|
TownX: 2183,
|
|
TownY: 156,
|
|
PreservedSections: []string{"open visible event", "event open"},
|
|
InputSize: inputInfo.Size(),
|
|
OutputSize: len(patched),
|
|
OutputSHA256: strings.ToUpper(hex.EncodeToString(sum[:])),
|
|
}
|
|
encoded, err := json.MarshalIndent(result, "", " ")
|
|
must(err)
|
|
fmt.Println(string(encoded))
|
|
}
|
|
|
|
func ensureTownNPCPlacement(archive *platformpvf.Archive, raw []byte, npcID, oldX, oldY, newX, newY int) ([]byte, error) {
|
|
if archive == nil || len(raw)%5 != 0 {
|
|
return nil, fmt.Errorf("map token stream is invalid: bytes=%d", len(raw))
|
|
}
|
|
out := append([]byte(nil), raw...)
|
|
inNPCSection := false
|
|
matches := 0
|
|
for offset := 0; offset < len(out); offset += 5 {
|
|
token := out[offset : offset+5]
|
|
if token[0] == 3 {
|
|
label := normalizedLabel(archive.ResolveString(scriptTokenInt(token)))
|
|
inNPCSection = label == "npc"
|
|
continue
|
|
}
|
|
if !inNPCSection || token[0] != 0 || scriptTokenInt(token) != npcID || offset+25 > len(out) {
|
|
continue
|
|
}
|
|
direction := out[offset+5 : offset+10]
|
|
xToken := out[offset+10 : offset+15]
|
|
yToken := out[offset+15 : offset+20]
|
|
zToken := out[offset+20 : offset+25]
|
|
if direction[0] != 6 || normalizedLabel(archive.ResolveString(scriptTokenInt(direction))) != "left" ||
|
|
xToken[0] != 0 || yToken[0] != 0 || zToken[0] != 0 || scriptTokenInt(zToken) != 0 {
|
|
return nil, fmt.Errorf("town NPC %d placement does not match the proved tuple", npcID)
|
|
}
|
|
x, y := scriptTokenInt(xToken), scriptTokenInt(yToken)
|
|
switch {
|
|
case x == oldX && y == oldY:
|
|
binary.LittleEndian.PutUint32(xToken[1:5], uint32(int32(newX)))
|
|
binary.LittleEndian.PutUint32(yToken[1:5], uint32(int32(newY)))
|
|
case x == newX && y == newY:
|
|
default:
|
|
return nil, fmt.Errorf("town NPC %d placement=(%d,%d), want original=(%d,%d) or midpoint=(%d,%d)", npcID, x, y, oldX, oldY, newX, newY)
|
|
}
|
|
matches++
|
|
}
|
|
if matches != 1 {
|
|
return nil, fmt.Errorf("town NPC %d placement matches=%d, want=1", npcID, matches)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func replaceSectionIntValue(archive *platformpvf.Archive, raw []byte, section string, oldValue, newValue int) ([]byte, error) {
|
|
if archive == nil || len(raw)%5 != 0 {
|
|
return nil, fmt.Errorf("script token stream is invalid: bytes=%d", len(raw))
|
|
}
|
|
out := append([]byte(nil), raw...)
|
|
matches := 0
|
|
for offset := 0; offset+10 <= len(out); offset += 5 {
|
|
token := out[offset : offset+5]
|
|
if (token[0] != 3 && token[0] != 6) || normalizedLabel(archive.ResolveString(scriptTokenInt(token))) != normalizedLabel(section) {
|
|
continue
|
|
}
|
|
valueToken := out[offset+5 : offset+10]
|
|
if valueToken[0] != 0 || scriptTokenInt(valueToken) != oldValue {
|
|
return nil, fmt.Errorf("section [%s] value does not match proved integer %d", section, oldValue)
|
|
}
|
|
binary.LittleEndian.PutUint32(valueToken[1:5], uint32(int32(newValue)))
|
|
matches++
|
|
}
|
|
if matches != 1 {
|
|
return nil, fmt.Errorf("section [%s] matches=%d, want=1", section, matches)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func requireSectionIntValue(archive *platformpvf.Archive, path, section string, want int) error {
|
|
raw, err := archive.ReadRaw(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
matches := 0
|
|
for offset := 0; offset+10 <= len(raw); offset += 5 {
|
|
token := raw[offset : offset+5]
|
|
if (token[0] != 3 && token[0] != 6) || normalizedLabel(archive.ResolveString(scriptTokenInt(token))) != normalizedLabel(section) {
|
|
continue
|
|
}
|
|
valueToken := raw[offset+5 : offset+10]
|
|
if valueToken[0] != 0 || scriptTokenInt(valueToken) != want {
|
|
return fmt.Errorf("section [%s] value does not match required integer %d", section, want)
|
|
}
|
|
matches++
|
|
}
|
|
if matches != 1 {
|
|
return fmt.Errorf("section [%s] matches=%d, want=1", section, matches)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizedLabel(value string) string {
|
|
return strings.ToLower(strings.Trim(strings.TrimSpace(value), "[]"))
|
|
}
|
|
|
|
func scriptTokenInt(token []byte) int {
|
|
return int(int32(binary.LittleEndian.Uint32(token[1:5])))
|
|
}
|
|
|
|
func requireContains(path, text string, values ...string) {
|
|
for _, value := range values {
|
|
if !strings.Contains(text, value) {
|
|
panic(fmt.Errorf("%s is missing required evidence %q", path, value))
|
|
}
|
|
}
|
|
}
|
|
|
|
func must(err error) {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|