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