501 lines
25 KiB
C#
501 lines
25 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// RACE AI OPPONENT VEHICLES (R190, ported from the Lyco fork's GameRouter.RacingAiVehicles.cs,
|
||
/// adapted to v2.1's own vehicle/Aether helpers) — the entity half of "AI 车 + 小地图蓝点 + 真名次".
|
||
///
|
||
/// 🔑 WHY REAL ENTITIES (§59.28): the zone packet carries <c>RacingZoneInfo.AIVehicleInfos</c> =
|
||
/// a plain <c>Dictionary<ulong,uint></c> of <b>scene vehicle entity id → racing AI cfg id</b>.
|
||
/// Every client consumer resolves that key against a REAL scene entity:
|
||
/// <code>
|
||
/// CarRaceManager.lua AddEntity(id) -> LX6.Drive.DriveUtils.GetBaseVehicle(id) (miss = no entity)
|
||
/// MapSubSystem_CarRace.lua -> element:BindVehicle(id) (the minimap blue dot)
|
||
/// C# CarRaceManager.RaceSpeedManager -> rank / lap map keyed by that same id
|
||
/// </code>
|
||
/// Inventing ids (the legacy 0x6000… grid) therefore can never materialise. So each opponent is
|
||
/// spawned as a real BaseVehicle + Aether registration + NPC driver + ForceGo, and the (entityId,
|
||
/// aiCfgId, vehicleCfgId) triples are fed into AIVehicleInfos/ParticipantInfos verbatim.
|
||
///
|
||
/// v2.1 ADAPTATION NOTES (vs the Lyco original):
|
||
/// • Spawn = the SAME three-packet sequence as v2.1's own SpawnDirectAsync (SyncLogicVehicleEnter
|
||
/// + SyncSpawnVehicle + SyncChangeVehicleInteractable), but with a GIVEN entity id, no
|
||
/// summon-registry/replacement side effects and a free CreateSourceType (4 = Online; 2 = Summon
|
||
/// would file the car as the PLAYER'S own — exactly what registerSummoned:false exists to avoid).
|
||
/// • Aether entity registration reuses v2.1's PROVEN wire shape (bare ClientVehicleInitData4229938
|
||
/// with LaneHandle = -1 / ControlType = 2, the shape RegisterHackVehicleAsync has been shipping
|
||
/// since the 载具骇入 work) instead of the Lyco wrapper.
|
||
/// • Aether init = SendDonorAetherInitAsync (v2.1's one proven SyncAetherAIInitDatas channel).
|
||
/// • Player anchor = GetStateIfExists + LastReportedPlayerPosition / WorldEntryCreateHeroPosition.
|
||
/// • The driver formwork pool: env override → known-good fallback (v2.1 has no Aether
|
||
/// VehicleNpcFormworkIds config section).
|
||
/// • Best-effort everywhere: empty world state / missing track row / empty roster ⇒ empty list,
|
||
/// and RunRacingAutoStartAsync then sends an HONEST EMPTY grid (never invented ids again).
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>
|
||
/// Gate for spawning real AI opponent vehicles before the race zone is pushed.
|
||
///
|
||
/// ⚠ R203c(2026-09-30)——**两次定案,第二次推翻第一次。当前 = 未定案**。
|
||
///
|
||
/// 第一次定案(❌ 已推翻):据 `noaigrid` 消融(照常 spawn、网格推空,`console-20260930-153727.log`)
|
||
/// **仍然崩**(死在 GAMESTART 当帧)⇒ 曾判「wire 层无关、AI 实体 spawn = 真凶」。
|
||
///
|
||
/// ★ 第二次(15:54 实测,`console-20260930-155057.log`):把本门关掉(`spawnGate=False`、
|
||
/// `AI grid ready real=0 requested=5`)+ 客户端 guard **打开** ⇒ **照样崩**(GAMESTART 后 1 s
|
||
/// `IOException`,`PSO Collect`=0)⇒ **关掉 AI spawn 不能治崩 ⇒ 「AI spawn 是真凶」不成立**。
|
||
///
|
||
/// **四会话矩阵**(唯一有效方法 = 差分,★ 必须「除目标变量外全体一致」):
|
||
/// 08:11 spawn-OFF + guard **未注入** = ✅ 跑完整局;
|
||
/// 14:54 spawn-ON + guard-OFF = 崩;15:37 spawn-ON + guard-OFF(空网格) = 崩;
|
||
/// 15:54 spawn-OFF + guard-**ON** = 崩。
|
||
/// ⇒ **唯一成功 = 「spawn OFF 且 guard 未注入」**;真凶收敛到 **AI spawn 副作用** 或
|
||
/// **客户端 `racing_guard.lua` 补丁**(或二者合力)。★ `racing_guard.lua` mtime = 09:36:02
|
||
/// (**在 08:11 成功之后被改**)⇒ 头号嫌疑。
|
||
/// wire 层仍被排除:08:11 与 15:54 的 `SyncGameGroundZoneInfo` DISPLAY 包均 71 B **逐字节相同**。
|
||
///
|
||
/// ⇒ **默认关闭**(R203c 方向 1「先让玩法可用」)。单人计时挑战可正常游玩。
|
||
/// ⛔ 待办:**「spawn OFF + guard 不注入」单变量实测**(= 08:11 已知成功配置,见
|
||
/// `config/racing-ablation.txt` = 单行 `noguard`):不崩 ⇒ guard 有罪;仍崩 ⇒ 还有第四变量。
|
||
///
|
||
/// 临时打开(免重编):设环境变量 `DRMK_RACE_AI_SPAWN=1`,或把下面的常量改回 true 重编。
|
||
/// </summary>
|
||
internal static readonly bool RacingAiSpawnEnabled = ResolveRacingAiSpawnEnabled();
|
||
|
||
private static bool ResolveRacingAiSpawnEnabled()
|
||
{
|
||
var env = Environment.GetEnvironmentVariable("DRMK_RACE_AI_SPAWN");
|
||
if (!string.IsNullOrWhiteSpace(env))
|
||
{
|
||
var v = env.Trim().ToLowerInvariant();
|
||
if (v is "1" or "true" or "on" or "yes") return true;
|
||
if (v is "0" or "false" or "off" or "no") return false;
|
||
}
|
||
// ⚠ R203c:默认 **关**(「AI spawn = 崩」已被 15:54 实测推翻;关着只是为了先保玩法可用,
|
||
// 别再引用这条当定案 —— 见上方四会话矩阵与待办)。
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gate for the AETHER DRIVER half — BaseVehicle alone renders but has no Aether entity,
|
||
/// therefore no NPC driver and no vehicle AI (a statue). The four steps in
|
||
/// <see cref="AttachRaceAetherDriversAsync"/> (init → add-data → npc → force-go) turn it on.
|
||
/// Set false to instantly degrade back to statues.
|
||
/// </summary>
|
||
internal const bool RacingAiAetherDriverEnabled = true;
|
||
|
||
/// <summary>
|
||
/// <c>VehicleCreateSourceType</c> for the opponent cars. 4 = Online is the semantic default:
|
||
/// the opponents only exist inside a race competition. Summon(2) is deliberately excluded —
|
||
/// it arms the ownership handshake and would file the opponents as the player's OWN cars.
|
||
/// Lyco probe conclusion (2026-09-23): the client accepts every candidate, 4 is just the
|
||
/// honest one.
|
||
/// </summary>
|
||
internal const byte RacingAiCreateSourceType = 4; // Online
|
||
|
||
/// <summary>Entity-id range for race opponents: 770_000_000_000, deliberately outside the
|
||
/// summon segment (300_000_000_000) and the traffic segment (750_000_000_000). The generation
|
||
/// step exists because the client caches uid → LOD state — re-used uids make it ignore new
|
||
/// init data — so every race's opponents look brand-new.</summary>
|
||
private const long RacingAiVehicleIdBase = 770_000_000_000L;
|
||
|
||
private const int RacingAiVehicleGenerationSpan = 1_000;
|
||
private const int RacingAiVehicleGenerationMod = 100;
|
||
|
||
private static int _racingAiVehicleGeneration;
|
||
|
||
/// <summary>NPC-driver id segment: 770_500_000_000 + generation*1000 + slot — same
|
||
/// "bump per race" trick as the cars, and outside every other id family we use.</summary>
|
||
private const long RacingAiDriverIdBase = 770_500_000_000L;
|
||
|
||
private static ulong RacingAiDriverId(int generation, int slot)
|
||
=> (ulong)(RacingAiDriverIdBase + (long)generation * RacingAiVehicleGenerationSpan + slot);
|
||
|
||
/// <summary>Fallback driver NPC formwork (client <c>VehicleSpawnConfig.NewNpcType</c> family).</summary>
|
||
private const uint RacingAiDriverFormworkFallback = 45200091;
|
||
|
||
/// <summary>Driver NPC template ids — DRMK_RACE_DRIVER_FORMWORK=45200091,45200092 overrides;
|
||
/// otherwise the known-good fallback.</summary>
|
||
private static readonly uint[] RacingAiDriverFormworks = ResolveRacingAiDriverFormworks();
|
||
|
||
private static uint[] ResolveRacingAiDriverFormworks()
|
||
{
|
||
var env = Environment.GetEnvironmentVariable("DRMK_RACE_DRIVER_FORMWORK");
|
||
if (!string.IsNullOrWhiteSpace(env))
|
||
{
|
||
var parsed = env
|
||
.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(s => uint.TryParse(s.Trim(), out var v) ? v : 0u)
|
||
.Where(v => v != 0)
|
||
.Distinct()
|
||
.ToArray();
|
||
if (parsed.Length > 0) return parsed;
|
||
}
|
||
return [RacingAiDriverFormworkFallback];
|
||
}
|
||
|
||
/// <summary>One spawned opponent: the scene entity, its racing-AI cfg id (the AIVehicleInfos
|
||
/// value), the vehicle config it was built from, the VehicleCreateSourceType it was created
|
||
/// with and the world transform it was placed at (re-used verbatim by the Aether packets).</summary>
|
||
internal sealed record RacingAiVehicle(
|
||
ulong EntityId, uint AiCfgId, uint VehicleCfgId, byte SourceType, Vec3 Position, float Facing);
|
||
|
||
/// <summary>
|
||
/// Spawn the opponent cars of one race. Returns one entry per successfully created entity;
|
||
/// callers must treat an empty list as "no AI this race" and send an empty grid.
|
||
/// </summary>
|
||
internal static async Task<List<RacingAiVehicle>> SpawnRacingAiVehiclesAsync(
|
||
TcpSession session, uint trackCfgId, int count,
|
||
CancellationToken token = default)
|
||
{
|
||
var spawned = new List<RacingAiVehicle>();
|
||
if (!RacingAiSpawnEnabled || count <= 0)
|
||
return spawned;
|
||
|
||
var state = GetStateIfExists(session);
|
||
if (state is null)
|
||
{
|
||
session.Log.Warn("[RACE] AI spawn skipped: no WorldEntryState (client not in world yet)");
|
||
return spawned;
|
||
}
|
||
|
||
if (!ClientConfigRepository.RacingTracks().TryGetValue(trackCfgId, out var track))
|
||
{
|
||
session.Log.Warn($"[RACE] AI spawn skipped: no RacingDriverTrackInformationConfig row for track={trackCfgId}");
|
||
return spawned;
|
||
}
|
||
|
||
var roster = track.HighAI
|
||
.Concat(track.MiddleAI)
|
||
.Concat(track.LowAI)
|
||
.Where(id => id != 0)
|
||
.Distinct()
|
||
.ToArray();
|
||
if (roster.Length == 0)
|
||
{
|
||
session.Log.Warn($"[RACE] AI spawn skipped: track={trackCfgId} has an empty HighAI/MiddleAI/LowAI roster");
|
||
return spawned;
|
||
}
|
||
|
||
var aiRows = ClientConfigRepository.RacingAiRows();
|
||
|
||
// 🔴 Keep only opponents the CLIENT can actually build. v2.1 has no VehicleSpawnConfig
|
||
// loader, so the check is soft (VehicleConfig dump presence + a model): a miss logs a
|
||
// warning but does NOT drop the entry (Lyco measured the client accepting all
|
||
// candidates; a missing model only degrades to invisible-or-dropped for that one car).
|
||
var known = roster.Where(id =>
|
||
aiRows.TryGetValue(id, out var r) && r.VehicleId != 0
|
||
&& VehicleCatalog4229938.TryGet(r.VehicleId, out var vr) && !string.IsNullOrWhiteSpace(vr.Model))
|
||
.ToArray();
|
||
if (known.Length > 0 && known.Length != roster.Length)
|
||
session.Log.Info($"[RACE] AI roster: {known.Length}/{roster.Length} entries known to the vehicle dump "
|
||
+ "(others are spawned unfiltered — the client drops unknown ones itself)");
|
||
|
||
// Anchor = the player's last reported transform (that is where the grid is staged).
|
||
var anchorPos = state.HasLastReportedPlayerTransform
|
||
? state.LastReportedPlayerPosition
|
||
: state.WorldEntryCreateHeroPosition;
|
||
var facing = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : 0f;
|
||
|
||
var yaw = facing * (MathF.PI / 180f);
|
||
var fwdX = MathF.Sin(yaw);
|
||
var fwdZ = MathF.Cos(yaw);
|
||
|
||
var generation = Interlocked.Increment(ref _racingAiVehicleGeneration)
|
||
% RacingAiVehicleGenerationMod;
|
||
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var aiCfgId = roster[i % roster.Length];
|
||
|
||
// The vehicle the CLIENT will expect this AI id to be driving.
|
||
var vehicleCfgId = aiRows.TryGetValue(aiCfgId, out var aiRow) ? aiRow.VehicleId : 0u;
|
||
if (vehicleCfgId == 0)
|
||
{
|
||
session.Log.Warn($"[RACE] AI spawn: no vehicle config for aiCfgId={aiCfgId} (RacingDriverRacingAIConfig miss)");
|
||
continue;
|
||
}
|
||
|
||
var entityId = (ulong)(RacingAiVehicleIdBase
|
||
+ (long)generation * RacingAiVehicleGenerationSpan
|
||
+ i);
|
||
|
||
var sourceType = RacingAiCreateSourceType;
|
||
|
||
// Grid layout (Lyco retune, 2026-09-23): two columns, rows marching AWAY from the
|
||
// player, generous pitch — the authored racers are TRUCKS, tight offsets visually
|
||
// merge into one lump. Slot 0/1 form the front row ~8 m ahead so the opponents stay
|
||
// on screen during the countdown.
|
||
var row = i / 2;
|
||
var side = i % 2 == 0 ? -1f : 1f;
|
||
var lateral = side * (4.0f + row * 0.5f); // ±4.0 m, widening slightly per row
|
||
var along = 8f + row * 9f; // 8 m, 17 m, 26 m ... ahead
|
||
// A real grid is never perfectly parallel; a small per-slot yaw removes the
|
||
// "every car is a clone of mine" look. Kept to ±3° so it cannot matter for physics.
|
||
var slotFacing = facing + ((i % 3) - 1) * 3f;
|
||
|
||
var nearPos = new Vec3(
|
||
anchorPos.X + fwdX * along,
|
||
anchorPos.Y,
|
||
anchorPos.Z + fwdZ * along);
|
||
|
||
var spawnOk = await SpawnRaceAiVehicleAsync(
|
||
session, vehicleCfgId, nearPos, slotFacing, lateral, sourceType, entityId, $"racing-ai#{i}", token);
|
||
|
||
if (!spawnOk)
|
||
{
|
||
session.Log.Warn($"[RACE] AI spawn failed slot={i} ai={aiCfgId} veh={vehicleCfgId} src={sourceType}");
|
||
continue;
|
||
}
|
||
|
||
spawned.Add(new RacingAiVehicle(entityId, aiCfgId, vehicleCfgId, sourceType, nearPos, slotFacing));
|
||
}
|
||
|
||
session.Log.Info($"[RACE] AI vehicles spawned={spawned.Count}/{count} track={trackCfgId} gen={generation}"
|
||
+ " entries=" + string.Join(",", spawned.Select(v => $"id{v.EntityId}=ai{v.AiCfgId}/veh{v.VehicleCfgId}/src{v.SourceType}"))
|
||
+ $" anchor=({anchorPos.X:F1},{anchorPos.Y:F1},{anchorPos.Z:F1}) facing={facing:F1}"
|
||
+ " expected=csharp.entityFound+=spawned csharp.entityMissing=0");
|
||
|
||
// AETHER DRIVER HALF — "车上没有 NPC" + "车也不会动". Additive and gated: a failure here
|
||
// leaves the (already working) BaseVehicle spawn untouched, i.e. statue behaviour.
|
||
if (RacingAiAetherDriverEnabled && spawned.Count > 0)
|
||
await AttachRaceAetherDriversAsync(session, spawned, generation, token);
|
||
|
||
return spawned;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Direct scene spawn for ONE AI opponent: the same three-packet sequence as
|
||
/// <see cref="SpawnDirectAsync"/> (SyncLogicVehicleEnter + SyncSpawnVehicle +
|
||
/// SyncChangeVehicleInteractable) with three differences that matter:
|
||
/// 1. the entity id is GIVEN (770_000_000_000 segment) — the zone grid must name exactly
|
||
/// these ids;
|
||
/// 2. NO summon-registry side effects — <c>SummonedEntity</c>/<c>CurrentVehicleId</c> are
|
||
/// never touched (the record still lands in <c>Vehicles</c> so the teardown channel works);
|
||
/// 3. CreateSourceType is free (4 = Online) instead of the hardcoded Summon(2).
|
||
/// </summary>
|
||
private static async Task<bool> SpawnRaceAiVehicleAsync(
|
||
TcpSession session, uint vehicleId, Vec3 exactPos, float facing, float lateral,
|
||
byte sourceType, ulong entityId, string reason, CancellationToken token)
|
||
{
|
||
if (vehicleId == 0)
|
||
return false;
|
||
|
||
var seatCount = 2;
|
||
if (VehicleCatalog4229938.TryGet(vehicleId, out var config))
|
||
{
|
||
if (config.SeatCount is >= 1 and <= 16)
|
||
seatCount = config.SeatCount;
|
||
if (string.IsNullOrWhiteSpace(config.Model))
|
||
session.Log.Warn($"[RACE] AI vehicle config {vehicleId} has no prefab model in dump, trying anyway");
|
||
}
|
||
else
|
||
{
|
||
session.Log.Warn($"[RACE] AI vehicle config {vehicleId} not in VehicleConfig dump, trying with {seatCount} seats");
|
||
}
|
||
|
||
var spawn = new Vec3(exactPos.X, exactPos.Y + 0.15f, exactPos.Z);
|
||
var runtime = GetVehicleRuntimeState(session);
|
||
var record = new VehicleRecord
|
||
{
|
||
EntityId = entityId, ConfigId = vehicleId, SeatCount = seatCount,
|
||
Position = spawn, Facing = facing, ControllerPid = 0, Interactable = true, TaskToken = 0,
|
||
};
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
runtime.Vehicles[entityId] = record; // teardown channel only — no SummonedEntity write
|
||
}
|
||
|
||
var euler = new SceneMethods.UxVector3(0f, facing, 0f);
|
||
var pos = new SceneMethods.UxVector3(spawn.X, spawn.Y, spawn.Z);
|
||
|
||
await session.NotifyAsync(MethodId.SyncLogicVehicleEnter, UxSerializer.Serialize(new GameMethods.LogicVehicleClientInfo4229938
|
||
{
|
||
EntityId = entityId, VehicleConfigId = vehicleId, CreateSourceType = sourceType,
|
||
Parts = [], SuitId = 0, LicensePlate = string.Empty, Interactable = true, MoveToken = 0,
|
||
Position = pos, EulerAngles = euler, VehicleSpoonName = null,
|
||
}), token);
|
||
await session.NotifyAsync(MethodId.SyncSpawnVehicle, UxSerializer.Serialize(new GameMethods.VehicleClientInfo4229938
|
||
{
|
||
ControllerPid = 0, CreateSourceType = sourceType, EntityId = entityId, VehicleConfigId = vehicleId,
|
||
Parts = [], SuitId = 0, Position = pos, Facing = facing, EulerAngles = euler, Velocity = 0,
|
||
IsStatic = false, DeformStatus = 0,
|
||
SeatInfos = Enumerable.Range(0, seatCount).Select(i => new GameMethods.RaidVehicleSeatInfo4229938
|
||
{ EntityId = 0, SeatIndex = (byte)i, SeatState = 0, DestroyRelated = false }).ToList(),
|
||
SpoonId = 0, IsDynamicGo = false, VehicleEnemyId = 0, DisableNavigation = false,
|
||
Interactable = true, MoveToken = 0, LicensePlate = string.Empty,
|
||
}), token);
|
||
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
|
||
new GameMethods.SyncChangeVehicleInteractable4229938 { vehicleInstanceId = entityId, interactable = true }),
|
||
token);
|
||
|
||
session.Log.Info($"[RACE] AI DIRECT_SPAWN reason={reason} config={vehicleId} entity={entityId} src={sourceType} "
|
||
+ $"lateral={lateral:F1} spawn=({spawn.X:F1},{spawn.Y:F1},{spawn.Z:F1}) facing={facing:F1}");
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The AETHER half of the opponent grid: registers each car with the client's vehicle-AI
|
||
/// subsystem, binds an NPC driver to it and switches its AI on. Every step is best-effort and
|
||
/// individually guarded: a wiring mistake can only degrade to the statue behaviour.
|
||
/// </summary>
|
||
private static async Task AttachRaceAetherDriversAsync(
|
||
TcpSession session, IReadOnlyList<RacingAiVehicle> cars, int generation,
|
||
CancellationToken token)
|
||
{
|
||
var okInit = 0;
|
||
var okAdd = 0;
|
||
var okNpc = 0;
|
||
var okGo = 0;
|
||
|
||
// (1) Turn the client's vehicle-AI subsystem on. Reuses v2.1's proven
|
||
// SyncAetherAIInitDatas channel (variant list + real ZoneStorageDataHandle from config).
|
||
try
|
||
{
|
||
await SendDonorAetherInitAsync(session);
|
||
okInit = 1;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[RACE] Aether init for race drivers failed: {ex.Message}");
|
||
}
|
||
|
||
for (var i = 0; i < cars.Count; i++)
|
||
{
|
||
var car = cars[i];
|
||
var pos = new SceneMethods.UxVector3(car.Position.X, car.Position.Y, car.Position.Z);
|
||
var euler = new SceneMethods.UxVector3(0f, car.Facing, 0f);
|
||
|
||
try
|
||
{
|
||
// (2) Aether entity — SAME id as the BaseVehicle we just built. v2.1's proven wire
|
||
// shape (RegisterHackVehicleAsync): BARE data, LaneHandle = -1 (keep the
|
||
// explicit world transform), ControlType = 2 = RaidVehicle (register with
|
||
// Aether without activating lane simulation).
|
||
await session.NotifyAsync(MethodId.SyncAetherAIVehicleAddData,
|
||
UxSerializer.Serialize(new GameMethods.ClientVehicleInitData4229938
|
||
{
|
||
VehicleConfigId = car.VehicleCfgId,
|
||
Id = car.EntityId,
|
||
Position = pos,
|
||
Facing = car.Facing,
|
||
EulerAngles = euler,
|
||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0,
|
||
LaneHandle = -1,
|
||
ControlType = 2,
|
||
}), token);
|
||
okAdd++;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[RACE] Aether add-data failed id={car.EntityId}: {ex.Message}");
|
||
}
|
||
|
||
var driverId = RacingAiDriverId(generation, i);
|
||
var formwork = RacingAiDriverFormworks[i % RacingAiDriverFormworks.Length];
|
||
|
||
try
|
||
{
|
||
// (3) The driver NPC, sitting in seat 0 of THIS car.
|
||
await session.NotifyAsync(MethodId.SyncAetherAIVehicleNpcAdd,
|
||
UxSerializer.Serialize(new GameMethods.SyncAetherAIVehicleNpcAdd4229938
|
||
{
|
||
initData = new GameMethods.ClientVehicleNpcInitData4229938
|
||
{
|
||
Id = driverId,
|
||
NpcFormworkId = formwork,
|
||
BindVehicleId = car.EntityId,
|
||
SeatIndex = 0,
|
||
},
|
||
}), token);
|
||
okNpc++;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[RACE] Aether driver add failed id={car.EntityId}: {ex.Message}");
|
||
}
|
||
|
||
try
|
||
{
|
||
// (4) Simulation owner for the driver. Without it the client's ApplyMasterPolicy(false)
|
||
// shuts the model state machine down and the driver never renders.
|
||
await session.NotifyAsync(MethodId.SyncManagedLogicAgent,
|
||
UxSerializer.Serialize(new SceneMethods.SyncManagedLogicAgent
|
||
{
|
||
agentId = driverId,
|
||
pid = Profile.PlayerPid,
|
||
moveId = 0,
|
||
}), token);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[RACE] ManagedLogicAgent for driver failed id={driverId}: {ex.Message}");
|
||
}
|
||
|
||
try
|
||
{
|
||
// (5) Switch the vehicle AI ON. From here the CLIENT's own VehicleAINext drives the
|
||
// car along the ZoneGraph lanes (the server stops pushing coordinates). Both
|
||
// the generic and the Aether-specific twin are sent — the generic entry is not
|
||
// necessarily the one the Aether vehicle manager listens to (Lyco conclusion).
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleForceGo,
|
||
UxSerializer.Serialize(new GameMethods.SyncVehicleForceGo4229938
|
||
{
|
||
vehicleEntityId = car.EntityId,
|
||
isForceGo = true,
|
||
}), token);
|
||
|
||
await session.NotifyAsync(MethodId.SyncAetherAIVehicleForceGo,
|
||
UxSerializer.Serialize(new GameMethods.SyncAetherAIVehicleForceGoArgs4229938
|
||
{
|
||
instanceId = car.EntityId,
|
||
forceGo = true,
|
||
}), token);
|
||
okGo++;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[RACE] ForceGo failed id={car.EntityId}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
session.Log.Info($"[RACE] AI aether-drivers init={okInit} addData={okAdd}/{cars.Count}"
|
||
+ $" npc={okNpc}/{cars.Count} forceGo={okGo}/{cars.Count}"
|
||
+ $" formworks=[{string.Join(",", RacingAiDriverFormworks)}] gen={generation}"
|
||
+ " expected=AI cars get a driver and start driving along the ZoneGraph lanes");
|
||
}
|
||
|
||
/// <summary>Remove this race's opponents (teardown). Best-effort, never throws.</summary>
|
||
internal static async Task DestroyRacingAiVehiclesAsync(
|
||
TcpSession session, IReadOnlyList<RacingAiVehicle> vehicles, string reason)
|
||
{
|
||
if (vehicles.Count == 0)
|
||
return;
|
||
var runtime = GetVehicleRuntimeState(session);
|
||
foreach (var v in vehicles)
|
||
{
|
||
try
|
||
{
|
||
await DestroyVehicleAsync(session, runtime, v.EntityId, reason);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[RACE] AI destroy failed id={v.EntityId}: {ex.Message}");
|
||
}
|
||
}
|
||
session.Log.Info($"[RACE] AI vehicles destroyed={vehicles.Count} reason={reason}");
|
||
}
|
||
}
|