Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.NpcChat.cs
T

238 lines
12 KiB
C#
Raw 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 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&lt;T&gt; 而不是 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;
}
}
}