Files

423 lines
20 KiB
C#
Raw Permalink Normal View History

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;
}
}