319 lines
16 KiB
C#
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 [];
|
|
}
|
|
}
|