using System.Runtime.CompilerServices; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Handlers; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using Auto = Ananta.Server.RpcTypes.Client4229938.Auto; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; internal sealed partial class GameRouter { private const uint LixiSpirit4229938 = 15021021; private const uint PoliceRootNid4229938 = 10; private const string PoliceGateSessionKey4229938 = "lixi-police-gate"; private const string PolicePreparedMessage4229938 = "PolicePrepared"; private static long s_policeSupportId = 0x320000000000; private static readonly ConditionalWeakTable PoliceRuntimes = new(); private sealed class PoliceRuntime4229938 { internal readonly SemaphoreSlim Gate = new(1, 1); internal readonly Dictionary Npcs = new(); internal readonly List Support = []; internal readonly Dictionary Cooldowns = new(); internal bool RootSent; internal int Generation = -1; internal uint NextNode = 0x20000, Child, LastSection; internal bool HasSection; internal DateTime EntryStartedUtc; internal ulong Agent, Owner; internal long Serial; internal string? PrepareNonce; internal ulong PrepareTarget, PrepareOwner; internal DateTime PrepareDeadline; internal readonly Dictionary Passengers = new(); internal PolicePassenger4229938? PendingBoarding; internal bool Alighting; internal uint LastInteraction; internal readonly HashSet PausedEscorts = new(); internal ulong Detained; internal ulong PendingPrison; internal string? PrisonTimelineToken; internal ulong PrisonTimelineOwner; internal bool PrisonInteractionEnded, PrisonTimelineEnded; internal long PrisonCorrectionAt; } private static bool LixiReady(WorldEntryState? state) => state is { Ready: true, ActiveSpiritTemplateId: LixiSpirit4229938, PendingSwitchTemplateId: 0, PendingTeleportId: 0 } && state.ActiveSpiritUnitId != 0; // Lua-generated SpiritPoliceJobInfo uses Int32 collections internally, whereas // SyncPoliceDispatchInfos uses an Int7 dictionary at the method boundary. [UxContract] private sealed class PoliceDispatch4229938 { public uint Id; public uint NextAvailableTime; public bool IsTemp = false; public uint TempEventId = 0; public uint TodayArrestSupportTimes = 0; } [UxContract] private sealed class PoliceService4229938 { public uint DispatchTimes = 0, PatrolTimes = 0, ArrestTimes = 0, FineCount = 0; [UxCollection(Count = UxCountEncoding.Int32)] public List TotalDrops = []; public uint LastUpdateTime = 0, CarFineCount = 0; } [UxContract] private sealed class PoliceDuty4229938 { public uint DailyViolationCount = 0, LeaveDueTime = 0, LastViolationUpdateTime = 0; public PoliceService4229938 ServiceData = new(), WeeklyServiceData = new(); [UxCollection(Count = UxCountEncoding.Int32)] public Dictionary ViolationCdInfos = new(); } [UxContract] private sealed class PoliceJob4229938 { [UxCollection(Count = UxCountEncoding.Int32, ValueObjectEncoding = UxObjectEncoding.Complex)] public Dictionary DispatchInfos = new(); // No fabricated criminal records/rewards. Empty lists have the same wire // framing regardless of element type; populate only with exact DTOs later. [UxCollection(Count = UxCountEncoding.Int32)] public List ViolationInfos = [], CaseInfos = []; public PoliceDuty4229938 DutyBasicInfo = new(); [UxCollection(Count = UxCountEncoding.Int32)] public List EscortedNpcs = []; public PoliceFakeFile4229938 PoliceFakeFileInfo = new(); [UxCollection(Count = UxCountEncoding.Int32)] public Dictionary PeriodInvalidVehicleFine2CountDict = new(); public uint NextPeriodUpdateTime = 0; } [UxContract] private sealed class PoliceFakeFile4229938 { [UxCollection(Count = UxCountEncoding.Int32)] public Dictionary UnlockFileInfoDict = new(); [UxCollection(Count = UxCountEncoding.Int32)] public List HistoryClueAgentInfoList = []; } [UxContract(Inline = true)] private sealed class PoliceJobSync4229938 { public uint SpiritId = LixiSpirit4229938; public PoliceJob4229938 Info = new(); } [UxContract(Inline = true)] private sealed class PoliceDispatchSync4229938 { public uint SpiritId = LixiSpirit4229938; [UxCollection(Count = UxCountEncoding.Int7, ValueObjectEncoding = UxObjectEncoding.Complex)] public Dictionary Infos = new(); } private static Dictionary PoliceDispatches(PoliceRuntime4229938 r) => new uint[] { 2, 4 }.ToDictionary(id => id, id => new PoliceDispatch4229938 { Id = id, NextAvailableTime = r.Cooldowns.GetValueOrDefault(id) }); private static async Task PublishLixiPolice4229938(TcpSession session, uint spiritId) { if (spiritId != LixiSpirit4229938) return; var state = GetStateIfExists(session); if (state is null || !state.Ready || state.ActiveSpiritTemplateId != spiritId || state.ActiveSpiritUnitId == 0 || state.PendingTeleportId != 0) return; var r = PoliceRuntimes.GetValue(session, _ => new()); await r.Gate.WaitAsync(session.ClosedToken); try { // The role/scene may have changed while waiting behind support cleanup. if (!state.Ready || state.ActiveSpiritTemplateId != spiritId || state.ActiveSpiritUnitId == 0 || state.PendingTeleportId != 0 || (state.PendingSwitchTemplateId != 0 && state.PendingSwitchTemplateId != spiritId)) return; if (r.Generation != state.WorldEntryControlGeneration) { r.Detained = 0; r.PendingPrison = 0; r.PrisonCorrectionAt = 0; r.RootSent = false; r.Child = 0; r.Agent = 0; r.Npcs.Clear(); r.Passengers.Clear(); r.PausedEscorts.Clear(); r.PendingBoarding = null; r.Alighting = false; r.LastInteraction = 0; r.Generation = state.WorldEntryControlGeneration; } await session.NotifyAsync(MethodId.SyncSpiritPoliceJobInfo, UxSerializer.Serialize(new PoliceJobSync4229938 { Info = new PoliceJob4229938 { DispatchInfos = PoliceDispatches(r) } }), session.ClosedToken); // Police APP's license vehicle button uses ApplyCarManager's owned // fleet, filtered by VehicleType.PoliceCar; dispatch entries are NOT cars. await session.NotifyAsync(MethodId.SyncAllUnlockedVehicles, UxSerializer.Serialize(VehicleOwnershipSnapshot(GetVehicleRuntimeState(session))), session.ClosedToken); if (!r.RootSent) { // SNpcBtnQuery requires PoliceJobManager.HasPoliceJob, not merely // S002 + exam data. Login intentionally seeds jobless characters. // Refresh ONLY the controlled Lixi after CurrentSpirit/duty hydration: // SyncSpiritJobInfo emits JOB_CHANGE_EVENT and recomputes the native // gate against the current actor (the pre-control switch event may // still see the previous actor). Keep the authored level-1 policy. var jobInfo = RuntimePayloadFactory.SpiritJobInfo4229938(spiritId); await SendLixiPoliceCareer4229938(session); await SendStoryAsync(session, StoryS002Codec4229938.Root(PoliceRootNid4229938)); r.RootSent = true; await SendPoliceButtons4229938(session, r.Npcs.Keys.ToArray(), state.ActiveSpiritUnitId, true); session.Log.Info($"[LIXI-POLICE] F bootstrap active={spiritId}/{state.ActiveSpiritUnitId} generation={r.Generation} job={jobInfo.CurrentJob} level=1 order=duty,job,S002"); } else { // S002 is scene-owned, not hero-owned. Reuse it when switching // back to Lixi; refresh eligibility/buttons for the current hero. await SendLixiPoliceCareer4229938(session); await SendPoliceButtons4229938(session, r.Npcs.Keys.ToArray(), state.ActiveSpiritUnitId, true); } await SendPolicePrisonButtons4229938(session, r, true); session.Log.Info("[LIXI-POLICE] S002 root and duty data published; dispatch=2,4; job level unchanged"); } finally { r.Gate.Release(); } } private static async Task RegisterPoliceNpc4229938(TcpSession session, ulong id, uint template, Vec3 position) { var state = GetStateIfExists(session); if (state is null) return; var r = PoliceRuntimes.GetValue(session, _ => new()); await r.Gate.WaitAsync(session.ClosedToken); try { if (r.Generation != state.WorldEntryControlGeneration) { r.Detained = 0; r.PendingPrison = 0; r.PrisonCorrectionAt = 0; r.Npcs.Clear(); r.Passengers.Clear(); r.PausedEscorts.Clear(); r.PendingBoarding = null; r.Alighting = false; r.LastInteraction = 0; r.Generation = state.WorldEntryControlGeneration; r.RootSent = false; r.Child = 0; r.Agent = 0; } r.Npcs[id] = (position, template); } finally { r.Gate.Release(); } // A session may enter directly as Lixi without the character-switch // publisher running. The native S002 root owns SNpcBtnQuery (the F UI). // Restore the current actor's level-1 career and police runtime together. // No other character's careers or buffs are replayed. if (LixiReady(state) && !r.RootSent) await PublishLixiPolice4229938(session, LixiSpirit4229938); await SendPoliceExamData4229938(session, id); } // AgentCharacterComponent is separate from AgentPoliceExamData. The native ID // panel unconditionally reads module.Component.Portrait. A missing component // throws before the close/continue callback is installed (Player.log 23:07:24). // 4000 is an existing NpcInformationPortraitConfig row, a neutral display tag // for our generated test NPCs; this does not invent crimes or grant rewards. internal static byte[] PoliceCharacterPacket4229938(ulong id) => new UxWriter().U64(id).U8(255).U32(4000).ToArray(); private static Task SendPoliceCharacter4229938(TcpSession session, ulong id) => session.NotifyAsync(MethodId.SyncAgentCharacter, PoliceCharacterPacket4229938(id), session.ClosedToken); private static async Task SendPoliceExamData4229938(TcpSession session, ulong id) { // Replayed by the bounded post-spawn pass because SyncAgentCharacter // discards updates if the native agent has not finished loading yet. await SendPoliceCharacter4229938(session, id); // AgentPoliceExamData { FineTimes, Fines }. var w = new UxWriter(); w.U64(id); w.U8(255); w.U32(0); w.U8(255); w.Int7(0); await session.NotifyAsync(MethodId.SyncAgentPoliceExamData, w.ToArray(), session.ClosedToken); } private static Task SendLixiPoliceCareer4229938(TcpSession session) { var jobInfo = RuntimePayloadFactory.SpiritJobInfo4229938(LixiSpirit4229938); return session.NotifyAsync(MethodId.SyncSpiritJobInfo, UxSerializer.Serialize(new GameMethods.SyncSpiritJobInfo4229938 { spiritId = LixiSpirit4229938, spiritJobs = jobInfo.AvailableJobs, currentJob = jobInfo.CurrentJob, }), session.ClosedToken); } private static async Task RefreshPoliceNpcInitializationPass4229938(TcpSession session, ulong[] ids, int generation) { var state = GetStateIfExists(session); if (state is null || !state.Ready || state.WorldEntryControlGeneration != generation || state.PendingTeleportId != 0 || !PoliceRuntimes.TryGetValue(session, out var r)) return; await r.Gate.WaitAsync(session.ClosedToken); try { // Recheck after the await: recall, teleport and character switching // can invalidate the state while this pass is waiting on the gate. if (!state.Ready || state.WorldEntryControlGeneration != generation || state.PendingTeleportId != 0 || r.Generation != generation) return; var tracked = ids.Distinct().Where(r.Npcs.ContainsKey).ToArray(); if (tracked.Length == 0) return; foreach (var id in tracked) if (r.Child == 0 || r.Agent != id) await SendPoliceExamData4229938(session, id); // RootSent records a server send, NOT completion of asynchronous // client actor loading. Exam data alone never updates HasPoliceJob. // Replay the current Lixi's level-1 JOB_CHANGE_EVENT after spawning, // without resetting duty/cooldowns, S002, POI, AI or an open panel. if (!LixiReady(state) || !r.RootSent || r.Child != 0) return; await SendLixiPoliceCareer4229938(session); await SendPoliceButtons4229938(session, tracked, state.ActiveSpiritUnitId, true); session.Log.Info($"[LIXI-POLICE] F post-spawn refresh active={state.ActiveSpiritUnitId} generation={generation} npcs={tracked.Length} job=301 level=1; client F visibility unconfirmed"); } finally { r.Gate.Release(); } } private static async Task RefreshSpawnedPoliceNpcs4229938(TcpSession session, ulong[] ids, int generation) { // Client OnSyncExamineData drops updates before the asynchronous full // model exists. Two finite retries cover that handoff; never replay POI // or reset battle state after the player has started an interaction. try { foreach (var delay in new[] { 750, 1250 }) { await Task.Delay(delay, session.ClosedToken); await RefreshPoliceNpcInitializationPass4229938(session, ids, generation); } } catch (OperationCanceledException) when (session.ClosedToken.IsCancellationRequested) { } catch (Exception ex) { session.Log.Info($"[LIXI-POLICE] NPC initialization retry stopped: {ex.Message}"); } } // Native SInteract.OnEnable uses ServerInitiate to construct its own // SPreInteractExam, instead of relying on the F-button's preparatory node. // Keep the GM nearest-target fallback on the known test model. Explicit F // requests can also target registered action NPCs and functional shopkeepers. internal static Task<(bool Ok, string Message)> ControlPoliceExam4229938(TcpSession session, bool start) => ControlPoliceTarget4229938(session, start, 0); private static async Task<(bool Ok, string Message)> ControlPoliceTarget4229938(TcpSession session, bool start, ulong requestedTarget) { var state = GetStateIfExists(session); if (state is null || !PoliceRuntimes.TryGetValue(session, out var r)) return (false, "请先切换里希,并生成测试动作 NPC 4065053。"); if (start && !LixiReady(state)) return (false, "请在场景加载完成后切换里希,等待切人/传送结束。"); var generation = state.WorldEntryControlGeneration; var owner = state.ActiveSpiritUnitId; await r.Gate.WaitAsync(session.ClosedToken); try { if (!state.Ready || state.WorldEntryControlGeneration != generation || r.Generation != generation) return (false, "场景已变化,请在当前场景重新生成测试 NPC。"); if (!start) { r.PrepareNonce = null; if (r.Child == 0) return (true, "当前没有服务端登记的盘查。"); await ClosePoliceInteraction4229938(session, r, "GM cancelled"); return (true, "已结束本次原生盘查;未召回支援或删除 NPC。"); } if (!LixiReady(state) || state.ActiveSpiritUnitId != owner || !state.HasLastReportedPlayerTransform) return (false, "角色状态已变化,请使用里希在地面移动一下后重试。"); if (GetVehicleRuntimeState(session).CurrentVehicleId != 0) return (false, "请先下车,再发起盘查。"); if (r.Child != 0) { if (PoliceEntryRetryAllowed4229938(r)) await ClosePoliceInteraction4229938(session, r, "explicit retry of unresponsive exam entry"); else return (true, "已有盘查正在进行;若尚未出现界面,请等待 10 秒后再次点击重试。"); } if (r.PrepareNonce != null && DateTime.UtcNow < r.PrepareDeadline) return (true, "正在等待客户端原生盘查数据初始化回执,请稍候;可点结束盘查取消。"); r.PrepareNonce = null; var p = state.LastReportedPlayerPosition; if (!float.IsFinite(p.X) || !float.IsFinite(p.Y) || !float.IsFinite(p.Z)) return (false, "角色坐标无效,请移动后重试。"); ulong target = 0; float nearest = 6 * 6; foreach (var (id, npc) in r.Npcs) { if (id == r.Detained) continue; // The GM fallback keeps its known test model; the independent F // button must examine exactly the registered NPC that was clicked. // ⛑⛑ 测试模板只能是 4065053:AgentConfig.json 的 ExamType=2。 // 客户端原生 SNpcBtnQuery / PoliceJobUtils 对 ExamType!=2 的模板 // 不授予盘查资格 ⇒ 面板能靠服务端硬推 S002 打开,但按键不挂 // 原生流程(点了无反应),且菜单里不会给「押送上车」。 // 反例:42031099(摩天轮售票员)ExamType=0,2026-10-02 实测就是这个症状。 // 见 GameAdminBridge.SpawnPoliceTestNpcAsync 的注释。 if (requestedTarget == 0 ? npc.Template != 4065053 : id != requestedTarget) continue; // This explicit F callback is already bound to a locally nearby // registered passenger. Map cars may have no server transform, // and a driven car's latest report can lag behind the local seat. // Do not extend this exception to GM nearest-target selection. if (requestedTarget == id && r.Passengers.ContainsKey(id)) { target = id; break; } var targetPosition = PoliceTargetPosition4229938(session, r, id, npc.Position); float d2 = MathF.Pow(p.X - targetPosition.X, 2) + MathF.Pow(p.Y - targetPosition.Y, 2) + MathF.Pow(p.Z - targetPosition.Z, 2); if (!float.IsFinite(d2) || d2 > nearest || (d2 == nearest && target != 0 && id > target)) continue; nearest = d2; target = id; } if (target == 0) return (false, requestedTarget == 0 ? "6 米内没有已登记的测试 NPC 4065053。请生成一个,等待模型加载后靠近;距离按服务端登记位置计算。" : "该 NPC 不在 6 米范围内,或已卸载/不属于本次生成。请靠近当前动作 NPC 或店员再试。"); if (r.Passengers.ContainsKey(target)) { await ReleasePolicePassenger4229938(session, r, target, besidePlayer: true); r.PausedEscorts.Add(target); } if (r.PausedEscorts.Contains(target)) { // TemporaryLeave (18) preserves the native arrested state. // Starting an exam here cannot satisfy its native gates. Resume // through SPreInteractEscort; SEscortInteract opens the HUD itself. if (!r.RootSent) { await SendStoryAsync(session, StoryS002Codec4229938.Root(PoliceRootNid4229938)); r.RootSent = true; } r.Child = ++r.NextNode; r.Agent = target; r.HasSection = false; r.LastInteraction = 0; r.EntryStartedUtc = DateTime.UtcNow; await SendStoryAsync(session, StoryS002Codec4229938.Interaction(r.Child, target, start: false, escort: true, serverInitiate: true)); session.Log.Info($"[LIXI-POLICE] resume escort agent={target} nid={r.Child}; awaiting native interaction 19"); return (true, "已请求原生继续押送;靠近完成后由客户端恢复押送按键。"); } if (!session.Items.TryGetValue(PoliceGateSessionKey4229938, out var gateValue) || gateValue is not TcpSession gate || gate.ClosedToken.IsCancellationRequested) return (false, "原生盘查初始化需要当前登录的 Gate 连接,请重新登录后再试;未启动空数据盘查。"); // Only repair the missing story root after validating a target. Do not // replay job/duty initialization or reset the shared story ACK stream. if (!r.RootSent) { await SendStoryAsync(session, StoryS002Codec4229938.Root(PoliceRootNid4229938)); r.RootSent = true; } await SendPoliceExamData4229938(session, target); r.PrepareNonce = Guid.NewGuid().ToString("N"); r.PrepareTarget = target; r.PrepareOwner = owner; r.PrepareDeadline = DateTime.UtcNow.AddSeconds(10); var nonce = r.PrepareNonce; try { var w = new UxWriter(); w.UxString(PolicePrepareScript4229938(target, owner, nonce)); await gate.NotifyAsync(MethodId.SyncDoToLuaMaster, w.ToArray(), session.ClosedToken); } catch { r.PrepareNonce = null; throw; } _ = ExpirePolicePrepare4229938(session, r, nonce); session.Log.Info($"[LIXI-POLICE] native prepare requested agent={target} generation={generation} via=Gate; awaiting data-ready receipt before S002"); return (true, $"已请求为 NPC {target} 初始化原生盘查数据,客户端确认后才启动靠近和菜单。不需要按 F;无反应可查看 LIXI-POLICE 回执/超时日志。"); } finally { r.Gate.Release(); } } // 4229938 MasterToClientImpl.SyncDoToLuaMaster (0x1281DDE0) calls the // built-in gCsToLuaHandler.GmRunLua, registered on Gate (0x14883B90). // Invoke the ORIGINAL PrepareData_Story, as S002PlayerNet.EnterExam does; // no replacement functions, persistent hooks, assets or client files. // GmUtils.ParseUlongToLua (0x14677AC0) uses ulong.new(low32, high32): // a decimal Lua number would round our 8300000000000000001 IDs. internal static string PolicePrepareScript4229938(ulong target, ulong owner, string nonce) { if (nonce.Length != 32 || nonce.Any(c => !char.IsAsciiHexDigit(c))) throw new ArgumentException("Invalid preparation nonce", nameof(nonce)); static string U64(ulong id) => FormattableString.Invariant($"ulong.new({(uint)id}, {id >> 32})"); static string Receipt(string text) { // Non-reliable SetMessageCommand + StringPayload; no ACK reset. var w = new UxWriter(); w.U8(255); w.Int7(1); w.U8(4); w.U32(PoliceRootNid4229938); w.UxString(PolicePreparedMessage4229938); w.U8(42); w.UxString(text); w.I64(0); w.Bool(false); return string.Join(",", w.ToArray()); } return $$""" local ok, result = pcall(function() local player = gCS.MyPlayerManager.PlayerUnit local npc = gCS.SceneDataMgr.GetUnit({{U64(target)}}) if not player or player.Pid ~= {{U64(owner)}} or not npc then return false end gPoliceJobManager:PrepareData_Story(npc.Pid) local data = gPoliceJobManager.examineMgr.enterExamData return data ~= nil and data.npcUnit == npc end) local bytes if ok and result then bytes = { {{Receipt(nonce + ":ok")}} } else print_error("[LIXI-POLICE] native prepare failed: " .. tostring(result)) bytes = { {{Receipt(nonce + ":failed")}} } end gClientToGameSceneDelegate:Notify(67130745, function(writer) for i = 1, #bytes do writer:WriteByte(bytes[i]) end end) """; } private static async Task ExpirePolicePrepare4229938(TcpSession session, PoliceRuntime4229938 r, string nonce) { try { await Task.Delay(TimeSpan.FromSeconds(10), session.ClosedToken); await r.Gate.WaitAsync(session.ClosedToken); try { if (r.PrepareNonce != nonce) return; r.PrepareNonce = null; session.Log.Warn("[LIXI-POLICE] native prepare timed out; no S002 exam started. Check client GmRunLua/PrepareData_Story errors; retry is allowed."); } finally { r.Gate.Release(); } } catch (OperationCanceledException) when (session.ClosedToken.IsCancellationRequested) { } catch (Exception ex) { session.Log.Warn($"[LIXI-POLICE] prepare timeout cleanup: {ex.Message}"); } } // Cooperative reaction rows from PoliceExamReactionConfig. These are not a // simulation of guilt or fine adjudication: no money/rewards are invented. internal static uint PoliceReaction4229938(uint interaction) => interaction switch { 1 => 1001, 2 => 2001, 3 => 3001, 4 => 4001, 5 => 5001, 6 => 6001, 7 => 7001, 8 => 8001, 9 => 9001, 10 => 10001, 11 or 12 => 12003, // No server evidence ledger: refuse ticket rather than award false success. 13 => 13001, 14 => 14001, 15 => 15001, 16 => 16001, 17 => 17001, // 20001 Exit=EscortToVehicle(9) selects SEscortToVehicleInteract with // the client's requested VehicleId/SeatIndex. 15002 is Detach, NOT boarding. 18 => 18001, 19 => 19001, 20 => 20001, 21 => 21001, 22 => 22001, 23 => 23001, 24 => 24001, 30 => 30001, // EscortToWardCfg: native SEscortToWard + body-type timeline. _ => 15002, // Unsupported transport/case operation exits safely. }; private static bool PoliceEscortVehicleRequestValid4229938(TcpSession session, PoliceStoryPayload4229938 request) { if (request.VehicleId == 0 || request.SeatIndex is < 0 or >= 16) return false; var vehicles = GetVehicleRuntimeState(session); lock (vehicles.SyncRoot) { // Map vehicles need not have a server spawn record. Preserve their // native selected target, but validate known GM/owned vehicles. if (!vehicles.Vehicles.TryGetValue(request.VehicleId, out var vehicle)) return true; byte seat = (byte)request.SeatIndex; return vehicle.Interactable && seat < vehicle.SeatCount && !vehicle.SeatOccupants.ContainsKey(seat) && !vehicle.SeatReservations.ContainsKey(seat); } } // Called with the police gate held. Deleting only this interaction invokes // native OnDisable cleanup without deleting the NPC, police root or support. private static bool PoliceEntryRetryAllowed4229938(PoliceRuntime4229938 r) => r.Child != 0 && !r.HasSection && r.LastInteraction == 0 && !r.Alighting && r.PendingBoarding is null && r.PendingPrison == 0 && !r.Passengers.ContainsKey(r.Agent) && !r.PausedEscorts.Contains(r.Agent) && r.EntryStartedUtc != default && DateTime.UtcNow - r.EntryStartedUtc >= TimeSpan.FromSeconds(10); private static async Task ClosePoliceInteraction4229938(TcpSession session, PoliceRuntime4229938 r, string reason) { if (r.Child == 0) return; await PoliceAlightVisualHeight4229938(session, r.Agent, restore: true); await PoliceBoardingFacing4229938(session, r, restore: true); await PoliceOfficerBoardingPosition4229938(session, r, 0, restore: true); if (r.LastInteraction is 19 or 20) { // Cancellation/role switching can end the paired action without // Clearance. Native OnDisable removes Escort, but keeps the arrest. r.PausedEscorts.Add(r.Agent); RememberPoliceEscortPosition4229938(session, r); } await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(r.Child)); CancelPoliceSeatReservation4229938(session, r); if (r.Alighting && r.Passengers.TryGetValue(r.Agent, out var restorePassenger)) { // Successful native completion clears Alighting before Close. A // cancellation/timeout must restore, not evict, the seated passenger. await SendPolicePassengerBinding4229938(session, restorePassenger, board: true); session.Log.Info($"[LIXI-POLICE] alight rollback agent={r.Agent} vehicle={restorePassenger.Vehicle} seat={restorePassenger.Seat}"); } session.Log.Info($"[LIXI-POLICE] exam closed agent={r.Agent} nid={r.Child} reason={reason}"); r.Child = 0; r.Agent = 0; r.HasSection = false; r.LastSection = 0; r.Alighting = false; r.LastInteraction = 0; r.PendingPrison = 0; r.PrisonTimelineToken = null; r.PrisonTimelineOwner = 0; r.PrisonInteractionEnded = false; r.PrisonTimelineEnded = false; r.EntryStartedUtc = default; } private static async Task RecoverStalledPoliceBoarding4229938(TcpSession session, uint child, uint section, int generation, ulong owner) { if (!PoliceRuntimes.TryGetValue(session, out var r)) return; await r.Gate.WaitAsync(session.ClosedToken); try { var state = GetStateIfExists(session); if (!LixiReady(state) || state!.WorldEntryControlGeneration != generation || r.Generation != generation || state.ActiveSpiritUnitId != owner || r.Child != child || !r.HasSection || r.LastSection != section) return; await ClosePoliceInteraction4229938(session, r, "boarding-timeout; native cleanup, no forced teleport or reward"); } finally { r.Gate.Release(); } } private static async Task WatchPoliceBoarding4229938(TcpSession session, uint child, uint section, int generation, ulong owner) { try { // A boarding transition is finite; ordinary ID/menu reading has no // timer. Stale timers cannot close a newer interaction/role/scene. await Task.Delay(TimeSpan.FromSeconds(20), session.ClosedToken); await RecoverStalledPoliceBoarding4229938(session, child, section, generation, owner); } catch (OperationCanceledException) when (session.ClosedToken.IsCancellationRequested) { } catch (Exception ex) { session.Log.Warn($"[LIXI-POLICE] boarding recovery failed: {ex.Message}"); } } private static async Task HandlePoliceStory4229938(TcpSession session, StoryClientCommand4229938 cmd) { if (cmd.Nid != PoliceRootNid4229938 && cmd.Nid < 0x20000) return false; if (cmd.Nid == PoliceRootNid4229938 && cmd.Name == "PolicePrisonTimelineEnd") { await HandlePolicePrisonTimelineEnd4229938(session, cmd.PayloadString); return true; } if (cmd.Nid == PoliceRootNid4229938 && cmd.Name == "PolicePrisonRequest") { // Same authoritative validation as GM; a position button is not // permission to arrest an arbitrary target or act across the map. var result = await ControlPolicePrison4229938(session, cmd.PayloadString ?? ""); session.Log.Info($"[LIXI-PRISON] F request ok={result.Ok}: {result.Message}"); // A rejected F previously produced no in-game feedback at all, so the // player could not tell which precondition failed. Reuse the shared // player message channel already used by taxi/autodrive/delivery. if (!result.Ok) await DeliveryText4229938(session, result.Message); return true; } // Enter before taking r.Gate: the preparation handshake owns that gate. if (cmd.Nid == PoliceRootNid4229938 && cmd.Name == "PoliceFRequest") { if (ulong.TryParse(cmd.PayloadString, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var target) && target != 0) { var result = await ControlPoliceTarget4229938(session, true, target); session.Log.Info($"[LIXI-POLICE] F request target={target} ok={result.Ok}: {result.Message}"); } return true; } var r = PoliceRuntimes.GetValue(session, _ => new()); await r.Gate.WaitAsync(session.ClosedToken); try { if (cmd.Nid != PoliceRootNid4229938 && (cmd.Nid <= 0x20000 || cmd.Nid > r.NextNode)) return false; var state = GetStateIfExists(session); if (!LixiReady(state) || !r.RootSent || r.Generation != state!.WorldEntryControlGeneration) return true; if (cmd.Nid == PoliceRootNid4229938 && cmd.Name == PolicePreparedMessage4229938) { var nonce = r.PrepareNonce; if (nonce == null || (cmd.PayloadString != nonce + ":ok" && cmd.PayloadString != nonce + ":failed")) return true; r.PrepareNonce = null; (Vec3 Position, uint Template) npc = default; var target = r.PrepareTarget; var p = state.LastReportedPlayerPosition; if (cmd.PayloadString != nonce + ":ok" || DateTime.UtcNow >= r.PrepareDeadline || state.ActiveSpiritUnitId != r.PrepareOwner || !state.HasLastReportedPlayerTransform || r.Child != 0 || !r.Npcs.TryGetValue(target, out npc) || GetVehicleRuntimeState(session).CurrentVehicleId != 0) { session.Log.Warn("[LIXI-POLICE] native prepare failed or state changed; no exam started"); return true; } float d2 = MathF.Pow(p.X - npc.Position.X, 2) + MathF.Pow(p.Y - npc.Position.Y, 2) + MathF.Pow(p.Z - npc.Position.Z, 2); if (!float.IsFinite(d2) || d2 > 36) return true; r.Child = ++r.NextNode; r.Agent = target; r.HasSection = false; r.LastInteraction = 0; r.EntryStartedUtc = DateTime.UtcNow; await SendStoryAsync(session, StoryS002Codec4229938.Interaction(r.Child, target, start: true, serverInitiate: true)); session.Log.Info($"[LIXI-POLICE] native prepare confirmed agent={target}; S002 started nid={r.Child} wire=s002-prepared-r3; awaiting menu Interaction"); return true; } if (cmd.Nid == PoliceRootNid4229938 && cmd.Name == StoryS002Codec4229938.NpcInteract && cmd.Police is { } enter) { if (enter.AgentId == r.Detained && r.Detained != 0) return true; if (enter.Tag is not (StoryS002Codec4229938.EnterExamRequestTag or StoryS002Codec4229938.EscortFromVehicleRequestTag or StoryS002Codec4229938.EscortRequestTag) || !r.Npcs.TryGetValue(enter.AgentId, out var npc)) { session.Log.Warn($"[LIXI-POLICE] untracked/unsupported target={enter.AgentId} tag={enter.Tag}"); return true; } bool fromVehicle = enter.Tag == StoryS002Codec4229938.EscortFromVehicleRequestTag; bool seated = r.Passengers.ContainsKey(enter.AgentId); var p = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerPosition : state.WorldEntryCreateHeroPosition; var targetPosition = PoliceTargetPosition4229938(session, r, enter.AgentId, npc.Position); float d2 = MathF.Pow(p.X - targetPosition.X, 2) + MathF.Pow(p.Y - targetPosition.Y, 2) + MathF.Pow(p.Z - targetPosition.Z, 2); if (fromVehicle != seated) { session.Log.Warn($"[LIXI-POLICE] native entry seat mismatch agent={enter.AgentId} tag={enter.Tag} fromVehicle={fromVehicle} seated={seated}; request not started"); return true; } // A known passenger has already been resolved from the local // seat by native EscortFromRequest (wire mark20). A stale pose must not strand // that local preinteraction waiting for a story response. if (!fromVehicle && (!float.IsFinite(d2) || d2 > 35 * 35)) { session.Log.Warn($"[LIXI-POLICE] native entry out of range agent={enter.AgentId} tag={enter.Tag} distance2={d2}"); return true; } if (r.Agent == enter.AgentId && r.Child != 0 && (enter.Tag != StoryS002Codec4229938.EnterExamRequestTag || !PoliceEntryRetryAllowed4229938(r))) return true; if (r.Child != 0) await ClosePoliceInteraction4229938(session, r, "replaced"); // Spawn-time data may have arrived before the client model loaded. // Hydrate the selected NPC again on the same game connection, // before its interaction node is enabled. Do not replay job/root. if (enter.Tag == StoryS002Codec4229938.EnterExamRequestTag) await SendPoliceExamData4229938(session, enter.AgentId); r.PrepareNonce = null; r.Child = ++r.NextNode; r.Agent = enter.AgentId; r.HasSection = false; r.LastInteraction = 0; r.EntryStartedUtc = DateTime.UtcNow; r.Alighting = fromVehicle; if (fromVehicle && r.Passengers.TryGetValue(r.Agent, out var alightPassenger)) { await BeginPoliceAlightVisualHeight4229938(session, r.Child, r.Agent, alightPassenger.Vehicle); await PreparePoliceAlighting4229938(session, alightPassenger); } if (fromVehicle && r.Passengers.TryGetValue(r.Agent, out var tracePassenger)) _ = TracePoliceVehicleAnimation4229938(session, r.Child, r.Agent, tracePassenger.Vehicle, tracePassenger.Seat, boarding: false); await SendStoryAsync(session, StoryS002Codec4229938.Interaction(r.Child, r.Agent, enter.StartInteract, fromVehicle || enter.Tag == StoryS002Codec4229938.EscortRequestTag)); if (r.Alighting) _ = WatchPoliceAlighting4229938(session, r.Child, r.Generation, state.ActiveSpiritUnitId); session.Log.Info($"[LIXI-POLICE] F interaction agent={r.Agent} template={npc.Template} nid={r.Child} tag={enter.Tag} fromVehicle={fromVehicle} wire=escort-entry-r7"); return true; } if (cmd.Nid != r.Child || r.Child == 0) return true; if (cmd.Name == StoryS002Codec4229938.InteractEnd && cmd.PayloadBool == true) { // The client has finished its authored boarding animation. Delete // that animation's story first, then persist the seat attachment. var boarding = r.PendingBoarding; var finishedAgent = r.Agent; // Config names, not assumed keyboard letters: 17 Clearance // (Exit2, clears arrest), 18 TemporaryLeave (Exit4, keeps arrest). if (r.LastInteraction is 17 or 18) RememberPoliceEscortPosition4229938(session, r); if (r.LastInteraction == 18) r.PausedEscorts.Add(finishedAgent); else if (r.LastInteraction == 17) r.PausedEscorts.Remove(finishedAgent); bool prisonFinished = r.PendingPrison == finishedAgent && r.LastInteraction == 30; if (prisonFinished) { r.PrisonInteractionEnded = true; await TryCompleteNativePolicePrison4229938(session, r, state); return true; // Native timeline, not InteractEnd, owns the end boundary. } await ClosePoliceInteraction4229938(session, r, "client-InteractEnd"); if (boarding != null) await CommitPolicePassenger4229938(session, r, boarding); else if (r.Npcs.ContainsKey(finishedAgent)) // ★★ 2026-10-02 18:1x:19 = 拘捕/押送开始。押送中**主动摘掉**我们注入的假 F 按钮 // (`AddBtn`/`RemoveBtnByType` 会让客户端原生按钮列表失效,见 PoliceButtons.cs 注释), // 把 NPC 头顶还给客户端原生菜单 ⇒ 「靠近警车按 X 押送上车」才有机会出现。 // 暂离(18)/放行(17)维持原行为(照常挂按钮)。 await SendPoliceButtons4229938(session, [finishedAgent], state.ActiveSpiritUnitId, add: r.LastInteraction != 19); } else if (cmd.Name == StoryS002Codec4229938.InteractionMessage && cmd.Police is { Tag: 24 } request) { if (r.HasSection && request.SectionId <= r.LastSection) return true; if (request.InteractionId == 20 && !ReservePoliceSeat4229938(session, r, request)) { session.Log.Warn($"[LIXI-POLICE] invalid boarding target vehicle={request.VehicleId} seat={request.SeatIndex}; ending interaction safely"); await ClosePoliceInteraction4229938(session, r, "invalid-boarding-target"); return true; } // A request after the native SPreInteractEscortFromVehicle has // prepared means its get-out animation finished. Do not detach // before that node has resolved the passenger and seat. if ((r.Alighting && request.InteractionId == 19) || request.InteractionId == 21) { await ReleasePolicePassenger4229938(session, r, r.Agent, besidePlayer: true); r.Alighting = false; } if (request.InteractionId == 19) { r.PausedEscorts.Remove(r.Agent); RememberPoliceEscortPosition4229938(session, r); } // Send before the ID-check reaction starts its animation. Unlike // resetting exam data, this preserves existing examination progress. if (request.InteractionId == 3) await SendPoliceCharacter4229938(session, r.Agent); uint reaction = PoliceReaction4229938(request.InteractionId); if (request.InteractionId == 30) { // Native Police_Prison sends EscortToWardCfg (30). Do not // delete the paired escort or move the NPC until its timeline // reports InteractEnd. Previously the unknown-ID fallback // replied 15002 (Detach), leaving both actors in a bad pose. bool valid = r.LastInteraction == 19 && r.Detained == 0 && r.PendingPrison == 0 && !r.Passengers.ContainsKey(r.Agent) && r.Npcs.ContainsKey(r.Agent) && state.HasLastReportedPlayerTransform && PolicePrisonNativeNear4229938(state.LastReportedPlayerPosition); if (valid) { r.PendingPrison = r.Agent; r.PrisonTimelineToken = Guid.NewGuid().ToString("N"); r.PrisonTimelineOwner = state.ActiveSpiritUnitId; r.PrisonInteractionEnded = false; r.PrisonTimelineEnded = false; await PreparePolicePrisonAnimation4229938(session, state.ActiveSpiritUnitId, r.Agent, r.PrisonTimelineToken); } else { reaction = 15002; session.Log.Warn($"[LIXI-PRISON] rejected native ward request npc={r.Agent} last={r.LastInteraction} detained={r.Detained}"); } } if (request.InteractionId == 20) { await PreparePoliceOfficerBoarding4229938(session, r, state.ActiveSpiritUnitId); await PoliceOfficerBoardingPosition4229938(session, r, state.ActiveSpiritUnitId); await PoliceBoardingFacing4229938(session, r, restore: false); } await SendStoryAsync(session, StoryS002Codec4229938.Reaction(r.Child, request.SectionId, reaction)); if (request.InteractionId == 20) _ = TracePoliceVehicleAnimation4229938(session, r.Child, r.Agent, request.VehicleId, request.SeatIndex, boarding: true); r.HasSection = true; r.LastSection = request.SectionId; r.LastInteraction = request.InteractionId; if (request.InteractionId == 20) _ = WatchPoliceBoarding4229938(session, r.Child, r.LastSection, r.Generation, state.ActiveSpiritUnitId); if (request.InteractionId == 30 && r.PendingPrison == r.Agent) _ = WatchPolicePrison4229938(session, r.Child, r.Agent, r.Generation, state.ActiveSpiritUnitId); session.Log.Info($"[LIXI-POLICE] agent={r.Agent} interaction={request.InteractionId} section={request.SectionId} reaction={reaction} vehicle={request.VehicleId} seat={request.SeatIndex} wire=escort-lifecycle-r6"); } else if (cmd.Name == StoryS002Codec4229938.ClientMessage) session.Log.Info($"[LIXI-POLICE] client event agent={r.Agent} name={cmd.PayloadString}"); return true; } finally { r.Gate.Release(); } } internal static async Task StopLixiPolice4229938(TcpSession session, bool sceneChange = false, bool supportOnly = false) { if (!PoliceRuntimes.TryGetValue(session, out var r)) return; await r.Gate.WaitAsync(session.ClosedToken); try { if (supportOnly) return; if (r.Child != 0) await ClosePoliceInteraction4229938(session, r, "role/scene cleanup"); foreach (var id in r.Passengers.Keys.ToArray()) await ReleasePolicePassenger4229938(session, r, id, besidePlayer: !sceneChange); await SendPoliceButtons4229938(session, r.Npcs.Keys.ToArray(), 0, false); await SendPolicePrisonButtons4229938(session, r, false); r.PrepareNonce = null; if (r.Child != 0) await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(r.Child)); // SPreInteract.OnDisable looks up the unique S002 root before it // clears HasPreInteract, IsPoliceInteracting and playerPoliceStage. // Keep that scene-owned root alive on role changes; deleting both // together caused the observed native null-reference/locked F state. if (sceneChange && r.RootSent) await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(PoliceRootNid4229938)); r.Child = 0; r.Agent = 0; if (sceneChange) { r.Detained = 0; r.PendingPrison = 0; r.PrisonCorrectionAt = 0; r.RootSent = false; r.Npcs.Clear(); r.PausedEscorts.Clear(); r.Generation = -1; } } finally { r.Gate.Release(); } } }