using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; internal sealed partial class GameRouter { // Police_Prison.prefab (79100336), Gadget pathId 231849593, graph 36001524. // Placement independently present in both native GadgetBlobData tables. internal static readonly Vec3 PolicePrisonEntrance4229938 = new(2379.887939f, 0.474f, 1823.083008f); // PoliceConfig.ArrestNpcPosition[0], used by native SEscortToWard.OnTimelineEnd. // The previous unrelated graph vector was beyond the authored cell slots. internal static readonly Vec3 PolicePrisonInside4229938 = new(2376.8999f, 0.546000004f, 1818.72998f); private const ulong PolicePrisonPid4229938 = 5959555001335678319; private const int PolicePrisonButton4229938 = 8700520; internal static bool PolicePrisonNear4229938(Vec3 position) { var p = PolicePrisonEntrance4229938; float distance = MathF.Pow(position.X - p.X, 2) + MathF.Pow(position.Y - p.Y, 2) + MathF.Pow(position.Z - p.Z, 2); return float.IsFinite(distance) && distance <= 16; } // The client's authored EscortToWard trigger is inside the station, not at // our custom door F marker. A captured native InteractionId=30 occurred // roughly 8 m from that marker; retain a bounded police-station check. private static bool PolicePrisonNativeNear4229938(Vec3 position) { var p = PolicePrisonEntrance4229938; float distance = MathF.Pow(position.X - p.X, 2) + MathF.Pow(position.Y - p.Y, 2) + MathF.Pow(position.Z - p.Z, 2); return float.IsFinite(distance) && distance <= 144; } private static bool PolicePrisonScene4229938(WorldEntryState? state) => state is { Ready: true, ActiveInstanceId: 20001222, PendingTeleportId: 0 }; internal static async Task CanVisitPolicePrison4229938(TcpSession session) { if (!PolicePrisonScene4229938(GetStateIfExists(session)) || GetVehicleRuntimeState(session).CurrentVehicleId != 0 || (HackerDevices4229938.TryGetValue(session, out var device) && (device.Controlled || device.Riding))) return false; if (!PoliceRuntimes.TryGetValue(session, out var r)) return true; await r.Gate.WaitAsync(session.ClosedToken); try { return r.Child == 0 && r.PrepareNonce == null && r.PendingBoarding == null && !r.Alighting; } finally { r.Gate.Release(); } } // The caller owns the police gate. Reuses the native position interaction UI; // never duplicates the existing cell geometry or replaces client functions. private static async Task SendPolicePrisonButtons4229938(TcpSession session, PoliceRuntime4229938 r, bool add) { var state = GetStateIfExists(session); if (!session.Items.TryGetValue(PoliceGateSessionKey4229938, out var value) || value is not TcpSession gate || gate.ClosedToken.IsCancellationRequested) return; // PublishLixiPolice can run during the final phase of a switch to Lixi; // install now, while click authorization still requires switch complete. bool enabled = add && state is { ActiveSpiritTemplateId: LixiSpirit4229938, ActiveSpiritUnitId: not 0 } && (state.PendingSwitchTemplateId == 0 || state.PendingSwitchTemplateId == LixiSpirit4229938) && PolicePrisonScene4229938(state); var script = PolicePrisonButtonsScript4229938(state?.ActiveSpiritUnitId ?? 0, enabled, r.Detained != 0); await gate.NotifyAsync(MethodId.SyncDoToLuaMaster, new UxWriter().UxString(script).ToArray(), session.ClosedToken); } internal static string PolicePrisonButtonsScript4229938(ulong owner, bool add, bool occupied) { static string U64(ulong id) => FormattableString.Invariant($"ulong.new({(uint)id}, {id >> 32})"); string action = occupied ? "prison-return" : "prison-commit"; var bytes = new UxWriter().U8(255).Int7(1).U8(4).U32(PoliceRootNid4229938) .UxString("PolicePrisonRequest").U8(42).UxString(action).I64(0).Bool(false).ToArray(); var p = PolicePrisonEntrance4229938; return $$""" local ok, err = pcall(function() local mgr = L50.L50App.L50Game.InteractBtnMgr local pid = {{U64(PolicePrisonPid4229938)}} mgr:RemoveBtnByType(pid, LX6.Interact.BtnTargetType.Pos, {{PolicePrisonButton4229938}}) if not {{(add ? "true" : "false")}} then return end local owner = {{U64(owner)}} local function visible() local player = gCS.MyPlayerManager.PlayerUnit return player ~= nil and player.Pid == owner and gPoliceJobManager ~= nil end local function click() if not visible() then return end local bytes = { {{string.Join(',', bytes)}} } gClientToGameSceneDelegate:Notify(67130745, function(writer) for i = 1, #bytes do writer:WriteByte(bytes[i]) end end) end local pos = Vector3.New({{p.X.ToString("R", System.Globalization.CultureInfo.InvariantCulture)}}, {{p.Y.ToString("R", System.Globalization.CultureInfo.InvariantCulture)}}, {{p.Z.ToString("R", System.Globalization.CultureInfo.InvariantCulture)}}) local btn = LX6.Interact.PosBtnInfo.New(pid, 4, pos, {{PolicePrisonButton4229938}}, click, visible, 0, 0) btn.exText = "{{(occupied ? "带出收押对象" : "收押押送对象")}}" btn.Dis = 3 btn.pcHideText = false mgr:AddBtn(btn) end) if not ok then print_error("[LIXI-PRISON] position interaction failed: " .. tostring(err)) end """; } internal static async Task<(bool Ok, string Message)> ControlPolicePrison4229938(TcpSession session, string action) { if (action is not ("prison-enable" or "prison-commit" or "prison-return")) return (false, "未知牢房操作。"); var state = GetStateIfExists(session); if (!LixiReady(state) || !PolicePrisonScene4229938(state)) return (false, "请在主城(场景 20001222)切换里希,完成加载后操作。"); if (action == "prison-enable") await PublishLixiPolice4229938(session, LixiSpirit4229938); var r = PoliceRuntimes.GetValue(session, _ => new()); await r.Gate.WaitAsync(session.ClosedToken); try { if (!LixiReady(state) || !PolicePrisonScene4229938(state) || r.Generation != state!.WorldEntryControlGeneration || !r.RootSent) return (false, "场景或角色状态已变化,请重新启用牢房互动。"); if (!session.Items.TryGetValue(PoliceGateSessionKey4229938, out var gateValue) || gateValue is not TcpSession gate || gate.ClosedToken.IsCancellationRequested) return (false, "Gate 连接不可用,请重新登录。"); if (action == "prison-enable") { await SendPolicePrisonButtons4229938(session, r, true); return (true, "已发送原生牢房门口互动:X 2379.888 / Y 0.474 / Z 1823.083。每次收押一人,可带出继续押送;需游戏内确认 F 提示。"); } if (!state.HasLastReportedPlayerTransform || !PolicePrisonNear4229938(state.LastReportedPlayerPosition)) return (false, "请走到警局牢房门口 4 米内;远程 GM 不会隔空收押或带出。"); if (GetVehicleRuntimeState(session).CurrentVehicleId != 0 || (HackerDevices4229938.TryGetValue(session, out var device) && (device.Controlled || device.Riding))) return (false, "请先下车、下机械狗并退出遥控。"); if (r.PrepareNonce != null || r.PendingBoarding != null || r.Alighting || r.PendingPrison != 0) return (false, "请先完成当前盘查初始化或上下车动作。"); bool commit = action == "prison-commit"; ulong id; if (commit) { if (r.Detained != 0) return (false, "此牢房已经收押一人,请先带出。"); // Accept an active confirmed escort, or a SINGLE arrested NPC // temporarily left at the door. Native paired-action HUD may // hide normal F interactions until Q / TemporaryLeave completes. if (r.Child != 0 && r.LastInteraction == 19 && r.Agent != 0 && !r.Passengers.ContainsKey(r.Agent)) id = r.Agent; else if (r.Child == 0) { var paused = r.PausedEscorts.Where(candidate => !r.Passengers.ContainsKey(candidate) && r.Npcs.TryGetValue(candidate, out var item) && PolicePrisonNear4229938(item.Position)).Take(2).ToArray(); if (paused.Length != 1) return (false, "请先押送 NPC 到牢房门口;按 Q 暂离后可按 F 收押。门口有多名暂离对象时,请先继续押送要收押的一人,再使用 GM 收押。"); id = paused[0]; } else return (false, "请先完成盘查并进入押送;车内 NPC 必须先押送下车。"); } else { if (r.Detained == 0) return (false, "牢房中没有本次会话收押的 NPC。"); if (r.Child != 0) return (false, "请先结束当前盘查或押送,再带出牢房内的 NPC。"); id = r.Detained; } if (!r.Npcs.ContainsKey(id)) return (false, "目标 NPC 已卸载,请重新生成并押送。"); if (commit && r.Child != 0) { // The active escort owns the native two-actor animation. Ask its // PoliceJobManager event to emit InteractionId 30; the reaction // and custody placement happen only after the client ends it. // AskPoliceEscortInteract is ExportToLua in this client build; // EscortToWard_Story is NOT exported and silently failed here. string target = FormattableString.Invariant($"ulong.new({(uint)id}, {id >> 32})"); string startWard = $$""" local ok, err = pcall(function() if gPoliceJobManager == nil or gPoliceJobManager.cs == nil then error('PoliceJobManager unavailable') end gPoliceJobManager.cs:AskPoliceEscortInteract({{target}}, 30) end) if not ok then print_error('[LIXI-PRISON] native ward start failed: ' .. tostring(err)) end """; await gate.NotifyAsync(MethodId.SyncDoToLuaMaster, new UxWriter().UxString(startWard).ToArray(), session.ClosedToken); session.Log.Info($"[LIXI-PRISON] requested native EscortToWard npc={id}; awaiting InteractionId=30 and InteractEnd"); return (true, "已请求原生押送进牢房过场;等待客户端动作结束后收押。若无动作,请查看 LIXI-PRISON 日志。"); } return await CompletePolicePrison4229938(session, r, state, id, commit); } finally { r.Gate.Release(); } } // The police gate is held; native path calls this only after InteractEnd. private static async Task<(bool Ok, string Message)> CompletePolicePrison4229938( TcpSession session, PoliceRuntime4229938 r, WorldEntryState state, ulong id, bool commit) { if (r.Child != 0 || r.PendingPrison != 0) return (false, "请先等待原生押送动作结束。"); if (commit ? r.Detained != 0 : r.Detained != id) return (false, "牢房收押状态已变化,请重新操作。"); if (!r.Npcs.TryGetValue(id, out var npc)) return (false, "目标 NPC 已卸载,请重新生成并押送。"); var enemies = GetEnemyRuntimeState(session); await enemies.HackDispatchGate.WaitAsync(session.ClosedToken); try { if (!LixiReady(state) || !PolicePrisonScene4229938(state) || state.WorldEntryControlGeneration != r.Generation) return (false, "等待期间角色或场景变化,本次收押操作已取消。"); lock (enemies.SyncRoot) { if (enemies.Enemies.TryGetValue(id, out var enemy) && (enemy.SceneGeneration != r.Generation || !float.IsFinite(enemy.Hp) || enemy.Hp <= 0)) return (false, "不能收押或带出已死亡/已换场景的 NPC。"); } var position = commit ? PolicePrisonInside4229938 : PolicePrisonEntrance4229938; // Mark custody before sending movement so concurrent AI cannot // restart pursuit between native escort cleanup and placement. // Retain the occupied slot on a failed return send so the user // can retry, rather than leaving an unreachable prisoner. r.Detained = id; if (commit) r.PausedEscorts.Remove(id); r.Npcs[id] = (position, npc.Template); lock (enemies.SyncRoot) { if (enemies.Enemies.TryGetValue(id, out var enemy)) { enemy.IsPoliceDetained = true; enemy.Position = position; enemy.AttackAiFollowing = false; enemy.AttackAiFollowTarget = 0; enemy.AttackAiTarget = 0; enemy.AttackAiCastSkill = 0; enemy.AttackAiCastTarget = 0; enemy.AttackAiCastUntil = 0; } } await PlacePolicePrisonNpc4229938(session, id, position); if (!commit) { r.Detained = 0; r.PausedEscorts.Add(id); long worker = 0; lock (enemies.SyncRoot) { if (enemies.Enemies.TryGetValue(id, out var enemy)) { enemy.IsPoliceDetained = false; // A short grace period allows F/escort to resume. enemy.HackBusyUntilTicks = Environment.TickCount64 + 60000; if (enemy.AttackAiEnabled && enemies.AttackAiWorkerGeneration == 0) { enemies.AttackAiWorkerGeneration = r.Generation; worker = ++enemies.AttackAiWorkerId; } } } if (worker != 0) _ = RunEnemyAttackAiAsync(session, r.Generation, worker); } await SendPoliceButtons4229938(session, [id], state.ActiveSpiritUnitId, !commit); await SendPolicePrisonButtons4229938(session, r, true); session.Log.Info($"[LIXI-PRISON] {(commit ? "committed" : "returned")} npc={id} generation={r.Generation} position={position}; server-managed custody, native placement/UI"); return (true, commit ? "已下发收押:结束押送、停止战斗并移入牢房。门口互动已改为“带出收押对象”;请确认 NPC 实际位置。" : "已下发带出:NPC 移至牢房门口,靠近按 F 可继续押送。"); } finally { enemies.HackDispatchGate.Release(); } } // Runs under the police gate. Both messages can arrive in either order. private static async Task TryCompleteNativePolicePrison4229938( TcpSession session, PoliceRuntime4229938 r, WorldEntryState state) { if (!r.PrisonInteractionEnded || !r.PrisonTimelineEnded || r.PendingPrison == 0 || r.PendingPrison != r.Agent || r.LastInteraction != 30 || r.Child == 0) return; var npc = r.PendingPrison; await ClosePoliceInteraction4229938(session, r, "verified native ward timeline end"); var result = await CompletePolicePrison4229938(session, r, state, npc, commit: true); session.Log.Info($"[LIXI-PRISON] verified timeline end npc={npc} committed={result.Ok}: {result.Message}"); if (!result.Ok) await DeliveryText4229938(session, result.Message); } private static async Task HandlePolicePrisonTimelineEnd4229938(TcpSession session, string? token) { if (string.IsNullOrEmpty(token) || !PoliceRuntimes.TryGetValue(session, out var r)) return; await r.Gate.WaitAsync(session.ClosedToken); try { var state = GetStateIfExists(session); if (!LixiReady(state) || !PolicePrisonScene4229938(state) || r.Generation != state!.WorldEntryControlGeneration || r.PrisonTimelineOwner != state.ActiveSpiritUnitId || r.PrisonTimelineToken != token || r.PendingPrison == 0 || r.Child == 0 || r.LastInteraction != 30 || r.PendingPrison != r.Agent) return; r.PrisonTimelineEnded = true; await TryCompleteNativePolicePrison4229938(session, r, state); } finally { r.Gate.Release(); } } private static async Task WatchPolicePrison4229938( TcpSession session, uint child, ulong npc, int generation, ulong owner) { try { await Task.Delay(TimeSpan.FromSeconds(65), session.ClosedToken); 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 || state.ActiveSpiritUnitId != owner || r.Generation != generation || r.Child != child || r.Agent != npc || r.PendingPrison != npc) return; await ClosePoliceInteraction4229938(session, r, "native ward timeline timeout; no custody committed"); await SendPoliceButtons4229938(session, [npc], owner, true); await DeliveryText4229938(session, "牢房过场未完成,已安全退出押送;NPC 未收押,可重新盘查尝试。"); session.Log.Warn($"[LIXI-PRISON] native timeline timeout npc={npc}; custody not committed"); } finally { r.Gate.Release(); } } catch (OperationCanceledException) when (session.ClosedToken.IsCancellationRequested) { } catch (Exception ex) { session.Log.Warn($"[LIXI-PRISON] timeline timeout cleanup failed: {ex.Message}"); } } private static async Task PlacePolicePrisonNpc4229938(TcpSession session, ulong id, Vec3 position) { await session.NotifyAsync(MethodId.SyncBreakSkill, UxSerializer.Serialize(CombatCodec.BreakSkill(id)), session.ClosedToken); await session.NotifyAsync(MethodId.SyncEnemyStopMove, UxSerializer.Serialize(new SceneMethods.EnemyAiStopMove4229938 { pid = id, pos = new(position.X, position.Y, position.Z), facing = 0 }), session.ClosedToken); await session.NotifyAsync(MethodId.SyncUnitPosition_P, UxSerializer.Serialize(WorldCodec.PositionP(id, position, 0, setPositionType: 6)), session.ClosedToken); } // Called under the police gate on movement reports, not a permanent timer. private static async Task HoldPolicePrisonNpc4229938(TcpSession session, PoliceRuntime4229938 r, Vec3 position) { var p = PolicePrisonInside4229938; float d2 = MathF.Pow(p.X-position.X, 2) + MathF.Pow(p.Y-position.Y, 2) + MathF.Pow(p.Z-position.Z, 2); if (!float.IsFinite(d2) || d2 <= 0.25f || Environment.TickCount64 < r.PrisonCorrectionAt) return; r.PrisonCorrectionAt = Environment.TickCount64 + 2000; await PlacePolicePrisonNpc4229938(session, r.Detained, p); } }