238 lines
12 KiB
C#
238 lines
12 KiB
C#
using System.Buffers.Binary;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// BBChat — AppId 27 "Message" (SystemId 146), the phone's NPC chat app. It is NOT the player IM:
|
||
/// its red dot comes from <c>gRedDotUtils.CheckNpcChatHasRedDot()</c> (MainPhoneUtils.lua:688) and
|
||
/// its panels are the <c>NpcChat*</c> stores, i.e. everything here is <c>gNpcChatManager</c>.
|
||
///
|
||
/// Content half: the conversation list is published by the login frame
|
||
/// (<c>InfoNpcCultivation.NpcChats</c> / <c>NpcGroupChats</c>, see RuntimePayloadFactory.FillNpcChats
|
||
/// and NpcChatCatalog4229938). Nothing here refills it — there is no pull request for that list.
|
||
///
|
||
/// Interaction half: every request below carries exactly one u32 (<c>chatid</c>) and the client
|
||
/// declares no reply reader for any of them (no <c>midToReader</c> entry in RPCDeserializeAuto for
|
||
/// 63013781 / 63098840 / 63137850 / 63203620 / 63266674 / 63082065), so the answer must be the
|
||
/// empty OK — anything else desynchronises nothing the client reads but wastes bytes. What actually
|
||
/// moves a conversation is the <c>SyncNpcChat</c> push: the next row of NPCChatConfig.
|
||
///
|
||
/// ⛑ The server owns no chat text. A row is "which config id is unlocked next"; the client resolves
|
||
/// speaker, side, avatar and body itself via <c>LTConfig.NPCChatConfig.GetConfig(chatItem.ChatId)</c>
|
||
/// (<c>NpcChatUtils.NewFromNpcChatItem</c>), which drops any id it does not know with a
|
||
/// "NPCChat表没有查到" error — hence NextChatId always comes from the catalogue's own map.
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>InteractNpcChatToEnd bursts get this many rows per gesture — see InteractNpcChatToEnd.</summary>
|
||
private const int NpcChatSkipBatch = 24;
|
||
|
||
/// <summary>Leading u32 of every request body here.</summary>
|
||
private static uint NpcChatRequestId(UxRpcMessage msg)
|
||
=> msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
|
||
|
||
// =================================================================================
|
||
// Advancing a conversation
|
||
//
|
||
// Three different requests do the same thing from three different entry points, and all of them
|
||
// mean "take the given row's NextMessage[0] and hand it to the client":
|
||
// InteractNpcChat(63098840) NpcChatChattingPanelStore.lua:1035/1055 — tap to continue
|
||
// ChatToNpc(63013781) :1169 — the player picked an authored reply
|
||
// InviteNpcChat(63137850) :1142 — the player accepted an invite row
|
||
// The client arms a timeout before sending (StartChatTimeout) and clears it when the push lands,
|
||
// so pushing nothing would leave the panel spinning for its timeout window; pushing the same row
|
||
// back would be worse, hence the explicit unknown-row branch below.
|
||
// =================================================================================
|
||
|
||
[Handler(MethodId.InteractNpcChat, HandlerPacketKind.Invoke)]
|
||
private Task InteractNpcChat(Connection conn, UxRpcMessage msg)
|
||
=> AdvanceNpcChatAsync(conn, msg, "interact");
|
||
|
||
[Handler(MethodId.ChatToNpc, HandlerPacketKind.Invoke)]
|
||
private Task ChatToNpc(Connection conn, UxRpcMessage msg)
|
||
=> AdvanceNpcChatAsync(conn, msg, "chatToNpc");
|
||
|
||
[Handler(MethodId.InviteNpcChat, HandlerPacketKind.Invoke)]
|
||
private Task InviteNpcChat(Connection conn, UxRpcMessage msg)
|
||
=> AdvanceNpcChatAsync(conn, msg, "invite");
|
||
|
||
private async Task AdvanceNpcChatAsync(Connection conn, UxRpcMessage msg, string via)
|
||
{
|
||
var id = NpcChatRequestId(msg);
|
||
if (!NpcChatCatalog4229938.Knows(id))
|
||
{
|
||
conn.Log.Info($"[NPCCHAT] {via} id={id} -> unknown row, nothing to push");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var next = NpcChatCatalog4229938.NextOf(id);
|
||
if (next == 0)
|
||
{
|
||
// End of the conversation. The client's own contract for that is the NpcChatFinished
|
||
// error code; it is left as OK because returning an unexpected code path here closes the
|
||
// phone panel (NpcChatChattingPanelStore.lua:1064-1078) even mid-conversation.
|
||
conn.Log.Info($"[NPCCHAT] {via} id={id} -> end of thread");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
await conn.NotifyAsync(MethodId.SyncNpcChat, BuildNpcChatItem(next, id, read: false));
|
||
conn.Log.Info($"[NPCCHAT] {via} id={id} -> pushed {next}");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// InteractNpcChatToEnd(63203620) — "skip to the end" button. The whole remaining tail of the
|
||
/// thread is pushed as one burst so the panel can render it as history.
|
||
///
|
||
/// Capped at <see cref="NpcChatSkipBatch"/> rows: the rows are pushed one SyncNpcChat at a time
|
||
/// because that is the only framing the client reads for this, and a 60-message thread would
|
||
/// otherwise hold the gesture hostage for a full row-by-row animation queue.
|
||
/// </summary>
|
||
[Handler(MethodId.InteractNpcChatToEnd, HandlerPacketKind.Invoke)]
|
||
private async Task InteractNpcChatToEnd(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var id = NpcChatRequestId(msg);
|
||
if (!NpcChatCatalog4229938.Knows(id))
|
||
{
|
||
conn.Log.Info($"[NPCCHAT] skipToEnd id={id} -> unknown row");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var pushed = 0;
|
||
var current = id;
|
||
while (pushed < NpcChatSkipBatch)
|
||
{
|
||
var next = NpcChatCatalog4229938.NextOf(current);
|
||
if (next == 0)
|
||
break;
|
||
await conn.NotifyAsync(MethodId.SyncNpcChat, BuildNpcChatItem(next, current, read: true));
|
||
current = next;
|
||
pushed++;
|
||
}
|
||
|
||
conn.Log.Info($"[NPCCHAT] skipToEnd id={id} -> pushed {pushed} rows");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
// =================================================================================
|
||
// Bookkeeping requests — nothing persists, everything answers OK
|
||
// =================================================================================
|
||
|
||
/// <summary>AskMarkNpcChatRead(63266674) — NpcChatChattingPanelStore.lua:132/818, NpcChatN2NChattingPanel.lua:217.</summary>
|
||
[Handler(MethodId.AskMarkNpcChatRead, HandlerPacketKind.Invoke)]
|
||
private Task AskMarkNpcChatRead(Connection conn, UxRpcMessage msg)
|
||
{
|
||
conn.Log.Info($"[NPCCHAT] markRead id={NpcChatRequestId(msg)}");
|
||
return conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// RequestNpcChatList(63082065) — ChatNpcsPhoneManager.lua:229 / ChattingToNpcPanelStore.lua:375.
|
||
///
|
||
/// Used by the GM "pull this conversation" route; the player-facing list already arrived in the
|
||
/// login frame, so there is nothing to stream back — **but the reply still has a shape**:
|
||
/// ⛑⛑ 2026-09-29 修正:客户端 `midToReturnMessageReader[63082065]` 是
|
||
/// `Base.ReadList7Bit(reader, …Complex[372])`,即 RET 是 **List<T> 而不是 void**。
|
||
/// 原来 `ReturnEmptyOkAsync` 是 **0 字节载荷**(Connection.cs:32 `Array.Empty<byte>()`)
|
||
/// ⇒ 客户端 reader 读不到 presence 字节。实测 [NPCCHAT] 有触发(今日累计 2 次)。
|
||
/// ⇒ 回**空列表** `FF 01`(0xFF=presence,0x01=Int7(0) 的 +1 偏置,硬规则"空列表 2B")。
|
||
/// 数据本身仍为空(列表确实走登录包),但 wire 形状正确,客户端不会读空 buffer。
|
||
/// ⛑ 回滚:把 return 换回 `conn.ReturnEmptyOkAsync(msg)`。
|
||
/// </summary>
|
||
[Handler(MethodId.RequestNpcChatList, HandlerPacketKind.Invoke)]
|
||
private Task RequestNpcChatList(Connection conn, UxRpcMessage msg)
|
||
{
|
||
conn.Log.Info($"[NPCCHAT] requestList id={NpcChatRequestId(msg)} " +
|
||
$"(list rides the login frame) -> empty list(ff 01)");
|
||
return conn.ReturnRawAsync(msg, new byte[] { 0xFF, 0x01 });
|
||
}
|
||
|
||
/// <summary>AskCloseNpcChatWnd(67994672) — NpcChatChattingPanelStore.lua:635/1240, sent on the scene delegate.</summary>
|
||
[Handler(MethodId.AskCloseNpcChatWnd, HandlerPacketKind.Invoke)]
|
||
private Task AskCloseNpcChatWnd(Connection conn, UxRpcMessage msg)
|
||
{
|
||
conn.Log.Info($"[NPCCHAT] closeWnd id={NpcChatRequestId(msg)}");
|
||
return conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>AskClearSpiritGroupChat(63527240) / AskClearNpcUncompletedInviteChat(63639364) — one-shot cleanups.</summary>
|
||
[Handler(MethodId.AskClearSpiritGroupChat, HandlerPacketKind.Invoke)]
|
||
private Task AskClearSpiritGroupChat(Connection conn, UxRpcMessage msg)
|
||
{
|
||
conn.Log.Info($"[NPCCHAT] clearSpiritGroupChat id={NpcChatRequestId(msg)}");
|
||
return conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
[Handler(MethodId.AskClearNpcUncompletedInviteChat, HandlerPacketKind.Invoke)]
|
||
private Task AskClearNpcUncompletedInviteChat(Connection conn, UxRpcMessage msg)
|
||
{
|
||
conn.Log.Info($"[NPCCHAT] clearUncompletedInvite id={NpcChatRequestId(msg)}");
|
||
return conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
// =================================================================================
|
||
// Row construction
|
||
// =================================================================================
|
||
|
||
/// <summary>
|
||
/// One pushed/downloaded history row. Field order and the complex root come from the client's own
|
||
/// reader — <c>Auto.Reader[372]</c> read via <c>Base.ReadComplex</c>, so the push starts with a
|
||
/// 0xFF marker and no other framing is involved.
|
||
/// </summary>
|
||
private static Ananta.Server.RpcTypes.Client4229938.Auto.NpcChatItem BuildNpcChatItem(
|
||
uint chatId, uint fromChatId, bool read)
|
||
{
|
||
return new Ananta.Server.RpcTypes.Client4229938.Auto.NpcChatItem
|
||
{
|
||
Timestamp = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||
ChatId = chatId,
|
||
NextChatId = NpcChatCatalog4229938.NextOf(chatId),
|
||
// No emoji / hyperlink payload: bubbles come from NPCChatConfig, not from us.
|
||
ChatContext = null,
|
||
// A pushed row is new to the player, so it must stay unread for the notify path; the
|
||
// skip-to-end burst marks them read so opening a skipped thread does not leave 20 dots.
|
||
IsRead = read,
|
||
// AddNewNpcChatItem falls back to BelongNpc for the dictionary key when the row's
|
||
// AsNpcCultivation is 0, so it has to be the row's own owner rather than anything we invent.
|
||
BelongNpc = OwnerOf(chatId, fromChatId),
|
||
ChatActionTriggeredTime = 0,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Best-effort owner for a pushed row. The catalogue only stores owners for published threads, so
|
||
/// anything else falls back to the owner of the row the player came from — what really matters is
|
||
/// that it is non-zero and stable for a conversation, because it becomes the dictionary key.
|
||
/// </summary>
|
||
private static uint OwnerOf(uint chatId, uint fromChatId)
|
||
{
|
||
if (FindOwner(chatId) is { } direct)
|
||
return direct;
|
||
if (fromChatId != 0 && FindOwner(fromChatId) is { } viaCaller)
|
||
return viaCaller;
|
||
return 0;
|
||
|
||
static uint? FindOwner(uint rowId)
|
||
{
|
||
foreach (var owner in NpcChatCatalog4229938.Npcs)
|
||
foreach (var thread in owner.Threads)
|
||
foreach (var node in thread.Items)
|
||
if (node.Id == rowId)
|
||
return owner.Id;
|
||
foreach (var owner in NpcChatCatalog4229938.Groups)
|
||
foreach (var thread in owner.Threads)
|
||
foreach (var node in thread.Items)
|
||
if (node.Id == rowId)
|
||
return owner.Id;
|
||
return null;
|
||
}
|
||
}
|
||
}
|