324 lines
16 KiB
C#
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,
|
|
});
|
|
}
|
|
}
|