using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// 里希**押送**:把犯人押上警车、开车带到牢房、再押下来收押(2026-09-30 新增)。 /// /// ★ 捐赠端 v0.5-2 确实做得到,而且它用的两条帧主线全都具备: /// /// SyncUnitVehicleStatus(68195719) —— NewClientBoardingInfo, /// Status:4=已上车 / 5=正在下车 / 0=不在车上。主线 /// GameRouter.VehicleStory.cs 本来就在用它(玩家上下车); /// SyncUnitBoardingInfo(68699595) —— NPC 的座位挂载句柄 /// (LogicAgentAttachVehicleHandle.Setup:原生座位偏移/旋转,attach 句柄每帧跟随车辆)。 /// ⛑ VehicleUId=0 会同时解除挂载与原生座位注册 ⇒ 下车就用它。 /// /// ⇒ 捐赠端 GameRouter.PoliceVehicle.cs:93-103 的 SendPolicePassengerBinding4229938 /// 就是这两条;主线 mid 一个在用、一个从未用过(全仓零调用点), /// **不需要 GATE Lua 通道** ⇒ 这一条主线能做,跟牢房那步不一样。 /// /// 与捐赠端的差异(照旧:不做演出,做功能): /// /// 捐赠端触发的是原生押送过场 /// (gPoliceJobManager.cs:AskPoliceEscortInteract(agent, 20) → /// SEscortToVehicleInteract,映射表 LixiPolice.cs:445 的 20 => 20001), /// 有"押着人走到车边、开门、塞进去"的动画; /// 主线直接发绑定帧 ⇒ 犯人直接出现在座位上,没有过场动画。 /// 跟出租车/牢房同一个取舍:先要功能,动画等通道。 /// /// /// ★ SyncUnitBoardingInfo 的契约已按规则 239 核过(2026-09-30): /// 客户端把 68699595 标成 CSharpOnly(RPCDeserializeAuto.lua:1153) /// ⇒ Lua 侧没有明文 Reader,改走 C# 侧取证 —— /// ux_game_all.dump.cs:22762 ClientBoardingInfo 字段布局 + 签名第二个参数 /// bool force。主线据此建了 ClientBoardingInfo4229938 / /// SyncUnitBoardingInfo4229938 两个 DTO(不再手写字节), /// 序列化结果与捐赠端 PoliceVehicle.cs:82-91 的手写字节逐字节相同, /// 且已进 _ux_wire_check 做 53 B 回归断言。 /// internal sealed partial class GameRouter { private static PolicePrisonSettings EscortCfg => PrivateServerConfigStore.Current.Gameplay.PolicePrison; private const string PoliceEscortStateKey = "client4229938-police-escort"; private sealed class PoliceEscortState { /// 坐在车上的犯人 uid;0 = 车上没人。 public ulong AgentUid; public ulong VehicleUid; public byte Seat; } private static PoliceEscortState EscortStateOf(TcpSession session) { if (session.Items.TryGetValue(PoliceEscortStateKey, out var raw) && raw is PoliceEscortState st) return st; st = new PoliceEscortState(); session.Items[PoliceEscortStateKey] = st; return st; } /// /// SyncUnitBoardingInfo(68699595) 全包(info + force,53 B)。 /// 走 序列化,不再手写字节; /// vehicle=0 ⇒ 解除挂载。 /// private static byte[] BoardingInfoWire(ulong agent, ulong vehicle, byte seat) => UxSerializer.Serialize(new GameMethods.SyncUnitBoardingInfo4229938 { info = new GameMethods.ClientBoardingInfo4229938 { EntityId = agent, VehicleUId = vehicle, SeatIndex = seat, PositionOffset = default, RotationOffset = default, CanBeEjected = false, UseSpecificAction = false, ActionGroup = 0, ActionId = 0, }, force = true, }); /// 发这两条绑定帧。 = false ⇒ 解除(VehicleUId=0)。 private static async Task SendPassengerBindingAsync( TcpSession session, ulong agent, ulong vehicle, byte seat, bool board) { await session.NotifyAsync(MethodId.SyncUnitVehicleStatus, UxSerializer.Serialize( new GameMethods.NewClientBoardingInfo4229938 { EntityId = agent, VehicleUId = vehicle, SeatIndex = seat, Status = board ? (byte)4 : (byte)0, ExtInfo = null, }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncUnitBoardingInfo, BoardingInfoWire(agent, board ? vehicle : 0UL, seat), CancellationToken.None); } // ------------------------------------------------------------------ 押上车 /// /// 把 押上 的 /// 号座(0 = 主驾,犯人应该坐 1..SeatCount-1)。 /// internal static async Task<(bool Ok, string Message)> EscortBoardAsync( TcpSession session, ulong agentUid, ulong vehicleUid, int seat) { if (!EscortCfg.Enabled) return (false, "gameplay.policePrison.enabled=false(押送与牢房共用一个开关)"); if (agentUid == 0) return (false, "请先填要押送的 NPC uid(用「生成一个 NPC 并盘查」刷一个)。"); var st = EscortStateOf(session); if (st.AgentUid != 0) return (false, $"车上已经有 uid={st.AgentUid}(座位 {st.Seat}),先「押下车」再换人。"); // 犯人必须在服务端账本里 —— 城里绝大多数路人是客户端本地实体,服务端点不了名。 if (!TryFindInLedger(session, agentUid, out var ledger, out _)) return (false, $"uid={agentUid} 不在服务端账本(多半是客户端本地路人),无法押送;" + "请用 GM 召唤的 NPC。"); VehicleRecord? vehicle; var runtime = GetVehicleRuntimeState(session); lock (runtime.SyncRoot) runtime.Vehicles.TryGetValue(vehicleUid, out vehicle); if (vehicle is null) return (false, $"uid={vehicleUid} 不在服务端车辆账本里;请先用 GM 面板召唤一辆车再押送。"); if (!vehicle.Interactable) return (false, $"车辆 {vehicleUid} 当前不可交互。"); if (seat <= 0 || seat >= vehicle.SeatCount) return (false, $"座位号 {seat} 不合法(这车 {vehicle.SeatCount} 座;0 是主驾,犯人坐 1..{vehicle.SeatCount - 1})。"); lock (runtime.SyncRoot) { if (vehicle.SeatOccupants.ContainsKey((byte)seat) || vehicle.SeatReservations.ContainsKey((byte)seat)) return (false, $"座位 {seat} 已经被占了。"); vehicle.SeatOccupants[(byte)seat] = agentUid; } await SendPassengerBindingAsync(session, agentUid, vehicleUid, (byte)seat, board: true); st.AgentUid = agentUid; st.VehicleUid = vehicleUid; st.Seat = (byte)seat; session.Log.Info($"[POLICE-ESCORT] board agent={agentUid} ledger={ledger} vehicle={vehicleUid} seat={seat}"); return (true, $"已把 uid={agentUid}({ledger})押上 {vehicleUid} 的 {seat} 号座;" + "现在你自己开车去牢房,到了点「押下车」再「收押进牢房」。"); } // ------------------------------------------------------------------ 押下车 /// 把车上的犯人押下来,放在车旁(捐赠端用 seat 奇偶决定左右 ±2 m)。 internal static async Task<(bool Ok, string Message)> EscortAlightAsync(TcpSession session) { if (!EscortCfg.Enabled) return (false, "gameplay.policePrison.enabled=false(押送与牢房共用一个开关)"); var st = EscortStateOf(session); if (st.AgentUid == 0) return (false, "车上没有本次押送的犯人。"); var agent = st.AgentUid; var vehicleUid = st.VehicleUid; var seat = st.Seat; VehicleRecord? vehicle; var runtime = GetVehicleRuntimeState(session); lock (runtime.SyncRoot) runtime.Vehicles.TryGetValue(vehicleUid, out vehicle); // ① 先解除挂载:捐赠端 PreparePoliceAlighting 的"handoff" —— // VehicleUId=0 清掉 attach 句柄,再用 Status=5(Leaving)让原生动作接手。 await session.NotifyAsync(MethodId.SyncUnitBoardingInfo, BoardingInfoWire(agent, 0, seat), CancellationToken.None); await session.NotifyAsync(MethodId.SyncUnitVehicleStatus, UxSerializer.Serialize( new GameMethods.NewClientBoardingInfo4229938 { EntityId = agent, VehicleUId = vehicleUid, SeatIndex = seat, Status = 5, ExtInfo = null, }), CancellationToken.None); // ② 落点:车旁 2 m(捐赠端 `p.Seat % 2 == 0 ? +2 : -2`),别把人留在车中心。 var pos = vehicle is not null ? new Vec3(vehicle.Position.X + (seat % 2 == 0 ? 2f : -2f), vehicle.Position.Y, vehicle.Position.Z) : new Vec3(float.NaN, float.NaN, float.NaN); if (float.IsFinite(pos.X)) { await session.NotifyAsync(MethodId.SyncUnitPosition_P, UxSerializer.Serialize( WorldCodec.PositionP(agent, pos, 0, WorldCodec.SetPositionType.Teleport)), CancellationToken.None); RewriteLedgerPosition(session, agent, pos, stopWalking: true); } // ③ 清状态(先清再解绑,避免中断时残留"幽灵乘客")。 await SendPassengerBindingAsync(session, agent, vehicleUid, seat, board: false); lock (runtime.SyncRoot) { if (runtime.Vehicles.TryGetValue(vehicleUid, out var v2)) v2.SeatOccupants.Remove(seat); } st.AgentUid = 0; st.VehicleUid = 0; st.Seat = 0; session.Log.Info($"[POLICE-ESCORT] alight agent={agent} vehicle={vehicleUid} seat={seat} " + $"pos=({pos.X:F1},{pos.Y:F1},{pos.Z:F1})"); return (true, $"已把 uid={agent} 押下车,落在车旁 ({pos.X:F1},{pos.Z:F1});" + "现在可以点「收押进牢房」(玩家要在牢房门口 4 m 内)。"); } /// 押送状态(给 GM 面板显示)。 internal static (bool Enabled, ulong Agent, ulong Vehicle, int Seat) EscortStatus(TcpSession session) { var st = EscortStateOf(session); return (EscortCfg.Enabled, st.AgentUid, st.VehicleUid, st.Seat); } }