Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.RacingTaskGraph.cs
T

324 lines
18 KiB
C#
Raw 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.Linq;
using System.Threading.Tasks;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Protocol.Client4229938;
// v2.1: the task contracts (SyncPlayerSingleTaskData4229938 / TaskSpoonViewInfo4229938 /
// TaskStateData4229938) live in Methods.Game, not Methods.GameScene like the Lyco fork.
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 原版赛车图加载通道 — <c>SyncPlayerSingleTask</c>(64917604) + <c>TaskSpoonViewInfo</c>.
///
/// WHY THIS FILE EXISTS (2026-09-23 evening, "还原大于一切")
/// Until now the racing Spoon graph was force-merged by a CLIENT fastpatch that injected
/// <c>"&lt;raidId&gt;_RacingDriver"</c> into <c>EnterSceneInfo.SceneSpoonNames</c>. That is NOT the original
/// behaviour and we can now prove it: in the only 4 official-server captures we own
/// (<c>logs\packets-20260913-*.log</c>; 09-14 onward is our own private server — the discriminator is
/// that <c>SyncAetherAIVehicleAddData</c> first appears 09-14 15:41), the official server sends
/// <c>SceneSpoonNames: []</c> AND <c>SceneSpoonMd5s: []</c> while the player stands inside the racing
/// raid 23300888. <c>RaidConfig</c> carries no spoon field either. So the racing graph is NOT delivered
/// as a scene spoon.
///
/// The remaining native channel is the TASK graph:
/// <code>
/// SpoonTaskMgr (client, [ExportToLua])
/// TASK_SPOON_FILE_PATH / TASK_RAID_FILE_PATH ← graphs are local files
/// GetSpoonTaskFilePath(spRaidId, alias, out fileName) ← keyed by (spRaidId, alias)
/// SyncPlayerSingleTask(taskId, TaskViewData, TaskSpoonViewInfo, TaskStateData) ← RPC entry
/// LoadTaskWorkActionData(taskId, taskSpoon) → GetTaskWorkActionByTask
/// </code>
/// <c>TaskSpoonViewInfo</c> = { SpoonMd5, SpRaidId, StartTaskId, EndTaskId, Alias, EventId,
/// EventStartTaskId } — and the racing track table authors exactly those two keys inside
/// <c>RacingDriverTrackInformationConfig.RaidSpoonName</c>:
/// <code>
/// "23300888_RacingDriver" → SpRaidId 23300888, Alias "RacingDriver"
/// "spoon23301289_RacingDriver" → SpRaidId 23301289, Alias "RacingDriver"
/// </code>
/// <c>GameRouter.Task.cs</c> already speaks this RPC, but ONLY for the demo story task and with a
/// PLACEHOLDER <c>SpoonMd5</c>; the racing task 60000147 was never sent through it. That is the
/// documented gap this file closes.
///
/// WHAT IT SHOULD FIX
/// The 2026-09-23 probes read <c>task.workActions=0</c> and, in the client log,
/// <c>CarRaceManager.lua:291/417 nextRankPoint is nil</c> + <c>counter is nil</c> spam at ~60/s — i.e.
/// the racing task has NO work actions, which is exactly what <c>LoadTaskWorkActionData</c> fills in.
/// Without them the client cannot build the checkpoint / rank-point list, so the arrow, the GPS track
/// and the rank logic are all dead. Those checkpoints are also the only thing that could give the
/// racing AI its route, which is why the AI opponents currently spawn but never drive.
///
/// IS IT ORIGINAL?
/// The RPC, the payload types and the key derivation are all native (client dump + client-generated
/// readers). What is NOT original is the CONTENT: we synthesise the task-graph reference from
/// <c>RacingDriverTrackInformationConfig</c> because the official server's task-Spoon config
/// (<c>TASK_SPOON_FILE_PATH</c> / <c>TASK_RAID_FILE_PATH</c> source) is server-side and we do not have
/// it. <c>SpoonMd5</c> is therefore left <b>empty</b>, and the client then <b>silently ignores the whole
/// push</b> (no error, no graph, <c>workActions = 0</c>). The real md5 is the one genuinely missing piece.
///
/// ⛔ 已证伪的两个旧说法(别再用):
/// 1. 「空 md5 客户端会容忍,只警告版本不一致、照旧跑图」—— 那是**场景**通道的行为
/// (<c>SpoonRaidMgr.cs:87</c>),**任务图**通道不是。任务图这边是静默忽略。
/// 2. 「填个非空假 md5 就能逼客户端走加载分支」—— 实测**硬崩**(R211b,2026-09-30 23:05)。
/// 见 <see cref="RacingTaskSpoonMd5"/>。
///
/// SAFETY
/// Additive only: nothing already being sent changes, and a failure degrades to today's behaviour.
/// Kill switch: <see cref="RacingTaskSpoonLoadEnabled"/>.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// Master switch for the original task-graph load path (see the class doc). Set to false to fall
/// back to exactly the pre-2026-09-23-evening behaviour (scene-spoon injection only).
///
/// ★ R233(2026-10-01 19:0x)**实验性置 false**:客户端在 accept 之后 0.2 秒静默终止
/// (`AC wrapper done` → 心跳永久缺席;`R229 tasksync: ENTER` 从未出现 = 崩在 C# 层)。
/// 本包(`SyncPlayerSingleTask` 64917604)是 accept 之后**服务端推的第一个 ntf**,
/// 其注释自述「非空假 md5 会让客户端**在收到本包那一瞬间硬崩**」。
/// 而「空 md5 = 客户端静默忽略」⇒ **推空值 ≙ 不推**(行为等价)。
/// ⇒ 关掉它 = **零行为差异的单变量实验**:
/// · 关掉后**不崩** ⇒ 「客户端其实收到就崩」,本包即崩因;
/// · 关掉后**仍崩** ⇒ 干净排除本包,去别的通道找。
/// 检查点不依赖本包(R225 用本地赛道 `RT_888_001` 弧长采样自建 16 点)⇒ 可玩性不受影响。
/// **回滚:改回 true 即可。**
/// 注:用 <c>static readonly</c> 而非 <c>const</c>,避免 `if (!X) return;` 之后的
/// 代码被编译器判为不可达(CS0162);单使用点,二者行为等价。
/// </summary>
internal static readonly bool RacingTaskSpoonLoadEnabled = false;
/// <summary>
/// Empty = auto-resolve. Set to a literal (e.g. <c>"XQ_RacingDriver01"</c>) to force one specific
/// alias without rebuilding anything else — the fastest way to A/B a candidate alias when the
/// auto-resolved value turns out not to exist client-side.
/// </summary>
internal static readonly string RacingTaskSpoonAliasOverride = "";
/// <summary>
/// ★★ R211b (2026-09-30 23:05) — <b>DO NOT put a fake value here.</b> It hard-crashes the client.
///
/// R211 briefly sent 32 hex zeroes to force the client past its "nothing to load" bail-out.
/// The client DID take the load path — and died instantly, at the exact instant the push landed:
/// <code>
/// server 23:05:53 -> rpc AskAcceptAndSetCurrentTask 9b
/// server 23:05:53 [RACE][TASKGRAPH] pushed SyncPlayerSingleTask … bytes=133 md5='000…0'
/// client 23:05:53.217 [DRMK-PROBE] ENTER racer.TrySetCompetitionVehicle ← last client log
/// client 23:05:53.219 [DRMK-PROBE] LEAVE racer.TrySetCompetitionVehicle
/// client 23:05:53.2+ process gone (probe heartbeat stops dead, no further log)
/// </code>
/// The 22:39 (R210) run sent the same push with <c>bytes=101</c> (empty md5, 32 bytes shorter)
/// at the same point in the same flow and the client sailed on for another 100 s.
/// ⇒ The md5 is <b>not a validity flag</b>, it is the <b>lookup key</b> for the graph. A
/// syntactically valid but nonexistent key does not produce a polite
/// <c>Not Have File '…'</c> — it goes off the end of the load path and kills the process.
///
/// So an empty md5 is the <b>safe</b> value: the client silently ignores the push
/// (<c>workActions = 0</c> ⇒ no checkpoints), which is exactly R210's behaviour — no crash.
/// The real md5 must be <b>recovered</b>, never guessed. See R204 (铁律 255) and R211b report.
/// </summary>
internal static readonly string RacingTaskSpoonMd5 = string.Empty;
/// <summary>
/// 背景(R211 观察,仍然成立):空 md5 时,服务端确实推了 <c>SyncPlayerSingleTask</c> for task 60000147
/// (<c>spRaid=23300888 alias=XQ_RacingDriver01 bytes=101</c>),客户端也没报错,但它产生 <b>零</b>
/// task-graph 日志 —— 没有 <c>LoadTaskResidentInfo error</c>、没有 <c>LoadGraphGroup Fail</c>、
/// 尤其<b>没有</b> <c>@liufuqiang01 Not Have File '…'</c>(该消息由 <c>ReadRawTaskFile</c> 发出)。
/// 探针读到 <c>workActions = 0</c> ⇒ 图根本没加载。整场唯一的 Spoon 日志来自**场景**通道
/// (<c>SpoonRaidMgr.cs:87 客户端与服务器版本不一致 23300888_RacingDriver</c>)。
/// ⇒ 空 md5 = 客户端提前「无图可加载」静默返回。
///
/// ★ R211b 更正:上文曾据此推论「那就发个非空的假 md5,看客户端走哪支」——**这个推论已被证伪**。
/// 假 md5 不是让客户端走加载分支,而是让它<b>硬崩</b>(见 <see cref="RacingTaskSpoonMd5"/> 的注释)。
/// 所以现在只能保持空值(安全),真 md5 必须**取到**、不能猜。
/// </summary>
/// <summary>
/// Task-graph aliases read straight out of the CLIENT's own graph registry, which is what
/// <c>SpoonTaskMgr.GetSpoonTaskFilePath(spRaidId, alias)</c> actually keys on.
///
/// Found by byte-scanning the client asset block
/// <c>E:\anatacbt\Ananta_Data\StreamingAssets\Blocks\vfc_1378</c> (67 MB): at offset
/// ~59 348 000 it serialises a flat list of (raidId, alias) pairs for the 新启 raid —
/// <c>(23300888, HotelBomb)</c>, <c>(23300888, Modify_HackerCamp_12/13)</c>,
/// <c>(23300888, TaffyFight9/10/11/13new)</c>, <c>(23300888, WeiRen2_part2)</c> and, the only
/// racing one, <c>(23300888, XQ_RacingDriver01)</c>; then <c>(23300999, FangYeFight1)</c>,
/// <c>(23300999, HongFight1)</c>, <c>(23300999, SideMissions_ShadowPuzzle02)</c>.
/// <c>"RacingDriver"</c> (the literal we get by parsing <c>RaidSpoonName</c>) appears NOWHERE in
/// that block — <c>XQ_RacingDriver01</c> is the only "Racing" string in the whole 67 MB.
///
/// This matters: <c>RacingDriverTrackInformationConfig.RaidSpoonName</c> =
/// <c>"23300888_RacingDriver"</c> is the SCENE-spoon name (and the official server sends
/// <c>SceneSpoonNames: []</c> anyway), while the TASK graph uses its own alias. They are not the
/// same string, so parsing <c>RaidSpoonName</c> produces a key the client has no graph for.
///
/// Keep this table tiny and evidence-backed: an entry is only added when the alias was literally
/// read out of a client asset. Anything else falls back to <see cref="TryParseRaidSpoonName"/>.
/// </summary>
private static readonly System.Collections.Generic.Dictionary<uint, string> RacingTaskSpoonVerifiedAliases =
new()
{
// 新启 racing circuit — client asset vfc_1378 @59348589.
{ 23300888u, "XQ_RacingDriver01" },
};
/// <summary>
/// Splits a <c>RacingDriverTrackInformationConfig.RaidSpoonName</c> into the two keys
/// <c>TaskSpoonViewInfo</c> actually wants: <c>SpRaidId</c> (the numeric prefix, with an optional
/// "spoon" word in front) and <c>Alias</c> (everything after the first underscore).
///
/// Real values observed in the client config:
/// <code>
/// 23300888_RacingDriver → (23300888, "RacingDriver")
/// 23300999_RacingDriver → (23300999, "RacingDriver")
/// spoon23301289_RacingDriver → (23301289, "RacingDriver")
/// spoon23301305_main → (23301305, "main")
/// spoon23301294_23301294_Race_Chaos_Gadgets → (23301294, "23301294_Race_Chaos_Gadgets")
/// </code>
/// Returns false (and zero/empty) when the string cannot be parsed — the caller then skips the
/// push rather than sending a nonsense graph key.
/// </summary>
internal static bool TryParseRaidSpoonName(string? raidSpoonName, out uint spRaidId, out string alias)
{
spRaidId = 0;
alias = string.Empty;
if (string.IsNullOrWhiteSpace(raidSpoonName)) return false;
var text = raidSpoonName.Trim();
var sep = text.IndexOf('_');
if (sep <= 0 || sep >= text.Length - 1) return false; // need "prefix_alias" with both parts
var prefix = text[..sep];
if (prefix.StartsWith("spoon", StringComparison.OrdinalIgnoreCase))
prefix = prefix[5..];
if (!uint.TryParse(prefix, out spRaidId) || spRaidId == 0) return false;
alias = text[(sep + 1)..];
return alias.Length > 0;
}
/// <summary>
/// Racing task-graph reference for a track: <c>SpRaidId</c> + <c>Alias</c> parsed from the track's
/// authored <c>RaidSpoonName</c>, plus the track's <c>EventIds[0]</c> for
/// <c>TaskSpoonViewInfo.EventId</c>.
///
/// The authored name is authoritative and self-contained (its numeric prefix IS the spoon raid),
/// so <paramref name="raidId"/> is NOT used as a filter — the caller logs it for inspection only,
/// because the client keys the graph on (spRaidId, alias) rather than on the player's raid.
///
/// Alias precedence (highest first):
/// 1. <see cref="RacingTaskSpoonAliasOverride"/> — manual A/B knob.
/// 2. <see cref="RacingTaskSpoonVerifiedAliases"/> — alias literally read from a client asset.
/// 3. parsed from <c>RaidSpoonName</c> (known to be the SCENE-spoon spelling, so it is only a
/// last resort and is logged as such).
/// </summary>
internal static bool TryResolveRacingTaskSpoon(
uint trackId, out uint spRaidId, out string alias, out uint eventId, out string aliasSource)
{
spRaidId = 0;
alias = string.Empty;
eventId = 0;
aliasSource = "none";
if (!ClientConfigRepository.RacingTracks().TryGetValue(trackId, out var track)) return false;
eventId = track.EventIds.Length > 0 ? track.EventIds[0] : 0u;
if (!TryParseRaidSpoonName(track.RaidSpoonName, out spRaidId, out var parsedAlias)) return false;
alias = parsedAlias;
aliasSource = "parsed";
if (!string.IsNullOrWhiteSpace(RacingTaskSpoonAliasOverride))
{
alias = RacingTaskSpoonAliasOverride.Trim();
aliasSource = "override";
}
else if (RacingTaskSpoonVerifiedAliases.TryGetValue(spRaidId, out var verified))
{
alias = verified;
aliasSource = "client-asset";
}
return alias.Length > 0;
}
/// <summary>Back-compat overload without the alias-provenance output.</summary>
internal static bool TryResolveRacingTaskSpoon(
uint trackId, out uint spRaidId, out string alias, out uint eventId)
=> TryResolveRacingTaskSpoon(trackId, out spRaidId, out alias, out eventId, out _);
/// <summary>
/// Pushes <c>SyncPlayerSingleTask</c>(64917604) for one racing task so the client loads the racing
/// TASK graph and populates its work actions (checkpoints / rank points).
///
/// Wire (method-frame body, Inline — no root 0xFF marker; nested entities are Complex):
/// TaskId = uint32
/// TaskInfo = TaskViewData (0xFF)
/// SpoonViewInfo = TaskSpoonViewInfo (0xFF)
/// StateData = TaskStateData (0xFF)
///
/// Best-effort: any failure is logged and swallowed so the race itself is unaffected.
/// </summary>
internal static async Task PublishRacingTaskSpoonAsync(RpcContext ctx, uint taskId, uint trackId, uint raidId)
{
if (!RacingTaskSpoonLoadEnabled) return;
if (!TryResolveRacingTaskSpoon(trackId, out var spRaidId, out var alias, out var eventId,
out var aliasSource))
{
ctx.Session.Log.Info(
$"[RACE][TASKGRAPH] skipped task={taskId} track={trackId}: RaidSpoonName not parseable " +
$"(row present={ClientConfigRepository.RacingTracks().ContainsKey(trackId)})");
return;
}
if (raidId != 0 && spRaidId != raidId)
ctx.Session.Log.Info(
$"[RACE][TASKGRAPH] note task={taskId}: authored SpRaidId={spRaidId} != player raid={raidId} " +
"(RacingTrackIdForRaid picked another raid's circuit; pushing the authored key anyway)");
try
{
var body = new GameMethods.SyncPlayerSingleTaskData4229938
{
TaskId = taskId,
TaskInfo = BuildChallengeTaskViewData(taskId),
SpoonViewInfo = new GameMethods.TaskSpoonViewInfo4229938
{
// ★★ R211b:**必须留空**。空值 = 客户端静默忽略(R210 行为:不崩、但 workActions=0
// ⇒ 无检查点)。填任何非空假值都会让客户端在收到本包的那一瞬间硬崩
// (23:05:53 实测)。真 md5 只能取到,不能猜。详见 RacingTaskSpoonMd5 的注释。
SpoonMd5 = RacingTaskSpoonMd5,
SpRaidId = spRaidId,
StartTaskId = taskId,
EndTaskId = taskId,
Alias = alias,
EventId = eventId,
EventStartTaskId = taskId,
},
StateData = new GameMethods.TaskStateData4229938
{
State = GameMethods.TaskState4229938.Accepted, // 1
Reason = GameMethods.ChangeSingleTaskReason4229938.Accept, // 0
FailTextId = 0,
CanSkip = false,
},
};
var bytes = UxSerializer.Serialize(body);
await ctx.NotifyAsync(MethodId.SyncPlayerSingleTask, bytes);
ctx.Session.Log.Info(
$"[RACE][TASKGRAPH] pushed SyncPlayerSingleTask(64917604) task={taskId} " +
$"spRaid={spRaidId} alias={alias} (src={aliasSource}) event={eventId} bytes={bytes.Length} " +
$"md5='' (R211b: intentionally EMPTY = safe value; the client ignores the push and " +
$"workActions stays 0. A non-empty fake md5 hard-crashes the client — do not fill it in.)");
}
catch (Exception ex)
{
ctx.Session.Log.Info($"[RACE][TASKGRAPH] push failed task={taskId}: {ex.Message}");
}
}
}