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;
///
/// 音乐 / 演出线三条玩法的服务端闭环 —— 2026-10-04:
///
/// - 1. Livehouse 音游(节奏打击,引擎 MusicGameManager.lua)
/// - 2. PartyDance 舞池(同一套音游引擎,LivehouseConfig.Use == Party 那 5 首)
/// - 3. PartyLive 派对直播(点赞 / 礼物 / 弹幕,PartyLiveStreamPanelStore.lua)
///
///
///
/// ★★ 接之前这三条线全套落在空包兜底里(DefaultReturnCatalog4229938.cs):
/// 63885626 → [0xFF,0x01] list7-empty、63848409 → [0xFF,0x00] ReadBoolean、
/// 其余 shape="void"。症状不是报错,而是静默没内容:
/// · 曲目列表恒空 ⇒ 面板上的等级打点永远是 0,重进清零;
/// · FirstPlay 恒 false ⇒ 首次引导 timeline 永远不播;
/// · 舞会三连里 AskStartPartyDance 的 Callback 什么都不做,客户端在等
/// SyncPartyDanceStart 推送 ⇒ 不推 = 点了没反应;
/// · 直播间 UI 自己不发 RPC,数据全靠 SyncPartyResponse(68456156) ⇒ 从不推 = 空直播间。
///
///
///
/// ⛑ 硬规则 239 —— 每个契约都对照客户端自己的反序列化器核过,不是照 dump.cs 猜:
/// Drmk.Proxy\proxy\lua\RPCDeserializeAuto.lua 的 Auto.Reader[571] /
/// [568] / [428] / [429] / [1123],
/// 参数形状对照 _lua_all\lua\LuaGen\AutoGen\ClientToGameDelegate.lua 的 *_Serializer。
///
///
/// ⛑ 回滚:从 GameRouter.cs 的 Enabled4229938MethodIds 里删掉对应行即可
/// (handler 不注册 = 回到中性兜底),本文件留着无害。
///
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。
// ---------------------------------------------------------------------------------------
/// 取登录后才能拿到的 WorldEntryState;还没建好就是 null(按新账号处理)。
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
//
// 客户端把整张表灌进 gMusicGameManager.RecordMusicInfo,之后 GetScoreLevel 才显示
// 非最低档评级。空列表客户端不报错,只是等级打点全 0 —— 别指望有日志线索。
[Handler(MethodId.AskLiveHouseMusicList, HandlerPacketKind.Invoke)]
private Task AskLiveHouseMusicList4229938(Connection conn, UxRpcMessage msg)
{
var state = LivehouseState(msg);
var records = state is null
? new Dictionary()
: LivehouseProgressStore.Snapshot(state);
var list = new List();
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
{
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 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();
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 而不会走到这里(: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 早就写好了
// (PartyWireContracts.cs:306 / :356),但全仓零使用 ⇒ 舞会从来没真正开始过。
//
// 客户端三连(ChooseDanceMusicPanelStore.lua:343-384 RequestStartPartyDance):
// AskSelectPartyDanceSong(musicId) → AskSelectPartyDanceMode(mode) → AskStartPartyDance()
// ⛑ 规则 257:那三处 `if err == MessageConfig.Ok then DisplayServerMessageId; return end`
// 要按「err != Ok 才中断」读(反混淆把比较符翻了),否则下一步永远不发。
// 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 = 0 命中,
// 一切都挂在 gEventConstants.ON_SYNC_PARTY_RESPONSE 上,而它唯一的发送点是
// PartyManager.lua:702 OnSyncResponse ⇒ 来自 SyncPartyResponse。
//
// ⛑⛑ likeList / giftList 的元素不是计数,是观众 npcId
// (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;
/// 每个 session 一份直播间账本。没有 session 时返回一个丢弃用的空壳。
private static PartyLiveResponsePush PartyLiveLedger(Dictionary? 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(),
});
}
[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);
}
///
/// 读 int32 eventid + string。string 的形状是 ReadString:
/// 无值写单字节 0x00,有值写 0xFF 后跟 UTF8 裸字节。
///
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);
}
/// 往「累计送礼表」追加一行。每个元素是一次事件,不去重也不断言 id 范围。
private static void AppendRow(List list, int id)
{
list.Add(id);
if (list.Count > PartyLiveMaxRows)
list.RemoveRange(0, list.Count - PartyLiveMaxRows);
}
private static uint U(List list, int index) => index < list.Count ? list[index] : 0u;
}