Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

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)
}