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 }