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 TeamMode bool TeamLeaderID uint16 TeamSize int Online bool OnlineAt time.Time OfflineAt time.Time OfflineReason string LogoutSent bool NativePartyID uint16 NativePartySent 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 }