424 lines
20 KiB
C#
424 lines
20 KiB
C#
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;
|
||
}
|
||
}
|