297 lines
15 KiB
C#
297 lines
15 KiB
C#
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"/> > 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!);
|
|||
|
|
}
|