Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.PolicePrison.cs

339 lines
20 KiB
C#

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<bool> 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);
}
}