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; /// /// 眼界 (Tuite) — the phone's short-video / Twitter-like app. /// /// Why this file exists: the panel opens but every list is empty, because all of them are filters /// over one server-owned list and nothing ever filled it. SocialNetWorkUtils.lua reads /// gPlayerManager.infoMinor.bindData.playerTuiteInfo.TuiteList from six places — /// GetRecommendList:143, GetExploreTuiteList:400, GetFollowList:431, GetTuiteListByPubisher:445, /// GetCollectionList:1687, GetLocalTuiteList:3046 — and GetTuiteInfo(v) then resolves the /// body with LTConfig.TuiteConfig.GetConfig(v.CfgId). The list itself is published in the /// login frame (see RuntimePayloadFactory), so the app is populated on entry; what is left is the /// interaction half, which is one INVOKE per gesture. /// /// The server only names the post instance. Text, images, area and author all come from /// tables the client already has (TuiteConfig 204 rows / TuiteCommentConfig 1031 rows / /// TuiteNPCConfig 562 rows) — see TuiteCatalog4229938. /// /// ⛑ Return framing is taken from the client's own reader, never guessed: /// 63653286 AskTuiteGetDetail -> struct Reader[348] /// 63950672 AskTuiteGetDetailByConfigId -> struct Reader[348] /// 63989368 AskInteractTuite -> struct Reader[348] /// 63477523 AskTuiteGetCommentList -> struct Reader[620] /// 63242664 AskTuiteLikeComment -> struct Reader[605] /// 63264230 AskTuiteGetTimelineData -> complex Reader[608] (0xFF + u32 + string) /// 63241162 / 63029158 / 63801315 -> void /// A wrong shape does not throw — it desynchronises the client's reader of the reply and silently /// drops the callback, so each handler below returns exactly the shape above even when empty. /// /// ⛑ HasTimeline is never set: the blob PlayMultiVideo wants is one the client /// authors and uploads (AskTuiteUpdateTimelineInfo, 102400-byte limit), so there is no server-side /// copy of the five video posts' data. Marking them true would make the client request a blob we /// cannot serve; leaving it false costs only the video. /// internal sealed partial class GameRouter { /// UX.Game.TuiteState — the byte the client sends with AskInteractTuite (dump.cs:1743256). private const byte TuiteStateNone = 0; private const byte TuiteStatePublishing = 1; private const byte TuiteStateFollow = 2; private const byte TuiteStateComment = 4; private const byte TuiteStateLike = 8; private const byte TuiteStateFavorite = 16; private const byte TuiteStateDetailOpened = 32; private const string TuiteLikedKey = "client4229938-tuite-liked"; private const string TuiteCollectedKey = "client4229938-tuite-collected"; private const string TuiteFollowedRolesKey = "client4229938-tuite-followed-roles"; /// /// Projects a catalogue post into the wire row. Shared by the detail handlers so the numbers on /// a detail page always match the row it was opened from. /// private static GameMethods.ClientTuiteInfo4229938 BuildTuiteInfo(TuitePost4229938 post, TcpSession session) { var liked = BubbleIdSet(session, TuiteLikedKey); var collected = BubbleIdSet(session, TuiteCollectedKey); var followed = BubbleIdSet(session, TuiteFollowedRolesKey); var isLiked = liked.Contains(post.Id); return new GameMethods.ClientTuiteInfo4229938 { CfgId = post.Id, PublishTime = TuiteCatalog4229938.PublishTimeFor(post.Id, DateTimeOffset.UtcNow.ToUnixTimeSeconds()), IsCollect = collected.Contains(post.Id), IsLike = isLiked, // GetFollowList keeps only IsFollow rows. TuiteFollowAll fills the tab immediately; // otherwise it fills as AskTuiteFollowRole marks each author. IsFollow = PrivateServerConfigStore.Current.Gameplay.TuiteFollowAll || followed.Contains(post.Publisher), HasTimeline = false, TimelineData = null, // The client's own counter plus the player's own like, which GetPostLikeAndLikes-style // arithmetic expects to be reflected in the number it shows. LikeCount = post.Likes + (isLiked ? 1 : 0), ViewCount = post.Views, CommentCount = post.Comments.Count, DropId = 0, OssKey = null, }; } private static GameMethods.ClientTuiteComment4229938 BuildTuiteComment( TuiteComment4229938 comment, long index) { var npc = TuiteCatalog4229938.Npcs.TryGetValue(comment.Publisher, out var found) ? found : null; var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); return new GameMethods.ClientTuiteComment4229938 { Id = comment.Id, // Non-zero CfgId makes GetTuiteComment take the body from TuiteCommentConfig. CfgId = comment.Id, PublishTime = (uint)Math.Clamp(now - index * 300L, 0, uint.MaxValue), IsCollect = false, IsLike = false, LikeCount = 0, ViewCount = 0, CommentCount = 0, Content = null, RoleInfo = new GameMethods.ClientTuiteRoleInfo4229938 { Pid = 0, // Both identity readings filled — see ClientTuiteRoleInfo4229938 for why. ShortPid = comment.Publisher, RoleId = comment.Publisher, Name = npc?.Name ?? string.Empty, AvatarId = npc?.Avatar ?? 0, }, }; } /// Reads the leading u32 of a request body (every Tuite request starts with an id). private static uint TuiteRequestId(UxRpcMessage msg) => msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u; // ================================================================================= // Detail — what tapping a row opens // // GetSocialNetworkDetail (SocialNetWorkUtils.lua:637) picks the request by whether the id is a // config row: `if tuiteCfg then` -> AskTuiteGetDetailByConfigId, else AskTuiteGetDetail. Every // id we publish is a TuiteConfig row, so in practice only the ByConfigId variant fires — the // other one is implemented anyway because a player-published post takes that path. // ================================================================================= [Handler(MethodId.AskTuiteGetDetailByConfigId, HandlerPacketKind.Invoke)] private Task AskTuiteGetDetailByConfigId(Connection conn, UxRpcMessage msg) => ReturnTuiteDetailAsync(conn, msg, "byCfgId"); [Handler(MethodId.AskTuiteGetDetail, HandlerPacketKind.Invoke)] private Task AskTuiteGetDetail(Connection conn, UxRpcMessage msg) => ReturnTuiteDetailAsync(conn, msg, "byId"); private async Task ReturnTuiteDetailAsync(Connection conn, UxRpcMessage msg, string via) { var id = TuiteRequestId(msg); var post = TuiteCatalog4229938.Find(id); if (post is null) { // Not a catalogue post (or the catalogue is unavailable). An all-zero struct is still a // well-formed Reader[348], so the callback runs and the panel closes cleanly instead of // hanging on an INVOKE that never resolves. conn.Log.Info($"[TUITE] detail via={via} id={id} -> not in catalogue, empty struct"); await conn.ReturnAsync(msg, new GameMethods.ClientTuiteInfo4229938()); return; } await conn.ReturnAsync(msg, BuildTuiteInfo(post, msg.Context.Session)); conn.Log.Info($"[TUITE] detail via={via} id={id} likes={post.Likes} views={post.Views} comments={post.Comments.Count}"); } /// /// AskInteractTuite(63989368) — (uint tuiteId, TuiteState interactType), 5 bytes. /// /// Like (8) and Favorite (16) are the two the panels actually send /// (SocialNetWorkUtils.lua:498 / :552). The reply is the updated row, and the client also edits /// its own copy in place (GetSeverTuiteInfoById), so the session set is what keeps the /// flag across a re-opened panel. /// [Handler(MethodId.AskInteractTuite, HandlerPacketKind.Invoke)] private async Task AskInteractTuite(Connection conn, UxRpcMessage msg) { var id = TuiteRequestId(msg); var state = msg.Body.Length >= 5 ? msg.Body[4] : TuiteStateNone; var post = TuiteCatalog4229938.Find(id); if (post is null) { conn.Log.Info($"[TUITE] interact id={id} state={state} -> not in catalogue, empty struct"); await conn.ReturnAsync(msg, new GameMethods.ClientTuiteInfo4229938()); return; } switch (state) { case TuiteStateLike: var liked = BubbleIdSet(msg.Context.Session, TuiteLikedKey); if (!liked.Add(id)) liked.Remove(id); break; case TuiteStateFavorite: var collected = BubbleIdSet(msg.Context.Session, TuiteCollectedKey); if (!collected.Add(id)) collected.Remove(id); break; case TuiteStateFollow: BubbleIdSet(msg.Context.Session, TuiteFollowedRolesKey).Add(post.Publisher); break; default: // Publishing / Comment / DetailOpened are notifications with nothing to store. break; } conn.Log.Info($"[TUITE] interact id={id} state={state} liked={BubbleIdSet(msg.Context.Session, TuiteLikedKey).Count} " + $"collected={BubbleIdSet(msg.Context.Session, TuiteCollectedKey).Count}"); await conn.ReturnAsync(msg, BuildTuiteInfo(post, msg.Context.Session)); } /// /// AskTuiteFollowRole(63241162) — (uint roleId), void return. /// /// The client marks the whole author's list itself on success /// (SocialNetWorkUtils.lua:610-625), so the server only has to answer; the set is kept anyway so /// a later detail request agrees with the local flag. /// [Handler(MethodId.AskTuiteFollowRole, HandlerPacketKind.Invoke)] private Task AskTuiteFollowRole(Connection conn, UxRpcMessage msg) { var roleId = TuiteRequestId(msg); BubbleIdSet(msg.Context.Session, TuiteFollowedRolesKey).Add(roleId); conn.Log.Info($"[TUITE] followRole role={roleId}"); return conn.ReturnEmptyOkAsync(msg); } /// AskReportTuiteDetailOpened(63029158) — (uint tuiteId), void. Fired by YanjieNewDetailPagePanel.lua:165. [Handler(MethodId.AskReportTuiteDetailOpened, HandlerPacketKind.Invoke)] private Task AskReportTuiteDetailOpened(Connection conn, UxRpcMessage msg) { conn.Log.Info($"[TUITE] detailOpened id={TuiteRequestId(msg)}"); return conn.ReturnEmptyOkAsync(msg); } /// /// AskTuiteTakeReward(63801315) — (uint tuiteId), void. /// /// Only reachable when a row carries a non-zero DropId, which catalogue posts never do /// (the 领取 button is hidden without it), so this is a safety net for a hand-edited catalogue. /// [Handler(MethodId.AskTuiteTakeReward, HandlerPacketKind.Invoke)] private Task AskTuiteTakeReward(Connection conn, UxRpcMessage msg) { conn.Log.Info($"[TUITE] takeReward id={TuiteRequestId(msg)} (no drop on catalogue posts)"); return conn.ReturnEmptyOkAsync(msg); } // ================================================================================= // Comments // // The list page's comment count comes from the client (GetYanjieCommentCount falls back to // #TuiteConfig.Comment), so this handler is what fills the comment section itself. Author // identity is the only thing the server owns down here — the text is TuiteCommentConfig's. // ================================================================================= /// AskTuiteGetCommentList(63477523) — (uint tuiteId, int page, int pageSize), 12 bytes. [Handler(MethodId.AskTuiteGetCommentList, HandlerPacketKind.Invoke)] private async Task AskTuiteGetCommentList(Connection conn, UxRpcMessage msg) { var id = TuiteRequestId(msg); var page = msg.Body.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(msg.Body.AsSpan(4)) : 0; var pageSize = msg.Body.Length >= 12 ? BinaryPrimitives.ReadInt32LittleEndian(msg.Body.AsSpan(8)) : 0; var post = TuiteCatalog4229938.Find(id); if (post is null || post.Comments.Count == 0) { conn.Log.Info($"[TUITE] commentList id={id} page={page} size={pageSize} -> empty"); await conn.ReturnAsync(msg, new GameMethods.TuiteCommentListData4229938()); return; } // pageSize 0 means "no paging asked for" — serve everything rather than nothing. var take = pageSize <= 0 ? post.Comments.Count : Math.Min(pageSize, post.Comments.Count); var skip = page > 0 ? Math.Min((page - 1) * take, post.Comments.Count) : 0; var slice = post.Comments.Skip(skip).Take(Math.Max(0, take)).ToList(); var payload = new GameMethods.TuiteCommentListData4229938 { Total = post.Comments.Count }; for (var i = 0; i < slice.Count; i++) payload.CommentList.Add(BuildTuiteComment(slice[i], i)); conn.Log.Info($"[TUITE] commentList id={id} page={page} size={pageSize} -> {payload.CommentList.Count}/{payload.Total}"); await conn.ReturnAsync(msg, payload); } /// /// AskTuiteLikeComment(63242664) — (uint tuiteId, uint commentId, bool isLike), struct Reader[605]. /// Per-session only; there is no counter to move because TuiteCommentConfig carries no likes. /// [Handler(MethodId.AskTuiteLikeComment, HandlerPacketKind.Invoke)] private async Task AskTuiteLikeComment(Connection conn, UxRpcMessage msg) { var tuiteId = TuiteRequestId(msg); var commentId = msg.Body.Length >= 8 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(4)) : 0u; var isLike = msg.Body.Length < 9 || msg.Body[8] != 0; var post = TuiteCatalog4229938.Find(tuiteId); var index = post is null ? -1 : post.Comments.ToList().FindIndex(c => c.Id == commentId); var payload = index < 0 ? new GameMethods.ClientTuiteComment4229938 { Id = commentId, CfgId = commentId } : BuildTuiteComment(post!.Comments[index], index); payload.IsLike = isLike; conn.Log.Info($"[TUITE] likeComment tuite={tuiteId} comment={commentId} like={isLike} known={index >= 0}"); await conn.ReturnAsync(msg, payload); } // ================================================================================= // Timeline — deliberately empty // // AskTuiteGetTimelineData(63264230) -> complex Reader[608] { uint Id, string TimelineData }. // Reachable only when a row is published with HasTimeline, which never happens (see the header), // so the honest answer is an empty blob rather than an invented one — an empty string makes // PlayMultiVideo a no-op instead of feeding it fabricated data. // ================================================================================= [Handler(MethodId.AskTuiteGetTimelineData, HandlerPacketKind.Invoke)] private async Task AskTuiteGetTimelineData(Connection conn, UxRpcMessage msg) { var id = TuiteRequestId(msg); conn.Log.Info($"[TUITE] timelineData id={id} -> empty (blob is client-authored, see header)"); await conn.ReturnAsync(msg, new Ananta.Server.RpcTypes.Client4229938.Auto.TuiteTimelineData { Id = id, TimelineData = string.Empty, }); } }