服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
313 lines
9.8 KiB
Go
313 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
type skillRoleInput struct {
|
|
Schema int `json:"schema"`
|
|
RefreshMS uint32 `json:"refresh_ms"`
|
|
Common map[string]json.RawMessage `json:"common"`
|
|
Jobs map[string]map[string]json.RawMessage `json:"jobs"`
|
|
PassiveGrow []struct {
|
|
Job uint8 `json:"job"`
|
|
File string `json:"file"`
|
|
Grow uint8 `json:"grow"`
|
|
} `json:"passive_grow"`
|
|
}
|
|
|
|
type skillPolicyRow struct {
|
|
Job uint8 `json:"job"`
|
|
ID uint16 `json:"id"`
|
|
Purpose uint8 `json:"purpose"`
|
|
Flags uint8 `json:"flags"`
|
|
Group uint8 `json:"exclusive_group"`
|
|
PassiveGrow uint16 `json:"passive_grow"`
|
|
Cooldown uint32 `json:"cooldown_ms"`
|
|
Refresh uint32 `json:"refresh_ms"`
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
var policyPurposes = map[string]uint8{
|
|
"party_buff": 2, "once_party_buff": 2, "heal": 3, "support_awakening": 5,
|
|
"self_buff": 6, "once_self_buff": 6, "defensive_support": 7, "unsupported": 8, "summon": 9, "self_heal": 10,
|
|
}
|
|
|
|
// Only the main dungeon cooldown is authoritative. Apocalypse has a 145000
|
|
// reference-skill value before its actual 170000 dungeon cooldown. Never use
|
|
// first-number lookup or the PvP table for native action timing.
|
|
func currentDungeonCooldown(a *platformpvf.Archive, raw []byte) (uint32, error) {
|
|
depth, reference, alternate := 0, 0, 0
|
|
var global uint32
|
|
items := toks(a, raw)
|
|
for i, t := range items {
|
|
if t.typ != 3 {
|
|
continue
|
|
}
|
|
switch strings.ToLower(t.txt) {
|
|
case "[reference skill]":
|
|
reference++
|
|
case "[/reference skill]":
|
|
reference--
|
|
case "[dungeon]":
|
|
depth++
|
|
case "[/dungeon]":
|
|
depth--
|
|
case "[pvp]", "[death tower]", "[warroom]":
|
|
alternate++
|
|
case "[/pvp]", "[/death tower]", "[/warroom]":
|
|
alternate--
|
|
case "[cool time]":
|
|
if alternate != 0 || reference != 0 {
|
|
continue
|
|
}
|
|
maximum := float64(0)
|
|
count := 0
|
|
for j := i + 1; j < len(items) && items[j].typ != 3; j++ {
|
|
v := float64(items[j].val)
|
|
if items[j].typ == 2 {
|
|
v = float64(math.Float32frombits(uint32(items[j].val)))
|
|
} else if items[j].typ != 0 {
|
|
continue
|
|
}
|
|
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 3600000 {
|
|
return 0, fmt.Errorf("invalid main dungeon cooldown %g", v)
|
|
}
|
|
maximum = math.Max(maximum, v)
|
|
count++
|
|
}
|
|
if count != 2 {
|
|
return 0, fmt.Errorf("dungeon cooldown has %d values, expected rank endpoints", count)
|
|
}
|
|
value := uint32(math.Ceil(maximum)) // Conservative rank maximum, no guessed interpolation.
|
|
if depth > 0 {
|
|
return value, nil
|
|
}
|
|
global = value // Files without [dungeon] store the main value at top level.
|
|
}
|
|
}
|
|
return global, nil
|
|
}
|
|
|
|
func compileSkillPolicy(a *platformpvf.Archive, input skillRoleInput) ([]skillPolicyRow, error) {
|
|
if input.Schema != 1 || input.RefreshMS < 1000 || input.RefreshMS > 3600000 {
|
|
return nil, fmt.Errorf("invalid skill role schema/refresh policy")
|
|
}
|
|
master, err := a.ReadText("skill/skilllist.lst")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc, err := dnfpvf.Parse("skill/skilllist.lst", master)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rows []skillPolicyRow
|
|
seenJobs := map[uint8]bool{}
|
|
for _, entry := range dnfpvf.ParseList(doc) {
|
|
if entry.ID < 0 || entry.ID >= 16 {
|
|
return nil, fmt.Errorf("unsupported job list %d", entry.ID)
|
|
}
|
|
job := uint8(entry.ID)
|
|
seenJobs[job] = true
|
|
jobRules, ok := input.Jobs[strconv.Itoa(int(job))]
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing job rules %d", job)
|
|
}
|
|
listPath := path.Join("skill", entry.Path)
|
|
text, err := a.ReadText(listPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
list, err := dnfpvf.Parse(listPath, text)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seenFiles := map[string]bool{}
|
|
roleFiles := map[string]string{}
|
|
nativeFiles := map[string]bool{}
|
|
groups := map[string]uint8{}
|
|
for _, set := range []map[string]json.RawMessage{input.Common, jobRules} {
|
|
for role, encoded := range set {
|
|
if role == "exclusive_groups" {
|
|
var named [][]string
|
|
if err := json.Unmarshal(encoded, &named); err != nil {
|
|
return nil, err
|
|
}
|
|
for group, files := range named {
|
|
for _, file := range files {
|
|
groups[strings.ToLower(file)] = uint8(group + 1)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
var files []string
|
|
if err := json.Unmarshal(encoded, &files); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, file := range files {
|
|
key := strings.ToLower(file)
|
|
if role == "native_buff" {
|
|
nativeFiles[key] = true
|
|
continue
|
|
}
|
|
if _, ok := policyPurposes[role]; !ok {
|
|
return nil, fmt.Errorf("unknown purpose %s", role)
|
|
}
|
|
if old, exists := roleFiles[key]; exists && old != role {
|
|
return nil, fmt.Errorf("conflicting roles job=%d %s", job, file)
|
|
}
|
|
roleFiles[key] = role
|
|
}
|
|
}
|
|
}
|
|
seenIDs := map[uint16]bool{}
|
|
for _, skill := range dnfpvf.ParseList(list) {
|
|
if skill.ID < 0 || skill.ID >= 512 {
|
|
return nil, fmt.Errorf("job %d skill %d exceeds native projection contract", job, skill.ID)
|
|
}
|
|
id := uint16(skill.ID)
|
|
if seenIDs[id] {
|
|
return nil, fmt.Errorf("duplicate job %d skill %d", job, id)
|
|
}
|
|
seenIDs[id] = true
|
|
skillPath := path.Join(path.Dir(listPath), skill.Path)
|
|
text, err := a.ReadText(skillPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d, err := dnfpvf.Parse(skillPath, text)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name, _ := d.Text("name")
|
|
kind, _ := d.Text("type")
|
|
row := skillPolicyRow{Job: job, ID: id, Path: skillPath, Name: name, Purpose: 4}
|
|
file := strings.ToLower(path.Base(skillPath))
|
|
seenFiles[file] = true
|
|
if strings.EqualFold(kind, "[active]") {
|
|
row.Purpose = 1
|
|
role, exists := roleFiles[file]
|
|
if exists {
|
|
row.Purpose = policyPurposes[role]
|
|
if strings.HasPrefix(role, "once_") || role == "summon" {
|
|
row.Flags |= 1
|
|
}
|
|
row.Group = groups[file]
|
|
if nativeFiles[file] {
|
|
if job != 4 || row.Purpose != 2 {
|
|
return nil, fmt.Errorf("unverified native buff rule %d/%s", job, file)
|
|
}
|
|
row.Flags |= 2
|
|
}
|
|
if row.Purpose == 2 || row.Purpose == 6 {
|
|
row.Refresh = input.RefreshMS
|
|
}
|
|
}
|
|
raw, err := a.ReadRaw(skillPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
row.Cooldown, err = currentDungeonCooldown(a, raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", skillPath, err)
|
|
}
|
|
if row.Purpose == 1 {
|
|
explain, _ := d.Text("explain")
|
|
for _, phrase := range []string{"周围敌人", "周围的敌人", "范围内的敌人", "范围内所有敌人", "结界内的敌人", "所有敌人", "大范围", "广范围", "多名敌人"} {
|
|
if strings.Contains(explain, phrase) {
|
|
row.Flags |= 4
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, exception := range input.PassiveGrow {
|
|
if exception.Job == job && strings.EqualFold(exception.File, file) {
|
|
if exception.Grow >= 16 {
|
|
return nil, fmt.Errorf("invalid passive grow")
|
|
}
|
|
row.PassiveGrow |= 1 << exception.Grow
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
for file := range roleFiles {
|
|
if !seenFiles[file] {
|
|
return nil, fmt.Errorf("job %d role references missing skill file %s", job, file)
|
|
}
|
|
}
|
|
for file := range nativeFiles {
|
|
if !seenFiles[file] {
|
|
return nil, fmt.Errorf("missing native buff %d/%s", job, file)
|
|
}
|
|
}
|
|
}
|
|
if len(seenJobs) != 16 {
|
|
return nil, fmt.Errorf("expected 16 current job lists, got %d", len(seenJobs))
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].Job != rows[j].Job {
|
|
return rows[i].Job < rows[j].Job
|
|
}
|
|
return rows[i].ID < rows[j].ID
|
|
})
|
|
return rows, nil
|
|
}
|
|
|
|
func writeSkillPolicy(base, rulesPath, headerPath, catalogPath string) error {
|
|
a, err := platformpvf.Open(base)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := os.ReadFile(rulesPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var input skillRoleInput
|
|
if err = json.Unmarshal(raw, &input); err != nil {
|
|
return err
|
|
}
|
|
rows, err := compileSkillPolicy(a, input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
baseHash, _, err := sha256File(base)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var b bytes.Buffer
|
|
fmt.Fprintf(&b, "// Generated by companionpvf --skill-policy from current PVF %s.\n// Edit data/companion_skill_roles.json and regenerate; do not edit this table.\n#pragma once\n#include <cstdint>\n#include <cstddef>\nnamespace dnf90_companion_skill_data {\nstruct Rule { std::uint8_t job; std::uint16_t id; std::uint8_t purpose,flags,group; std::uint16_t passiveGrow; std::uint32_t cooldown,refresh; };\nconstexpr Rule rules[] = {\n", baseHash)
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "{%d,%d,%d,%d,%d,%d,%d,%d}, // %s\n", r.Job, r.ID, r.Purpose, r.Flags, r.Group, r.PassiveGrow, r.Cooldown, r.Refresh, r.Path)
|
|
}
|
|
fmt.Fprintln(&b, "};\ninline const Rule* Find(std::uint8_t job, std::uint32_t id) {\n if (job>=16 || id>=512) return nullptr;\n std::size_t low=0, high=sizeof(rules)/sizeof(rules[0]); const auto key=(std::uint32_t(job)<<16)|id;\n while (low<high) {const auto mid=low+(high-low)/2; const auto candidate=(std::uint32_t(rules[mid].job)<<16)|rules[mid].id; if (candidate<key) low=mid+1; else high=mid;}\n return low<sizeof(rules)/sizeof(rules[0]) && rules[low].job==job && rules[low].id==id ? &rules[low] : nullptr;\n}\n}")
|
|
if err = os.WriteFile(headerPath, b.Bytes(), 0644); err != nil {
|
|
return err
|
|
}
|
|
catalog := struct {
|
|
PVFSHA256 string `json:"pvf_sha256"`
|
|
Rows []skillPolicyRow `json:"skills"`
|
|
}{baseHash, rows}
|
|
data, err := json.MarshalIndent(catalog, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = os.WriteFile(catalogPath, data, 0644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("generated jobs=16 skills=%d header=%s catalog=%s\n", len(rows), headerPath, catalogPath)
|
|
return nil
|
|
}
|