Files

297 lines
15 KiB
C#
Raw Permalink Normal View History

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;
/// <summary>
/// ROUTE A race-start experiment (§59.14 §7.9): push a single <c>SyncGameGroundZoneInfo</c>
/// (68004609) carrying a <c>RacingZoneInfo</c> and watch what the client does with it.
///
/// The question this answers is the only remaining unknown in the racing chain:
/// <code>
/// 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
/// </code>
/// 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 <c>SyncCarRaceGame</c> (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.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>UX.Game.GameGroundZoneType.Racing</summary>
private const byte ZoneTypeRacing = 4;
/// <summary>UX.Game.GameGroundZoneState.Display — the zone is staged and visible.</summary>
internal const byte ZoneStateDisplay = 1;
/// <summary>UX.Game.GameGroundZoneState.GameStart — the race is meant to be running.</summary>
internal const byte ZoneStateGameStart = 2;
/// <summary>UX.Game.GameGroundZoneState.GameOver — the race clock is stopped, results pending.</summary>
internal const byte ZoneStateGameOver = 3;
/// <summary>UX.Game.GameGroundZoneSyncReason.Enter</summary>
internal const byte SyncReasonEnter = 0;
/// <summary>UX.Game.GameGroundZoneStartReason.Single</summary>
private const byte StartReasonSingle = 0;
/// <summary>
/// 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.
/// </summary>
internal const int RacingAiGridCount = 0;
/// <summary>
/// Options surfaced to the debug panel / HTTP caller. <c>cfgId</c> is a
/// RacingDriverTrackInformationConfig Id (the "Id" column, NOT an EventId).
/// </summary>
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.",
};
}
/// <summary>
/// Build the AI opponent grid for a racing zone. Returns BOTH:
/// • <c>AIVehicleInfos</c> — the plain dict (vehicleInstanceId → cfgId) that drives the minimap
/// blue dots (client GetAllVehicleAIIds reads AIVehicleInfos); and
/// • <c>ParticipantInfos</c> — real AI opponents, each a <c>GameGroundParticipantInfo4229938</c>
/// (0xFF marker + 8 base fields), which is the element shape the client's own
/// <c>Auto.WriteRacingZoneInfo</c> uses. (R203: the earlier RacingParticipantInfo/TypeMark=14
/// shape desynced the packet by 28 B/participant.)
///
/// Returns empty lists when <paramref name="count"/> ≤ 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.
/// </summary>
internal static (Dictionary<ulong, uint> AIVehicleInfos, List<GameMethods.GameGroundParticipantInfo4229938> Participants)
BuildRacingAiGrid(uint trackCfgId, int count, IReadOnlyList<RacingAiVehicle>? realVehicles = null)
{
var infos = new Dictionary<ulong, uint>();
var participants = new List<GameMethods.GameGroundParticipantInfo4229938>();
// ── 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);
}
/// <summary>
/// One AI opponent entry, typed as the BASE <c>GameGroundParticipantInfo</c> — which is what the
/// client's own <c>Auto.WriteRacingZoneInfo</c> writes for this list
/// (<c>Base.WriteComplexWrap(Auto.WriteGameGroundParticipantInfo, …)</c>): each element is
/// <c>0xFF</c> (present marker) + 8 base fields, no TypeMark.
///
/// <paramref name="agentInstanceId"/> is the SCENE ENTITY id (it is also what the client keys
/// <c>AIVehicleInfos</c> by) and <paramref name="vehicleCfgId"/> is the <c>VehicleConfig</c> the car
/// was built from. It lands in <c>VehicleInstanceId</c> / <c>AIAgentInfo.VehicleId</c>; the
/// Racing-only extras (<c>AiVehicleCfgId</c> / <c>Lap</c> / <c>RacingTotalTimeMilli</c> …) are NOT
/// part of this shape — R203 proved adding them desyncs the packet by 28 B per participant.
/// </summary>
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,
};
}
/// <summary>
/// Build the payload the client's <c>Auto.Dispatch[449]</c> expects: the RacingZoneInfo
/// discriminator (0x0F, emitted by <c>[UxContract(TypeMark = 15)]</c>) followed by the Racing
/// fields and then the GameGroundZoneInfo fields.
///
/// When <paramref name="aiCount"/> &gt; 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.
///
/// <para>
/// <paramref name="prepareEnd"/> / <paramref name="displayEnd"/> are unix seconds for
/// <c>GameGroundZoneCountDownInfo</c> (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.
/// </para>
/// </summary>
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<RacingAiVehicle>? 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<GameMethods.RacingResultData4229938>(),
// 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,
},
};
}
/// <summary>
/// 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 <c>InitAetherClientData</c> a no-op and
/// silently produce a "nothing happened" result that looks like outcome (b)).
/// </summary>
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;
}
/// <summary>
/// A null complex: exactly one 0x00 byte. The client's
/// <c>GameSceneToClientImpl.SyncGameGroundZoneInfo</c> treats a nil zone as "tear down the
/// current zone", which is also the only way to undo the probe without relogging.
/// </summary>
internal static byte[] BuildRacingZoneClearBody()
=> Ananta.SDK.Serialization.UxSerializer.Serialize<GameMethods.RacingZoneInfo4229938>(null!);
}