1073 lines
39 KiB
Go
1073 lines
39 KiB
Go
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
|
||
}
|