feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio
This commit is contained in:
1 parent
74e2963a12
commit
3e5adf1dc5
1571 files changed
+336468
-6348
No files matched your search
@@ -0,0 +1,318 @@
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user