Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.RacingZoneProbe.cs

298 lines
15 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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!);
}