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

1289 lines
81 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 System.Threading.Tasks;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
// ★ R309:载具瞬移契约 `SyncTeleportVehicle4229938` 在 GameScene 组(与改装店共用)。
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
using Ananta.Server.RpcTypes.Client4229938; // ★ R309:Vec3(载具记录位置)
using System.IO;
using System.Text.Json;
using System.Threading; // ★ R306:Interlocked / Volatile(代际护栏)
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Server-side racing auto-start — turns "点开始挑战" into an actual race WITHOUT the manual
/// /api/racing/zone-probe call.
///
/// WHY THIS IS THE MISSING PIECE (skill drmk-ananta-rpc-diag §59.14–§59.17)
/// The private server never sends <c>SceneSpoonNames</c>, so the client never merged the racing
/// Spoon graph and the C# chain
/// <code>
/// SyncGameGroundZoneInfo(68004609)
/// -> L50.Spoon.CarRaceManager.SyncCarRaceGame
/// -> InitClientRaceData -> InitClientData -> CarRaceData.InitCarRaceData -> CarRaceData.Init
/// </code>
/// NRE'd on a null <c>SpoonCarRaceData</c>. That was fixed on the CLIENT side (the fastpatch injects
/// "&lt;raidId&gt;_RacingDriver" into <c>EnterSceneInfo.SceneSpoonNames</c> before the C# loader runs).
/// With the graph loaded the NRE is gone — but nothing PUSHES the zone, so the race never stages.
///
/// The client cannot start it either: it never sends <c>AskStartCompetitionRacing</c>(63221200) in
/// offline mode, and the challenge panel's 开始挑战 only calls <c>gTaskManager:SetCurrentTask()</c>
/// locally (GPS arming). The zone packet is server-authoritative — so the server has to drive it.
///
/// WHAT IT DOES
/// On <c>AskAcceptAndSetCurrentTask</c> for a task owned by a Racing challenge, it later pushes
/// <c>SyncGameGroundZoneInfo</c> four times: ZoneState=Display (stage the grid, with a real
/// DisplayCountDownEndTime so the client's countdown panel actually runs and releases vehicle
/// control), ZoneState=GameStart (start the lap clock with a real unix StartTime), then
/// ZoneState=GameOver and a null-zone Dispose once the race length elapses — so the zone can never
/// stay armed (and the player can never be left in a permanently undrivable car). The delays let the
/// client finish arming the challenge (SyncCurrentTask / SyncPlayerAllTask) before the zone lands.
///
/// 🔴 It is fire-and-forget and reply-neutral: <c>AskAcceptAndSetCurrentTask4229938</c> still returns
/// its usual empty OK, so task-accept semantics are unchanged. The only new observable is the log
/// line <c>[RACE] auto-start ...</c> plus the zone packets.
///
/// 🔴 AI OPPONENTS are OPT-IN. The LinkAIAgentInfo / LinkAIFashionInfo contracts are now declared
/// (RacingZoneMethods.cs), and <c>RacingZoneProbe.BuildRacingAiGrid</c> populates
/// <c>AIVehicleInfos</c> + <c>ParticipantInfos</c> from the track's HighAI/MiddleAI/LowAI pools when
/// asked. It is OFF by default (<c>RacingAiGridCount = 0</c> in RacingZoneProbe.cs, and the debug
/// <c>/api/racing/zone-probe</c> accepts an <c>aiCount</c> override) because a non-empty grid changes
/// what the client parses on zone sync — enable only after confirming the wire format via the debug
/// API, or leave it 0 and experiment per-shot there.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>How long to wait before staging the grid, so the client's challenge arming lands first.</summary>
private const int RacingZoneDisplayDelayMs = 1200;
/// <summary>Gap between Display and GameStart (the client builds the grid during this window).</summary>
private const int RacingZoneGameStartDelayMs = 3000;
/// <summary>Race length used when the track row carries no MaxGameTime.</summary>
private const uint RacingZoneFallbackGameTimeSeconds = 180;
/// <summary>Gap between GameOver and the null-zone Dispose teardown.</summary>
private const int RacingZoneTeardownGapMs = 1500;
// ── R234 · 把「跑完全程」变成「真的结束」──────────────────────────────────────
//
// 现象(2026-10-01 19:03 与 19:05 两轮实测):客户端**确实跑完了** ——
// `[RACE][COUNTER] index=0..15`(16 个检查点)**再加一条 `index=16`**,
// 而 `index=16 == self.taskCounterCount` 正是 `CarRaceManager.OnCurrentTaskChanged`
// 的**完成分支**(`CarRaceManager.lua:201-204`:`AskSetTaskCounterValue(taskID,
// taskCounterCount, 1)` + `gPanelManager:Close(S_RACING_PANEL)`)。
// 但服务端**什么都不回** ⇒ 客户端关掉赛车 HUD 后没有任何结算/结束画面,
// 玩家只能自己退出(用户原话:「没有办法到终点结束」)。
// 实测同轮服务端日志:`GAMEOVER`/`SyncRacingResultData` **一条都没有**
// —— 因为旧实现只会 `await Task.Delay(MaxGameTime = 300s)`,玩家早在 79 秒就跑完了。
//
// ⇒ 修法:把「等 300 秒」改成「**等客户端完成上报,或超时(谁先到)**」。
// 收到 `index == RacingClientCheckpointCount` 的计数器上报即立刻结算
// (GAMEOVER + SyncRacingResultData,与超时路径**完全同一段代码**,只是提前触发)。
// `MaxGameTime` 仍然保留作兜底(客户端崩了/中途退出的情况)。
/// <summary>
/// 客户端自建检查点的总数 —— ★ **必须与客户端补丁 `racing_guard.lua` 里的
/// <c>R255_CP_COUNT</c> 保持一致**(两边都改,否则完成上报认不出来,只能等 300 秒兜底)。
///
/// 语义(实测):`CarRaceManager.OnCurrentTaskChanged` 在**最后一个检查点通过后**,
/// 用 `counterIndex = taskCounterCount`(= 本常量)上报一次 `value=1`,
/// 这就是「玩家跑完全程」的唯一权威信号。
///
/// <para>★★★ R255(2026-10-02):<b>16 → 26</b>。16 是 R225 自己猜的值
/// (<c>R225_CP_COUNT = 16 -- 原版 Spoon 图数量未知,取保守中值</c>)。
/// 官方权威值 = <b>26</b>:关卡块 <c>vfc_819</c> 的明文导出 JSON
/// (<c>ZoneShapes_WorldMap_Release_Saidao.prefab</c>)里每个赛道点带
/// <c>checkpointIndex</c>,值域 <c>0..25</c> ⇒ 含终点共 26 个。
/// 这也正是用户当时说的「全程包含终点有 26 个检查点」(他跟着视频一个一个数的)。
/// 详见 <c>GameRouter.RacingGates.OfficialTrack3Checkpoints</c> 的注释。</para>
/// </summary>
internal const int RacingClientCheckpointCount = 26;
/// <summary>
/// 每局赛车一个「可以结算了」的信号。key = taskId(单人赛车同一时刻只跑一局),
/// 由 <c>RunRacingAutoStartAsync</c> 登记、<c>AskSetTaskCounterValueAccept</c> 触发、
/// 结算完成后移除。用 <c>ConcurrentDictionary</c> + <c>TrySetResult</c> 保证
/// **多次上报只结算一次**(客户端可能重发)。
/// </summary>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<uint, TaskCompletionSource<bool>>
RacingSettleSignals = new();
// ═══════════════════════════════════════════════════════════════════════════════════════════
// ★★★★★ R306(2026-10-03 00:5x)· 「跑了两三个检查点就被强制结算」的两个服务端根因
//
// 实机铁证(`logs/console-latest.log`,用户同一晚连跑三局):
// · 第 1 局:`auto-start scheduled` **两行**(00:34:55 ×2)、`DISPLAY` **两条**(00:34:56/57)、
// `GAMESTART` **两条**(00:35:01 ×2)⇒ **一次点击打了两条流水线**。
// · 00:36:32 客户端的 FINISH 只叫醒了**后登记的那条**(`RacingSettleSignals[taskId]` 被覆盖)
// ⇒ 另一条没人取消,它的 `Task.Delay(300)` 在 **00:40:01** 醒来。
// · 00:39:28 开的第 2 局(玩家只跑了 2 个点)在 **00:40:01** 被那条**上一局的**流水线结算掉。
// · 第 2 局自己的 `Task.Delay` 又跑到 **00:44:28** 把第 3 局掐了(玩家只跑了 3 个点)。
// ⇒ 症状 = **永久错位一局**:每一局都被「上一局的 300 s 兜底」结算。
//
// 修法(两条,缺一不可):
// ① **arming 去重**:同一次「点开始挑战」的第二条流水线在建起来之前就丢掉
// (窗口 = 接受 → GAMESTART 推完;见 <see cref="RacingAutoStartArming"/>);
// ② **代际护栏**:每条流水线进场领一个号,只有**最新**那条允许结算
// ⇒ 任何残留的旧流水线醒来后只会自杀,绝不会再结算别人的局。
// ═══════════════════════════════════════════════════════════════════════════════════════════
/// <summary>
/// R306:arming 闸(key = taskId)。`TryAdd` 成功 = 本局的第一条流水线;
/// 失败 = 同一次点击的第二条 ⇒ **直接忽略**。GAMESTART 推完即释放。
/// </summary>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<uint, byte>
RacingAutoStartArming = new();
/// <summary>
/// R306:流水线代际号。由每条流水线进场时 <c>Interlocked.Increment</c> 领取 ⇒ 号码
/// **只增、唯一**;领到**最新**号码的那条 = **唯一有权结算**的那条。
/// 任何较早的代(同一次点击被重复调度出来的、或上一局残留的)走到结算点时都会看到
/// <c>Volatile.Read(ref RacingRunGeneration) != gen</c> ⇒ **就地自杀**,
/// 绝不结算「别人那一局」(这正是 00:40:01 / 00:44:28 两次提前结算的机制)。
/// 单字段即可:`Increment` 本身既发号又原子地把所有权交给最新那条。
/// </summary>
private static int RacingRunGeneration;
/// <summary>
/// 「玩家跑完全程了」——由计数器上报 handler 调用。返回 true 表示这次真的叫醒了等待中的那局
/// (false = 当时没有在跑的比赛 / 已经结过账,两种都属于正常幂等)。调用方负责打日志。
/// </summary>
internal static bool RequestRacingSettle(uint taskId)
=> RacingSettleSignals.TryGetValue(taskId, out var tcs) && tcs.TrySetResult(true);
/// <summary>
/// ★ R294:本局客户端**最后上报到的检查点下标**(key = taskId)。
/// 只在超时兜底结算时用来回答「为什么这局没在终点结算」——
/// 例:`settlement by TIMEOUT ... lastCounter=20/26 (还差 5 个检查点)` 直接说明是 300s 到点、
/// 不是终点判据坏了。由计数器 handler 写、结算时读并清。
/// </summary>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<uint, int>
RacingLastCounterIndex = new();
/// <summary>R294:记录本局最后上报的检查点下标(计数器 handler 调)。</summary>
internal static void NoteRacingCounter(uint taskId, int counterIndex)
=> RacingLastCounterIndex[taskId] = counterIndex;
/// <summary>R294:取并清除本局最后上报的检查点下标(结算时调)。返回 -1 = 一条都没上报。</summary>
internal static int TakeRacingLastCounter(uint taskId)
=> RacingLastCounterIndex.TryRemove(taskId, out var v) ? v : -1;
// ══════════════════════════════════════════════════════════════════════════════════
// ★★★★★ R301(2026-10-02 23:4x)· 「上车才启动挑战」——玩家上车前不点火
//
// 用户原话(连起来读):
// 「我看别人视频开始赛车…**在路中间上车后才开始倒计时**,排名上有大大的 321 倒计时,
// 还有音效,然后检查点出现,开始赛车」
// 「我根据别人视频里推测出来的情况是,我们点击开始挑战后,车出现在起点位置,然后步行
// 前往车辆位置,**上车后开始倒计时**,比赛正式开始」
// (R300 交付 321 过场之后)「**有倒计时了,就差上车启动挑战了**」
//
// ⇒ 缺口只剩**触发时机**:R300 的 484 过场是挂在 zone `ZoneState==1`(Display 边)上的,
// 而 zone 由 `ScheduleRacingAutoStart` 在**点开始挑战的那一刻**就用固定延时推出去
// ⇒ 无论玩家有没有上车、人在哪,1.2 s 后 321 都会放。官方是**上车**那一刻才放。
//
// 做法(最小侵入):不动任何包的内容与顺序,只把「1) Zone Display」这一步**推迟**到
// 「玩家上车」之后 —— 即在 `Task.Delay(RacingZoneDisplayDelayMs)` 之前插一道闸。
// · 任务图 / AI 车(0) 段)照旧在点开始挑战时立刻做(客户端 arming 需要,早做无害);
// · zone / 门 / 倒计时 / 结算 / teardown 全部跟在闸后面 ⇒ 时间轴自动整体平移,
// 客户端侧一行都不用改(R300 的 484 仍然挂在 Display 边上,只是 Display 变晚了)。
//
// 判据(服务端日志,两行都要有且顺序正确):
// `[RACE] boarding gate: ARMED task=…` ← 点开始挑战
// `[RACE] boarding gate: RELEASED by …` ← 玩家上车(随后 1.2 s 才出现 pushed RacingZoneInfo DISPLAY)
// 若只看到 ARMED + `TIMEOUT … starting anyway` ⇒ 上车包没到(先查
// `grep -c "C2S DECODED.*EnterOrExitVehicle" packets-*.log`,R301 之前它恒 0)。
//
// 兜底:`RacingBoardingWaitTimeoutMs` 到点自动开 —— 决不允许因为"没上车"把比赛卡死。
// 一键回退:`config\racing-ablation.txt` 加一行 `boarding` ⇒ 恢复「等玩家上车」的时序
// (R303 起默认是官方时序:不加任何词 = 接受即走)。
// ══════════════════════════════════════════════════════════════════════════════════
/// <summary>等上车的上限。到点无条件开赛(见上方 R301 段注释「兜底」)。</summary>
private const int RacingBoardingWaitTimeoutMs = 90_000;
/// <summary>
/// 正在等「玩家上车」的那一局(key = taskId)。上车包(两个 EnterOrExitVehicle 之一)到达时
/// 由 <c>SignalRacingBoarding</c> 置真 ⇒ 闸放行。单人赛车同一时刻只有一局,但用字典是为了
/// 与 <c>RacingSettleSignals</c> 同形,且天然幂等(重复信号不会重复放行)。
/// </summary>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<uint, TaskCompletionSource<bool>>
RacingBoardingSignals = new();
/// <summary>
/// 玩家上车(或下车,但只取上车那一侧)—— 由 <c>GameRouter.Vehicle</c> 的两个
/// EnterOrExitVehicle handler 在**解析成功**之后调用。返回 true = 这次真的放行了等待中的一局。
/// 没有等待中的局(旗标关掉 / 手动开车 / 比赛进行中上车)⇒ 静默 false。
/// </summary>
internal static bool SignalRacingBoarding(RpcContext ctx, ulong vehicleId, int seat, string source)
{
var woke = false;
foreach (var kv in RacingBoardingSignals)
if (kv.Value.TrySetResult(true))
{
woke = true;
ctx.Session.Log.Info(
$"[RACE] boarding gate: RELEASED by {source} task={kv.Key} "
+ $"vehicle={vehicleId} seat={seat} ⇒ Display/321/GameStart 现在才发");
}
return woke;
}
/// <summary>R301:等玩家上车。★ R303 起**默认不启用** —— 只有 ablation 里显式写了
/// <c>boarding</c> 才会真的等(官方实测没有这个闸,见 RacingTasks.cs 的长注释)。</summary>
private static async Task WaitForRacingBoardingAsync(RpcContext ctx, uint taskId)
{
if (!RacingStartRequiresBoarding) return;
// 点「开始挑战」时人已经在车里(上一局没下车、或坐在车里点的挑战)⇒ 没什么可等的。
try
{
var runtime = GetVehicleRuntime(ctx);
lock (runtime.SyncRoot)
{
if (runtime.CurrentVehicleId != 0)
{
ctx.Session.Log.Info(
$"[RACE] boarding gate: already driving vehicle={runtime.CurrentVehicleId} "
+ $"task={taskId} ⇒ no wait");
return;
}
}
}
catch (Exception vex)
{
ctx.Session.Log.Info(
$"[RACE] boarding gate: cannot read vehicle runtime ({vex.Message}) ⇒ skip the gate");
return;
}
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
RacingBoardingSignals[taskId] = tcs;
ctx.Session.Log.Info(
$"[RACE] boarding gate: ARMED task={taskId} — 点开始挑战只做 arming;"
+ $"Display/321/GameStart 等玩家上车后才发({RacingBoardingWaitTimeoutMs / 1000}s 未上车则自动开;"
+ "⚠ 非官方行为,由 ablation 的 boarding 一词启用 —— 官方实测是接受即开赛)");
try
{
var first = await Task.WhenAny(tcs.Task, Task.Delay(RacingBoardingWaitTimeoutMs));
if (first != tcs.Task)
ctx.Session.Log.Info(
$"[RACE] boarding gate: TIMEOUT after {RacingBoardingWaitTimeoutMs / 1000}s task={taskId} "
+ "— starting anyway (上车包没到:查 packets-*.log 的 `C2S DECODED .*EnterOrExitVehicle`)");
}
finally
{
RacingBoardingSignals.TryRemove(taskId, out _);
}
}
/// <summary>
/// AI opponents injected into the normal "点开始挑战" race — how many entries land in both
/// <c>AIVehicleInfos</c> (the plain <c>Dictionary&lt;ulong,uint&gt;</c> = vehicleInstanceId → cfgId
/// that the client's <c>GetAllVehicleAIIds()</c> enumerates) and <c>ParticipantInfos</c> (sent as
/// GameGroundParticipantInfo4229938 — the element shape the client's own Auto.WriteRacingZoneInfo
/// uses: 0xFF marker + 8 base fields; R203 replaced the old RacingParticipantInfo/TypeMark=14 shape
/// which desynced the packet by 28 B per participant).
///
/// 📌 SOLVED (2026-09-23 evening, §59.28). The wire format was never the problem — the ENTITY
/// was. <c>AIVehicleInfos</c> is <c>sceneVehicleEntityId → racingAiCfgId</c>, and the client
/// resolves the key against a real scene entity
/// (<c>DriveUtils.GetBaseVehicle(id)</c> → <c>element:BindVehicle(id)</c> → the rank map), while
/// the value indexes <c>RacingDriverRacingAIConfig</c> → <c>VehicleID</c> (which car it must be
/// driving) + <c>VehicleTaskID</c> (the AI task that laps the circuit). Inventing ids
/// (<c>0x6000_0000_0000_0000 + n</c>) could therefore never materialise: the v3 probe read
/// <c>csharp.aiCount=5 entityFound=0 entityMissing=5</c>.
///
/// ⇒ <c>RunRacingAutoStartAsync</c> now calls <c>SpawnRacingAiVehiclesAsync</c> (see
/// GameRouter.RacingAiVehicles.cs) FIRST, building each opponent from
/// <c>RacingAIConfig[aiCfgId].VehicleID</c>, and feeds those real entity ids back into the grid.
/// This count is the number of opponents requested; if the spawn fails the race runs with an
/// honest EMPTY grid rather than a ghost one.
/// </summary>
internal const int RacingAutoStartAiCount = 5;
/// <summary>
/// Gate for the SyncCarRaceGame(49071) push + SyncRacingResultData(68006935) settlement.
/// The 14:25 attempt pushed 49071 with 5 AI and regressed the whole race flow (even the minimap
/// route vanished), so this is OFF by default — the server sends ONLY the zone packets (grid +
/// route), which is the known-good 14:07 baseline. Flip to true (keeping RacingAutoStartAiCount
/// = 0) to test whether 49071 ALONE restores the countdown / AI minimap dots WITHOUT breaking the
/// route; only after that succeeds do we raise the AI count.
/// </summary>
internal const bool RacingAutoStartPushSyncCarRaceGame = true;
/// <summary>
/// Serialises one race-completion record per finished race. Offline single-player mode has no
/// client→server result report, so this is the honest first cut of "成绩/排行存储": it records
/// WHO finished WHICH track WHEN (a real completion log the user can inspect in
/// .runtime/racing_records.jsonl). Per-lap times / final ranking would need the client to report
/// the result back (no such C2S exists in offline mode) — that is a documented follow-up.
/// </summary>
private static readonly object RacingRecordLock = new();
/// <summary>
/// Gate for the REAL countdown push — <c>IGameSceneToClient_SyncOpenPanelCountDown</c>(68652507),
/// signature <c>(uint panelId, float countDown, bool redTenSeconds, bool isCountDown,
/// bool showMillisecond)</c> (dump.cs:184843 / 1901295 / 2056210; the client's own
/// <c>RPCMethodIdToName.lua:3277</c> maps 68652507 → "SyncOpenPanelCountDown", which is how we
/// know the wire id is real and not another Spoon IDMAP number).
///
/// The client's <c>ChallengeCountDownRacingPanelStore.OnShow</c> reads exactly
/// <c>countDownSecondNum / isCountDown / redTenSeconds / showMillisecond</c> — a 1:1 match with
/// this signature — so this is the panel the official server uses for the racing 3-2-1.
/// <c>CarRaceManager.OpenChallengeCountDownPanel(float,double)</c> is <c>private</c>, so this RPC
/// is the ONLY way a private server can open it.
///
/// panelId comes from the probe: <c>gPanelId.CHALLENGE_COUNT_DOWN_RACING_PANEL = 95</c>
/// ([DRMK-TRUTH] 2026-09-23 13:14:49). The v3 probe now enumerates gPanelId so a wrong guess is
/// correctable from the log alone. Off = the previous behaviour (route, no countdown).
///
/// ⛑ R218 曾把这里置 false(想在「倒计时面板」与「DISPLAY 包」之间二选一)。
/// ⛔⛔ **R219 已作废该消融并改回 true** —— 二选一实验做了,结论是**两个都不是崩因**:
/// · 本轮(10-01 09:32,本 const = false)`SyncOpenPanelCountDown` 推送 **0 次**(消融生效),
/// 但客户端**仍然死**;
/// · 真正的时间线(用探针心跳判定,比服务端日志准):
/// 客户端按钮 LEAVE ok = t=137.301(09:32:13.99)
/// 服务端 DISPLAY 推送 ≈ t=138.3(09:32:15) ← **客户端已死 1 秒**
/// 服务端 IOException = 09:32:17
/// `auto-start failed ... disposed object` = 09:32:18
/// ⇒ 客户端**死在服务端任何推包之前**;R218 的「死亡时刻 == displayEnd」是
/// **服务端断链检测延迟(约 2~3 秒)造成的假相关**,不是因果。
/// · `RacingZoneInfo GAMESTART` 推不出去,只是因为它在 DISPLAY 之后 3 秒才发,
/// 而那时客户端早就没了 —— 它是**结果**,不是判据。
/// 保留 true(倒计时面板是游戏正常流程的一部分,关掉会少一个可见特性)。
/// </summary>
internal const bool RacingAutoStartPushCountDown = true;
/// <summary>Panel id of the racing countdown (<c>gPanelId.CHALLENGE_COUNT_DOWN_RACING_PANEL</c>).</summary>
internal const uint RacingCountDownPanelId = 95;
/// <summary>
/// ★★★ R295 · 「中上比赛计时器」推送开关(第二次 <c>SyncOpenPanelCountDown</c>)。
///
/// <para><b>为什么需要它(R295 的实测证据链)</b></para>
/// <para>用户 2026-10-02 原话:「<i>中间的计时器还是没有</i>」(连问三轮)。</para>
/// <para>R253/R254 曾判定「中上那块是 native HUD,不需要 Lua 面板」——
/// 这条判定**在 R295 被实机日志推翻**:</para>
/// <list type="bullet">
/// <item>客户端 <c>log/Player.log</c> 21:01 那一局的面板装载清单里只有
/// <c>S_ChallengeCountDownPanel</c>(= 官方 <c>PanelConfig[485]</c>,
/// <c>ChallengeCountDownPanelStore</c>),**从来没有**
/// <c>S_ChallengeCountDownRacingPanel</c>(= <c>PanelConfig[95]</c>);</item>
/// <item>而 485 那次装载的存活时长恰好是 <b>3.15 s</b> ≈ 我们喂的 <c>countDown = 3</c>
/// (3 s + 0.15 s 关闭动画)⇒ 中上那块**确实是被 <c>SyncOpenPanelCountDown</c>
/// 打开的**,只是我们一局只喂了 3 秒;</item>
/// <item><c>ChallengeCountDownRacingPanelStore.OnShow</c> 一次性把
/// <c>countDownSecondNum</c> 转成 <c>endTime = startTime + countDown</c>,
/// 之后 <c>OnUpdate</c> 到点自己 <c>Close</c> ⇒ **没有第二次机会**。</item>
/// </list>
/// <para>⇒ 用户要的「计时器」= <b>整场剩余时间</b>(服务端自己判超时用的那个
/// <c>maxGameTime</c>),而我们只推过开赛的 3-2-1。本开关就是那第二次推送。</para>
///
/// <para><b>为什么取 <c>maxGameTime</c> 而不是别的数</b>:它是
/// <c>RacingDriverTrackInformationConfig</c> 里本赛道(track 3)的官方字段,
/// 也正是本文件下方 <c>raceSeconds</c> 超时兜底所依据的同一个值 ⇒ 屏幕上的
/// 倒计时与服务端真正结账的时刻**天然对齐**,不引入任何自造常量。
/// (R294 已实证:用户中途停 3 分 29 秒 ⇒ 到点走 TIMEOUT 兜底;有了这个可见倒计时,
/// 这种「莫名其妙就结束了」就不会再出现。)</para>
///
/// <para><b>为什么是「再推一次」而不是把第一次的 3 改成 maxGameTime</b>:第一次那条
/// 是官方语义的开赛 3-2-1(DISPLAY 阶段),改动它会同时改掉开赛节奏;
/// 第二次落在 GAMESTART 之后 ⇒ 两个语义各归其位,且**可以单独消融**
/// (<c>config\racing-ablation.txt</c> 加 <c>notimer</c> ⇒ 一键回退到改动前)。</para>
///
/// <para><b>panelId 仍写 95</b>:官方面板表实证 95 = <c>S_ChallengeCountDownRacingPanel</c>
/// 属于赛车挑战;但实测客户端实际装的是 485 —— 选哪个由客户端
/// <c>gChallengeManager:IsCurrentRacingChallenge()</c>(= 当前挑战的
/// <c>ChallengeType == Racing</c>)决定。**两者都是「中上计时器」**,对用户诉求等价,
/// 所以不为此跟客户端较劲;判据看「屏幕上有没有一个真在走的倒计时」。</para>
/// </summary>
internal const bool RacingTimerPushCountDown = true;
/// <summary>
/// Wire args for <c>SyncOpenPanelCountDown</c>(68652507) — a method-frame body (Inline, no root
/// 0xFF marker): <c>panelId u32 + countDown f32 + redTenSeconds bool + isCountDown bool +
/// showMillisecond bool</c> = 11 bytes. Booleans are 1 byte each, the same convention as
/// <c>RacingZoneInfo4229938.GameTypeRandom</c>. A wrong width shows up immediately as a client
/// <c>deserialize failed</c> for 68652507 and is then fixed from the log.
/// </summary>
[UxContract(Inline = true)]
internal sealed class SyncOpenPanelCountDownArgs
{
public uint panelId;
public float countDown;
public bool redTenSeconds;
public bool isCountDown;
public bool showMillisecond;
}
private static void RecordRacingCompletion(uint trackId, uint taskId, uint maxGameTime, ulong elapsedMs = 0)
{
try
{
var path = PrivateServerConfigStore.ResolveProjectPath(".runtime/racing_records.jsonl");
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var record = new
{
pid = Profile.PlayerPid,
trackCfgId = trackId,
taskId,
maxGameTime,
// ★ R241:真实用时(毫秒 + 秒两种写法,方便人读)。此前这张表里没有用时,
// 而结算面板显示的是写死的 1000ms —— 两边都无从核对。
elapsedMs,
elapsedSeconds = Math.Round(elapsedMs / 1000.0, 3),
finishedAtUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
finishedAtLocal = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"),
};
var line = JsonSerializer.Serialize(record);
lock (RacingRecordLock)
{
File.AppendAllText(path, line + "\n");
}
}
catch (Exception ex)
{
// A record-write failure must never break the race teardown.
try { /* swallow — best effort */ } finally { _ = ex; }
}
}
/// <summary>
/// The racing Spoon graph name of a raid. The client fastpatch injects exactly this string
/// (see <c>__DRMK_RacingSpoonNameForRaid</c> in client_game_switch_source.lua), and it is the
/// RaidSpoonName authored on the matching RacingDriverTrackInformationConfig rows
/// (raid 23300888 -> track 3, 5, 8, 9, 10, 13).
/// </summary>
internal static string RacingSpoonNameForRaid(uint raidId)
=> raidId == 0 ? string.Empty : raidId + "_RacingDriver";
/// <summary>
/// Which playable track belongs to <paramref name="raidId"/>.
///
/// Candidates = rows whose RaidSpoonName is "&lt;raidId&gt;_RacingDriver" AND MaxGamePeople &gt; 0
/// (the Id 1000+ block are map POI placeholders, not tracks — §59.16 D).
/// Several tracks share one Spoon graph, so a tie is broken by preferring a track that a
/// RacingDriverCompetitionGroupConfig group actually races (that is the authored "real" circuit
/// for the map; for raid 23300888 that selects track 3 / event 2040, group 1 = C级赛 新启01).
/// </summary>
internal static uint RacingTrackIdForRaid(uint raidId)
{
if (raidId == 0) return 0;
var spoon = RacingSpoonNameForRaid(raidId);
var tracks = ClientConfigRepository.RacingTracks();
var candidates = tracks.Values
.Where(t => t.MaxPlayers > 0
&& string.Equals(t.RaidSpoonName, spoon, StringComparison.OrdinalIgnoreCase))
.OrderBy(t => t.Id)
.ToArray();
if (candidates.Length == 0) return 0;
if (candidates.Length == 1) return candidates[0].Id;
var groupTracks = new HashSet<uint>(ClientConfigRepository.RacingCompetitionGroups().Values
.SelectMany(g => g.Tracks)
.Select(t => t.TrackId));
var preferred = candidates.FirstOrDefault(t => groupTracks.Contains(t.Id));
return (preferred ?? candidates[0]).Id;
}
/// <summary>
/// ★★★★★ R334:服务端**已有成套检查点表**的赛道 id 白名单。
///
/// <para>为什么必须有这道闸:客户端的赛道几何(`RacingPathName` → ZoneShape 赛道线)
/// 与服务端摆的**检查点门表**是**成套**的 —— 只放行「几何已发布」的 track 还不够,
/// 服务端得**同时**有一张与之对齐的门表,否则门会摆到别人家赛道上去。</para>
///
/// <para>加一条 = **先给它备好门表**(见 `RacingGates.cs`)。</para>
/// </summary>
private static readonly HashSet<uint> RacingPlayableTrackIds = [3];
/// <summary>
/// R334:`RaidSpoonName` 的 raid 段(去掉可选的 `spoon` 前缀)是否等于 <paramref name="raidId"/>。
/// <code>
/// "23300888_RacingDriver" -> 23300888 (track 3)
/// "23300999_Taffy_RaceGuide" -> 23300999 (track 2)
/// "spoon23301294_23301294_Race_Chaos_Gadgets" -> 23301294 (track 6,混厄)
/// </code>
/// 不能只做 `StartsWith(raidId)` —— 那样 2330129**4** 会被 2330129 前缀误配。
/// </summary>
private static bool RacingSpoonRaidMatches(string raidSpoonName, uint raidId)
{
if (raidId == 0 || string.IsNullOrEmpty(raidSpoonName)) return false;
var s = raidSpoonName;
if (s.StartsWith("spoon", StringComparison.OrdinalIgnoreCase)) s = s[5..];
var sep = s.IndexOf('_');
var head = sep > 0 ? s[..sep] : s;
return uint.TryParse(head, out var id) && id == raidId;
}
/// <summary>
/// ★★★★★ R334:**按任务选轨**(取代「按 raid 猜」)。返回 false = 不该开赛。
///
/// <para><b>为什么非改不可</b>:闸门只认任务 id、赛道却由 <c>RacingTrackIdForRaid(Profile.RaidId)</c>
/// 决定 —— 后者对 raid 23300888 **恒返 3**。于是主城**任何**一条赛车挑战被点开后:
/// 跑的都是 track 3 的 Downtown 路线,人也被传到 track 3 起点
/// (用户原话:「王牌02 新启新岸有开始挑战的点,<b>但是开始后启动的是我们已经做完的那条赛道</b>」)。</para>
///
/// <para><b>判据链(全部读服务端自己持有的生效配表,零硬编码坐标)</b>:</para>
/// <list type="number">
/// <item>task → event:<c>TaskEventCatalog4229938.EventsForTask(taskId)</c></item>
/// <item>event → track 行:<c>RacingDriverTrackInformationConfig.EventIds</c> 相交</item>
/// <item>track 行必须可用:<c>MaxPlayers &gt; 0</c> ∧ <c>RacingPathName</c> 非空
/// (几何**随客户端包发布过**的才有值;其余连一个字节都没有)</item>
/// <item>同 raid:<see cref="RacingSpoonRaidMatches"/></item>
/// <item>★ <see cref="RacingPlayableTrackIds"/>:服务端已有成套门表的赛道</item>
/// </list>
///
/// <para><b>语义(关键)</b>:任务在事件链上(events 非空)但筛不出成套赛道 ⇒ 返回 <b>false</b> ⇒
/// 「不启动」并打一行写明原因的日志,**绝不回退去乱开一条别人的赛道**。
/// 只有「任务压根不在任何事件链上」(events 为空)才回退 <see cref="RacingTrackIdForRaid"/>。</para>
///
/// <para><b>一键回退</b>:旗标文件加一行 <c>trackbytaskoff</c> ⇒ 完全回到改前的「按 raid 猜」
/// (见 <c>RacingTasks.cs</c> 的 <c>RacingTrackSelectStrict</c>)。</para>
/// </summary>
internal static bool TryPickRacingTrack(uint raidId, uint taskId, out uint trackId, out string reason)
{
trackId = 0;
reason = string.Empty;
if (taskId == 0)
{
trackId = RacingTrackIdForRaid(raidId);
reason = $"taskId=0 ⇒ fall back to by-raid pick ⇒ track={trackId}";
return trackId != 0;
}
if (!RacingTrackSelectStrict)
{
trackId = RacingTrackIdForRaid(raidId);
reason = $"trackbytaskoff (R334 ablation) ⇒ by-raid pick ⇒ track={trackId}";
return trackId != 0;
}
var events = TaskEventCatalog4229938.EventsForTask(taskId);
if (events.Count == 0)
{
trackId = RacingTrackIdForRaid(raidId);
reason = $"task not on any event chain ⇒ fall back to by-raid pick ⇒ track={trackId}";
return trackId != 0;
}
var all = ClientConfigRepository.RacingTracks();
var geoOk = all.Values
.Where(t => t.MaxPlayers > 0
&& !string.IsNullOrWhiteSpace(t.RacingPathName)
&& t.EventIds.Any(e => events.Contains(e)))
.OrderBy(t => t.Id)
.ToArray();
var picked = geoOk.FirstOrDefault(t => RacingSpoonRaidMatches(t.RaidSpoonName, raidId)
&& RacingPlayableTrackIds.Contains(t.Id));
if (picked is not null)
{
trackId = picked.Id;
reason = $"task={taskId} events=[{string.Join(',', events)}] ⇒ track={picked.Id} "
+ $"(path={picked.RacingPathName}, spoon={picked.RaidSpoonName})";
return true;
}
// ★ 这里是本改动真正的语义:**知道它属于哪个事件,但那个事件没有成套赛道 ⇒ 不开赛**。
reason = $"task={taskId} events=[{string.Join(',', events)}] raid={raidId}: "
+ $"no playable track (geometry-published candidates=[{string.Join(',', geoOk.Select(t => t.Id))}], "
+ $"server-implemented tracks=[{string.Join(',', RacingPlayableTrackIds.OrderBy(x => x))}]) "
+ "⇒ REFUSING to fall back to another circuit's route (R334)";
return false;
}
/// <summary>
/// ★★★★★ R309(2026-10-03):把玩家<b>连人带车</b>瞬移到赛道起点(官方检查点表第 0 个 = cp00)。
///
/// <para><b>为什么是我们自己实现(用户授权)</b>:官方抓包 <c>packets-*.log</c> <b>10/10</b>
/// 证明<b>官方不传送</b> —— 官方的前置条件是「你已经在车里、且车在起点」:
/// 赛前声明页对赛车挑战先调 <c>gRacerManager:TrySetCompetitionVehicle(trackId)</c>,它取
/// <c>gDriveVehiclesManager.cs_manager.CurrentPlayerBaseVehicle</c>,取不到就报
/// 「未找到玩家载具,无法设置赛事车辆!」。私服里玩家常常<b>就地点挑战</b> ⇒ 人和车留在原地,
/// 离赛道起点可能 2.9 km ⇒ 321 倒计时照走,屏幕上却是别的街景、也没有起跑线。</para>
///
/// <para><b>用户原话</b>:「…就直接最简单的,在面板点击开始挑战后把角色连人带车一起传送到
/// 比赛起点,然后正常倒计时321,开始比赛就行」+「如果官方没有,就我们自己实现一个,
/// 这个我允许自创」。</para>
///
/// <para><b>通道(两条都是本仓已经在用、已实机验证的)</b>:</para>
/// <list type="bullet">
/// <item>人 = <c>SyncUnitPositionAndFacing</c>(68030312) —— 同 GM 传送
/// (<c>TeleportPlayerInstantAsync</c>)。选它而<b>不是</b> <c>SyncTeleport</c> 的原因:
/// 它无 loading 流程,不会在 1.2 s / 3 s 的开赛时序里插一段黑屏。</item>
/// <item>车 = <c>SyncTeleportVehicle</c>(68088911) <c>Reset=true</c> —— 同改装店把车钉在工位上
/// (<c>GameRouter.Teleport.cs:155</c> / <c>GameRouter.VehicleTuning.cs:229</c>)。</item>
/// </list>
///
/// <para><b>位置与朝向</b>:<c>OfficialTrack3Checkpoints[0]</c> —— 官方关卡块 <c>vfc_819</c>
/// 的 259 点折线上第 0 个检查点(26 点逐点偏差 ≤ 0.009 m);朝向 = 该点处的<b>道路切向</b>
/// (沿路前进方向,<c>R293</c> 定案),正是玩家发车时应有的车头方向。</para>
///
/// <para><b>失败绝不抛出</b>:传送只是「让手感顺」,比赛本身不能因为它而开不起来 ——
/// 任何一步异常都只打一行日志。</para>
///
/// <para><b>消融</b>:<c>config/racing-ablation.txt</c> 加 <c>noteleport</c>(本文件不用改)。</para>
/// </summary>
/// <summary>
/// ★ R310b:开赛传送的落点,从 <c>OfficialTrack3Checkpoints[0]</c>(cp00)
/// **沿发车方向后退** 这么多米。0 = 正落在 cp00(R309 的旧行为)。
///
/// <para><b>为什么非退不可</b>:官方 HUD 那个「蓝色菱形 + 距离」画不画的唯一开关是客户端
/// <c>RacingPanelStore.lua:224-237</c> 的
/// <c>local isShow = gCS.LuaUtils.IsInCameraView(MainCamera, uiPos)</c>,
/// 而 <c>uiPos = arrowPos + Quaternion.Euler(arrowRot) * Vector3(0, zOffset, 0)</c>
/// —— <c>arrowPos</c> = 那件 3D 指示牌(<c>arrowGO</c>)的世界位置,<c>zOffset</c> 恒 10。</para>
///
/// <para>R309 把玩家**精确送到 cp00** ⇒ 指示牌锚点与玩家**重合** ⇒
/// <c>uiPos</c> 落在玩家**头顶 10 m**,摄像机在车后、俯角很小 ⇒
/// <c>IsInCameraView</c> 判假 ⇒ **菱形整块不画**。</para>
///
/// <para>这正是用户 2026-10-03 01:4x 报的「<b>起点、第二个检查点都看不到了,
/// 到第三个检查点才会显示</b>」—— 官方 <c>taskCounterList</c> 的 index=1/2 都指向
/// cp00 同一块区域(实测 <c>uar1</c> 与 <c>t+3</c>/<c>t+4.5</c> 推的都是
/// <c>(2988.4, −0.1, 969.0)</c> = cp00),所以前两个一起丢。</para>
///
/// <para><b>方向约定</b>(用两帧实测反证):cp00 → cp01 的 Δ 归一化 = <c>(−0.884, +0.468)</c>,
/// 而 <c>(sin yaw, cos yaw)</c> @ <c>yaw = −61.54°</c> = <c>(−0.879, +0.477)</c>
/// ⇒ 一致(差 &lt;1°)⇒ <b>前进 = (sin yaw, 0, cos yaw)</b>,落点 = <c>cp00 − 前进 × 偏移</c>。</para>
///
/// <para><b>y 仍取 cp00 的高度</b>:这一段 259 点折线在路面上几乎全平
/// (cp00..cp07 的 y 都是 −0.11)⇒ 退 30 m 不会造成悬空/陷地。</para>
///
/// <para><b>★★ 这个偏移量必须 &gt; 22 m(不是 18)</b>:客户端 `LTConfig.PoiGameConfig`
/// 里 <c>BabyDriver_CheckPointRange = 22</c> —— 那就是「到达检查点」的判定半径。
/// 落点只要仍落在门周围 22 m 内,客户端**开赛瞬间就上报「已通过该检查点」**
/// ⇒ 服务端 `[RACEGATE] cpNN passed -> removed` 立刻把那扇门撤掉
/// (实测:开赛 01:37:24 传送 → 01:37:25 摆好 52 扇 → 01:37:29 就 `cp00 passed`
/// 把 ids=[…52,53] 撤了)⇒ **玩家根本看不到起点那扇门**。这正是用户
/// 2026-10-03 01:4x 报的「不光是没有菱形啊,是<b>检查点门也没有</b>」。</para>
///
/// <para>⇒ 退到 <b>30 m</b>(&gt; 22 且有 8 m 余量)= 一举两得:
/// ① 门不会被"秒过"撤掉,玩家看得到起点门;
/// ② cp00 落在玩家正前方,HUD 菱形能被 <c>IsInCameraView</c> 判为可见。</para>
/// </summary>
private const float RacingStartBackOffsetMeters = 30f;
private static async Task TeleportPlayerAndVehicleToRacingStartAsync(RpcContext ctx)
{
if (RacingSkipTeleportToStart)
{
ctx.Session.Log.Info("[RACE][ABLATION] noteleport: race-start teleport SKIPPED "
+ "(player and vehicle stay where the player clicked 开始挑战)");
return;
}
if (OfficialTrack3Checkpoints.Length == 0)
{
ctx.Session.Log.Warn("[RACE] R309 race-start teleport skipped: "
+ "OfficialTrack3Checkpoints is empty");
return;
}
var cp = OfficialTrack3Checkpoints[0];
var yaw = cp.Yaw;
// ★★★★★ R310b:落点沿发车方向**后退** —— 见 `RacingStartBackOffsetMeters` 的文档注释
// (根因:玩家与指示牌锚点重合 ⇒ 官方 `IsInCameraView` 判假 ⇒ 菱形前两个检查点不画)。
var tx = cp.X;
var ty = cp.Y;
var tz = cp.Z;
if (RacingStartBackOffsetMeters > 0f)
{
var rad = yaw * System.MathF.PI / 180f;
tx = cp.X - System.MathF.Sin(rad) * RacingStartBackOffsetMeters;
tz = cp.Z - System.MathF.Cos(rad) * RacingStartBackOffsetMeters;
ctx.Session.Log.Info($"[RACE] R310b race-start back-offset: cp00=({cp.X:F2},{cp.Y:F2},{cp.Z:F2}) "
+ $"yaw={yaw:F2} − {RacingStartBackOffsetMeters:F1}m ⇒ 落点=({tx:F2},{ty:F2},{tz:F2}) "
+ "(让 cp00 落在玩家正前方 ⇒ 官方 HUD 菱形才会被 IsInCameraView 判为可见)");
}
// ★ 先后顺序与改装店 `PushPlayerAndVehicleAsync`(`GameRouter.Teleport.cs:140`)一致:
// **先人、后车** —— 人在车里时「人」是 leader(相机 / 控制都跟它),先把 leader 放对,
// 再把车摆到同一个点,客户端的一致性最好。反过来(先车后人)会多一帧「车在远处、人还在原地」。
try
{
await TeleportPlayerInstantAsync(ctx, tx, ty, tz, yaw, "R309-race-start");
}
catch (Exception pex)
{
ctx.Session.Log.Warn($"[RACE] R309 player teleport failed (race continues): {pex.Message}");
}
// 车:`SyncTeleportVehicle`(68088911) `Reset=true` = 真跳变(同改装店把车钉在工位上)。
ulong vehicleId;
var runtime = GetVehicleRuntimeState(ctx.Session);
lock (runtime.SyncRoot)
{
vehicleId = runtime.CurrentVehicleId != 0
? runtime.CurrentVehicleId
: runtime.LastDrivenVehicleId;
}
if (vehicleId != 0)
{
try
{
await ctx.NotifyAsync(MethodId.SyncTeleportVehicle,
UxSerializer.Serialize(new SceneMethods.SyncTeleportVehicle4229938
{
EntityId = vehicleId,
Position = new SceneMethods.UxVector3(tx, ty, tz),
Rotation = new SceneMethods.UxVector3(0f, yaw, 0f),
Velocity = 0f,
Reset = true, // ★ 真跳变 ⇒ 客户端直接落位,不插值
MoveToken = 0,
}));
lock (runtime.SyncRoot)
{
// 服务端自己的一致性:AdminWeb 面板位置、改装店「最近开过的车」都读这个。
if (runtime.Vehicles.TryGetValue(vehicleId, out var rec))
{
rec.Position = new Vec3(tx, ty, tz);
rec.Facing = yaw;
}
}
ctx.Session.Log.Info($"[RACE] R309 teleport VEHICLE entity={vehicleId} "
+ $"→ ({tx:F2},{ty:F2},{tz:F2}) yaw={yaw:F2} (SyncTeleportVehicle 68088911 Reset=true)");
}
catch (Exception vex)
{
ctx.Session.Log.Warn($"[RACE] R309 vehicle teleport failed (race continues): {vex.Message}");
}
}
else
{
ctx.Session.Log.Info("[RACE] R309 race-start teleport: no current/last vehicle recorded "
+ "⇒ moving the player only (on foot)");
}
}
/// <summary>
/// Called from the AskAcceptAndSetCurrentTask handler. No-ops unless the accepted task belongs to
/// a Racing challenge; otherwise schedules the Display -> GameStart zone push.
/// </summary>
internal static void ScheduleRacingAutoStart(RpcContext ctx, uint taskId)
{
if (taskId == 0) return;
if (!RacingChallengeTaskIds.Value.Contains(taskId)) return;
// ★★★★★ R306①:**arming 去重**(同一次「点开始挑战」只允许一条流水线)。
// 铁证(`logs/console-latest.log`,用户同一晚连跑三局):00:34:55 同一个 task 打了
// **两行** `auto-start scheduled`、两条 `DISPLAY`、两条 `GAMESTART`;两条都在 00:35:01
// 登记了 `RacingSettleSignals[taskId]`(后者**覆盖**前者)⇒ 前者永远收不到客户端的
// FINISH,它那条 `Task.Delay(300)` 无人取消,在 **00:40:01** 醒来,把**当时正在跑的
// 第 2 局**结算掉;第 2 局的定时器又跑到 00:44:28 掐了第 3 局 ⇒ **永久错位一局**。
// 窗口 = 「接受 → GAMESTART 推完」;正常在 GAMESTART 后立刻释放(下方 3) 之后),
// 异常/提前 return 由 <c>RunRacingAutoStartAsync</c> 的 finally 保底释放。
if (!RacingAutoStartArming.TryAdd(taskId, 0))
{
ctx.Session.Log.Warn(
$"[RACE] auto-start DUPLICATE dropped task={taskId}: another pipeline for the "
+ "same click is still arming (R306 dedup; window = accept .. GAMESTART pushed)");
return;
}
var raidId = Profile.RaidId;
// ★★★★★ R334:**按 task/EventId 精确选轨**。
// 改前 = `RacingTrackIdForRaid(raidId)` —— 对主城 23300888 恒返 3 ⇒ 点**任何**一条赛车
// 挑战都被拉去跑 Downtown(用户报的「王牌02 新启新岸…开始后启动的是我们已经做完的那条赛道」)。
// 判据链 / 语义 / 一键回退见 `TryPickRacingTrack` 的文档注释。
var hasTrack = TryPickRacingTrack(raidId, taskId, out var trackId, out var pickReason);
if (!hasTrack || trackId == 0)
{
ctx.Session.Log.Info($"[RACE][R334] auto-start skipped task={taskId} raid={raidId}: {pickReason}");
// ★ R306①:提前离场必须把闸还回去,否则该 taskId 之后永远被自己的去重挡住。
RacingAutoStartArming.TryRemove(taskId, out _);
return;
}
ctx.Session.Log.Info($"[RACE][R334] track pick task={taskId} ⇒ track={trackId} | {pickReason}");
var maxGameTime = ClientConfigRepository.RacingTracks().TryGetValue(trackId, out var row)
? row.MaxGameTime
: 0u;
ctx.Session.Log.Info(
$"[RACE] auto-start scheduled task={taskId} raid={raidId} track={trackId} " +
$"maxGameTime={maxGameTime} (+{RacingZoneDisplayDelayMs}ms display, " +
$"+{RacingZoneDisplayDelayMs + RacingZoneGameStartDelayMs}ms gameStart)");
_ = RunRacingAutoStartAsync(ctx, taskId, trackId, maxGameTime);
}
private static async Task RunRacingAutoStartAsync(RpcContext ctx, uint taskId, uint trackId, uint maxGameTime)
{
try
{
// ★★★★★ R306②:进场先领一个**唯一且最新**的代际号 ⇒ 本流水线成为「唯一有权结算」的那条。
// (配对的两道护栏:GAMESTART 之后 / 真正醒来要结算那一刻;见下方注释。)
var gen = Interlocked.Increment(ref RacingRunGeneration);
ctx.Session.Log.Info($"[RACE] run generation claimed task={taskId} track={trackId} gen={gen}");
// 🔴 REAL RACE START (§59.25): the zone packet (68004609) only stages the grid + minimap
// route. The actual race simulation — countdown panel, AI vehicles, lap clock, ranking —
// is driven by SyncCarRaceGame(49071), which is what the official server pushes and what
// L50.Spoon.CarRaceManager.SyncCarRaceGame consumes (→ lua OnCarRaceStart → AI minimap
// dots via GetAllVehicleAIIds). We never sent it before, which is exactly why the player
// saw a route but no countdown / no AI / no ranking. So we push BOTH: the zone (grid +
// route) and SyncCarRaceGame (the race itself).
// ⛑ 消融旗标(R191b,与 RacingTasks.cs 的 RacingAblationMode 同源)。定位后删除。
if (RacingAblationMode == "norace")
{
ctx.Session.Log.Info("[RACE][ABLATION] norace: ignition chain SKIPPED at entry "
+ "(task packets were still pushed by the handler; crash here = task packets guilty)");
return;
}
var aiCount = RacingAutoStartAiCount;
if (RacingAblationMode == "noai")
{
aiCount = 0;
ctx.Session.Log.Info("[RACE][ABLATION] noai: AI vehicle spawn DISABLED (task packets + zone + countdown still fire)");
}
// 0) 🔴 ORIGINAL TASK-GRAPH LOAD (2026-09-23 evening, "还原大于一切").
// The racing Spoon graph was being force-merged by a client fastpatch that injected
// "<raidId>_RacingDriver" into EnterSceneInfo.SceneSpoonNames — but the only official
// captures we own (logs\packets-20260913-*.log) show the official server sends
// SceneSpoonNames: [] / SceneSpoonMd5s: [] inside the racing raid, so that is NOT how
// the graph arrives. The native channel is the TASK graph: SyncPlayerSingleTask
// (64917604) carrying TaskSpoonViewInfo{SpRaidId, Alias} — the very two keys authored
// inside RacingDriverTrackInformationConfig.RaidSpoonName. It also runs
// LoadTaskWorkActionData, which is what fills the racing task's work actions — the
// reason the client spams "nextRankPoint is nil" and our probe read task.workActions=0.
// Additive + best-effort; kill switch RacingTaskSpoonLoadEnabled.
await PublishRacingTaskSpoonAsync(ctx, taskId, trackId, Profile.RaidId);
// 0b) 🔴 AI OPPONENT ENTITIES (§59.28) — spawn BEFORE the zone lands. The client resolves
// every AIVehicleInfos key against the scene (DriveUtils.GetBaseVehicle → minimap
// BindVehicle → the rank map), so a grid whose entities do not exist yet is a grid it
// can never materialise. This is the fix for the 13:3x probe reading
// `csharp.entityFound=0 csharp.entityMissing=5`.
List<RacingAiVehicle> aiVehicles = [];
if (RacingAiSpawnEnabled && aiCount > 0)
{
aiVehicles = await SpawnRacingAiVehiclesAsync(ctx.Session, trackId, aiCount);
}
else if (!RacingAiSpawnEnabled)
{
// ⚠ R203c:AI spawn 默认关闭(⚠ 不是「已定案的真凶」——15:54 实测已推翻该定案,
// 见 RacingAiVehicles.cs 的四会话矩阵)。关着只为先保单人挑战可用。
ctx.Session.Log.Info("[RACE] AI opponent spawn is OFF (R203c gate; the spawn="
+ " crash-root-cause verdict was REFUTED by the 15:54 run). Solo timed"
+ " challenge runs with an honest empty grid."
+ " Set env DRMK_RACE_AI_SPAWN=1 to re-enable for diagnosis.");
}
// Real entities win. If the spawn produced nothing we send an EMPTY grid on purpose —
// falling back to invented ids would silently recreate the ghost-grid bug.
var gridCount = aiVehicles.Count;
// ★ R203c 消融:noaigrid = 实体照常 spawn,但网格推空(区分「spawn 副作用」vs「网格数据」)。
var gridSendEmpty = RacingAblationMode == "noaigrid";
if (gridSendEmpty)
{
ctx.Session.Log.Info($"[RACE][ABLATION] noaigrid: {gridCount} AI entities spawned,"
+ " but zone ParticipantInfos/AIVehicleInfos pushed EMPTY"
+ " (isolates spawn side-effects from the grid payload)");
gridCount = 0;
}
ctx.Session.Log.Info($"[RACE] AI grid ready real={gridCount} requested={aiCount}"
+ $" spawnGate={RacingAiSpawnEnabled} (empty ⇒ honest no-AI race)");
// ★★★ R301:**上车闸**(见本文件 R301 段注释)。
// 位置是刻意选的:上面 0) 任务图 + AI 车都已经发完(客户端 arming 与起跑线布车
// 不依赖玩家位置),从这一行往下 = 「比赛真的开始」,全部等玩家上车再走
// ⇒ zone Display(→客户端 484 全屏 321) / 摆门 / 3-2-1 / GameStart / 计时 / 结算
// 整体平移,客户端补丁一行都不用改。
// 旗标 `boarding`(**默认不加 = 官方时序**,R303 起):不加则本调用**立刻返回**,
// 时序 = 官方实测的「接受 → 1.2 s Display → +3 s GameStart」(见
// `GameRouter.RacingTasks.cs` 的 `RacingStartRequiresBoarding` 注释与
// `MEMORY.racing.md` R303 的官方抓包 10/10 证据)。
await WaitForRacingBoardingAsync(ctx, taskId);
// ★★★★★ R309(2026-10-03)「点开始挑战 ⇒ 连人带车瞬移到赛道起点」───────────────
// 用户原话:「就直接最简单的,在面板点击开始挑战后把角色连人带车一起传送到比赛起点,
// 然后正常倒计时321,开始比赛就行」+「如果官方没有,就我们自己实现一个,这个我允许自创」。
// 官方抓包 10/10 证明官方不传送(官方要求你**自己开到起点**,见
// `RacingTasks.cs` 的 `RacingSkipTeleportToStart` 长注释)。
// 位置刻意选在这里 = 上车闸之后、DISPLAY(→ 客户端 484 全屏 321)之前:
// 传送先落地,然后才是「1.2 s DISPLAY → 3 s GAMESTART」的官方时序,
// 客户端补丁一行都不用改。方法内部任何异常都只打日志、绝不抛出。
await TeleportPlayerAndVehicleToRacingStartAsync(ctx);
// 1) Zone Display — stage the grid + minimap route. Carries a real DisplayCountDownEndTime
// so the native vehicle-control-unlock path arms (see §14.3 of the racing notes).
await Task.Delay(RacingZoneDisplayDelayMs);
var displayAt = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var displayEnd = displayAt + (uint)(RacingZoneGameStartDelayMs / 1000); // +3s
await ctx.NotifyAsync(MethodId.SyncGameGroundZoneInfo, UxSerializer.Serialize(
BuildRacingZoneProbe(trackId, taskId, ZoneStateDisplay, SyncReasonEnter, null, 0,
0, 0, 0, displayEnd, gridCount, gridSendEmpty ? null : aiVehicles)));
ctx.Session.Log.Info(
$"[RACE] pushed RacingZoneInfo DISPLAY track={trackId} task={taskId} displayEnd={displayEnd} ai={aiCount}");
// ★★★ R247(2026-10-01):把「绿框门」摆出来。
// 用户报「开始赛车了,没有看到绿框门」。门 = gadget
// Racing_challenge01_CheckPoint / _CheckPointEnd,官方按检查点位置运行时 spawn,
// 而检查点来自**从未合并进客户端**的 Spoon 图 ⇒ 官方那条链一次都没跑过,门从来没人 spawn。
// R244 试过客户端 Lua 自己 Instantiate:16/16 建成、active=true、scale=1/1/1、
// 零 Load asset FAILED —— 但**肉眼一道都看不见**(同「家具全无 / 健身器材」的病根:
// 客户端不会自己实例化这类物件)⇒ 改走已验证通道 SpawnGadgetAsync。
// 位置 = DISPLAY 刚推出去(布置阶段)⇒ 倒计时 3 秒期间门就该立好了。
// 拿不到坐标表时会在玩家四周摆 4 道「验证信标」,把「通道对不对」与「坐标对不对」解耦。
try
{
await SpawnRacingGatesAsync(
ctx.Session, GetStateStatic(ctx.Session), trackId, taskId);
}
catch (Exception gex)
{
// 摆门失败绝不能拖累比赛本身。
ctx.Session.Log.Warn($"[RACE] racing gate spawn skipped: {gex.Message}");
}
// 2) 🔴 THE REAL COUNTDOWN (§59.27): IGameSceneToClient_SyncOpenPanelCountDown(68652507).
// Neither the zone packet nor the (dead) 49187 push opens the 3-2-1 panel, and
// CarRaceManager.OpenChallengeCountDownPanel is private — so this RPC is the only way
// in. Fired on the Display edge so the panel expires exactly when GameStart lands.
// redTenSeconds = false: the red-10s alert animation only makes sense for a >=10s timer,
// and it would fire instantly on a 3s countdown.
if (RacingAutoStartPushCountDown)
{
try
{
var countDownSeconds = (float)(RacingZoneGameStartDelayMs / 1000.0); // 3.0
var countdownBytes = UxSerializer.Serialize(new SyncOpenPanelCountDownArgs
{
panelId = RacingCountDownPanelId,
countDown = countDownSeconds,
redTenSeconds = false,
isCountDown = true,
showMillisecond = false,
});
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncOpenPanelCountDown, countdownBytes);
ctx.Session.Log.Info(
$"[RACE] pushed SyncOpenPanelCountDown(68652507) panelId={RacingCountDownPanelId} "
+ $"countDown={countDownSeconds} bytes={countdownBytes.Length}");
}
catch (Exception cex)
{
// A countdown-panel hiccup must never block the race itself.
ctx.Session.Log.Info($"[RACE] countdown push skipped: {cex.Message}");
}
}
// 🔴 DISABLED & REMOVED (§59.26, Lyco): this spot used to push SyncCarRaceGame(49071)
// and OpenChallengeCountDownPanel(49187). Both ids are Spoon IDMAP action numbers, NOT
// wire RPC ids — the client answered `#NoCreateIssue method not found` for each. The
// race itself is NOT missing: the zone packet above already carries RacingZoneInfo ->
// C# CarRaceManager.SyncCarRaceGame (IsGameStart=TRUE). What the client needs is DATA
// (real AI entities matching AIVehicleInfos + the task checkpoint list) and the
// countdown through IGameSceneToClient_SyncOpenPanelCountDown(68652507), both above.
// The dead mid references were dropped in R190 so v2.1 compiles without inventing
// MethodId entries for Spoon action numbers.
await Task.Delay(RacingZoneGameStartDelayMs); // let the 3s countdown run
// 3) Zone GameStart — actually start the race (lap clock / car unlock via the watchdog).
var goAt = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var goGameTimeEnd = maxGameTime > 0 ? goAt + maxGameTime : 0u;
await ctx.NotifyAsync(MethodId.SyncGameGroundZoneInfo, UxSerializer.Serialize(
BuildRacingZoneProbe(trackId, taskId, ZoneStateGameStart, SyncReasonEnter, null, 0,
goAt, goGameTimeEnd, 0, 0, gridCount, gridSendEmpty ? null : aiVehicles)));
ctx.Session.Log.Info(
$"[RACE] pushed RacingZoneInfo GAMESTART track={trackId} task={taskId} start={goAt} end={goGameTimeEnd} ai={gridCount}");
// ★ R306①:GAMESTART 已推完 ⇒ arming 窗口结束,把闸还回去(保底释放见 finally)。
RacingAutoStartArming.TryRemove(taskId, out _);
// ★★★ R295 · 「中上比赛计时器」——把整场剩余时间交给客户端自己去走。
// 上面那条 DISPLAY 边上的 3-2-1 到点就自行 Close(store 的 OnUpdate),
// 所以这里必须在 GAMESTART 之后再推一次,`countDown = maxGameTime`,
// 客户端就会一直显示到 00:00 才关 —— 也就是用户说的「中间的计时器」。
// ★★★ R296(2026-10-02 21:41 用户原话):「中间的计时器不对,要桌面最新截图样式的
// (`00:30.199`),之前不是已经调出来过吗,不过那个时候调出来的是倒计时」
// ⇒ R295 首版推的 `isCountDown = true` 是**倒计时**(`05:00 → 00:00`),形态不对。
// 官方(截图)要的是**正计时 + 毫秒** `MM:SS.mmm`:
// · `isCountDown = false` ⇒ store 里 `reverseCountDown = not isCountDown = true`
// ⇒ `GetFormatCountDownTime` 取 `nowTime - startTime` = **已用时间**(递增);
// · `showMillisecond = true` ⇒ 走 `gString.Format("%02d:%02d.%03d", …)`
// = 截图里那个 `00:30.199`(两个 store 的这三个分支逐字相同,已核对
// `ChallengeCountDownRacingPanelStore.lua:41/46/166-187` 与
// `ChallengeCountDownPanelStore.lua` 的同名函数)。
// `countDown` 仍取 `maxGameTime`:正计时下它同时是**上限**
// (`endTime = startTime + countDown`,到点 store 自己 `Close`)
// ⇒ 屏幕走到 `05:00.000` 与服务端超时结账仍是同一时刻。
// `redTenSeconds = true` ⇒ 最后 10 秒走官方的红色告警动画
// (第一次那条 3 秒的必须为 false,否则开赛瞬间就闪红)。
// 消融:config\racing-ablation.txt 加 `notimer`。
if (RacingTimerPushCountDown && !RacingSkipRaceTimer && maxGameTime > 0)
{
try
{
var timerSeconds = (float)maxGameTime;
var timerBytes = UxSerializer.Serialize(new SyncOpenPanelCountDownArgs
{
panelId = RacingCountDownPanelId,
countDown = timerSeconds,
redTenSeconds = true,
isCountDown = false, // ★ R296:false = 正计时(已用),截图样式
showMillisecond = true, // ★ R296:true = `MM:SS.mmm`(截图 `00:30.199`)
});
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncOpenPanelCountDown, timerBytes);
ctx.Session.Log.Info(
$"[RACE] pushed SyncOpenPanelCountDown(68652507) ** RACE-TIMER(R296 counting-UP ms) ** "
+ $"panelId={RacingCountDownPanelId} countDown={timerSeconds}s "
+ $"(== MaxGameTime, the same value raceSeconds uses) "
+ $"isCountDown=false showMillisecond=true bytes={timerBytes.Length}");
}
catch (Exception tex)
{
// A countdown-panel hiccup must never block the race itself.
ctx.Session.Log.Info($"[RACE] race-timer push skipped: {tex.Message}");
}
}
else if (RacingSkipRaceTimer)
{
ctx.Session.Log.Info("[RACE][ABLATION] notimer: race-timer SyncOpenPanelCountDown NOT pushed");
}
// ★★★ R241(2026-10-01):**赛道时钟起点**。用户报「到终点能结算了,但结算页的用时
// 永远是 1 秒」。原因在 BuildRacingResultData:离线模式没有真实圈速,三个时间字段
// 一直被写死 `1000`(ms)当占位符 ⇒ 面板 `time = TotalTime/1000 = 1.000s`。
// 这里在 GAMESTART 推出去的那一刻起表,结算那一刻停表 ⇒ 得到真实比赛用时。
// ⚠ 这只是**服务端视角**的用时(含一次单向网络延迟,几十毫秒量级);客户端自己
// 还有更精确的计时(`ChallengeManager.beginTime`),R241 的客户端补丁会用
// 游戏自己的钟覆盖面板显示值。服务端这个值是「无补丁时的诚实兜底」。
// ★★★★★ R306②(代际护栏 · 第一道):走到这里 = DISPLAY / 摆门 / 3-2-1 / GAMESTART /
// 整场计时器都推完了。若已经有**更新的**流水线接手(同一次点击的重复调度、或玩家
// 在比赛中又点了一次开始),本代必须**就地自杀**:既不登记结算信号,也不再往这个
// 世界推任何东西(否则会把新一代的 DISPLAY/GAMESTART 覆盖成旧值)。
if (Volatile.Read(ref RacingRunGeneration) != gen)
{
ctx.Session.Log.Warn(
$"[RACE] stale run gen={gen} (current={Volatile.Read(ref RacingRunGeneration)}) "
+ $"task={taskId} track={trackId} — ABORT before settle "
+ "(R306: refusing to take over a newer pipeline's race)");
return;
}
var raceClockStart = DateTimeOffset.UtcNow;
// ---- teardown ----------------------------------------------------------------
// ★★★ R234:不再死等 `MaxGameTime`(track 3 = 300s)——玩家实测 79 秒就跑完全程,
// 剩余 220 秒里客户端已经把赛车面板关了,却什么都等不到(「没有办法到终点结束」)。
// 改成「**客户端的完成上报** 或 **超时**,谁先到谁触发」,两条路进的是**同一段结算**。
var raceSeconds = maxGameTime > 0 ? maxGameTime : RacingZoneFallbackGameTimeSeconds;
var settleSignal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
// ★ R241:真实用时(毫秒)。客户端的完成上报(index == taskCounterCount)一到达就停表;
// 走超时兜底时等于 `MaxGameTime`(没跑完,时间打满 —— 与官方「超时即失败」同义)。
ulong elapsedMs = 0;
RacingSettleSignals[taskId] = settleSignal;
try
{
var settleTimeout = Task.Delay(TimeSpan.FromSeconds(raceSeconds));
var firstToFinish = await Task.WhenAny(settleTimeout, settleSignal.Task);
// ★★★★★ R306②(代际护栏 · 第二道,**这才是「提前结算」的正解**):
// 醒来的第一件事是核对代际,而不是直接去结算。
// 铁证:上一局残留的 `Task.Delay(300)` 在 00:40:01 醒来时,被它结算掉的是
// **当时正在跑的第 2 局**(`lastCounter=1/26`,玩家只跑了 2 个点);
// 第 2 局的定时器又在 00:44:28 掐了第 3 局(`lastCounter=2/26`)。
// 不是最新的一代 ⇒ 直接走人,绝不动 GAMEOVER / 成绩存储 / 结果面板。
if (Volatile.Read(ref RacingRunGeneration) != gen)
{
ctx.Session.Log.Warn(
$"[RACE] settlement ABORTED: stale run gen={gen} "
+ $"(current={Volatile.Read(ref RacingRunGeneration)}) task={taskId} track={trackId} "
+ $"wokenBy={(firstToFinish == settleSignal.Task ? "client-report" : $"{raceSeconds}s-timeout")} "
+ "— 这是**上一局**残留的流水线,拒绝结算当前这一局");
return;
}
var byClient = firstToFinish == settleSignal.Task;
elapsedMs = (ulong)Math.Max(0L, (long)(DateTimeOffset.UtcNow - raceClockStart).TotalMilliseconds);
// ★ R294:把「最后跑到第几个检查点」一起打出来 —— 超时结算时这是唯一能区分
// 「终点判据坏了」和「300s 到点没跑完」的证据。
var lastCounter = TakeRacingLastCounter(taskId);
ctx.Session.Log.Info(byClient
? $"[RACE] settlement EARLY by client completion report task={taskId} track={trackId}"
+ $" (counter index={RacingClientCheckpointCount} == RacingClientCheckpointCount;"
+ $" {raceSeconds}s MaxGameTime backstop skipped)"
+ $" — ** real race time {elapsedMs}ms ({elapsedMs / 1000.0:0.000}s) **"
+ $" lastCounter={lastCounter}"
: $"[RACE] settlement by TIMEOUT after {raceSeconds}s task={taskId} track={trackId}"
+ $" (no client completion report arrived; lastCounter={lastCounter}"
+ (lastCounter >= 0
? $"/{RacingClientCheckpointCount} ⇒ 只跑到第 {(lastCounter < RacingClientCheckpointCount ? lastCounter + 1 : RacingClientCheckpointCount)} 个检查点,"
+ $"距终点还差 {System.Math.Max(0, RacingClientCheckpointCount - lastCounter)} 个"
+ " —— **这是超时兜底,不是终点判据坏了**)"
: " ⇒ 本局一条检查点上报都没有)"));
}
finally
{
// ★ R306:**值比较式移除** —— 只删自己登记的那个 TCS。
// 旧写法 `TryRemove(taskId, out _)` 会把**后一代**登记的信号一起删掉,
// 让真正在跑的那一局再也等不到客户端的完成上报(只能吃满 300s 超时)。
RacingSettleSignals.TryRemove(
new KeyValuePair<uint, TaskCompletionSource<bool>>(taskId, settleSignal));
}
await ctx.NotifyAsync(MethodId.SyncGameGroundZoneInfo, UxSerializer.Serialize(
BuildRacingZoneProbe(trackId, taskId, ZoneStateGameOver, SyncReasonEnter, null, 0,
goAt, goGameTimeEnd, 0, 0, gridCount, gridSendEmpty ? null : aiVehicles)));
ctx.Session.Log.Info($"[RACE] pushed RacingZoneInfo GAMEOVER track={trackId} task={taskId} ai={gridCount}");
// ★★★★★ R294(2026-10-02)—— 比赛结束 ⇒ 把这一局摆的赛车门**全清**。
// 用户原话:「而且比赛结束了所有检查点还在」。放在 GAMEOVER 之后、结算面板之前
// ⇒ 玩家看到的是「比赛一结束,赛道上的门一起消失」。
// 与「过点即撤」(`GameRouter.RacingVehicle` 的计数器 handler)共用同一个闸门
// `racingGates.removeOnPass`;撤门失败绝不能拖垮结算,所以整块包 try。
if (PrivateServerConfigStore.Current.World.Content.RacingGates.RemoveOnPass)
{
try
{
await RemoveAllRacingGatesAsync(ctx.Session, GetStateStatic(ctx.Session));
}
catch (Exception gex)
{
ctx.Session.Log.Info($"[RACEGATE] teardown clear skipped: {gex.Message}");
}
}
// 成绩存储 (§59.24): persist a completion record now that the race clock has stopped.
RecordRacingCompletion(trackId, taskId, maxGameTime, elapsedMs);
// 4) SyncRacingResultData(68006935) — offline settlement. In offline mode the client's
// OnSyncRacingResultData seals the challenge, pulls the result and shows the ending /
// ranking panel. Ranks are FABRICATED (offline has no client→server lap times). Guarded
// so a serialization hiccup can never block the teardown below.
if (RacingAutoStartPushSyncCarRaceGame)
{
try
{
var results = BuildRacingResultData(trackId, taskId, gridSendEmpty ? null : aiVehicles, elapsedMs);
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncRacingResultData, UxSerializer.Serialize(results));
ctx.Session.Log.Info(
$"[RACE] pushed SyncRacingResultData(68006935) track={trackId} task={taskId}"
+ $" entries={results.Count} playerTime={elapsedMs}ms ({elapsedMs / 1000.0:0.000}s)");
}
catch (Exception rex)
{
ctx.Session.Log.Info($"[RACE] ranking push skipped: {rex.Message}");
}
}
// Retire the opponent cars together with the race (§59.28). They exist only for this
// race; leaving them would litter the circuit and keep stale uids warm in the client's
// vehicle LOD cache.
await DestroyRacingAiVehiclesAsync(ctx.Session, aiVehicles, "racing-teardown");
await Task.Delay(RacingZoneTeardownGapMs);
await ctx.NotifyAsync(MethodId.SyncGameGroundZoneInfo, BuildRacingZoneClearBody());
ctx.Session.Log.Info($"[RACE] pushed RacingZoneInfo DISPOSE (null zone) track={trackId} task={taskId}");
}
catch (Exception ex)
{
ctx.Session.Log.Info($"[RACE] auto-start failed task={taskId} track={trackId}: {ex.Message}");
}
finally
{
// ★ R306①:arming 闸的**保底释放**。正常路径在 GAMESTART 推完时已经释放过
// (那时 TryRemove 返回 false ⇒ 这里不打印);只有异常/提前 return 的路径会走到
// 「真的还在这里」这一支 —— 也正因为要能区分,才把日志放在 TryRemove 返回值里。
if (RacingAutoStartArming.TryRemove(taskId, out _))
{
ctx.Session.Log.Info(
$"[RACE] auto-start arming released on abnormal exit task={taskId} (R306 finally)");
}
}
}
/// <summary>
/// Build a (FABRICATED, offline) ranking for <c>SyncRacingResultData</c>(68006935). Offline
/// single-player mode has no client→server lap times, so a real ranking is impossible — we just
/// put the player at rank 1 and the AI opponents at ranks 2..N with increasing (fake) total times
/// so the client's <c>_BuildRaceRankData</c> produces a sensible ordered leaderboard and triggers
/// the ending panel.
///
/// 🔴 AI ENTRY KEY (fixed 2026-09-23, user report "名称没看到"):
/// the client resolves each AI entry like this (ChallengeManager.lua:_BuildRaceRankData):
/// <code>
/// local isAIEntry = ulong.equals(data.Pid, 0)
/// local key = isAIEntry and data.AIVehicle or data.Pid
/// local info = self.racingParticipantInfo[key] -- nil ⇒ "[...] key not match, isAI=true"
/// </code>
/// This method used to write the SAME invented ids as the old ghost grid
/// (<c>0x6000_0000_0000_0000 + n</c>), while <c>racingParticipantInfo</c> is built by C# from the
/// participants whose ids are the REAL spawned entity ids — so every AI row missed and all
/// opponent names came out empty. It now takes the spawned vehicles and reuses
/// <see cref="RacingAiVehicle.EntityId"/>, i.e. exactly the ids that went into
/// <c>AIVehicleInfos</c>/<c>ParticipantInfos</c> (AgentInstanceId / VehicleInstanceId /
/// AIAgentInfo.Uid all carry that same value).
/// </summary>
private static List<GameMethods.RacingResultData4229938> BuildRacingResultData(
uint trackId, uint taskId, IReadOnlyList<RacingAiVehicle>? aiVehicles, ulong elapsedMs)
{
// ★★★ R241(2026-10-01):真实用时。此前三个时间字段**写死 1000**(占位符),
// 用户实测「到终点能结算了,但不管开多久结算页都显示 1 秒」
// (客户端 `_BuildRaceRankData` 是 `time = TotalTime / 1000` ⇒ 面板显示 0:00:01.000)。
// 单位是**毫秒**(`ulong`),`elapsedMs = 0` 只可能是异常路径 ⇒ 兜到 1000 保持旧行为。
// `track 3` 的 `RacingDriverTrackInformationConfig.NumberOfLaps = 1` ⇒ 单圈赛,
// 最快圈 == 总用时,所以 BestLapTime 与 TotalTime 同值(不是偷懒,是这张表就这么定义的)。
var playerTime = elapsedMs > 0 ? elapsedMs : 1000UL;
var results = new List<GameMethods.RacingResultData4229938>
{
// Player — real pid, AIVehicle = 0, rank 1 (winner).
new GameMethods.RacingResultData4229938
{
SeatIndex = 0,
Pid = Profile.PlayerPid,
AIVehicle = 0,
FinishTime = playerTime,
TotalTime = playerTime,
BestLapTime = playerTime,
IsBest = true,
Rank = 1,
},
};
for (var i = 0; aiVehicles is not null && i < aiVehicles.Count; i++)
{
var instanceId = aiVehicles[i].EntityId;
// ★ R241:AI 的假成绩必须与 Rank **单调一致**。旧实现是 `1000 * (i + 2)`(2s/3s…),
// 在玩家真实用时 ~90s 之后,会出现「AI 用时 2 秒却排在玩家后面」的自相矛盾榜。
// 现在 = 玩家用时 + 每名 1 秒 ⇒ 名次越大耗时越长。
var t = playerTime + 1000UL * (ulong)(i + 1);
results.Add(new GameMethods.RacingResultData4229938
{
SeatIndex = i + 1,
Pid = 0, // AI ⇒ the client keys on AIVehicle instead
AIVehicle = instanceId, // MUST equal the spawned entity id (see summary)
FinishTime = t,
TotalTime = t,
BestLapTime = t,
IsBest = false,
Rank = i + 2,
});
}
return results;
}
}