using System.Buffers.Binary; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// 趴趴 (Bubble / SocialMedia) — the phone's WeChat-like app, AppId 12 / SystemId 231. /// /// Why this file exists: the panel opens but every tab is empty. The reason is a one-way street on /// the client — BubbleBasePanelStore.lua:32 calls NewBubbleMgr.AskPostList() when the /// panel model initialises, and NewBubbleMgr.lua:1061-1065 is the only place that ever fills /// postDict: /// /// for i = 1, #data do /// local post = data[i] /// self.lastPostId = math.max(self.lastPostId, post.Id) /// self.postDict[post.Id] = post /// end /// /// Before this handler the server answered that INVOKE with an empty list (it sat in /// UnhandledBatch.KnownNeutralInvokeBatch2), so BuildPostList() had nothing to sort /// into publishers and the friend / mine / home tabs all rendered zero rows. /// /// The server only names the post instance. PostConfigId is the client's own /// SocialMediaConfig id and is what unlocks text, image and comment bodies locally /// (NewBubbleMgr.lua:429,460,872 all go through SocialMediaConfig.GetConfig(info.PostConfigId)). /// /// ⛑ Which tab a post lands in is decided by the client, not by us: /// BuildPostList puts a post under SHARE when cfg.IfPinStory, under STORY when /// cfg.IfStory, otherwise under POST — and GetPostPublisher resolves /// SocialMediaConfig.Publisher → NpcCultivationConfig.AgentTag. Agent type 0 means "my own /// post" and would file it under the wrong tab, so _gen_bubble_posts.py verifies all 55 publishers /// map to a non-zero agent type before writing BubblePosts.json. /// internal sealed partial class GameRouter { /// /// Rollback switch (Gameplay.BubblePayloadMode): "full" (default) publishes every post in /// BubblePosts.json, "empty" answers with an empty list — the behaviour of every build /// before 2026-09-24, and the setting to fall back to if the frame ever desynchronises and the /// client logs deserialize failed for 63452251. /// private const string BubbleModeFull = "full"; /// Marks the session that already received SyncOssBucketInfo, so it goes out once. private const string BubbleOssKey = "client4229938-bubble-oss"; [Handler(MethodId.AskMomentsPostSimpleInfos, HandlerPacketKind.Invoke)] private async Task AskMomentsPostSimpleInfos(Connection conn, UxRpcMessage msg) { var mode = PrivateServerConfigStore.Current.Gameplay.BubblePayloadMode; // The request is a bare (uint lastPostId, byte postType) — 5 bytes, no presence marker. // Hard rule 111: read the primitive straight off the body instead of trusting a contract. var lastPostId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u; var postType = msg.Body.Length >= 5 ? msg.Body[4] : (byte)0; if (!string.Equals(mode, BubbleModeFull, StringComparison.OrdinalIgnoreCase)) { conn.Log.Info($"[BUBBLE] mode={mode} -> empty (lastId={lastPostId} type={postType})"); await conn.ReturnAsync(msg, new List()); return; } var loadError = BubbleCatalog4229938.LoadError; if (loadError is not null) { conn.Log.Warn($"[BUBBLE] catalog unusable: {loadError}"); await conn.ReturnAsync(msg, new List()); return; } // The posts are in hand, so this is also the right moment to tell the client where their // pictures live — without SyncOssBucketInfo every ImageOSS key resolves to nothing and the // panel renders text-only rows. await PublishOssBucketInfoOnceAsync(conn, msg.Context.Session); // The client keeps a monotone cursor: it only ever asks for posts newer than lastPostId and // re-asks from 0 (ResetPostCache) when the server says the cursor went stale. var source = BubbleCatalog4229938.Posts; var selected = lastPostId == 0 ? source : source.Where(p => p.Id > lastPostId).ToList(); var step = PrivateServerConfigStore.Current.Gameplay.BubblePostDateStepSeconds; if (step == 0) step = 900; var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var liked = BubbleIdSet(msg.Context.Session, BubbleLikedKey); var markedRead = BubbleIdSet(msg.Context.Session, BubbleReadKey); // IsRead decides more than the red dot: with unread posts, OnClickNpcBubbleHead // (NewBubbleMgr.lua:750) opens that single post instead of the three-tab NPC home page. var markAllRead = PrivateServerConfigStore.Current.Gameplay.BubbleMarkAllRead; var infos = new List(selected.Count); for (var i = 0; i < selected.Count; i++) { var post = selected[i]; var seconds = now - i * step; infos.Add(new GameMethods.PostSimpleClientInfo4229938 { Id = post.Id, // 0 = GetPostType.All; no Bubble panel branches on this field. PostType = 0, PostConfigId = post.PostConfigId, // Must be in the past: BubbleFilmPanelStore renders ServerUnixTime - info.Date. Date = (uint)Math.Clamp(seconds, 0, uint.MaxValue), // PostSimpleClientInfo.ImageUrl: the same picture as a full CDN link, straight out of // SocialMediaConfig.Image. NewBubbleMgr.lua:885 only reads it when the row has no // ImageOSS key, so it is normally dead weight — but there is no other server-side way // to hand the client a picture url, and it costs nothing to publish it. ImageUrl = post.ImageUrl, Approved = true, Title = string.Empty, Likes = post.Likes, // Interaction state is per session: a like survives reopening the panel because the // list is re-served from here, and only AskMomentsMarkRead clears the red dot. Liked = liked.Contains(post.Id), LikeNpcs = new List(), Comments = new List(post.Comments), PlayerComments = new List(), IsRead = markAllRead || markedRead.Contains(post.Id), HasNewLike = false, AcquireCfgId = 0, ActivityCfgId = 0, IsStory = post.IsStory, IsPinStory = post.IsPinStory, }); } // Every field above is hand-transcribed from RPCSerializeAuto.lua:8310 and the client // declares PostSimpleClientInfo as an empty class, so the only proof this frame is right is // the client log. newestId/step make the payload itself checkable against BubblePosts.json // without a second look at the code. conn.Log.Info($"[BUBBLE] postList={infos.Count}/{BubbleCatalog4229938.Count} mode={mode} " + $"lastId={lastPostId} type={postType} step={step}s " + $"newestId={(infos.Count > 0 ? infos[0].Id : 0)} " + $"withImage={infos.Count(i => !string.IsNullOrEmpty(i.ImageUrl))}"); await conn.ReturnAsync(msg, infos); } /// /// Pushes SyncOssBucketInfo(64833344) once per session, if Gameplay.OssPicture.Enabled. /// /// Why it belongs here: the only client-side consumer of this configuration is the 趴卜 picture /// widget, and every post only carries an object key, never a URL. The packet is pure /// configuration — AliOssManager.SetOssBucketInfo just stores the strings — so sending it /// late (when the panel opens) costs nothing and keeps it out of the critical login frame. /// /// Off by default: we do not yet know a bucket/endpoint pair that actually serves /// Bubble/*.png, and an endpoint pointing nowhere is indistinguishable from none. /// private async Task PublishOssBucketInfoOnceAsync(Connection conn, TcpSession session) { var oss = PrivateServerConfigStore.Current.Gameplay.OssPicture; if (!oss.Enabled) return; lock (session.Items) { if (session.Items.ContainsKey(BubbleOssKey)) return; session.Items[BubbleOssKey] = true; } await conn.NotifyAsync( MethodId.IGameToClient_SyncOssBucketInfo, new GameMethods.OssBucketInfo4229938 { Region = oss.Region, Endpoint = oss.Endpoint, Bucket = oss.Bucket, }); conn.Log.Info($"[OSS-PIC] SyncOssBucketInfo region='{oss.Region}' endpoint='{oss.Endpoint}' bucket='{oss.Bucket}'"); } // ================================================================================= // 趴卜 interaction half — likes / mark-read / comments / taps // // The list channel (AskMomentsPostSimpleInfos above) is enough to see posts, but every // interaction the player performs on one of those rows is its own INVOKE. Before this block // none of them had a handler, so an un-routed request could only ever get the generic typed // default: likes never registered, mark-read never landed and the panel kept re-showing the // same unread dot. // // Return shapes are authoritative from DefaultReturnCatalog4229938 (generated from the client's // own RPCDeserializeAuto.lua), not guessed: // 63879443 PostInfos -> list7 63525787 UnreadMessage -> list7 // 63304982 HaveUnread -> bool 63132673 MarkRead -> void // 63895686 LikePost -> void 63024597 TapPost -> void // 63943988 SendCommentWithId-> complex(string, u32, bool, u32, u32) // Getting one of these wrong does not throw — it just desynchronises the client's reader of the // reply, so each one below deliberately returns the shape above even when the answer is empty. // ================================================================================= private const string BubbleLikedKey = "client4229938-bubble-liked"; private const string BubbleReadKey = "client4229938-bubble-read"; static HashSet BubbleIdSet(TcpSession session, string key) { lock (session.Items) { if (session.Items.TryGetValue(key, out var existing) && existing is HashSet set) return set; var created = new HashSet(); session.Items[key] = created; return created; } } [Handler(MethodId.AskMomentsPostInfos, HandlerPacketKind.Invoke)] private async Task AskMomentsPostInfos(Connection conn, UxRpcMessage msg) { // Full-row details: the request carries the wanted post ids, the reply is a list7 of the // client's full post type, for which we have no DTO (only PostSimpleClientInfo exists here, // and it is not the same shape). The rows the panel already loaded from // AskMomentsPostSimpleInfos stay complete, so an empty list costs nothing visible. await conn.ReturnAsync(msg, new List()); conn.Log.Info($"[BUBBLE] postInfos requested body={msg.Body.Length}b -> empty list7 (details come from the simple list)"); } [Handler(MethodId.AskMomentsUnreadMessage, HandlerPacketKind.Invoke)] private async Task AskMomentsUnreadMessage(Connection conn, UxRpcMessage msg) { await conn.ReturnAsync(msg, new List()); conn.Log.Info("[BUBBLE] unreadMessage -> empty list7"); } [Handler(MethodId.AskMomentsHaveUnreadMessage, HandlerPacketKind.Invoke)] private async Task AskMomentsHaveUnreadMessage(Connection conn, UxRpcMessage msg) { // Everything we serve is authored content with no inbox semantics, so after the first // fetch nothing is pending. Returning anything else would keep a red dot burning. await conn.ReturnAsync(msg, false); conn.Log.Info("[BUBBLE] haveUnreadMessage -> false"); } [Handler(MethodId.AskMomentsMarkRead, HandlerPacketKind.Invoke)] private async Task AskMomentsMarkRead(Connection conn, UxRpcMessage msg) { var read = BubbleIdSet(msg.Context.Session, BubbleReadKey); var ids = MomentsRequestedIds(msg); foreach (var id in ids) read.Add(id); await conn.ReturnEmptyOkAsync(msg); conn.Log.Info($"[BUBBLE] markRead ids={ids.Count} totalRead={read.Count}"); } [Handler(MethodId.AskMomentsLikePost, HandlerPacketKind.Invoke)] private Task AskMomentsLikePost(Connection conn, UxRpcMessage msg) { // (uint postId, bool like) — read straight off the body rather than through a contract we // have not verified against a capture (hard rule 111). var postId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u; var like = msg.Body.Length < 5 || msg.Body[4] != 0; var liked = BubbleIdSet(msg.Context.Session, BubbleLikedKey); if (like) liked.Add(postId); else liked.Remove(postId); conn.Log.Info($"[BUBBLE] likePost post={postId} like={like} totalLiked={liked.Count}"); return conn.ReturnEmptyOkAsync(msg); } [Handler(MethodId.AskMomentsTapPostWithCount, HandlerPacketKind.Invoke)] private Task AskMomentsTapPostWithCount(Connection conn, UxRpcMessage msg) { var postId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u; conn.Log.Info($"[BUBBLE] tapPost post={postId} body={msg.Body.Length}b"); return conn.ReturnEmptyOkAsync(msg); } [Handler(MethodId.AskMomentsSendCommentWithId, HandlerPacketKind.Invoke)] private async Task AskMomentsSendCommentWithId(Connection conn, UxRpcMessage msg) { // Reply is the complex outlined above — exactly the fields of // PostPlayerCommentClientInfo4229938. IsFinish=true is what lets the client close its // "waiting for the server line" state; without it the comment box never clears. await conn.ReturnAsync(msg, new GameMethods.PostPlayerCommentClientInfo4229938 { Comment = string.Empty, CommentId = msg.Body.Length >= 8 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(4)) : 0u, IsFinish = true, CreateTime = 0, CommentTime = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(), }); conn.Log.Info("[BUBBLE] sendComment -> ack IsFinish=true"); } /// /// Best-effort extraction of the post ids a Moments request wants. Both the Int7 and Int32 /// count generations exist across these handlers and reading the wrong one silently yields /// garbage ids, so this only ever returns ids it can also prove plausible (non-zero, and not /// more than the body can hold). /// static IReadOnlyList MomentsRequestedIds(UxRpcMessage msg) { if (msg.Body.Length < 2 || msg.Body[0] == 0x00) return []; try { if (msg.TryGetArgs(out var int7) && int7?.postIds is { } list) return list.Where(x => x != 0).ToArray(); } catch { // fall through to the empty answer — an id list we cannot trust is worse than none } return []; } }