using System; using System.Collections.Generic; using System.Linq; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// ROUTE A race-start experiment (§59.14 §7.9): push a single SyncGameGroundZoneInfo /// (68004609) carrying a RacingZoneInfo and watch what the client does with it. /// /// The question this answers is the only remaining unknown in the racing chain: /// /// 68004609 → C# GameSceneToClientImpl.SyncGameGroundZoneInfo (dump.cs:184816) /// → lua GameSceneToClientImpl.SyncGameGroundZoneInfo (lua:1135) /// → Racing branch → gChallengeManager:OnSyncRacingZoneInfo (lua:526) /// → ...and then? lua never calls L50.Spoon.CarRaceManager.Instance:SyncCarRaceGame /// /// Either the C# hop stops at the C# CarRaceManager itself (in which case this single packet is /// enough to stage the grid, run the countdown and start the lap clock), or the race only runs when /// a Spoon graph calls SyncCarRaceGame (IDMAP 49071), in which case nothing happens and /// Route A stays blocked on the Spoon md5 problem. /// /// Observable outcomes (see VEHICLE-RACING-INVESTIGATION.md §7.9): /// • cars appear on the track / countdown panel / AI dots on the minimap (gMapCsApi.OnCarRaceStart) /// ⇒ (a) C#-internal wiring — Route A is unblocked, only participants + settlement remain. /// • absolutely nothing, and no CarRaceManager line in Player.log /// ⇒ (b) Spoon-graph driven — Route A needs the Spoon md5. /// /// Nothing here is wired into normal gameplay: the only entry point is the debug HTTP API /// (/api/racing/zone-probe), so this is inert until deliberately fired. /// internal sealed partial class GameRouter { /// UX.Game.GameGroundZoneType.Racing private const byte ZoneTypeRacing = 4; /// UX.Game.GameGroundZoneState.Display — the zone is staged and visible. internal const byte ZoneStateDisplay = 1; /// UX.Game.GameGroundZoneState.GameStart — the race is meant to be running. internal const byte ZoneStateGameStart = 2; /// UX.Game.GameGroundZoneState.GameOver — the race clock is stopped, results pending. internal const byte ZoneStateGameOver = 3; /// UX.Game.GameGroundZoneSyncReason.Enter internal const byte SyncReasonEnter = 0; /// UX.Game.GameGroundZoneStartReason.Single private const byte StartReasonSingle = 0; /// /// How many AI opponents to inject into the racing grid. 0 = none (default — the confirmed-working /// race is unchanged). Raise to N (suggest 3–7) to race against N AI cars. The opponent vehicle /// cfgIds come from the track's HighAI/MiddleAI/LowAI pools (RacingTrackRow), so the cars use real /// authored opponent configs. Set this and rebuild to enable AI in normal play; the debug /// /api/racing/zone-probe also accepts an "aiCount" override for experimentation without a rebuild. /// /// 🔴 Default OFF because the AI grid changes what the client reads on zone sync; if the wire format /// were subtly wrong the whole race could fail to stage. Flip it on after confirming via the debug /// API, or leave at 0 and use the debug API's aiCount to experiment per-shot. /// internal const int RacingAiGridCount = 0; /// /// Options surfaced to the debug panel / HTTP caller. cfgId is a /// RacingDriverTrackInformationConfig Id (the "Id" column, NOT an EventId). /// internal static object RacingZoneProbeStatus() { // Id ≥ 1000 rows have MaxGamePeople == 0 — those are map POI markers, not real tracks (§59.6). var tracks = ClientConfigRepository.RacingTracks() .Where(kv => kv.Value.MaxPlayers > 0) .OrderBy(kv => kv.Key) .Select(kv => new { cfgId = kv.Key, laps = kv.Value.Laps, maxPlayers = kv.Value.MaxPlayers, maxGameTime = kv.Value.MaxGameTime, contestType = kv.Value.ContestType, }) .ToArray(); return new { ok = true, zoneType = ZoneTypeRacing, syncMethodId = MethodId.SyncGameGroundZoneInfo, typeMark = 15, states = new { idle = 0, display = ZoneStateDisplay, gameStart = ZoneStateGameStart, gameOver = 3, dispose = 4 }, suggestedTaskId = 60000147, usableTrackCount = tracks.Length, tracks, howTo = "POST /api/racing/zone-probe {\"cfgId\":3,\"taskId\":60000147,\"zoneState\":1} — " + "then watch Player.log for CarRaceManager / a staged grid / countdown. " + "POST /api/racing/zone-clear to push a null zone and tear it down.", }; } /// /// Build the AI opponent grid for a racing zone. Returns BOTH: /// • AIVehicleInfos — the plain dict (vehicleInstanceId → cfgId) that drives the minimap /// blue dots (client GetAllVehicleAIIds reads AIVehicleInfos); and /// • ParticipantInfos — real AI opponents, each a GameGroundParticipantInfo4229938 /// (0xFF marker + 8 base fields), which is the element shape the client's own /// Auto.WriteRacingZoneInfo uses. (R203: the earlier RacingParticipantInfo/TypeMark=14 /// shape desynced the packet by 28 B/participant.) /// /// Returns empty lists when ≤ 0 (byte-compatible with the pre-AI probe). /// Opponent cfgIds come from the track's HighAI/MiddleAI/LowAI pools (RacingTrackRow); if the row is /// missing or the pools are empty, no AI is produced. /// internal static (Dictionary AIVehicleInfos, List Participants) BuildRacingAiGrid(uint trackCfgId, int count, IReadOnlyList? realVehicles = null) { var infos = new Dictionary(); var participants = new List(); // ── REAL grid (§59.28) ──────────────────────────────────────────────────────────────── // Spawn first, claim second. The client resolves every AIVehicleInfos key with // DriveUtils.GetBaseVehicle(id) / element:BindVehicle(id), so the key MUST be an entity that // actually exists in the scene. These are the ids of the cars SpawnRacingAiVehiclesAsync just // created, and their value is the racing-AI cfg id the client looks up in // RacingDriverRacingAIConfig (→ VehicleID + VehicleTaskID = it drives itself). if (realVehicles is { Count: > 0 }) { for (var i = 0; i < realVehicles.Count; i++) { var v = realVehicles[i]; infos[v.EntityId] = v.AiCfgId; participants.Add(BuildRacingParticipant(v.EntityId, v.AiCfgId, v.VehicleCfgId, i)); } return (infos, participants); } if (count <= 0) return (infos, participants); // ── LEGACY grid (A/B control only) ──────────────────────────────────────────────────── // Invented ids: the client parses them and GetAllVehicleAIIds() returns them, but nothing in // the scene carries them, so no car / no blue dot / no rank ever materialises. Kept so the two // shapes can be compared from the log; never the default. uint[] cfgPool = []; if (ClientConfigRepository.RacingTracks().TryGetValue(trackCfgId, out var track)) { cfgPool = track.HighAI .Concat(track.MiddleAI) .Concat(track.LowAI) .Where(id => id != 0) .Distinct() .ToArray(); } for (var i = 0; i < count; i++) { var instanceId = 0x6000_0000_0000_0000UL + (ulong)(i + 1); var cfgId = cfgPool.Length > 0 ? cfgPool[i % cfgPool.Length] : 0u; if (cfgId == 0) continue; // no valid vehicle config → skip this slot rather than send 0 infos[instanceId] = cfgId; participants.Add(BuildRacingParticipant(instanceId, cfgId, cfgId, i)); } return (infos, participants); } /// /// One AI opponent entry, typed as the BASE GameGroundParticipantInfo — which is what the /// client's own Auto.WriteRacingZoneInfo writes for this list /// (Base.WriteComplexWrap(Auto.WriteGameGroundParticipantInfo, …)): each element is /// 0xFF (present marker) + 8 base fields, no TypeMark. /// /// is the SCENE ENTITY id (it is also what the client keys /// AIVehicleInfos by) and is the VehicleConfig the car /// was built from. It lands in VehicleInstanceId / AIAgentInfo.VehicleId; the /// Racing-only extras (AiVehicleCfgId / Lap / RacingTotalTimeMilli …) are NOT /// part of this shape — R203 proved adding them desyncs the packet by 28 B per participant. /// private static GameMethods.GameGroundParticipantInfo4229938 BuildRacingParticipant( ulong agentInstanceId, uint aiCfgId, uint vehicleCfgId, int seatIndex) { return new GameMethods.GameGroundParticipantInfo4229938 { Pid = agentInstanceId, NpcCultivationId = 0, AIAgentInfo = new GameMethods.LinkAIAgentInfo4229938 { Uid = agentInstanceId, FightSpiritId = 0, Fashion = null, Nickname = $"AI {seatIndex + 1}", NameId = 0, AvatarImageId = 0, VehicleId = vehicleCfgId, }, AgentInstanceId = agentInstanceId, VehicleInstanceId = agentInstanceId, SeatIndex = seatIndex, IsReady = true, IsPlayAgain = false, }; } /// /// Build the payload the client's Auto.Dispatch[449] expects: the RacingZoneInfo /// discriminator (0x0F, emitted by [UxContract(TypeMark = 15)]) followed by the Racing /// fields and then the GameGroundZoneInfo fields. /// /// When > 0 the grid is populated with that many AI opponents /// (cfgIds from the track's HighAI/MiddleAI/LowAI pools); when 0 or omitted the grid stays empty, /// which is also the byte-compatible original probe. The LinkAIAgentInfo contract is now declared /// (RacingZoneMethods.cs), so a non-empty grid serialises correctly. /// /// /// / are unix seconds for /// GameGroundZoneCountDownInfo (dump.cs:1918571). They drive the client's countdown panel /// and therefore the vehicle-control release — an all-zero countdown is what left the player in /// an undrivable car on 2026-09-22 (see GameRouter.RacingAutoStart.cs). Both default to 0 so the /// debug /api/racing/zone-probe payload stays byte-identical to the original 71-byte probe. /// /// internal static GameMethods.RacingZoneInfo4229938 BuildRacingZoneProbe( uint cfgId, uint taskId, byte zoneState, byte syncReason, string? zoneSessionId, ulong gadgetUId, uint startTime = 0, uint gameTimeEnd = 0, uint prepareEnd = 0, uint displayEnd = 0, int aiCount = -1, IReadOnlyList? realAiVehicles = null) { // AI grid: a real (spawned) roster wins; otherwise -1 ⇒ module default (RacingAiGridCount, // normally 0 = none) and a non-negative value ⇒ the legacy invented-id grid, A/B only. var gridCount = aiCount < 0 ? RacingAiGridCount : aiCount; var (aiVehicleInfos, aiParticipants) = BuildRacingAiGrid(cfgId, gridCount, realAiVehicles); return new GameMethods.RacingZoneInfo4229938 { // Racing-specific, and they come first on the wire. CfgId = cfgId, StartTime = startTime, AIVehicleInfos = aiVehicleInfos, ResultData = new List(), // Then the base GameGroundZoneInfo fields. ZoneSessionId = string.IsNullOrWhiteSpace(zoneSessionId) ? $"drmk-racing-{cfgId}-{taskId}" : zoneSessionId, GadgetUId = gadgetUId, TaskId = taskId, StartReason = StartReasonSingle, GameTypeRandom = false, SyncReason = syncReason, ZoneType = ZoneTypeRacing, ZoneState = zoneState, ParticipantInfos = aiParticipants, CountDownInfo = new GameMethods.GameGroundZoneCountDownInfo4229938 { PrepareCountDownEndTime = prepareEnd, DisplayCountDownEndTime = displayEnd, // Zero start (the default) keeps the original probe payload byte-identical; the // auto-start passes real unix seconds so the client's lap clock has a base time. GameTimeCountDownStartTime = startTime, GameTimeCountDownEndTime = gameTimeEnd, }, }; } /// /// Human-readable sanity check for the caller-supplied cfgId, so a bad id is caught before it /// reaches the client (an unknown cfgId would make InitAetherClientData a no-op and /// silently produce a "nothing happened" result that looks like outcome (b)). /// internal static string? ValidateRacingCfgId(uint cfgId) { if (!ClientConfigRepository.RacingTracks().TryGetValue(cfgId, out var track)) return $"cfgId {cfgId} is not in RacingDriverTrackInformationConfig"; if (track.MaxPlayers == 0) return $"cfgId {cfgId} has MaxGamePeople=0 — that is a map POI marker row, not a real track"; return null; } /// /// A null complex: exactly one 0x00 byte. The client's /// GameSceneToClientImpl.SyncGameGroundZoneInfo treats a nil zone as "tear down the /// current zone", which is also the only way to undo the probe without relogging. /// internal static byte[] BuildRacingZoneClearBody() => Ananta.SDK.Serialization.UxSerializer.Serialize(null!); }