Files

444 lines
21 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Ananta.SDK.Rpc;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 音乐 / 演出线三条玩法的服务端闭环 —— 2026-10-04:
/// <list>
/// <item><b>1. Livehouse 音游</b>(节奏打击,引擎 <c>MusicGameManager.lua</c>)</item>
/// <item><b>2. PartyDance 舞池</b>(同一套音游引擎,<c>LivehouseConfig.Use == Party</c> 那 5 首)</item>
/// <item><b>3. PartyLive 派对直播</b>(点赞 / 礼物 / 弹幕,<c>PartyLiveStreamPanelStore.lua</c>)</item>
/// </list>
///
/// <para>
/// ★★ 接之前这三条线<b>全套落在空包兜底</b>里(<c>DefaultReturnCatalog4229938.cs</c>):
/// <c>63885626 → [0xFF,0x01] list7-empty</c>、<c>63848409 → [0xFF,0x00] ReadBoolean</c>、
/// 其余 <c>shape="void"</c>。症状不是报错,而是<b>静默没内容</b>:
/// · 曲目列表恒空 ⇒ 面板上的等级打点永远是 0,重进清零;
/// · <c>FirstPlay</c> 恒 false ⇒ 首次引导 timeline 永远不播;
/// · 舞会三连里 <c>AskStartPartyDance</c> 的 Callback 什么都不做,客户端在等
/// <c>SyncPartyDanceStart</c> 推送 ⇒ 不推 = 点了没反应;
/// · 直播间 UI 自己不发 RPC,数据全靠 <c>SyncPartyResponse</c>(68456156) ⇒ 从不推 = 空直播间。
/// </para>
///
/// <para>
/// ⛑ 硬规则 239 —— 每个契约都对照客户端<b>自己的</b>反序列化器核过,不是照 dump.cs 猜:
/// <c>Drmk.Proxy\proxy\lua\RPCDeserializeAuto.lua</c> 的 <c>Auto.Reader[571]</c> /
/// <c>[568]</c> / <c>[428]</c> / <c>[429]</c> / <c>[1123]</c>,
/// 参数形状对照 <c>_lua_all\lua\LuaGen\AutoGen\ClientToGameDelegate.lua</c> 的 <c>*_Serializer</c>。
/// </para>
///
/// <para>⛑ 回滚:从 <c>GameRouter.cs</c> 的 <c>Enabled4229938MethodIds</c> 里删掉对应行即可
/// (handler 不注册 = 回到中性兜底),本文件留着无害。</para>
/// </summary>
internal sealed partial class GameRouter
{
// ---------------------------------------------------------------------------------------
// 1. Livehouse 音游
//
// 客户端整条链(逐行核过,非推断):
// 进 Sonic Boom(IndoorId 5) / 万能旋钮(274) 的室内扇区 → 选曲面板
// LivehouseSelectPanelStore.lua:291 OnPlayBtn
// · LivehouseConfig.DialogBeforeTimelineSingle 命中 ⇒ 先播对话,DialogEnd 再进
// · 否则直接 PlayLiveHouseV2
// MusicGameManager.PlayLiveHouseV2(:324) → StartGame(:371)
// → CheckShow(LIVEHOUSE_GAME_PANEL) + PlayGameMainTimeline(:564)
// → 未显式跳过 intro 时先 AskQueryLiveHouseInfo():FirstPlay 为真才播 intro timeline
// → LiveHouseMusicStart(musicId, inviteNpcId, difficulty) ← NOTIFY
// → 打完 SetLiveHouseMusicResult(musicId, {P,G,M,S,percent}, inviteNpcId) ← INVOKE
// → SetRecordMusicInfo + PlayLivehouseEndTimeline(Good / BadEnding 对话)
// 开局前勾了「金手指」⇒ 先 AskLiveHouseUseItem(musicId) 再 StartGame。
// ---------------------------------------------------------------------------------------
/// <summary>取登录后才能拿到的 <c>WorldEntryState</c>;还没建好就是 null(按新账号处理)。</summary>
private WorldEntryState? LivehouseState(UxRpcMessage msg)
=> msg.Context is null ? null : GetWorldState(msg.Context);
// AskQueryLiveHouseInfo (63848409) — Invoke ; 无参 ; return: Complex{ bool FirstPlay }
//
// 「要不要播首次引导」的唯一判据。第一次答 true,之后恒 false,由
// LivehouseProgressStore 把这个 latch 落进 config/player-progress.json。
[Handler(MethodId.AskQueryLiveHouseInfo, HandlerPacketKind.Invoke)]
private Task AskQueryLiveHouseInfo4229938(Connection conn, UxRpcMessage msg)
{
var state = LivehouseState(msg);
var firstPlay = state is not null && LivehouseProgressStore.TakeFirstPlay(state);
conn.Log.Info($"[LIVEHOUSE] AskQueryLiveHouseInfo -> FirstPlay={firstPlay}");
return conn.ReturnAsync(msg, new LiveHouseQueryInfoPush { FirstPlay = firstPlay });
}
// AskLiveHouseMusicList (63885626) — Invoke ; 无参 ; return: List7Bit<Complex(571)>
//
// 客户端把整张表灌进 gMusicGameManager.RecordMusicInfo,之后 GetScoreLevel 才显示
// 非最低档评级。空列表客户端<b>不报错</b>,只是等级打点全 0 —— 别指望有日志线索。
[Handler(MethodId.AskLiveHouseMusicList, HandlerPacketKind.Invoke)]
private Task AskLiveHouseMusicList4229938(Connection conn, UxRpcMessage msg)
{
var state = LivehouseState(msg);
var records = state is null
? new Dictionary<uint, LivehouseRecord4229938>()
: LivehouseProgressStore.Snapshot(state);
var list = new List<MusicClientInfoPush>();
foreach (var (musicId, record) in records)
{
if (musicId == 0 || record is null)
continue;
// 顺序必须严格是 Perfect / Great / Miss / Special(见契约文件的类注释)。
list.Add(new MusicClientInfoPush
{
MusicId = musicId,
RecordInfo = new List<uint>
{
record.Perfect,
record.Great,
record.Miss,
record.Special,
},
AlreadyReward = record.AlreadyReward,
});
}
conn.Log.Info($"[LIVEHOUSE] AskLiveHouseMusicList -> {list.Count} 首有成绩");
return conn.ReturnAsync(msg, list);
}
// SetLiveHouseMusicResult (63723141) — Invoke ; args: u32 musicId, list7bit<u32> counts, u32 npcId
//
// 唯一的成绩写入路径。客户端实际给 5 项 {Perfect, Great, Miss, Special, floor(percent)}
// (MusicGameManager.lua:264-270),我们只需要前 4 项。
[Handler(MethodId.SetLiveHouseMusicResult, HandlerPacketKind.Invoke)]
private Task SetLiveHouseMusicResult4229938(Connection conn, UxRpcMessage msg)
{
var body = msg.Body;
uint musicId = 0;
uint npcId = 0;
var counts = new List<uint>();
var pos = 0;
if (body.Length >= 4)
{
musicId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4));
pos += 4;
}
// ReadList7Bit:长度是 varint,空表写 0x00;紧跟着 n 个裸 u32。
if (body.Length > pos)
{
var n = body[pos];
pos += 1;
for (var i = 0; i < n && pos + 4 <= body.Length; i++)
{
counts.Add(BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)));
pos += 4;
}
}
if (body.Length >= pos + 4)
npcId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4));
var state = LivehouseState(msg);
if (state is null)
{
conn.Log.Warn($"[LIVEHOUSE] SetLiveHouseMusicResult musicId={musicId} 无 world state ⇒ 本次不落盘");
return conn.ReturnEmptyOkAsync(msg);
}
LivehouseProgressStore.Record(state, musicId, counts);
conn.Log.Info(
$"[LIVEHOUSE] result musicId={musicId} npcId={npcId} counts={counts.Count} " +
$"P={U(counts, 0)} G={U(counts, 1)} M={U(counts, 2)} S={U(counts, 3)}");
// 落盘走 PlayerProgressStore.Save 的既有 tick(Save 读的就是 WorldEntryState)。
return conn.ReturnEmptyOkAsync(msg);
}
// LiveHouseMusicStart (63293678) — Notify ; args: u32 musicId, u32 npcId, u32 difficulty
// LiveHouseMusicInterrupt (63752208)— Notify ; args: u32 musicId, u32 npcId
//
// 两条都只是「服务端知道一下就够了」:客户端既不 await,回调里也没有业务分支。
// 只落日志(将来要做局数统计时这份日志就是现成素材,同硬规则 112 的做法)。
[Handler(MethodId.LiveHouseMusicStart)]
[Handler(MethodId.LiveHouseMusicInterrupt)]
private Task LiveHouseMusicNotify4229938(Connection conn, UxRpcMessage msg)
{
var body = msg.Body;
var musicId = body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0, 4)) : 0u;
var npcId = body.Length >= 8 ? BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4, 4)) : 0u;
// Notify 的 handler 不需要回包,ReturnEmptyOkAsync 对 Notify 无意义(第三条坑见铁律 4)。
var extra = msg.MethodId == MethodId.LiveHouseMusicStart && body.Length >= 12
? $" difficulty={BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, 4))}"
: string.Empty;
conn.Log.Info($"[LIVEHOUSE] {msg.MethodId} musicId={musicId} npcId={npcId}{extra}");
return Task.CompletedTask;
}
// AskLiveHouseUseItem (63665380) — Invoke ; args: u32 musicId ; return: void
//
// 「金手指」(强制全 Perfect)的道具消耗请求 —— LivehouseSelectPanelStore.lua:320。
// ⛑ 客户端自己的前置检查才是权威:面板在道具不足时先弹
// MessageConfig.LivehouseGoldFinger 而<b>不会走到这里</b>(:322)。
// ⇒ 服务端再扣一次是双重扣减;这里只放行,扣减交给客户端驱动的道具系统。
[Handler(MethodId.AskLiveHouseUseItem, HandlerPacketKind.Invoke)]
private Task AskLiveHouseUseItem4229938(Connection conn, UxRpcMessage msg)
{
var musicId = msg.Body.Length >= 4
? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(0, 4))
: 0u;
conn.Log.Info($"[LIVEHOUSE] AskLiveHouseUseItem musicId={musicId} -> 放行(扣减由客户端道具系统负责)");
return conn.ReturnEmptyOkAsync(msg);
}
// ---------------------------------------------------------------------------------------
// 2. PartyDance 舞池
//
// ★★ 关键:契约 SyncPartyDanceStartPush / SyncPartyDanceEndPush <b>早就写好了</b>
// (PartyWireContracts.cs:306 / :356),但全仓零使用 ⇒ 舞会从来没真正开始过。
//
// 客户端三连(ChooseDanceMusicPanelStore.lua:343-384 RequestStartPartyDance):
// AskSelectPartyDanceSong(musicId) → AskSelectPartyDanceMode(mode) → AskStartPartyDance()
// ⛑ 规则 257:那三处 `if err == MessageConfig.Ok then DisplayServerMessageId; return end`
// 要按「<b>err != Ok 才中断</b>」读(反混淆把比较符翻了),否则下一步永远不发。
// AskStartPartyDance 的 Callback 什么都不做 ⇒ 客户端在等 SyncPartyDanceStart。
// ---------------------------------------------------------------------------------------
private const string PartyDanceSongKey = "PartyDance.SelectedMusicId";
private const string PartyDanceModeKey = "PartyDance.SelectedMode";
[Handler(MethodId.AskSelectPartyDanceSong, HandlerPacketKind.Invoke)]
private Task AskSelectPartyDanceSong4229938(Connection conn, UxRpcMessage msg)
{
var musicId = msg.Body.Length >= 4
? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(0, 4))
: 0u;
var session = msg.Context?.Session;
if (session is not null)
session.Items[PartyDanceSongKey] = musicId;
conn.Log.Info($"[PARTYDANCE] select song musicId={musicId}");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskSelectPartyDanceMode, HandlerPacketKind.Invoke)]
private Task AskSelectPartyDanceMode4229938(Connection conn, UxRpcMessage msg)
{
// 单个 byte(UX.Game.PartyDanceMode,值域 {0,1})。服务端不猜枚举名,回显即可。
var mode = msg.Body.Length > 0 ? msg.Body[0] : (byte)0;
var session = msg.Context?.Session;
if (session is not null)
session.Items[PartyDanceModeKey] = mode;
conn.Log.Info($"[PARTYDANCE] select mode={mode}");
return conn.ReturnEmptyOkAsync(msg);
}
// AskStartPartyDance (63292540) — Invoke ; 无参 ; return: void
//
// ★ 唯一能让舞会真正开始的推送点。不推 = 面板开得出来、按钮点得动、什么都不会发生。
[Handler(MethodId.AskStartPartyDance, HandlerPacketKind.Invoke)]
private async Task AskStartPartyDance4229938(Connection conn, UxRpcMessage msg)
{
uint musicId = 0;
byte mode = 0;
var session = msg.Context?.Session;
if (session is not null)
{
if (session.Items.TryGetValue(PartyDanceSongKey, out var songObj) && songObj is uint song)
musicId = song;
if (session.Items.TryGetValue(PartyDanceModeKey, out var modeObj) && modeObj is byte m)
mode = m;
}
// ZoneGadgetUId 必须 = 0:官方哨兵,语义是「没有舞池机关区域 ⇒ 人人都是参与者」
// (PartyManager.lua:432)。填非 0 还得先把 Slot1/Slot2 占好,否则客户端不认参与者身份。
await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncPartyDanceStart, new SyncPartyDanceStartPush
{
ZoneGadgetUId = 0,
MusicId = musicId,
Mode = mode,
StartTimestamp = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
});
conn.Log.Info($"[PARTYDANCE] start musicId={musicId} mode={mode} -> SyncPartyDanceStart");
await conn.ReturnEmptyOkAsync(msg);
}
// AskLeavePartyDance (63516846) — Invoke ; 无参 ; return: void
[Handler(MethodId.AskLeavePartyDance, HandlerPacketKind.Invoke)]
private async Task AskLeavePartyDance4229938(Connection conn, UxRpcMessage msg)
{
// 与 Start 成对,同样用 0 哨兵(PartyWireContracts.cs:356 有说明)。
await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncPartyDanceEnd, new SyncPartyDanceEndPush
{
ZoneGadgetUId = 0,
});
conn.Log.Info("[PARTYDANCE] leave -> SyncPartyDanceEnd");
await conn.ReturnEmptyOkAsync(msg);
}
// ---------------------------------------------------------------------------------------
// 3. PartyLive 派对直播
//
// ★★ 数据入口只有一个:S2C 推送 SyncPartyResponse(68456156)。
// PartyLiveStreamPanelStore.lua 自身 grep gClientToGameDelegate = <b>0 命中</b>,
// 一切都挂在 gEventConstants.ON_SYNC_PARTY_RESPONSE 上,而它唯一的发送点是
// PartyManager.lua:702 OnSyncResponse ⇒ 来自 SyncPartyResponse。
//
// ⛑⛑ likeList / giftList 的元素<b>不是计数,是观众 npcId</b>
// (ProcessGiftList:212-240:`for _, npcId in ipairs(giftList)`,出现一次 = 该观众又送了一份,
// 客户端自己 `data.count = data.count + 1` 累加)。写反了就永远显示没人送礼。
// ⛑ npcId 是 u64,客户端过 `gFurnitureUtils:ConvertServerUidToNumber(npcInfo.NpcId)` 后再
// 去 npcIdList 花名册里反查 ⇒ 不在这份花名册里的 id 会被直接丢掉(面板上没反应)。
//
// C2S 三条:
// SendPartyEvent(63765017) args: int32 eventid, string comment
// PartyLiveSwitch(63948406) args: bool start
// GmPartyLiveAddEvent(65785804) args: int32 eventid, string message ← GM 通道
// ---------------------------------------------------------------------------------------
private const string PartyLiveLedgerKey = "PartyLive.Ledger";
private const int PartyLiveMaxRows = 64;
/// <summary>每个 session 一份直播间账本。没有 session 时返回一个丢弃用的空壳。</summary>
private static PartyLiveResponsePush PartyLiveLedger(Dictionary<string, object>? bag)
{
if (bag is null)
return new PartyLiveResponsePush();
if (bag.TryGetValue(PartyLiveLedgerKey, out var existing) && existing is PartyLiveResponsePush ledger)
return ledger;
var fresh = new PartyLiveResponsePush();
bag[PartyLiveLedgerKey] = fresh;
return fresh;
}
private Task PushPartyLiveAsync(Connection conn, PartyLiveResponsePush response, string why)
{
conn.Log.Info(
$"[PARTYLIVE] {why} -> SyncPartyResponse like={response.LikeList.Count} " +
$"gift={response.GiftList.Count} popularity={response.Popularity} msg={response.NPCMessage.Count}");
return conn.NotifyAsync(MethodId.SyncPartyResponse, new SyncPartyResponsePush
{
Response = response,
NPCIds = new List<PartyNpcIdInfoPush>(),
});
}
[Handler(MethodId.PartyLiveSwitch, HandlerPacketKind.Invoke)]
private async Task PartyLiveSwitch4229938(Connection conn, UxRpcMessage msg)
{
// 单个 bool:`SerializeBase.WritePrimitive(writer, start, writer.WriteBoolean, false)`
var start = msg.Body.Length > 0 && msg.Body[0] != 0;
conn.Log.Info($"[PARTYLIVE] switch start={start}");
var bag = msg.Context?.Session?.Items;
if (start && bag is not null)
{
// 开播推一次空账本:客户端是纯追加渲染,不会自己清上一次会话残留的礼物提示。
bag[PartyLiveLedgerKey] = new PartyLiveResponsePush();
await PushPartyLiveAsync(conn, new PartyLiveResponsePush(), "switch on (clear)");
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.SendPartyEvent, HandlerPacketKind.Invoke)]
private async Task SendPartyEvent4229938(Connection conn, UxRpcMessage msg)
{
ReadInt32AndString(msg.Body, out var eventId, out var comment);
var bag = msg.Context?.Session?.Items;
var ledger = PartyLiveLedger(bag);
// PartyLiveStreamPanelStore.lua:发送的是 LTConfig.PartyPartyEventConfig.AskForLike 等事件 id。
// 服务端不猜客户端表里哪个 id 是点赞/送礼(我们手上没有 PartyPartyEventConfig),
// ⇒ 按符号分流:正 id 记进 likeList、负 id 取绝对值记进 giftList。
// GM 想指定走礼物就从 GmPartyLiveAddEvent 发负 eventId。
lock (ledger)
{
if (eventId >= 0)
AppendRow(ledger.LikeList, eventId);
else
AppendRow(ledger.GiftList, -eventId);
ledger.Popularity = (uint)(ledger.LikeList.Count + (ledger.GiftList.Count * 3));
}
await PushPartyLiveAsync(conn, ledger, $"event id={eventId} comment={comment}");
await conn.ReturnEmptyOkAsync(msg);
}
// GmPartyLiveAddEvent (65785804) — Invoke ; args: int32 eventid, string message ; return: void
//
// ★ GM 通道。官方留它是给自己调试用的;我们拿它做「服务端往直播间塞一条弹幕」。
// 这是三个玩法里最容易现场见效的一处(不用改 GM 面板,GATE Lua / GM delegate 直接发即可)。
[Handler(MethodId.GmPartyLiveAddEvent, HandlerPacketKind.Invoke)]
private async Task GmPartyLiveAddEvent4229938(Connection conn, UxRpcMessage msg)
{
ReadInt32AndString(msg.Body, out var eventId, out var message);
var bag = msg.Context?.Session?.Items;
var ledger = PartyLiveLedger(bag);
lock (ledger)
{
ledger.NPCMessage.Add(new PartyNpcLiveMessagePush { Id = eventId, Message = message });
if (ledger.NPCMessage.Count > PartyLiveMaxRows)
ledger.NPCMessage.RemoveRange(0, ledger.NPCMessage.Count - PartyLiveMaxRows);
}
await PushPartyLiveAsync(conn, ledger, $"gm event id={eventId} msg={message}");
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// 读 <c>int32 eventid + string</c>。string 的形状是 <c>ReadString</c>:
/// 无值写单字节 0x00,有值写 0xFF 后跟 UTF8 裸字节。
/// </summary>
private static void ReadInt32AndString(byte[] body, out int eventId, out string text)
{
eventId = 0;
text = string.Empty;
if (body.Length < 4)
return;
eventId = BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(0, 4));
var pos = 4;
if (pos >= body.Length)
return;
if (body[pos] == 0)
return;
pos += 1;
if (pos < body.Length)
text = Encoding.UTF8.GetString(body, pos, body.Length - pos);
}
/// <summary>往「累计送礼表」追加一行。<b>每个元素是一次事件</b>,不去重也不断言 id 范围。</summary>
private static void AppendRow(List<int> list, int id)
{
list.Add(id);
if (list.Count > PartyLiveMaxRows)
list.RemoveRange(0, list.Count - PartyLiveMaxRows);
}
private static uint U(List<uint> list, int index) => index < list.Count ? list[index] : 0u;
}