服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
849 lines
26 KiB
Go
849 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
type token struct {
|
|
typ byte
|
|
val int32
|
|
txt string
|
|
idx int
|
|
}
|
|
|
|
func toks(a *platformpvf.Archive, raw []byte) []token {
|
|
var out []token
|
|
for off := 0; off+5 <= len(raw); off += 5 {
|
|
t := token{typ: raw[off], val: int32(binary.LittleEndian.Uint32(raw[off+1 : off+5])), idx: off / 5}
|
|
if t.typ == 3 || t.typ == 6 {
|
|
t.txt = strings.TrimSpace(a.ResolveString(int(t.val)))
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func section(ts []token, open, close string) (int, int) {
|
|
start := -1
|
|
for i, t := range ts {
|
|
if t.typ != 3 {
|
|
continue
|
|
}
|
|
if strings.EqualFold(t.txt, open) {
|
|
start = i
|
|
continue
|
|
}
|
|
if start >= 0 && strings.EqualFold(t.txt, close) {
|
|
return start, i
|
|
}
|
|
}
|
|
return -1, -1
|
|
}
|
|
|
|
func rawToken(typ byte, value int) []byte {
|
|
out := make([]byte, 5)
|
|
out[0] = typ
|
|
binary.LittleEndian.PutUint32(out[1:5], uint32(value))
|
|
return out
|
|
}
|
|
|
|
func rawFloat(value float32) []byte {
|
|
out := make([]byte, 5)
|
|
out[0] = 2
|
|
binary.LittleEndian.PutUint32(out[1:5], math.Float32bits(value))
|
|
return out
|
|
}
|
|
|
|
func actualPath(a *platformpvf.Archive, p string) string {
|
|
f, ok := a.FindFile(p)
|
|
if !ok {
|
|
panic("missing pvf path: " + p)
|
|
}
|
|
return strings.ReplaceAll(f.ArchivePath, "\\", "/")
|
|
}
|
|
|
|
func replaceSection(raw []byte, ts []token, open, close string, interior []byte) []byte {
|
|
s, e := section(ts, open, close)
|
|
if s < 0 || e <= s {
|
|
panic("section missing: " + open)
|
|
}
|
|
out := make([]byte, 0, len(raw)+len(interior))
|
|
out = append(out, raw[:(s+1)*5]...)
|
|
out = append(out, interior...)
|
|
out = append(out, raw[e*5:]...)
|
|
return out
|
|
}
|
|
|
|
func replaceOrInsertSection(
|
|
a *platformpvf.Archive,
|
|
raw []byte,
|
|
ts []token,
|
|
open, close, afterClose string,
|
|
interior []byte,
|
|
) []byte {
|
|
if s, e := section(ts, open, close); s >= 0 && e > s {
|
|
return replaceSection(raw, ts, open, close, interior)
|
|
}
|
|
insertAfter := -1
|
|
for i, t := range ts {
|
|
if t.typ == 3 && strings.EqualFold(t.txt, afterClose) {
|
|
insertAfter = i
|
|
break
|
|
}
|
|
}
|
|
if insertAfter < 0 {
|
|
panic("insert anchor missing: " + afterClose)
|
|
}
|
|
openOff, err := a.EnsureANSIString(open)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
closeOff, err := a.EnsureANSIString(close)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
block := append([]byte{}, rawToken(3, openOff)...)
|
|
block = append(block, interior...)
|
|
block = append(block, rawToken(3, closeOff)...)
|
|
out := make([]byte, 0, len(raw)+len(block))
|
|
out = append(out, raw[:(insertAfter+1)*5]...)
|
|
out = append(out, block...)
|
|
out = append(out, raw[(insertAfter+1)*5:]...)
|
|
return out
|
|
}
|
|
|
|
func removeStandaloneTagIfPresent(raw []byte, ts []token, tag string) []byte {
|
|
index := -1
|
|
for i, t := range ts {
|
|
if t.typ == 3 && strings.EqualFold(t.txt, tag) {
|
|
if index >= 0 {
|
|
panic("duplicate standalone tag: " + tag)
|
|
}
|
|
index = i
|
|
}
|
|
}
|
|
if index < 0 {
|
|
return raw
|
|
}
|
|
out := make([]byte, 0, len(raw)-5)
|
|
out = append(out, raw[:index*5]...)
|
|
out = append(out, raw[(index+1)*5:]...)
|
|
return out
|
|
}
|
|
|
|
func replaceIntAfterTagIfPresent(raw []byte, ts []token, tag string, value int) []byte {
|
|
out := append([]byte(nil), raw...)
|
|
for i := 0; i+1 < len(ts); i++ {
|
|
if ts[i].typ == 3 && strings.EqualFold(ts[i].txt, tag) &&
|
|
ts[i+1].typ == 0 {
|
|
binary.LittleEndian.PutUint32(
|
|
out[(i+1)*5+1:(i+1)*5+5], uint32(value))
|
|
return out
|
|
}
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func firstNumericInSection(
|
|
a *platformpvf.Archive,
|
|
raw []byte,
|
|
open, close string,
|
|
) (float64, byte, int, bool) {
|
|
ts := toks(a, raw)
|
|
s, e := section(ts, open, close)
|
|
if s < 0 || e <= s {
|
|
return 0, 0, -1, false
|
|
}
|
|
for i := s + 1; i < e; i++ {
|
|
switch ts[i].typ {
|
|
case 0:
|
|
return float64(ts[i].val), 0, i, true
|
|
case 2:
|
|
return float64(math.Float32frombits(uint32(ts[i].val))), 2, i, true
|
|
}
|
|
}
|
|
return 0, 0, -1, false
|
|
}
|
|
|
|
func firstNumericAfterTag(
|
|
a *platformpvf.Archive,
|
|
raw []byte,
|
|
tag string,
|
|
) (float64, byte, int, bool) {
|
|
ts := toks(a, raw)
|
|
for i := 0; i+1 < len(ts); i++ {
|
|
if ts[i].typ != 3 || !strings.EqualFold(ts[i].txt, tag) {
|
|
continue
|
|
}
|
|
switch ts[i+1].typ {
|
|
case 0:
|
|
return float64(ts[i+1].val), 0, i + 1, true
|
|
case 2:
|
|
return float64(math.Float32frombits(uint32(ts[i+1].val))), 2, i + 1, true
|
|
default:
|
|
return 0, 0, -1, false
|
|
}
|
|
}
|
|
return 0, 0, -1, false
|
|
}
|
|
|
|
func setCompanionHPRate(
|
|
a *platformpvf.Archive,
|
|
raw []byte,
|
|
baseHP float64,
|
|
) []byte {
|
|
current, typ, index, ok := firstNumericInSection(
|
|
a, raw, "[character status rate]", "[/character status rate]")
|
|
if !ok {
|
|
panic("character status rate HP missing")
|
|
}
|
|
target := baseHP * 20
|
|
if math.Abs(current-baseHP) > 0.0001 &&
|
|
math.Abs(current-target) > 0.0001 {
|
|
panic(fmt.Sprintf(
|
|
"unexpected companion HP rate current=%g base=%g target=%g",
|
|
current, baseHP, target))
|
|
}
|
|
out := append([]byte(nil), raw...)
|
|
offset := index*5 + 1
|
|
switch typ {
|
|
case 0:
|
|
if target != math.Trunc(target) ||
|
|
target < math.MinInt32 || target > math.MaxInt32 {
|
|
panic(fmt.Sprintf("integer HP rate out of range: %g", target))
|
|
}
|
|
binary.LittleEndian.PutUint32(
|
|
out[offset:offset+4], uint32(int32(target)))
|
|
case 2:
|
|
binary.LittleEndian.PutUint32(
|
|
out[offset:offset+4], math.Float32bits(float32(target)))
|
|
default:
|
|
panic("unsupported HP rate token type")
|
|
}
|
|
return out
|
|
}
|
|
|
|
func removeStandaloneTag(raw []byte, ts []token, tag string) []byte {
|
|
index := -1
|
|
for i, t := range ts {
|
|
if t.typ == 3 && strings.EqualFold(t.txt, tag) {
|
|
if index >= 0 {
|
|
panic("duplicate standalone tag: " + tag)
|
|
}
|
|
index = i
|
|
}
|
|
}
|
|
if index < 0 {
|
|
panic("standalone tag missing: " + tag)
|
|
}
|
|
out := make([]byte, 0, len(raw)-5)
|
|
out = append(out, raw[:index*5]...)
|
|
out = append(out, raw[(index+1)*5:]...)
|
|
return out
|
|
}
|
|
|
|
func replaceIntAfterTag(raw []byte, ts []token, tag string, value int) []byte {
|
|
out := append([]byte(nil), raw...)
|
|
for i := 0; i+1 < len(ts); i++ {
|
|
if ts[i].typ == 3 && strings.EqualFold(ts[i].txt, tag) &&
|
|
ts[i+1].typ == 0 {
|
|
binary.LittleEndian.PutUint32(
|
|
out[(i+1)*5+1:(i+1)*5+5], uint32(value))
|
|
return out
|
|
}
|
|
}
|
|
panic("integer after tag missing: " + tag)
|
|
}
|
|
|
|
// replaceFloatAfterTag patches a scalar float without changing the token
|
|
// shape of the surrounding AIC. Callers use the fixed support-AIC allowlist;
|
|
// ordinary APC AIC files never reach this function.
|
|
func replaceFloatAfterTag(raw []byte, ts []token, tag string, value float32) []byte {
|
|
for i := 0; i+1 < len(ts); i++ {
|
|
if ts[i].typ != 3 || !strings.EqualFold(ts[i].txt, tag) {
|
|
continue
|
|
}
|
|
next := ts[i+1]
|
|
offset := (i + 1) * 5
|
|
out := append([]byte(nil), raw...)
|
|
switch next.typ {
|
|
case 2:
|
|
binary.LittleEndian.PutUint32(out[offset+1:offset+5], math.Float32bits(value))
|
|
return out
|
|
case 0:
|
|
if value != float32(int(value)) {
|
|
panic("float value cannot be represented by integer AIC token: " + tag)
|
|
}
|
|
binary.LittleEndian.PutUint32(out[offset+1:offset+5], uint32(int(value)))
|
|
return out
|
|
default:
|
|
panic("tag is not a numeric AIC token: " + tag)
|
|
}
|
|
}
|
|
panic("float tag missing: " + tag)
|
|
}
|
|
|
|
func patchCooldownMap(a *platformpvf.Archive, raw []byte, mapping map[int32]int32) []byte {
|
|
out := append([]byte(nil), raw...)
|
|
ts := toks(a, out)
|
|
counts := map[int32]int{}
|
|
for i := 0; i+1 < len(ts); i++ {
|
|
if ts[i].typ != 6 || !strings.EqualFold(ts[i].txt, "is the skill in cooltime()") || ts[i+1].typ != 0 {
|
|
continue
|
|
}
|
|
next, ok := mapping[ts[i+1].val]
|
|
if !ok {
|
|
continue
|
|
}
|
|
binary.LittleEndian.PutUint32(out[(i+1)*5+1:(i+1)*5+5], uint32(next))
|
|
counts[ts[i+1].val]++
|
|
}
|
|
for old := range mapping {
|
|
if counts[old] == 0 {
|
|
panic(fmt.Sprintf("action cooldown id %d not found", old))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildQuickSlotKey(a *platformpvf.Archive, key string) []byte {
|
|
tag := func(value string) []byte {
|
|
off, err := a.EnsureANSIString(value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return rawToken(3, off)
|
|
}
|
|
str := func(value string) []byte {
|
|
off, err := a.EnsureANSIString(value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return rawToken(6, off)
|
|
}
|
|
var out []byte
|
|
out = append(out, tag("[input]")...)
|
|
out = append(out, str("target current direction")...)
|
|
out = append(out, rawToken(0, 100)...)
|
|
out = append(out, str("time")...)
|
|
out = append(out, rawToken(0, 50)...)
|
|
out = append(out, tag("[input]")...)
|
|
out = append(out, str(key)...)
|
|
out = append(out, rawToken(0, 30)...)
|
|
out = append(out, str("time")...)
|
|
out = append(out, rawToken(0, 100)...)
|
|
return out
|
|
}
|
|
|
|
func buildDirectPlaceholderKey(a *platformpvf.Archive) []byte {
|
|
tag := func(value string) []byte {
|
|
off, err := a.EnsureANSIString(value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return rawToken(3, off)
|
|
}
|
|
str := func(value string) []byte {
|
|
off, err := a.EnsureANSIString(value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return rawToken(6, off)
|
|
}
|
|
var out []byte
|
|
out = append(out, tag("[input]")...)
|
|
out = append(out, str("")...)
|
|
out = append(out, rawToken(0, 50)...)
|
|
out = append(out, str("time")...)
|
|
out = append(out, rawToken(0, 50)...)
|
|
out = append(out, tag("[input]")...)
|
|
out = append(out, tag("[state check]")...)
|
|
out = append(out, str("target current direction x")...)
|
|
out = append(out, rawToken(0, 100)...)
|
|
out = append(out, str("time")...)
|
|
out = append(out, rawToken(0, 50)...)
|
|
out = append(out, tag("[input]")...)
|
|
out = append(out, str("skill:511")...)
|
|
out = append(out, rawToken(0, 100)...)
|
|
out = append(out, str("time")...)
|
|
out = append(out, rawToken(0, 200)...)
|
|
return out
|
|
}
|
|
|
|
// buildDefaultCompanionAI keeps the legacy generator on the same native
|
|
// state/party/mode tree used by the profile overlay. The old default path
|
|
// emitted a random key bucket that the current client could not bind, leaving
|
|
// the companion able to chase but unable to select attacks.
|
|
func buildDefaultCompanionAI(
|
|
a *platformpvf.Archive,
|
|
aiFiles []string,
|
|
profile dnfcompanion.AIProfile,
|
|
) ([]platformpvf.RawReplacement, error) {
|
|
if len(aiFiles) != 4 {
|
|
return nil, fmt.Errorf("default companion AI requires event, destination, action and move paths")
|
|
}
|
|
profile = dnfcompanion.NormalizeAIProfile(profile)
|
|
action := buildCompanionActionAI(a, profile, 4)
|
|
if err := validateCompanionActionAI(a, action); err != nil {
|
|
return nil, fmt.Errorf("generated default action: %w", err)
|
|
}
|
|
return []platformpvf.RawReplacement{
|
|
{Path: aiFiles[0], Data: buildCompanionEventAI(a, profile)},
|
|
{Path: aiFiles[1], Data: buildCompanionDestinationAI(a, profile, 4)},
|
|
{Path: aiFiles[2], Data: action},
|
|
{Path: aiFiles[3], Data: buildCompanionMoveAI(a, profile, 4)},
|
|
}, nil
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) >= 2 && os.Args[1] == "--skill-policy" {
|
|
if len(os.Args) != 6 {
|
|
panic("usage: companionpvf --skill-policy <base.pvf> <roles.json> <output.h> <catalog.json>")
|
|
}
|
|
if err := writeSkillPolicy(os.Args[2], os.Args[3], os.Args[4], os.Args[5]); err != nil {
|
|
panic(err)
|
|
}
|
|
return
|
|
}
|
|
if len(os.Args) >= 2 && os.Args[1] == "--profile-overlay" {
|
|
if len(os.Args) < 5 {
|
|
panic("usage: companionpvf --profile-overlay <base.pvf> <output.pvf> <profiles.json>")
|
|
}
|
|
data, err := os.ReadFile(os.Args[4])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
var input struct {
|
|
Profiles []dnfcompanion.AIProfile `json:"profiles"`
|
|
Jobs []uint8 `json:"jobs"`
|
|
}
|
|
if err := json.Unmarshal(data, &input); err != nil {
|
|
panic(fmt.Errorf("read profile overlay JSON: %w", err))
|
|
}
|
|
result, err := BuildProfileOverlay(ProfileOverlayRequest{
|
|
BasePVF: os.Args[2], OutputPVF: os.Args[3],
|
|
Profiles: input.Profiles, Jobs: input.Jobs,
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
if err := encoder.Encode(result); err != nil {
|
|
panic(err)
|
|
}
|
|
return
|
|
}
|
|
root := ".."
|
|
if len(os.Args) >= 2 && strings.TrimSpace(os.Args[1]) != "" {
|
|
root = os.Args[1]
|
|
}
|
|
absoluteRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
projectRoot := filepath.Clean(absoluteRoot)
|
|
src := filepath.Join(projectRoot, "runtime", "data", "dnf", "Script.pvf")
|
|
outPath := filepath.Join(projectRoot, "runtime", "Script.companion-12slot-dynamic.pvf")
|
|
a, err := platformpvf.Open(src)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
companionBaseHPRates := map[string]float64{
|
|
"aicharacter/_bizarre/swordman/mirror_swordman/mirror_swordman.aic": 210,
|
|
"aicharacter/fighter/arad_fighter/arad_fighter_doll.aic": 6,
|
|
"aicharacter/_bizarre/gunner/mirror_gunner/mirror_gunner.aic": 210,
|
|
"aicharacter/_bizarre/magician/mirror_magician/mirror_magician.aic": 210,
|
|
"aicharacter/_bizarre/priest/mirror_priest/mirror_priest.aic": 210,
|
|
"aicharacter/_bizarre/atgunner/mirror_atgunner/mirror_atgunner.aic": 210,
|
|
"aicharacter/_bizarre/Thief/mirror_Thief/mirror_Thief.aic": 210,
|
|
"aicharacter/_bizarre/atfighter/mirror_atfighter/mirror_atfighter.aic": 210,
|
|
"aicharacter/autocharacter/atmage/auto1/auto_atmage1.aic": 270,
|
|
"aicharacter/_bizarre/swordman/despair_darkknight/despair_darkknight.aic": 400,
|
|
"aicharacter/_bizarre/magician/despair_creator/despair_creator.aic": 400,
|
|
"aicharacter/_bizarre/atswordman/despair_atswordman/despair_atswordman.aic": 400,
|
|
"aicharacter/_bizarre/knight/despair_knight/despair_knight.aic": 400,
|
|
"aicharacter/demoniclancer/mirror_demonicLancer/mirror_demonicLancer.aic": 210,
|
|
"aicharacter/atpriest/despair_atpriest/despair_atpriest.aic": 400,
|
|
"aicharacter/gunblader/mirror_gunblader/mirror_gunblader.aic": 210,
|
|
}
|
|
targetAICs := []string{
|
|
"aicharacter/_bizarre/swordman/mirror_swordman/mirror_swordman.aic",
|
|
"aicharacter/fighter/arad_fighter/arad_fighter_doll.aic",
|
|
"aicharacter/_bizarre/gunner/mirror_gunner/mirror_gunner.aic",
|
|
"aicharacter/_bizarre/magician/mirror_magician/mirror_magician.aic",
|
|
"aicharacter/_bizarre/priest/mirror_priest/mirror_priest.aic",
|
|
"aicharacter/_bizarre/atgunner/mirror_atgunner/mirror_atgunner.aic",
|
|
"aicharacter/_bizarre/Thief/mirror_Thief/mirror_Thief.aic",
|
|
"aicharacter/_bizarre/atfighter/mirror_atfighter/mirror_atfighter.aic",
|
|
"aicharacter/autocharacter/atmage/auto1/auto_atmage1.aic",
|
|
"aicharacter/_bizarre/swordman/despair_darkknight/despair_darkknight.aic",
|
|
"aicharacter/_bizarre/magician/despair_creator/despair_creator.aic",
|
|
"aicharacter/_bizarre/atswordman/despair_atswordman/despair_atswordman.aic",
|
|
"aicharacter/_bizarre/knight/despair_knight/despair_knight.aic",
|
|
"aicharacter/demoniclancer/mirror_demonicLancer/mirror_demonicLancer.aic",
|
|
"aicharacter/atpriest/despair_atpriest/despair_atpriest.aic",
|
|
"aicharacter/gunblader/mirror_gunblader/mirror_gunblader.aic",
|
|
}
|
|
aiFiles := []string{
|
|
"aicharacter/atgunner/blackwidow/ai/event.ai",
|
|
"aicharacter/atgunner/blackwidow/ai/destinationselect.ai",
|
|
"aicharacter/atgunner/blackwidow/ai/action.ai",
|
|
"aicharacter/atgunner/blackwidow/ai/movemethod.ai",
|
|
}
|
|
keyFiles := []string{
|
|
"aicharacter/magician/biske2/key/1.key",
|
|
"aicharacter/magician/biske2/key/2.key",
|
|
"aicharacter/magician/biske2/key/3.key",
|
|
"aicharacter/magician/biske2/key/4.key",
|
|
"aicharacter/magician/biske2/key/5.key",
|
|
"aicharacter/magician/biske2/key/6.key",
|
|
"aicharacter/magician/biske2/key/a.key",
|
|
"aicharacter/magician/biske2/key/c.key",
|
|
"aicharacter/magician/biske2/key/d.key",
|
|
"aicharacter/magician/biske2/key/f.key",
|
|
"aicharacter/magician/biske2/key/g.key",
|
|
"aicharacter/magician/biske2/key/h.key",
|
|
}
|
|
directPoolFiles := []string{
|
|
"aicharacter/magician/flea_inflame_hands2/key/1.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/2.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/3.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/4.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/5.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/6.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/a.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/c.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/d.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/f.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/g.key",
|
|
"aicharacter/magician/flea_inflame_hands2/key/h.key",
|
|
}
|
|
basicAttackFile := companionBasicAttackFile
|
|
basicAttackName := "CompanionBasicAttack"
|
|
|
|
quickSlotKeys := []string{"a", "s", "d", "f", "g", "h", "q", "w", "e", "r", "t", "y"}
|
|
|
|
aiOffsets := make([]int, len(aiFiles))
|
|
for i, p := range aiFiles {
|
|
full := actualPath(a, p)
|
|
off, e := a.EnsureANSIString(full)
|
|
if e != nil {
|
|
panic(e)
|
|
}
|
|
aiOffsets[i] = off
|
|
fmt.Printf("AI_PATH[%d] off=%d %s\n", i, off, full)
|
|
}
|
|
keyOffsets := make([]int, len(keyFiles))
|
|
for i, p := range keyFiles {
|
|
full := actualPath(a, p)
|
|
off, e := a.EnsureANSIString(full)
|
|
if e != nil {
|
|
panic(e)
|
|
}
|
|
keyOffsets[i] = off
|
|
fmt.Printf("KEY_PATH[%d] off=%d %s\n", i, off, full)
|
|
}
|
|
directPoolOffsets := make([]int, len(directPoolFiles))
|
|
directPoolNameOffsets := make([]int, len(directPoolFiles))
|
|
for i, p := range directPoolFiles {
|
|
full := actualPath(a, p)
|
|
pathOff, e := a.EnsureANSIString(full)
|
|
if e != nil {
|
|
panic(e)
|
|
}
|
|
nameOff, e := a.EnsureANSIString(
|
|
fmt.Sprintf("CompanionDirectPool%02d", i))
|
|
if e != nil {
|
|
panic(e)
|
|
}
|
|
directPoolOffsets[i] = pathOff
|
|
directPoolNameOffsets[i] = nameOff
|
|
}
|
|
basicAttackPathOffset, err := a.EnsureANSIString(
|
|
actualPath(a, basicAttackFile))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
basicAttackNameOffset, err := a.EnsureANSIString(basicAttackName)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
var aiInterior []byte
|
|
for _, off := range aiOffsets {
|
|
aiInterior = append(aiInterior, rawToken(6, off)...)
|
|
}
|
|
keyNames := make([]string, len(keyFiles))
|
|
keyNameOffsets := make([]int, len(keyFiles))
|
|
for i := range keyNames {
|
|
keyNames[i] = fmt.Sprintf("CompanionSlot%02d", i)
|
|
off, e := a.EnsureANSIString(keyNames[i])
|
|
if e != nil {
|
|
panic(e)
|
|
}
|
|
keyNameOffsets[i] = off
|
|
}
|
|
var keyInterior []byte
|
|
for i := range keyFiles {
|
|
keyInterior = append(keyInterior, rawToken(6, keyNameOffsets[i])...)
|
|
keyInterior = append(keyInterior, rawToken(6, keyOffsets[i])...)
|
|
}
|
|
for i := range directPoolFiles {
|
|
keyInterior = append(keyInterior, rawToken(6, directPoolNameOffsets[i])...)
|
|
keyInterior = append(keyInterior, rawToken(6, directPoolOffsets[i])...)
|
|
}
|
|
keyInterior = append(keyInterior, rawToken(6, basicAttackNameOffset)...)
|
|
keyInterior = append(keyInterior, rawToken(6, basicAttackPathOffset)...)
|
|
keyCooldowns := make([]int, len(keyFiles))
|
|
var keyCooldownInterior []byte
|
|
for i := range keyFiles {
|
|
keyCooldowns[i] = 600
|
|
keyCooldownInterior = append(keyCooldownInterior, rawToken(6, keyNameOffsets[i])...)
|
|
keyCooldownInterior = append(keyCooldownInterior, rawToken(0, keyCooldowns[i])...)
|
|
}
|
|
keyCooldownInterior = append(keyCooldownInterior, rawToken(6, basicAttackNameOffset)...)
|
|
keyCooldownInterior = append(keyCooldownInterior, rawToken(0, 0)...)
|
|
|
|
type scalarPatch struct {
|
|
tag string
|
|
value int
|
|
}
|
|
profile := dnfcompanion.DefaultAIProfile(4)
|
|
profile.Behavior = "aggressive"
|
|
tuning := dnfcompanion.EffectiveAITuning(profile.Tuning)
|
|
scalarPatches := []scalarPatch{
|
|
{"[think term]", tuning.ThinkIntervalMS},
|
|
{"[destination change term]", tuning.RetryMS},
|
|
{"[keep distance with target]", tuning.FollowDistance},
|
|
{"[warlike]", 80},
|
|
{"[vision]", tuning.VisionRange},
|
|
}
|
|
const companionAttackDamageRate float32 = 1.0
|
|
baselineRaw := make(map[string][]byte, len(targetAICs))
|
|
for _, targetAIC := range targetAICs {
|
|
targetRaw, e := a.ReadRaw(targetAIC)
|
|
if e != nil {
|
|
panic(e)
|
|
}
|
|
baseHP, ok := companionBaseHPRates[targetAIC]
|
|
if !ok {
|
|
panic("companion HP baseline missing: " + targetAIC)
|
|
}
|
|
targetRaw = setCompanionHPRate(a, targetRaw, baseHP)
|
|
targetTs := toks(a, targetRaw)
|
|
targetRaw = replaceSection(
|
|
targetRaw, targetTs,
|
|
"[ai pattern]", "[/ai pattern]",
|
|
aiInterior)
|
|
|
|
targetTs = toks(a, targetRaw)
|
|
targetRaw = replaceSection(
|
|
targetRaw, targetTs,
|
|
"[key stream]", "[/key stream]",
|
|
keyInterior)
|
|
|
|
targetTs = toks(a, targetRaw)
|
|
targetRaw = replaceOrInsertSection(
|
|
a, targetRaw, targetTs,
|
|
"[key cooltime]", "[/key cooltime]",
|
|
"[/key stream]",
|
|
keyCooldownInterior)
|
|
|
|
targetTs = toks(a, targetRaw)
|
|
targetRaw = removeStandaloneTagIfPresent(
|
|
targetRaw, targetTs, "[no cooltime]")
|
|
|
|
for _, patch := range scalarPatches {
|
|
targetTs = toks(a, targetRaw)
|
|
targetRaw = replaceIntAfterTag(
|
|
targetRaw, targetTs,
|
|
patch.tag, patch.value)
|
|
}
|
|
targetTs = toks(a, targetRaw)
|
|
targetRaw = replaceIntAfterTagIfPresent(
|
|
targetRaw, targetTs,
|
|
"[far attack reaction rate]", 100)
|
|
targetTs = toks(a, targetRaw)
|
|
targetRaw = replaceFloatAfterTag(
|
|
targetRaw, targetTs,
|
|
"[attack damage rate]", companionAttackDamageRate)
|
|
|
|
baselineRaw[targetAIC] = targetRaw
|
|
fmt.Printf("BASELINE_PATCH %s attack_damage_rate=%.1f\n",
|
|
targetAIC, companionAttackDamageRate)
|
|
}
|
|
|
|
// The runtime quickbar selector binds the full state/party/mode tree.
|
|
// Keep the default builder on the same grammar as --profile-overlay;
|
|
// a random key tree cannot be bound and executes empty quickbar slots.
|
|
repls, err := buildDefaultCompanionAI(a, aiFiles, profile)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
for _, targetAIC := range targetAICs {
|
|
repls = append(repls, platformpvf.RawReplacement{
|
|
Path: targetAIC,
|
|
Data: baselineRaw[targetAIC],
|
|
})
|
|
}
|
|
for i, p := range keyFiles {
|
|
raw := buildQuickSlotKey(a, quickSlotKeys[i])
|
|
repls = append(repls, platformpvf.RawReplacement{Path: p, Data: raw})
|
|
}
|
|
for _, p := range directPoolFiles {
|
|
raw := buildDirectPlaceholderKey(a)
|
|
repls = append(repls, platformpvf.RawReplacement{Path: p, Data: raw})
|
|
}
|
|
|
|
patched, err := a.RepackRaw(repls)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if err := os.WriteFile(outPath, patched, 0644); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
verify, err := platformpvf.Open(outPath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
fmt.Printf("VERIFY format=%s files=%d size=%d\n", verify.Format(), verify.FileCount(), len(patched))
|
|
for _, targetAIC := range targetAICs {
|
|
targetText, e := verify.ReadText(targetAIC)
|
|
if e != nil {
|
|
panic(e)
|
|
}
|
|
targetFlat := strings.Join(strings.Fields(targetText), " ")
|
|
targetLower := strings.ToLower(targetFlat)
|
|
if strings.Contains(targetLower, "[no cooltime]") {
|
|
panic("baseline still has [no cooltime]: " + targetAIC)
|
|
}
|
|
raw, e := verify.ReadRaw(targetAIC)
|
|
if e != nil {
|
|
panic(e)
|
|
}
|
|
actualHP, _, _, ok := firstNumericInSection(
|
|
verify, raw, "[character status rate]", "[/character status rate]")
|
|
if !ok || math.Abs(actualHP-companionBaseHPRates[targetAIC]*20) > 0.0001 {
|
|
panic(fmt.Sprintf(
|
|
"baseline %s HP multiplier mismatch actual=%g want=%g",
|
|
targetAIC, actualHP, companionBaseHPRates[targetAIC]*20))
|
|
}
|
|
actualAttackDamageRate, _, _, ok := firstNumericAfterTag(
|
|
verify, raw, "[attack damage rate]")
|
|
if !ok || math.Abs(actualAttackDamageRate-float64(companionAttackDamageRate)) > 0.0001 {
|
|
panic(fmt.Sprintf(
|
|
"baseline %s attack damage rate mismatch actual=%g want=%g",
|
|
targetAIC, actualAttackDamageRate, companionAttackDamageRate))
|
|
}
|
|
for _, check := range []string{
|
|
fmt.Sprintf("[think term] %d", tuning.ThinkIntervalMS),
|
|
fmt.Sprintf("[destination change term] %d", tuning.RetryMS),
|
|
fmt.Sprintf("[keep distance with target] %d", tuning.FollowDistance),
|
|
"[warlike] 80",
|
|
fmt.Sprintf("[vision] %d", tuning.VisionRange),
|
|
} {
|
|
if !strings.Contains(targetLower, check) {
|
|
panic(fmt.Sprintf("baseline %s missing %s", targetAIC, check))
|
|
}
|
|
}
|
|
for _, name := range keyNames {
|
|
if strings.Count(targetFlat, name) < 2 {
|
|
panic(fmt.Sprintf(
|
|
"baseline %s missing stream/cooldown key %s",
|
|
targetAIC, name))
|
|
}
|
|
}
|
|
for i := range directPoolFiles {
|
|
name := fmt.Sprintf("CompanionDirectPool%02d", i)
|
|
if !strings.Contains(targetFlat, name) {
|
|
panic(fmt.Sprintf(
|
|
"baseline %s missing direct pool %s",
|
|
targetAIC, name))
|
|
}
|
|
}
|
|
if !strings.Contains(targetFlat, basicAttackName) {
|
|
panic("baseline missing basic attack program: " + targetAIC)
|
|
}
|
|
fmt.Printf("VERIFY_BASELINE %s attack_damage_rate=%.1f programs=%d+%d+1\n",
|
|
targetAIC, companionAttackDamageRate, len(keyFiles), len(directPoolFiles))
|
|
}
|
|
|
|
actionText, err := verify.ReadText(aiFiles[2])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
fmt.Println("ACTION", strings.Join(strings.Fields(actionText), " "))
|
|
actionFlat := " " + strings.Join(strings.Fields(actionText), " ") + " "
|
|
actionRaw, err := verify.ReadRaw(aiFiles[2])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if err := validateCompanionActionAI(verify, actionRaw); err != nil {
|
|
panic(fmt.Errorf("default companion action readback: %w", err))
|
|
}
|
|
if strings.Contains(strings.ToLower(actionFlat), "get random()") {
|
|
panic("default companion action still uses an unbound random key tree")
|
|
}
|
|
for index, name := range keyNames {
|
|
if !strings.Contains(actionFlat, name) || !strings.Contains(actionFlat, fmt.Sprintf(" %d ", index)) {
|
|
panic(fmt.Sprintf("action missing key/index %s/%d", name, index))
|
|
}
|
|
}
|
|
if strings.Contains(strings.ToLower(actionFlat), "is the skill in cooltime()") {
|
|
panic("action still depends on skill cooldown")
|
|
}
|
|
if !strings.Contains(actionFlat, fmt.Sprintf(" %d ", companionBasicAttackIndex)) {
|
|
panic("action missing basic-attack fallback index")
|
|
}
|
|
basicAttackText, err := verify.ReadText(basicAttackFile)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
basicAttackFlat := strings.ToLower(strings.Join(
|
|
strings.Fields(basicAttackText), " "))
|
|
basicToken := string(rune(96)) + "x" + string(rune(96))
|
|
if !strings.Contains(basicAttackFlat, basicToken) {
|
|
panic("basic attack key does not contain native X input")
|
|
}
|
|
|
|
for i, p := range keyFiles {
|
|
text, e := verify.ReadText(p)
|
|
if e != nil {
|
|
panic(e)
|
|
}
|
|
flat := strings.Join(strings.Fields(text), " ")
|
|
fmt.Printf("KEY[%d] %s => %s\n", i, p, flat)
|
|
needle := "`" + quickSlotKeys[i] + "`"
|
|
if !strings.Contains(strings.ToLower(flat), needle) {
|
|
panic(fmt.Sprintf("key %d missing quick-slot input %s", i, quickSlotKeys[i]))
|
|
}
|
|
if strings.Contains(strings.ToLower(flat), "skill:") {
|
|
panic(fmt.Sprintf("key %d still hardcodes a skill id", i))
|
|
}
|
|
}
|
|
|
|
jisunPath := "aicharacter/fighter/jisun/ai/action.ai"
|
|
jisunText, err := verify.ReadText(jisunPath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
jisunFlat := " " + strings.Join(strings.Fields(jisunText), " ") + " "
|
|
if !strings.Contains(jisunFlat, " 58 ") || !strings.Contains(jisunFlat, " 0 ") {
|
|
panic("Jisun action changed unexpectedly")
|
|
}
|
|
fmt.Println("JISUN_UNCHANGED", strings.Join(strings.Fields(jisunText), " "))
|
|
fmt.Println("WROTE", outPath)
|
|
}
|