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