Files

324 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>
/// 眼界 (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. <c>SocialNetWorkUtils.lua</c> reads
/// <c>gPlayerManager.infoMinor.bindData.playerTuiteInfo.TuiteList</c> from six places —
/// GetRecommendList:143, GetExploreTuiteList:400, GetFollowList:431, GetTuiteListByPubisher:445,
/// GetCollectionList:1687, GetLocalTuiteList:3046 — and <c>GetTuiteInfo(v)</c> then resolves the
/// body with <c>LTConfig.TuiteConfig.GetConfig(v.CfgId)</c>. 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 <b>post instance</b>. 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.
///
/// ⛑ <c>HasTimeline</c> is never set: the blob <c>PlayMultiVideo</c> 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.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>UX.Game.TuiteState — the byte the client sends with AskInteractTuite (dump.cs:1743256).</summary>
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";
/// <summary>
/// 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.
/// </summary>
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,
},
};
}
/// <summary>Reads the leading u32 of a request body (every Tuite request starts with an id).</summary>
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}");
}
/// <summary>
/// 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 (<c>GetSeverTuiteInfoById</c>), so the session set is what keeps the
/// flag across a re-opened panel.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>AskReportTuiteDetailOpened(63029158) — (uint tuiteId), void. Fired by YanjieNewDetailPagePanel.lua:165.</summary>
[Handler(MethodId.AskReportTuiteDetailOpened, HandlerPacketKind.Invoke)]
private Task AskReportTuiteDetailOpened(Connection conn, UxRpcMessage msg)
{
conn.Log.Info($"[TUITE] detailOpened id={TuiteRequestId(msg)}");
return conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// AskTuiteTakeReward(63801315) — (uint tuiteId), void.
///
/// Only reachable when a row carries a non-zero <c>DropId</c>, which catalogue posts never do
/// (the 领取 button is hidden without it), so this is a safety net for a hand-edited catalogue.
/// </summary>
[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.
// =================================================================================
/// <summary>AskTuiteGetCommentList(63477523) — (uint tuiteId, int page, int pageSize), 12 bytes.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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,
});
}
}