using Ananta.SDK.Rpc; using Ananta.Server.Protocol.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 环境 NPC 的剧情 / 刺激事件收尾(2026-10-02 自 7k-v0.6 捐赠端 /// GameRouter.Plot.cs 移植,三个全是 Notify)。 /// /// ★ 它不驱动任务剧情,只维护一个账本 /// WorldEntryState.StaticNpcPreparedPlotEvents[agentEntityId] /// (= 这个环境 NPC 当前挂着哪条剧情事件,0 表示没有)。 /// 这条账本主线早就有了(WorldEntryState.cs:27,注释里就写着 /// "GameRouter.Plot uses it",但主线一直没有 Plot 文件 ⇒ 从来没人真正消费它)。 /// 铺市民 / DonorNpc / FunctionalNpc / RosterNpc 在生成时都会 [id] = 0 播种, /// 骰子对手离场时 Remove(id)。 /// /// ★ 三条的语义: /// /// AskPreparePlotEvent(67966346):客户端说「这个 NPC 准备演 event X」⇒ 记账。 /// AskEndPreparePlotEvent(67025961):准备结束 ⇒ 清账。 /// AskTriggerPlotEvent(67895788):演完了,进入服务端反应阶段。 /// 我们的环境 NPC 没有剧情后续,所以显式把它关掉 —— 发 /// SyncPlotCurrentEvent(AgentEntityId, EventId=0),让客户端 /// SelectStimOnline 不再拿「已经演完的那条事件」的优先级下限去压新的刺激。 /// 只有账本里 pending 的那条对得上才发(TryCompleteStaticNpcPlot)。 /// /// /// ⛑ 游戏里的可见效果:环境 NPC 演完一次反应(被撞、被喊、盘查反应……)之后 /// 能回到待机、能接受下一次刺激,而不是卡在那条已完成的反应上不理人。 /// /// ⛑ 坐标必须是有限数(HasFinitePosition):捐赠端用这个挡掉 /// 客户端偶尔发出来的 NaN / Infinity,避免日志和下游逻辑被脏数据带偏。 /// /// ⛑ 回滚:把三个 MethodId.* 从 GameRouter.cs 的 /// Enabled4229938MethodIds 里删掉即可 —— Notify 不回包, /// 不注册只是回到 DefaultUnknownNotify4229938(接受 + 打日志),不会卡客户端。 /// internal sealed partial class GameRouter { [Handler(MethodId.AskPreparePlotEvent, HandlerPacketKind.Notify)] private Task AskPreparePlotEvent(Connection conn, UxRpcMessage msg) { if (msg.Body.Length != SceneMethods.AskPreparePlotEventArgs4229938.WireSize || !msg.TryGetArgs(out var args) || args is null || !HasFinitePosition(args.Source.Source) || !HasFinitePosition(args.Source.Source2)) { conn.Log.Warn($"[PLOT] AskPreparePlotEvent invalid payload bytes={msg.Body.Length}"); return Task.CompletedTask; } conn.Log.Info($"[PLOT] AskPreparePlotEvent event={args.EventId} agent={args.AgentEntityId} " + $"response={args.ResponseId} source=[{FormatPlotTarget(args.Source.Source)}] " + $"source2=[{FormatPlotTarget(args.Source.Source2)}]"); var state = GetStateIfExists(conn.Session); if (state is not null) lock (state.SyncRoot) if (state.StaticNpcPreparedPlotEvents.ContainsKey(args.AgentEntityId)) state.StaticNpcPreparedPlotEvents[args.AgentEntityId] = args.EventId; return Task.CompletedTask; } [Handler(MethodId.AskEndPreparePlotEvent, HandlerPacketKind.Notify)] private Task AskEndPreparePlotEvent(Connection conn, UxRpcMessage msg) { if (msg.Body.Length != SceneMethods.AskEndPreparePlotEventArgs4229938.WireSize || !msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[PLOT] AskEndPreparePlotEvent invalid payload bytes={msg.Body.Length}"); return Task.CompletedTask; } conn.Log.Info($"[PLOT] AskEndPreparePlotEvent event={args.EventId} agent={args.AgentEntityId}"); TryCompleteStaticNpcPlot(GetStateIfExists(conn.Session), args.AgentEntityId, args.EventId); return Task.CompletedTask; } [Handler(MethodId.AskTriggerPlotEvent, HandlerPacketKind.Notify)] private async Task AskTriggerPlotEvent(Connection conn, UxRpcMessage msg) { if (msg.Body.Length != SceneMethods.AskTriggerPlotEventArgs4229938.WireSize || !msg.TryGetArgs(out var args) || args is null || !HasFinitePosition(args.Parameter.Source) || !HasFinitePosition(args.Parameter.Source2)) { conn.Log.Warn($"[PLOT] AskTriggerPlotEvent invalid payload bytes={msg.Body.Length}"); return; } conn.Log.Info($"[PLOT] AskTriggerPlotEvent event={args.EventId} agent={args.AgentEntityId} " + $"source=[{FormatPlotTarget(args.Parameter.Source)}] source2=[{FormatPlotTarget(args.Parameter.Source2)}] " + $"clientDmemOverrideCfgId={args.ClientDmemOverrideCfgId}"); // The native response has finished and entered its server-reaction phase. // These ambient NPCs have no plot continuation. Explicitly close it so // SelectStimOnline no longer uses the completed event's priority floor. if (TryCompleteStaticNpcPlot(GetStateIfExists(conn.Session), args.AgentEntityId, args.EventId)) { conn.Log.Info($"[PLOT] AMBIENT_RESPONSE_FINISHED agent={args.AgentEntityId} event={args.EventId} currentEvent=0"); await conn.NotifyAsync(MethodId.SyncPlotCurrentEvent, new SceneMethods.SyncPlotCurrentEvent4229938 { AgentEntityId = args.AgentEntityId }); } } /// /// 账本里这条 NPC 挂着的确实是 就清成 0(表示「演完了」)。 /// EventId == 0 或账本里没有这个 NPC ⇒ 返回 false(不关、不发 SyncPlotCurrentEvent), /// 免得把服务端没参与过的反应误关掉。 /// internal static bool TryCompleteStaticNpcPlot(WorldEntryState? state, ulong agentId, uint eventId) { if (state is null || eventId == 0) return false; lock (state.SyncRoot) { if (!state.StaticNpcPreparedPlotEvents.TryGetValue(agentId, out var pending) || pending != eventId) return false; state.StaticNpcPreparedPlotEvents[agentId] = 0; return true; } } private static bool HasFinitePosition(SceneMethods.PlotEventTarget4229938 target) => float.IsFinite(target.Position.X) && float.IsFinite(target.Position.Y) && float.IsFinite(target.Position.Z); private static string FormatPlotTarget(SceneMethods.PlotEventTarget4229938 target) => $"id={target.Id} type={target.Type} pos=({target.Position.X:F2},{target.Position.Y:F2},{target.Position.Z:F2})"; }