Files

424 lines
20 KiB
C#
Raw Permalink 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 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;
/// <summary>
/// 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 — <c>eventPanelInfo.EventsInfo</c>, 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 <c>gameplay.taskPanel</c>
/// 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.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>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.</summary>
const int TaskBatchSize = 800;
/// <summary>
/// 任务面板推送的启动延迟(毫秒)。进世界那一秒客户端还在加载场景/建 ECS(Player.log 实测
/// 「场景不支持ecs的group数量过多 count:1036」就在这时候),此时灌 264KB 的任务包只会把
/// 它的主线程顶死 —— 见 <see cref="PublishPlayerTasksAsync"/> 里的断线事故记录。
/// </summary>
const int TaskPublishStartupDelayMs = 6000;
/// <summary>等客户端退出 hangup 的最长时间(毫秒)。0 表示不等。</summary>
const int TaskPublishHangupWaitMs = 30000;
/// <summary>SyncPlayerAllTaskDebug 分批之间的间隔(毫秒)。</summary>
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}");
}
/// <summary>
/// R238(2026-10-03 随赛车移植)—— 进世界时**只**发布赛车挑战那几条任务行,
/// 让客户端的 task manager 在玩家点「开始挑战」之前就拥有 60000147 的运行时节点。
///
/// 刻意**不是** <see cref="PublishTaskListAsync"/>:
/// · 载荷只有赛车那几条(~23 行),不是 4342 行的全量表 —— 全量表会让客户端
/// 挑中一个无关挑战(60029952)并把手机换成「结算挑战」弹窗。
/// · 通道是 <c>SyncPlayerAllTaskDebug(64423039)</c>(CSharpOnly 孪生包)。64260853 有
/// 客户端 Lua 实现会吞掉 taskInfos,而且它正是 R236 的崩因嫌疑包。
///
/// 状态保持 <c>Accepted</c>:客户端把它读成「可以开始 / 正在追踪的挑战」。
/// 回滚 = <c>gameplay.taskPanel.primeRacingChallengeTasks = false</c>。
/// </summary>
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<GameMethods.PlayerTaskInfo4229938>(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 嫌疑包)");
}
/// <summary>
/// Unlock every TaskTitle (panel tab / chapter group). Ids come from TaskTitleCatalog.json,
/// a projection of TaskTitleConfig.json (21 rows: 1..25 and 99).
/// </summary>
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<GameMethods.PlayerTaskInfo4229938>(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}");
}
/// <summary>
/// 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.
/// </summary>
static IEnumerable<TaskCatalogEntry4229938> 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,
};
/// <summary>
/// <c>eventPanelInfo</c> — 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).
/// </summary>
static GameMethods.TaskEventPanelInfo4229938 BuildEventPanelInfo(
TaskPanelSettings panel, List<TaskCatalogEntry4229938> 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<uint>();
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<uint> 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;
}
/// <summary>
/// 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.
/// </summary>
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<int>(e.Counter.Count);
info.Counters = new List<GameMethods.TaskCounter4229938>(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;
}
}