Files

1073 lines
39 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 := &currentDungeonCompanionRun{
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 := &currentDungeonCompanionRun{
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 := &currentDungeonCompanionRun{
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
}