DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,264 @@
package companion
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// AICArtifact is the deterministic, text representation of the part of an
// AI profile that is already expressible by the checked-in AIC grammar. It
// is deliberately separate from a packed PVF: callers may inspect and
// validate this artifact without opening or modifying a runtime archive.
//
// The Lua source remains the source of truth for the full profile. AICText
// contains only whitelisted scalar AIC tags, so unsupported Lua capabilities
// cannot accidentally turn into arbitrary PVF tokens.
type AICArtifact struct {
Path string `json:"path"`
TemplateKey string `json:"template_key"`
Job uint8 `json:"job"`
ClassID uint16 `json:"class_id"`
Behavior string `json:"behavior"`
ComboPreset string `json:"combo_preset"`
APCCode int64 `json:"apc_code"`
AICPath string `json:"aic_path"`
AICText string `json:"aic_text"`
SHA256 string `json:"sha256"`
LuaSHA256 string `json:"lua_sha256"`
StrategyStatus string `json:"strategy_status"`
MissingCapabilities []StrategyCapability `json:"missing_capabilities,omitempty"`
}
// PVFOverlayManifest is a small offline hand-off artifact. The
// companionpvf command is expected to consume Path/AICText and encode it
// with the PVF archive's string pool in a copied archive; this manifest
// itself never writes runtime/data/dnf/Script.pvf.
type PVFOverlayManifest struct {
Format string `json:"format"`
BasePVF string `json:"base_pvf"`
BaseSHA256 string `json:"base_sha256,omitempty"`
Artifacts []AICArtifact `json:"artifacts"`
ManifestHash string `json:"manifest_sha256"`
}
var aicScalarTags = []string{
"[think term]",
"[destination change term]",
"[keep distance with target]",
"[warlike]",
"[vision]",
}
var aicScalarLine = regexp.MustCompile(`^\[([^\]]+)\]\s*(-?[0-9]+)\s*$`)
// CompileAICArtifact lowers a validated profile into an inspectable AIC
// artifact. It intentionally does not claim that the artifact has been
// consumed by a native client; that claim belongs to the action-receipt gate.
func CompileAICArtifact(profile AIProfile, caps StrategyCapabilities) (AICArtifact, error) {
if !caps.ThinkTerm || !caps.Distance {
return AICArtifact{}, fmt.Errorf("AIC scalar compiler requires think_term and target_distance capabilities")
}
job := uint8(profile.ClassID)
return CompileAICArtifactForJob(profile, caps, job)
}
// CompileAICArtifactForJob is the job-aware form used by dungeon projection.
// ClassID selects the learned combo branch; job selects the native profession
// family and therefore the verified APC/AIC pair.
func CompileAICArtifactForJob(profile AIProfile, caps StrategyCapabilities, job uint8) (AICArtifact, error) {
compiled, err := CompileAIProfile(profile, caps)
if err != nil {
return AICArtifact{}, err
}
profile = NormalizeAIProfile(profile)
selector := SelectAITemplate(profile, uint16(job))
tuning := EffectiveAITuning(profile.Tuning)
lines := []string{
"[think term]", strconv.Itoa(tuning.ThinkIntervalMS),
"[destination change term]", strconv.Itoa(tuning.RetryMS),
"[keep distance with target]", strconv.Itoa(tuning.FollowDistance),
"[warlike]", strconv.Itoa(aicWarlike(profile)),
"[vision]", strconv.Itoa(tuning.VisionRange),
}
text := strings.Join(lines, "\n") + "\n"
if _, err := ReadAICArtifact(text); err != nil {
return AICArtifact{}, fmt.Errorf("generated AIC failed readback: %w", err)
}
return AICArtifact{
// Path is the real AICharacter.lst path that the PVF overlay replaces.
// TemplateKey/LuaSHA256 keep the profile identity auditable even when
// two profiles share one native profession template.
Path: selector.AICPath, TemplateKey: selector.Key,
Job: job, ClassID: selector.ClassID, Behavior: selector.Behavior,
ComboPreset: selector.ComboPreset,
APCCode: selector.APCCode, AICPath: selector.AICPath,
AICText: text,
SHA256: sha256Hex([]byte(text)),
LuaSHA256: compiled.LuaSHA256,
StrategyStatus: compiled.Status,
MissingCapabilities: append([]StrategyCapability(nil), compiled.MissingCapabilities...),
}, nil
}
func aicWarlike(profile AIProfile) int {
if profile.Behavior == "aggressive" {
return 80
}
return 0
}
func artifactSlug(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var b strings.Builder
lastDash := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "auto"
}
return out
}
// ReadAICArtifact validates and indexes a generated AIC text artifact. The
// parser accepts exactly one scalar value for each generated tag and rejects
// unknown tags, duplicate tags, malformed numbers, or non-whitespace data.
func ReadAICArtifact(text string) (map[string]int, error) {
allowed := make(map[string]struct{}, len(aicScalarTags))
for _, tag := range aicScalarTags {
allowed[tag] = struct{}{}
}
values := make(map[string]int, len(aicScalarTags))
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
for i := 0; i < len(lines); {
line := strings.TrimSpace(lines[i])
if line == "" {
i++
continue
}
match := aicScalarLine.FindStringSubmatch(line)
var tag string
var number string
if len(match) == 3 {
tag = "[" + strings.ToLower(match[1]) + "]"
number = match[2]
} else if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
tag = "[" + strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")) + "]"
if i+1 >= len(lines) || strings.TrimSpace(lines[i+1]) == "" {
return nil, fmt.Errorf("line %d: scalar tag %q has no value", i+1, tag)
}
number = strings.TrimSpace(lines[i+1])
} else {
return nil, fmt.Errorf("line %d: expected [tag] followed by an integer", i+1)
}
if _, ok := allowed[tag]; !ok {
return nil, fmt.Errorf("line %d: unsupported AIC tag %q", i+1, tag)
}
if _, ok := values[tag]; ok {
return nil, fmt.Errorf("line %d: duplicate AIC tag %q", i+1, tag)
}
value, err := strconv.Atoi(number)
if err != nil {
return nil, fmt.Errorf("line %d: invalid value: %w", i+1, err)
}
values[tag] = value
if len(match) == 3 {
i++
} else {
i += 2
}
}
if len(values) != len(aicScalarTags) {
return nil, fmt.Errorf("AIC scalar set incomplete: got %d of %d tags", len(values), len(aicScalarTags))
}
return values, nil
}
// NewPVFOverlayManifest builds a stable JSON manifest for an offline PVF
// overlay. The hash is computed over canonical JSON with ManifestHash
// cleared, making repeat compilation byte-for-byte deterministic.
func NewPVFOverlayManifest(basePVF, baseSHA256 string, artifacts []AICArtifact) (PVFOverlayManifest, error) {
if strings.TrimSpace(basePVF) == "" {
return PVFOverlayManifest{}, fmt.Errorf("base PVF path is required")
}
copyArtifacts := append([]AICArtifact(nil), artifacts...)
sort.Slice(copyArtifacts, func(i, j int) bool { return copyArtifacts[i].Path < copyArtifacts[j].Path })
manifest := PVFOverlayManifest{
Format: "dnf90-companion-pvf-overlay-v1",
BasePVF: basePVF,
BaseSHA256: baseSHA256,
Artifacts: copyArtifacts,
}
manifest.ManifestHash = manifestHash(manifest)
return manifest, nil
}
func manifestHash(manifest PVFOverlayManifest) string {
manifest.ManifestHash = ""
data, _ := json.Marshal(manifest)
return sha256Hex(data)
}
// ValidatePVFOverlayManifest checks both each AIC readback and the manifest
// hash. It is suitable for a CI/readback step after an offline artifact was
// copied to a review directory.
func ValidatePVFOverlayManifest(manifest PVFOverlayManifest) error {
if manifest.Format != "dnf90-companion-pvf-overlay-v1" {
return fmt.Errorf("unsupported overlay format %q", manifest.Format)
}
if strings.TrimSpace(manifest.BasePVF) == "" || len(manifest.Artifacts) == 0 {
return fmt.Errorf("overlay must contain base PVF and at least one AIC artifact")
}
seen := make(map[string]struct{}, len(manifest.Artifacts))
for _, artifact := range manifest.Artifacts {
if artifact.Path == "" {
return fmt.Errorf("artifact path is empty")
}
selector := AITemplateSelector{
Key: artifact.TemplateKey, Job: artifact.Job, ClassID: artifact.ClassID,
Behavior: artifact.Behavior, ComboPreset: artifact.ComboPreset,
APCCode: artifact.APCCode, AICPath: artifact.AICPath,
BaseAICPath: artifact.AICPath,
}
if err := ValidateAITemplateSelector(selector); err != nil {
return fmt.Errorf("%s: invalid template selector: %w", artifact.Path, err)
}
if !strings.EqualFold(artifact.Path, artifact.AICPath) {
return fmt.Errorf("%s: artifact path must equal AIC path %s", artifact.Path, artifact.AICPath)
}
if _, ok := seen[artifact.Path]; ok {
return fmt.Errorf("duplicate artifact path %q", artifact.Path)
}
seen[artifact.Path] = struct{}{}
if _, err := ReadAICArtifact(artifact.AICText); err != nil {
return fmt.Errorf("%s: %w", artifact.Path, err)
}
if want := sha256Hex([]byte(artifact.AICText)); artifact.SHA256 != want {
return fmt.Errorf("%s: artifact hash mismatch", artifact.Path)
}
}
if manifest.ManifestHash == "" || manifest.ManifestHash != manifestHash(manifest) {
return fmt.Errorf("manifest hash mismatch")
}
return nil
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,101 @@
package companion
import (
"encoding/json"
"strings"
"testing"
)
func TestCompileAICArtifactRoundTripsAndDiffersByBehavior(t *testing.T) {
assist := DefaultAIProfile(9)
aggressive := assist
aggressive.Behavior = "aggressive"
a, err := CompileAICArtifact(assist, DefaultStrategyCapabilities())
if err != nil {
t.Fatal(err)
}
b, err := CompileAICArtifact(aggressive, DefaultStrategyCapabilities())
if err != nil {
t.Fatal(err)
}
if a.SHA256 == b.SHA256 || a.AICText == b.AICText {
t.Fatal("assist and aggressive artifacts must differ")
}
values, err := ReadAICArtifact(a.AICText)
if err != nil {
t.Fatal(err)
}
if values["[warlike]"] != 0 {
t.Fatalf("assist warlike=%d, want 0", values["[warlike]"])
}
values, err = ReadAICArtifact(b.AICText)
if err != nil {
t.Fatal(err)
}
if values["[warlike]"] != 80 {
t.Fatalf("aggressive warlike=%d, want 80", values["[warlike]"])
}
}
func TestCompileAICArtifactUsesTimeForDestinationRetryAndRejectsMissingPrimitives(t *testing.T) {
profile := DefaultAIProfile(4)
tuning := DefaultAITuning()
tuning.RetryMS = 700
tuning.AssistLeashX = 240
profile.Tuning = &tuning
artifact, err := CompileAICArtifact(profile, DefaultStrategyCapabilities())
if err != nil {
t.Fatal(err)
}
values, err := ReadAICArtifact(artifact.AICText)
if err != nil {
t.Fatal(err)
}
if values["[destination change term]"] != 700 {
t.Fatal("destination change term must use a time value")
}
if _, err := CompileAICArtifact(profile, StrategyCapabilities{}); err == nil {
t.Fatal("missing static AIC primitives unexpectedly accepted")
}
}
func TestPVFOverlayManifestIsStableAndValidatesTampering(t *testing.T) {
profile := DefaultAIProfile(15)
artifact, err := CompileAICArtifact(profile, DefaultStrategyCapabilities())
if err != nil {
t.Fatal(err)
}
manifest, err := NewPVFOverlayManifest("runtime/data/dnf/Script.pvf", "base-hash", []AICArtifact{artifact})
if err != nil {
t.Fatal(err)
}
if err := ValidatePVFOverlayManifest(manifest); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "dnf90-companion-pvf-overlay-v1") {
t.Fatal("manifest format missing")
}
manifest.Artifacts[0].AICText = strings.Replace(manifest.Artifacts[0].AICText, "[warlike]\n0", "[warlike]\n80", 1)
if err := ValidatePVFOverlayManifest(manifest); err == nil {
t.Fatal("tampered artifact unexpectedly validated")
}
}
func TestReadAICArtifactRejectsUnknownAndDuplicateTags(t *testing.T) {
profile := DefaultAIProfile(1)
artifact, err := CompileAICArtifact(profile, DefaultStrategyCapabilities())
if err != nil {
t.Fatal(err)
}
if _, err := ReadAICArtifact(artifact.AICText + "[custom tag]\n1\n"); err == nil {
t.Fatal("unknown tag unexpectedly accepted")
}
if _, err := ReadAICArtifact(artifact.AICText + "[vision]\n1\n"); err == nil {
t.Fatal("duplicate tag unexpectedly accepted")
}
}
@@ -0,0 +1,250 @@
package companion
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strconv"
"strings"
)
// StrategyCapability is a named primitive that the offline compiler is
// allowed to consume. Keeping this list explicit prevents a Lua profile from
// injecting arbitrary AIC tags or silently claiming a missing client feature.
type StrategyCapability string
const (
CapabilityThinkTerm StrategyCapability = "think_term"
CapabilityDistance StrategyCapability = "target_distance"
CapabilityCooldown StrategyCapability = "key_cooldown"
CapabilityActionStream StrategyCapability = "action_stream"
CapabilityFriendAnchor StrategyCapability = "friend_anchor"
CapabilityPartyHP StrategyCapability = "party_hp"
CapabilityEnemyCount StrategyCapability = "enemy_count_in_area"
CapabilityLeaderFollow StrategyCapability = "leader_follow"
CapabilityLeaderBuff StrategyCapability = "leader_buff"
CapabilityActionAck StrategyCapability = "action_ack"
CapabilityTargetSticky StrategyCapability = "target_sticky"
CapabilityStuckDetour StrategyCapability = "stuck_detour"
)
// StrategyCapabilities describes the currently proven compiler/runtime
// surface. The defaults reflect the AIC tokens found in the checked-in PVF;
// the dynamic leader/action features remain intentionally false until a real
// client implementation proves them.
type StrategyCapabilities struct {
ThinkTerm bool `json:"think_term"`
Distance bool `json:"target_distance"`
Cooldown bool `json:"key_cooldown"`
ActionStream bool `json:"action_stream"`
FriendAnchor bool `json:"friend_anchor"`
PartyHP bool `json:"party_hp"`
EnemyCount bool `json:"enemy_count_in_area"`
LeaderFollow bool `json:"leader_follow"`
LeaderBuff bool `json:"leader_buff"`
ActionAck bool `json:"action_ack"`
TargetSticky bool `json:"target_sticky"`
StuckDetour bool `json:"stuck_detour"`
}
func DefaultStrategyCapabilities() StrategyCapabilities {
return StrategyCapabilities{
ThinkTerm: true, Distance: true, Cooldown: true, ActionStream: true,
FriendAnchor: true, PartyHP: true, EnemyCount: true,
LeaderFollow: false, LeaderBuff: false, ActionAck: false,
TargetSticky: false, StuckDetour: false,
}
}
func (c StrategyCapabilities) Has(cap StrategyCapability) bool {
switch cap {
case CapabilityThinkTerm:
return c.ThinkTerm
case CapabilityDistance:
return c.Distance
case CapabilityCooldown:
return c.Cooldown
case CapabilityActionStream:
return c.ActionStream
case CapabilityFriendAnchor:
return c.FriendAnchor
case CapabilityPartyHP:
return c.PartyHP
case CapabilityEnemyCount:
return c.EnemyCount
case CapabilityLeaderFollow:
return c.LeaderFollow
case CapabilityLeaderBuff:
return c.LeaderBuff
case CapabilityActionAck:
return c.ActionAck
case CapabilityTargetSticky:
return c.TargetSticky
case CapabilityStuckDetour:
return c.StuckDetour
default:
return false
}
}
type StrategyStep struct {
Name string `json:"name"`
Priority int `json:"priority"`
Value int `json:"value,omitempty"`
}
// CompiledStrategy is an offline artifact. It can be written into a
// generated AIC/PVF overlay by the companionpvf tool, but it is never marked
// as applied until a future client executor reports a real action receipt.
type CompiledStrategy struct {
Status string `json:"status"`
LuaSource string `json:"lua_source"`
LuaSHA256 string `json:"lua_sha256"`
Steps []StrategyStep `json:"steps"`
MissingCapabilities []StrategyCapability `json:"missing_capabilities,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
func (r CompiledStrategy) ReadyForStaticAIC() bool {
return r.Status == "compiled_static" || r.Status == "compiled_with_gaps"
}
// CompileAIProfile converts the high-level GM profile to a small, auditable
// strategy IR and a Lua configuration source. The IR is deliberately limited
// to known decisions; unproven dynamic features are reported as gaps instead
// of being silently treated as live behaviour.
func CompileAIProfile(profile AIProfile, caps StrategyCapabilities) (CompiledStrategy, error) {
profile = NormalizeAIProfile(profile)
if err := profile.Validate(); err != nil {
return CompiledStrategy{}, err
}
tuning := EffectiveAITuning(profile.Tuning)
steps := []StrategyStep{
{Name: "think", Priority: 100, Value: tuning.ThinkIntervalMS},
{Name: "vision", Priority: 95, Value: tuning.VisionRange},
{Name: "keep_distance", Priority: 90, Value: tuning.FollowDistance},
}
missing := make(map[StrategyCapability]struct{})
warnings := []string{}
need := func(cap StrategyCapability, step StrategyStep, warning string) {
if caps.Has(cap) {
steps = append(steps, step)
return
}
missing[cap] = struct{}{}
if warning != "" {
warnings = append(warnings, warning)
}
}
if profile.Behavior == "assist" {
need(CapabilityFriendAnchor,
StrategyStep{Name: "assist_anchor", Priority: 85, Value: tuning.AssistLeashX},
"辅助站位使用已验证的 friend anchor 近似;固定队长后方坐标仍需客户端能力。")
need(CapabilityLeaderFollow,
StrategyStep{Name: "follow_leader", Priority: 84, Value: tuning.FollowDistance},
"当前没有经过验证的队长坐标读取,未宣称实时躲在主角后方。")
if profile.BuffPolicy != AIBuffPolicyOff {
need(CapabilityLeaderBuff,
StrategyStep{Name: "buff_leader", Priority: 120},
"BUFF 优先级已保存,但当前编译器没有真实队长施法回执。")
}
if tuning.HealLeaderPercent > 0 {
need(CapabilityPartyHP,
StrategyStep{Name: "heal_leader", Priority: 118, Value: tuning.HealLeaderPercent},
"治疗阈值可编译为 party hp 条件;技能实际生效仍需 AIC/PVF 回读。")
}
steps = append(steps, StrategyStep{Name: "assist_attack_gate", Priority: 40, Value: tuning.AssistAttackIntervalMS})
} else {
if profile.BuffPolicy != AIBuffPolicyOff {
need(CapabilityActionStream,
StrategyStep{Name: "buff_first", Priority: 120},
"先 BUFF 后攻击仅能生成有序技能流,当前没有动作回执。")
}
steps = append(steps, StrategyStep{Name: "attack_rotation", Priority: 70, Value: tuning.ChaseRange})
}
if profile.ComboEnabled {
need(CapabilityCooldown,
StrategyStep{Name: "combo_rotation", Priority: 80},
"职业连招使用白名单 key stream;没有冷却原语时拒绝生成。")
}
if profile.AOEMinTargets >= 2 {
need(CapabilityEnemyCount,
StrategyStep{Name: "aoe_when_many", Priority: 105, Value: profile.AOEMinTargets},
"范围技能条件依赖已验证的区域敌人数;技能键仍由职业模板提供。")
}
if tuning.TargetStickinessMS > 0 {
need(CapabilityTargetSticky, StrategyStep{Name: "target_sticky", Priority: 60, Value: tuning.TargetStickinessMS},
"目标黏性参数已保存,当前 AIC 没有可证明的目标锁定回执。")
}
if tuning.StuckMS > 0 || tuning.DetourMS > 0 || tuning.GiveUpMS > 0 {
need(CapabilityStuckDetour, StrategyStep{Name: "stuck_detour_give_up", Priority: 30, Value: tuning.GiveUpMS},
"卡住重试、绕行和放弃目标等待客户端位置/目标状态。")
}
missingList := make([]StrategyCapability, 0, len(missing))
for cap := range missing {
missingList = append(missingList, cap)
}
sort.Slice(missingList, func(i, j int) bool { return missingList[i] < missingList[j] })
sort.Slice(steps, func(i, j int) bool {
if steps[i].Priority != steps[j].Priority {
return steps[i].Priority > steps[j].Priority
}
return steps[i].Name < steps[j].Name
})
lua := renderLuaPolicy(profile, tuning, steps)
hash := sha256.Sum256([]byte(lua))
status := "compiled_static"
if len(missingList) > 0 {
status = "compiled_with_gaps"
}
return CompiledStrategy{
Status: status, LuaSource: lua, LuaSHA256: hex.EncodeToString(hash[:]),
Steps: steps, MissingCapabilities: missingList, Warnings: warnings,
}, nil
}
func luaString(value string) string {
var b strings.Builder
b.WriteByte('"')
for _, r := range value {
switch r {
case '\\':
b.WriteString("\\\\")
case '"':
b.WriteString("\\\"")
case '\n':
b.WriteString("\\n")
case '\r':
b.WriteString("\\r")
case '\t':
b.WriteString("\\t")
default:
if r < 32 || r == 127 {
fmt.Fprintf(&b, "\\%03d", r)
} else {
b.WriteRune(r)
}
}
}
b.WriteByte('"')
return b.String()
}
func renderLuaPolicy(profile AIProfile, tuning AITuning, steps []StrategyStep) string {
var b strings.Builder
b.WriteString("-- generated by companion strategy compiler; data only\nreturn {\n")
fmt.Fprintf(&b, " version = 1, behavior = %s, class_id = %d, buff_policy = %s, combo_preset = %s, combo_enabled = %s, aoe_min_targets = %d, protect_leader = %s,\n",
luaString(profile.Behavior), profile.ClassID, luaString(profile.BuffPolicy), luaString(profile.ComboPreset), strconv.FormatBool(profile.ComboEnabled), profile.AOEMinTargets, strconv.FormatBool(profile.ProtectLeader))
fmt.Fprintf(&b, " tuning = { think_interval_ms = %d, assist_attack_interval_ms = %d, assist_leash_x = %d, assist_leash_y = %d, chase_range = %d, vision_range = %d, target_stickiness_ms = %d, heal_leader_percent = %d, heal_self_percent = %d, retry_ms = %d, support_retry_ms = %d, busy_move_ms = %d, stuck_ms = %d, detour_ms = %d, give_up_ms = %d, follow_distance = %d },\n",
tuning.ThinkIntervalMS, tuning.AssistAttackIntervalMS, tuning.AssistLeashX, tuning.AssistLeashY, tuning.ChaseRange, tuning.VisionRange, tuning.TargetStickinessMS, tuning.HealLeaderPercent, tuning.HealSelfPercent, tuning.RetryMS, tuning.SupportRetryMS, tuning.BusyMoveMS, tuning.StuckMS, tuning.DetourMS, tuning.GiveUpMS, tuning.FollowDistance)
b.WriteString(" steps = {\n")
for _, step := range steps {
fmt.Fprintf(&b, " { name = %s, priority = %d, value = %d },\n", luaString(step.Name), step.Priority, step.Value)
}
b.WriteString(" },\n}\n")
return b.String()
}
@@ -0,0 +1,96 @@
package companion
import (
"strings"
"testing"
)
func TestCompileAIProfileProducesDeterministicLuaAndReportsGaps(t *testing.T) {
profile := DefaultAIProfile(9)
one, err := CompileAIProfile(profile, DefaultStrategyCapabilities())
if err != nil {
t.Fatal(err)
}
if !one.ReadyForStaticAIC() || one.Status != "compiled_with_gaps" {
t.Fatalf("compile status=%+v", one)
}
if one.LuaSHA256 == "" || !strings.Contains(one.LuaSource, "behavior = \"assist\"") {
t.Fatalf("lua artifact missing: %+v", one)
}
two, err := CompileAIProfile(profile, DefaultStrategyCapabilities())
if err != nil {
t.Fatal(err)
}
if one.LuaSHA256 != two.LuaSHA256 || one.LuaSource != two.LuaSource {
t.Fatalf("compiler is not deterministic: %q %q", one.LuaSHA256, two.LuaSHA256)
}
for _, want := range []StrategyCapability{CapabilityLeaderFollow, CapabilityLeaderBuff, CapabilityTargetSticky, CapabilityStuckDetour} {
found := false
for _, got := range one.MissingCapabilities {
if got == want {
found = true
break
}
}
if !found {
t.Fatalf("missing capability %q not reported: %v", want, one.MissingCapabilities)
}
}
}
func TestCompileAIProfileAggressiveOrdersBuffBeforeAttack(t *testing.T) {
profile := DefaultAIProfile(1)
profile.Behavior = "aggressive"
profile.ComboPreset = "sword_guard"
result, err := CompileAIProfile(profile, StrategyCapabilities{
ThinkTerm: true, Distance: true, Cooldown: true, ActionStream: true, EnemyCount: true,
})
if err != nil {
t.Fatal(err)
}
if !result.ReadyForStaticAIC() {
t.Fatalf("status=%s missing=%v", result.Status, result.MissingCapabilities)
}
if len(result.Steps) == 0 || result.Steps[0].Name != "buff_first" {
t.Fatalf("steps=%+v", result.Steps)
}
if !strings.Contains(result.LuaSource, "combo_preset = \"sword_guard\"") {
t.Fatalf("lua preset missing: %s", result.LuaSource)
}
}
func TestAITuningRejectsUnsafeValues(t *testing.T) {
profile := DefaultAIProfile(1)
profile.Tuning = &AITuning{ThinkIntervalMS: 1}
if err := profile.Validate(); err != ErrSelection {
t.Fatalf("err=%v", err)
}
profile.Tuning = &AITuning{HealLeaderPercent: 101}
if err := profile.Validate(); err != ErrSelection {
t.Fatalf("heal err=%v", err)
}
}
func TestSelectAITemplateUsesNativeBaselineForClassAndBehavior(t *testing.T) {
assist := SelectAITemplate(DefaultAIProfile(4), 4)
aggressiveProfile := DefaultAIProfile(4)
aggressiveProfile.Behavior = "aggressive"
aggressive := SelectAITemplate(aggressiveProfile, 4)
other := SelectAITemplate(DefaultAIProfile(9), 2)
for name, selector := range map[string]AITemplateSelector{
"assist": assist, "aggressive": aggressive, "other": other,
} {
if !selector.Valid() {
t.Fatalf("%s selector invalid: %+v", name, selector)
}
}
if assist.APCCode != 20902 || assist.AICPath != "aicharacter/_bizarre/priest/mirror_priest/mirror_priest.aic" {
t.Fatalf("priest assist did not use native baseline: %+v", assist)
}
if aggressive.APCCode != assist.APCCode || aggressive.AICPath != assist.AICPath {
t.Fatalf("behavior selectors must patch the same native baseline: assist=%+v aggressive=%+v", assist, aggressive)
}
if assist.APCCode == other.APCCode || assist.AICPath == other.AICPath {
t.Fatalf("class selectors aliased: assist=%+v other=%+v", assist, other)
}
}
@@ -0,0 +1,278 @@
package companion
import (
"errors"
"reflect"
"strings"
"sync"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/repository"
)
// Synthetic fixtures test policies only, not any real DNF job/skill ABI.
func fixture(t *testing.T) (repository.CompanionSourceState, Selection, Profile) {
t.Helper()
data := repository.CompanionSourceState{
Owner: repository.CharacterRecord{CharacterID: "1", AccountID: "account-test"},
Source: repository.CharacterRecord{CharacterID: "2", AccountID: "account-test", Name: "Fixture", Job: "0", Level: 40, Stats: map[string]int64{"grow_type": 1}},
Equipment: repository.EquipmentRecord{CharacterID: "2", Entries: map[string]repository.EquipmentEntry{
"weapon": {SlotIndex: 12, ItemID: 10000, RawEntry: []byte{1, 2}, Extra: map[string]string{"test": "unchanged"}},
}},
Skill: repository.SkillRecord{CharacterID: "2", Skills: map[int64]repository.SkillState{
10: {Level: 3, Enabled: true}, 20: {Level: 7, Enabled: true}, 30: {Level: 4, Enabled: false},
}, Cooldowns: map[int64]time.Time{10: time.Unix(100, 0)}}, HasEquipment: true, HasSkill: true,
}
s := Selection{AccountID: "account-test", OwnerCharacterID: "1", SourceCharacterID: "2", OnlineStateKnown: true,
ClientSHA256: strings.Repeat("a", 64), PVFSHA256: strings.Repeat("b", 64)}
p := Profile{ID: "synthetic-policy-test-only", Version: 1, EvidenceID: "unit-test-not-native-evidence",
ClientSHA256: s.ClientSHA256, PVFSHA256: s.PVFSHA256, Job: 0, GrowType: 1,
BasicAttackVerified: true, SkillLevelLimits: map[int64]int{10: 5}}
return data, s, p
}
func prepared(t *testing.T) Snapshot {
t.Helper()
data, s, p := fixture(t)
snapshot, err := Prepare(data, s, p, time.Unix(1000, 0))
if err != nil {
t.Fatal(err)
}
return snapshot
}
func runKey() RunKey {
return RunKey{Instance: "test-instance", SessionGeneration: 1, OwnerCharacterID: "1", LifecycleToken: 2, SpawnSequence: 1, Nonce: strings.Repeat("c", 64)}
}
func TestCompanionProductionProfilesFailClosed(t *testing.T) {
if len(VerifiedProfiles()) != 0 {
t.Fatal("unverified native profile advertised")
}
data, s, p := fixture(t)
p.BasicAttackVerified = false
if _, err := Prepare(data, s, p, time.Unix(1000, 0)); !errors.Is(err, ErrProfile) {
t.Fatal(err)
}
}
func TestCompanionConfigRevisionAndDisable(t *testing.T) {
c := DefaultConfig()
if _, err := NextConfig(c, 1, true, "2", "1", nil); !errors.Is(err, ErrRevision) {
t.Fatal(err)
}
if _, err := NextConfig(c, 0, true, "1", "1", nil); !errors.Is(err, ErrSelf) {
t.Fatal(err)
}
if _, err := NextConfig(c, 0, true, "2", "1", ErrProfile); !errors.Is(err, ErrProfile) {
t.Fatal(err)
}
next, err := NextConfig(c, 0, false, "2", "1", ErrProfile)
if err != nil || next.Enabled || next.Revision != 1 || c.Revision != 0 {
t.Fatalf("%+v %v", next, err)
}
c.Revision = ^uint64(0)
if _, err := NextConfig(c, c.Revision, false, "", "1", nil); !errors.Is(err, ErrRevision) {
t.Fatal(err)
}
}
func TestCompanionInvalidCharacterIDs(t *testing.T) {
for _, id := range []string{"0", "01", " 2", "65536", "-1", "2.0", ""} {
t.Run(id, func(t *testing.T) {
if validCharacterID(id) {
t.Fatal("accepted invalid ID")
}
})
}
}
func TestCompanionSelectionRejections(t *testing.T) {
tests := []struct {
name string
edit func(*repository.CompanionSourceState, *Selection, *Profile)
want error
}{
{"self", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { s.SourceCharacterID = "1" }, ErrSelf},
{"other-account", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { d.Source.AccountID = "another" }, ErrAccount},
{"deleted", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { d.Source.Stats["delete_flag"] = 1 }, ErrSource},
{"online", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { s.SourceOnline = true }, ErrOnline},
{"online-unknown", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { s.OnlineStateKnown = false }, ErrOnlineUnknown},
{"client-version", func(d *repository.CompanionSourceState, s *Selection, p *Profile) {
s.ClientSHA256 = strings.Repeat("d", 64)
}, ErrAssets},
{"job", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { d.Source.Job = "3" }, ErrProfile},
{"missing-grow", func(d *repository.CompanionSourceState, s *Selection, p *Profile) {
delete(d.Source.Stats, "grow_type")
}, ErrProfile},
{"missing-skills", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { d.HasSkill = false }, ErrSnapshot},
{"cross-equipment", func(d *repository.CompanionSourceState, s *Selection, p *Profile) { d.Equipment.CharacterID = "3" }, ErrSnapshot},
{"duplicate-slot", func(d *repository.CompanionSourceState, s *Selection, p *Profile) {
d.Equipment.Entries["duplicate"] = d.Equipment.Entries["weapon"]
}, ErrSnapshot},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
data, s, p := fixture(t)
tc.edit(&data, &s, &p)
if _, err := Prepare(data, s, p, time.Unix(1000, 0)); !errors.Is(err, tc.want) {
t.Fatalf("got %v want %v", err, tc.want)
}
})
}
}
func TestCompanionSnapshotDetachedAndStable(t *testing.T) {
data, s, p := fixture(t)
a, err := Prepare(data, s, p, time.Unix(1000, 0))
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(a.Skills, []SkillSpec{{ID: 10, Level: 3}}) {
t.Fatal(a.Skills)
}
b, err := Prepare(data, s, p, time.Unix(1000, 0))
if err != nil || a.Fingerprint() != b.Fingerprint() {
t.Fatal("unstable fingerprint")
}
a.Equipment[0].ItemID = 123
a.Skills[0].Level = 1
if data.Equipment.Entries["weapon"].ItemID != 10000 || data.Skill.Skills[10].Level != 3 {
t.Fatal("source mutation")
}
data.Source.Name = "changed"
data.Skill.Skills[10] = repository.SkillState{Level: 5, Enabled: true}
if b.Name != "Fixture" || b.Skills[0].Level != 3 {
t.Fatal("snapshot mutation")
}
}
func TestCompanionEmptySnapshotClonePreservesFingerprint(t *testing.T) {
data, s, p := fixture(t)
data.Equipment.Entries = map[string]repository.EquipmentEntry{}
data.Skill.Skills = nil
a, err := Prepare(data, s, p, time.Unix(1000, 0))
if err != nil {
t.Fatal(err)
}
if a.Fingerprint() != a.Clone().Fingerprint() {
t.Fatal("empty-list clone changed content hash")
}
}
func TestCompanionExpiredEquipmentExcluded(t *testing.T) {
data, s, p := fixture(t)
gear := data.Equipment.Entries["weapon"]
gear.ExpireAt = time.Unix(999, 0)
data.Equipment.Entries["weapon"] = gear
a, err := Prepare(data, s, p, time.Unix(1000, 0))
if err != nil || len(a.Equipment) != 0 {
t.Fatalf("%+v %v", a, err)
}
}
func TestCompanionLifecycleFullRun(t *testing.T) {
key := runKey()
source := prepared(t)
i, err := NewInstance(key, source, 3, 1)
if err != nil {
t.Fatal(err)
}
source.Name = "external change"
event := func(seq uint64, kind string, room uint64) Event {
return Event{Run: key, Sequence: seq, Kind: kind, RoomGeneration: room, ObjectKey: 24, ObjectGeneration: 7}
}
apply := func(e Event) {
t.Helper()
if err := i.Apply(e, map[uint16]bool{1: true, 400: true}); err != nil {
t.Fatal(e.Kind, err)
}
}
apply(event(1, "room_ready", 1))
apply(event(2, "native_created", 1))
if i.Owns(key, 24, 7) {
t.Fatal("owner allowed before binding")
}
if err := i.Apply(event(3, "activate", 1), nil); !errors.Is(err, ErrTransition) {
t.Fatal(err)
}
apply(event(3, "owner_bound", 1))
apply(event(4, "activate", 1))
apply(event(4, "activate", 1))
if !i.Owns(key, 24, 7) || i.Owns(key, 24, 8) {
t.Fatal("owner generation validation")
}
apply(event(5, "room_leaving", 1))
apply(event(6, "room_ready", 2))
if i.Status().ObjectKey != 24 || i.Snapshot().Name != "Fixture" || i.Status().ConfigRevision != 3 {
t.Fatal("cross-room instance replaced")
}
apply(event(7, "dead", 2))
if err := i.Apply(event(8, "activate", 2), nil); !errors.Is(err, ErrTransition) {
t.Fatal("dead actor revived", err)
}
if !i.Owns(key, 24, 7) {
t.Fatal("in-flight ownership lost prematurely")
}
apply(event(8, "retire", 2))
apply(event(9, "disposed", 2))
if i.Owns(key, 24, 7) {
t.Fatal("disposed owner retained")
}
if err := i.Apply(event(10, "room_ready", 3), nil); err == nil {
t.Fatal("disposed run resurrected")
}
}
func TestCompanionLifecycleRejectsStaleAndCollision(t *testing.T) {
key := runKey()
i, err := NewInstance(key, prepared(t), 1, 1)
if err != nil {
t.Fatal(err)
}
stale := key
stale.LifecycleToken++
if err := i.Apply(Event{Run: stale, Sequence: 1, Kind: "room_ready", RoomGeneration: 1}, nil); !errors.Is(err, ErrStaleRun) {
t.Fatal(err)
}
if err := i.Apply(Event{Run: key, Sequence: 1, Kind: "room_ready", RoomGeneration: 1}, nil); err != nil {
t.Fatal(err)
}
for _, n := range []uint16{0, 1, 400, 65535} {
e := Event{Run: key, Sequence: 2, Kind: "native_created", RoomGeneration: 1, ObjectKey: n, ObjectGeneration: 1}
if err := i.Apply(e, map[uint16]bool{400: true}); !errors.Is(err, ErrCollision) {
t.Fatalf("key %d: %v", n, err)
}
}
if i.Status().LastSequence != 1 {
t.Fatal("rejected event advanced ledger")
}
if err := i.Apply(Event{Run: key, Sequence: 1, Kind: "skip"}, nil); !errors.Is(err, ErrTransition) {
t.Fatal("sequence reused")
}
e := Event{Run: key, Sequence: 2, Kind: "native_created", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 1}
if err := i.Apply(e, nil); !errors.Is(err, ErrCollision) {
t.Fatal("missing collision evidence accepted")
}
}
func TestCompanionLifecycleConcurrentDuplicates(t *testing.T) {
key := runKey()
i, err := NewInstance(key, prepared(t), 1, 1)
if err != nil {
t.Fatal(err)
}
e := Event{Run: key, Sequence: 1, Kind: "room_ready", RoomGeneration: 1}
var wg sync.WaitGroup
for n := 0; n < 32; n++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := i.Apply(e, nil); err != nil {
t.Error(err)
}
_ = i.Snapshot()
_ = i.Status()
}()
}
wg.Wait()
if i.Status().LastSequence != 1 || i.Status().Phase != Ready {
t.Fatal(i.Status())
}
clone := i.Snapshot()
clone.Equipment[0].ItemID = 12
if i.Snapshot().Equipment[0].ItemID == 12 {
t.Fatal("status query mutated run")
}
}
@@ -0,0 +1,205 @@
package companion
import "sync"
type RunKey struct {
Instance string
SessionGeneration uint64
OwnerCharacterID string
LifecycleToken uint64
SpawnSequence uint64
Nonce string
}
func (k RunKey) valid() bool {
return validIdentity(k.Instance) && k.SessionGeneration > 0 && validCharacterID(k.OwnerCharacterID) &&
k.LifecycleToken > 0 && k.SpawnSequence > 0 && validHash(k.Nonce)
}
type Phase string
const (
Prepared Phase = "prepared"
Ready Phase = "room_ready"
Created Phase = "spawned_inactive"
Bound Phase = "owner_bound"
Active Phase = "active"
Transitioning Phase = "transitioning"
Dead Phase = "dead"
Retiring Phase = "retiring"
Disposed Phase = "disposed"
Skipped Phase = "skipped"
)
type Event struct {
Run RunKey
Sequence uint64
Kind string
RoomGeneration uint64
ObjectKey uint16
ObjectGeneration uint64
}
type Status struct {
Run RunKey
Phase Phase
ConfigRevision uint64
SnapshotHash string
LastSequence uint64
RoomGeneration uint64
ObjectKey uint16
ObjectGeneration uint64
OwnerBound bool
}
// Instance is an isolated coordinator, not a client message handler.
// Callers must authenticate events and hold the real runtime/object admission
// boundary. An object-confirmation event is never proof of native creation by
// itself. No network endpoint or game callback currently invokes this type.
type Instance struct {
mu sync.Mutex
status Status
snapshot Snapshot
last Event
playerObjectKey uint16
}
func NewInstance(key RunKey, snapshot Snapshot, configRevision uint64, playerObjectKey uint16) (*Instance, error) {
if !key.valid() || playerObjectKey == 0 || playerObjectKey == 65535 {
return nil, ErrStaleRun
}
if snapshot.SchemaVersion != 1 || snapshot.OwnerCharacterID != key.OwnerCharacterID ||
!validCharacterID(snapshot.SourceCharacterID) || snapshot.SourceCharacterID == key.OwnerCharacterID ||
snapshot.ProfileID == "" || !validHash(snapshot.ClientSHA256) || !validHash(snapshot.PVFSHA256) {
return nil, ErrSnapshot
}
frozen := snapshot.Clone()
return &Instance{status: Status{Run: key, Phase: Prepared, ConfigRevision: configRevision,
SnapshotHash: frozen.Fingerprint()}, snapshot: frozen, playerObjectKey: playerObjectKey}, nil
}
func (i *Instance) Status() Status { i.mu.Lock(); defer i.mu.Unlock(); return i.status }
func (i *Instance) Snapshot() Snapshot { i.mu.Lock(); defer i.mu.Unlock(); return i.snapshot.Clone() }
// occupied contains other actors only, freshly inspected by the future bridge.
// nil means collision evidence missing and rejects a first native creation.
// Successful repeated messages are no-ops; a reused sequence with new content
// is an error. No transition can create another APC or respawn a dead one.
func (i *Instance) Apply(e Event, occupied map[uint16]bool) error {
i.mu.Lock()
defer i.mu.Unlock()
s := i.status
if e.Run != s.Run {
return ErrStaleRun
}
if e.Sequence == 0 {
return ErrTransition
}
if e.Sequence == s.LastSequence && e == i.last {
return nil
}
if e.Sequence <= s.LastSequence {
return ErrTransition
}
switch e.Kind {
case "room_ready":
if s.Phase != Prepared && s.Phase != Transitioning {
return ErrTransition
}
if e.RoomGeneration <= s.RoomGeneration {
return ErrRoom
}
s.RoomGeneration = e.RoomGeneration
if s.Phase == Prepared {
s.Phase = Ready
} else {
s.Phase = Active
}
case "native_created":
if (s.Phase != Ready && s.Phase != Retiring) || e.RoomGeneration != s.RoomGeneration {
return ErrRoom
}
// A native create may finish after cancellation. Track it for exact
// cleanup, but never authorize it or replace an existing actor.
if s.Phase == Retiring && s.ObjectKey != 0 {
return ErrCollision
}
if occupied == nil || e.ObjectKey == 0 || e.ObjectKey == 65535 || e.ObjectKey == i.playerObjectKey || occupied[e.ObjectKey] || e.ObjectGeneration == 0 {
return ErrCollision
}
s.ObjectKey = e.ObjectKey
s.ObjectGeneration = e.ObjectGeneration
if s.Phase == Ready {
s.Phase = Created
}
case "owner_bound", "activate":
if e.ObjectKey != s.ObjectKey || e.ObjectGeneration != s.ObjectGeneration || e.RoomGeneration != s.RoomGeneration {
return ErrCollision
}
if e.Kind == "owner_bound" {
if s.Phase != Created {
return ErrTransition
}
s.Phase = Bound
s.OwnerBound = true
} else {
if s.Phase != Bound {
return ErrTransition
}
s.Phase = Active
}
case "room_leaving":
if s.Phase != Active || e.RoomGeneration != s.RoomGeneration {
return ErrTransition
}
s.Phase = Transitioning
case "dead":
if s.Phase != Active && s.Phase != Transitioning {
return ErrTransition
}
if e.ObjectKey != s.ObjectKey || e.ObjectGeneration != s.ObjectGeneration {
return ErrCollision
}
s.Phase = Dead
case "retire":
if s.Phase == Disposed || s.Phase == Skipped {
return ErrTransition
}
s.Phase = Retiring
case "disposed":
if s.Phase != Retiring {
return ErrTransition
}
// The bridge must also prove the native operation/callbacks drained;
// a missing room manager is not proof that the actor was destroyed.
if e.ObjectKey != s.ObjectKey || e.ObjectGeneration != s.ObjectGeneration {
return ErrCollision
}
if e.RoomGeneration != s.RoomGeneration {
return ErrRoom
}
s.Phase = Disposed
s.OwnerBound = false
case "skip":
if s.Phase != Prepared && s.Phase != Ready {
return ErrTransition
}
s.Phase = Skipped
default:
return ErrTransition
}
s.LastSequence = e.Sequence
i.status = s
i.last = e
return nil
}
// Retiring/dead owners remain valid for already-in-flight combat. The bridge
// must validate targets and safely drain reports before reporting disposed.
// Object generation is internal evidence; no existing game wire is changed.
func (i *Instance) Owns(run RunKey, objectKey uint16, generation uint64) bool {
i.mu.Lock()
defer i.mu.Unlock()
s := i.status
return run == s.Run && s.OwnerBound && objectKey != 0 && objectKey == s.ObjectKey && generation == s.ObjectGeneration
}
@@ -0,0 +1,114 @@
package companion
import "testing"
func retirementInstance(t *testing.T, withActor bool) (*Instance, RunKey) {
t.Helper()
key := runKey()
i, err := NewInstance(key, prepared(t), 1, 1)
if err != nil {
t.Fatal(err)
}
if err := i.Apply(Event{Run: key, Sequence: 1, Kind: "room_ready", RoomGeneration: 1}, nil); err != nil {
t.Fatal(err)
}
if withActor {
for n, kind := range []string{"native_created", "owner_bound", "activate"} {
if err := i.Apply(Event{Run: key, Sequence: uint64(n + 2), Kind: kind, RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}, map[uint16]bool{}); err != nil {
t.Fatal(err)
}
}
}
seq := i.Status().LastSequence + 1
if err := i.Apply(Event{Run: key, Sequence: seq, Kind: "retire", RoomGeneration: 1}, nil); err != nil {
t.Fatal(err)
}
return i, key
}
func TestCompanionDisposedRequiresExactActorAndRoom(t *testing.T) {
i, key := retirementInstance(t, true)
before := i.Status()
valid := Event{Run: key, Sequence: before.LastSequence + 1, Kind: "disposed", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}
for _, field := range []string{"key", "generation", "room", "empty"} {
e := valid
switch field {
case "key":
e.ObjectKey++
case "generation":
e.ObjectGeneration++
case "room":
e.RoomGeneration++
case "empty":
e.ObjectKey = 0
e.ObjectGeneration = 0
}
if err := i.Apply(e, nil); err == nil {
t.Fatalf("accepted mismatched %s disposal", field)
}
if i.Status() != before || !i.Owns(key, 24, 7) {
t.Fatalf("invalid disposal revoked owner: %s", field)
}
}
if err := i.Apply(valid, nil); err != nil {
t.Fatal(err)
}
if i.Owns(key, 24, 7) || i.Status().Phase != Disposed {
t.Fatal("valid disposal did not close")
}
}
func TestCompanionLateCreationTracksCleanupWithoutAuthorization(t *testing.T) {
i, key := retirementInstance(t, false)
e := Event{Run: key, Sequence: 3, Kind: "native_created", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}
if err := i.Apply(e, map[uint16]bool{}); err != nil {
t.Fatal("late creation lost:", err)
}
if i.Status().Phase != Retiring || i.Owns(key, 24, 7) {
t.Fatal("late creation resurrected cancelled run")
}
if err := i.Apply(e, map[uint16]bool{}); err != nil {
t.Fatal("exact duplicate should be idempotent", err)
}
if err := i.Apply(Event{Run: key, Sequence: 4, Kind: "owner_bound", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}, nil); err == nil {
t.Fatal("late actor authorized")
}
if err := i.Apply(Event{Run: key, Sequence: 4, Kind: "disposed", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}, nil); err != nil {
t.Fatal(err)
}
if i.Status().Phase != Disposed {
t.Fatal(i.Status())
}
}
func TestCompanionLateCreationCannotReplaceExistingActor(t *testing.T) {
i, key := retirementInstance(t, true)
before := i.Status()
e := Event{Run: key, Sequence: before.LastSequence + 1, Kind: "native_created", RoomGeneration: 1, ObjectKey: 25, ObjectGeneration: 8}
if err := i.Apply(e, map[uint16]bool{}); err == nil {
t.Fatal("replaced retiring actor")
}
if i.Status() != before {
t.Fatal("rejected late creation changed state")
}
}
func TestCompanionCancellationWithoutActorRequiresEmptyDisposal(t *testing.T) {
i, key := retirementInstance(t, false)
before := i.Status()
e := Event{Run: key, Sequence: 3, Kind: "disposed", RoomGeneration: 1, ObjectKey: 24, ObjectGeneration: 7}
if err := i.Apply(e, nil); err == nil {
t.Fatal("undisclosed actor disposal accepted")
}
if i.Status() != before {
t.Fatal("bad disposal changed state")
}
e.ObjectKey = 0
e.ObjectGeneration = 0
if err := i.Apply(e, nil); err != nil {
t.Fatal(err)
}
if i.Status().Phase != Disposed {
t.Fatal(i.Status())
}
}
@@ -0,0 +1,567 @@
// Package companion owns detached policies, not native APC calls.
// No production activation is provided until the native contract is verified.
package companion
import (
"strconv"
"strings"
)
type ErrorCode string
func (e ErrorCode) Error() string { return string(e) }
const (
ErrSelection ErrorCode = "INVALID_SELECTION"
ErrSelf ErrorCode = "SELF_SELECTED"
ErrAccount ErrorCode = "ACCOUNT_MISMATCH"
ErrSource ErrorCode = "SOURCE_UNAVAILABLE"
ErrOnlineUnknown ErrorCode = "ONLINE_STATE_UNKNOWN"
ErrOnline ErrorCode = "SOURCE_ONLINE"
ErrProfile ErrorCode = "PROFILE_UNSUPPORTED"
ErrAssets ErrorCode = "ASSET_MISMATCH"
ErrSnapshot ErrorCode = "INVALID_SNAPSHOT"
ErrStaleRun ErrorCode = "STALE_RUN"
ErrTransition ErrorCode = "INVALID_TRANSITION"
ErrCollision ErrorCode = "OWNER_COLLISION"
ErrRoom ErrorCode = "ROOM_NOT_READY"
ErrRevision ErrorCode = "REVISION_CONFLICT"
)
func validCharacterID(value string) bool {
n, err := strconv.ParseUint(value, 10, 16)
return err == nil && n != 0 && strconv.FormatUint(n, 10) == value
}
func validIdentity(value string) bool {
if value == "" || len(value) > 128 || strings.TrimSpace(value) != value {
return false
}
for _, r := range value {
if r < 32 || r == 127 {
return false
}
}
return true
}
type Config struct {
SchemaVersion int `json:"schema_version"`
Revision uint64 `json:"revision"`
Enabled bool `json:"enabled"`
BalanceEnabled bool `json:"balance_enabled"`
ProgressionEnabled bool `json:"progression_enabled"`
RandomEquipmentEnabled bool `json:"random_equipment_enabled"`
AI AIConfig `json:"ai"`
AIProfiles map[string]AIProfile `json:"ai_profiles,omitempty"`
SourceCharacterID string `json:"source_character_id,omitempty"`
SourceCharacterIDs []string `json:"source_character_ids,omitempty"`
}
// AITuning contains the bounded, user-facing timing and distance controls
// used by the Lua-to-AIC strategy compiler. It intentionally contains no
// raw skill ids or arbitrary PVF tokens; those are selected by the
// profession compiler from a verified allow-list.
//
// A nil tuning pointer keeps old settings rows byte-compatible. Callers that
// need effective values should use EffectiveAITuning, which supplies the
// safe defaults below.
type AITuning struct {
ThinkIntervalMS int `json:"think_interval_ms,omitempty"`
AssistAttackIntervalMS int `json:"assist_attack_interval_ms,omitempty"`
AssistLeashX int `json:"assist_leash_x,omitempty"`
AssistLeashY int `json:"assist_leash_y,omitempty"`
ChaseRange int `json:"chase_range,omitempty"`
VisionRange int `json:"vision_range,omitempty"`
TargetStickinessMS int `json:"target_stickiness_ms,omitempty"`
HealLeaderPercent int `json:"heal_leader_percent,omitempty"`
HealSelfPercent int `json:"heal_self_percent,omitempty"`
RetryMS int `json:"retry_ms,omitempty"`
SupportRetryMS int `json:"support_retry_ms,omitempty"`
BusyMoveMS int `json:"busy_move_ms,omitempty"`
StuckMS int `json:"stuck_ms,omitempty"`
DetourMS int `json:"detour_ms,omitempty"`
GiveUpMS int `json:"give_up_ms,omitempty"`
FollowDistance int `json:"follow_distance,omitempty"`
}
func DefaultAITuning() AITuning {
return AITuning{
ThinkIntervalMS: 120,
AssistAttackIntervalMS: 1800,
AssistLeashX: 180,
AssistLeashY: 90,
ChaseRange: 50,
VisionRange: 10000,
TargetStickinessMS: 1200,
HealLeaderPercent: 85,
HealSelfPercent: 85,
RetryMS: 300,
SupportRetryMS: 1500,
BusyMoveMS: 2500,
StuckMS: 700,
DetourMS: 900,
GiveUpMS: 3500,
FollowDistance: 75,
}
}
func EffectiveAITuning(t *AITuning) AITuning {
if t == nil {
return DefaultAITuning()
}
out := *t
defaults := DefaultAITuning()
if out.ThinkIntervalMS == 0 {
out.ThinkIntervalMS = defaults.ThinkIntervalMS
}
if out.AssistAttackIntervalMS == 0 {
out.AssistAttackIntervalMS = defaults.AssistAttackIntervalMS
}
if out.AssistLeashX == 0 {
out.AssistLeashX = defaults.AssistLeashX
}
if out.AssistLeashY == 0 {
out.AssistLeashY = defaults.AssistLeashY
}
if out.ChaseRange == 0 {
out.ChaseRange = defaults.ChaseRange
}
if out.VisionRange == 0 {
out.VisionRange = defaults.VisionRange
}
if out.TargetStickinessMS == 0 {
out.TargetStickinessMS = defaults.TargetStickinessMS
}
if out.HealLeaderPercent == 0 {
out.HealLeaderPercent = defaults.HealLeaderPercent
}
if out.HealSelfPercent == 0 {
out.HealSelfPercent = defaults.HealSelfPercent
}
if out.RetryMS == 0 {
out.RetryMS = defaults.RetryMS
}
if out.SupportRetryMS == 0 {
out.SupportRetryMS = defaults.SupportRetryMS
}
if out.BusyMoveMS == 0 {
out.BusyMoveMS = defaults.BusyMoveMS
}
if out.StuckMS == 0 {
out.StuckMS = defaults.StuckMS
}
if out.DetourMS == 0 {
out.DetourMS = defaults.DetourMS
}
if out.GiveUpMS == 0 {
out.GiveUpMS = defaults.GiveUpMS
}
if out.FollowDistance == 0 {
out.FollowDistance = defaults.FollowDistance
}
return out
}
// Validate checks user-editable limits before a strategy can be compiled.
func (t AITuning) Validate() error {
positive := map[string]int{
"think_interval_ms": t.ThinkIntervalMS,
"assist_attack_interval_ms": t.AssistAttackIntervalMS,
"assist_leash_x": t.AssistLeashX,
"assist_leash_y": t.AssistLeashY,
"chase_range": t.ChaseRange,
"vision_range": t.VisionRange,
"target_stickiness_ms": t.TargetStickinessMS,
"retry_ms": t.RetryMS,
"support_retry_ms": t.SupportRetryMS,
"busy_move_ms": t.BusyMoveMS,
"stuck_ms": t.StuckMS,
"detour_ms": t.DetourMS,
"give_up_ms": t.GiveUpMS,
"follow_distance": t.FollowDistance,
}
limits := map[string][2]int{
"think_interval_ms": {50, 2000}, "assist_attack_interval_ms": {200, 10000},
"assist_leash_x": {30, 500}, "assist_leash_y": {20, 300},
"chase_range": {20, 1000}, "vision_range": {100, 20000},
"target_stickiness_ms": {0, 10000}, "retry_ms": {50, 5000},
"support_retry_ms": {100, 10000}, "busy_move_ms": {100, 10000},
"stuck_ms": {100, 5000}, "detour_ms": {100, 5000},
"give_up_ms": {500, 20000}, "follow_distance": {20, 300},
}
for name, value := range positive {
if value == 0 {
continue
}
limit := limits[name]
if value < limit[0] || value > limit[1] {
return ErrSelection
}
}
if t.HealLeaderPercent != 0 && (t.HealLeaderPercent < 1 || t.HealLeaderPercent > 100) {
return ErrSelection
}
if t.HealSelfPercent != 0 && (t.HealSelfPercent < 1 || t.HealSelfPercent > 100) {
return ErrSelection
}
return nil
}
// AIProfile is the user-facing strategy selector for one support soldier.
type AIProfile struct {
Behavior string `json:"behavior"` // assist or aggressive; kept for wire compatibility
ClassID uint16 `json:"class_id,omitempty"`
BuffPolicy string `json:"buff_policy"`
ComboPreset string `json:"combo_preset"`
ComboEnabled bool `json:"combo_enabled"`
AOEMinTargets int `json:"aoe_min_targets"`
ProtectLeader bool `json:"protect_leader"`
Tuning *AITuning `json:"tuning,omitempty"`
}
func DefaultAIProfile(classID uint16) AIProfile {
return AIProfile{
Behavior: "assist",
ClassID: classID,
BuffPolicy: "leader_first",
ComboPreset: "auto",
ComboEnabled: true,
AOEMinTargets: 3,
ProtectLeader: true,
Tuning: nil,
}
}
const (
AIBuffPolicyLeaderFirst = "leader_first"
AIBuffPolicyAssistOnly = "assist_only"
AIBuffPolicyOff = "off"
)
func normalizeBehavior(value string) string {
switch value {
case "", "assist":
return "assist"
case "aggressive":
return "aggressive"
case "custom":
// The old custom mode had no client owner. Existing rows are safely
// migrated to the deterministic assist policy.
return "assist"
default:
return value
}
}
func normalizeBuffPolicy(value string) string {
switch value {
case "":
return AIBuffPolicyLeaderFirst
case AIBuffPolicyAssistOnly, AIBuffPolicyOff:
return value
default:
return value
}
}
func normalizeAOEMinTargets(value int) int {
if value == 0 {
return 3
}
return value
}
func NormalizeAIProfile(p AIProfile) AIProfile {
p.Behavior = normalizeBehavior(p.Behavior)
p.BuffPolicy = normalizeBuffPolicy(p.BuffPolicy)
if strings.TrimSpace(p.ComboPreset) == "" {
p.ComboPreset = "auto"
}
p.AOEMinTargets = normalizeAOEMinTargets(p.AOEMinTargets)
return p
}
func (p AIProfile) Validate() error {
p = NormalizeAIProfile(p)
if p.Behavior != "assist" && p.Behavior != "aggressive" {
return ErrSelection
}
if p.BuffPolicy != AIBuffPolicyLeaderFirst &&
p.BuffPolicy != AIBuffPolicyAssistOnly && p.BuffPolicy != AIBuffPolicyOff {
return ErrSelection
}
if p.AOEMinTargets < 2 || p.AOEMinTargets > 6 {
return ErrSelection
}
if p.ComboPreset == "" {
return ErrSelection
}
if len(p.ComboPreset) > 64 || strings.TrimSpace(p.ComboPreset) != p.ComboPreset {
return ErrSelection
}
for _, r := range p.ComboPreset {
if r < 32 || r == 127 {
return ErrSelection
}
}
if p.Tuning != nil {
if err := p.Tuning.Validate(); err != nil {
return err
}
}
return nil
}
func cloneAIProfiles(profiles map[string]AIProfile) map[string]AIProfile {
if profiles == nil {
return nil
}
cloned := make(map[string]AIProfile, len(profiles))
for key, profile := range profiles {
cloned[key] = NormalizeAIProfile(profile)
}
return cloned
}
// AIConfig contains account-level defaults for the strategy compiler. A
// profile's tuning takes precedence over this optional account default.
type AIConfig struct {
BuffPolicy string `json:"buff_policy"`
ComboPreset string `json:"combo_preset"`
ComboEnabled bool `json:"combo_enabled"`
AOEMinTargets int `json:"aoe_min_targets"`
ProtectLeader bool `json:"protect_leader"`
Tuning *AITuning `json:"tuning,omitempty"`
}
// DefaultAIConfig is the only account-level default exposed to GM. All
// distance, timing, target and skill decisions belong to the client executor.
func DefaultAIConfig() AIConfig {
return AIConfig{
BuffPolicy: AIBuffPolicyLeaderFirst,
ComboPreset: "auto",
ComboEnabled: true,
AOEMinTargets: 3,
ProtectLeader: true,
}
}
func (a AIConfig) IsZero() bool { return a == (AIConfig{}) }
func NormalizeAIConfig(a AIConfig) AIConfig {
if a.IsZero() {
return DefaultAIConfig()
}
if a.BuffPolicy == "" {
a.BuffPolicy = AIBuffPolicyLeaderFirst
}
if strings.TrimSpace(a.ComboPreset) == "" {
a.ComboPreset = "auto"
}
if a.AOEMinTargets == 0 {
a.AOEMinTargets = 3
}
return a
}
// EffectiveAI keeps legacy rows working without rewriting them on read.
func (c Config) EffectiveAI() AIConfig {
return NormalizeAIConfig(c.AI)
}
func (a AIConfig) Validate() error {
if a.IsZero() {
return nil
}
if a.BuffPolicy != "" && a.BuffPolicy != AIBuffPolicyLeaderFirst &&
a.BuffPolicy != AIBuffPolicyAssistOnly && a.BuffPolicy != AIBuffPolicyOff {
return ErrSelection
}
if a.ComboPreset != "" && (len(a.ComboPreset) > 64 || strings.TrimSpace(a.ComboPreset) != a.ComboPreset) {
return ErrSelection
}
if a.AOEMinTargets != 0 && (a.AOEMinTargets < 2 || a.AOEMinTargets > 6) {
return ErrSelection
}
if a.Tuning != nil {
if err := a.Tuning.Validate(); err != nil {
return err
}
}
return nil
}
func DefaultConfig() Config { return Config{SchemaVersion: 1} }
func (c Config) Sources() []string {
values := c.SourceCharacterIDs
if len(values) == 0 && c.SourceCharacterID != "" {
values = []string{c.SourceCharacterID}
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func (c Config) Validate() error {
if c.SchemaVersion != 1 {
return ErrSelection
}
sources := c.Sources()
if c.Enabled && len(sources) == 0 {
return ErrSelection
}
for _, source := range sources {
if !validCharacterID(source) {
return ErrSelection
}
}
if c.SourceCharacterID != "" {
if !validCharacterID(c.SourceCharacterID) ||
len(sources) == 0 || sources[0] != c.SourceCharacterID {
return ErrSelection
}
}
if err := c.AI.Validate(); err != nil {
return err
}
for characterID, profile := range c.AIProfiles {
if !validCharacterID(characterID) {
return ErrSelection
}
if err := profile.Validate(); err != nil {
return err
}
}
return nil
}
func canonicalSourceCharacters(sources []string) ([]string, error) {
out := make([]string, 0, len(sources))
seen := make(map[string]struct{}, len(sources))
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" || !validCharacterID(source) {
return nil, ErrSelection
}
if _, ok := seen[source]; ok {
continue
}
seen[source] = struct{}{}
out = append(out, source)
}
return out, nil
}
// NextConfigMany is pure validation, NOT an atomic persistent compare-and-swap.
// The account-scoped policy supports any number of distinct source characters.
// Selection is frozen for the next dungeon and no source respawns during a run.
func NextConfigMany(c Config, expected uint64, enabled bool, sources []string, owner string, switches ...bool) (Config, error) {
return NextConfigManyWithAI(c, expected, enabled, sources, owner, c.AI, switches...)
}
// NextConfigManyWithAI updates the account policy and freezes the requested AI
// controls for the next dungeon. Passing a zero AIConfig preserves the
// current value, which keeps older callers source-compatible.
func NextConfigManyWithAI(c Config, expected uint64, enabled bool, sources []string, owner string, ai AIConfig, switches ...bool) (Config, error) {
return NextConfigManyWithAIProfiles(c, expected, enabled, sources, owner, ai, nil, switches...)
}
// NextConfigManyWithAIProfiles is the account-scoped update entry point used
// by the GM when it edits individual support-soldier roles. A nil profile map
// preserves the existing map for legacy callers; a non-nil map replaces it
// atomically with the same revision as the source selection and AI controls.
func NextConfigManyWithAIProfiles(c Config, expected uint64, enabled bool, sources []string, owner string, ai AIConfig, profiles map[string]AIProfile, switches ...bool) (Config, error) {
if err := c.Validate(); err != nil {
return Config{}, err
}
if c.Revision != expected || c.Revision == ^uint64(0) {
return Config{}, ErrRevision
}
if !validCharacterID(owner) {
return Config{}, ErrSelection
}
normalized, err := canonicalSourceCharacters(sources)
if err != nil {
return Config{}, err
}
if enabled && len(normalized) == 0 {
return Config{}, ErrSelection
}
if ai.IsZero() {
ai = c.AI
}
if !ai.IsZero() {
ai = NormalizeAIConfig(ai)
}
if err := ai.Validate(); err != nil {
return Config{}, err
}
if profiles != nil {
for characterID, profile := range profiles {
if !validCharacterID(characterID) {
return Config{}, ErrSelection
}
if err := profile.Validate(); err != nil {
return Config{}, err
}
}
}
balanced := c.BalanceEnabled
if len(switches) > 0 {
balanced = switches[0]
}
progressionEnabled := c.ProgressionEnabled
if len(switches) > 1 {
progressionEnabled = switches[1]
}
randomEquipmentEnabled := c.RandomEquipmentEnabled
if len(switches) > 2 {
randomEquipmentEnabled = switches[2]
}
next := Config{
SchemaVersion: 1,
Revision: c.Revision + 1,
Enabled: enabled,
BalanceEnabled: balanced,
ProgressionEnabled: progressionEnabled,
RandomEquipmentEnabled: randomEquipmentEnabled,
AI: ai,
AIProfiles: cloneAIProfiles(c.AIProfiles),
SourceCharacterIDs: normalized,
}
if profiles != nil {
next.AIProfiles = cloneAIProfiles(profiles)
}
if len(normalized) != 0 {
next.SourceCharacterID = normalized[0]
}
return next, nil
}
// NextConfig preserves the original single-source API for older callers/tests.
func NextConfig(c Config, expected uint64, enabled bool, source, owner string, eligibility error, switches ...bool) (Config, error) {
if enabled && source == owner {
return Config{}, ErrSelf
}
if enabled && eligibility != nil {
return Config{}, eligibility
}
sources := []string{}
if strings.TrimSpace(source) != "" {
sources = append(sources, source)
}
return NextConfigMany(c, expected, enabled, sources, owner, switches...)
}
@@ -0,0 +1,250 @@
package companion
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"longheng.io/server/internal/modules/dnf/equip"
"longheng.io/server/internal/modules/dnf/repository"
)
const (
sourceCharacterSettingKey = "source_character_id"
sourceCharactersSettingKey = "source_character_ids"
)
func SettingsScope(ownerCharacterID string) (string, error) {
ownerCharacterID = strings.TrimSpace(ownerCharacterID)
if !validCharacterID(ownerCharacterID) {
return "", ErrSelection
}
return "character:" + ownerCharacterID + ":companion-v1", nil
}
func AccountSettingsScope(accountID string) (string, error) {
accountID = strings.TrimSpace(accountID)
if !validIdentity(accountID) {
return "", ErrSelection
}
return "account:" + accountID + ":companion-v1", nil
}
func SourceCharacterIDFromSettings(
record repository.SettingsRecord,
ownerCharacterID string,
) (string, bool, error) {
cfg, err := ConfigFromSettings(record, ownerCharacterID)
if err != nil {
return "", false, err
}
if !cfg.Enabled {
return "", false, nil
}
if cfg.SourceCharacterID == strings.TrimSpace(ownerCharacterID) {
return "", false, ErrSelf
}
return cfg.SourceCharacterID, true, nil
}
// VisualProjection is detached display data for one source character.
// It contains template IDs only and never owns inventory rows, native pointers,
// source cooldowns or mutable APC template state.
type VisualProjection struct {
SchemaVersion int `json:"schema_version"`
AccountID string `json:"account_id"`
OwnerCharacterID string `json:"owner_character_id"`
SourceCharacterID string `json:"source_character_id"`
Name string `json:"name"`
Job uint8 `json:"job"`
GrowType uint8 `json:"grow_type"`
Level int `json:"level"`
Weapon *VisualItem `json:"weapon,omitempty"`
Avatars []VisualItem `json:"avatars"`
}
type VisualItem struct {
Slot int16 `json:"slot"`
ItemID int64 `json:"item_id"`
DisplayItemID int64 `json:"display_item_id"`
EquipmentType string `json:"equipment_type,omitempty"`
PVFPath string `json:"pvf_path,omitempty"`
}
func BuildVisualProjection(
source repository.CompanionSourceState,
s Selection,
now time.Time,
) (VisualProjection, error) {
if !validIdentity(s.AccountID) ||
!validCharacterID(s.OwnerCharacterID) ||
!validCharacterID(s.SourceCharacterID) {
return VisualProjection{}, ErrSelection
}
if s.OwnerCharacterID == s.SourceCharacterID {
return VisualProjection{}, ErrSelf
}
if source.Owner.CharacterID != s.OwnerCharacterID ||
source.Source.CharacterID != s.SourceCharacterID ||
source.Owner.Stats["delete_flag"] != 0 ||
source.Source.Stats["delete_flag"] != 0 {
return VisualProjection{}, ErrSource
}
if source.Owner.AccountID != s.AccountID ||
source.Source.AccountID != s.AccountID {
return VisualProjection{}, ErrAccount
}
if !s.OnlineStateKnown {
return VisualProjection{}, ErrOnlineUnknown
}
if s.SourceOnline {
return VisualProjection{}, ErrOnline
}
job, err := strconv.ParseUint(source.Source.Job, 10, 8)
grow, hasGrow := source.Source.Stats["grow_type"]
if err != nil || !hasGrow || grow < 0 || grow > 255 {
return VisualProjection{}, ErrSnapshot
}
if now.IsZero() || source.Source.Level < 1 || source.Source.Level > 90 ||
!validIdentity(source.Source.Name) || !utf8.ValidString(source.Source.Name) ||
!source.HasEquipment ||
source.Equipment.CharacterID != s.SourceCharacterID {
return VisualProjection{}, ErrSnapshot
}
out := VisualProjection{
SchemaVersion: 1,
AccountID: s.AccountID,
OwnerCharacterID: s.OwnerCharacterID,
SourceCharacterID: s.SourceCharacterID,
Name: source.Source.Name,
Job: uint8(job),
GrowType: uint8(grow),
Level: source.Source.Level,
Avatars: []VisualItem{},
}
for _, entry := range source.Equipment.Entries {
if entry.ItemID <= 0 || entry.ItemID > int64(^uint32(0)) {
return VisualProjection{}, ErrSnapshot
}
if !entry.ExpireAt.IsZero() && !entry.ExpireAt.After(now) {
continue
}
item := visualItem(entry)
if visualAvatar(entry) {
out.Avatars = append(out.Avatars, item)
continue
}
// Freshly-created characters do not always persist item_kind or
// current_exe_equipment_type, while pvf_path/equipment_type are already
// authoritative enough to identify a worn weapon. Do not drop a real
// weapon solely because those derived extras have not been populated yet.
if visualWeapon(entry) {
if out.Weapon != nil {
return VisualProjection{}, ErrSnapshot
}
copy := item
out.Weapon = &copy
}
}
sort.Slice(out.Avatars, func(i, j int) bool {
if out.Avatars[i].Slot != out.Avatars[j].Slot {
return out.Avatars[i].Slot < out.Avatars[j].Slot
}
return out.Avatars[i].DisplayItemID < out.Avatars[j].DisplayItemID
})
return out, nil
}
// visualAvatar 判断一条装备记录是不是**时裝**(外观)。
//
// 不能只看 extra["item_kind"] == "avatar":本部署的 dnf90.db 里,所有装备记录的
// item_kind 都是 "equipment",时裝是靠下面这些标记体现的 ——
//
// slot 0..10 且 current_exe_equipment_type 0..10 (本部署实测:角色 1 的 10 件时裝)
// extra["avatar_ability_no"] / extra["avatar_duration_days"] 存在
//
// 旧实现只认 item_kind,导致**任何角色的时裝都被丢弃**,投影里 avatar_count 恒为 0,
// 支援兵永远是光板外壳。这里改成按槽位/标记判定,并保留 item_kind 作为兜底。
func visualAvatar(entry repository.EquipmentEntry) bool {
if strings.EqualFold(strings.TrimSpace(entry.Extra["item_kind"]), "avatar") {
return true
}
if strings.TrimSpace(entry.Extra["avatar_ability_no"]) != "" ||
strings.TrimSpace(entry.Extra["avatar_duration_days"]) != "" {
return true
}
// 实测取证(女圣职者时装 415550013 等):GM 系统邮件发放并穿上后,条目里
// 既没有 item_kind="avatar" 也没有 avatar_* 字段,只有
// equipment_type = "[hat avatar]" ← 直接带 avatar 字样
// current_exe_equipment_type = "0" ← 时裝槽 0..10
// pvf_path = .../priest/at_avatar/cap/...
// 所以把 equipment_type 也算作判定依据。
if strings.Contains(strings.ToLower(strings.TrimSpace(entry.Extra["equipment_type"])), "avatar") {
return true
}
// 时裝槽固定是 0..10;武器在 12,support/offhand 在 24,都不会落进这个区间。
if entry.SlotIndex >= 0 && entry.SlotIndex <= 10 {
if raw := strings.TrimSpace(entry.Extra["current_exe_equipment_type"]); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 && parsed <= 10 {
return true
}
}
}
return false
}
func visualWeapon(entry repository.EquipmentEntry) bool {
// Slot 24 is the stored support/offhand weapon (including Vagabond's
// second sword). It is not a second primary display weapon. Keep it in
// the detached equipment snapshot, but never reject a valid dual setup
// or substitute it for slot 12's current weapon.
if entry.SlotIndex == 24 {
return false
}
path := strings.ToLower(strings.TrimSpace(entry.Extra["pvf_path"]))
if strings.Contains(path, "/weapon/") {
return true
}
return strings.TrimSpace(entry.Extra["current_exe_equipment_type"]) == "12" &&
strings.TrimSpace(entry.Extra["equipment_type"]) == "17"
}
func visualItem(entry repository.EquipmentEntry) VisualItem {
displayID := entry.ItemID
if raw := strings.TrimSpace(entry.Extra[equip.CloneAvatarDisplayItemIDExtraKey]); raw != "" {
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil &&
parsed > 0 && parsed <= int64(^uint32(0)) {
displayID = parsed
}
}
return VisualItem{
Slot: entry.SlotIndex,
ItemID: entry.ItemID,
DisplayItemID: displayID,
EquipmentType: strings.TrimSpace(entry.Extra["equipment_type"]),
PVFPath: strings.TrimSpace(entry.Extra["pvf_path"]),
}
}
func (p VisualProjection) Clone() VisualProjection {
if p.Weapon != nil {
weapon := *p.Weapon
p.Weapon = &weapon
}
if p.Avatars != nil {
p.Avatars = append([]VisualItem(nil), p.Avatars...)
}
return p
}
func (p VisualProjection) Fingerprint() string {
body, _ := json.Marshal(p)
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,264 @@
package companion
import (
"reflect"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/equip"
"longheng.io/server/internal/modules/dnf/repository"
)
func visualFixture(
sourceID, name, job string,
grow int64,
weapon int64,
avatars map[string]repository.EquipmentEntry,
) (repository.CompanionSourceState, Selection) {
entries := map[string]repository.EquipmentEntry{
"weapon": {
SlotIndex: 12,
ItemID: weapon,
RawEntry: []byte{1, 2, 3},
Extra: map[string]string{
"item_kind": "equipment",
"equipment_type": "17",
"current_exe_equipment_type": "12",
"pvf_path": "equipment/character/test/weapon/test.equ",
},
},
}
for key, value := range avatars {
entries[key] = value
}
state := repository.CompanionSourceState{
Owner: repository.CharacterRecord{
CharacterID: "1",
AccountID: "account-test",
Stats: map[string]int64{"delete_flag": 0},
},
Source: repository.CharacterRecord{
CharacterID: sourceID,
AccountID: "account-test",
Name: name,
Job: job,
Level: 86,
Stats: map[string]int64{"delete_flag": 0, "grow_type": grow},
},
Equipment: repository.EquipmentRecord{
CharacterID: sourceID,
Entries: entries,
},
HasEquipment: true,
}
selection := Selection{
AccountID: "account-test",
OwnerCharacterID: "1",
SourceCharacterID: sourceID,
OnlineStateKnown: true,
}
return state, selection
}
func avatarFixture(slot int16, item int64, equipmentType string) repository.EquipmentEntry {
return repository.EquipmentEntry{
SlotIndex: slot,
ItemID: item,
RawEntry: []byte{4, 5, 6},
Extra: map[string]string{
"item_kind": "avatar",
"equipment_type": equipmentType,
"pvf_path": "equipment/character/test/avatar/test.equ",
},
}
}
func TestVisualProjectionSwitchesABAndDoesNotMutateSource(t *testing.T) {
aState, aSelection := visualFixture(
"2", "DETSCS", "0", 35, 101010912,
map[string]repository.EquipmentEntry{
"hat": avatarFixture(0, 401550018, "[hat avatar]"),
"hair": avatarFixture(1, 101560869, "[hair avatar]"),
},
)
bState, bSelection := visualFixture(
"8", "DETSDT", "13", 3, 114000248,
map[string]repository.EquipmentEntry{
"weapon-avatar": avatarFixture(10, 414600011, "[weapon avatar]"),
},
)
aBefore := repository.CompanionSourceState{
Owner: repository.CloneCharacter(aState.Owner),
Source: repository.CloneCharacter(aState.Source),
Equipment: repository.CloneEquipment(aState.Equipment),
HasEquipment: aState.HasEquipment,
}
bBefore := repository.CompanionSourceState{
Owner: repository.CloneCharacter(bState.Owner),
Source: repository.CloneCharacter(bState.Source),
Equipment: repository.CloneEquipment(bState.Equipment),
HasEquipment: bState.HasEquipment,
}
now := time.Unix(1_800_000_000, 0)
a, err := BuildVisualProjection(aState, aSelection, now)
if err != nil {
t.Fatal(err)
}
b, err := BuildVisualProjection(bState, bSelection, now)
if err != nil {
t.Fatal(err)
}
if a.Name != "DETSCS" || b.Name != "DETSDT" ||
a.Job != 0 || b.Job != 13 ||
a.GrowType != 35 || b.GrowType != 3 {
t.Fatalf("identity projection A=%+v B=%+v", a, b)
}
if a.Weapon == nil || b.Weapon == nil ||
a.Weapon.ItemID != 101010912 ||
b.Weapon.ItemID != 114000248 {
t.Fatalf("weapon projection A=%+v B=%+v", a.Weapon, b.Weapon)
}
if len(a.Avatars) != 2 || len(b.Avatars) != 1 ||
a.Avatars[0].DisplayItemID != 401550018 ||
b.Avatars[0].DisplayItemID != 414600011 {
t.Fatalf("avatar projection A=%+v B=%+v", a.Avatars, b.Avatars)
}
if a.Fingerprint() == b.Fingerprint() {
t.Fatal("different source characters produced same visual fingerprint")
}
if !reflect.DeepEqual(aState.Owner, aBefore.Owner) ||
!reflect.DeepEqual(aState.Source, aBefore.Source) ||
!reflect.DeepEqual(aState.Equipment, aBefore.Equipment) ||
!reflect.DeepEqual(bState.Owner, bBefore.Owner) ||
!reflect.DeepEqual(bState.Source, bBefore.Source) ||
!reflect.DeepEqual(bState.Equipment, bBefore.Equipment) {
t.Fatal("BuildVisualProjection mutated source records")
}
a.Weapon.ItemID = 1
a.Avatars[0].ItemID = 2
if aState.Equipment.Entries["weapon"].ItemID != 101010912 ||
aState.Equipment.Entries["hat"].ItemID != 401550018 {
t.Fatal("returned projection aliases source equipment")
}
}
func TestVisualProjectionAcceptsFreshCharacterWeaponWithoutDerivedKind(t *testing.T) {
state, selection := visualFixture(
"2", "DETXX", "11", 0, 101010912, nil,
)
weapon := state.Equipment.Entries["weapon"]
delete(weapon.Extra, "item_kind")
delete(weapon.Extra, "current_exe_equipment_type")
weapon.Extra["pvf_path"] = "equipment/character/swordman/weapon/katana/101010912.equ"
weapon.Extra["equipment_type"] = "17"
state.Equipment.Entries["weapon"] = weapon
got, err := BuildVisualProjection(
state, selection, time.Unix(1_800_000_000, 0),
)
if err != nil {
t.Fatal(err)
}
if got.Weapon == nil || got.Weapon.ItemID != 101010912 {
t.Fatalf("fresh-character weapon projection=%+v", got.Weapon)
}
}
func TestVisualProjectionUsesCloneAvatarDisplayID(t *testing.T) {
clone := avatarFixture(3, 499999999, "[coat avatar]")
clone.Extra[equip.CloneAvatarDisplayItemIDExtraKey] = "412500009"
state, selection := visualFixture(
"8", "DETSDT", "13", 3, 114000248,
map[string]repository.EquipmentEntry{"coat": clone},
)
got, err := BuildVisualProjection(
state, selection, time.Unix(1_800_000_000, 0),
)
if err != nil {
t.Fatal(err)
}
if len(got.Avatars) != 1 ||
got.Avatars[0].ItemID != 499999999 ||
got.Avatars[0].DisplayItemID != 412500009 {
t.Fatalf("clone avatar projection=%+v", got.Avatars)
}
}
func TestVisualProjectionAcceptsPrimaryAndSupportWeapon(t *testing.T) {
state, selection := visualFixture("13", "Vagabond", "11", 4, 28246, nil)
secondary := state.Equipment.Entries["weapon"]
secondary.SlotIndex, secondary.ItemID = 24, 28239
state.Equipment.Entries["support"] = secondary
before := repository.CloneEquipment(state.Equipment)
got, err := BuildVisualProjection(state, selection, time.Unix(1_800_000_000, 0))
if err != nil || got.Weapon == nil || got.Weapon.Slot != 12 || got.Weapon.ItemID != 28246 {
t.Fatalf("valid dual weapons rejected or primary changed: %+v err=%v", got, err)
}
if !reflect.DeepEqual(before, state.Equipment) {
t.Fatal("display projection changed source equipment")
}
delete(state.Equipment.Entries, "weapon")
got, err = BuildVisualProjection(state, selection, time.Unix(1_800_000_000, 0))
if err != nil || got.Weapon != nil {
t.Fatalf("support weapon substituted for missing primary: %+v err=%v", got, err)
}
}
func TestVisualProjectionRejectsDuplicateWeapons(t *testing.T) {
state, selection := visualFixture(
"2", "DETSCS", "0", 35, 101010912, nil,
)
state.Equipment.Entries["weapon2"] = repository.EquipmentEntry{
SlotIndex: 20,
ItemID: 101010913,
Extra: map[string]string{
"item_kind": "equipment",
"pvf_path": "equipment/character/test/weapon/other.equ",
},
}
if _, err := BuildVisualProjection(
state, selection, time.Unix(1_800_000_000, 0),
); err != ErrSnapshot {
t.Fatalf("duplicate weapon err=%v want=%v", err, ErrSnapshot)
}
}
func TestProjectionSettingsSelectExactDifferentCharacter(t *testing.T) {
scope, err := SettingsScope("3")
if err != nil || scope != "character:3:companion-v1" {
t.Fatalf("scope=%q err=%v", scope, err)
}
record := repository.SettingsRecord{
Scope: scope,
Values: map[string]string{
"source_character_id": "2",
},
}
source, found, err := SourceCharacterIDFromSettings(record, "3")
if err != nil || !found || source != "2" {
t.Fatalf("source=%q found=%t err=%v", source, found, err)
}
record.Values["source_character_id"] = "8"
source, found, err = SourceCharacterIDFromSettings(record, "3")
if err != nil || !found || source != "8" {
t.Fatalf("source=%q found=%t err=%v", source, found, err)
}
for _, tc := range []struct {
name string
record repository.SettingsRecord
owner string
want error
}{
{"self", repository.SettingsRecord{Scope: scope, Values: map[string]string{"source_character_id": "3"}}, "3", ErrSelf},
{"leading zero", repository.SettingsRecord{Scope: scope, Values: map[string]string{"source_character_id": "02"}}, "3", ErrSelection},
{"wrong scope", repository.SettingsRecord{Scope: "character:4:companion-v1", Values: map[string]string{"source_character_id": "2"}}, "3", ErrSelection},
} {
if _, _, got := SourceCharacterIDFromSettings(tc.record, tc.owner); got != tc.want {
t.Fatalf("%s err=%v want=%v", tc.name, got, tc.want)
}
}
}
@@ -0,0 +1,283 @@
package companion
import (
"encoding/json"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/repository"
)
const (
configSchemaKey = "schema_version"
configRevisionKey = "revision"
configEnabledKey = "enabled"
configBalanceKey = "balance_enabled"
configProgressionKey = "progression_enabled"
configRandomEquipmentKey = "random_equipment_enabled"
configAISchemaKey = "ai_schema_version"
configAIDefaultsKey = "ai_defaults"
configAIProfilesKey = "ai_profiles"
)
func ConfigFromSettings(
record repository.SettingsRecord,
ownerCharacterID string,
) (Config, error) {
scope, err := SettingsScope(ownerCharacterID)
if err != nil {
return Config{}, err
}
return configFromSettingsScope(record, scope)
}
func ConfigFromAccountSettings(
record repository.SettingsRecord,
accountID string,
) (Config, error) {
scope, err := AccountSettingsScope(accountID)
if err != nil {
return Config{}, err
}
return configFromSettingsScope(record, scope)
}
func configFromSettingsScope(
record repository.SettingsRecord,
expectedScope string,
) (Config, error) {
if strings.TrimSpace(record.Scope) != expectedScope {
return Config{}, ErrSelection
}
values := record.Values
if values == nil {
return DefaultConfig(), nil
}
source := strings.TrimSpace(values[sourceCharacterSettingKey])
sourcesRaw, hasSources := values[sourceCharactersSettingKey]
_, hasSchema := values[configSchemaKey]
_, hasRevision := values[configRevisionKey]
_, hasEnabled := values[configEnabledKey]
// Phase-1 rows stored only source_character_id. Keep them readable so the
// phase-2 account-scope migration does not invalidate accepted setups.
if !hasSchema && !hasRevision && !hasEnabled {
if source == "" {
return DefaultConfig(), nil
}
cfg := Config{
SchemaVersion: 1,
Revision: 0,
Enabled: true,
SourceCharacterID: source,
SourceCharacterIDs: []string{source},
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
schema, err := parseCanonicalUint(values[configSchemaKey], 32)
if err != nil || schema != 1 {
return Config{}, ErrSelection
}
revision, err := parseCanonicalUint(values[configRevisionKey], 64)
if err != nil {
return Config{}, ErrSelection
}
enabled, err := strconv.ParseBool(strings.TrimSpace(values[configEnabledKey]))
if err != nil {
return Config{}, ErrSelection
}
sources := []string{}
if hasSources {
if strings.TrimSpace(sourcesRaw) != sourcesRaw {
return Config{}, ErrSelection
}
if sourcesRaw != "" {
var err error
sources, err = canonicalSourceCharacters(strings.Split(sourcesRaw, ","))
if err != nil {
return Config{}, err
}
}
} else if source != "" {
sources = []string{source}
}
if len(sources) != 0 {
if source == "" {
source = sources[0]
} else if source != sources[0] {
return Config{}, ErrSelection
}
}
balanceEnabled := false
if raw, exists := values[configBalanceKey]; exists {
balanceEnabled, err = strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
return Config{}, ErrSelection
}
}
progressionEnabled := false
if raw, exists := values[configProgressionKey]; exists {
progressionEnabled, err = strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
return Config{}, ErrSelection
}
}
randomEquipmentEnabled := false
if raw, exists := values[configRandomEquipmentKey]; exists {
randomEquipmentEnabled, err = strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
return Config{}, ErrSelection
}
}
// Old rows may still contain the former distance/timing ai_* keys. They
// are intentionally ignored; those values never had a verified action
// owner. Schema 2 stores only the high-level profile and its bounded tuning
// object, never raw Lua/AIC instructions.
ai := AIConfig{}
aiSchemaV2 := false
if raw, exists := values[configAISchemaKey]; exists && strings.TrimSpace(raw) != "2" {
return Config{}, ErrSelection
} else if exists {
aiSchemaV2 = true
}
if raw, exists := values[configAIDefaultsKey]; exists {
if strings.TrimSpace(raw) == "" || json.Unmarshal([]byte(raw), &ai) != nil {
return Config{}, ErrSelection
}
if err := ai.Validate(); err != nil {
return Config{}, err
}
ai = NormalizeAIConfig(ai)
}
profiles := map[string]AIProfile(nil)
if raw, exists := values[configAIProfilesKey]; exists {
raw = strings.TrimSpace(raw)
if raw == "" {
return Config{}, ErrSelection
}
if err := json.Unmarshal([]byte(raw), &profiles); err != nil || profiles == nil {
return Config{}, ErrSelection
}
for characterID, profile := range profiles {
if !validCharacterID(characterID) {
return Config{}, ErrSelection
}
profile = NormalizeAIProfile(profile)
if !aiSchemaV2 {
// Old profiles had no protection field; preserve the new safe
// default when they are first read.
profile.ProtectLeader = true
}
profiles[characterID] = profile
if err := profile.Validate(); err != nil {
return Config{}, err
}
}
}
cfg := Config{
SchemaVersion: int(schema),
Revision: revision,
Enabled: enabled,
BalanceEnabled: balanceEnabled,
ProgressionEnabled: progressionEnabled,
RandomEquipmentEnabled: randomEquipmentEnabled,
AI: ai,
AIProfiles: profiles,
SourceCharacterID: source,
SourceCharacterIDs: sources,
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func ConfigSettingsRecord(
ownerCharacterID string,
cfg Config,
now time.Time,
) (repository.SettingsRecord, error) {
scope, err := SettingsScope(ownerCharacterID)
if err != nil {
return repository.SettingsRecord{}, err
}
return configSettingsRecordScope(scope, cfg, now)
}
func AccountConfigSettingsRecord(
accountID string,
cfg Config,
now time.Time,
) (repository.SettingsRecord, error) {
scope, err := AccountSettingsScope(accountID)
if err != nil {
return repository.SettingsRecord{}, err
}
return configSettingsRecordScope(scope, cfg, now)
}
func configSettingsRecordScope(
scope string,
cfg Config,
now time.Time,
) (repository.SettingsRecord, error) {
if err := cfg.Validate(); err != nil {
return repository.SettingsRecord{}, err
}
if now.IsZero() {
return repository.SettingsRecord{}, ErrSelection
}
values := map[string]string{
configSchemaKey: strconv.Itoa(cfg.SchemaVersion),
configRevisionKey: strconv.FormatUint(cfg.Revision, 10),
configEnabledKey: strconv.FormatBool(cfg.Enabled),
configBalanceKey: strconv.FormatBool(cfg.BalanceEnabled),
configProgressionKey: strconv.FormatBool(cfg.ProgressionEnabled),
configRandomEquipmentKey: strconv.FormatBool(cfg.RandomEquipmentEnabled),
}
if !cfg.AI.IsZero() {
values[configAISchemaKey] = "2"
encoded, err := json.Marshal(NormalizeAIConfig(cfg.AI))
if err != nil {
return repository.SettingsRecord{}, ErrSelection
}
values[configAIDefaultsKey] = string(encoded)
}
if cfg.AIProfiles != nil {
values[configAISchemaKey] = "2"
profiles := cloneAIProfiles(cfg.AIProfiles)
encoded, err := json.Marshal(profiles)
if err != nil {
return repository.SettingsRecord{}, ErrSelection
}
values[configAIProfilesKey] = string(encoded)
}
sources := cfg.Sources()
if len(sources) != 0 {
values[sourceCharacterSettingKey] = sources[0]
values[sourceCharactersSettingKey] = strings.Join(sources, ",")
}
return repository.SettingsRecord{
Scope: scope,
Values: values,
UpdatedAt: now,
}, nil
}
func parseCanonicalUint(value string, bits int) (uint64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, ErrSelection
}
parsed, err := strconv.ParseUint(value, 10, bits)
if err != nil || strconv.FormatUint(parsed, 10) != value {
return 0, ErrSelection
}
return parsed, nil
}
@@ -0,0 +1,337 @@
package companion
import (
"reflect"
"strings"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/repository"
)
func TestDefaultAIConfigIsProductConfig(t *testing.T) {
got := DefaultAIConfig()
want := AIConfig{BuffPolicy: "leader_first", ComboPreset: "auto", ComboEnabled: true, AOEMinTargets: 3, ProtectLeader: true}
if !reflect.DeepEqual(got, want) {
t.Fatalf("default AI=%+v want=%+v", got, want)
}
}
func TestAIConfigRejectsInvalidProductValues(t *testing.T) {
ai := DefaultAIConfig()
for name, edit := range map[string]func(*AIConfig){
"buff_policy": func(v *AIConfig) { v.BuffPolicy = "unknown" },
"aoe_low": func(v *AIConfig) { v.AOEMinTargets = 1 },
"aoe_high": func(v *AIConfig) { v.AOEMinTargets = 7 },
"preset": func(v *AIConfig) { v.ComboPreset = strings.Repeat("x", 65) },
} {
t.Run(name, func(t *testing.T) {
value := ai
edit(&value)
if err := value.Validate(); err != ErrSelection {
t.Fatalf("validate=%v want=%v", err, ErrSelection)
}
})
}
}
func TestAIConfigSettingsRoundTrip(t *testing.T) {
want := Config{SchemaVersion: 1, Revision: 3, Enabled: true, SourceCharacterID: "8", SourceCharacterIDs: []string{"8"}, AI: DefaultAIConfig(), AIProfiles: map[string]AIProfile{
"8": {Behavior: "aggressive", ClassID: 9, BuffPolicy: "leader_first", ComboPreset: "sword_guard", ComboEnabled: true, AOEMinTargets: 3, ProtectLeader: true},
}}
record, err := AccountConfigSettingsRecord("ai-round-trip", want, time.Date(2026, 10, 3, 5, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if record.Values["ai_schema_version"] != "2" || record.Values["ai_defaults"] == "" {
t.Fatalf("product AI fields missing: %+v", record.Values)
}
for key := range record.Values {
if strings.HasPrefix(key, "ai_") && key != "ai_schema_version" && key != "ai_defaults" && key != "ai_profiles" {
t.Fatalf("legacy AI field was written: %q", key)
}
}
got, err := ConfigFromAccountSettings(record, "ai-round-trip")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("round trip=%+v want=%+v", got, want)
}
}
func TestAITuningSettingsRoundTrip(t *testing.T) {
tuning := &AITuning{ThinkIntervalMS: 200, FollowDistance: 60, HealLeaderPercent: 70, StuckMS: 900, DetourMS: 1100, GiveUpMS: 4000}
want := Config{
SchemaVersion: 1, Revision: 4, Enabled: true,
SourceCharacterID: "8", SourceCharacterIDs: []string{"8"},
AIProfiles: map[string]AIProfile{"8": {
Behavior: "assist", ClassID: 9, BuffPolicy: AIBuffPolicyLeaderFirst,
ComboPreset: "priest_support", ComboEnabled: false, AOEMinTargets: 3,
ProtectLeader: true, Tuning: tuning,
}},
}
record, err := AccountConfigSettingsRecord("ai-tuning", want, time.Date(2026, 10, 4, 5, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
got, err := ConfigFromAccountSettings(record, "ai-tuning")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("tuning round trip=%+v want=%+v", got, want)
}
}
func TestLegacyAIInternalFieldsAreIgnoredAndCustomMigrates(t *testing.T) {
record := repository.SettingsRecord{
Scope: "account:ai-legacy:companion-v1",
Values: map[string]string{
"schema_version": "1", "revision": "2", "enabled": "true",
"source_character_id": "8", "source_character_ids": "8",
"ai_tempo": "custom", "ai_leader_leash_x": "999", "ai_give_up_ms": "20000",
"ai_profiles": `{"8":{"behavior":"custom","class_id":9,"combo_preset":"auto","combo_enabled":true}}`,
},
}
cfg, err := ConfigFromAccountSettings(record, "ai-legacy")
if err != nil {
t.Fatal(err)
}
profile := cfg.AIProfiles["8"]
if profile.Behavior != "assist" || profile.BuffPolicy != AIBuffPolicyLeaderFirst || profile.AOEMinTargets != 3 || !profile.ProtectLeader {
t.Fatalf("legacy profile was not normalized: %+v", profile)
}
if !cfg.AI.IsZero() {
t.Fatalf("legacy internal scalar fields were retained: %+v", cfg.AI)
}
}
func TestAIProfileRejectsInvalidModeOrPreset(t *testing.T) {
for name, profile := range map[string]AIProfile{
"mode": {Behavior: "wander", ComboPreset: "auto"},
"preset": {Behavior: "assist", ComboPreset: strings.Repeat("x", 65)},
} {
t.Run(name, func(t *testing.T) {
if err := profile.Validate(); err != ErrSelection {
t.Fatalf("validate=%v want=%v", err, ErrSelection)
}
})
}
}
func TestConfigFromSettingsLegacySourceRow(t *testing.T) {
record := repository.SettingsRecord{
Scope: "character:3:companion-v1",
Values: map[string]string{"source_character_id": "2"},
}
cfg, err := ConfigFromSettings(record, "3")
if err != nil {
t.Fatalf("ConfigFromSettings legacy: %v", err)
}
if !cfg.Enabled || cfg.Revision != 0 || cfg.SourceCharacterID != "2" {
t.Fatalf("legacy cfg = %+v", cfg)
}
}
func TestConfigSettingsRecordRoundTrip(t *testing.T) {
now := time.Date(2026, 9, 25, 5, 0, 0, 0, time.UTC)
want := Config{SchemaVersion: 1, Revision: 7, Enabled: true, BalanceEnabled: true, RandomEquipmentEnabled: true, SourceCharacterID: "8", SourceCharacterIDs: []string{"8"}}
record, err := ConfigSettingsRecord("3", want, now)
if err != nil {
t.Fatalf("ConfigSettingsRecord: %v", err)
}
if !record.UpdatedAt.Equal(now) {
t.Fatalf("updated_at = %v want %v", record.UpdatedAt, now)
}
got, err := ConfigFromSettings(record, "3")
if err != nil {
t.Fatalf("ConfigFromSettings: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("round trip = %+v want %+v", got, want)
}
}
func TestConfigFromSettingsRejectsNonCanonicalRevision(t *testing.T) {
record := repository.SettingsRecord{
Scope: "character:3:companion-v1",
Values: map[string]string{
"schema_version": "1",
"revision": "007",
"enabled": "true",
"source_character_id": "2",
},
}
if _, err := ConfigFromSettings(record, "3"); err != ErrSelection {
t.Fatalf("err = %v want %v", err, ErrSelection)
}
}
func TestSourceCharacterIDFromSettingsDisabled(t *testing.T) {
record := repository.SettingsRecord{
Scope: "character:3:companion-v1",
Values: map[string]string{
"schema_version": "1",
"revision": "2",
"enabled": "false",
"source_character_id": "8",
},
}
source, selected, err := SourceCharacterIDFromSettings(record, "3")
if err != nil {
t.Fatalf("SourceCharacterIDFromSettings: %v", err)
}
if selected || source != "" {
t.Fatalf("disabled source = %q selected=%v", source, selected)
}
}
func TestNextConfigPersistsBalanceSwitch(t *testing.T) {
current := Config{
SchemaVersion: 1,
Revision: 4,
Enabled: true,
BalanceEnabled: false,
SourceCharacterID: "2",
}
next, err := NextConfig(current, 4, true, "2", "3", nil, true)
if err != nil {
t.Fatal(err)
}
if next.Revision != 5 || !next.Enabled || !next.BalanceEnabled ||
next.SourceCharacterID != "2" {
t.Fatalf("next=%+v", next)
}
original, err := NextConfig(next, 5, true, "2", "3", nil, false)
if err != nil {
t.Fatal(err)
}
if original.BalanceEnabled {
t.Fatalf("balance switch did not turn off: %+v", original)
}
}
func TestNextConfigPersistsProgressionSwitchDefaultOff(t *testing.T) {
current := Config{
SchemaVersion: 1, Revision: 4, Enabled: true,
SourceCharacterID: "2", SourceCharacterIDs: []string{"2"},
}
next, err := NextConfigMany(current, 4, true, []string{"2"}, "3", false, true)
if err != nil {
t.Fatal(err)
}
if !next.ProgressionEnabled || next.BalanceEnabled {
t.Fatalf("next=%+v", next)
}
record, err := AccountConfigSettingsRecord("account-progression", next, time.Date(2026, 9, 25, 5, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if record.Values[configProgressionKey] != "true" {
t.Fatalf("stored progression switch=%q", record.Values[configProgressionKey])
}
legacy := repository.SettingsRecord{
Scope: "account:account-progression:companion-v1",
Values: map[string]string{
"schema_version": "1", "revision": "1", "enabled": "true",
"source_character_id": "2",
},
}
legacyCfg, err := ConfigFromAccountSettings(legacy, "account-progression")
if err != nil {
t.Fatal(err)
}
if legacyCfg.ProgressionEnabled {
t.Fatalf("legacy config unexpectedly enabled progression: %+v", legacyCfg)
}
}
func TestNextConfigPersistsRandomEquipmentSwitchDefaultOff(t *testing.T) {
current := Config{
SchemaVersion: 1, Revision: 4, Enabled: true,
SourceCharacterID: "2", SourceCharacterIDs: []string{"2"},
}
next, err := NextConfigMany(current, 4, true, []string{"2"}, "3", false, false, true)
if err != nil {
t.Fatal(err)
}
if !next.RandomEquipmentEnabled || next.BalanceEnabled || next.ProgressionEnabled {
t.Fatalf("next=%+v", next)
}
record, err := AccountConfigSettingsRecord("account-random-equipment", next, time.Date(2026, 9, 25, 5, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if record.Values[configRandomEquipmentKey] != "true" {
t.Fatalf("stored random equipment switch=%q", record.Values[configRandomEquipmentKey])
}
legacy := repository.SettingsRecord{
Scope: "account:account-random-equipment:companion-v1",
Values: map[string]string{
"schema_version": "1", "revision": "1", "enabled": "true",
"source_character_id": "2",
},
}
legacyCfg, err := ConfigFromAccountSettings(legacy, "account-random-equipment")
if err != nil {
t.Fatal(err)
}
if legacyCfg.RandomEquipmentEnabled {
t.Fatalf("legacy config unexpectedly enabled random equipment: %+v", legacyCfg)
}
}
func TestAccountConfigSettingsRoundTrip(t *testing.T) {
now := time.Date(2026, 9, 25, 15, 0, 0, 0, time.FixedZone("CST", 8*60*60))
cfg := Config{
SchemaVersion: 1,
Revision: 7,
Enabled: true,
BalanceEnabled: true,
RandomEquipmentEnabled: true,
SourceCharacterID: "11",
SourceCharacterIDs: []string{"11"},
}
record, err := AccountConfigSettingsRecord("account-projection", cfg, now)
if err != nil {
t.Fatal(err)
}
if record.Scope != "account:account-projection:companion-v1" {
t.Fatalf("scope=%q", record.Scope)
}
got, err := ConfigFromAccountSettings(record, "account-projection")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, cfg) {
t.Fatalf("got=%+v want=%+v", got, cfg)
}
if _, err := ConfigFromAccountSettings(record, "other-account"); err != ErrSelection {
t.Fatalf("wrong-account scope err=%v", err)
}
}
func TestAccountConfigSettingsMultiSourceRoundTrip(t *testing.T) {
now := time.Date(2026, 9, 27, 2, 0, 0, 0, time.FixedZone("CST", 8*60*60))
cfg := Config{
SchemaVersion: 1, Revision: 9, Enabled: true,
SourceCharacterID: "2",
SourceCharacterIDs: []string{"2", "8", "11"},
}
record, err := AccountConfigSettingsRecord("account-multi", cfg, now)
if err != nil {
t.Fatal(err)
}
if got := record.Values[sourceCharactersSettingKey]; got != "2,8,11" {
t.Fatalf("stored sources=%q", got)
}
got, err := ConfigFromAccountSettings(record, "account-multi")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, cfg) {
t.Fatalf("got=%+v want=%+v", got, cfg)
}
}
@@ -0,0 +1,198 @@
package companion
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strconv"
"time"
"unicode/utf8"
"longheng.io/server/internal/modules/dnf/repository"
)
// Profile is developer-owned evidence, never a client-submitted capability.
type Profile struct {
ID string
Version uint32
EvidenceID string
ClientSHA256 string
PVFSHA256 string
Job uint8
GrowType uint8
BasicAttackVerified bool
SkillLevelLimits map[int64]int
}
// There are no accepted native profiles yet. Tests use synthetic profiles
// only to exercise policies; those profiles are not shipped in this registry.
func VerifiedProfiles() []Profile { return nil }
func validHash(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == 32 && hex.EncodeToString(decoded) == value
}
func (p Profile) validate(client, pvf string) error {
if !validIdentity(p.ID) || p.Version == 0 || !validIdentity(p.EvidenceID) || !p.BasicAttackVerified {
return ErrProfile
}
if !validHash(client) || !validHash(pvf) || client != p.ClientSHA256 || pvf != p.PVFSHA256 {
return ErrAssets
}
return nil
}
type Selection struct {
AccountID string
OwnerCharacterID string
SourceCharacterID string
OnlineStateKnown bool
SourceOnline bool
ClientSHA256 string
PVFSHA256 string
}
type EquipmentSpec struct {
Slot int16 `json:"slot"`
ItemID int64 `json:"item_id"`
}
type SkillSpec struct {
ID int64 `json:"id"`
Level int `json:"level"`
}
type Exclusion struct {
Kind string `json:"kind"`
ID int64 `json:"id,omitempty"`
Reason string `json:"reason"`
}
// Snapshot contains declarative template IDs, not inventory serials, raw
// item-instance bytes, live pointers, source cooldowns, wallets or quests.
// It is NOT the native ABI or a claim of gear/stat projection parity.
type Snapshot struct {
SchemaVersion int `json:"schema_version"`
AccountID string `json:"account_id"`
OwnerCharacterID string `json:"owner_character_id"`
SourceCharacterID string `json:"source_character_id"`
Name string `json:"name"`
Job uint8 `json:"job"`
GrowType uint8 `json:"grow_type"`
Level int `json:"level"`
ProfileID string `json:"profile_id"`
ProfileVersion uint32 `json:"profile_version"`
ClientSHA256 string `json:"client_sha256"`
PVFSHA256 string `json:"pvf_sha256"`
Equipment []EquipmentSpec `json:"equipment"`
Skills []SkillSpec `json:"skills"`
Exclusions []Exclusion `json:"exclusions"`
}
// Prepare consumes a single transaction's detached source state. The caller
// must keep the source offline check valid through preparation/run binding.
// It does not load or save repositories and cannot enable a native adapter.
func Prepare(source repository.CompanionSourceState, s Selection, p Profile, now time.Time) (Snapshot, error) {
if !validIdentity(s.AccountID) || !validCharacterID(s.OwnerCharacterID) || !validCharacterID(s.SourceCharacterID) {
return Snapshot{}, ErrSelection
}
if s.OwnerCharacterID == s.SourceCharacterID {
return Snapshot{}, ErrSelf
}
if source.Owner.CharacterID != s.OwnerCharacterID || source.Source.CharacterID != s.SourceCharacterID ||
source.Owner.Stats["delete_flag"] != 0 || source.Source.Stats["delete_flag"] != 0 {
return Snapshot{}, ErrSource
}
if source.Owner.AccountID != s.AccountID || source.Source.AccountID != s.AccountID {
return Snapshot{}, ErrAccount
}
if !s.OnlineStateKnown {
return Snapshot{}, ErrOnlineUnknown
}
if s.SourceOnline {
return Snapshot{}, ErrOnline
}
if err := p.validate(s.ClientSHA256, s.PVFSHA256); err != nil {
return Snapshot{}, err
}
job, err := strconv.ParseUint(source.Source.Job, 10, 8)
grow, hasGrow := source.Source.Stats["grow_type"]
if err != nil || !hasGrow || grow < 0 || grow > 255 || uint8(job) != p.Job || uint8(grow) != p.GrowType {
return Snapshot{}, ErrProfile
}
if now.IsZero() || source.Source.Level < 1 || source.Source.Level > 90 ||
!validIdentity(source.Source.Name) || !utf8.ValidString(source.Source.Name) || !source.HasEquipment || !source.HasSkill ||
source.Equipment.CharacterID != s.SourceCharacterID || source.Skill.CharacterID != s.SourceCharacterID {
return Snapshot{}, ErrSnapshot
}
if len(source.Equipment.Entries) > 64 || len(source.Skill.Skills) > 1024 {
return Snapshot{}, ErrSnapshot
}
out := Snapshot{SchemaVersion: 1, AccountID: s.AccountID, OwnerCharacterID: s.OwnerCharacterID,
SourceCharacterID: s.SourceCharacterID, Name: source.Source.Name, Job: uint8(job), GrowType: uint8(grow),
Level: source.Source.Level, ProfileID: p.ID, ProfileVersion: p.Version,
ClientSHA256: s.ClientSHA256, PVFSHA256: s.PVFSHA256,
Equipment: []EquipmentSpec{}, Skills: []SkillSpec{}, Exclusions: []Exclusion{
{Kind: "attributes", Reason: "native_base_stats_and_equipment_projection_not_verified"},
{Kind: "equipment", Reason: "instance_options_set_and_proc_effects_not_projected"},
}}
slots := map[int16]bool{}
for _, item := range source.Equipment.Entries {
if item.SlotIndex < 0 || item.SlotIndex > 255 || item.ItemID <= 0 || item.ItemID > int64(^uint32(0)) || slots[item.SlotIndex] {
return Snapshot{}, ErrSnapshot
}
slots[item.SlotIndex] = true
if !item.ExpireAt.IsZero() && !item.ExpireAt.After(now) {
out.Exclusions = append(out.Exclusions, Exclusion{Kind: "equipment", ID: item.ItemID, Reason: "expired"})
continue
}
out.Equipment = append(out.Equipment, EquipmentSpec{Slot: item.SlotIndex, ItemID: item.ItemID})
}
for id, skill := range source.Skill.Skills {
if id <= 0 || id > 65535 || skill.Level < 0 || skill.Level > 65535 {
return Snapshot{}, ErrSnapshot
}
if !skill.Enabled || skill.Level == 0 {
continue
}
limit, supported := p.SkillLevelLimits[id]
if !supported || limit <= 0 || skill.Level > limit {
out.Exclusions = append(out.Exclusions, Exclusion{Kind: "skill", ID: id, Reason: "native_skill_or_level_unverified"})
continue
}
out.Skills = append(out.Skills, SkillSpec{ID: id, Level: skill.Level})
}
sort.Slice(out.Equipment, func(i, j int) bool { return out.Equipment[i].Slot < out.Equipment[j].Slot })
sort.Slice(out.Skills, func(i, j int) bool { return out.Skills[i].ID < out.Skills[j].ID })
sort.Slice(out.Exclusions, func(i, j int) bool {
a, b := out.Exclusions[i], out.Exclusions[j]
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
if a.ID != b.ID {
return a.ID < b.ID
}
return a.Reason < b.Reason
})
return out, nil
}
func (s Snapshot) Clone() Snapshot {
if s.Equipment != nil {
s.Equipment = append([]EquipmentSpec{}, s.Equipment...)
}
if s.Skills != nil {
s.Skills = append([]SkillSpec{}, s.Skills...)
}
if s.Exclusions != nil {
s.Exclusions = append([]Exclusion{}, s.Exclusions...)
}
return s
}
// Fingerprint is content addressing, NOT an authentication token.
func (s Snapshot) Fingerprint() string {
body, _ := json.Marshal(s)
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,137 @@
package companion
import (
"fmt"
"strings"
)
// AITemplateSelector binds one saved profile to a verified AICharacter.lst
// entry. The selector is part of the offline contract; it does not imply
// that the current client has loaded or executed the entry.
type AITemplateSelector struct {
Key string `json:"key"`
Job uint8 `json:"job"`
ClassID uint16 `json:"class_id"`
Behavior string `json:"behavior"`
ComboPreset string `json:"combo_preset"`
APCCode int64 `json:"apc_code"`
AICPath string `json:"aic_path"`
BaseAICPath string `json:"base_aic_path"`
Legacy bool `json:"legacy"`
}
type aiTemplateVariant struct {
Code int64
Path string
}
type aiTemplatePair struct {
Profession string
Assist aiTemplateVariant
Aggressive aiTemplateVariant
}
// These are the stable APC/AIC pairs selected by the checked-in client native
// projection contract (90CN_companion_p0.h) and verified against the current
// AICharacter.lst. The server must patch the AIC that the client actually
// spawns. The old friendly-doll entries (for example christian_ronaldo and
// yugo) are valid archive assets, but they are not the active baseline APCs
// used by the native companion projection and therefore cannot change live
// behaviour.
//
// Assist and aggressive profiles intentionally share the active baseline AIC
// for a job. The profile-specific action/destination/move programs are
// compiled into that AIC; the caller must build/select one profile for a
// baseline at a time because a single PVF cannot contain two different
// programs at the same archive path.
var aiTemplatePairs = map[uint8]aiTemplatePair{
0: {"swordman", aiTemplateVariant{900, "aicharacter/_bizarre/swordman/mirror_swordman/mirror_swordman.aic"}, aiTemplateVariant{900, "aicharacter/_bizarre/swordman/mirror_swordman/mirror_swordman.aic"}},
1: {"fighter", aiTemplateVariant{5405, "aicharacter/fighter/arad_fighter/arad_fighter_doll.aic"}, aiTemplateVariant{5405, "aicharacter/fighter/arad_fighter/arad_fighter_doll.aic"}},
2: {"gunner", aiTemplateVariant{10903, "aicharacter/_bizarre/gunner/mirror_gunner/mirror_gunner.aic"}, aiTemplateVariant{10903, "aicharacter/_bizarre/gunner/mirror_gunner/mirror_gunner.aic"}},
3: {"mage", aiTemplateVariant{15902, "aicharacter/_bizarre/magician/mirror_magician/mirror_magician.aic"}, aiTemplateVariant{15902, "aicharacter/_bizarre/magician/mirror_magician/mirror_magician.aic"}},
4: {"priest", aiTemplateVariant{20902, "aicharacter/_bizarre/priest/mirror_priest/mirror_priest.aic"}, aiTemplateVariant{20902, "aicharacter/_bizarre/priest/mirror_priest/mirror_priest.aic"}},
5: {"atgunner", aiTemplateVariant{25900, "aicharacter/_bizarre/atgunner/mirror_atgunner/mirror_atgunner.aic"}, aiTemplateVariant{25900, "aicharacter/_bizarre/atgunner/mirror_atgunner/mirror_atgunner.aic"}},
6: {"thief", aiTemplateVariant{30901, "aicharacter/_bizarre/Thief/mirror_Thief/mirror_Thief.aic"}, aiTemplateVariant{30901, "aicharacter/_bizarre/Thief/mirror_Thief/mirror_Thief.aic"}},
7: {"atfighter", aiTemplateVariant{35903, "aicharacter/_bizarre/atfighter/mirror_atfighter/mirror_atfighter.aic"}, aiTemplateVariant{35903, "aicharacter/_bizarre/atfighter/mirror_atfighter/mirror_atfighter.aic"}},
8: {"atmage", aiTemplateVariant{40800, "aicharacter/autocharacter/atmage/auto1/auto_atmage1.aic"}, aiTemplateVariant{40800, "aicharacter/autocharacter/atmage/auto1/auto_atmage1.aic"}},
9: {"demonic_swordman", aiTemplateVariant{902, "aicharacter/_bizarre/swordman/despair_darkknight/despair_darkknight.aic"}, aiTemplateVariant{902, "aicharacter/_bizarre/swordman/despair_darkknight/despair_darkknight.aic"}},
10: {"creator", aiTemplateVariant{15903, "aicharacter/_bizarre/magician/despair_creator/despair_creator.aic"}, aiTemplateVariant{15903, "aicharacter/_bizarre/magician/despair_creator/despair_creator.aic"}},
11: {"atswordman", aiTemplateVariant{56901, "aicharacter/_bizarre/atswordman/despair_atswordman/despair_atswordman.aic"}, aiTemplateVariant{56901, "aicharacter/_bizarre/atswordman/despair_atswordman/despair_atswordman.aic"}},
12: {"knight", aiTemplateVariant{60901, "aicharacter/_bizarre/knight/despair_knight/despair_knight.aic"}, aiTemplateVariant{60901, "aicharacter/_bizarre/knight/despair_knight/despair_knight.aic"}},
13: {"demonic_lancer", aiTemplateVariant{65001, "aicharacter/demoniclancer/mirror_demonicLancer/mirror_demonicLancer.aic"}, aiTemplateVariant{65001, "aicharacter/demoniclancer/mirror_demonicLancer/mirror_demonicLancer.aic"}},
// job 14(女圣职者)必须保持 70003 `atpriest/despair_atpriest.aic`,**不要改成 70002**。
//
// 曾试过改成 70002 `atpriest/mirror_demoniclancer.aic`,实测完全无效。原因是
// 选哪个 APC 由**客户端内置基线表**决定,服务端的 ai_template 只决定“补哪条 AIC”。
//
// 90CN_companion_p0.h 的基线表格式是 {job, sourceGrow, apcCode, level, aicJob, aicGrow}:
//
// {14,0,70003,70,14,0}, // <- stable 外壳(sourceGrow=0 且 aicGrow=0)
// {14,1,71200,86,14,1},
// {14,2,71203,86,14,2},
//
// ProjectionBaselineContract::TrySelect 的逻辑是
//
// selected = stable ? *stable : *exact; // 优先 grow=0 的稳定外壳
//
// 所以奶妈 grow_type=33(firstGrow=1, awakening=2)虽然 exact 命中 {14,1,71200},
// 最终仍被 stable 抢走,**客户端永远用 APC 70003 的 AIC**。服务端把模板改成 70002
// 只会让覆盖打到一条没人用的 AIC 上,反而让 70003 丢掉 12 格快捷栏程序 —— 表现为
// “能召唤但不放技能、不攻击”。
//
// 另外注意:外壳 APC 固定是 grow=0 的形态,客户端只把源角色的 grow/觉醒/技能桥接
// 到 AIC 的每实例副本上,**视觉职业仍然是那个 grow=0 外壳**。所以支援兵看起来
// “未觉醒”是这套投影设计的固有限制,不是模板选错。
14: {"atpriest", aiTemplateVariant{70003, "aicharacter/atpriest/despair_atpriest/despair_atpriest.aic"}, aiTemplateVariant{70003, "aicharacter/atpriest/despair_atpriest/despair_atpriest.aic"}},
15: {"gunblader", aiTemplateVariant{75001, "aicharacter/gunblader/mirror_gunblader/mirror_gunblader.aic"}, aiTemplateVariant{75001, "aicharacter/gunblader/mirror_gunblader/mirror_gunblader.aic"}},
}
func SelectAITemplate(profile AIProfile, job uint16) AITemplateSelector {
profile = NormalizeAIProfile(profile)
job8 := uint8(job)
pair, ok := aiTemplatePairs[job8]
if !ok {
job8 = 1
pair = aiTemplatePairs[job8]
}
classID := profile.ClassID
if classID == 0 {
classID = job
}
variant := pair.Assist
if profile.Behavior == "aggressive" {
variant = pair.Aggressive
}
return AITemplateSelector{
Key: fmt.Sprintf("%s/%s/job-%02d/class-%d/%s", pair.Profession, profile.Behavior, job8, classID, artifactSlug(profile.ComboPreset)),
Job: job8,
ClassID: classID,
Behavior: profile.Behavior,
ComboPreset: profile.ComboPreset,
APCCode: variant.Code,
AICPath: variant.Path,
BaseAICPath: variant.Path,
Legacy: variant.Code == 5405,
}
}
// ResolveAITemplate is the descriptive name used by server projection code;
// SelectAITemplate remains the compiler-facing alias.
func ResolveAITemplate(profile AIProfile, job uint16) AITemplateSelector {
return SelectAITemplate(profile, job)
}
func ValidateAITemplateSelector(selector AITemplateSelector) error {
if strings.TrimSpace(selector.Key) == "" || selector.APCCode <= 0 || selector.AICPath == "" {
return ErrSelection
}
want := SelectAITemplate(AIProfile{Behavior: selector.Behavior, ClassID: selector.ClassID, ComboPreset: selector.ComboPreset}, uint16(selector.Job))
if selector.APCCode != want.APCCode || !strings.EqualFold(selector.AICPath, want.AICPath) {
return ErrSelection
}
return nil
}
func (s AITemplateSelector) Valid() bool {
return ValidateAITemplateSelector(s) == nil
}