using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Handlers; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// Task (quest) publication. /// /// Two things have to arrive together for anything to appear in the Mission / task panel: /// /// 1. The rows. SyncTaskTitleGuideUnlock(64841491) reveals the panel tabs (its Lua impl calls /// gMapSystem.taskUtils:SetTaskTitleGuide), SyncPlayerAllTask(64260853) carries TaskConfig rows, /// and SyncPlayerAllTaskDebug(64423039) is the CSharpOnly twin that actually reaches the client's /// native task manager (64260853 has its own Lua impl which only reads `currentTask`). /// /// 2. The event list — eventPanelInfo.EventsInfo, and THIS is what the list renders. The /// panel rows are TaskEventConfig rows, not TaskConfig rows: the evidence branch's own /// investigation pinned it down by matching every visible row against both tables (7/7 hits in /// TaskEventConfig, 0/7 in TaskConfig), and traced it to /// L18.Spoon.Task.TaskManager.SyncPlayerAllEventPanel filling TaskManager.m_TaskEvents. Map /// markers are unrelated — those come from the client's own local TaskEventConfig, which is why /// the map always worked while the panel stayed empty. /// /// This is why publishing all 4342 TaskConfig rows changed nothing on its own: without EventsInfo /// the list has no data source at all. Everything is config-gated under gameplay.taskPanel /// so any half of it can be switched off without a rebuild. /// /// Known limit, not fixable here: accepting a task does not start any in-game flow, because the /// task graph is a Spoon resource (GameRes/Spoon/HotRaid/*.rcp, GameRes/Spoon/Task/*.tcp) and the /// local client has no hot-update layer for it. The list can show; the objectives cannot run. /// internal sealed partial class GameRouter { /// Rows per SyncPlayerAllTaskDebug packet. The full catalog is ~4342 rows with an /// average of 1.5 counters each, roughly 200 KB of payload — far too much for one frame. const int TaskBatchSize = 800; /// /// 任务面板推送的启动延迟(毫秒)。进世界那一秒客户端还在加载场景/建 ECS(Player.log 实测 /// 「场景不支持ecs的group数量过多 count:1036」就在这时候),此时灌 264KB 的任务包只会把 /// 它的主线程顶死 —— 见 里的断线事故记录。 /// const int TaskPublishStartupDelayMs = 6000; /// 等客户端退出 hangup 的最长时间(毫秒)。0 表示不等。 const int TaskPublishHangupWaitMs = 30000; /// SyncPlayerAllTaskDebug 分批之间的间隔(毫秒)。 const int TaskDebugBatchDelayMs = 80; async Task PublishPlayerTasksAsync(RpcContext ctx) { var state = GetWorldState(ctx); lock (state.SyncRoot) { if (state.PlayerTasksPublished) return; state.PlayerTasksPublished = true; } var panel = PrivateServerConfigStore.Current.Gameplay.TaskPanel; // ★★ 赛车(2026-10-03 移植自捐赠分支):赛车任务「预热」与 taskPanel 开关**无关**, // 必须无条件先跑。没有 60000147 的运行时节点 ⇒ 客户端 gTaskManager 建不出节点 // ⇒ TASK_STATE_CHANGED 不产出 ⇒ StartChallenge 不被调 ⇒ currentChallengeId 恒 -1 // ⇒ OnSyncRacingResultData 取不到 cfg ⇒ **不弹结算面板**(=“点了没反应”)。 if (panel.PrimeRacingChallengeTasks) await PublishRacingChallengeTasksAsync(ctx); if (!panel.Enabled) { ctx.Session.Log.Info("[TASK] publish disabled (gameplay.taskPanel.enabled=false) — task panel left to the client"); return; } // ⛑️⛑️ 2026-09-30 断线重连风暴的元凶(config 17:26 把 taskPanel.enabled 打开后立刻发作): // 这一段原本是 **同步** 跑在 AskLoadingFinished handler 里的,一次进世界要发 // 21 帧 SyncTaskTitleGuideUnlock + 264 201 B 的 SyncPlayerAllTask // + 3×~51 KB 的 SyncPlayerAllTaskDebug + **1688 行 Log.Info** // 而收包循环是串行的(TcpServer.HandleClientAsync:读完一帧 → await DispatchAsync // → 才读下一帧),handler 不返回就一帧都不读。实测 handler 从 18:36:29 跑到 18:36:45 // 共 15 秒(其中 1688 行日志占 ~8 秒),客户端 8 秒后 TimeScaleAnomaly 判定掉线 // (Player.log 18:36:37.136),之后每 ~18 秒重连一次,永不收敛。 // // 任务面板不是「能进世界」的必要条件,所以整段挪到后台:先让客户端把场景加载完, // 再等它退出 hangup,然后摊平发。handler 立刻返回,收包循环不再被堵。 var session = ctx.Session; _ = Task.Run(async () => { try { if (TaskPublishStartupDelayMs > 0) await Task.Delay(TaskPublishStartupDelayMs, CancellationToken.None); await WaitForClientInteractiveAsync(session, TaskPublishHangupWaitMs); if (panel.PublishTitleUnlocks) await PublishTaskTitleUnlocksAsync(session); await PublishTaskListAsync(session); } catch (Exception ex) { session.Log.Warn($"[TASK] background publish failed: {ex.Message}"); } }); ctx.Session.Log.Info($"[TASK] publish scheduled in the background delayMs={TaskPublishStartupDelayMs}"); } /// /// R238(2026-10-03 随赛车移植)—— 进世界时**只**发布赛车挑战那几条任务行, /// 让客户端的 task manager 在玩家点「开始挑战」之前就拥有 60000147 的运行时节点。 /// /// 刻意**不是** : /// · 载荷只有赛车那几条(~23 行),不是 4342 行的全量表 —— 全量表会让客户端 /// 挑中一个无关挑战(60029952)并把手机换成「结算挑战」弹窗。 /// · 通道是 SyncPlayerAllTaskDebug(64423039)(CSharpOnly 孪生包)。64260853 有 /// 客户端 Lua 实现会吞掉 taskInfos,而且它正是 R236 的崩因嫌疑包。 /// /// 状态保持 Accepted:客户端把它读成「可以开始 / 正在追踪的挑战」。 /// 回滚 = gameplay.taskPanel.primeRacingChallengeTasks = false。 /// async Task PublishRacingChallengeTasksAsync(RpcContext ctx) { var ids = RacingChallengeTaskIds.Value; if (ids.Length == 0) { ctx.Session.Log.Info("[TASK][RACING] no racing-challenge task ids in ChallengeConfig; prime skipped"); return; } var infos = new List(ids.Length); foreach (var id in ids) infos.Add(BuildChallengeTaskViewData(id)); await ctx.NotifyAsync(MethodId.SyncPlayerAllTaskDebug, new GameMethods.SyncPlayerAllTaskDebugData4229938 { TaskInfos = infos, SubmitTaskList = [], SubmitEventList = [], }); ctx.Session.Log.Info("[TASK][RACING] primed task manager: ids=[" + string.Join(",", ids) + "] rows=" + infos.Count + " state=Accepted via SyncPlayerAllTaskDebug(64423039)" + " (CSharpOnly; SyncPlayerAllTask(64260853) 不走 —— 避免 R236 嫌疑包)"); } /// /// Unlock every TaskTitle (panel tab / chapter group). Ids come from TaskTitleCatalog.json, /// a projection of TaskTitleConfig.json (21 rows: 1..25 and 99). /// async Task PublishTaskTitleUnlocksAsync(TcpSession session) { var titles = TaskCatalog4229938.Titles; if (titles.Count == 0) { session.Log.Info("[TASK] no TaskTitleCatalog rows; chapter tabs left locked"); return; } foreach (var t in titles) { await session.NotifyAsync(MethodId.SyncTaskTitleGuideUnlock, UxSerializer.Serialize(new GameMethods.SyncTaskTitleGuideUnlockData4229938 { TaskTitleId = (ushort)t.Id, Unlock = true, }), CancellationToken.None); } session.Log.Info($"[TASK] SyncTaskTitleGuideUnlock x{titles.Count} ids=[{string.Join(",", titles.Select(t => t.Id))}] chapters={TaskCatalog4229938.Chapters.Count}"); } async Task PublishTaskListAsync(TcpSession session) { var panel = PrivateServerConfigStore.Current.Gameplay.TaskPanel; // RelatedRaid != 0 marks a row authored inside a raid/spoon. Those were the rows that // broke ESC: published as Accepted, the client resolves TaskId against its own TaskConfig, // decides a challenge is running, and replaces the phone with the "settle the challenge" // dialog (confirming it fires AskDeleteTask(63618078) with an id the client picked itself // — measured: 60029952 "志强白靖", raid 23300999). As Submited they are completed history, // not an active challenge, so they are safe. var query = ResolvePublishedEntries(panel); var entries = query .OrderBy(e => e.Title == 99 ? int.MaxValue : (int)e.Title) .ThenBy(e => e.Id) .ToList(); if (entries.Count == 0) { session.Log.Info("[TASK] no TaskCatalog rows; task panel left empty"); return; } var infos = new List(entries.Count); foreach (var e in entries) infos.Add(BuildTaskInfo(e)); // Every id goes in SubmitTaskList: the client treats it as the set of already-turned-in // quests, which is what makes the "completed" view render instead of an empty list. var submitTaskList = entries.Select(e => e.Id).ToList(); var eventPanel = panel.PublishEvents ? BuildEventPanelInfo(panel, entries) : new GameMethods.TaskEventPanelInfo4229938(); // CurrentTask stays 0 on purpose: it names the task the client should auto-track, and a // non-zero value is what first made ESC offer "settle the current challenge". The list is // driven by EventsInfo, not by this field, so leaving it empty costs nothing. await session.NotifyAsync(MethodId.SyncPlayerAllTask, UxSerializer.Serialize(new GameMethods.SyncPlayerAllTaskData4229938 { TaskInfos = infos, SubmitTaskList = submitTaskList, CurrentTask = 0, EventPanelInfo = eventPanel, LoginGameServer = true, }), CancellationToken.None); // ⛑️ 这里原来是 **每个事件一行 Log.Info**(全目录 1688 行)。日志写盘是同步的, // 实测 ~4.7ms/行 ⇒ 8 秒纯日志,全部算在 AskLoadingFinished handler 头上 // (2026-09-30 断线风暴里它是第二大耗时项)。改成一条汇总 + 前若干个样例。 const int eventLogSample = 8; var preview = string.Join(" | ", eventPanel.EventsInfo .Take(eventLogSample) .Select(ev => { var name = TaskEventCatalog4229938.Find(ev.EventId) is { } row ? (row.NameCn.Length > 0 ? row.NameCn : row.Name) : "(不在 TaskEventCatalog 里)"; return $"event={ev.EventId} '{name}' task={ev.TaskId} status={ev.StatusData}"; })); var missing = eventPanel.EventsInfo .Count(ev => TaskEventCatalog4229938.Find(ev.EventId) is null); session.Log.Info($"[TASK][EVENT] events={eventPanel.EventsInfo.Count} missingInCatalog={missing}" + (preview.Length > 0 ? $" sample=[{preview}]" : "")); var batches = 0; if (panel.PublishDebugRows) { for (var off = 0; off < infos.Count; off += TaskBatchSize) { if (batches > 0 && TaskDebugBatchDelayMs > 0) await Task.Delay(TaskDebugBatchDelayMs, CancellationToken.None); var slice = infos.GetRange(off, Math.Min(TaskBatchSize, infos.Count - off)); var submitSlice = submitTaskList.GetRange(off, Math.Min(TaskBatchSize, submitTaskList.Count - off)); await session.NotifyAsync(MethodId.SyncPlayerAllTaskDebug, UxSerializer.Serialize(new GameMethods.SyncPlayerAllTaskDebugData4229938 { TaskInfos = slice, SubmitTaskList = submitSlice, SubmitEventList = [.. panel.SubmitEventIds], }), CancellationToken.None); batches++; } } var raidRows = entries.Count(e => e.RelatedRaid != 0); var withCounter = entries.Count(e => e.Counter.Count > 0); session.Log.Info($"[TASK] published tasks={infos.Count} state={ResolveTaskState(panel)} batches={batches} " + $"openWorld={infos.Count - raidRows} raid={raidRows} withCounter={withCounter} " + $"titles={TaskCatalog4229938.Titles.Count} current=0 (ESC-safe) " + $"events={eventPanel.EventsInfo.Count} submitEvents={panel.SubmitEventIds.Length}"); } /// /// Which TaskConfig rows to publish. "configured" publishes only the configured ids and is the /// small shape the reference branch uses (one authored task that maps to one authored event); /// "catalog" keeps the historical full dump. /// static IEnumerable ResolvePublishedEntries(TaskPanelSettings panel) { if (!string.Equals(panel.TaskIdsSource, "configured", StringComparison.OrdinalIgnoreCase)) { var all = TaskCatalog4229938.All.AsEnumerable(); if (!panel.PublishRaidRows) all = all.Where(e => e.RelatedRaid == 0); return all; } if (panel.TaskIds.Length == 0) return []; var wanted = panel.TaskIds.Distinct().ToHashSet(); var rows = TaskCatalog4229938.All.Where(e => wanted.Contains(e.Id)).ToList(); var missing = wanted.Where(id => rows.All(r => r.Id != id)).ToList(); if (missing.Count > 0) Console.WriteLine($"[TASK] taskPanel.taskIds contains {missing.Count} id(s) absent from TaskCatalog: {string.Join(",", missing)}"); return rows; } static GameMethods.TaskState4229938 ResolveTaskState(TaskPanelSettings panel) => panel.TaskState switch { nameof(GameMethods.TaskState4229938.NotAccept) => GameMethods.TaskState4229938.NotAccept, nameof(GameMethods.TaskState4229938.Accepted) => GameMethods.TaskState4229938.Accepted, nameof(GameMethods.TaskState4229938.Submited) => GameMethods.TaskState4229938.Submited, nameof(GameMethods.TaskState4229938.Aborted) => GameMethods.TaskState4229938.Aborted, _ => GameMethods.TaskState4229938.Submited, }; /// /// eventPanelInfo — the actual list data source. TaskEvents are TaskEventConfig rows; /// each row needs EventId, a TaskId pointing at a step the client recognises as started, /// UnlockTime, and the status flags (Visible(1) is mandatory or nothing renders). /// static GameMethods.TaskEventPanelInfo4229938 BuildEventPanelInfo( TaskPanelSettings panel, List publishedTasks) { var info = new GameMethods.TaskEventPanelInfo4229938(); foreach (var eventId in panel.SubmitEventIds) if (eventId != 0 && !info.SubmitEventList.Contains(eventId)) info.SubmitEventList.Add(eventId); var candidates = new HashSet(); if (panel.AutoDeriveEvents) { foreach (var task in publishedTasks) foreach (var eventId in TaskEventCatalog4229938.EventsForTask(task.Id)) candidates.Add(eventId); } foreach (var eventId in panel.EventIds) if (eventId != 0) candidates.Add(eventId); if (candidates.Count == 0) return info; var published = publishedTasks.Select(t => t.Id).ToHashSet(); var unlockTime = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // Newest-first chain order is what makes the list agree with the task details: the container // is cumulative (finished steps stay in it), so "the first chain step still in the container" // is always the OLDEST one — that mismatch was a real regression on the reference branch. var ordered = candidates .Select(id => TaskEventCatalog4229938.Find(id)) .Where(row => row is not null) .Select(row => row!) .OrderBy(row => row.SortOrder) .ThenBy(row => row.Id) .ToList(); // Configured ids we could not resolve still get a row (with StartTask unknown -> 0), so a // typo shows up as a nameless row instead of silently dropping the whole entry. foreach (var eventId in candidates.Except(ordered.Select(r => r.Id)).OrderBy(x => x)) ordered.Add(new TaskEventEntry4229938(eventId, string.Empty, string.Empty, 0, 0, 0, [], [], [])); if (panel.MaxEvents > 0 && ordered.Count > panel.MaxEvents) ordered = ordered.Take(panel.MaxEvents).ToList(); foreach (var row in ordered) { info.EventsInfo.Add(new GameMethods.TaskEventInfo4229938 { EventId = row.Id, TaskId = ResolveEventTaskId(panel, row, published), UnlockTime = unlockTime, FinishedChoiceLs = [], StatusData = panel.EventStatusFlags, }); } return info; } static uint ResolveEventTaskId(TaskPanelSettings panel, TaskEventEntry4229938 row, HashSet publishedTasks) { if (panel.EventTaskIdFollowsContainer) { var chain = TaskEventCatalog4229938.TasksOf(row.Id); for (var i = chain.Count - 1; i >= 0; i--) if (publishedTasks.Contains(chain[i])) return chain[i]; } return row.StartTask; } /// /// One panel row. CounterValues/Counters carry the objective progress: CounterValues[i] is the /// current value and Counters[i] repeats it alongside the configured target, because the UI /// renders "value/target" and skips rows that have neither. /// static GameMethods.PlayerTaskInfo4229938 BuildTaskInfo(TaskCatalogEntry4229938 e) { var state = ResolveTaskState(PrivateServerConfigStore.Current.Gameplay.TaskPanel); var info = new GameMethods.PlayerTaskInfo4229938 { TaskId = e.Id, State = state, // Left null: a non-null SpoonViewInfo tells the client this task runs inside a raid, // which is one of the ways the session ends up in the in-progress-challenge state. SpoonViewInfo = null, }; if (e.Counter.Count == 0) return info; info.CounterValues = new List(e.Counter.Count); info.Counters = new List(e.Counter.Count); for (var i = 0; i < e.Counter.Count; i++) { var target = e.Counter[i]; // Submited rows are complete, so current == target. var value = state == GameMethods.TaskState4229938.Submited ? target : 0; info.CounterValues.Add(value); info.Counters.Add(new GameMethods.TaskCounter4229938 { Index = i, Value = value, ConfigValue = target, Parent = -1, }); } return info; } }