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; /// /// BBChat — AppId 27 "Message" (SystemId 146), the phone's NPC chat app. It is NOT the player IM: /// its red dot comes from gRedDotUtils.CheckNpcChatHasRedDot() (MainPhoneUtils.lua:688) and /// its panels are the NpcChat* stores, i.e. everything here is gNpcChatManager. /// /// Content half: the conversation list is published by the login frame /// (InfoNpcCultivation.NpcChats / NpcGroupChats, 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 (chatid) and the client /// declares no reply reader for any of them (no midToReader 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 SyncNpcChat 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 LTConfig.NPCChatConfig.GetConfig(chatItem.ChatId) /// (NpcChatUtils.NewFromNpcChatItem), which drops any id it does not know with a /// "NPCChat表没有查到" error — hence NextChatId always comes from the catalogue's own map. /// internal sealed partial class GameRouter { /// InteractNpcChatToEnd bursts get this many rows per gesture — see InteractNpcChatToEnd. private const int NpcChatSkipBatch = 24; /// Leading u32 of every request body here. 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); } /// /// 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 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. /// [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 // ================================================================================= /// AskMarkNpcChatRead(63266674) — NpcChatChattingPanelStore.lua:132/818, NpcChatN2NChattingPanel.lua:217. [Handler(MethodId.AskMarkNpcChatRead, HandlerPacketKind.Invoke)] private Task AskMarkNpcChatRead(Connection conn, UxRpcMessage msg) { conn.Log.Info($"[NPCCHAT] markRead id={NpcChatRequestId(msg)}"); return conn.ReturnEmptyOkAsync(msg); } /// /// 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()`) /// ⇒ 客户端 reader 读不到 presence 字节。实测 [NPCCHAT] 有触发(今日累计 2 次)。 /// ⇒ 回**空列表** `FF 01`(0xFF=presence,0x01=Int7(0) 的 +1 偏置,硬规则"空列表 2B")。 /// 数据本身仍为空(列表确实走登录包),但 wire 形状正确,客户端不会读空 buffer。 /// ⛑ 回滚:把 return 换回 `conn.ReturnEmptyOkAsync(msg)`。 /// [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 }); } /// AskCloseNpcChatWnd(67994672) — NpcChatChattingPanelStore.lua:635/1240, sent on the scene delegate. [Handler(MethodId.AskCloseNpcChatWnd, HandlerPacketKind.Invoke)] private Task AskCloseNpcChatWnd(Connection conn, UxRpcMessage msg) { conn.Log.Info($"[NPCCHAT] closeWnd id={NpcChatRequestId(msg)}"); return conn.ReturnEmptyOkAsync(msg); } /// AskClearSpiritGroupChat(63527240) / AskClearNpcUncompletedInviteChat(63639364) — one-shot cleanups. [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 // ================================================================================= /// /// One pushed/downloaded history row. Field order and the complex root come from the client's own /// reader — Auto.Reader[372] read via Base.ReadComplex, so the push starts with a /// 0xFF marker and no other framing is involved. /// 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, }; } /// /// 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. /// 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; } } }