This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

774 lines
47 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(); }
}
}