Files

319 lines
16 KiB
C#

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;
/// <summary>
/// 趴趴 (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 — <c>BubbleBasePanelStore.lua:32</c> calls <c>NewBubbleMgr.AskPostList()</c> when the
/// panel model initialises, and <c>NewBubbleMgr.lua:1061-1065</c> is the only place that ever fills
/// <c>postDict</c>:
/// <code>
/// for i = 1, #data do
/// local post = data[i]
/// self.lastPostId = math.max(self.lastPostId, post.Id)
/// self.postDict[post.Id] = post
/// end
/// </code>
/// Before this handler the server answered that INVOKE with an empty list (it sat in
/// <c>UnhandledBatch.KnownNeutralInvokeBatch2</c>), so <c>BuildPostList()</c> had nothing to sort
/// into publishers and the friend / mine / home tabs all rendered zero rows.
///
/// The server only names the <b>post instance</b>. <c>PostConfigId</c> is the client's own
/// SocialMediaConfig id and is what unlocks text, image and comment bodies locally
/// (<c>NewBubbleMgr.lua:429,460,872</c> all go through <c>SocialMediaConfig.GetConfig(info.PostConfigId)</c>).
///
/// ⛑ Which tab a post lands in is decided by the client, not by us:
/// <c>BuildPostList</c> puts a post under SHARE when <c>cfg.IfPinStory</c>, under STORY when
/// <c>cfg.IfStory</c>, otherwise under POST — and <c>GetPostPublisher</c> resolves
/// <c>SocialMediaConfig.Publisher → NpcCultivationConfig.AgentTag</c>. 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.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// Rollback switch (Gameplay.BubblePayloadMode): <c>"full"</c> (default) publishes every post in
/// BubblePosts.json, <c>"empty"</c> 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 <c>deserialize failed</c> for 63452251.
/// </summary>
private const string BubbleModeFull = "full";
/// <summary>Marks the session that already received SyncOssBucketInfo, so it goes out once.</summary>
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<GameMethods.PostSimpleClientInfo4229938>());
return;
}
var loadError = BubbleCatalog4229938.LoadError;
if (loadError is not null)
{
conn.Log.Warn($"[BUBBLE] catalog unusable: {loadError}");
await conn.ReturnAsync(msg, new List<GameMethods.PostSimpleClientInfo4229938>());
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<GameMethods.PostSimpleClientInfo4229938>(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<uint>(),
Comments = new List<uint>(post.Comments),
PlayerComments = new List<GameMethods.PostPlayerCommentClientInfo4229938>(),
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);
}
/// <summary>
/// Pushes SyncOssBucketInfo(64833344) once per session, if <c>Gameplay.OssPicture.Enabled</c>.
///
/// 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 — <c>AliOssManager.SetOssBucketInfo</c> 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
/// <c>Bubble/*.png</c>, and an endpoint pointing nowhere is indistinguishable from none.
/// </summary>
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<uint> BubbleIdSet(TcpSession session, string key)
{
lock (session.Items)
{
if (session.Items.TryGetValue(key, out var existing) && existing is HashSet<uint> set)
return set;
var created = new HashSet<uint>();
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<GameMethods.PostPlayerCommentClientInfo4229938>());
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<GameMethods.PostPlayerCommentClientInfo4229938>());
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");
}
/// <summary>
/// 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).
/// </summary>
static IReadOnlyList<uint> MomentsRequestedIds(UxRpcMessage msg)
{
if (msg.Body.Length < 2 || msg.Body[0] == 0x00)
return [];
try
{
if (msg.TryGetArgs<GameMethods.AskMomentsMarkRead4229938>(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 [];
}
}