353 lines
23 KiB
C#
353 lines
23 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Serialization;
|
||
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.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;
|
||
|
||
public sealed record DonorNpcSpawnItem(uint NpcFormworkId, uint PoiActionId);
|
||
|
||
/// <summary>
|
||
/// Verbatim port of the donor build's GameRouter.Npc.cs (2026-09-17), kept deliberately
|
||
/// separate from GameRouter.Civilians.cs so the two implementations can be A/B'd.
|
||
///
|
||
/// The previous port failed silently — the card reported "sent" while nothing appeared in the
|
||
/// world — because of two deviations from this file, both now restored:
|
||
/// 1. SendDonorAetherInitAsync before the loop. It was dropped on the theory that World.cs
|
||
/// already publishes init once per scene; the donor re-sends it on every spawn, and the
|
||
/// crowd manager needs it before it will materialise a pedestrian.
|
||
/// 2. The nested sync contract. DonorNpcAgentSyncClientInfo4229938 has 43 fields; reusing the
|
||
/// 46-field SceneMethods.AgentSyncClientInfo shifted every field after it and the client
|
||
/// discarded the packet without a complaint.
|
||
///
|
||
/// Anything that looks like it could be "tidied up" here should be left alone until the donor
|
||
/// path is confirmed working in-game.
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
// 高水位落盘(IdCounterStore §6.10):进程内递增 = 重启后复用旧 instanceId。
|
||
private const long StaticNpcIdFloor = 8_300_000_000_000_000_000L;
|
||
private const string StaticNpcIdCounter = "DonorNpc.StaticNpcId";
|
||
private const long NpcBelongingIdFloor = 8_310_000_000_000_000_000L;
|
||
private const string NpcBelongingIdCounter = "DonorNpc.NpcBelongingId";
|
||
|
||
/// <summary>NPC 默认动作组 = 客户端 AetherNpcConfig.NPCDefaultActionGroup(上限 499)。
|
||
/// POI 表 3978 行里 3977 行的 MainActionGroup 也是这个值。</summary>
|
||
private const uint DefaultNpcActionGroupId = 400;
|
||
|
||
/// <summary>默认动作 = 通用 idle(POI 2「idle」/ POI 17「站姿_通用_站姿闲置00」的 MainActions 都是 1001)。</summary>
|
||
private const uint DefaultNpcActionId = 1001;
|
||
|
||
internal static Task<(bool Ok, string Message, ulong[] Ids)> SpawnDonorStaticNpcAsync(
|
||
TcpSession session, uint npcFormworkId, uint poiActionId)
|
||
=> SpawnDonorStaticNpcsAsync(session, [new(npcFormworkId, poiActionId)], 1.5f, 1.5f, 10);
|
||
|
||
internal static async Task<(bool Ok, string Message, ulong[] Ids)> SpawnDonorStaticNpcsAsync(
|
||
TcpSession session, IReadOnlyList<DonorNpcSpawnItem> items, float xSpacing, float zSpacing, int maxPerRow)
|
||
{
|
||
if (items.Count < 1)
|
||
return (false, "provide at least one NPC item", []);
|
||
if (!float.IsFinite(xSpacing) || xSpacing < 0f || !float.IsFinite(zSpacing) || zSpacing < 0f)
|
||
return (false, "NPC X/Z spacing must be finite non-negative numbers", []);
|
||
if (maxPerRow < 1)
|
||
return (false, "maximum NPCs per row must be at least 1", []);
|
||
// Validate the entire batch before sending any NPCs.
|
||
foreach (var item in items)
|
||
{
|
||
if (item is null || item.NpcFormworkId > int.MaxValue || !DonorNpcCatalog4229938.TryGet(item.NpcFormworkId, out _))
|
||
return (false, $"unknown NPC formwork {item?.NpcFormworkId}; expected an AgentConfig Id", []);
|
||
if (item.PoiActionId != 0 && !DonorPoiActionCatalog4229938.TryGet(item.PoiActionId, out _))
|
||
return (false, $"unknown NPC POI action {item.PoiActionId}", []);
|
||
}
|
||
|
||
var state = GetStateIfExists(session);
|
||
if (state is null)
|
||
return (false, "no player world state", []);
|
||
Vec3 near;
|
||
float facing;
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
|
||
return (false, "wait until the player is ready in the scene with a known position", []);
|
||
near = state.LastReportedPlayerPosition;
|
||
facing = state.LastReportedPlayerRotation.Y;
|
||
}
|
||
if (!float.IsFinite(near.X) || !float.IsFinite(near.Y) || !float.IsFinite(near.Z) || !float.IsFinite(facing))
|
||
return (false, "player transform is not finite", []);
|
||
|
||
var ids = new List<ulong>();
|
||
var yaw = facing * MathF.PI / 180f;
|
||
var npcFacing = (facing + 180f) % 360f;
|
||
if (npcFacing > 180f)
|
||
npcFacing -= 360f;
|
||
|
||
// ★ 2026-10-02 对齐捐赠端 GameRouter.Npc.cs:29-31:静态 NPC 生成必须串行。
|
||
// ⛑ 一个 NPC 的诞生是一串**有序包**(StaticNpcAddData → ManagedLogicAgent →
|
||
// BattleAiS=false → POI → 归属物 → 警察登记),两批并发会把这串包交错发出,
|
||
// 客户端拿到半初始化实体 ⇒ 「NPC 看得到,但原生盘查菜单弹不出来」。
|
||
// Wait(0) 是非阻塞的:批次进行中就直接拒绝,不排队(与捐赠端一致)。
|
||
if (!state.StaticNpcSpawnGate.Wait(0))
|
||
return (false, "NPC 批次正在进行中,请等上一批结束再点", []);
|
||
try
|
||
{
|
||
try
|
||
{
|
||
// The static-NPC lifecycle creates the logical agent and its pedestrian data.
|
||
await SendDonorAetherInitAsync(session);
|
||
for (var i = 0; i < items.Count; i++)
|
||
{
|
||
var item = items[i];
|
||
Console.WriteLine($"[{i}]Spawn npc formwork: {item.NpcFormworkId}. poi: {item.PoiActionId}");
|
||
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
|
||
// Begin directly in front of the player, then lay out a
|
||
// player-relative grid with the requested number of NPCs per row.
|
||
var forward = 3f + (i / maxPerRow) * zSpacing;
|
||
var right = (i % maxPerRow) * xSpacing;
|
||
DonorNpcCatalog4229938.TryGet(item.NpcFormworkId, out var npc);
|
||
DonorPoiActionCatalog4229938.TryGet(item.PoiActionId, out var poi);
|
||
var mainActionId = poi?.SelectMainAction(id) ?? 0;
|
||
var position = new SceneMethods.UxVector3(
|
||
near.X + MathF.Sin(yaw) * forward + MathF.Cos(yaw) * right,
|
||
near.Y,
|
||
near.Z + MathF.Cos(yaw) * forward - MathF.Sin(yaw) * right);
|
||
var agentSyncClientInfo = CreateDonorStaticNpcSyncInfo(item, npc, poi, mainActionId);
|
||
// A standalone battle projection omits persona and static pedestrian POI
|
||
// state. Let AddAetherAIStaticNpc create the complete logical entity;
|
||
// ForceGo requests the full character without replacing that lifecycle.
|
||
await session.NotifyAsync(MethodId.SyncAetherAIStaticNpcAddData,
|
||
UxSerializer.Serialize(new GameMethods.DonorClientStaticNpcInitData4229938
|
||
{
|
||
StaticNpcInfoId = item.NpcFormworkId,
|
||
NpcFormworkId = item.NpcFormworkId,
|
||
AgentPersonaId = npc.AgentPersonaId,
|
||
SPoiActionId = 0,
|
||
CPoiActionId = item.PoiActionId,
|
||
UrbanDiversityId = 88888000,
|
||
IgnoreAllStim = false,
|
||
TaskRelated = false,
|
||
// ★ 捐赠端 GameRouter.Npc.cs:110-120 —— 主线原本**整个字段都没设**。
|
||
// ⛑⛑ 2026-10-02 取证(`packets-20260927-142740.log` 会话 G:32e2a0):
|
||
// 捐赠端成功那次日志是 `[NPC-HACK] entity=… enableHack=True phone=True
|
||
// usage=0 items=80003481`;主线这次指纹 **0 次**。
|
||
// 序列化器照样写这个 bool ⇒ **包体大小不变(两边都是 230b)、字段位置也不变**,
|
||
// 只是值是 false —— 所以「两边包一样大」证明不了「内容一样」。
|
||
// 缺了它 ⇒ 客户端不认为该 NPC 可骇入 ⇒ 原生 F 交互菜单不出现,
|
||
// 服务端永远收不到 `NpcInteract`(而认 EscortRequest=21「押送上车」的
|
||
// 正是 `GameRouter.LixiPolice.cs:593` 那条 `NpcInteract` 分支)。
|
||
EnableHack = npc.AgentPersonaId != 0,
|
||
NpcPid = (int)item.NpcFormworkId,
|
||
AgentSyncClientInfo = agentSyncClientInfo,
|
||
ForceGo = true,
|
||
SourceType = 1, // UX.Game.StaticNpcSourceType.Spoon (4229938).
|
||
Id = id,
|
||
Position = position,
|
||
Facing = npcFacing,
|
||
EulerAngles = new SceneMethods.UxVector3(0, npcFacing, 0),
|
||
}), CancellationToken.None);
|
||
ids.Add(id);
|
||
lock (state.SyncRoot)
|
||
state.StaticNpcPreparedPlotEvents[id] = 0;
|
||
// Static creation registers the logical entity and its sync module,
|
||
// but does not assign a simulation owner. Without this notification,
|
||
// LogicAgentModule.ApplyMasterPolicy(false) disables the full-model
|
||
// state machine after the ECS-to-GO POI handoff.
|
||
await session.NotifyAsync(MethodId.SyncManagedLogicAgent,
|
||
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(id, Profile.PlayerPid, 0)), CancellationToken.None);
|
||
// ★ 捐赠端 GameRouter.Npc.cs:137-142:静态 NPC 必须显式置为非战斗 AI。
|
||
// 原生 PoliceJobUtils.CanUnitInteract 会**拒绝** IsBattleAiS 的目标 ⇒ 不置 false,
|
||
// 这个 NPC 永远盘查不了(F 键不亮)。主线原本缺这一段。
|
||
await session.NotifyAsync(MethodId.SyncBattleAiS, UxSerializer.Serialize(
|
||
new SceneMethods.SyncBattleAiS { agentId = id, value = false }), CancellationToken.None);
|
||
await RegisterPoliceNpc4229938(session, id, item.NpcFormworkId,
|
||
new Vec3(position.X, position.Y, position.Z));
|
||
if (poi is { UsageId: not 0 })
|
||
{
|
||
// The usage loader requires owned items before it can bind them.
|
||
// Inventory is cached on AgentData; usage is retained by the ECS
|
||
// handle and transferred by ECSToGOBelongingTransition.
|
||
await session.NotifyAsync(MethodId.SyncAgentBelongings,
|
||
UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongings4229938
|
||
{
|
||
Id = id,
|
||
Items = poi.BelongingSceneItems.Select(configId => new GameMethods.DonorNpcBelongingItem4229938
|
||
{
|
||
InstanceId = unchecked((ulong)IdCounterStore.Next(NpcBelongingIdCounter, NpcBelongingIdFloor)),
|
||
ConfigId = configId,
|
||
}).ToList(),
|
||
DebugAgentId = item.NpcFormworkId,
|
||
}), CancellationToken.None);
|
||
await session.NotifyAsync(MethodId.SyncAgentUseBelongingChanged,
|
||
UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongingUsage4229938
|
||
{
|
||
Id = id,
|
||
UsageIds = [poi.UsageId],
|
||
}), CancellationToken.None);
|
||
session.Log.Info($"[NPC] POI_BELONGINGS_SENT entity={id} poi={poi.Id} usage={poi.UsageId} items={string.Join(',', poi.BelongingSceneItems)}");
|
||
}
|
||
// Store the active POI in the logical POI module at normal playback speed.
|
||
// Do not periodically replay it: reactions must be able to interrupt it.
|
||
if (item.PoiActionId != 0)
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
|
||
UxSerializer.Serialize(new GameMethods.DonorClientNpcPoiActionData4229938
|
||
{
|
||
Id = id,
|
||
CPoiActionId = item.PoiActionId,
|
||
IsActive = true,
|
||
PlayPoiSpeed = 1f,
|
||
}), CancellationToken.None);
|
||
// T2-32 路人骇入:GM 批量刷出来的 static NPC 一样要能被骇(并且得有一部正在用的手机,
|
||
// 否则面板只剩名字 —— 见 GameRouter.HackNpc.cs 的说明)。
|
||
var donorUsageHackable = poi is { UsageId: not 0 } dp && HackableUsageIds.Contains(dp.UsageId);
|
||
await MarkNpcHackableOnSpawnAsync(session, id, HackNpcChannel.Static,
|
||
skipPhone: donorUsageHackable);
|
||
// ★ 捐赠端 GameRouter.Npc.cs:179 / :143 的两条取证指纹 —— 主线原本一条都没有,
|
||
// 导致上一次排查只能靠猜(详见 2026-10-02 日志 §45)。
|
||
// ⛑ 主线 NpcCatalog 投影里**没有** PoliceExamType 列,故 ACTION_NPC 不打印 examType。
|
||
session.Log.Info($"[NPC-HACK] entity={id} enableHack={npc.AgentPersonaId != 0} persona={npc.AgentPersonaId} usage={poi?.UsageId ?? 0} skipPhone={donorUsageHackable} phone=by-MarkNpcHackableOnSpawnAsync");
|
||
session.Log.Info($"[LIXI-POLICE] ACTION_NPC entity={id} template={item.NpcFormworkId} battleAi=false; native F requires EnableHack + eligible template and player state");
|
||
session.Log.Info($"[NPC] STATIC_PEDESTRIAN_SPAWN_SENT formwork={item.NpcFormworkId} model={npc.GeneralModelId} persona={npc.AgentPersonaId} poiAction={item.PoiActionId} action={mainActionId}/{poi?.MainActionGroup ?? 0} behaviorActionConfig={npc.BehaviorActionConfigId} activityConfig={agentSyncClientInfo.AgentDataSetsActivityCfgId} ai={npc.AiSettingId} entity={id} managedPid={Profile.PlayerPid} spoon={agentSyncClientInfo.SpoonAgentId} forceGo=true ignoreStim=false poiSpeed=1 fashion={agentSyncClientInfo.FashionSuitId} pos={position.X},{position.Y},{position.Z} playerPos={near}");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return (false, $"sent {ids.Count}/{items.Count} NPCs before send failed: {ex.Message}", ids.ToArray());
|
||
}
|
||
// ★ 捐赠端 GameRouter.Npc.cs:199:整批刷完之后统一刷新警察登记表(F 按钮 / 盘查候选)。
|
||
// ⛑ 即使上面中途抛异常也要刷:ids 里已有的 NPC 已经在客户端落地了。
|
||
_ = RefreshSpawnedPoliceNpcs4229938(session, ids.ToArray(), state.WorldEntryControlGeneration);
|
||
return (true, $"sent spawn for {ids.Count} static NPC(s) in front of you", ids.ToArray());
|
||
}
|
||
finally { state.StaticNpcSpawnGate.Release(); }
|
||
}
|
||
|
||
internal static GameMethods.DonorNpcAgentSyncClientInfo4229938 CreateDonorStaticNpcSyncInfo(
|
||
DonorNpcSpawnItem item, DonorNpcCatalogEntry4229938 npc,
|
||
DonorPoiActionCatalogEntry4229938? poi, uint mainActionId,
|
||
CivilianLinkDress? dress = null, uint activityCfgId = 0,
|
||
// ★ 第 46 轮(自 v2.1 捐赠端移植,2026-09-29):两个**可选**覆盖(默认值 = 原行为,
|
||
// 所有既有调用点零差异)。说明见
|
||
// PrivateServerConfig.PartyNpcAISettingOverride / PartyNpcTreeNameOverride。
|
||
uint aiSettingOverride = 0,
|
||
string? treeNameOverride = null)
|
||
{
|
||
// ⛑ 「这个 NPC 其实是某个可玩角色」的原生表达:把 AIAgentInfo(LinkAIAgentInfo)
|
||
// 填上,里面带 FightSpiritId + 单件衣服列表(见 CivilianLinkDress 的说明)。
|
||
// 模板 NPC 一律留空 —— donor 原本也是空的。
|
||
var linkAiAgentInfo = dress is null
|
||
? new GameMethods.DonorNpcLinkAIAgentInfo4229938()
|
||
: new GameMethods.DonorNpcLinkAIAgentInfo4229938
|
||
{
|
||
Uid = dress.Uid,
|
||
FightSpiritId = dress.FightSpiritId,
|
||
Fashion = new GameMethods.DonorNpcLinkAIFashionInfo4229938
|
||
{
|
||
SuitId = dress.SuitId,
|
||
// ⛑ WearInfo 在 wire 上**不可空**(RPCSerializeAuto.lua:4718 最后一个参数 false),
|
||
// 所以这里永远要给一个实例,不能留 null。
|
||
WearInfo = new Auto.OtherPlayerSpiritWearFashionsInfo
|
||
{
|
||
WearFashionColoringInfoDict = new Dictionary<uint, Auto.FashionColoringInfo>(),
|
||
WearFashionInfoList = dress.FashionIds
|
||
.Where(id => id != 0)
|
||
.Select(id => new Auto.WearFashionInfo { FashionId = id })
|
||
.ToList(),
|
||
WearFashionEditInfoList = [],
|
||
HiddenParts = dress.HiddenParts,
|
||
EditedHiddenParts = 0,
|
||
},
|
||
},
|
||
Nickname = dress.Nickname,
|
||
NameId = 0,
|
||
AvatarImageId = 0,
|
||
VehicleId = 0,
|
||
};
|
||
|
||
return new GameMethods.DonorNpcAgentSyncClientInfo4229938
|
||
{
|
||
stimIDList = new List<int>(),
|
||
CanBeExaminedByPolice = true,
|
||
indoorList = new(),
|
||
roomIds = new List<int>(),
|
||
SpoonAgentId = (int)item.NpcFormworkId,
|
||
treeName = string.IsNullOrEmpty(treeNameOverride) ? "PedBase" : treeNameOverride,
|
||
petPerformData = "",
|
||
spawnEffectId = new List<uint>(),
|
||
randomModelCfgId = 0,
|
||
LeaveDistance = 10000,
|
||
approachDistance = 10000,
|
||
// ⛑ 只能填**套装** id;玩家搭配几乎全是散件(FunctionSuitId=0)⇒ 散件走
|
||
// AIAgentInfo.Fashion.WearInfo,这里只在真的有套装时才覆盖。
|
||
FashionSuitId = dress is { SuitId: not 0 } ? dress.SuitId : 11190001,
|
||
AIAgentInfo = linkAiAgentInfo,
|
||
// ⛑️ T0-9(2026-09-22):`actionGroupId` = 动作组(400 = NPC 默认,客户端
|
||
// AetherNpcConfig.NPCDefaultActionGroup,POI 表 3977/3978 行的 MainActionGroup 也是 400);
|
||
// `actionId` = 组内的具体动作(POI 表 MainActions:1001 idle / 9018 走路 / …)。
|
||
// 没有 POI 的 NPC 原来两个槽位都是 0 = **空动作组**(IsEmptyAction,只配给怪物/隐藏单位)
|
||
// ⇒ 模型在、不 T-pose,但不会有任何待机/行走/POI 动画,看着就是个雕像。
|
||
// 兜底:组恒 400,动作落到 1001(通用 idle,POI 2/17 都是它)。
|
||
actionId = mainActionId != 0 ? mainActionId : DefaultNpcActionId,
|
||
actionGroupId = poi?.MainActionGroup ?? DefaultNpcActionGroupId,
|
||
initPoiActionId = item.PoiActionId,
|
||
useDefaultPoiOnReturn = item.PoiActionId != 0,
|
||
returnPoiActionIds = item.PoiActionId == 0 ? [] : [item.PoiActionId],
|
||
// ★ 过日子接线点(与 SendLeftBehindAgentAsync 里那个同义):0 = 只走 ambient POI
|
||
//(只会站着);非 0 = AgentDataSetsActivityConfig.Id,客户端这个 NPC 改走生活日程
|
||
// 状态,去播那条活动的表演(跑步机 / 坐着刷手机 / 修车 …),并盖掉 ambient POI。
|
||
AgentDataSetsActivityCfgId = activityCfgId,
|
||
InteractId = npc.InteractSettingId,
|
||
AISetting = aiSettingOverride != 0 ? aiSettingOverride : npc.AiSettingId,
|
||
beHitType = 1, // Civilian hit reaction without combat damage.
|
||
agentStimType = 0,
|
||
forbidStimulateType = 0,
|
||
isAttackInSafeMode = true,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Donor's SyncAetherAIInitDatas (68704070). Sent before every static-NPC batch.
|
||
///
|
||
/// v0.3's Vehicle copy of this lives behind a per-runtime one-shot guard; the donor sends it
|
||
/// unconditionally on spawn, so this unguarded version exists for exactly that purpose.
|
||
/// </summary>
|
||
private static async Task SendDonorAetherInitAsync(TcpSession session)
|
||
{
|
||
var settings = PrivateServerConfigStore.Current.Gameplay.Vehicles;
|
||
var activeRaidId = GetStateIfExists(session)?.ActiveRaidId ?? Profile.RaidId;
|
||
var hasZoneGraph = activeRaidId == PrivateServerConfigStore.Current.World.RaidId;
|
||
var handle = hasZoneGraph ? settings.ZoneStorageDataHandle : 0;
|
||
|
||
// ⛑️⛑️ 不要退回 `AetherAIInitData4229938`(Intersections 用 Int32 count)。
|
||
// 客户端按 Int7 偏置读那个 count:`FF 00000000` 被解成 -64 ⇒
|
||
// `new List<T>(-64)` ⇒ "Non-negative number required" ⇒ 整包丢弃 ⇒
|
||
// AetherCityManager.InitAetherCity 不跑 ⇒ 车/行人/静态 NPC 全没。
|
||
// 正确线格式是 12 字节 `FF188B630101611ABBC1FF01`(见 VehicleMethods4229938.cs 注释)。
|
||
// 走 World.cs 那条线用的同一个 wire 工厂,保证两处永不漂移。
|
||
var variants = PrivateServerConfigStore.Current.Gameplay.Aether.InitVariants;
|
||
if (variants is null || variants.Count == 0)
|
||
variants = ["top-int7"];
|
||
foreach (var variant in variants)
|
||
{
|
||
var wire = GameAdminBridge.BuildAetherInitWire(variant, activeRaidId, hasZoneGraph, handle);
|
||
if (wire is null)
|
||
{
|
||
session.Log.Info($"[AETHER] donor init unknown variant '{variant}' (skipped)");
|
||
continue;
|
||
}
|
||
await session.NotifyAsync(MethodId.SyncAetherAIInitDatas, wire, CancellationToken.None);
|
||
session.Log.Info($"[AETHER] donor init raid={activeRaidId} zoneGraph={hasZoneGraph} " +
|
||
$"handle={handle} variant={variant} bytes={wire.Length} hex={Convert.ToHexString(wire)}");
|
||
}
|
||
|
||
// ★ 2026-10-02:捐赠端在 Aether init 之后也会推一次路口相位(捐赠端 GameRouter.Aether.cs:449)。
|
||
// `PushIntersectionUpdatesAsync` 幂等(只推没推过的路口)、`StartIntersectionSignals`
|
||
// 有"已在跑"守卫 ⇒ 这里重复调用无害,作用是把 Vehicle 那条 init 链路没跑到的情况兜住。
|
||
await PushIntersectionUpdatesAsync(session, CancellationToken.None).ConfigureAwait(false);
|
||
StartIntersectionSignals(session);
|
||
}
|
||
}
|