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!);
}