using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 厕所 QTE / 马桶鸵鸟骑乘(2026-09-30 新增)。 /// /// ★ 玩法本体:ToiletOstrichGamePanelStore.lua —— 骑上马桶鸵鸟后弹一个音游式三键 QTE /// (QTE_DURATION=2 / PERFECT_WINDOW=0.1 / GREAT_WINDOW=0.2,满分 100), /// 失败时鸵鸟逃跑。面板与判定全在客户端,服务端要做的是「让骑乘坐实」+「接住逃跑上报」。 /// /// ★ 触发链(三条证据实锤,见 的契约注释) /// /// 面板由 C#→lua 回调打开:CsToLuaHandler.lua 的 /// module.OnStartRideQTE(riderPid, beRidenPid) ⇒ /// gPanelManager:CheckShow(gPanelId.TOILET_OSTRICH_GAME_PANEL, { npcPid = beRidenPid })。 /// 也就是说 C# 认定「骑乘成立」时才 fire,lua 只是被动开面板。 /// 面板里 SceneDataMgr.GetUnit(self.npcPid) ⇒ 被骑的那只必须是世界里真实存在的 unit。 /// 失败分支会主动上报 AskToiletOstrichFlee;成功分支只发本地信号 5753。 /// /// /// ★ 为什么不需要新的生成通道 /// 马桶鸵鸟(40122028 / 40652007 / 40922011 / 42046401 / 45000446,规范 id /// AgentConfig.ToiletOstrich = 42046401)在 NpcCatalog.json 里是标准 Agent /// (GeneralModelId=86963029、EnemyClassType=1、faction 18000300「通用怪物」), /// 而且 AgentSummonCatalog.json 里本来就有它 ⇒ 直接用既有的服务端直生成通道 /// (GameAdminBridge.SpawnAgentAsync)就能出,客户端 ai 状态树 /// (MassAI.Npc.MassAetherAI.Tasks.TryOnRideAgent / TryDownRideAgent,走 /// BehaviorActionConfigId 40214321 + AISetting 40213146)自带骑乘能力,服务端不用给标记。 /// 面板入口见 GameAdminBridge.AgentSummonCatalog(它被置顶到「生成敌人」下拉框第一位)。 /// /// ★ 一次只动一个(实测顺序,按这个走) /// /// 生成 42046401 → 走到旁边按骑乘。 /// 面板出来了 ⇒ 本文件的默认配置已经够用,接下来看 /// [TOILET] 日志里 flee 出现时鸵鸟是否正确消失。 /// 面板不出来 ⇒ 把 gameplay.toiletQte.pushRideState 打开(推 /// SyncRideAgent(rider, ridee, true)),这与自动驾驶那条「服务端推状态」是同一个模式; /// ⛑ 注意 GameRouter.VehicleNav.cs §43.1 的包序教训:状态包要排在动作包之前, /// 所以这里也是先推状态、再回 ack。 /// /// /// ⛑ 回滚:把本文件涉及的三个 mid 从 GameRouter.cs 的 /// Enabled4229938MethodIds 里删掉即可(handler 不再注册 = 回到 /// ReturnEmptyOkAsync / DefaultUnknownNotify 兜底,不在场时行为与今天之前一致); /// 或者一个开关 gameplay.toiletQte.enabled=false 只关「回头推 + 回收」,不动注册。 /// internal sealed partial class GameRouter { private static ToiletQteSettings ToiletCfg => PrivateServerConfigStore.Current.Gameplay.ToiletQte; /// /// 马桶鸵鸟的全部模板 id(NpcCatalog.json / AgentSummonCatalog.json 里同名同模 /// GeneralModelId=86963029 的 5 条)。42046401 是客户端常量 /// AgentConfig.ToiletOstrich,也是面板下拉框置顶后用的那个 id。 /// private static readonly uint[] ToiletOstrichAgentIds = [40122028, 40652007, 40922011, 42046401, 45000446]; /// 模板是不是马桶鸵鸟。GameAdminBridge.SpawnAgentAsync 用它决定「生成成坐骑还是生成成敌人」。 internal static bool IsToiletOstrichAgent(uint agentId) => Array.IndexOf(ToiletOstrichAgentIds, agentId) >= 0; private const string ToiletMountKey = "client4229938-toilet-mounts"; // --------------------------------------------------------------------------------- // ★★ 骑乘的完整四步(2026-09-30 19:2x 补完,来源:v0.5-2 捐赠端 GameRouter.HackerDevices.cs:254-282) // // "Native Mount() -> AskRideAgent is separate from AskControlAgent/DoHackEnemy. // UnitStateConfig 345 (BeRiddenS, behavior 10345) enters the mount's riding // movement state; 346 marks the rider. An empty successful return alone does // not publish either state. Do not replace this with a remote camera switch." // // 四步: // ① SyncManagedLogicAgent(坐骑, ownerPid, 0) —— ⛑ 我们**不需要在上马时发**: // GameAdminBridge.SpawnAgentAsync 生成时已经发过同一条(GameAdminBridge.cs:3155, // 参数 agentId=uid / pid=ownerPid / moveId=0,与捐赠端逐字段一致)。 // ② SyncRideAgent(rider, ridee, hasRideOn) 17B // ③ SyncAddUnitState(坐骑, 345) ← 被骑移动状态=驯服动画/能跑起来的开关 // ④ SyncAddUnitState(骑手, 346) ← 骑手标记 // 下马 = 反向:SyncRemoveUnitState 撤掉 345/346 + SyncRideAgent(hasRideOn=false)。 // // ⛑ 只推 ② 不推 ③④(上一版就是这样)的表现正是用户反馈的 // 「QTE 正常,但没有驯服动画,成功也骑不起来」—— 坐骑从来没进入 BeRiddenS。 // --------------------------------------------------------------------------------- /// UnitStateConfig 345 = BeRiddenS(behavior 10345):坐骑进入「被骑」移动状态。 private const uint UnitStateBeRidden = 345; /// UnitStateConfig 346:骑手标记(挂在骑手单位上)。 private const uint UnitStateRider = 346; private const string ToiletRideKey = "client4229938-toilet-riding"; /// 当前正骑着哪只(session 级)。下马/逃跑时要靠它知道该撤哪个 uid 的状态。 private static ulong GetRidingMount(TcpSession session) => session.Items.TryGetValue(ToiletRideKey, out var raw) && raw is ulong uid ? uid : 0UL; private static void SetRidingMount(TcpSession session, ulong uid) { if (uid == 0) session.Items.Remove(ToiletRideKey); else session.Items[ToiletRideKey] = uid; } /// /// 建立/解除完整骑乘状态(② + ③④)。顺序照抄捐赠端:SyncRideAgent 先,345/346 后。 /// ⛑⛑ 必须在 ReturnEmptyOkAsync **之前**发完 —— §43.1 的包序教训。 /// private static async Task PublishToiletRideAsync(Connection conn, ulong rider, ulong mount, bool enter) { if (rider == 0 || mount == 0) return; await conn.NotifyAsync(MethodId.SyncRideAgent, new GameMethods.SyncRideAgent4229938 { rider = rider, ridee = mount, hasRideOn = enter }); if (ToiletCfg.PushUnitStates) { foreach (var (unit, state) in new[] { (mount, UnitStateBeRidden), (rider, UnitStateRider) }) { if (enter) await conn.NotifyAsync(MethodId.SyncAddUnitState, new GameMethods.SyncAddUnitStateArgs4229938 { Id = unit, State = state, EffectFreezeState = 0 }); else await conn.NotifyAsync(MethodId.SyncRemoveUnitState, new SceneMethods.SyncRemoveUnitState { unitId = unit, state = state }); } } SetRidingMount(conn.Session, enter ? mount : 0UL); conn.Log.Info($"[TOILET] ride-state enter={enter} rider={rider} mount={mount} unitStates={(ToiletCfg.PushUnitStates ? "345/346" : "off")}"); // ★★ 2026-10-02 新增:原生坐骑操控桥(加速 / 跳跃),见 GameRouter.ToiletControls.cs。 // enter=true 下发 arm(装 HUD 钩子并武装转发);下马 / QTE 失败 / 逃跑下发 clear(卸载 + 补 Release)。 // ⛑ 状态包已经发完才轮到它 —— Lua 不是状态,晚一点发不影响骑乘成立。 if (ToiletCfg.NativeControls) await PublishToiletControls4229938(conn.Session, rider, mount, enter ? ToiletControlsArm : ToiletControlsClear); } /// /// uid → 模板 id,**只为马桶鸵鸟**记一条。 /// /// ⛑ 为什么不直接查敌人账本:坐骑模式是 engage=false(不下令开战、不登记进 /// LocalEnemyRuntimeState.Enemies),账本里根本没有它;而 AskRideAgent / /// AskToiletOstrichFlee 收到的载荷只有 unit uid,没有模板 id ⇒ 日志里 /// isOstrich=? 就再也判不出来了。 /// 会话销毁即失效(存在 session.Items 里),本来也是一次性的东西。 /// private static Dictionary ToiletMounts(TcpSession session) { if (session.Items.TryGetValue(ToiletMountKey, out var raw) && raw is Dictionary existing) return existing; var created = new Dictionary(); session.Items[ToiletMountKey] = created; return created; } /// 面板生成马桶鸵鸟时登记一条(由 GameAdminBridge.SpawnAgentAsync 调用)。 internal static void NoteToiletMountSpawned(TcpSession session, ulong uid, uint agentId) { if (uid == 0 || !IsToiletOstrichAgent(agentId)) return; var map = ToiletMounts(session); lock (map) map[uid] = agentId; } /// /// uid 是不是本服自己生成的(GameAdminBridge.ServerSpawnUidBase 起的池子)。 /// /// ⛑⛑ 2026-09-30 实测事故:AskToiletOstrichFlee 送来过 uid=19000, /// 而 19000 是场景自带的真实敌人([LENEMY] ... uid=19000 serverSpawned=False hp=1500, /// 在 -4739,169,-2828),当时代码无条件回收 ⇒ 把一只无辜的场景敌人删了。 /// 根因是 AskRideAgent 我们一律回 0 字节成功,客户端于是对任何可按 F 的单位 /// 都 fire OnStartRideQTE,失败后又对那个单位发 flee。 /// /// ⇒ 回收的硬门槛改成「只回收本服生成的单位」:客户端这条 Notify 只在马桶 QTE /// 失败时发,而我们的马桶一定是我们自己 spawn 的,两条判据交集足够;场景单位落在池子外, /// 天然被排除。ServerSpawnUidBase = 0x200000000000(= 35184372088832)远高于任何 /// 场景单位 uid。 /// private static bool IsServerSpawnedUid(ulong uid) => uid >= GameAdminBridge.ServerSpawnUidBase; // ======================================================================================== // AskRideAgent(67575069) —— C2S Invoke,载荷 ulong agentId,**回包必须是 0 字节** // ======================================================================================== [Handler(MethodId.AskRideAgent)] private async Task AskRideAgent4229938Handler(Connection conn, UxRpcMessage msg) { var agentId = TryReadRideTarget(msg); var rider = CurrentRiderUnit(conn.Session); var template = LookupEnemyTemplate(conn.Session, agentId); var isOstrich = IsToiletOstrichAgent(template); conn.Log.Info($"[TOILET] ride-request ridee={agentId} template={(template == 0 ? "?" : template.ToString())}" + $" isOstrich={isOstrich} rider={rider} cfg(enabled={ToiletCfg.Enabled} pushRideState={ToiletCfg.PushRideState})"); if (ToiletCfg.Enabled && ToiletCfg.PushRideState && rider != 0 && agentId != 0) { // ⛑ 先推状态、再回 ack(§43.1:状态包必须在动作包之前)。 // 完整四步见 PublishToiletRideAsync 的注释:① 生成时已发 ②③④ 在这里。 await PublishToiletRideAsync(conn, rider, agentId, true); } if (ToiletCfg.Enabled && ToiletCfg.PushPosture && agentId != 0) { await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncToiletPosture, new GameMethods.SyncToiletPosture4229938 { agentId = agentId, isSquatting = false }); } // 回包函数体在客户端是空的(RPCDeserializeAuto.lua:15516)⇒ 0 字节才是正确形状。 await conn.ReturnEmptyOkAsync(msg); } // ======================================================================================== // AskStopRideAgent(67449345) —— C2S Invoke,**无参**,回包也是 0 字节 // ======================================================================================== [Handler(MethodId.AskStopRideAgent)] private async Task AskStopRideAgent4229938Handler(Connection conn, UxRpcMessage msg) { var rider = CurrentRiderUnit(conn.Session); // ⛑ 用**真实坐骑 uid**,别发 ridee=0:下马要撤的是坐骑身上那条 345, // ridee=0 的话状态撤不掉(捐赠端 PublishPoliceDogRide4229938 同样传的是 runtime.Agent)。 var mount = GetRidingMount(conn.Session); conn.Log.Info($"[TOILET] ride-stop rider={rider} mount={mount} body={(msg.Body is { Length: > 0 } b ? b.Length.ToString() : "0")}B"); if (ToiletCfg.Enabled && ToiletCfg.PushRideState && rider != 0 && mount != 0) await PublishToiletRideAsync(conn, rider, mount, false); await conn.ReturnEmptyOkAsync(msg); } // ======================================================================================== // AskToiletOstrichFlee(67154315) —— C2S **Notify**,载荷 ulong agentid(QTE 失败时客户端上报) // ======================================================================================== [Handler(MethodId.AskToiletOstrichFlee)] private async Task AskToiletOstrichFlee4229938Handler(Connection conn, UxRpcMessage msg) { var agentId = TryReadFleeTarget(msg); var template = LookupEnemyTemplate(conn.Session, agentId); var ours = IsServerSpawnedUid(agentId); conn.Log.Info($"[TOILET] ostrich-flee agent={agentId} template={(template == 0 ? "?" : template.ToString())}" + $" isOstrich={IsToiletOstrichAgent(template)} serverSpawned={ours} retire={ToiletCfg.RetireOnFlee}"); if (!ToiletCfg.Enabled || agentId == 0) return; // ⛑ 第一步永远是「解除骑乘」,与要不要销毁实体无关 —— 客户端的骑乘态是本地维护的, // 服务端不推一条 enter=false(外加撤掉 345/346),它就是用户看到的「人还坐在空中」。 var rider = CurrentRiderUnit(conn.Session); if (ToiletCfg.PushRideState && rider != 0) await PublishToiletRideAsync(conn, rider, agentId, false); // ⛑⛑ 默认**不再销毁**坐骑(2026-09-30 19:2x,对齐 v0.5-2 捐赠端): // 捐赠端原注释 "retaining the test mount for retry instead of destroying it" // ⇒ 失败只是下马,马桶留在原地可以再来一次。想销毁就开 retireOnFlee。 if (!ToiletCfg.RetireOnFlee) return; // ⛑⛑ 硬门槛:只回收本服生成的单位。场景自带单位(uid 落在 spawn 池之外)一律不碰 // (2026-09-30 实测把 uid=19000 的场景敌人删掉了,见 IsServerSpawnedUid 的注释)。 if (!ours) { conn.Log.Info($"[TOILET] flee ignored: agent={agentId} is not server-spawned -> scene unit left untouched"); return; } // ⛑ SyncLogicAgentLeave(68281244) 是 SyncLogicAgentEnter(68281243) 的配对,也正是 // SpawnAgentAsync 发的第一个包 ⇒ 它就是这条生成通道的移除通道(同 GameRouter.EnemyTerritory.cs)。 // ⛑ 顺手把敌人账本里的条目也清掉:面板生成时若带了 engage,条目留着会让攻击 AI 去驱动一只 // 已经不存在的 unit,死亡/虚弱定时器也会打进空气里。 await conn.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(agentId)); ForgetTerritoryEnemy(conn.Session, agentId); conn.Log.Info($"[TOILET] retired ostrich unit={agentId}"); } // ------------------------------------------------------------------ helpers /// /// 当前「骑的人」是谁 —— 也就是 SyncRideAgent 的 rider。 /// 出场单位优先(WorldEntryControlUnit),没有就退回当前操控体 /// (ActiveSpiritUnitId,切人后它会跟着变)。拿不到返回 0(日志里就是 0,此时不推状态包)。 /// private static ulong CurrentRiderUnit(TcpSession session) { var state = GetStateIfExists(session); if (state is null) return 0UL; return state.WorldEntryControlUnit != 0 ? state.WorldEntryControlUnit : state.ActiveSpiritUnitId; } /// /// 读出 AskRideAgent 里的单位 id。 /// ⛑ AskStopRideAgent 没有载荷,别拿它来调这个方法。 /// 解析失败不能抛:这是客户端**上报**类请求,服务端炸了会把整帧链路带断(规则 168 的同族问题)。 /// private static ulong TryReadRideTarget(UxRpcMessage msg) { try { return msg.TryGetArgs(out var args) && args is not null ? args.agentId : 0UL; } catch { return 0UL; } } /// /// 读出 AskToiletOstrichFlee 里的单位 id。 /// ⛑ 两条载荷在线上字节相同(都是单个 u64),但**各用各的契约** —— 互相借会在将来某一方 /// 加字段时静默解析错位,而这里正好是「客户端上报」路径,错了只会静默丢事件。 /// private static ulong TryReadFleeTarget(UxRpcMessage msg) { try { return msg.TryGetArgs(out var args) && args is not null ? args.agentId : 0UL; } catch { return 0UL; } } /// /// uid → 模板 id。先查马桶鸵鸟登记表(坐骑模式不 engage ⇒ 不进敌人账本),再查敌人账本, /// 都没有返回 0(日志里打成 ?,那是正常的,不代表解析失败)。 /// private static uint LookupEnemyTemplate(TcpSession session, ulong uid) { if (uid == 0) return 0; var mounts = ToiletMounts(session); lock (mounts) { if (mounts.TryGetValue(uid, out var mounted)) return mounted; } var runtime = GetLocalEnemyRuntimeState(session); lock (runtime.SyncRoot) return runtime.Enemies.TryGetValue(uid, out var record) ? record.EnemyId : 0; } }