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