using System.Buffers.Binary;
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.Configuration;
using Ananta.Server.Handlers;
using Ananta.Server.Gameplay;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// Generic character switching. No protagonist-specific switch path lives here.
internal sealed partial class GameRouter
{
// 「留下来的同伴」unitId 号段。roster 用 1e11 + index(40 个 ⇒ …0000000000~…0000000039),
// 主角固定占 Profile.InitialUnitId(= 1e11),所以同伴从 1e11+100 起发号,
// 走 IdCounterStore 落盘(硬规则 104:自分配号段的高水位必须持久化)。
private const string SwitchCompanionCounter = "switchCompanion";
private const long SwitchCompanionFloor = 100000000100;
///
/// 切人演出的两段式:phase 1 由服务端挑 SwitchSpiritConfig 行并推 SyncPreSwitchSpirit,
/// 客户端播完 Timeline 后用 ReportPreSwitchSpiritFinish 回报,phase 2 才做身份移交。
/// 本类只做「落点正确」的那一份 —— 见 。
///
private bool SwitchShowEnabled => PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.Enabled;
///
/// 面板「切人演出」指定要播的那一条 SwitchSpiritConfig 行;0 = 照老规矩自己挑
/// (距离 / DisperseAnchorsByCharacter / 权重)。
///
///
/// true = 真的把切人演出(SyncPreSwitchSpirit / SyncSwitchSpiritConfigId)推出去了;
/// false = 什么演出都没播(世界没就绪 / 上一次还 pending / 角色不在可控花名册 /
/// 目标就是当前角色 / 演出开关关着 / 退化成静默换皮 / 锚点超限)。
/// 面板「切人演出」卡靠这个返回值给玩家一句人话,而不是一律回「已播放」。
///
async Task OnSwitchSpirit(RpcContext ctx, uint requestedSpiritId, bool allowShow = true, uint forcedSwitchShowId = 0)
{
var state = GetWorldState(ctx);
if (!state.Ready)
{
ctx.Session.Log.Warn($"[SWITCH-MIN] ignored before world ready spirit={requestedSpiritId}");
return false;
}
// 上一次演出没等到回执:直接把它结算掉,否则 roster 永久卡在 pending。
var timeoutMs = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.PreSwitchTimeoutMs;
if (state.PendingPreSwitchSent && state.PendingSwitchRequestedTicks != 0 &&
Environment.TickCount64 - state.PendingSwitchRequestedTicks > timeoutMs)
{
var waited = Environment.TickCount64 - state.PendingSwitchRequestedTicks;
ctx.Session.Log.Warn(
$"[SWITCH] pre-switch timeout target={state.PendingSwitchTemplateId} waited={waited}ms; forcing handoff");
await CompleteSwitchWithoutTimelineAsync(ctx, state, "pre-switch-timeout");
}
if (state.PendingSwitchTemplateId != 0)
{
ctx.Session.Log.Warn($"[SWITCH-MIN] ignored while pending target={state.PendingSwitchTemplateId}");
return false;
}
// 科斯机甲不在 Characters() 里(隐藏可控原型,IsPublish=false / CanJoin=false),
// 所以不能只查花名册。多一道闸:只有走过 ControlKesi4229938 才接受它 —— 捐赠端
// GameRouter.Switching.cs:33-38 同一处逻辑,防止客户端自己冒Requests replay 出一个
// 没有 SpiritViewData 的单位。
var target = requestedSpiritId == ClientConfigRepository.Kesi.TemplateId
&& !KesiControls4229938.GetValue(ctx.Session, _ => new()).Unlocked
? null
: ClientConfigRepository.ControlCharacter(requestedSpiritId);
if (target is null)
{
ctx.Session.Log.Warn($"[SWITCH-MIN] unknown requested spirit={requestedSpiritId}");
return false;
}
if (target.TemplateId == state.ActiveSpiritTemplateId)
{
// ★ 面板最常踩的一个「点了没反应」:目标就是当前角色,这里原本静默 return,
// 调用方却照样回报「已播放」。现在把状态传出去,由调用方说人话。
ctx.Session.Log.Info($"[SWITCH] already controlling {target.TemplateId}; nothing to do");
return false;
}
// 切角色:机械羊属于「梅卡妮卡在场上时的随从」,子弹时间属于白靖本人 ——
// 两者都绑在旧角色身上,必须在身份移交前收回,否则旧世界的实体会跟着新角色进新场景。
// (各自的 runtime 里还有 WorldEntryControlGeneration 守卫兜底,这里是即时回收。)
await StopMechanikaSheep4229938(ctx.Session);
// ★ 捐赠端 GameRouter.Switching.cs:62:切人只收摊、保留场景所有的 S002 root。
await StopLixiPolice4229938(ctx.Session);
await StopBaiJingTime4229938(ctx.Session, "switch-character");
// ⛑ 切人发起打点:AskSwitchSpiritComplete(67272187) 回报时用它算「演出真实播了多久」。
state.SwitchStartedTicks = Environment.TickCount64;
// ⛑⛑⛑ 实验开关:客户端发起的切人,服务端只回 ack,一个包都不推。
// 目的是判掉「客户端会不会自己建主角」——会的话,从 09-24 起那七样补丁全都可以删。
// 判据见 PrivateServerConfig.AskOnlyForClientSwitch 的注释(A/B/C 三种)。
// ⛑ 这个实验开关是给「客户端发起的切人」用的(注释原话:只作用于 allowShow=true 的客户端路径)。
// 面板「切人演出」卡虽然也是 allowShow=true,但它是服务端主动指定演出,不属于客户端发起
// ⇒ 用 forcedSwitchShowId != 0 把它排除掉,两套逻辑不混。
if (allowShow && forcedSwitchShowId == 0 &&
PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.AskOnlyForClientSwitch)
{
ctx.Session.Log.Info($"[SWITCH-ASKONLY] ack only, zero push target={target.TemplateId}/{target.UnitId} " +
$"active={state.ActiveSpiritTemplateId}");
return false;
}
// 这个角色上次被切换掉时留在了大世界里:unitId、位置都在 LeftBehindCharacters 里。
var switchSettings = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations;
state.LeftBehindCharacters.TryGetValue(target.TemplateId, out var leftBehind);
var targetInWorld = leftBehind is not null;
var hasLiveTransform = state.HasLastReportedPlayerTransform;
var position = hasLiveTransform
? state.LastReportedPlayerPosition
: state.WorldEntryCreateHeroPosition;
var facing = hasLiveTransform && float.IsFinite(state.LastReportedPlayerRotation.Y)
? state.LastReportedPlayerRotation.Y
: state.WorldEntryCreateHeroFacing;
// ★★ 落点的第三个来源:**角色自己的日程点位**(09-26 00:4x 加,开关
// `switchAnimations.switchToScheduleSpot`,默认开)。
// 原来只有两个来源:「上次切走时留在哪」或「玩家现在站哪」⇒ 第一次切某个角色
// (LeftBehindCharacters 里没有他)就只能在原地换皮,玩家看到的是
// 「他明明有日程在城里某个点,切过去却是在我脚下」。
// ⛑ 用**同一个**裁决函数 NpcActivityCatalog4229938.Resolve(常驻摆放 / 切人留场都用它),
// 不另写一套;查不到(主角 tag 2/3 没时刻表、该时段活动没坐标)就保持 null ⇒ 照旧原地切。
// ⛑ 距离上限 SwitchToScheduleSpotMaxMeters(0 = 不限,默认)。
NpcActivityChoice? scheduleChoice = null;
if (leftBehind is null && switchSettings.SwitchToScheduleSpot)
{
int daySecond;
uint raid;
lock (state.SyncRoot)
{
// ⛑ 时间基准收在 WorldEntryState.CurrentDaySecond 一处,本地时间(规则 149)。
daySecond = state.CurrentDaySecond();
raid = state.ActiveRaidId;
}
scheduleChoice = NpcActivityCatalog4229938.Resolve(
target.TemplateId, daySecond, raid, true, position.X, position.Y, position.Z);
}
var scheduleSpot = scheduleChoice is { Placeable: true } ? scheduleChoice : null;
if (scheduleSpot is not null && switchSettings.SwitchToScheduleSpotMaxMeters > 0f)
{
var dx = scheduleSpot.PlaceX - position.X;
var dz = scheduleSpot.PlaceZ - position.Z;
if (Math.Sqrt(dx * dx + dz * dz) > switchSettings.SwitchToScheduleSpotMaxMeters)
scheduleSpot = null;
}
// Freeze the old actor's last authoritative transform before CurrentSpirit changes ownership.
// Delayed movement packets from the old unit are ignored after the handoff, so every target
// starts exactly where the player stood when the switch was requested.
state.LastReportedPlayerPosition = position;
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
state.HasLastReportedPlayerTransform = true;
// ⛑ 旧角色「留下」的位姿必须在这里单独记一份:演出期间客户端 Timeline 会把角色
// 移动到落点并照常上报位置,phase 2 再读 LastReportedPlayerPosition 拿到的就是
// 新角色的落点(旧角色因此被冻结在新角色身边 + T-pose,16:5x 实测)。
state.SwitchOldUnitPosition = position;
state.SwitchOldUnitFacing = facing;
state.HasSwitchOldUnitPose = true;
// Minimal mode never starts SyncPreSwitchSpirit/SyncSwitchSpiritConfigId Timeline flows.
// It performs only the identity/unit handoff needed to keep the requested character controllable.
// ★★ 面板「切人演出(按动画)」卡(forcedSwitchShowId != 0)与 Z 轮盘切人是两套逻辑,
// 不共用开关:switchAnimations.enabled 是给客户端 AskSwitchSpirit 那套用的,
// 面板这条路径明确指定要播哪条演出,不该被它挡住(用户 2026-10-03 要求)。
// ⛑ 这里只放开 forcedSwitchShowId != 0 的情况,其余行为一字未改。
if (!allowShow || (!SwitchShowEnabled && forcedSwitchShowId == 0))
{
// 目标留在大世界里 ⇒ 直接移交到它所在的位置(服务端驱动 ⇒ 移交时 Leave+重建)。
// 目标没留过 ⇒ 用上面查到的**日程点位**(没有就照旧原地切)。
Vec3 directPos;
if (targetInWorld)
directPos = leftBehind!.Position;
else if (scheduleSpot is null)
directPos = position;
else
{
// ★ 同一个活动级微调 —— 切过去落的点和常驻摆放的点必须一致。
var (sx, sy, sz) = ApplyActivityOffset(scheduleSpot.Entry?.Id ?? 0u,
scheduleSpot.PlaceX, scheduleSpot.PlaceY, scheduleSpot.PlaceZ);
directPos = new Vec3(sx, sy, sz);
}
// ⛑ 朝向无表可查(规则 171②)⇒ 沿用玩家切人时的朝向;要调就配
// gameplay.rosterNpc.activityPoiOverrides[].facing(那条对常驻摆放同样生效)。
var directFacing = targetInWorld ? leftBehind!.Facing : facing;
var viaScheduleSpot = !targetInWorld && scheduleSpot is not null;
ctx.Session.Log.Info(
$"[SWITCH] show=off (allow={allowShow} enabled={SwitchShowEnabled} forced={forcedSwitchShowId}) " +
$"inWorld={targetInWorld} viaScheduleSpot={viaScheduleSpot}; direct handoff");
// ⛑⛑ 走日程点位时:常驻化很可能已经在那个点摆了一具同样的身体 ⇒ 先撤掉,
// 否则瞬移过去就是「两个里希重叠」(规则 166)。常驻循环 1.5s 才跳,等不及。
if (viaScheduleSpot)
await RetireRosterNpcForSwitchAsync(ctx.Session, target.TemplateId);
// ⛑⛑ 切回「上次被切走、留在大世界里」的角色时,先把那个留下的 NPC 收掉,
// 否则玩家会看到"两个自己"(一个受控 + 一个还在原地做自己的事)。
// 回收本来只在演出分支的 heroUnitReuse 段里做(:186),就地路径没有 ——
// 04:1x 关掉演出后 Z 轮盘全走这里,这个残留就会变成默认行为,所以补上。
if (targetInWorld && leftBehind is not null)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(leftBehind.UnitId));
state.LeftBehindCharacters.Remove(target.TemplateId);
ctx.Session.Log.Info($"[SWITCH] reclaim left-behind unit={leftBehind.UnitId} " +
$"template={target.TemplateId} pos=({leftBehind.Position.X:0.##},{leftBehind.Position.Y:0.##},{leftBehind.Position.Z:0.##})");
}
await SwitchSpiritDirectSameScene4229938(
ctx, state, target.TemplateId, target.UnitId, directPos, directFacing,
targetInWorld: targetInWorld);
return false; // 没播演出(allowShow=false 或 switchAnimations.enabled=false)
}
// 演出分支:挑一个 SwitchSpiritConfig 行,落点 = 行的 Position(不是玩家当前位置)。
// 目标留在大世界里 ⇒ 行按「离它留下位置最近」挑(镜头锚点≈角色所在),落点取留下位置。
// 否则按 DisperseAnchorsByCharacter 决定:每个角色散列到自己固定的行(全城分散),
// 或回到「离玩家最近」的老模式。
var disperse = switchSettings.DisperseAnchorsByCharacter;
SwitchAnimationChoice animation;
// ★ 面板指定了一条演出 ⇒ 直接用它,不再走「距离/散列/权重」那套挑选。
// 这是用户 2026-10-03 纠正的点:切人演出是**按动画**选的,不是按角色;同一角色有
// 十几条演出,让服务端替他挑等于玩家永远选不到想看的那条。
var forced = forcedSwitchShowId != 0 ? ClientConfigRepository.SwitchShow(forcedSwitchShowId) : null;
if (forced is not null)
{
animation = new SwitchAnimationChoice(
forced.Id, forced.Timeline,
forced.Weight > 0f && float.IsFinite(forced.Weight) ? forced.Weight : 1f,
0f, 1, 1, null,
forced.Position, forced.Facing, forced.UseZeroPosAndAngle);
ctx.Session.Log.Info($"[SWITCH] forced show id={forced.Id} tl={forced.Timeline} " +
$"desc={forced.Description}/{forced.TimelineDescription} " +
$"agents={forced.AgentIds.Count} target={target.TemplateId}");
}
else
try
{
animation = targetInWorld && switchSettings.ReturnToLeftBehindLocation
? ClientConfigRepository.RandomSwitchAnimation(target.TemplateId, state.LastSwitchShowId, leftBehind!.Position)
: disperse
? ClientConfigRepository.SwitchAnimationForTemplate(target.TemplateId, state.LastSwitchShowId)
: ClientConfigRepository.RandomSwitchAnimation(target.TemplateId, state.LastSwitchShowId, position);
}
catch (Exception ex)
{
ctx.Session.Log.Warn(
$"[SWITCH] no authored switch animation for {target.TemplateId} ({ex.Message}); direct handoff");
await SwitchSpiritDirectSameScene4229938(ctx, state, target.TemplateId, target.UnitId, position, facing,
targetInWorld: targetInWorld);
return false; // 没播演出(这一行挑不出可用演出)
}
var landing = animation.UseZeroPosAndAngle ? position : animation.Position;
var landingFacing = animation.UseZeroPosAndAngle ? facing : animation.Facing;
if (targetInWorld && switchSettings.ReturnToLeftBehindLocation)
{
landing = leftBehind!.Position;
landingFacing = leftBehind.Facing;
}
var maxDistance = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.MaxAnchorDistanceMeters;
if (maxDistance > 0f && !targetInWorld && !disperse)
{
var dx = (double)landing.X - position.X;
var dz = (double)landing.Z - position.Z;
var distance = Math.Sqrt(dx * dx + dz * dz);
if (distance > maxDistance)
{
ctx.Session.Log.Info(
$"[SWITCH] anchor too far ({distance:0.#}m > {maxDistance:0.#}m) config={animation.Id}; direct handoff");
await SwitchSpiritDirectSameScene4229938(ctx, state, target.TemplateId, target.UnitId, position, facing);
return false; // 没播演出(锚点超过 maxAnchorDistanceMeters)
}
}
state.PendingSwitchTemplateId = target.TemplateId;
state.PendingSwitchUnitId = target.UnitId;
state.PendingSwitchOldUnitId = state.ActiveSpiritUnitId;
state.PendingSwitchShowId = animation.Id;
state.PendingSwitchConfigId = animation.Id;
state.PendingSwitchPosition = position;
state.PendingSwitchFacing = facing;
state.PendingSwitchLandingPosition = landing;
state.PendingSwitchLandingFacing = landingFacing;
state.PendingSwitchControlTransferred = false;
state.PendingSwitchLandingStarted = false;
state.PendingSwitchRequestedTicks = Environment.TickCount64;
state.PendingPreSwitchSent = true;
state.PendingSwitchAgentPreSpawned = false;
state.PendingSwitchTargetInWorld = targetInWorld;
// ⛑⛑⛑ 主角换皮模式(HeroUnitReuse):主角单位固定用登录时 CreateHero 建出来的那一个,
// 切人 = 给它换 templateId。IsMe=True 只挂在它身上,换 unitId 或拆掉重建都拿不回来。
var heroUnitReuse = switchSettings.HeroUnitReuse;
var heroUnitId = heroUnitReuse ? Profile.InitialUnitId : target.UnitId;
state.PendingSwitchUnitId = heroUnitId;
// 目标角色上次被切走时留了个同伴在场上 ⇒ 先把那个同伴收掉(演出还没起,可以动单位)。
if (heroUnitReuse && leftBehind is not null)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(leftBehind.UnitId));
state.LeftBehindCharacters.Remove(target.TemplateId);
ctx.Session.Log.Info($"[SWITCH-HERO] reclaim old companion unit={leftBehind.UnitId} " +
$"template={target.TemplateId} pos=({leftBehind.Position.X:0.##},{leftBehind.Position.Y:0.##},{leftBehind.Position.Z:0.##})");
leftBehind = null;
targetInWorld = false;
}
// Phase 1a:把目标角色先投放到演出落点。这是「镜头飞过去时那里真的站着人」的关键 ——
// 走的正是 GM 生成 NPC 那条已验证的四连发(GameAdminBridge.cs:1583-1590),
// ExistingUnitProjection 的 ManagedPid=0 让它保持非受控,不会提前抢走当前角色。
// 目标已经留在大世界里 ⇒ 跳过投放(同一个 unitId 重复 Enter 没意义),
// 但语义上等同预生成:phase 2 移交前仍要 Leave+重建才能交还操控。
var spawnedAgents = new List();
if (!heroUnitReuse && targetInWorld)
{
spawnedAgents.Add(target.UnitId);
state.PendingSwitchAgentPreSpawned = true;
}
else if (!heroUnitReuse && PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.SpawnTargetBeforeShow)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(target.UnitId));
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent,
WorldCodec.ManagedLogicAgent(target.UnitId, Profile.PlayerPid, 0));
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.ExistingUnitProjection(target.UnitId, target.TemplateId, landing, landingFacing));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(target.UnitId, landing, landingFacing));
// 同上:预生成的单位也是服务端驱动的,镜头飞过去的这几秒里它同样会往下掉。
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.FeedPreSpawnedTarget)
{
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentSyncData,
WorldCodec.LogicAgentSyncData(target.UnitId, landing, landingFacing,
PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.LeftBehindFeedMoveId));
}
spawnedAgents.Add(target.UnitId);
state.PendingSwitchAgentPreSpawned = true;
}
// ⛑⛑⛑ Phase 1a3(2026-10-04):把**演出演员** spawn 到落点。
//
// 这才是「切人演出里那个在演戏的人」。官方语义见 ClientConfigRepository.cs:862 引的注释:
// "Those rows carry AgentId entries that stock SwitchTeleport expects the server to
// spawn before landing."
// ⇒ 官方 SwitchTeleport 期望服务端在落地前把这些 agent 建好。
// 主演 = 演出行的 FightSpiritIds[0](嘉姆 15020989 / 塔菲 15020992),经
// AgentIdForSpirit 映射成它在 NPC 世界的那一面(40140032 / 40140035);
// 陪衬 = 行的 AgentIds(如 59 行里希的两个混混)。
// ⛑ 和「主角单位换皮」是两件事:换皮那个是给玩家操控用的,
// 这些演员是演戏的身体,演完由服务端 Leave 掉。
state.PendingSwitchShowActors = [];
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.SpawnShowActors)
{
// ⛑ animation 是 SwitchAnimationChoice(只有位置/权重),出场名单在原始行上 ⇒ 反查。
var showRow = ClientConfigRepository.SwitchShow(animation.Id);
var cast = new List();
if (showRow is not null && showRow.FightSpiritIds.Count > 0)
{
var lead = GameAdminBridge.AgentIdForSpirit(showRow.FightSpiritIds[0]);
if (lead != 0) cast.Add(lead);
else ctx.Session.Log.Warn($"[SWITCH-SHOW] config={animation.Id} 主演 spirit={showRow.FightSpiritIds[0]} 映射不到 AgentId(FightSpiritConfig.AgentId 为空)");
}
foreach (var extra in showRow?.AgentIds ?? [])
if (extra != 0 && !cast.Contains(extra)) cast.Add(extra);
foreach (var agentId in cast)
{
// 演员是友方/中立,不该被当敌人;SpawnAgentAsync 会按 AgentSummonCatalog 的
// EnemyClassType 对账,对不上就拒绝 —— 失败只记日志,不阻断演出。
var (castOk, castMsg, castUid) = await GameAdminBridge.SpawnAgentAsync(
agentId, landing.X, landing.Y, landing.Z, landingFacing,
hostile: false, engage: false, forceEnemyRelation: false, campOverride: 0);
if (!castOk || castUid == 0)
{
ctx.Session.Log.Warn($"[SWITCH-SHOW] 演员 spawn 失败 config={animation.Id} agent={agentId} reason={castMsg}");
continue;
}
spawnedAgents.Add(castUid);
state.PendingSwitchShowActors.Add(castUid);
ctx.Session.Log.Info($"[SWITCH-SHOW] 演员就位 config={animation.Id} agent={agentId} uid={castUid} at=({landing.X:F2},{landing.Y:F2},{landing.Z:F2})");
}
}
// ⛑⛑⛑ Phase 1a2:演出开始前先把目标角色以**主角身份**重建好。
// 客户端的 LiftOffOneShot 时间线是 SyncPreSwitchSpirit 起的,全长约 14 秒
//(2026-09-25 01:31:50.011 CreateChangeLodCam → 01:32:04.308 TryDestroyChangeLodCam)。
// 把 Leave+重建放在它播放期间(ReportPreSwitchSpiritFinish 之后 = 第 3.5 秒)
// ⇒ 时间线在第 12.5 秒连抛 3 条 "SwitchChar_common_04 2 System.NullReferenceException"
// ⇒ 主角再也建不出来 ⇒ InitModules IsMe False ⇒ 原地跑步、坐标不动。
// ⇒ 任何 Leave / 重建一律挪到发 PreSwitch 之前。
state.PendingSwitchHeroPrebuilt = false;
if (heroUnitReuse)
{
// 主角换皮:Enter 只是把 id 注册进客户端的 logic-agent 同步管理器,
// projection 把该单位换成新 templateId。
// ⛑ 全程**不 Leave**(规则 134 + 02:0x 实测:Leave 掉这个单位就再也拿不回 IsMe=True)。
//
// ⛑⛑⛑ 但「不 Leave」正是「只有一张脸」的根因(规则 141):BaseUnit 从不销毁重建
// ⇒ 身体部件不按新 templateId 重载。而「独立 unitId + 完整重建」虽然模型正常却**走不动**
//(客户端只对登录时那个 unitId 应用本地移动输入)。
// ⇒ `HeroUnitRebuildOnSwitch`:id 保持不变(保住移动 / IsMe),但把单位销毁重建(修模型)。
// ⛑⛑⛑ 顺序与已验证能出模型的 `RecreateHeroBeforeShow` 分支严格对齐:
// Leave → delay → **Enter → Managed → projection** → PosFacing
// 03:4x 第一版把 projection 放在 Enter **之前**(Leave → projection → Enter),
// 实测仍然只剩脸 —— 猜测 projection 先到会被当成"更新已存在的单位"而不是"新建",
// 于是身体部件照样不重载。必须先 Enter 把 id 注册进 logic-agent 同步管理器,
// 再让 projection 到达。
if (switchSettings.HeroUnitRebuildOnSwitch)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(heroUnitId));
var rebuildDelay = switchSettings.RecreateDelayMs;
if (rebuildDelay > 0)
await Task.Delay((int)Math.Min(rebuildDelay, 2000));
ctx.Session.Log.Info($"[SWITCH-HERO] rebuild-before-reskin unit={heroUnitId} -> template={target.TemplateId} " +
$"delay={rebuildDelay}ms");
}
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(heroUnitId));
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent,
WorldCodec.ManagedLogicAgent(heroUnitId, Profile.PlayerPid, 0));
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.CharacterUnitProjection(heroUnitId, target.TemplateId, landing, landingFacing, state));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(heroUnitId, landing, landingFacing));
state.PendingSwitchAgentPreSpawned = true;
state.PendingSwitchHeroPrebuilt = true;
spawnedAgents.Add(heroUnitId);
ctx.Session.Log.Info($"[SWITCH-HERO] reskin unit={heroUnitId} -> template={target.TemplateId} " +
$"landing=({landing.X:0.##},{landing.Y:0.##},{landing.Z:0.##}) facing={landingFacing:0.##}");
}
else if (switchSettings.RecreateHeroBeforeShow && state.PendingSwitchAgentPreSpawned)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(target.UnitId));
var heroDelay = switchSettings.RecreateDelayMs;
if (heroDelay > 0)
await Task.Delay((int)Math.Min(heroDelay, 2000));
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(target.UnitId));
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent,
WorldCodec.ManagedLogicAgent(target.UnitId, Profile.PlayerPid, 0));
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.CharacterUnitProjection(target.TemplateId, landing, landingFacing, state));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(target.UnitId, landing, landingFacing));
state.PendingSwitchHeroPrebuilt = true;
ctx.Session.Log.Info($"[SWITCH-HERO] prebuilt before show target={target.TemplateId}/{target.UnitId} " +
$"landing=({landing.X:0.##},{landing.Y:0.##},{landing.Z:0.##}) facing={landingFacing:0.##}");
}
// Phase 1b:客户端从这一步接手播 Timeline。position 仍是玩家发起切人时站的位置(镜头起点),
// 落点走 SyncSwitchSpiritConfigId 的 position + spawnedAgentIds。
await ctx.NotifyAsync(MethodId.SyncPreSwitchSpirit,
WorldCodec.PreSwitchSpirit(animation.Id, target.TemplateId, position));
state.PendingSwitchConfigPublished = false;
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.PublishConfigIdEarly)
{
await ctx.NotifyAsync(MethodId.SyncSwitchSpiritConfigId,
WorldCodec.SwitchSpiritConfigId(animation.Id, landing, spawnedAgents));
state.PendingSwitchConfigPublished = true;
}
ctx.Session.Log.Info(
$"[SWITCH] pre-switch config={animation.Id} timeline={animation.Timeline} anchorDist={animation.AnchorDistance:0.#} " +
$"pool={animation.TimelinePool}/{animation.RowPool} prevTimeline={animation.PreviousTimeline ?? ""} " +
$"target={target.TemplateId}/{target.UnitId} oldUnit={state.PendingSwitchOldUnitId} " +
$"preSpawn={state.PendingSwitchAgentPreSpawned} heroPrebuilt={state.PendingSwitchHeroPrebuilt} inWorld={targetInWorld} disperse={disperse} configEarly={state.PendingSwitchConfigPublished} " +
$"from=({position.X:0.##},{position.Y:0.##},{position.Z:0.##}) " +
$"landing=({landing.X:0.##},{landing.Y:0.##},{landing.Z:0.##}) facing={landingFacing:0.##}");
return true; // 演出包已推出去
}
///
/// Phase 2:客户端播完 pre-switch Timeline。落点早已定好(见 ),
/// 这里只把控制权交给已经站在落点上的那个角色,不再把它挪回玩家发起切人时的位置。
///
async Task OnPreSwitchSpiritFinish(RpcContext ctx)
{
var state = GetWorldState(ctx);
if (!state.PendingPreSwitchSent || state.PendingSwitchTemplateId == 0)
{
ctx.Session.Log.Warn("[SWITCH] ReportPreSwitchSpiritFinish without a pending pre-switch; ignored");
return;
}
var templateId = state.PendingSwitchTemplateId;
var unitId = state.PendingSwitchUnitId;
var landing = state.PendingSwitchLandingPosition;
var landingFacing = state.PendingSwitchLandingFacing;
var configId = state.PendingSwitchConfigId;
var publishedEarly = state.PendingSwitchConfigPublished;
state.PendingPreSwitchSent = false;
state.PendingSwitchRequestedTicks = 0;
if (!publishedEarly)
{
// ⛑ 演出演员也要带上(官方语义:落地前给 SwitchTeleport 的 actor 列表)。
var spawned = new List();
if (state.PendingSwitchAgentPreSpawned) spawned.Add(unitId);
spawned.AddRange(state.PendingSwitchShowActors);
await ctx.NotifyAsync(MethodId.SyncSwitchSpiritConfigId,
WorldCodec.SwitchSpiritConfigId(configId, landing, spawned));
}
await SwitchSpiritDirectSameScene4229938(
ctx, state, templateId, unitId, landing, landingFacing, switchShowId: configId,
targetInWorld: state.PendingSwitchTargetInWorld);
// 演完收工:把这些"演戏的身体"撤掉。留着会和换皮后的主角单位重影(两个塔菲)。
await DismissSwitchShowActorsAsync(ctx, state, "pre-switch finish");
ctx.Session.Log.Info(
$"[SWITCH] pre-switch finish config={configId} target={templateId}/{unitId} count={state.SwitchCount} " +
$"configEarly={publishedEarly} landed=({landing.X:0.##},{landing.Y:0.##},{landing.Z:0.##})");
}
///
/// 撤掉这出演出 spawn 出来的演员()。
///
/// ⛑ 移除通道与召唤物一致:SyncLogicAgentLeave(68281244) —— 这类单位是
/// SpawnAgentAsync 出来的,用 SyncAetherAINpcRemove(ambient 行人通道)发了等于没发。
/// 幂等:列表清空后重复调用无副作用。
///
private static async Task DismissSwitchShowActorsAsync(RpcContext ctx, WorldEntryState state, string why)
{
if (state.PendingSwitchShowActors.Count == 0) return;
var actors = state.PendingSwitchShowActors.ToArray();
state.PendingSwitchShowActors = [];
foreach (var uid in actors)
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(uid));
ctx.Session.Log.Info($"[SWITCH-SHOW] 演员撤离 {actors.Length} uid=[{string.Join(",", actors)}] — {why}");
}
/// 演出没等到回执(丢包 / 客户端没播):按已定好的落点直接完成移交。
async Task CompleteSwitchWithoutTimelineAsync(RpcContext ctx, WorldEntryState state, string reason)
{
var templateId = state.PendingSwitchTemplateId;
var unitId = state.PendingSwitchUnitId;
var landing = state.PendingSwitchLandingPosition;
var landingFacing = state.PendingSwitchLandingFacing;
var configId = state.PendingSwitchConfigId;
state.PendingPreSwitchSent = false;
state.PendingSwitchRequestedTicks = 0;
if (templateId == 0 || unitId == 0)
{
ClearPendingSwitch(state);
// 演员也要收:这一趟演出没播成,留着就是几个站在远处的孤儿身体。
await DismissSwitchShowActorsAsync(ctx, state, "abandoned: " + reason);
return;
}
// ⛑ 兜底也落在演出锚点上:退回玩家原位置就是捐赠端那个「演了个寂寞又被拉回来」的形态。
ctx.Session.Log.Info($"[SWITCH] handoff without timeline reason={reason} target={templateId}/{unitId} config={configId}");
await SwitchSpiritDirectSameScene4229938(
ctx, state, templateId, unitId, landing, landingFacing, switchShowId: configId,
targetInWorld: state.PendingSwitchTargetInWorld);
await DismissSwitchShowActorsAsync(ctx, state, "no-timeline: " + reason);
}
private static void ClearPendingSwitch(WorldEntryState state)
{
state.PendingSwitchTemplateId = 0;
state.PendingSwitchUnitId = 0;
state.PendingSwitchOldUnitId = 0;
state.PendingSwitchShowId = 0;
state.PendingSwitchConfigId = 0;
state.PendingSwitchConfigPublished = false;
state.PendingSwitchAgentPreSpawned = false;
state.PendingSwitchHeroPrebuilt = false;
state.PendingSwitchTargetInWorld = false;
state.PendingPreSwitchSent = false;
state.PendingSwitchRequestedTicks = 0;
// ⛑ 只清记账,不就地发包(ClearPendingSwitch 是 static 且拿不到 ctx);
// 实际撤离由调用方走 DismissSwitchShowActorsAsync。
state.PendingSwitchShowActors = [];
}
///
/// Server-driven character switch, used by the admin panel instead of the in-game wheel.
///
/// The wheel (Z) is client-side only: it lists FightSpiritConfig rows whose CanJoin is true
/// (15 of 40 here) and switching from it never tells the server anything. Publishing a
/// SyncTaskRoleTeam roster cannot widen it either — a non-empty team greys out every entry the
/// team does not name (see TOPIC_切换角色.md §6), which is worse than leaving it alone.
///
/// So the panel drives directly: same handoff the client's own
/// AskSwitchSpirit takes, but without the client having to offer the character first. That
/// means every roster row is reachable, including the CanJoin=false ones (Bansy, Aileen,
/// Mimiko, Ringo, ...) and rows kept out of the roster by the placeholder-icon filter until
/// they are added through content.roster.additionalTemplateIds (15021042 Lemon Sour).
///
internal static async Task<(bool Ok, string Message)> SwitchSpiritAsync(TcpSession session, uint spiritId)
{
// _gateSessions is the only instance field and nothing on this path reads it, so a bare
// instance is enough to carry these methods. Nothing here mutates router-wide state.
var ctx = new RpcContext(
session,
new RpcPacket(RpcPacketKind.Invoke, MethodId.AskSwitchSpirit, 0, Array.Empty()),
CancellationToken.None);
try
{
// 面板切人走静默路径:面板是用来挑人的,点一下就被演出传送几百米会很难用。
// 想让面板也播演出,把这里的 allowShow 改成 true(或由配置控制)即可。
await new GameRouter().OnSwitchSpirit(ctx, spiritId, allowShow: false);
}
catch (Exception ex)
{
session.Log.Warn($"[SWITCH-MIN] admin switch failed spirit={spiritId}: {ex.Message}");
return (false, $"切换失败:{ex.Message}");
}
return (true, $"已切换到 {spiritId}(服务端直发,不切场景、不播换人演出)");
}
///
/// 面板「切人演出」卡片专用:**指定**要播的那一条 SwitchSpiritConfig 行。
///
/// ⛑ 与上面的 唯一的区别就是 allowShow:true +
/// forcedSwitchShowId —— 那条是「静默换皮」(面板挑人用),这条是「播演出」
/// (会发 SyncPreSwitchSpirit / SyncSwitchSpiritConfigId,并把玩家落到该行的 Position 锚点)。
///
/// ⛑ 201 行里 AgentId 非空的行服务端这条链路补不出随从(见 ClientConfigRepository 注释),
/// 面板默认不列这类行;真指定了也只是演出里少了那几个陪衬角色,不会崩。
///
internal static async Task<(bool Ok, string Message)> SwitchSpiritWithShowAsync(
TcpSession session, uint spiritId, uint switchShowId)
{
var ctx = new RpcContext(
session,
new RpcPacket(RpcPacketKind.Invoke, MethodId.AskSwitchSpirit, 0, Array.Empty()),
CancellationToken.None);
var row = ClientConfigRepository.SwitchShow(switchShowId);
if (row is null)
return (false, $"找不到切人演出 #{switchShowId}(SwitchSpiritConfig 里没有这一行)");
// ★★ 面板这条路径**不读** switchAnimations.enabled / askOnlyForClientSwitch ——
// 那两个是给客户端 AskSwitchSpirit(Z 轮盘)那套用的;这里是服务端主动指定演出,
// 两套逻辑不混(用户 2026-10-03 明确要求)。OnSwitchSpirit 里也已用
// forcedSwitchShowId != 0 把这两个开关对面板路径放开。
var settings = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations;
try
{
var played = await new GameRouter().OnSwitchSpirit(ctx, spiritId, allowShow: true, forcedSwitchShowId: switchShowId);
if (!played)
return (false, $"演出没播出去 —— 最常见的是「目标角色 {spiritId} 就是当前角色」," +
$"其次上一次切人还卡在 pending(等 {settings.PreSwitchTimeoutMs}ms 超时会自动结算)。" +
$"换个目标角色再试;服务端日志搜 [SWITCH] / [SWITCH-MIN] 看具体原因。" +
$"(本条演出 #{switchShowId}「{row.Description} · {row.TimelineDescription}」)");
return (true, $"已推出切人演出 #{switchShowId}「{row.Description} · {row.TimelineDescription}」" +
$"(timeline={row.Timeline},锚点 {row.Position.X:0.#},{row.Position.Y:0.#},{row.Position.Z:0.#})" +
$"→ 切成 {spiritId}。等客户端播完回报 ReportPreSwitchSpiritFinish 才真正换人。");
}
catch (Exception ex)
{
session.Log.Warn($"[SWITCH-SHOW] failed show={switchShowId} spirit={spiritId}: {ex.Message}");
return (false, $"播放切人演出失败:{ex.Message}");
}
}
///
/// 客户端 GM 控制台的「就地切人」:GmSwitchSpiritHere(69758035),
/// 参数是 uint32 的 FightSpirit id(ClientToGameSceneGMDelegate.lua:2132)。
///
/// 抓包样本(logs/unknown-methods.log):hex=D337E500 → 15020499、
/// hex=2C7D6402、hex=00000000。共 3 次 INVOKE,此前一直是
/// unimplemented —— 控制台按下去只回一个空 ack,什么都不会发生。
///
/// ⛑ 这是客户端自己的原生入口(GM 面板 324 个方法之一),不是我们造的:
/// 它证明官方确实有一条「不播演出、就地换人」的切人路径。接上它等于拿到一条
/// 完全原生的切人通道,可以拿来跟 AskSwitchSpirit 那条 14 秒演出路径做对照。
///
/// "Here" = 就地:走与 AskSwitchSpirit 完全相同的身份移交,但
/// allowShow:false ⇒ 不发 SyncPreSwitchSpirit / SyncSwitchSpiritConfigId,
/// 不播 LiftOff 演出、不传送到演出锚点(换皮 + 原地移交)。
///
[Handler(MethodId.GmSwitchSpiritHere, HandlerPacketKind.Invoke)]
private async Task GmSwitchSpiritHere(Connection conn, UxRpcMessage msg)
{
var spiritId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
// 0 字节 INVOKE:必须 ack,否则客户端这次调用会 await 到超时(规则 94)。
await conn.ReturnEmptyOkAsync(msg);
if (spiritId == 0)
{
conn.Log.Warn($"[GM-SWITCH] GmSwitchSpiritHere without a spirit id bytes={msg.Body.Length}");
return;
}
var agentId = GameAdminBridge.AgentIdForSpirit(spiritId);
var state = GetWorldState(msg.Context);
conn.Log.Info($"[GM-SWITCH] here spirit={spiritId} agent={agentId} bytes={msg.Body.Length} " +
$"active={state.ActiveSpiritTemplateId} ready={state.Ready}");
await OnSwitchSpirit(msg.Context, spiritId, allowShow: false);
}
///
/// Publish the character-wheel roster through SyncTaskRoleTeam (64534724).
///
/// Pressing Z sends nothing to the server — the rotation list is built entirely client-side, and
/// because retail never called this method the client has always used its own fallback: the
/// FightSpiritConfig rows with CanJoin=true (exactly the 15 characters the player sees, including
/// Yinglong but not Mimiko). This notify is the one server-side handle on that list
/// (SpiritManager.lua:233 <- GameToClientImpl.lua:2311 fills taskRoleTeam from enableRoleIds).
///
/// Opt-in only: an empty roster config means nothing is sent and the client keeps its default.
/// Non-empty takes the list over wholesale, so anything to keep has to be listed too.
///
/// Re-published on every scene load because lua does table.clear + refill — it is idempotent by
/// design, and re-sending is how the list survives a scene change.
///
async Task PublishSwitchRoleTeamAsync(RpcContext ctx)
{
var configured = PrivateServerConfigStore.Current.Content.Roster.SwitchableRoles;
if (configured is null || configured.Length == 0)
return;
var (ids, unknown) = TaskRoleCatalog4229938.Resolve(configured);
foreach (var dropped in unknown)
ctx.Session.Log.Warn($"[SWITCH-ROLE] dropped unresolved {dropped}");
if (ids.Length == 0)
{
ctx.Session.Log.Warn("[SWITCH-ROLE] no configured id resolved to a TaskRole row; nothing sent");
return;
}
await ctx.NotifyAsync(MethodId.SyncTaskRoleTeam, new GameMethods.SyncTaskRoleTeamNotify
{
RoleTeam = ids,
EnableRoleIds = ids,
TipRoleId = 0,
EnableSwitch = true,
});
ctx.Session.Log.Info($"[SWITCH-ROLE] published {ids.Length} roles: {string.Join(",", ids)}");
}
async Task SwitchSpiritDirectSameScene4229938(
RpcContext ctx, WorldEntryState state, uint templateId, ulong unitId, Vec3 switchPosition, float switchFacing,
uint switchShowId = 0, bool targetInWorld = false)
{
var oldUnitId = state.ActiveSpiritUnitId;
var oldTemplateId = state.ActiveSpiritTemplateId;
// 旧单位留下时的位姿:优先用在 OnSwitchSpirit 冻结的那份(演出 Timeline 移动角色时
// 会覆盖 LastReportedPlayerPosition,见 WorldEntryState.SwitchOldUnitPosition 的注释)。
var oldPosition = state.HasSwitchOldUnitPose ? state.SwitchOldUnitPosition : state.LastReportedPlayerPosition;
var oldFacing = state.HasSwitchOldUnitPose ? state.SwitchOldUnitFacing : state.LastReportedPlayerRotation.Y;
state.PendingSwitchTemplateId = templateId;
state.PendingSwitchUnitId = unitId;
state.PendingSwitchOldUnitId = oldUnitId;
state.PendingSwitchShowId = switchShowId;
state.PendingSwitchPosition = switchPosition;
state.PendingSwitchFacing = switchFacing;
state.PendingSwitchControlTransferred = false;
state.PendingSwitchLandingStarted = false;
// ⛑⛑ IsMe 是客户端「建单位那一刻」按「这个 unit 是不是它当前已知的主角 spirit」锁存的,
// 之后改不了(Player.log:登录 = "InitModules 100000000000 IsMe True" + "[LB] 进入场景 创建主角";
// 切人 = "InitModules IsMe False" + "[LB] 创建角色")。落成「角色」而不是「主角」
// ⇒ 客户端按别人/同伴的单位处理 ⇒ 头顶常驻 F,按下去是 AgentInteractPanel(个人信息/动作/离开)。
// 修法与登录流程对齐:先把客户端当前 spirit 指到新 unit,再建单位;建完之后那份
// CurrentSpirit 照旧发,负责真正绑定操控。预生成过的单位要先 Leave,让它以主角身份重建。
// targetInWorld = 目标上次被切换掉时留在了大世界,与预生成同语义:也是服务端驱动单位。
var preSpawnedTarget = state.PendingSwitchAgentPreSpawned || targetInWorld;
// ⛑⛑ 预生成的单位带着「服务端驱动」标记(建它时发过 SyncManagedLogicAgent)。
// 玩家直接接管它 ⇒ 客户端仍按服务端驱动的 actor 处理 ⇒
// **有跑步动画但位置不动**(攻击/手机正常,因为那些不走移动系统)。
// 必须 Leave 掉再重建。2026-09-24 14:2x 实测:把这段关掉后部分角色切过去就走不动。
// 注意这与头顶的 F 无关(F 是 IsMe 锁存,客户端侧摘按钮,见 switch_no_interact.lua)。
var recreate = preSpawnedTarget && !state.PendingSwitchHeroPrebuilt
&& PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.RecreatePreSpawnedTargetOnHandoff;
if (recreate)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(unitId));
var recreateDelay = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.RecreateDelayMs;
if (recreateDelay > 0)
await Task.Delay((int)Math.Min(recreateDelay, 2000));
}
// ⛑⛑ 已证伪,默认关闭:SyncPlayerCurrentSpirit(68830687) 是 LuaOnly,C# 侧不读。
// 详见 PrivateServerConfig.FixIsMeOnHandoff 的注释。留着只为记录,别再打开。
var fixIsMe = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.FixIsMeOnHandoff;
if (fixIsMe)
{
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
WorldCodec.CurrentSpirit(Profile.PlayerPid, templateId, unitId, isAgentSwitch: false));
}
// Materialize exactly one target character. No authored switch Timeline, weapons, combat graph,
// fashion inventory replay, vehicle cleanup or old-build gameplay hydration is published.
// ⛑ 演出开始前已经以主角身份建好(RecreateHeroBeforeShow)⇒ 这里不再重复下发建单位那一组:
// 演出期间再来一次 projection 等于让客户端二次重建 actor,又是一次「中途动单位」。
if (!state.PendingSwitchHeroPrebuilt)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(unitId));
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent, WorldCodec.ManagedLogicAgent(unitId, Profile.PlayerPid, 0));
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.CharacterUnitProjection(templateId, switchPosition, switchFacing, state));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(unitId, switchPosition, switchFacing));
}
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
WorldCodec.CurrentSpirit(Profile.PlayerPid, templateId, unitId, isAgentSwitch: false));
// ⛑⛑ 2026-10-03:官方切人序列里的收尾握手三包,主线从来没发过。
// 参考 CBT2 官方测试服务端 `ClientToGameserver.cs:3142-3160` 的第 5 / 5b 步;
// `SyncManagedSpirit` + `SyncManagedAgent` 的原注释是
// "explicitly transfer control (fix: frozen after switch)" ⇒ 正对「演完 T-pose 几秒」;
// `SyncPlayerSwitchSpiritInSitu` 带新角色的 spiritId ⇒ 正对「演出中角色不显示」。
// ⛑ 包体按 4229938 的签名写(2 字段),不能照抄 CBT2 的 3 字段版本。
// 开关 gameplay.switchAnimations.pushHandoffPackets(默认 true,设 false 即旧行为)。
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.PushHandoffPackets)
{
// ⛑ RpcContext.NotifyAsync 直接吃 DTO(内部序列化),不是 session.NotifyAsync 的字节版。
await ctx.NotifyAsync(MethodId.SyncPlayerSwitchSpiritInSitu,
WorldCodec.SwitchSpiritInSitu(templateId, unitId));
await ctx.NotifyAsync(MethodId.SyncManagedSpirit,
WorldCodec.ManagedUnit(Profile.PlayerPid, unitId));
await ctx.NotifyAsync(MethodId.SyncManagedAgent,
WorldCodec.ManagedUnit(Profile.PlayerPid, unitId));
ctx.Session.Log.Info($"[SWITCH] handoff pushed template={templateId} unit={unitId} " +
"(InSitu + ManagedSpirit + ManagedAgent)");
}
// CurrentSpirit can rebuild the controlled actor on the client. Re-assert the frozen transform
// after ownership transfer so the new character cannot fall back to its template/default spawn.
// ⛑ `SwitchHandoffPushPose=false` 可关掉这一发:用户 03:3x 怀疑「走不动 = 被切人坐标绑住」,
// 这是验证它的开关(关掉后若角色能走,说明 setPositionType=SwitchSpirit(3) 会把客户端
// 锁在"等待落点完成"的状态机里)。
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.SwitchHandoffPushPose)
{
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(unitId, switchPosition, switchFacing));
}
state.LastReportedPlayerPosition = switchPosition;
state.LastReportedPlayerRotation = new Vec3(0f, switchFacing, 0f);
state.HasLastReportedPlayerTransform = true;
state.ActiveSpiritTemplateId = templateId;
state.ActiveSpiritUnitId = unitId;
// ★ 登记「这个角色被玩家亲手操控过」—— 他下次被切走时不再去过日子,
// 而是停在玩家最后离开他的地方(WorldEntryState.ControlledSpirits)。
state.MarkSpiritControlled(templateId);
// The client allocates a fresh actor entity for the new character.
state.PendingSwitchControlTransferred = true;
state.ActiveSkillId = 0;
state.RestoreResourcesAfterActiveSkill = false;
state.ActiveClientSkillInstanceId = 0;
state.ActiveSkillStartedTicks = 0;
// Direct switch still needs a complete actor presentation even though combat gameplay is
// disabled. Bind the stock fashion/wheel/current weapon so the target does not become a
// half-initialized BaseUnit after CurrentSpirit changes ownership.
await PublishMinimalActorPresentation4229938(ctx, unitId, templateId, "direct-switch");
// A character's verbs live in the combat profile, and the login copy stays bound to whoever
// the player entered the world as. Without a re-publish the new character keeps the old
// unique skill: Seymour (15021023) never receives SyncChangeUniqueSkill=51943053, and his
// hack verb is that skill — which is why the hack prompt was missing entirely. Reference
// build logs show the identical profile republished with reason=direct-switch.
await PublishCombatProfileOnSwitch4229938(ctx, unitId, templateId);
if (state.ActiveFeiSuoBuffInstanceId != 0)
{
var transientUnitId = state.ActiveFeiSuoBuffUnitId != 0 ? state.ActiveFeiSuoBuffUnitId : oldUnitId;
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
WorldCodec.UnitRemoveBuff(transientUnitId, state.ActiveFeiSuoBuffInstanceId));
state.ActiveFeiSuoBuffInstanceId = 0;
state.ActiveFeiSuoBuffUnitId = 0;
}
await ClearClientWebLifecycleBuffs(ctx, oldUnitId, "minimal-direct-switch");
await PublishSafeRuntimeBuffSnapshot4229938(ctx, unitId, templateId, "minimal-direct-switch");
state.AllBuildBuffsPublished = true;
var sw = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations;
// ⛑ 换皮模式下 oldUnitId 恒等于 unitId(都是主角那个 id)⇒ 不能再拿「id 不同」当判据,
// 否则老角色一个都留不下来。
if (oldUnitId != 0 && (oldUnitId != unitId || sw.HeroUnitReuse))
{
// ⛑⛑ 科斯机甲**不走留场**:它是隐藏可控原型,留场通道会拿 AgentConfig 40140068
// 刷一个街头 NPC,而它本尊又没有动画状态 ⇒ 场上只剩一具 T-pose 残骸
//(2026-09-26 05:15 实测:切回后机甲站在原地不动,Pose 卡死)。
// 正确做法是**直接回收本尊**,一条 NPC 都不刷。
// ⛑ 前提是它的 unitId 在 SyncLogicAgentLeave 有效段(规则 165),
// 所以 ClientConfigRepository.Kesi.UnitId 走的是 0x300000000000 段。
if (oldTemplateId == ClientConfigRepository.Kesi.TemplateId)
{
state.LeftBehindCharacters.Remove(oldTemplateId);
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
ctx.Session.Log.Info($"[KESI] reclaim mecha unit={oldUnitId} " +
$"leftBehind={state.LeftBehindCharacters.Count} (no left-behind spawn)");
}
else if (sw.KeepLeftBehindCharacters && oldTemplateId != 0)
{
// 换皮模式下老角色要用**新分配的 unitId**:主角那个 id 必须留着给主角用。
// ⛑⛑⛑ 这一段必须落在 **GameAdminBridge.NextRetirableNpcUid()**(0x300000000000 段),
// 不能再用 SwitchCompanionFloor(100000000100+)—— 那是客户端**预分配的切人身份**,
// `SyncLogicAgentLeave` 对它**静默 no-op**(规则 165)。
// 09-25 21:5x 实机:每次切走里希都新发一个 100000000103 / 100000000105 …,
// 切回时那句 Leave 全部落空 ⇒ **每切一回跑步机上就多一个里希**。
var companionUnitId = sw.HeroUnitReuse
? GameAdminBridge.NextRetirableNpcUid()
: oldUnitId;
// 旧角色留在大世界:记进 LeftBehindCharacters,下次切回落点取这里。
var leftBehindEntry = new WorldEntryState.LeftBehindCharacter
{
UnitId = companionUnitId,
Position = oldPosition,
Facing = oldFacing,
};
// ⛑ 防御:同一个角色如果已经有一具留场身体(异常路径/重复切人请求),
// 覆盖前先把旧的收掉,否则那具就永远没人记得 Leave 了。
if (state.LeftBehindCharacters.TryGetValue(oldTemplateId, out var stale)
&& stale is not null && stale.UnitId != 0 && stale.UnitId != companionUnitId)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(stale.UnitId));
}
state.LeftBehindCharacters[oldTemplateId] = leftBehindEntry;
// ⛑⛑ 不能只发 PositionAndFacing 冻结:客户端在 CurrentSpirit 移交时已经把旧
// hero actor 销毁了,事后再发位置包只会重建一个没有动画状态的裸 actor
//(16:5x 实测「老角色 T-pose 站在新角色身边」)。
// 必须 Leave 掉再按预生成同款四连发重建(ExistingUnitProjection 走的就是
// GM 生成 NPC 那条路,产出的单位带待机动画,不是 T-pose)。
// ⛑⛑⛑ 但 Leave 有代价:22:2x 实测它把客户端的无缝切换状态机打崩
//(197 条 loading_switch_moving_seamless NullReferenceException,之后新接管的
// 角色 24 秒里只发得出 2 条移动包)。所以默认**不发**(LeftBehindRebuildLeave=false)——
// 客户端自己已经销毁了旧 hero actor,这里直接重建即可。
// ⛑⛑⛑ 换皮模式下**一步都不许碰 oldUnitId**:它就是主角那个单位(规则 134)。
// 老角色是以 companionUnitId 这个全新的 id 重新落地的,主角那边什么都不做。
// ★★★ 原生留场(默认开):老角色用它自己的 AgentConfig 行刷成一个真正的 NPC,
// 走的就是满城那 3978 个市民的通道 —— 见 PrivateServerConfig.LeftBehindAsNativeAgent。
var nativeAgentId = sw.LeftBehindAsNativeAgent ? GameAdminBridge.AgentIdForSpirit(oldTemplateId) : 0u;
if (nativeAgentId != 0)
{
DonorNpcCatalog4229938.TryGet(nativeAgentId, out var nativeNpc);
// ★★ 过日子:按「现在几点」从这个角色自己的日程里挑一条活动,把它刷到那条活动
// 编好的世界坐标上,并在出场包里带上 AgentDataSetsActivityCfgId。
// 全链路(三张官方表,零自造)见 PrivateServerConfig.LeftBehindDailyLife。
// ⛑ 查不到(两个主角 tag 2/3 没有时刻表 / 该时段活动没有坐标)就整段回退原逻辑:
// 留在切人时的坐标 + ambient POI,不会把角色刷到 (0,0,0)。
NpcActivityEntry4229938? dailyLife = null;
NpcActivityChoice? dailyChoice = null;
string dailyMode = "off";
// ⛑⛑ 时间基准收在 WorldEntryState.CurrentDaySecond 一处;必须是**本地时间**,
// 跟服务端其它地方同一套(规则 149)。用 UtcNow 会整整差一个时区 ——
// 09-25 实测 13:00 被算成 05:00,Richie 因此命中凌晨那段无坐标活动 ⇒ 原地不动。
var daySecond = state.CurrentDaySecond();
// ★★ 不管这次去不去过日子,都先解一次「当下的活动」,两个用途:
// ① 记进留场记录 —— 后台(LeftBehindFollowSchedule)靠它判断
// 「游戏时间是不是跨进了新的日程时段」。不记的话每个 tick 都误判
// slice changed ⇒ 1.5 秒重摆一次(09-25 跑步机堆一排里希那次的教训)。
// ② 下面要用就直接复用,**别 Resolve 两次**(两处会漂移)。
// 选择策略只有一份(NpcActivityCatalog4229938.Resolve),常驻那份也用它。
var currentChoice = NpcActivityCatalog4229938.Resolve(
oldTemplateId, daySecond, state.ActiveRaidId,
sw.LeftBehindDailyLifeRequirePosition,
oldPosition.X, oldPosition.Y, oldPosition.Z);
// ⛑⛑ 记 **Timed**(时段表当下那一段),不是 Entry(落地那条)——
// Entry 会被 FirstPositioned 兜底抹平成「与时间无关」的同一个 id,
// 拿它当判据时间跳 13 小时都不触发(2026-09-26 06:0x 实机翻车,详见字段注释)。
leftBehindEntry.TimedActivityId = currentChoice?.Timed?.Id ?? 0u;
// ★ 记下「切走他时时间被主动改过几次」,玩家后来推时间 ⇒ 数字对不上 ⇒ 该去过日子了。
leftBehindEntry.TimeEpoch = state.TimeEpoch;
// ★★ 被玩家亲手操控过的角色:切走后**不再**去过日子,
// 停在玩家最后离开他的地方(用户 09-26 03:3x 定)。
// ⛑ 但这只是「这一次不动」—— 等游戏时间跨进下一个日程时段,
// 后台会把他交给常驻化去演新活动(LeftBehindFollowSchedule)。
// 判据见 WorldEntryState.ControlledSpirits;常驻化那条路径不经过这里,不受影响。
if (sw.LeftBehindDailyLife && !state.HasControlledSpirit(oldTemplateId))
{
var choice = currentChoice;
dailyChoice = choice;
dailyLife = choice?.Entry;
dailyMode = choice?.Mode ?? "off";
ctx.Session.Log.Info($"[LEFTBEHIND-DAILY] spirit={oldTemplateId} " +
$"tag={NpcCultivationCatalog4229938.AgentTagForSpirit(oldTemplateId)} " +
$"daySecond={daySecond}({(state.HasExplicitTime ? "explicit" : "local")}) raid={state.ActiveRaidId} " +
$"mode={dailyMode} " +
$"timed={(choice?.Timed is null ? "none" : $"{choice.Timed.Id}/{choice.Timed.Performance}")} " +
$"actRaid={choice?.Timed?.Raid ?? 0} " +
$"chosen={(dailyLife is null ? "none" : $"{dailyLife.Id}/{dailyLife.Performance}")} " +
$"pos={(dailyLife is null ? "-" : $"{dailyLife.X:0.##},{dailyLife.Y:0.##},{dailyLife.Z:0.##}")}");
}
// 命中带坐标的活动 ⇒ 老角色真的「去了那个地方」,站位与留场记录一起改,
// 免得出现「人明明站在跑步机上,切回来却在街头」的错位。
var spawnX = oldPosition.X;
var spawnY = oldPosition.Y;
var spawnZ = oldPosition.Z;
if (dailyLife is { HasPosition: true })
{
spawnX = dailyLife.X;
spawnY = dailyLife.Y;
spawnZ = dailyLife.Z;
leftBehindEntry.Position = new Vec3(spawnX, spawnY, spawnZ);
}
else if (dailyChoice?.Spot is { } staticSpot)
{
// 没有官方日程的角色(OC 占位行)⇒ 送到他的手工常驻点,别留在切换原地。
spawnX = staticSpot.X;
spawnY = staticSpot.Y;
spawnZ = staticSpot.Z;
leftBehindEntry.Position = new Vec3(spawnX, spawnY, spawnZ);
}
// ★ 让留场的 NPC 穿上玩家给这个角色搭的那套衣服。
// 走 NPC 出场包自带的 AIAgentInfo.Fashion(单件列表),不是事后补 S2C。
CivilianLinkDress? leftBehindDress = null;
if (sw.LeftBehindDressWithPlayerFashion)
{
var worn = state.FashionBySpirit.GetValueOrDefault(oldTemplateId);
if (worn is { FashionIds.Count: > 0 })
leftBehindDress = new CivilianLinkDress(
Profile.PlayerPid, oldTemplateId, worn.FunctionSuitId,
worn.FashionIds, worn.HiddenParts);
}
// ★★ 出场表现走**同一个裁决点**(GameRouter.NpcLife.ResolveActivityPresentation):
// 常驻摆放和切人留场必须一致,否则「走过去看到的塔菲」和「切走留下的塔菲」
// 会演两套东西。POI 默认沿用切人那条线的 LeftBehindNativePoiActionId。
// ⛑⛑ 他**没有**被送去演那个活动 ⇒ 就别给他那个活动的表演。
// 角色级覆盖(gameplay.rosterNpc.poiOverrides 里「里希=198 跑步机 + 朝向 220.5」)
// 是按「他正站在跑步机上」这条日程配的;用到「站在玩家最后离开他的街角」的
// 留场身体上 ⇒ **原地摆出跑步机姿势**(2026-09-26 06:1x 用户报的「原地跑步」)。
// 真去演活动时(dailyLife 非空)才走裁决点拿活动/角色级表演;
// 否则一律中性闲置(LeftBehindNativePoiActionId)+ 沿用切走时的朝向。
var (leftBehindPoi, leftBehindActivityCfgId, leftBehindFacingOrNull, _) =
dailyLife is null
? (sw.LeftBehindNativePoiActionId, 0u, (float?)null, false)
: ResolveActivityPresentation(oldTemplateId, dailyLife.Id,
sw.LeftBehindNativePoiActionId);
var leftBehindFacing = leftBehindFacingOrNull ?? oldFacing;
// ⛑ POI 轮换的门禁:非 0 = 这个身体进了生活日程 ⇒ ambient 会盖掉 POI
//(硬规则 163)⇒ Movement.cs 那边直接不轮换这个角色。
leftBehindEntry.ActivityCfgId = leftBehindActivityCfgId;
// ★ 活动级坐标微调:跟常驻摆放同一个偏移(否则「走过去看到的她」和
// 「切过去落地的她」会差 0.8 米)。连 leftBehindEntry.Position 一起挪,
// 那个字段就是切回时的落点。
if (dailyLife is not null)
{
(spawnX, spawnY, spawnZ) =
ApplyActivityOffset(dailyLife.Id, spawnX, spawnY, spawnZ);
leftBehindEntry.Position = new Vec3(spawnX, spawnY, spawnZ);
}
if (sw.LeftBehindAgentUnitChannel)
{
// ★ 正解通道:SyncRaidBattleUnitAgent 带 FashionIdList(单件)。
await SendLeftBehindAgentAsync(ctx.Session, nativeAgentId, companionUnitId,
spawnX, spawnY, spawnZ, leftBehindFacing,
leftBehindPoi, leftBehindDress, sw.LeftBehindForceGo,
leftBehindActivityCfgId);
}
else
{
// 旧通道(Aether 静态 NPC):行为没问题,但没法指定单件衣服。
// ⛑ 留场身体不发骇客手机:否则 POI 17(UsageId=0)会被判定"本来不带
// usage" ⇒ 补一部手机 + usage 6 ⇒ 他一边闲逛一边玩手机,看着只有一种动作。
await SendCivilianAsync(ctx.Session,
new CivilianSpawnItem(nativeAgentId, leftBehindPoi),
companionUnitId, spawnX, spawnY, spawnZ, leftBehindFacing,
leftBehindDress, leftBehindActivityCfgId, sw.LeftBehindSkipPhone);
if (sw.LeftBehindForceGo)
{
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
new SceneMethods.SyncAgentForceGo4229938
{
id = companionUnitId,
isForceGo = true,
});
}
}
// ⛑⛑ A/B 用:老办法(建完补发 SyncSetSpiritFashions)已实测无效 ——
// 那个包按 spiritId 匹配,落不到这个 NPC 身上(09-25 用户实测「没有穿上我的搭配」)。
// 保留开关只是留一条回滚/对照的路,默认 false。
if (sw.LeftBehindDressSyncPacket)
{
await ctx.NotifyAsync(MethodId.SyncSetSpiritFashions,
UxSerializer.Serialize(RuntimePayloadFactory.CharacterFashions(oldTemplateId, state)));
}
// ⛑⛑ 回收**旧操控单位**(穿玩家衣服、没有 POI 的那个)。
// 少了这一步场上就会同时站着两个旧角色:一个是没被 Leave 的 oldUnitId,
// 一个是上面刚刷出来的市民 NPC(04:2x 用户实测)。
// ⛑ 与 `LeftBehindRebuildLeave` 是两件事:那个 Leave 的是 companionUnitId。
// 先建新的再回收旧的,避免中间出现"老角色整个消失"的空档。
if (sw.LeftBehindReclaimOldUnit && oldUnitId != 0 && oldUnitId != companionUnitId)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
}
ctx.Session.Log.Info($"[LEFTBEHIND-NATIVE] spirit={oldTemplateId} agent={nativeAgentId} " +
$"name={nativeNpc?.Name ?? "?"} persona={nativeNpc?.AgentPersonaId ?? 0} " +
$"ai={nativeNpc?.AiSettingId ?? 0} unit={companionUnitId} poi={leftBehindPoi} " +
$"activityCfgId={leftBehindActivityCfgId} facing={leftBehindFacing:0.##} " +
$"forceGo={sw.LeftBehindForceGo} " +
$"dressed={(leftBehindDress is null ? "none" : $"{leftBehindDress.FashionIds.Count}pcs/suit={leftBehindDress.SuitId}")} " +
$"dressSyncPacket={sw.LeftBehindDressSyncPacket} " +
$"daily={(dailyLife is null ? $"off({dailyMode})" : $"{dailyLife.Id}/{dailyLife.Performance}@{dailyLife.MapName}({dailyMode})")} " +
// ★ 记在留场记录里的「当下时段活动 id」—— 后台判「时段变没变」就靠它。
// 打 0 = 这个角色当前时段查不到活动(两个主角 tag 2/3 没有时刻表,
// 52 个角色里常态有 36 个 no-activity),那种情况下他本来就没日子可过,
// 不会触发回归日程 —— 这是对的,不是 bug。
$"sliceActivityId={leftBehindEntry.TimedActivityId} " +
$"reclaimedOldUnit={(sw.LeftBehindReclaimOldUnit ? oldUnitId.ToString() : "off")} " +
$"pos=({spawnX:0.##},{spawnY:0.##},{spawnZ:0.##})");
// ★★ 闲逛的后台循环**必须**在原生留场这条路上也起一次。
// 原本只有下面「非原生」那条分支的 else 里调了 EnsureLeftBehindLoop,
// 而现在默认走的就是原生通道(leftBehindAsNativeAgent=true)
// ⇒ 循环压根没起 ⇒ `[LEFTBEHIND-WANDER]` 一条都没有、旧角色只会站着玩手机
//(2026-09-26 21:5x 用户实测)。
// ⛑ 只在闲逛开着时起:闲逛关着时保持原样(绝不额外喂坐标/原地路点),
// 免得把本来站得好好的原生 NPC 搅动起来。
if (sw.LeftBehindWander || sw.LeftBehindPoiRotation)
EnsureLeftBehindLoop(ctx.Session, state);
}
else
{
// ⛑⛑ 不能只发 PositionAndFacing 冻结:客户端在 CurrentSpirit 移交时已经把旧
// hero actor 销毁了,事后再发位置包只会重建一个没有动画状态的裸 actor
//(16:5x 实测「老角色 T-pose 站在新角色身边」)。
// 必须 Leave 掉再按预生成同款四连发重建(ExistingUnitProjection 走的就是
// GM 生成 NPC 那条路,产出的单位带待机动画,不是 T-pose)。
// ⛑⛑⛑ 但 Leave 有代价:22:2x 实测它把客户端的无缝切换状态机打崩
//(197 条 loading_switch_moving_seamless NullReferenceException,之后新接管的
// 角色 24 秒里只发得出 2 条移动包)。所以默认**不发**(LeftBehindRebuildLeave=false)——
// 客户端自己已经销毁了旧 hero actor,这里直接重建即可。
if (sw.LeftBehindRebuildLeave && !sw.HeroUnitReuse)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(companionUnitId));
var leftBehindDelay = sw.RecreateDelayMs;
if (leftBehindDelay > 0)
await Task.Delay((int)Math.Min(leftBehindDelay, 2000));
}
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(companionUnitId));
if (sw.ManagedForLeftBehind)
{
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent,
WorldCodec.ManagedLogicAgent(companionUnitId, Profile.PlayerPid, 0));
}
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.ExistingUnitProjection(companionUnitId, oldTemplateId, oldPosition, oldFacing));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(companionUnitId, oldPosition, oldFacing));
// ⛑⛑⛑ 同伴协议:补齐 Ped 的第 4/5 步,让客户端行为树接管 locomotion。
// 留下的单位是「服务端驱动」的 ⇒ 客户端停用自己的 AI 后只做物理模拟、没有地面吸附
// ⇒ 自由落体掉出世界(18:1x 实测 Y → -3000)。止跌的正解不是「每 N 毫秒把坐标
// 硬拉回来」(21:33 实测:2s 喂一次路点 + 位姿,000/005 照样掉到 -2999.99),
// 而是把行为树启动起来 —— 街上那些不掉的路人多发的正是这两步。
// ⛑ 因此这两步一发,喂坐标 / 原地路点 / 后台续发循环**全部停掉**(见 Movement.cs 的门控)。
if (sw.LeftBehindCompanionProtocol)
{
// ⛑ CPoiActionId 是 POI 动作 id(17 = 站姿闲置),不是动作组号(400)—— 两张表。
if (sw.LeftBehindPoiActionId != 0)
{
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
new GameMethods.DonorClientNpcPoiActionData4229938
{
Id = companionUnitId,
SPoiActionId = 0,
CPoiActionId = sw.LeftBehindPoiActionId,
IsActive = true,
PlayPoiSpeed = 1f,
});
}
if (sw.LeftBehindForceGo)
{
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
new SceneMethods.SyncAgentForceGo4229938
{
id = companionUnitId,
isForceGo = true,
});
}
ctx.Session.Log.Info($"[LEFTBEHIND-COMPANION] ped4/5 unit={companionUnitId} " +
$"oldUnit={oldUnitId} poi={sw.LeftBehindPoiActionId} forceGo={sw.LeftBehindForceGo} " +
$"pos=({oldPosition.X:0.##},{oldPosition.Y:0.##},{oldPosition.Z:0.##}) " +
$"leave={sw.LeftBehindRebuildLeave} managed={sw.ManagedForLeftBehind}");
}
else
{
// ⛑⛑ 留下的单位是「服务端驱动」的 ⇒ 自由落体(见上)。老做法:先钉一发,
// 之后由 OnMovementReport → FeedLeftBehindUnits 接着钉。
if (sw.FeedLeftBehindPositions)
{
leftBehindEntry.LastFeedTicks = Environment.TickCount64;
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentSyncData,
WorldCodec.LogicAgentSyncData(companionUnitId, oldPosition, oldFacing,
sw.LeftBehindFeedMoveId));
}
// ⛑⛑ 老做法的止跌尝试:一条「原地路点任务」让客户端 locomotion 重新接管 actor。
// 之后由 OnMovementReport → FeedLeftBehindUnits 按 LeftBehindWaypointIntervalMs 续发。
if (sw.LeftBehindWaypointTask)
{
leftBehindEntry.LastWaypointTicks = Environment.TickCount64;
await SendLeftBehindWaypointAsync(ctx.Session, companionUnitId, oldPosition, CancellationToken.None);
}
// 后台续发循环:玩家站着不动时客户端不上报,那一刻旧角色正好在掉。
EnsureLeftBehindLoop(ctx.Session, state);
}
}
}
else if (!sw.HeroUnitReuse)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
}
}
// 目标角色现在归玩家操控了,不再算「留在大世界」。
state.LeftBehindCharacters.Remove(templateId);
state.HasSwitchOldUnitPose = false;
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
// 市民流式中心必须跟着切人落点走:不清掉的话人群还按切人前的位置铺,走过去一片空。
// LastReportedPlayerPosition/Rotation 已在上面按同一个 switchPosition 设好。
state.HasCrowdCenter = false;
state.SwitchCount++;
state.LastSwitchShowId = switchShowId;
ClearPendingSwitch(state);
state.PendingSwitchFacing = state.WorldEntryCreateHeroFacing;
state.PendingSwitchControlTransferred = false;
state.PendingSwitchLandingStarted = false;
ctx.Session.Log.Info($"[SWITCH-MIN] direct count={state.SwitchCount} oldUnit={oldUnitId} target={templateId}/{unitId} authoredShowId={switchShowId} combatModules=false actorPresentation=true vehicles=false buffs=safe-web-only isMeFix={(fixIsMe ? "reorder" : "off")} recreate={(recreate ? $"+{PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.RecreateDelayMs}ms" : "off")} inWorld={targetInWorld} leftBehind={state.LeftBehindCharacters.Count} pos=({switchPosition.X:0.##},{switchPosition.Y:0.##},{switchPosition.Z:0.##}) facing={switchFacing:0.##}");
}
///
/// ★ AskSwitchSpiritComplete(67272187) —— 客户端「整条切人演出播完了」的回执。
///
/// ⛑⛑ 以前它是**空转**的:`GameRouter.UnhandledBatch.cs` 把它挂在 SkillNeutralAccepts
/// 那个「只打一行 [COMBAT] accept 就 ReturnEmptyOkAsync」的兜底处理器上
/// ⇒ 演出到底播没播完、播了多久,服务端完全不知道(09-26 00:2x 评估 CBT3 资料时发现的盲区)。
/// CBT3 那份 `切人传送时机_apex传送实现.md` 用它标定 apexDelay;我们现在没有 apex 传送,
/// 但这条时间戳本身就是「演出真实时长」的唯一实测来源 —— 先记下来,别再丢。
///
/// ⛑ 客户端在切人流程里一共只发两个 C2S:这个 + ReportPreSwitchSpiritFinish(67654364)。
/// ReportPreTeleportFinish(63032762) **不发**(那是传送流程的),别拿它当切人信号。
///
[Handler(MethodId.AskSwitchSpiritComplete)]
private async Task AskSwitchSpiritComplete(Connection conn, UxRpcMessage msg)
{
await conn.ReturnEmptyOkAsync(msg);
var args = msg.GetArgs();
var state = GetWorldState(msg.Context);
var elapsedMs = Environment.TickCount64 - state.SwitchStartedTicks;
conn.Log.Info($"[SWITCH] complete spirit={args.switchSpiritId} count={state.SwitchCount} " +
$"showId={state.LastSwitchShowId} elapsed={elapsedMs}ms " +
$"(演出真实时长 —— 标定 preSwitchTimeoutMs / 以后做 apex 传送的 apexDelayMs 都靠它)");
}
}