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;
///
/// 原版赛车图加载通道 — SyncPlayerSingleTask(64917604) + TaskSpoonViewInfo.
///
/// WHY THIS FILE EXISTS (2026-09-23 evening, "还原大于一切")
/// Until now the racing Spoon graph was force-merged by a CLIENT fastpatch that injected
/// "<raidId>_RacingDriver" into EnterSceneInfo.SceneSpoonNames. That is NOT the original
/// behaviour and we can now prove it: in the only 4 official-server captures we own
/// (logs\packets-20260913-*.log; 09-14 onward is our own private server — the discriminator is
/// that SyncAetherAIVehicleAddData first appears 09-14 15:41), the official server sends
/// SceneSpoonNames: [] AND SceneSpoonMd5s: [] while the player stands inside the racing
/// raid 23300888. RaidConfig carries no spoon field either. So the racing graph is NOT delivered
/// as a scene spoon.
///
/// The remaining native channel is the TASK graph:
///
/// 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
///
/// TaskSpoonViewInfo = { SpoonMd5, SpRaidId, StartTaskId, EndTaskId, Alias, EventId,
/// EventStartTaskId } — and the racing track table authors exactly those two keys inside
/// RacingDriverTrackInformationConfig.RaidSpoonName:
///
/// "23300888_RacingDriver" → SpRaidId 23300888, Alias "RacingDriver"
/// "spoon23301289_RacingDriver" → SpRaidId 23301289, Alias "RacingDriver"
///
/// GameRouter.Task.cs already speaks this RPC, but ONLY for the demo story task and with a
/// PLACEHOLDER SpoonMd5; 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 task.workActions=0 and, in the client log,
/// CarRaceManager.lua:291/417 nextRankPoint is nil + counter is nil spam at ~60/s — i.e.
/// the racing task has NO work actions, which is exactly what LoadTaskWorkActionData 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
/// RacingDriverTrackInformationConfig because the official server's task-Spoon config
/// (TASK_SPOON_FILE_PATH / TASK_RAID_FILE_PATH source) is server-side and we do not have
/// it. SpoonMd5 is therefore left empty, and the client then silently ignores the whole
/// push (no error, no graph, workActions = 0). The real md5 is the one genuinely missing piece.
///
/// ⛔ 已证伪的两个旧说法(别再用):
/// 1. 「空 md5 客户端会容忍,只警告版本不一致、照旧跑图」—— 那是**场景**通道的行为
/// (SpoonRaidMgr.cs:87),**任务图**通道不是。任务图这边是静默忽略。
/// 2. 「填个非空假 md5 就能逼客户端走加载分支」—— 实测**硬崩**(R211b,2026-09-30 23:05)。
/// 见 。
///
/// SAFETY
/// Additive only: nothing already being sent changes, and a failure degrades to today's behaviour.
/// Kill switch: .
///
internal sealed partial class GameRouter
{
///
/// 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 即可。**
/// 注:用 static readonly 而非 const,避免 `if (!X) return;` 之后的
/// 代码被编译器判为不可达(CS0162);单使用点,二者行为等价。
///
internal static readonly bool RacingTaskSpoonLoadEnabled = false;
///
/// Empty = auto-resolve. Set to a literal (e.g. "XQ_RacingDriver01") 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.
///
internal static readonly string RacingTaskSpoonAliasOverride = "";
///
/// ★★ R211b (2026-09-30 23:05) — DO NOT put a fake value here. 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:
///
/// 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)
///
/// The 22:39 (R210) run sent the same push with bytes=101 (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 not a validity flag, it is the lookup key for the graph. A
/// syntactically valid but nonexistent key does not produce a polite
/// Not Have File '…' — it goes off the end of the load path and kills the process.
///
/// So an empty md5 is the safe value: the client silently ignores the push
/// (workActions = 0 ⇒ no checkpoints), which is exactly R210's behaviour — no crash.
/// The real md5 must be recovered, never guessed. See R204 (铁律 255) and R211b report.
///
internal static readonly string RacingTaskSpoonMd5 = string.Empty;
///
/// 背景(R211 观察,仍然成立):空 md5 时,服务端确实推了 SyncPlayerSingleTask for task 60000147
/// (spRaid=23300888 alias=XQ_RacingDriver01 bytes=101),客户端也没报错,但它产生 零
/// task-graph 日志 —— 没有 LoadTaskResidentInfo error、没有 LoadGraphGroup Fail、
/// 尤其没有 @liufuqiang01 Not Have File '…'(该消息由 ReadRawTaskFile 发出)。
/// 探针读到 workActions = 0 ⇒ 图根本没加载。整场唯一的 Spoon 日志来自**场景**通道
/// (SpoonRaidMgr.cs:87 客户端与服务器版本不一致 23300888_RacingDriver)。
/// ⇒ 空 md5 = 客户端提前「无图可加载」静默返回。
///
/// ★ R211b 更正:上文曾据此推论「那就发个非空的假 md5,看客户端走哪支」——**这个推论已被证伪**。
/// 假 md5 不是让客户端走加载分支,而是让它硬崩(见 的注释)。
/// 所以现在只能保持空值(安全),真 md5 必须**取到**、不能猜。
///
///
/// Task-graph aliases read straight out of the CLIENT's own graph registry, which is what
/// SpoonTaskMgr.GetSpoonTaskFilePath(spRaidId, alias) actually keys on.
///
/// Found by byte-scanning the client asset block
/// E:\anatacbt\Ananta_Data\StreamingAssets\Blocks\vfc_1378 (67 MB): at offset
/// ~59 348 000 it serialises a flat list of (raidId, alias) pairs for the 新启 raid —
/// (23300888, HotelBomb), (23300888, Modify_HackerCamp_12/13),
/// (23300888, TaffyFight9/10/11/13new), (23300888, WeiRen2_part2) and, the only
/// racing one, (23300888, XQ_RacingDriver01); then (23300999, FangYeFight1),
/// (23300999, HongFight1), (23300999, SideMissions_ShadowPuzzle02).
/// "RacingDriver" (the literal we get by parsing RaidSpoonName) appears NOWHERE in
/// that block — XQ_RacingDriver01 is the only "Racing" string in the whole 67 MB.
///
/// This matters: RacingDriverTrackInformationConfig.RaidSpoonName =
/// "23300888_RacingDriver" is the SCENE-spoon name (and the official server sends
/// SceneSpoonNames: [] anyway), while the TASK graph uses its own alias. They are not the
/// same string, so parsing RaidSpoonName 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 .
///
private static readonly System.Collections.Generic.Dictionary RacingTaskSpoonVerifiedAliases =
new()
{
// 新启 racing circuit — client asset vfc_1378 @59348589.
{ 23300888u, "XQ_RacingDriver01" },
};
///
/// Splits a RacingDriverTrackInformationConfig.RaidSpoonName into the two keys
/// TaskSpoonViewInfo actually wants: SpRaidId (the numeric prefix, with an optional
/// "spoon" word in front) and Alias (everything after the first underscore).
///
/// Real values observed in the client config:
///
/// 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")
///
/// Returns false (and zero/empty) when the string cannot be parsed — the caller then skips the
/// push rather than sending a nonsense graph key.
///
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;
}
///
/// Racing task-graph reference for a track: SpRaidId + Alias parsed from the track's
/// authored RaidSpoonName, plus the track's EventIds[0] for
/// TaskSpoonViewInfo.EventId.
///
/// The authored name is authoritative and self-contained (its numeric prefix IS the spoon raid),
/// so 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. — manual A/B knob.
/// 2. — alias literally read from a client asset.
/// 3. parsed from RaidSpoonName (known to be the SCENE-spoon spelling, so it is only a
/// last resort and is logged as such).
///
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;
}
/// Back-compat overload without the alias-provenance output.
internal static bool TryResolveRacingTaskSpoon(
uint trackId, out uint spRaidId, out string alias, out uint eventId)
=> TryResolveRacingTaskSpoon(trackId, out spRaidId, out alias, out eventId, out _);
///
/// Pushes SyncPlayerSingleTask(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.
///
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}");
}
}
}