774 lines
47 KiB
C#
774 lines
47 KiB
C#
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<TcpSession, PoliceRuntime4229938> PoliceRuntimes = new();
|
||
private sealed class PoliceRuntime4229938
|
||
{
|
||
internal readonly SemaphoreSlim Gate = new(1, 1);
|
||
internal readonly Dictionary<ulong, (Vec3 Position, uint Template)> Npcs = new();
|
||
internal readonly List<ulong> Support = [];
|
||
internal readonly Dictionary<uint, uint> 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<ulong, PolicePassenger4229938> Passengers = new();
|
||
internal PolicePassenger4229938? PendingBoarding;
|
||
internal bool Alighting;
|
||
internal uint LastInteraction;
|
||
internal readonly HashSet<ulong> 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<uint> 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<uint, uint> ViolationCdInfos = new();
|
||
}
|
||
[UxContract] private sealed class PoliceJob4229938
|
||
{
|
||
[UxCollection(Count = UxCountEncoding.Int32, ValueObjectEncoding = UxObjectEncoding.Complex)]
|
||
public Dictionary<uint, PoliceDispatch4229938> 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<uint> ViolationInfos = [], CaseInfos = [];
|
||
public PoliceDuty4229938 DutyBasicInfo = new();
|
||
[UxCollection(Count = UxCountEncoding.Int32)] public List<ulong> EscortedNpcs = [];
|
||
public PoliceFakeFile4229938 PoliceFakeFileInfo = new();
|
||
[UxCollection(Count = UxCountEncoding.Int32)] public Dictionary<uint, uint> PeriodInvalidVehicleFine2CountDict = new();
|
||
public uint NextPeriodUpdateTime = 0;
|
||
}
|
||
[UxContract] private sealed class PoliceFakeFile4229938
|
||
{
|
||
[UxCollection(Count = UxCountEncoding.Int32)] public Dictionary<uint, uint> UnlockFileInfoDict = new();
|
||
[UxCollection(Count = UxCountEncoding.Int32)] public List<uint> 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<uint, PoliceDispatch4229938> Infos = new();
|
||
}
|
||
|
||
private static Dictionary<uint, PoliceDispatch4229938> 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<int7,uint,bool> }.
|
||
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<bool> 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(); }
|
||
}
|
||
}
|