package dnfbridge import ( "context" "encoding/binary" "errors" "fmt" "os" "sort" "strconv" "strings" "time" dnfcompanion "longheng.io/server/internal/modules/dnf/companion" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) const ( companionReasonReady = "ready" companionReasonPartialReady = "partial_ready" companionReasonDisabled = "disabled" companionReasonNoSource = "source_not_selected" companionReasonSelf = "source_is_owner" companionReasonFeatureGate = "feature_gate_disabled" companionReasonRepository = "repository_unavailable" companionReasonOwnerAccount = "owner_account_unavailable" companionReasonReader = "consistent_reader_unavailable" companionReasonAccountMismatch = "account_mismatch" companionReasonSourceUnavailable = "source_unavailable" companionReasonSourceOnline = "source_online" companionReasonSnapshotInvalid = "snapshot_invalid" companionReasonConfigInvalid = "config_invalid" companionReasonConsistentReadFailed = "consistent_read_failed" ) type currentDungeonCompanionSourceRun struct { Ready bool Reason string ReasonMessage string SourceCharacterID uint16 SourceName string SourceOnline bool BalanceEnabled bool RandomEquipmentEnabled bool AIProfile dnfcompanion.AIProfile AITemplate dnfcompanion.AITemplateSelector AICompileStatus string AICArtifactSHA256 string LuaArtifactSHA256 string AIMissingCapabilities []dnfcompanion.StrategyCapability EffectiveLevel int SnapshotFingerprint string SnapshotAt time.Time Projection dnfcompanion.VisualProjection Body []byte ProgressionSnapshot dnfrepo.CompanionSourceState ProjectionRunToken uint64 } type currentDungeonCompanionRun struct { ConfigRevision uint64 ConfigSource string Enabled bool BalanceEnabled bool RandomEquipmentEnabled bool Ready bool Reason string ReasonMessage string SourceCharacterID uint16 SourceName string SourceOnline bool SnapshotFingerprint string SnapshotAt time.Time Projection dnfcompanion.VisualProjection Body []byte Sources []currentDungeonCompanionSourceRun } type CompanionControlSourceAttempt struct { Ready bool `json:"ready"` Reason string `json:"reason"` ReasonMessage string `json:"reason_message"` SourceCharacterID uint16 `json:"source_character_id,omitempty"` SourceName string `json:"source_name,omitempty"` SourceOnline bool `json:"source_online"` BalanceEnabled bool `json:"balance_enabled"` RandomEquipmentEnabled bool `json:"random_equipment_enabled"` AIProfile dnfcompanion.AIProfile `json:"ai_profile"` AITemplate dnfcompanion.AITemplateSelector `json:"ai_template"` AICompileStatus string `json:"ai_compile_status,omitempty"` AICArtifactSHA256 string `json:"aic_artifact_sha256,omitempty"` LuaArtifactSHA256 string `json:"lua_artifact_sha256,omitempty"` AIMissingCapabilities []dnfcompanion.StrategyCapability `json:"ai_missing_capabilities,omitempty"` EffectiveLevel int `json:"effective_level,omitempty"` SnapshotFingerprint string `json:"snapshot_fingerprint,omitempty"` SnapshotAt time.Time `json:"snapshot_at,omitempty"` } type CompanionControlAttempt struct { Ready bool `json:"ready"` Reason string `json:"reason"` ReasonMessage string `json:"reason_message"` ConfigRevision uint64 `json:"config_revision"` ConfigSource string `json:"config_source"` SourceCharacterID uint16 `json:"source_character_id,omitempty"` SourceName string `json:"source_name,omitempty"` SourceOnline bool `json:"source_online"` BalanceEnabled bool `json:"balance_enabled"` RandomEquipmentEnabled bool `json:"random_equipment_enabled"` EffectiveLevel int `json:"effective_level,omitempty"` SnapshotFingerprint string `json:"snapshot_fingerprint,omitempty"` SnapshotAt time.Time `json:"snapshot_at,omitempty"` SelectedCount int `json:"selected_count"` ReadyCount int `json:"ready_count"` Sources []CompanionControlSourceAttempt `json:"sources,omitempty"` } type CompanionControlState struct { Available bool `json:"available"` Mode string `json:"mode"` OwnerCharacterID uint16 `json:"owner_character_id"` ConfigFound bool `json:"config_found"` ConfigSource string `json:"config_source"` Config dnfcompanion.Config `json:"config"` AIRuntime CompanionAIRuntimeState `json:"ai_runtime"` Preflight CompanionControlAttempt `json:"preflight"` ActiveRun *CompanionControlAttempt `json:"active_run,omitempty"` } // CompanionAIRuntimeState makes the configuration/execution boundary // explicit. A saved profile is not reported as running until the client // action executor has loaded it and returned a real action acknowledgement. type CompanionAIRuntimeState struct { Configured bool `json:"configured"` Applied bool `json:"applied"` State string `json:"state"` Reason string `json:"reason"` CompileStatus string `json:"compile_status"` ArtifactSHA256 string `json:"artifact_sha256,omitempty"` LuaSource string `json:"lua_source,omitempty"` MissingCapabilities []dnfcompanion.StrategyCapability `json:"missing_capabilities,omitempty"` StrategyWarnings []string `json:"strategy_warnings,omitempty"` } func companionAIRuntimeState(cfg dnfcompanion.Config) CompanionAIRuntimeState { configured := !cfg.AI.IsZero() || cfg.AIProfiles != nil if !configured { return CompanionAIRuntimeState{ Configured: false, Applied: false, State: "not_configured", Reason: "尚未保存支援兵策略。", CompileStatus: "not_configured", } } profile := dnfcompanion.DefaultAIProfile(0) if len(cfg.AIProfiles) > 0 { keys := make([]string, 0, len(cfg.AIProfiles)) for key := range cfg.AIProfiles { keys = append(keys, key) } sort.Strings(keys) profile = cfg.AIProfiles[keys[0]] } defaults := cfg.EffectiveAI() if profile.BuffPolicy == "" { profile.BuffPolicy = defaults.BuffPolicy } if profile.ComboPreset == "" { profile.ComboPreset = defaults.ComboPreset } if profile.Tuning == nil { profile.Tuning = defaults.Tuning } compiled, err := dnfcompanion.CompileAIProfile(profile, dnfcompanion.DefaultStrategyCapabilities()) // Keep the existing wire state for older GM clients; compile_status carries // the new offline compiler result without implying client execution. state := "saved_pending_client" compileStatus := compiled.Status if err != nil { state = "compile_failed" compileStatus = "invalid" } return CompanionAIRuntimeState{ Configured: configured, Applied: false, State: state, Reason: "策略已保存并完成离线编译;当前客户端没有经过验证的队长坐标、目标选择和动作回执接口,因此不会把编译结果报告为已应用。", CompileStatus: compileStatus, ArtifactSHA256: compiled.LuaSHA256, LuaSource: compiled.LuaSource, MissingCapabilities: compiled.MissingCapabilities, StrategyWarnings: compiled.Warnings, } } func companionReasonMessage(reason string) string { switch reason { case companionReasonReady: return "配置与角色快照有效;下一次副本可生成所选队友。" case companionReasonPartialReady: return "部分支援兵已就绪;下一次副本只生成当前可用的所选角色。" case companionReasonSelf: return "当前出战角色本身不会作为支援兵重复生成。" case companionReasonDisabled: return "召唤队友开关已关闭。" case companionReasonNoSource: return "尚未选择召唤角色。" case companionReasonFeatureGate: return "客户端/服务端 companion runtime gate 未启用。" case companionReasonRepository: return "角色或设置仓储当前不可用。" case companionReasonOwnerAccount: return "无法确定当前角色所属账号。" case companionReasonReader: return "服务端缺少单事务 companion snapshot reader。" case companionReasonAccountMismatch: return "所选角色与当前角色不属于同一账号。" case companionReasonSourceUnavailable: return "所选角色不存在、已删除或当前不可读取。" case companionReasonSourceOnline: return "所选角色当前在线;为避免双重实体,当前副本不召唤。" case companionReasonSnapshotInvalid: return "所选角色的装备/技能快照不完整或不符合投影约束。" case companionReasonConfigInvalid: return "保存的召唤队友配置无效。" case companionReasonConsistentReadFailed: return "读取一致性角色快照失败。" default: return "召唤队友当前不可用。" } } func companionReasonFromError(err error) string { switch { case err == nil: return companionReasonReady case errors.Is(err, dnfrepo.ErrCompanionAccountMismatch), errors.Is(err, dnfcompanion.ErrAccount): return companionReasonAccountMismatch case errors.Is(err, dnfrepo.ErrCompanionCharacterUnavailable), errors.Is(err, dnfcompanion.ErrSource): return companionReasonSourceUnavailable case errors.Is(err, dnfcompanion.ErrOnline): return companionReasonSourceOnline case errors.Is(err, dnfcompanion.ErrSelection), errors.Is(err, dnfcompanion.ErrSelf): return companionReasonConfigInvalid case errors.Is(err, dnfcompanion.ErrSnapshot): return companionReasonSnapshotInvalid default: return companionReasonConsistentReadFailed } } func (r *currentDungeonCompanionRun) controlAttempt() CompanionControlAttempt { if r == nil { return CompanionControlAttempt{ Reason: companionReasonDisabled, ReasonMessage: companionReasonMessage(companionReasonDisabled), } } sources := make([]CompanionControlSourceAttempt, 0, len(r.Sources)) readyCount := 0 for _, source := range r.Sources { if source.Ready { readyCount++ } sources = append(sources, CompanionControlSourceAttempt{ Ready: source.Ready, Reason: source.Reason, ReasonMessage: source.ReasonMessage, SourceCharacterID: source.SourceCharacterID, SourceName: source.SourceName, SourceOnline: source.SourceOnline, BalanceEnabled: source.BalanceEnabled, RandomEquipmentEnabled: source.RandomEquipmentEnabled, AIProfile: source.AIProfile, AITemplate: source.AITemplate, AICompileStatus: source.AICompileStatus, AICArtifactSHA256: source.AICArtifactSHA256, LuaArtifactSHA256: source.LuaArtifactSHA256, AIMissingCapabilities: append([]dnfcompanion.StrategyCapability(nil), source.AIMissingCapabilities...), EffectiveLevel: source.EffectiveLevel, SnapshotFingerprint: source.SnapshotFingerprint, SnapshotAt: source.SnapshotAt, }) } return CompanionControlAttempt{ Ready: r.Ready, Reason: r.Reason, ReasonMessage: r.ReasonMessage, ConfigRevision: r.ConfigRevision, ConfigSource: r.ConfigSource, SourceCharacterID: r.SourceCharacterID, SourceName: r.SourceName, SourceOnline: r.SourceOnline, BalanceEnabled: r.BalanceEnabled, RandomEquipmentEnabled: r.RandomEquipmentEnabled, EffectiveLevel: r.Projection.Level, SnapshotFingerprint: r.SnapshotFingerprint, SnapshotAt: r.SnapshotAt, SelectedCount: len(r.Sources), ReadyCount: readyCount, Sources: sources, } } func cloneCurrentDungeonCompanionRun(in *currentDungeonCompanionRun) *currentDungeonCompanionRun { if in == nil { return nil } out := *in out.Projection = in.Projection.Clone() out.Body = append([]byte(nil), in.Body...) out.Sources = make([]currentDungeonCompanionSourceRun, len(in.Sources)) for i := range in.Sources { out.Sources[i] = in.Sources[i] out.Sources[i].Projection = in.Sources[i].Projection.Clone() out.Sources[i].Body = append([]byte(nil), in.Sources[i].Body...) state := in.Sources[i].ProgressionSnapshot state.Owner = dnfrepo.CloneCharacter(state.Owner) state.Source = dnfrepo.CloneCharacter(state.Source) state.Equipment = dnfrepo.CloneEquipment(state.Equipment) state.Skill = dnfrepo.CloneSkill(state.Skill) out.Sources[i].ProgressionSnapshot = state } return &out } func currentDungeonCompanionReadyCount(run *currentDungeonCompanionRun) int { if run == nil { return 0 } if len(run.Sources) == 0 { if run.Ready { return 1 } return 0 } count := 0 for _, source := range run.Sources { if source.Ready { count++ } } return count } func currentDungeonCompanionRunReady(runtime *runtimeDungeonState) bool { return runtime != nil && runtime.Companion != nil && runtime.Companion.Ready } // currentDungeonCompanionServerStartMapActorEnabled 决定服务端是否**自己** // 把支援兵 APC 注入副本的 START_MAP actor 列表。 // // 两种创建方式互斥,绝不能同时生效,否则客户端与服务端各建一个、出现重复 actor: // // ① 客户端执行器创建 —— 安装了 90CN 客户端补丁时走这条。服务端只下发 // 私有 opcode 0x7F41 投影,actor 由客户端的原生召唤路径建立。 // 此时服务端**必须不注入**(Server-90 的 // TestCompanionPersistedRunDoesNotInjectServerStartMapActor 就是这个契约)。 // // ② 服务端自建 —— 客户端**未打补丁**时走这条。APC 作为一条普通的 // AI角色(extended actor) 行放进 START_MAP,客户端原生就能渲染, // 因此【不需要】任何客户端补丁。 // // 本部署选择 ②:用户明确要求不动客户端文件。所以这里只认环境开关 // (DNF_COMPANION_P0_STARTMAP_APC=1),不再要求 ConfigSource 是 // environment_fallback —— 那样一旦在 GM 工具里保存过配置 // (ConfigSource 变成 account_settings)自建路径就被关掉了。 // // 默认关闭:不设环境变量时行为与移植前完全一致。 func currentDungeonCompanionServerStartMapActorEnabled(run *currentDungeonCompanionRun) bool { return run != nil && run.Ready && currentDungeonCompanionP0StartMapEnabled() } func (s *Service) loadCurrentDungeonCompanionConfig( ctx context.Context, ownerID string, accountID string, repos dnfrepo.Group, ) (dnfcompanion.Config, bool, string, error) { if repos.Settings == nil { return dnfcompanion.DefaultConfig(), false, "settings", dnfrepo.ErrCompanionReadUnavailable } accountID = strings.TrimSpace(accountID) if accountID == "" && repos.Character != nil { owner, found, err := repos.Character.Load(ctx, ownerID) if err != nil { return dnfcompanion.Config{}, false, "account_settings", err } if found && owner.Stats["delete_flag"] == 0 { accountID = strings.TrimSpace(owner.AccountID) } } if accountID != "" { scope, err := dnfcompanion.AccountSettingsScope(accountID) if err != nil { return dnfcompanion.Config{}, false, "account_settings", err } record, found, err := repos.Settings.Load(ctx, scope) if err != nil { return dnfcompanion.Config{}, false, "account_settings", err } if found { cfg, err := dnfcompanion.ConfigFromAccountSettings(record, accountID) return cfg, true, "account_settings", err } } // Character scope is read-only migration compatibility for phase 1/2 rows. scope, err := dnfcompanion.SettingsScope(ownerID) if err != nil { return dnfcompanion.Config{}, false, "legacy_character_settings", err } record, found, err := repos.Settings.Load(ctx, scope) if err != nil { return dnfcompanion.Config{}, false, "legacy_character_settings", err } if found { cfg, err := dnfcompanion.ConfigFromSettings(record, ownerID) return cfg, true, "legacy_character_settings", err } if sourceID, ok := currentDungeonCompanionProjectionSourceIDValue( os.Getenv(currentDungeonCompanionProjectionSourceEnv)); ok { source := strconv.FormatUint(uint64(sourceID), 10) return dnfcompanion.Config{ SchemaVersion: 1, Revision: 0, Enabled: true, SourceCharacterID: source, SourceCharacterIDs: []string{source}, }, false, "environment_fallback", nil } return dnfcompanion.DefaultConfig(), false, "settings_default", nil } func (s *Service) evaluateCurrentDungeonCompanionSource( ctx context.Context, ownerCharacterID uint16, ownerID string, accountID string, sourceRaw string, reader dnfrepo.CompanionSourceReader, balanceEnabled bool, randomEquipmentEnabled bool, aiProfile dnfcompanion.AIProfile, ) currentDungeonCompanionSourceRun { entry := currentDungeonCompanionSourceRun{ Reason: companionReasonConfigInvalid, AIProfile: aiProfile, } entry.ReasonMessage = companionReasonMessage(entry.Reason) sourceID, ok := currentDungeonCompanionProjectionSourceIDValue(sourceRaw) if !ok { return entry } entry.SourceCharacterID = sourceID if sourceID == ownerCharacterID { entry.Reason = companionReasonSelf entry.ReasonMessage = companionReasonMessage(entry.Reason) return entry } _, entry.SourceOnline = s.onlineGameSession(sourceID) state, err := reader.ReadCompanionSource( ctx, accountID, ownerID, strconv.FormatUint(uint64(sourceID), 10), ) if err != nil { entry.Reason = companionReasonFromError(err) entry.ReasonMessage = companionReasonMessage(entry.Reason) return entry } selection := dnfcompanion.Selection{ AccountID: accountID, OwnerCharacterID: ownerID, SourceCharacterID: strconv.FormatUint(uint64(sourceID), 10), OnlineStateKnown: true, SourceOnline: entry.SourceOnline, } now := s.gameplayNow() var projectionOptions []currentDungeonCompanionProjectionOptions var balanceSummary currentDungeonCompanionBalanceSummary var equipmentErr error if randomEquipmentEnabled { // Work on a detached, runtime-only record. This choice is frozen in // the run body and reused by room transitions and resurrection. state.Equipment, equipmentErr = s.currentCompanionRandomEquipment(ctx, state) if equipmentErr != nil { entry.Reason = companionReasonSnapshotInvalid entry.ReasonMessage = "支援兵装备生成失败:" + equipmentErr.Error() return entry } } if balanceEnabled { if !randomEquipmentEnabled { // Work on a detached, runtime-only record. This choice is frozen in // the run body and reused by room transitions and resurrection. state.Equipment, equipmentErr = s.currentCompanionBalancedEquipment(ctx, state) } if equipmentErr != nil { entry.Reason = companionReasonSnapshotInvalid entry.ReasonMessage = "支援兵装备生成失败:" + equipmentErr.Error() return entry } effectiveLevel, mode1, summary, balanceErr := s.buildCurrentDungeonCompanionBalanceMode1(ctx, nil, state) if balanceErr != nil { entry.Reason = companionReasonSnapshotInvalid entry.ReasonMessage = companionReasonMessage(entry.Reason) return entry } balanceSummary = summary projectionOptions = append(projectionOptions, currentDungeonCompanionProjectionOptions{ EffectiveLevel: effectiveLevel, Mode1: mode1, }) entry.BalanceEnabled = true entry.EffectiveLevel = effectiveLevel } entry.RandomEquipmentEnabled = randomEquipmentEnabled if len(projectionOptions) == 0 { projectionOptions = append(projectionOptions, currentDungeonCompanionProjectionOptions{AIProfile: aiProfile}) } else { projectionOptions[0].AIProfile = aiProfile } projection, body, err := buildCurrentDungeonCompanionProjectionEnvelope( state, selection, now, projectionOptions...) if err != nil { entry.Reason = companionReasonFromError(err) entry.ReasonMessage = companionReasonMessage(entry.Reason) return entry } entry.Ready = true entry.Reason = companionReasonReady entry.ReasonMessage = companionReasonMessage(entry.Reason) entry.SourceName = projection.Name entry.SnapshotFingerprint = projection.Fingerprint() entry.SnapshotAt = now entry.Projection = projection.Clone() entry.AITemplate = dnfcompanion.SelectAITemplate(aiProfile, uint16(projection.Job)) artifact, artifactErr := dnfcompanion.CompileAICArtifactForJob( aiProfile, dnfcompanion.DefaultStrategyCapabilities(), projection.Job) if artifactErr != nil { entry.Reason = companionReasonConfigInvalid entry.ReasonMessage = "支援兵 AIC 策略编译失败:" + artifactErr.Error() return entry } entry.AICompileStatus = artifact.StrategyStatus entry.AICArtifactSHA256 = artifact.SHA256 entry.LuaArtifactSHA256 = artifact.LuaSHA256 entry.AIMissingCapabilities = append([]dnfcompanion.StrategyCapability(nil), artifact.MissingCapabilities...) entry.Body = append([]byte(nil), body...) entry.ProgressionSnapshot = state if balanceEnabled { s.logPacketEvent("dnf-companion-balance-profile", "owner_character_id", ownerCharacterID, "source_character_id", sourceID, "level", balanceSummary.OwnerLevel, "hp_percent", currentDungeonCompanionBalanceHPPercent, "hp_max", balanceSummary.HPMax, "mp_max", balanceSummary.MPMax, "strength", balanceSummary.Strength, "intelligence", balanceSummary.Intelligence, "vitality", balanceSummary.Vitality, "spirit", balanceSummary.Spirit, "physical_defense", balanceSummary.PhysicalDefense, "magical_defense", balanceSummary.MagicalDefense, "physical_attack_budget", balanceSummary.PhysicalAttack, "magical_attack_budget", balanceSummary.MagicalAttack, "independent_attack_budget", balanceSummary.IndependentAttack, "move_speed", balanceSummary.MoveSpeed, "attack_speed", balanceSummary.AttackSpeed, "cast_speed", balanceSummary.CastSpeed) } return entry } func (s *Service) evaluateCurrentDungeonCompanion( ctx context.Context, ownerCharacterID uint16, accountID string, cfg dnfcompanion.Config, configSource string, ) *currentDungeonCompanionRun { run := ¤tDungeonCompanionRun{ ConfigRevision: cfg.Revision, ConfigSource: configSource, Enabled: cfg.Enabled, BalanceEnabled: cfg.BalanceEnabled, RandomEquipmentEnabled: cfg.RandomEquipmentEnabled, Reason: companionReasonDisabled, } run.ReasonMessage = companionReasonMessage(run.Reason) if !cfg.Enabled { return run } sources := cfg.Sources() if len(sources) == 0 { run.Reason = companionReasonNoSource run.ReasonMessage = companionReasonMessage(run.Reason) return run } // Persisted GM settings are authoritative. The Phase-1 environment gates // remain only for the single-source environment fallback path. if configSource == "environment_fallback" && (currentDungeonCompanionP0CaptureBudget() == 0 || !currentDungeonCompanionP0StartMapEnabled()) { run.Reason = companionReasonFeatureGate run.ReasonMessage = companionReasonMessage(run.Reason) return run } repos, ok := s.repositoryGroup() if !ok || repos.Character == nil { run.Reason = companionReasonRepository run.ReasonMessage = companionReasonMessage(run.Reason) return run } ownerID := strconv.FormatUint(uint64(ownerCharacterID), 10) if strings.TrimSpace(accountID) == "" { owner, found, err := repos.Character.Load(ctx, ownerID) if err != nil || !found || owner.Stats["delete_flag"] != 0 || strings.TrimSpace(owner.AccountID) == "" { run.Reason = companionReasonOwnerAccount run.ReasonMessage = companionReasonMessage(run.Reason) return run } accountID = strings.TrimSpace(owner.AccountID) } reader, ok := repos.Character.(dnfrepo.CompanionSourceReader) if !ok { run.Reason = companionReasonReader run.ReasonMessage = companionReasonMessage(run.Reason) return run } run.Sources = make([]currentDungeonCompanionSourceRun, 0, len(sources)) readyCount := 0 for _, source := range sources { profile := cfg.AIProfiles[source] if profile.Behavior == "" { profile = dnfcompanion.DefaultAIProfile(0) } entry := s.evaluateCurrentDungeonCompanionSource( ctx, ownerCharacterID, ownerID, accountID, source, reader, cfg.BalanceEnabled, cfg.RandomEquipmentEnabled, profile) run.Sources = append(run.Sources, entry) if entry.Ready { readyCount++ } } if len(run.Sources) != 0 { summary := run.Sources[0] for _, source := range run.Sources { if source.Ready { summary = source break } } run.SourceCharacterID = summary.SourceCharacterID run.SourceName = summary.SourceName run.SourceOnline = summary.SourceOnline run.SnapshotFingerprint = summary.SnapshotFingerprint run.SnapshotAt = summary.SnapshotAt run.Projection = summary.Projection.Clone() run.Body = append([]byte(nil), summary.Body...) } if readyCount == 0 { run.Reason = run.Sources[0].Reason run.ReasonMessage = run.Sources[0].ReasonMessage return run } run.Ready = true if readyCount == len(run.Sources) { run.Reason = companionReasonReady } else { run.Reason = companionReasonPartialReady } run.ReasonMessage = companionReasonMessage(run.Reason) return run } func (s *Service) prepareCurrentDungeonCompanionRun( ctx context.Context, session *gameSession, ) *currentDungeonCompanionRun { if s == nil || session == nil || session.selectedCharacterID == 0 { return nil } if ctx == nil { ctx = context.Background() } repos, ok := s.repositoryGroup() if !ok { run := ¤tDungeonCompanionRun{ Reason: companionReasonRepository, ReasonMessage: companionReasonMessage(companionReasonRepository), } return run } ownerID := strconv.FormatUint(uint64(session.selectedCharacterID), 10) cfg, _, source, err := s.loadCurrentDungeonCompanionConfig( ctx, ownerID, strings.TrimSpace(s.accountIDForSession(session)), repos, ) if err != nil { run := ¤tDungeonCompanionRun{ ConfigSource: source, Reason: companionReasonConfigInvalid, ReasonMessage: companionReasonMessage(companionReasonConfigInvalid), } s.logGameEvent(session, "game-dungeon-companion-run-frozen", "ready", false, "reason", run.Reason, "error", err) return run } run := s.evaluateCurrentDungeonCompanion( ctx, session.selectedCharacterID, strings.TrimSpace(s.accountIDForSession(session)), cfg, source, ) s.logGameEvent(session, "game-dungeon-companion-run-frozen", "enabled", run.Enabled, "ready", run.Ready, "reason", run.Reason, "config_revision", run.ConfigRevision, "config_source", run.ConfigSource, "source_character_id", run.SourceCharacterID, "source_online", run.SourceOnline, "snapshot_fingerprint", run.SnapshotFingerprint) return run } func (s *Service) CompanionControlState( ctx context.Context, ownerCharacterID uint16, ) (CompanionControlState, error) { state := CompanionControlState{ Available: true, Mode: "next_dungeon_frozen_snapshot", OwnerCharacterID: ownerCharacterID, Config: dnfcompanion.DefaultConfig(), } state.AIRuntime = companionAIRuntimeState(state.Config) if s == nil || ownerCharacterID == 0 { return state, dnfcompanion.ErrSelection } if ctx == nil { ctx = context.Background() } repos, ok := s.repositoryGroup() if !ok || repos.Character == nil || repos.Settings == nil { state.Available = false state.Preflight = CompanionControlAttempt{ Reason: companionReasonRepository, ReasonMessage: companionReasonMessage(companionReasonRepository), } return state, nil } ownerID := strconv.FormatUint(uint64(ownerCharacterID), 10) cfg, found, source, err := s.loadCurrentDungeonCompanionConfig( ctx, ownerID, "", repos) if err != nil { return state, err } state.Config = cfg // Expose the AiParty baseline to GM clients even for legacy rows that do // not yet contain the optional AI keys. Persistence remains untouched until // the operator saves the settings explicitly. state.Config.AI = cfg.EffectiveAI() state.AIRuntime = companionAIRuntimeState(cfg) state.ConfigFound = found state.ConfigSource = source preflight := s.evaluateCurrentDungeonCompanion(ctx, ownerCharacterID, "", cfg, source) state.Preflight = preflight.controlAttempt() if identity, ok := s.onlineGameSessionCharacterSnapshot(ownerCharacterID); ok { identity.session.dungeon.mu.Lock() var run *currentDungeonCompanionRun if identity.session.dungeon.runtime != nil { run = cloneCurrentDungeonCompanionRun(identity.session.dungeon.runtime.Companion) } identity.session.dungeon.mu.Unlock() if run != nil { attempt := run.controlAttempt() state.ActiveRun = &attempt } } return state, nil } func (s *Service) CompanionControlUpdate( ctx context.Context, ownerCharacterID uint16, expectedRevision uint64, enabled bool, sourceCharacterID uint16, switches ...bool, ) (CompanionControlState, error) { sources := []uint16{} if sourceCharacterID != 0 { sources = append(sources, sourceCharacterID) } return s.CompanionControlUpdateMany( ctx, ownerCharacterID, expectedRevision, enabled, sources, switches...) } func (s *Service) CompanionControlUpdateMany( ctx context.Context, ownerCharacterID uint16, expectedRevision uint64, enabled bool, sourceCharacterIDs []uint16, switches ...bool, ) (CompanionControlState, error) { return s.CompanionControlUpdateManyWithAI( ctx, ownerCharacterID, expectedRevision, enabled, sourceCharacterIDs, dnfcompanion.AIConfig{}, switches...) } // CompanionControlUpdateManyWithAI keeps the legacy update entry point stable // while allowing GM to persist the frozen native-AI policy in the same account // revision. A zero policy preserves the current value for old callers. func (s *Service) CompanionControlUpdateManyWithAI( ctx context.Context, ownerCharacterID uint16, expectedRevision uint64, enabled bool, sourceCharacterIDs []uint16, ai dnfcompanion.AIConfig, switches ...bool, ) (CompanionControlState, error) { return s.CompanionControlUpdateManyWithAIProfiles( ctx, ownerCharacterID, expectedRevision, enabled, sourceCharacterIDs, ai, nil, switches...) } // CompanionControlUpdateManyWithAIProfiles persists the account-scoped AI // policy together with optional per-source role and combo selections. A nil // profile map preserves the existing map for legacy GM clients. func (s *Service) CompanionControlUpdateManyWithAIProfiles( ctx context.Context, ownerCharacterID uint16, expectedRevision uint64, enabled bool, sourceCharacterIDs []uint16, ai dnfcompanion.AIConfig, profiles map[string]dnfcompanion.AIProfile, switches ...bool, ) (CompanionControlState, error) { if s == nil || ownerCharacterID == 0 { return CompanionControlState{}, dnfcompanion.ErrSelection } if ctx == nil { ctx = context.Background() } s.companionConfigMu.Lock() defer s.companionConfigMu.Unlock() repos, ok := s.repositoryGroup() if !ok || repos.Character == nil || repos.Settings == nil { return CompanionControlState{}, dnfrepo.ErrCompanionReadUnavailable } ownerID := strconv.FormatUint(uint64(ownerCharacterID), 10) owner, found, loadErr := repos.Character.Load(ctx, ownerID) if loadErr != nil { return CompanionControlState{}, loadErr } if !found || owner.Stats["delete_flag"] != 0 || strings.TrimSpace(owner.AccountID) == "" { return CompanionControlState{}, dnfrepo.ErrCompanionCharacterUnavailable } accountID := strings.TrimSpace(owner.AccountID) current, _, _, err := s.loadCurrentDungeonCompanionConfig( ctx, ownerID, accountID, repos) if err != nil { return CompanionControlState{}, err } sources := make([]string, 0, len(sourceCharacterIDs)) seen := make(map[uint16]struct{}, len(sourceCharacterIDs)) for _, sourceID := range sourceCharacterIDs { if sourceID == 0 { return CompanionControlState{}, dnfcompanion.ErrSelection } if _, ok := seen[sourceID]; ok { continue } seen[sourceID] = struct{}{} sources = append(sources, strconv.FormatUint(uint64(sourceID), 10)) } var next dnfcompanion.Config if ai.IsZero() && profiles == nil { next, err = dnfcompanion.NextConfigMany( current, expectedRevision, enabled, sources, ownerID, switches...) } else { next, err = dnfcompanion.NextConfigManyWithAIProfiles( current, expectedRevision, enabled, sources, ownerID, ai, profiles, switches...) } if err != nil { return CompanionControlState{}, err } if next.Enabled { reader, ok := repos.Character.(dnfrepo.CompanionSourceReader) if !ok { return CompanionControlState{}, dnfrepo.ErrCompanionReadUnavailable } for _, source := range next.Sources() { if source == ownerID { continue } if _, readErr := reader.ReadCompanionSource( ctx, accountID, ownerID, source, ); readErr != nil { return CompanionControlState{}, readErr } } } record, err := dnfcompanion.AccountConfigSettingsRecord( accountID, next, s.gameplayNow()) if err != nil { return CompanionControlState{}, err } if err := dnfrepo.SaveSettingsFields( ctx, repos.Settings, record, dnfrepo.SettingsFieldValues, ); err != nil { return CompanionControlState{}, fmt.Errorf("save companion config: %w", err) } return s.CompanionControlState(ctx, ownerCharacterID) } func rearmCurrentDungeonCompanionProjectionAfterRevive( runtime *runtimeDungeonState, ) uint16 { if runtime == nil { return 0 } readyCount := currentDungeonCompanionReadyCount(runtime.Companion) runtime.companionP0OwnerObjectKeys = nil if readyCount <= 0 { runtime.companionP0OwnerCaptureBudget = 0 return 0 } if readyCount > int(^uint16(0)) { readyCount = int(^uint16(0)) } runtime.companionP0OwnerCaptureBudget = uint16(readyCount) return runtime.companionP0OwnerCaptureBudget } func (s *Service) sendFrozenCurrentDungeonCompanionProjection( session *gameSession, runtime *runtimeDungeonState, source string, ) error { if s == nil || session == nil || runtime == nil { return nil } // 互斥守卫:服务端自建 APC 这条通道开启时,**不再**下发私有 opcode 0x7F41。 // // 为什么必须互斥:0x7F41 是客户端补丁约定的私有包。自建通道(文件/环境开关) // 的语义正是"这个客户端【没有】打补丁,支援兵由服务端作为一条普通 // AI角色(extended actor)行放进 START_MAP"。此时再发一个未打补丁客户端 // 不认识的私有 opcode,轻则被忽略、重则打断包处理 —— 而这条路线存在的 // 全部意义就是"不动客户端文件"。反过来,打了补丁的客户端走投影路径时, // 自建通道是关闭的,两者不会同时生效。 if currentDungeonCompanionP0StartMapEnabled() { s.logGameEvent(session, "game-dungeon-companion-projection-skipped", "source", source, "reason", "server_owned_start_map_apc_active") return nil } run := runtime.Companion if run == nil { s.logGameEvent(session, "game-dungeon-companion-projection-skipped", "source", source, "reason", "run_snapshot_unavailable") return nil } if !run.Ready || runtime.companionP0OwnerCaptureBudget == 0 { s.logGameEvent(session, "game-dungeon-companion-projection-skipped", "source", source, "selection_source", run.ConfigSource, "config_revision", run.ConfigRevision, "ready_count", currentDungeonCompanionReadyCount(run), "reason", run.Reason) return nil } entries := run.Sources if len(entries) == 0 && run.Ready { entries = []currentDungeonCompanionSourceRun{{ Ready: true, Reason: run.Reason, ReasonMessage: run.ReasonMessage, SourceCharacterID: run.SourceCharacterID, SourceName: run.SourceName, SourceOnline: run.SourceOnline, SnapshotFingerprint: run.SnapshotFingerprint, SnapshotAt: run.SnapshotAt, Projection: run.Projection.Clone(), Body: append([]byte(nil), run.Body...), AIProfile: dnfcompanion.DefaultAIProfile(uint16(run.Projection.Job)), }} } for index, entry := range entries { if !entry.Ready { s.logGameEvent(session, "game-dungeon-companion-projection-source-skipped", "source", source, "selection_source", run.ConfigSource, "config_revision", run.ConfigRevision, "source_character_id", entry.SourceCharacterID, "source_online", entry.SourceOnline, "reason", entry.Reason) continue } if len(entry.Body) == 0 { return fmt.Errorf("companion projection body missing for source=%d", entry.SourceCharacterID) } if entry.ProjectionRunToken == 0 && len(entry.Body) >= currentDungeonCompanionProjectionHeaderSize && len(run.Sources) > index { entry.ProjectionRunToken = uint64(runtime.startedAt.UnixNano()) ^ uint64(entry.SourceCharacterID) if entry.ProjectionRunToken == 0 { entry.ProjectionRunToken = 1 } entry.Body = append([]byte(nil), entry.Body...) binary.LittleEndian.PutUint64(entry.Body[8:16], entry.ProjectionRunToken) run.Sources[index] = entry } weaponID := int64(0) if entry.Projection.Weapon != nil { weaponID = entry.Projection.Weapon.DisplayItemID } s.logGameEvent(session, "game-dungeon-companion-projection-send", "source", source, "selection_source", run.ConfigSource, "config_revision", run.ConfigRevision, "projection_index", index, "projection_count", currentDungeonCompanionReadyCount(run), "owner_character_id", session.selectedCharacterID, "source_character_id", entry.SourceCharacterID, "name", entry.Projection.Name, "job", entry.Projection.Job, "grow_type", entry.Projection.GrowType, "level", entry.Projection.Level, "weapon_item_id", weaponID, "avatar_count", len(entry.Projection.Avatars), "snapshot_fingerprint", entry.SnapshotFingerprint, "snapshot_at", entry.SnapshotAt, "ai_behavior", entry.AIProfile.Behavior, "ai_class_id", entry.AIProfile.ClassID, "ai_combo_preset", entry.AIProfile.ComboPreset, "ai_combo_enabled", entry.AIProfile.ComboEnabled, "ai_template_key", entry.AITemplate.Key, "ai_template_apc", entry.AITemplate.APCCode, "ai_template_path", entry.AITemplate.AICPath, "private_opcode", currentDungeonCompanionProjectionOpcode, "body_len", len(entry.Body), "snapshot_frozen", true, "template_mutation", false) if err := s.sendGameUpperRawClass( session, currentDungeonCompanionProjectionOpcode, entry.Body, 0, ); err != nil { return err } } return nil }