using System.Buffers.Binary; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Handlers; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// 牛奶车(gadget 版)交互的服务端那一半。 /// /// 背景(2026-10-01 实机):用 debugPlace 探针把 T_MilkCar(pathId 331844513) / /// T_Milk_Drive_mcTemp(530867541) 摆到玩家面前 6 m 后,客户端给出四个交互点: /// 车侧面=调查日报 / 联系联络员 / 道具制作,车尾=回血。 /// /// 抓包结论: /// - 车尾「回血」客户端会发 AskDrinkMilk(67579042, 0x04072CA2),body 8 字节 /// = gadget uniqueId(实测 0x5000000000000001,与 debugPlace 的 Seq 逐位吻合)。 /// - 侧面三个里「调查日报 / 道具制作」客户端一个 RPC 都不发 ⇒ 纯客户端本地演出, /// 服务端无从介入(调查日报点下去还会卡在读取演出界面,属客户端 bug,重连即可解除)。 /// - 「联系联络员」走的是通用对话线 AskStartDialog(63754798),已在 /// GameRouter.Dialog.cs 里回 0u,不需要在这里重复。 /// /// 所以本文件只做一件事:接住 AskDrinkMilk 并把血回满。 /// /// 回法沿用 GameAdminBridge.RestorePlayerHealthAsync 的既定顺序(硬规则 52): /// 先属性表(SyncUnitAttrs,带 MaxHp 分母)再绝对值(SyncUnitHp);同时把 /// state.PlayerHp 这本账一起改掉,否则下一次受击会从旧值结算、立刻又把人打死。 /// /// ⛑ 回滚:把 GameRouter.cs 白名单里的 MethodId.AskDrinkMilk 那一行删掉即可 /// (handler 不注册 ⇒ 回落到 typed-default 兜底,点回血只剩演出、不回血)。 /// internal sealed partial class GameRouter { /// /// 官方解锁链:徽章 19000150「牛奶车解锁回血」→ Buffs:[52606182], /// 52606182 = BuffConfig「解锁牛奶车回血功能」,Duration -1(永久)。 /// 目前只记录不下发:客户端并没有因为缺这个 buff 就拦住 AskDrinkMilk(实机照发), /// 所以这里不发也不会影响可用性;将来要还原「先解锁才能回血」的门槛时, /// 就在这里读 state 判定并走失败分支。 /// private const uint MilkCarUnlockBuffId = 52606182u; /// /// `IGameSceneToClient.AskDrinkMilk(ulong instanceId)` — 67579042 (0x04072CA2, dump.cs:1730509)。 /// 返回 `UXRPCTask`(void)⇒ 回空 OK。请求体实测 8 字节 = 牛奶车 gadget 的 uniqueId。 /// ⛑⛑ 必须先回包再推血:客户端 await 的是这个 void,先推血会让回包晚一拍。 /// [Handler(MethodId.AskDrinkMilk, HandlerPacketKind.Invoke)] private async Task AskDrinkMilk(Connection conn, UxRpcMessage msg) { var body = msg.Body.ToArray(); ulong instanceId = body.Length >= 8 ? BinaryPrimitives.ReadUInt64LittleEndian(body) : 0UL; conn.Log.Info($"[MILKCAR] drink uid=0x{instanceId:X} bytes={body.Length} hex={Convert.ToHexString(body)}"); await conn.ReturnEmptyOkAsync(msg); var state = GetStateIfExists(conn.Session); if (state is null) { conn.Log.Warn($"[MILKCAR] heal-skipped reason=no-world-state uid=0x{instanceId:X}"); return; } ulong unit; lock (state.SyncRoot) { state.PlayerHp = CombatCodec.MaxHp; state.PlayerDowned = false; unit = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; } // 硬规则 52:属性表(MaxHp 分母)在前,绝对值在后。 await conn.Session.NotifyAsync(MethodId.SyncUnitAttrs, UxSerializer.Serialize(CombatCodec.UnitAttrs(unit, CombatCodec.MaxHp)), CancellationToken.None); await conn.Session.NotifyAsync(MethodId.SyncUnitHp, UxSerializer.Serialize(CombatCodec.UnitHp(unit, CombatCodec.MaxHp)), CancellationToken.None); conn.Log.Info($"[MILKCAR] heal unit={unit} hp={CombatCodec.MaxHp:F0} from=uid0x{instanceId:X}"); } }