using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; // PrivateServerConfigStore(消融旗标路径解析) using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// Racing-challenge TASK plumbing (R190, ported from the Lyco fork's GameRouter.Task.cs + /// TaskLifecycle.cs, adapted to v2.1's own task contracts in Methods.Game). /// /// WHY EVERY CHALLENGE TASK MUST BE PUBLISHED UP FRONT /// The 挑战面板 (S_ChallengeStatementPanel.OnStartChallengeButtonClick) starts a run with /// gTaskManager:SetCurrentTask(self.taskId, ...) — a PURE CLIENT-SIDE call. gTaskManager can /// only build the runtime node when the id is already present in the SyncPlayerAllTask list /// published at world entry. Otherwise it logs /// "#NoCreateIssue 当前任务找不到对应的数据" and the challenge silently does nothing — /// the exact "点开始挑战没后续" symptom. (Lyco measured 2026-09-21: 60000147/60000150 both hit it; /// challenge 1003 "王牌车神·城中之夜" → RelatedTask 60000147, raid 23300888.) /// /// The AskAcceptAndSetCurrentTask handler (63961538) is the other half: the client sends it when a /// task is accepted, and for a racing task we additionally fire the server-side auto-start /// (ScheduleRacingAutoStart → GameRouter.RacingAutoStart.cs). Reply-neutral: still returns empty OK. /// /// ⛑ 回滚:删掉 Enabled4229938MethodIds 里的 AskAcceptAndSetCurrentTask 一行即可 /// (handler 不注册 = 走 typed-default 兜底),本文件的其余代码保留无害。 /// internal sealed partial class GameRouter { /// ChallengeConfig.ChallengeType for a racing challenge (竞速挑战). private const uint RacingChallengeType = 2; // ── ⛑ 消融旗标(R191b,临时诊断,定位「开始挑战卡死闪退」用,定位后整块删除)── // 旗标文件:config\racing-ablation.txt,**一行一个词**(可多词共存): // all = 全链(默认;一个词都没有/文件不存在都按 all) // norace = 只推任务包(SyncCurrentTask + SyncPlayerAllTask),不点火 —— 崩 ⇒ 任务包有罪 // notask = 不推任务包,照常点火(AI 车 + zone + 倒计时)—— 不崩 ⇒ 任务包有罪 // noai = 任务包 + 点火,但 AI 车 0 辆 —— 不崩 ⇒ AI 车包群有罪 // noguard = **客户端**用(generate_client_config_patch.js 读同一文件,摘掉 racing_guard.lua // 整块);服务端只忽略它,但必须容忍它**和 noai 同行共存**。 // ⛔ R197 修正:原实现是 `v.Trim() is "all" or …`(整串比对)⇒ 一旦文件里多一行 // `noguard`,Trim 后 = "noai\nnoguard" ≠ "noai" ⇒ **静默退回 all**(AI 车又活过来)。 // 现改为**按行/按词扫描**,多旗标共存安全;客户端 JS 侧本来就用 /\bnoguard\b/ 按词匹配。 // ⛔⛔ R203c 追加铁律(新铁律 252):**旗标文件里绝不出现旗标词本体**(注释/备份名都不行)! // 本服务端是**整词 token** 匹配(`. + ) 。` 不是分隔符 ⇒ 注释里 `x+noguard)。` 恰好不命中), // 但**客户端 JS 是 `\b` 词边界正则** ⇒ `+`/`)` 是非词字符 ⇒ 注释里的 `noguard` **照样命中** // ⇒ guard 被**静默关掉**、与意图相反且无报错。需要说明就只写档名,别写旗标本身。 // ★ R295 追加:`noalltask`(R236)与 `notimer`(R295)走**独立旗标**(`RacingAblationWordsLazy`), // 不在本互斥链里 ⇒ 可与 noai 共存,一次只动一个变量。 // 绝对路径是有意的:服务端工作目录随启动方式漂移,这个旗标是本 fork 的临时诊断件。 private static readonly Lazy RacingAblationModeLazy = new(() => { try { var p = PrivateServerConfigStore.ResolveProjectPath(Path.Combine("config", "racing-ablation.txt")); if (!File.Exists(p)) return "all"; // ★ R203c(铁律 252):先剥掉**整行 # 注释**再分词,与客户端 JS 口径一致 // (防止「注释里写了旗标词」被误当成真旗标;两边容忍度必须一致)。 var effective = string.Join('\n', File.ReadAllText(p) .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) .Where(line => !line.TrimStart().StartsWith("#"))); var words = effective .Split(new[] { '\r', '\n', ' ', '\t', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) .Select(w => w.Trim().ToLowerInvariant()) .ToHashSet(); // 语义模式:后几者互斥,优先取更"缩"的那个(诊断时希望更小范围生效)。 if (words.Contains("norace")) return "norace"; if (words.Contains("notask")) return "notask"; // ★ R203c 新增:noaigrid = 照常 spawn AI 实体,但 zone 里推**空** participant 网格。 // 用来把「AI 车实体 spawn 的副作用」和「网格里 5 条 participant 数据」分开。 if (words.Contains("noaigrid")) return "noaigrid"; if (words.Contains("noai")) return "noai"; return "all"; } catch { return "all"; } }); internal static string RacingAblationMode => RacingAblationModeLazy.Value; // ── ★ R236 独立旗标 `noalltask`(**不是**互斥模式,可与 noai 共存)────────────────── // 崩因锁定:`SyncPlayerAllTask`(64260853, ~1148 B) 在 accept 后 ~16ms 送达客户端,客户端 // 要么 "deserialize failed parameter=taskInfos"(包被丢弃,安全),要么**硬崩**。 // 证据:R236 崩轮 19:38:24.247 推该包 ⇒ 心跳在 24.44 永久缺席;不崩轮(prev3 19:03/19:05) // 客户端打出 `deserialize failed … methodId=64260853 parameter=taskInfos`。服务端 `taskPanel.enabled=false` // 已让**通用**任务发布路径早退 ⇒ 该包只可能来自本文件(赛车挑战专用)。 // ⛔ 为什么不用现成的 `notask`:它走**互斥模式链**(norace/notask/noaigrid/noai 同级), // 启用后会把当前的 `noai` 顶掉 ⇒ AI 车重新 spawn = 一次动两个变量。本旗标独立求值,不动其它。 // 判读:加了它**不崩** ⇒ 就是该包;**仍崩** ⇒ 干净排除,回查 SyncCurrentTask / 点火链。 // 回滚:从 config\racing-ablation.txt 删掉 `noalltask` 即可(本文件不用改)。 private static readonly Lazy> RacingAblationWordsLazy = new(() => { try { var p = PrivateServerConfigStore.ResolveProjectPath(Path.Combine("config", "racing-ablation.txt")); if (!File.Exists(p)) return new HashSet(); var effective = string.Join('\n', File.ReadAllText(p) .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) .Where(line => !line.TrimStart().StartsWith("#"))); return effective .Split(new[] { '\r', '\n', ' ', '\t', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) .Select(w => w.Trim().ToLowerInvariant()) .ToHashSet(); } catch { return new HashSet(); } }); /// true ⇒ 跳过 `SyncPlayerAllTask`(保留 `SyncCurrentTask` 与点火链)。 // ── ★★★★★ R334 独立旗标 `trackbytaskoff`(**不是**互斥模式,可与 noai 等共存)────────── // 关掉「按 task/EventId 精确选轨」(GameRouter.RacingAutoStart.cs 的 TryPickRacingTrack), // 回到 R334 之前的「按 raid 猜一条」。存在理由:R334 会把「事件链已知、但没有成套 // 检查点表的赛道」的任务一律**不启动**(打一行写明原因的日志)。要单独回退这一条就用本旗标。 // 回滚:从 config\racing-ablation.txt 删掉 `trackbytaskoff` 即可(本文件不用改)。 internal static bool RacingTrackSelectStrict => !RacingAblationWordsLazy.Value.Contains("trackbytaskoff"); internal static bool RacingSkipPlayerAllTask => RacingAblationWordsLazy.Value.Contains("noalltask"); // ── ★ R295 独立旗标 `notimer`(**不是**互斥模式,可与 noai 共存)─────────────── // 关掉「中上比赛计时器」那一次 `SyncOpenPanelCountDown` 推送 // (见 GameRouter.RacingAutoStart.cs 的 `RacingTimerPushCountDown`)。 // // 为什么单独开一个旗标:R253/R254 一直把中上那块判成「native HUD,不需要 Lua 面板」, // 而 R295 的实机证据(Player.log 21:01 那局装载清单只有 `S_ChallengeCountDownPanel`、 // 从来没有 `S_ChallengeCountDownRacingPanel`,且它的存活时长恰好 = 我们喂的 countDown=3s) // 说明中上那块**确实是被 `SyncOpenPanelCountDown` 打开的**,只是我们只喂了 3 秒。 // 要判定「这一次推送到底有没有用」,必须能一键关掉它 ⇒ 这个旗标。 // // ⛔ 不用 `notask`:那条走**互斥模式链**(norace/notask/noaigrid/noai 同级), // 启用后会把当前的 `noai` 顶掉 = 一次动两个变量(同 R236 对 noalltask 的取舍)。 // 回滚:从 config\racing-ablation.txt 删掉 `notimer` 即可(本文件不用改)。 internal static bool RacingSkipRaceTimer => RacingAblationWordsLazy.Value.Contains("notimer"); // ── ★★★ R303 起:上车闸**默认关闭**(改回显式 `boarding` 才开)─────────────── // // 【为什么反过来了 —— 官方抓包 10/10 次实测,2026-09-23/24,见 `MEMORY.racing.md` R303】 // 官方**没有**「上车才倒计时」。官方的完整时序是: // 开车(在车里)→ 声明页 `AskNewChallengeRecord(ChallengeId:1003)` // → 点开始挑战 `AskAcceptAndSetCurrentTask` // → 服务器 0.05 s 内刷 5 台 AI 车(`SyncSpawnVehicle` ×5) // → **+1.2 s** 推 `SyncGameGroundZoneInfo`(DISPLAY) → **+12 ms** 推 `SyncOpenPanelCountDown` // → **+3.0 s** 推 `SyncGameGroundZoneInfo`(GAMESTART)。 // 那 10 次里「接受 → 倒计时」之间**没有任何 exit/enter**(`SyncPlayerExitVehicle` // 在 9/23–9/24 全部会话里 0 命中);其中 8 次接受那一刻玩家**正开着车**(氮气连喷)。 // 而且官方**要求**你在车里:赛前声明页对赛车挑战会先调 // `gRacerManager:TrySetCompetitionVehicle(trackId)`,它取 `CurrentPlayerBaseVehicle`, // 取不到就报「未找到玩家载具,无法设置赛事车辆!」。 // ⇒ 「下车再上车才倒计时」不是官方行为(视频里那个效果另有来源)。 // // 【现在的语义】默认 = 官方(不等,接受即走 1.2 s/3 s 时序)。想要「上车启动」的手感, // 往 config\racing-ablation.txt 加一行 `boarding` 即可(本文件不用改)。 // ⚠ 非官方:开着车点挑战时闸门会立刻放行(本来就在车里),只有「在车外点挑战」 // 才会真的等 —— 这正是 R301 实机看到的现象。 internal static bool RacingStartRequiresBoarding => RacingAblationWordsLazy.Value.Contains("boarding"); // ── ★★★★★ R309(2026-10-03):开赛「连人带车瞬移到起点」的开关 ────────────────── // // 【为什么是我们自己实现(用户授权)】官方抓包 `packets-*.log` 10/10 证明**官方不传送**: // 官方要求玩家**自己开车到起点**再点挑战 —— 赛前声明页对赛车挑战先调 // `gRacerManager:TrySetCompetitionVehicle(trackId)`,它取 // `gDriveVehiclesManager.cs_manager.CurrentPlayerBaseVehicle`,取不到就报 // 「未找到玩家载具,无法设置赛事车辆!」⇒ 官方前置条件 = **你已经在车里、且车在起点**。 // 私服里玩家常常就地点挑战 ⇒ 人和车留在原地(赛道起点 cp00 在 (2988, -0.1, 969), // 玩家出生点离它约 49 m 但那只是「出生点」;就地点挑战时可能差 2.9 km)⇒ 321 倒计时 // 照走,屏幕上却是别的街景、也没有起跑线。 // 用户原话:「…就直接最简单的,在面板点击开始挑战后把角色连人带车一起传送到比赛起点, // 然后正常倒计时321,开始比赛就行」+「如果官方没有,就我们自己实现一个,这个我允许自创」。 // // 【实现】见 `GameRouter.RacingAutoStart.cs` 的 `TeleportPlayerAndVehicleToRacingStartAsync`: // 人走 `SyncUnitPositionAndFacing`(68030312)(同 GM 传送,无 loading、不打断开赛时序), // 车走 `SyncTeleportVehicle`(68088911) `Reset=true`(同改装店钉车)。 // // 【回滚】往 `config/racing-ablation.txt` 加一行 `noteleport` 即可(本文件不用改)。 // ⚠ 与 `boarding`(上车闸)是两件事:那条 =「等玩家上车」,这条 =「把玩家挪到起点」。 internal static bool RacingSkipTeleportToStart => RacingAblationWordsLazy.Value.Contains("noteleport"); /// Every task id owned by a racing challenge (ChallengeConfig.ChallengeType == 2). private static readonly Lazy RacingChallengeTaskIds = new(() => ClientConfigRepository.Challenges() .Where(c => c.ChallengeType == RacingChallengeType) .SelectMany(c => c.RelatedTasks) .Where(id => id != 0) .Distinct() .OrderBy(id => id) .ToArray()); /// /// AskAcceptAndSetCurrentTask(63961538): (uint taskId, uint spirit, bool ignoreSwitchSpirit) /// -> empty return. Re-affirms "this task is the currently tracked one" (GPS arming) and, for /// racing tasks, schedules the zone auto-start. /// [Handler(MethodId.AskAcceptAndSetCurrentTask)] private async Task AskAcceptAndSetCurrentTaskAccept(Connection conn, UxRpcMessage msg) { var b = msg.Body; uint taskId = b.Length >= 4 ? ReadU32(b, 0) : 0u; var ablation = RacingAblationMode; conn.Log.Info($"[TASK][ABLATION] mode={ablation} taskId={taskId}"); // EventId is per-task; v2.1 has no demo-task event registry, so 0 for everything // (a wrong EventId would make the client re-render the wrong panel event). const uint eventId = 0; if (ablation != "notask") { // 1) Make this task the current tracked task (GPS arming). await conn.NotifyAsync(MethodId.SyncCurrentTask, UxSerializer.Serialize(new GameMethods.SyncCurrentTaskData4229938 { Type = 0, // CurrentTaskType.Primary TaskId = taskId, EventId = eventId, FirstTime = false, Reason = 2, // ChangeCurrentTaskReason.Client })); // 2) Reflect "accepted / underway" in the panel list. // ★ R236:`noalltask` ⇒ 单独跳过本包(崩因嫌疑包,~1148 B),SyncCurrentTask 保留。 if (!RacingSkipPlayerAllTask) { await conn.NotifyAsync(MethodId.SyncPlayerAllTask, UxSerializer.Serialize( BuildChallengeTaskPanelBody(taskId))); conn.Log.Info($"[TASK] AskAcceptAndSetCurrentTask taskId={taskId} -> current + panel updated " + $"(racingChallengeTasks={RacingChallengeTaskIds.Value.Length})"); } else { conn.Log.Info("[TASK][ABLATION] noalltask: SyncPlayerAllTask SKIPPED " + "(SyncCurrentTask kept; this is the R236 crash-suspect packet)"); } } else { conn.Log.Info("[TASK][ABLATION] notask: SyncCurrentTask + SyncPlayerAllTask SKIPPED (ignition still fires)"); } // 3) Racing challenges additionally need the server to stage the GameGround zone — the // client never sends AskStartCompetitionRacing in offline mode and 开始挑战 is a // local-only call. Reply-neutral fire-and-forget: see GameRouter.RacingAutoStart.cs. if (ablation == "norace") { conn.Log.Info("[TASK][ABLATION] norace: ignition chain SKIPPED (task packets alone were pushed; " + "client crashed here ⇒ task packets guilty)"); } else { ScheduleRacingAutoStart(msg.Context, taskId); } await conn.ReturnEmptyOkAsync(msg); } /// /// SyncPlayerAllTask body carrying the racing-challenge task nodes. The accepted task /// (, 0 = none) becomes CurrentTask and is marked /// Accepted+Underway; every other racing task is registered as an Accepted node only — /// which one becomes CURRENT is decided by the client when the player hits 开始挑战. /// private static GameMethods.SyncPlayerAllTaskData4229938 BuildChallengeTaskPanelBody(uint currentTaskId) { var body = new GameMethods.SyncPlayerAllTaskData4229938 { CurrentTask = currentTaskId, LoginGameServer = true, }; foreach (var taskId in RacingChallengeTaskIds.Value) { var node = BuildChallengeTaskViewData(taskId); if (taskId == currentTaskId) node.State = GameMethods.TaskState4229938.Accepted; body.TaskInfos.Add(node); } return body; } /// /// Minimal runtime node for a racing-challenge task. /// /// TaskConfig rows for these tasks carry NO Counter and NO EventObjective — the whole race is /// authored in the raid's Spoon graph (60000147 → RelatedRaid 23300888). The client refuses a /// task whose Counters list is empty ("一个计数器都没有"), so one placeholder counter is /// supplied; the goals the player actually sees (第1名 / 前3名 / 180秒内) come from /// ChallengeConfig.CountersDescription and are tracked by the challenge system. /// private static GameMethods.PlayerTaskInfo4229938 BuildChallengeTaskViewData(uint taskId) { return new GameMethods.PlayerTaskInfo4229938 { TaskId = taskId, CounterValues = { 0 }, Counters = { new GameMethods.TaskCounter4229938 { Index = 1, Value = 0, ConfigValue = 1, Parent = -1, // root counter, no parent }, }, State = GameMethods.TaskState4229938.Accepted, ActiveFunctionValue = { [0] = 1, // CounterDes [2] = 1, // GPS }, }; } private static uint ReadU32(ReadOnlySpan b, int off) => (uint)(b[off] | (b[off + 1] << 8) | (b[off + 2] << 16) | (b[off + 3] << 24)); }