323 lines
18 KiB
C#
323 lines
18 KiB
C#
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>"<raidId>_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}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|