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; /// /// 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 RacingZoneInfo.AIVehicleInfos = /// a plain Dictionary<ulong,uint> of scene vehicle entity id → racing AI cfg id. /// Every client consumer resolves that key against a REAL scene entity: /// /// 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 /// /// 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). /// internal sealed partial class GameRouter { /// /// 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 重编。 /// 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; } /// /// 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 /// (init → add-data → npc → force-go) turn it on. /// Set false to instantly degrade back to statues. /// internal const bool RacingAiAetherDriverEnabled = true; /// /// VehicleCreateSourceType 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. /// internal const byte RacingAiCreateSourceType = 4; // Online /// 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. private const long RacingAiVehicleIdBase = 770_000_000_000L; private const int RacingAiVehicleGenerationSpan = 1_000; private const int RacingAiVehicleGenerationMod = 100; private static int _racingAiVehicleGeneration; /// 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. private const long RacingAiDriverIdBase = 770_500_000_000L; private static ulong RacingAiDriverId(int generation, int slot) => (ulong)(RacingAiDriverIdBase + (long)generation * RacingAiVehicleGenerationSpan + slot); /// Fallback driver NPC formwork (client VehicleSpawnConfig.NewNpcType family). private const uint RacingAiDriverFormworkFallback = 45200091; /// Driver NPC template ids — DRMK_RACE_DRIVER_FORMWORK=45200091,45200092 overrides; /// otherwise the known-good fallback. 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]; } /// 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). internal sealed record RacingAiVehicle( ulong EntityId, uint AiCfgId, uint VehicleCfgId, byte SourceType, Vec3 Position, float Facing); /// /// 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. /// internal static async Task> SpawnRacingAiVehiclesAsync( TcpSession session, uint trackCfgId, int count, CancellationToken token = default) { var spawned = new List(); 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; } /// /// Direct scene spawn for ONE AI opponent: the same three-packet sequence as /// (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 — SummonedEntity/CurrentVehicleId are /// never touched (the record still lands in Vehicles so the teardown channel works); /// 3. CreateSourceType is free (4 = Online) instead of the hardcoded Summon(2). /// private static async Task 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; } /// /// 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. /// private static async Task AttachRaceAetherDriversAsync( TcpSession session, IReadOnlyList 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"); } /// Remove this race's opponents (teardown). Best-effort, never throws. internal static async Task DestroyRacingAiVehiclesAsync( TcpSession session, IReadOnlyList 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}"); } }