1288 lines
81 KiB
C#
1288 lines
81 KiB
C#
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
|
|||
|
|
/// "<raidId>_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<ulong,uint></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 "<raidId>_RacingDriver" AND MaxGamePeople > 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 > 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>
|
|||
|
|
/// ⇒ 一致(差 <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>★★ 这个偏移量必须 > 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>(> 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;
|
|||
|
|
}
|
|||
|
|
}
|