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 \n#include \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