339 lines
20 KiB
C#
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);
|
|
}
|
|
}
|