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

537 lines
23 KiB
Go

package main
import (
"fmt"
pathpkg "path"
"strings"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
platformpvf "longheng.io/server/internal/platform/pvf"
)
const companionQuickSlotCount = 12
const companionBasicAttackIndex = companionQuickSlotCount * 2
// companionQuickSlotKeys 是前段 12 格要按下的原生按键名,顺序与
// companionFrontKeyFiles 一一对应(与 legacy 路径的 quickSlotKeys 同源)。
var companionQuickSlotKeys = []string{
"a", "s", "d", "f", "g", "h", "q", "w", "e", "r", "t", "y",
}
var companionFrontKeyFiles = []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",
}
var companionDirectKeyFiles = []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",
}
// Use an existing, otherwise-unreferenced native key that contains a short
// four-hit X loop. The old mirror_priest key emits one X input and then waits
// 300 ms before the AI can choose it again, which makes a support soldier's
// basic attack look like isolated taps. Keeping this as a donor path avoids
// mutating the shared mirror_priest resource used by other APCs.
const companionBasicAttackFile = "aicharacter/_nelios/priest/corps/key/x.key"
// companionAIRefs resolves the [ai pattern] entries using the same relative
// layout used by the native PVF loader. Short names are relative to the AIC's
// sibling ai directory; fully-qualified entries are already archive paths.
func companionAIRefs(a *platformpvf.Archive, aicPath string, raw []byte) (map[string]string, error) {
ts := toks(a, raw)
start, end := section(ts, "[ai pattern]", "[/ai pattern]")
if start < 0 || end <= start {
return nil, fmt.Errorf("AIC %s has no [ai pattern] section", aicPath)
}
refs := make(map[string]string)
for i := start + 1; i < end; i++ {
t := ts[i]
if t.typ != 6 || !strings.HasSuffix(strings.ToLower(t.txt), ".ai") {
continue
}
candidate := strings.ReplaceAll(t.txt, "\\", "/")
if !strings.Contains(candidate, "/") {
candidate = pathpkg.Join(pathpkg.Dir(aicPath), "ai", candidate)
}
file, ok := a.FindFile(candidate)
if !ok {
return nil, fmt.Errorf("AIC %s references missing AI %q (resolved %s)", aicPath, t.txt, candidate)
}
canonical := strings.ReplaceAll(file.ArchivePath, "\\", "/")
base := strings.ToLower(pathpkg.Base(canonical))
switch {
case strings.Contains(base, "event") && refs["event"] == "":
refs["event"] = canonical
case strings.Contains(base, "destination") && refs["destination"] == "":
refs["destination"] = canonical
case strings.Contains(base, "action") && refs["action"] == "":
refs["action"] = canonical
case strings.Contains(base, "move") && refs["move"] == "":
refs["move"] = canonical
}
}
if refs["action"] == "" {
return nil, fmt.Errorf("AIC %s has no action AI reference", aicPath)
}
if refs["destination"] == "" {
return nil, fmt.Errorf("AIC %s has no destination AI reference", aicPath)
}
if refs["move"] == "" {
return nil, fmt.Errorf("AIC %s has no move AI reference", aicPath)
}
return refs, nil
}
func companionString(a *platformpvf.Archive, value string) []byte {
off, err := a.EnsureANSIString(value)
if err != nil {
panic(err)
}
return rawToken(6, off)
}
func companionTag(a *platformpvf.Archive, value string) []byte {
off, err := a.EnsureANSIString(value)
if err != nil {
panic(err)
}
return rawToken(3, off)
}
func companionAIThink(a *platformpvf.Archive, inner []byte) []byte {
out := append([]byte{}, companionTag(a, "[think]")...)
out = append(out, inner...)
out = append(out, companionTag(a, "[/think]")...)
return out
}
// A native think node stores one predicate and one true/false pair. A second
// predicate in the same block replaces the first node's condition/branches;
// r9 consequently loaded only its final area predicate, with both returns -1.
// Always nest a new decision rather than appending conditions to one block.
func companionBranch(a *platformpvf.Archive, condition, yes, no []byte) []byte {
body := append([]byte{}, condition...)
body = append(body, companionTag(a, "[true]")...)
body = append(body, yes...)
body = append(body, companionTag(a, "[false]")...)
body = append(body, no...)
return companionAIThink(a, body)
}
func companionYield(a *platformpvf.Archive) []byte {
return append(companionTag(a, "[return]"), rawToken(0, -1)...)
}
// The projection contract fixes quick programs at indexes 0..11. Numeric
// returns remove construction-time name lookup from action selection.
// The r11 parsing capture also showed that numeric returns require valid
// enclosing branches; an index alone cannot repair malformed think blocks.
func companionReturnSlot(a *platformpvf.Archive, slot int) []byte {
if slot < 0 || slot >= companionQuickSlotCount {
panic(fmt.Sprintf("companion action slot out of range: %d", slot))
}
return append(companionTag(a, "[return]"), rawToken(0, slot)...)
}
func companionCheckReady(a *platformpvf.Archive, key string) []byte {
out := append([]byte{}, companionTag(a, "[check key cooltime]")...)
out = append(out, companionString(a, key)...)
return out
}
// companionReadyChain returns the first key that is not cooling down. The
// native grammar reports true for a cooling key, so the true branch continues
// and the false branch emits the key. This is the same pattern used by the
// checked-in profession AIs and keeps the chain bounded.
func companionReadyChain(a *platformpvf.Archive, slots []int) []byte {
return companionReadyChainWithFallback(a, slots, companionYield(a))
}
func companionReadyChainWithFallback(a *platformpvf.Archive, slots []int, fallback []byte) []byte {
if len(slots) == 0 {
// Native 0x026224e0 reads a predicate immediately after [think].
// A return-only think exits before consuming its closing delimiter;
// r11's live capture then placed slot N in slot N-1's false branch.
// A completed chain is a branch leaf, not another decision node.
return fallback
}
slot := slots[0]
key := fmt.Sprintf("CompanionSlot%02d", slot)
var body []byte
body = append(body, companionCheckReady(a, key)...)
body = append(body, companionTag(a, "[true]")...)
body = append(body, companionReadyChainWithFallback(a, slots[1:], fallback)...)
body = append(body, companionTag(a, "[false]")...)
body = append(body, companionReturnSlot(a, slot)...)
return companionAIThink(a, body)
}
type companionBuffSpec struct {
slot int
family string
skill int
}
// These two nodes are the verified native support-selector placeholders.
// Every profession shares this grammar; the owned client selects the actual
// learned quickbar skill. These IDs are not another profession's buff IDs.
func companionBuffSpecs(job uint8) []companionBuffSpec {
return []companionBuffSpec{{1, "priest", 7}, {5, "priest", 53}}
}
// companionPartyBuffChain returns the first configured buff whose party
// target does not already have the corresponding native buff. The native
// grammar reports true when the buff is present, so the true branch advances
// and the false branch emits the matching quick-slot key.
func companionPartyBuffChain(a *platformpvf.Archive, specs []companionBuffSpec, fallback []byte) []byte {
if len(specs) == 0 {
return fallback
}
spec := specs[0]
key := fmt.Sprintf("CompanionSlot%02d", spec.slot)
next := companionPartyBuffChain(a, specs[1:], fallback)
pending := companionBranch(a, companionCheckReady(a, key), next, companionReturnSlot(a, spec.slot))
condition := append(companionTag(a, "[check party buff on]"), companionString(a, spec.family)...)
condition = append(condition, rawToken(0, spec.skill)...)
return companionBranch(a, condition, next, pending)
}
func companionAreaGate(a *platformpvf.Archive, x, y, halfX, halfY float32) []byte {
var out []byte
out = append(out, companionTag(a, "[void]")...)
out = append(out, companionString(a, "is target in attack area()")...)
out = append(out, rawFloat(x)...)
out = append(out, rawFloat(y)...)
out = append(out, rawFloat(halfX)...)
out = append(out, rawFloat(halfY)...)
return out
}
func companionEnemyCountGate(a *platformpvf.Archive, x, y, halfX, halfY float32, minimum int) []byte {
var out []byte
out = append(out, companionTag(a, "[void]")...)
out = append(out, companionString(a, "enemy number of in attack area()")...)
out = append(out, rawFloat(x)...)
out = append(out, rawFloat(y)...)
out = append(out, rawFloat(halfX)...)
out = append(out, rawFloat(halfY)...)
out = append(out, companionTag(a, "[think]")...)
out = append(out, companionTag(a, "[last result]")...)
out = append(out, companionString(a, ">=")...)
out = append(out, rawFloat(float32(minimum))...)
return out
}
func companionCountBranch(a *platformpvf.Archive, x, y float32, minimum int, yes, no []byte) []byte {
body := companionEnemyCountGate(a, x, y, x/2, y/2, minimum)
body = append(body, companionTag(a, "[true]")...)
body = append(body, yes...)
body = append(body, companionTag(a, "[false]")...)
body = append(body, no...)
body = append(body, companionTag(a, "[/think]")...)
return companionAIThink(a, body)
}
func companionBasicCombatAI(a *platformpvf.Archive) []byte {
yield := companionYield(a)
basicReturn := append(companionTag(a, "[return]"), rawToken(0, companionBasicAttackIndex)...)
ready := companionBranch(a, companionCheckReady(a, "CompanionBasicAttack"), yield, basicReturn)
// Native 2495960 skips movement and destination selection whenever an
// action is accepted. The r18 zero-cooldown fallback kept accepting x
// from the 880-wide spell area, so the APC never approached its enemy.
// Check both the selected target's melee area and nearby enemy count:
// a stale target alone must not keep emitting attacks in an empty room.
near := companionBranch(a, companionAreaGate(a, 200, 120, 100, 60), ready, yield)
return companionCountBranch(a, 200, 120, 1, near, yield)
}
func companionAIHeader(a *platformpvf.Archive) []byte {
return companionTag(a, "[ai pattern]")
}
func companionAIFooter(a *platformpvf.Archive) []byte {
return companionTag(a, "[/ai pattern]")
}
// The owned native selector consumes this state-0 predicate as the per-source
// mode switch. The original state-4 action guard still protects active casts.
func companionModeGate(a *platformpvf.Archive, assist, aggressive []byte) []byte {
mode := append(companionTag(a, "[my state]"), rawToken(0, 0)...)
return companionBranch(a, mode, assist, aggressive)
}
func buildCompanionDestinationAI(a *platformpvf.Archive, profile dnfcompanion.AIProfile, job uint8) []byte {
friend := append(companionTag(a, "[set friend character]"), rawToken(0, 0)...)
follow := companionBranch(a, friend, append(companionTag(a, "[return]"), rawToken(0, 5)...), companionYield(a))
target := append(companionTag(a, "[target state]"), rawFloat(4)...)
fight := companionBranch(a, target, append(companionTag(a, "[return]"), rawToken(0, 1)...), append(companionTag(a, "[return]"), rawToken(0, 0)...))
out := append(companionAIHeader(a), companionModeGate(a, follow, fight)...)
return append(out, companionAIFooter(a)...)
}
func buildCompanionMoveAI(a *platformpvf.Archive, profile dnfcompanion.AIProfile, job uint8) []byte {
tuning := dnfcompanion.EffectiveAITuning(dnfcompanion.NormalizeAIProfile(profile).Tuning)
// Leader or enemy: stop close to the selected actor. ChaseRange controls
// skill engagement, never a stand-off distance that prevents melee.
makeMove := func() []byte {
condition := append(companionTag(a, "[target distance]"), companionString(a, ">=")...)
condition = append(condition, rawFloat(float32(tuning.FollowDistance))...)
return companionBranch(a, condition, append(companionTag(a, "[return]"), rawFloat(1)...), append(companionTag(a, "[return]"), rawFloat(0)...))
}
out := append(companionAIHeader(a), companionModeGate(a, makeMove(), makeMove())...)
return append(out, companionAIFooter(a)...)
}
// The stock event scripts refer to profession-specific key names (combo1,
// elemental, ...). Once the AIC key stream is replaced by the profile's
// CompanionSlot contract those names no longer exist. Returning -1 keeps
// attack-success events from bypassing the profile action state machine.
func buildCompanionEventAI(a *platformpvf.Archive, profiles ...dnfcompanion.AIProfile) []byte {
profile := dnfcompanion.DefaultAIProfile(4)
if len(profiles) != 0 {
profile = dnfcompanion.NormalizeAIProfile(profiles[0])
}
tuning := dnfcompanion.EffectiveAITuning(profile.Tuning)
// This remains a no-op event. The owned client selector also evaluates
// this native predicate after selecting the party leader, so healing uses
// the game's current HP percentage rather than a stale server snapshot.
hp := append(companionTag(a, "[party member hp]"), companionString(a, "%<")...)
hp = append(hp, rawFloat(float32(tuning.HealLeaderPercent))...)
decision := companionBranch(a, hp, companionYield(a), companionYield(a))
out := append(companionAIHeader(a), decision...)
return append(out, companionAIFooter(a)...)
}
func buildCompanionActionAI(a *platformpvf.Archive, profile dnfcompanion.AIProfile, job uint8) []byte {
profile = dnfcompanion.NormalizeAIProfile(profile)
yield := companionYield(a)
state := append(companionTag(a, "[my state]"), rawToken(0, 4)...)
party := append(companionTag(a, "[set party member]"), rawToken(0, 1)...)
var decision []byte
switch strings.ToLower(strings.TrimSpace(profile.ComboPreset)) {
case "probe_slot1":
decision = companionBranch(a, state, companionReturnSlot(a, 1), companionReturnSlot(a, 1))
case "assist_cooldown_probe":
ready := companionBranch(a, companionCheckReady(a, "CompanionSlot01"), yield,
companionReturnSlot(a, 1))
decision = companionBranch(a, state, yield, companionBranch(a, party, ready, yield))
default:
tuning := dnfcompanion.EffectiveAITuning(profile.Tuning)
attacks := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}
// Descending chain is the serialized AoE-lane contract. The owned
// client validates/binds it and skips skills without an area role.
areaSlots := []int{11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0}
rangeX := tuning.ChaseRange * 2
if rangeX < 200 {
rangeX = 200
}
rangeY := 200
rotation := companionReadyChainWithFallback(a, attacks, companionBasicCombatAI(a))
single := companionBranch(a, companionAreaGate(a, float32(rangeX), float32(rangeY), float32(rangeX/2), float32(rangeY/2)), rotation, yield)
crowd := companionCountBranch(a, float32(rangeX), float32(rangeY), profile.AOEMinTargets,
companionReadyChainWithFallback(a, areaSlots, single), single)
aggressive := companionBranch(a, companionAreaGate(a, float32(rangeX), float32(rangeY), float32(rangeX/2), float32(rangeY/2)), crowd, yield)
aggressive = companionCountBranch(a, float32(rangeX), float32(rangeY), 1, aggressive, yield)
combat := companionModeGate(a, yield, aggressive)
// Always include support selection. GM mode and actual learned/bar
// state are runtime inputs; shared AI paths cannot hold per-job code.
buffs := companionPartyBuffChain(a, companionBuffSpecs(job), combat)
decision = companionBranch(a, state, yield, companionBranch(a, party, buffs, combat))
}
out := append(companionAIHeader(a), decision...)
return append(out, companionAIFooter(a)...)
}
// Reject the concrete r9 failure before repacking. Balanced delimiters alone
// did not detect predicates and branch targets overwriting each other.
func validateCompanionActionAI(a *platformpvf.Archive, raw []byte) error {
type frame struct {
predicates int
yes, no bool
}
var stack []frame
tokens := toks(a, raw)
for i, token := range tokens {
if token.typ != 3 {
continue
}
switch token.txt {
case "[return str]":
return fmt.Errorf("companion action must use fixed program indexes")
case "[return]":
if i+1 >= len(tokens) || tokens[i+1].typ != 0 || tokens[i+1].val < -1 ||
(tokens[i+1].val >= companionQuickSlotCount && tokens[i+1].val != companionBasicAttackIndex) {
return fmt.Errorf("companion action return must be -1, quick-program index 0..11, or basic attack 24")
}
if len(stack) == 0 || stack[len(stack)-1].predicates != 1 ||
(!stack[len(stack)-1].yes && !stack[len(stack)-1].no) {
return fmt.Errorf("action return must belong to a predicate's true/false branch; return-only think desynchronizes native parsing")
}
case "[think]":
stack = append(stack, frame{})
case "[/think]":
if len(stack) == 0 {
return fmt.Errorf("action AI has unmatched think close")
}
if stack[len(stack)-1].predicates != 1 {
return fmt.Errorf("action think must contain exactly one predicate")
}
stack = stack[:len(stack)-1]
case "[my state]", "[party member hp]", "[set party member]", "[check party buff on]", "[check key cooltime]", "[void]", "[last result]":
if len(stack) == 0 {
return fmt.Errorf("action predicate outside think")
}
f := &stack[len(stack)-1]
f.predicates++
if f.predicates > 1 {
return fmt.Errorf("action think contains multiple predicates; native branches would be overwritten")
}
case "[true]", "[false]":
if len(stack) == 0 {
return fmt.Errorf("action branch outside think")
}
f := &stack[len(stack)-1]
if token.txt == "[true]" {
if f.yes {
return fmt.Errorf("action think repeats true branch")
}
f.yes = true
} else {
if f.no {
return fmt.Errorf("action think repeats false branch")
}
f.no = true
}
}
}
if len(stack) != 0 {
return fmt.Errorf("action AI has unclosed think blocks")
}
return nil
}
func companionKeyStream(a *platformpvf.Archive) ([]byte, []byte, error) {
if len(companionFrontKeyFiles) != companionQuickSlotCount || len(companionDirectKeyFiles) != companionQuickSlotCount {
return nil, nil, fmt.Errorf("companion key contract has invalid slot count")
}
var stream, cooldown []byte
for i := 0; i < companionQuickSlotCount; i++ {
front, ok := a.FindFile(companionFrontKeyFiles[i])
if !ok {
return nil, nil, fmt.Errorf("missing front key %s", companionFrontKeyFiles[i])
}
name := fmt.Sprintf("CompanionSlot%02d", i)
stream = append(stream, companionString(a, name)...)
stream = append(stream, companionString(a, strings.ReplaceAll(front.ArchivePath, "\\", "/"))...)
cooldown = append(cooldown, companionString(a, name)...)
cooldown = append(cooldown, rawToken(0, 600)...)
}
// ProjectionRuntimeKeyContract addresses front programs by i and direct
// programs by 12+i. Interleaving the pairs puts a front key at index 12,
// causing direct-bytecode-shape rejection before any skill is bound.
for i := 0; i < companionQuickSlotCount; i++ {
direct, ok := a.FindFile(companionDirectKeyFiles[i])
if !ok {
return nil, nil, fmt.Errorf("missing direct key %s", companionDirectKeyFiles[i])
}
stream = append(stream, companionString(a, fmt.Sprintf("CompanionDirectPool%02d", i))...)
stream = append(stream, companionString(a, strings.ReplaceAll(direct.ArchivePath, "\\", "/"))...)
}
basic, ok := a.FindFile(companionBasicAttackFile)
if !ok {
return nil, nil, fmt.Errorf("missing basic attack key %s", companionBasicAttackFile)
}
stream = append(stream, companionString(a, "CompanionBasicAttack")...)
stream = append(stream, companionString(a, strings.ReplaceAll(basic.ArchivePath, "\\", "/"))...)
cooldown = append(cooldown, companionString(a, "CompanionBasicAttack")...)
cooldown = append(cooldown, rawToken(0, 0)...)
return stream, cooldown, nil
}
// Check the positional contract used by ApplyDynamicRuntimeKeyPrograms, not
// just the presence of the slot names. An archive can contain every name and
// still make the native client reject the entire pool if pairs are interleaved.
func validateCompanionKeyStream(a *platformpvf.Archive, raw []byte) error {
items := toks(a, raw)
start, end := section(items, "[key stream]", "[/key stream]")
if start < 0 || end-start-1 != 50 {
return fmt.Errorf("companion key stream must contain 25 name/path pairs")
}
for i := 0; i < 25; i++ {
name := "CompanionBasicAttack"
path := companionBasicAttackFile
if i < companionQuickSlotCount {
name = fmt.Sprintf("CompanionSlot%02d", i)
path = companionFrontKeyFiles[i]
} else if i < companionQuickSlotCount*2 {
name = fmt.Sprintf("CompanionDirectPool%02d", i-companionQuickSlotCount)
path = companionDirectKeyFiles[i-companionQuickSlotCount]
}
nameToken, pathToken := items[start+1+i*2], items[start+2+i*2]
if nameToken.typ != 6 || pathToken.typ != 6 || nameToken.txt != name || !strings.EqualFold(pathToken.txt, path) {
return fmt.Errorf("companion key program %d is %q/%q, want %q/%q", i, nameToken.txt, pathToken.txt, name, path)
}
}
return nil
}
func patchCompanionAICProfile(a *platformpvf.Archive, aicRaw []byte, artifact dnfcompanion.AICArtifact, profile dnfcompanion.AIProfile) ([]platformpvf.RawReplacement, error) {
refs, err := companionAIRefs(a, artifact.AICPath, aicRaw)
if err != nil {
return nil, err
}
stream, cooldown, err := companionKeyStream(a)
if err != nil {
return nil, err
}
patched := append([]byte(nil), aicRaw...)
ts := toks(a, patched)
patched = replaceSection(patched, ts, "[key stream]", "[/key stream]", stream)
ts = toks(a, patched)
patched = replaceOrInsertSection(a, patched, ts, "[key cooltime]", "[/key cooltime]", "[/key stream]", cooldown)
ts = toks(a, patched)
values, err := dnfcompanion.ReadAICArtifact(artifact.AICText)
if err != nil {
return nil, err
}
for tag, value := range values {
patched = replaceIntAfterTag(patched, toks(a, patched), tag, value)
}
replacements := []platformpvf.RawReplacement{{Path: artifact.AICPath, Data: patched}}
action := buildCompanionActionAI(a, profile, artifact.Job)
if err := validateCompanionActionAI(a, action); err != nil {
return nil, fmt.Errorf("invalid generated action %s: %w", refs["action"], err)
}
replacements = append(replacements,
platformpvf.RawReplacement{Path: refs["action"], Data: action},
platformpvf.RawReplacement{Path: refs["destination"], Data: buildCompanionDestinationAI(a, profile, artifact.Job)},
platformpvf.RawReplacement{Path: refs["move"], Data: buildCompanionMoveAI(a, profile, artifact.Job)},
)
if refs["event"] != "" {
replacements = append(replacements,
platformpvf.RawReplacement{Path: refs["event"], Data: buildCompanionEventAI(a, profile)},
)
}
return replacements, nil
}