Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.Wanted.cs
T

860 lines
36 KiB
C#
Raw 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 Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 开放世界通缉系统(Wanted)——「警车版」。
///
/// 2026-09-23 自捐赠端 <c>PrivateServer修改_miyuki_fix</c> 的 <c>GameRouter.Wanted.cs</c> 移植。
///
/// ★ 与捐赠端的关键差异(都是刻意的):
/// 1. **只做警车,不做步行警员**。捐赠端的步行警员靠它自己的 crowd agent 管线
/// (<c>CrowdAgent</c> / <c>PushLogicAgentHandoff</c> / <c>PushCrowdTaskCommand</c> /
/// <c>CrowdAdvanceEstimate</c>),本方没有那套类型,且它那份日志早于它自己的最后一次改动
/// ⇒ 步行警员那半根本没被实机验证过。警车由**客户端自己的车辆 AI** 驱动
/// (<c>NavConfigId=1002</c> / <c>ChaseDirectlySpeed=26</c>),服务端只负责"派"和"撤"。
/// 2. 因此**自动逃脱/被捕的判据换了**。捐赠端用"最近警员距离"(它自己算的位置),
/// 警车版拿不到警车位置(客户端 AI 不外报),改成两个服务端真信号:
/// · 通缉持续超过 <see cref="WantedPursuitTimeoutSeconds4229938"/> ⇒ 自动逃脱;
/// · 玩家倒地(<c>state.PlayerDowned</c>)⇒ 自动被捕。
/// 3. 没有 <c>resetAfterArrest</c> / <c>RestartCurrentWorld</c>(本方没有那个方法),
/// 被捕就是清场 + 罚款。
/// 4. 不碰 <c>CrimeGameplay</c>(商店抢劫/偷车任务链),那半依赖本方完全没有的
/// GmTasks 任务状态物件。
///
/// ⛑️ 完全绕开官方犯罪通道:不接 SyncAgentCrimeAdd(68831313)、
/// ReportPoliceCrimeEvent(67146776)、GmAddCrimeValue(69946581)。犯罪值是服务端自己加的,
/// 代价是"只有服务端认可的行为会涨值",好处是不会被官方那些未摸清的帧时序炸客户端。
///
/// 回滚:把 <c>wanted.enabled</c> 置 false(或直接删本文件 + 摘掉三处挂钩)。
/// </summary>
internal sealed partial class GameRouter
{
private const uint WantedMaxLevel4229938 = 5;
private const uint WantedMaxValue4229938 = 9_999;
private const double WantedRewardPerLevel4229938 = 5_000d;
private const double WantedPenaltyPerLevel4229938 = 2_500d;
private const long WantedVehicleHitRearmMs4229938 = 700L;
// 高水位落盘(IdCounterStore §6.10):警车 entityId 重启后不能复用。
private const long WantedVehicleEntityFloor = 430_000_000_000L;
private const string WantedVehicleEntityCounter = "Wanted.VehicleEntity";
/// <summary>警车实体号段 430_000_000_000+(不与本方车辆 8_200e15 / 行人 8_300e15 冲突)。</summary>
private const int WantedPursuitTickMs4229938 = 1_000;
private const int WantedPursuitTimeoutSeconds4229938 = 90;
/// <summary>
/// 三台真警车模板(已在本方 <c>ClientData/4229938/Configs/VehicleConfig.json</c> 复核):
/// 81007029 = T_PoliceCar01a、81007078 = U_NewPolice01a、81004027 = U_PoliceCar02a。
/// </summary>
private static readonly uint[] WantedPoliceVehicleTemplates4229938 =
[
81007029u,
81007078u,
81004027u,
];
internal sealed record WantedCrimeSettings4229938(
bool Enabled,
uint NpcHitPoints,
uint NpcKillPoints,
uint VehicleHitPoints,
uint VehicleDestroyPoints,
bool AutoEscapeEnabled,
bool ArrestOnPlayerDown,
int PursuitTimeoutSeconds,
int VehiclesPerLevel,
double RewardPerLevel,
double PenaltyPerLevel);
private static readonly object WantedCrimeSettingsLock4229938 = new();
private static WantedCrimeSettings4229938 s_wantedCrimeSettings4229938 = new(
Enabled: true,
NpcHitPoints: 10,
NpcKillPoints: 60,
VehicleHitPoints: 5,
VehicleDestroyPoints: 50,
AutoEscapeEnabled: true,
ArrestOnPlayerDown: true,
PursuitTimeoutSeconds: WantedPursuitTimeoutSeconds4229938,
VehiclesPerLevel: 1,
RewardPerLevel: WantedRewardPerLevel4229938,
PenaltyPerLevel: WantedPenaltyPerLevel4229938);
private sealed class WantedPursuitRuntime
{
internal CancellationTokenSource? Cancellation;
internal Task? Loop;
internal int Generation;
}
private static readonly object WantedPursuitLock4229938 = new();
private static readonly Dictionary<TcpSession, WantedPursuitRuntime> WantedPursuitRuntimes4229938 = [];
internal sealed class WantedActionOptions
{
public string Action { get; set; } = string.Empty;
public uint Level { get; set; }
public uint CrimeValue { get; set; }
public uint Points { get; set; } = 25;
public int Seconds { get; set; } = 10;
public double? Amount { get; set; }
public bool? Enabled { get; set; }
public uint NpcHitPoints { get; set; }
public uint NpcKillPoints { get; set; }
public uint VehicleHitPoints { get; set; }
public uint VehicleDestroyPoints { get; set; }
public bool? AutoEscapeEnabled { get; set; }
public bool? ArrestOnPlayerDown { get; set; }
public int PursuitTimeoutSeconds { get; set; }
public int VehiclesPerLevel { get; set; }
public double RewardPerLevel { get; set; }
public double PenaltyPerLevel { get; set; }
}
internal static WantedCrimeSettings4229938 WantedCrimeSettings()
{
lock (WantedCrimeSettingsLock4229938)
return s_wantedCrimeSettings4229938;
}
private static WantedCrimeSettings4229938 SetWantedCrimeSettings(WantedActionOptions options)
{
lock (WantedCrimeSettingsLock4229938)
{
var current = s_wantedCrimeSettings4229938;
return s_wantedCrimeSettings4229938 = new WantedCrimeSettings4229938(
options.Enabled ?? current.Enabled,
Math.Clamp(options.NpcHitPoints, 0u, WantedMaxValue4229938),
Math.Clamp(options.NpcKillPoints, 0u, WantedMaxValue4229938),
Math.Clamp(options.VehicleHitPoints, 0u, WantedMaxValue4229938),
Math.Clamp(options.VehicleDestroyPoints, 0u, WantedMaxValue4229938),
options.AutoEscapeEnabled ?? current.AutoEscapeEnabled,
options.ArrestOnPlayerDown ?? current.ArrestOnPlayerDown,
Math.Clamp(options.PursuitTimeoutSeconds <= 0 ? current.PursuitTimeoutSeconds : options.PursuitTimeoutSeconds, 5, 600),
Math.Clamp(options.VehiclesPerLevel <= 0 ? current.VehiclesPerLevel : options.VehiclesPerLevel, 1, 3),
Math.Clamp(options.RewardPerLevel <= 0d ? current.RewardPerLevel : options.RewardPerLevel, 0d, 1_000_000d),
Math.Clamp(options.PenaltyPerLevel <= 0d ? current.PenaltyPerLevel : options.PenaltyPerLevel, 0d, 1_000_000d));
}
}
internal static object WantedReport(TcpSession? session)
{
var settings = WantedCrimeSettings();
var state = session is null ? null : GetStateIfExists(session);
if (state is null)
return new
{
ok = true,
online = false,
enabled = settings.Enabled,
npcHitPoints = settings.NpcHitPoints,
npcKillPoints = settings.NpcKillPoints,
vehicleHitPoints = settings.VehicleHitPoints,
vehicleDestroyPoints = settings.VehicleDestroyPoints,
autoEscapeEnabled = settings.AutoEscapeEnabled,
arrestOnPlayerDown = settings.ArrestOnPlayerDown,
pursuitTimeoutSeconds = settings.PursuitTimeoutSeconds,
vehiclesPerLevel = settings.VehiclesPerLevel,
rewardPerLevel = settings.RewardPerLevel,
penaltyPerLevel = settings.PenaltyPerLevel,
note = "等待游戏会话进入世界;参数可以先设置,出警与结算需要进入世界后验证",
};
lock (state.SyncRoot)
{
var remainingMs = state.WantedEscapeActive
? Math.Max(0, state.WantedEscapeDeadlineTicks - Environment.TickCount64)
: 0;
var elapsedMs = state.WantedStartedTicks == 0
? 0
: Math.Max(0, Environment.TickCount64 - state.WantedStartedTicks);
return new
{
ok = true,
online = true,
ready = state.Ready,
crimeValue = state.WantedCrimeValue,
wantedLevel = state.WantedLevel,
chaseActive = state.WantedChaseActive,
policeVehicles = state.WantedPoliceVehicleIds.Order().ToArray(),
policeVehicleCount = state.WantedPoliceVehicleIds.Count,
pursuitElapsedSeconds = (int)(elapsedMs / 1000),
escapeActive = state.WantedEscapeActive,
escapeRemainingSeconds = (int)Math.Ceiling(remainingMs / 1000d),
escapeCount = state.WantedEscapeCount,
arrestCount = state.WantedArrestCount,
rewardTotal = state.WantedRewardTotal,
penaltyTotal = state.WantedPenaltyTotal,
enabled = settings.Enabled,
npcHitPoints = settings.NpcHitPoints,
npcKillPoints = settings.NpcKillPoints,
vehicleHitPoints = settings.VehicleHitPoints,
vehicleDestroyPoints = settings.VehicleDestroyPoints,
autoEscapeEnabled = settings.AutoEscapeEnabled,
arrestOnPlayerDown = settings.ArrestOnPlayerDown,
pursuitTimeoutSeconds = settings.PursuitTimeoutSeconds,
vehiclesPerLevel = settings.VehiclesPerLevel,
rewardPerLevel = settings.RewardPerLevel,
penaltyPerLevel = settings.PenaltyPerLevel,
npcHitCount = state.WantedNpcHitCount,
npcKillCount = state.WantedNpcKillCount,
vehicleHitCount = state.WantedVehicleHitCount,
vehicleDestroyCount = state.WantedVehicleDestroyCount,
lastIncident = state.WantedLastIncident,
lastOutcome = state.WantedLastOutcome,
lastSync = state.WantedLastSync,
note = "警车由客户端车辆 AI 驱动,服务端只负责派/撤;自动逃脱按通缉时长判定,自动被捕按玩家倒地判定",
};
}
}
internal static async Task<object> WantedActionAsync(
TcpSession? session,
WantedActionOptions options,
CancellationToken token)
{
var action = (options.Action ?? string.Empty).Trim().ToLowerInvariant();
if (action == "configure")
{
SetWantedCrimeSettings(options);
return WantedReport(session);
}
if (session is null)
return new { ok = false, error = "session offline" };
var state = GetStateIfExists(session);
if (state is null || !state.Ready)
return new { ok = false, error = "world state not ready (enter the world first)" };
switch (action)
{
case "addviolation":
await AddWantedCrimeAsync(session, state, options.Points == 0 ? 25u : options.Points,
"gm-violation", token);
break;
case "setvalue":
await SetWantedValueAsync(session, state, options.CrimeValue, "gm-value", token);
break;
case "setlevel":
await SetWantedLevelAsync(session, state,
Math.Clamp(options.Level == 0 ? 1u : options.Level, 1u, WantedMaxLevel4229938),
"gm-level", token);
break;
case "escape":
await BeginWantedEscapeAsync(session, state, options.Seconds, options.Amount, token);
break;
case "completeescape":
await CompleteWantedEscapeAsync(session, state, expectedGeneration: null, options.Amount,
"gm-complete", token);
break;
case "arrest":
await CompleteWantedArrestAsync(session, state, options.Amount, token);
break;
case "clear":
await ClearWantedAsync(session, state, "gm-clear", token);
break;
default:
return new { ok = false, error = $"unknown wanted action '{options.Action}'" };
}
return WantedReport(session);
}
// ── 犯罪值状态机 ────────────────────────────────────────────────────────────────────
private static async Task SetWantedValueAsync(
TcpSession session,
WorldEntryState state,
uint requestedValue,
string reason,
CancellationToken token)
{
var value = Math.Min(requestedValue, WantedMaxValue4229938);
if (value == 0)
{
await ClearWantedAsync(session, state, reason, token);
return;
}
var level = WantedLevelForValue4229938(value);
if (level == 0)
{
bool clearChase;
lock (state.SyncRoot)
{
state.WantedCrimeValue = value;
state.WantedLastOutcome = $"{reason}:below-threshold";
clearChase = state.WantedChaseActive || state.WantedLevel != 0;
state.WantedLevel = 0;
}
if (clearChase)
await ClearWantedDispatchAsync(session, state, $"below-threshold:{reason}", token);
return;
}
await SetWantedLevelAsync(session, state, level, reason, token, value);
}
private static async Task AddWantedCrimeAsync(
TcpSession session,
WorldEntryState state,
uint points,
string reason,
CancellationToken token)
{
if (points == 0)
return;
uint value;
uint oldLevel;
uint newLevel;
lock (state.SyncRoot)
{
oldLevel = state.WantedLevel;
state.WantedCrimeValue = (uint)Math.Min(WantedMaxValue4229938,
(ulong)state.WantedCrimeValue + points);
value = state.WantedCrimeValue;
newLevel = WantedLevelForValue4229938(value);
state.WantedLastOutcome = $"{reason}:+{points}";
}
// 同星级内涨值只记日志,不重新派车(否则打一下就刷一批警车)。
if (newLevel == 0 || (newLevel == oldLevel && oldLevel != 0))
{
session.Log.Info($"[WANTED] crime +{points} value={value} level={newLevel} reason={reason}");
return;
}
await SetWantedLevelAsync(session, state, newLevel, reason, token, value);
}
/// <summary>
/// 犯罪事件入口。由三处挂钩调用:npc-hit / npc-kill(玩家打路人、敌人)、
/// vehicle-hit / vehicle-destroy(玩家砸车)。targetId=0 直接忽略。
/// </summary>
internal static async Task RegisterWantedCrimeIncidentAsync(
TcpSession session,
string incident,
ulong targetId,
ulong attackerId,
CancellationToken token)
{
var state = GetStateIfExists(session);
if (state is null || !state.Ready || targetId == 0)
return;
var settings = WantedCrimeSettings();
if (!settings.Enabled)
return;
uint points;
var now = Environment.TickCount64;
lock (state.SyncRoot)
{
var playerId = state.ActiveSpiritUnitId == 0 ? Profile.InitialUnitId : state.ActiveSpiritUnitId;
// 玩家自己挨打不算犯罪(AskPlayHurtEffect 也会在玩家被打时上报)。
if (targetId == playerId)
return;
// attackerId=0 = 挂钩点拿不到攻击者(例如客户端自己报的敌人倒地),按玩家算;
// 非 0 且不是玩家 ⇒ 不是玩家干的,不计犯罪值。
if (attackerId != 0 && attackerId != playerId)
return;
switch (incident)
{
case "npc-hit":
state.WantedNpcHitCount++;
points = settings.NpcHitPoints;
break;
case "npc-kill":
if (!state.WantedKilledNpcIds.Add(targetId))
return;
state.WantedNpcKillCount++;
points = settings.NpcKillPoints;
break;
case "vehicle-hit":
// 一梭子/一次撞击会连报,700ms 内同一台车只记一次。
if (state.WantedLastIncidentTarget == targetId &&
now - state.WantedLastVehicleHitTicks < WantedVehicleHitRearmMs4229938)
return;
state.WantedLastVehicleHitTicks = now;
state.WantedVehicleHitCount++;
points = settings.VehicleHitPoints;
break;
case "vehicle-destroy":
if (!state.WantedDestroyedVehicleIds.Add(targetId))
return;
state.WantedVehicleDestroyCount++;
points = settings.VehicleDestroyPoints;
break;
default:
return;
}
state.WantedLastIncidentTarget = targetId;
state.WantedLastIncident = $"{incident}:target={targetId}:points={points}";
}
session.Log.Info($"[WANTED] incident={incident} target={targetId} attacker={attackerId} points={points}");
await AddWantedCrimeAsync(session, state, points, incident, token);
}
private static async Task SetWantedLevelAsync(
TcpSession session,
WorldEntryState state,
uint level,
string reason,
CancellationToken token,
uint? explicitValue = null)
{
level = Math.Clamp(level, 1u, WantedMaxLevel4229938);
await ClearWantedDispatchAsync(session, state, $"replace:{reason}", token);
lock (state.SyncRoot)
{
state.WantedLevel = level;
state.WantedCrimeValue = explicitValue ?? MinimumWantedValue4229938(level);
state.WantedChaseActive = true;
state.WantedStartedTicks = Environment.TickCount64;
CancelWantedEscapeLocked(state);
state.WantedLastOutcome = reason;
}
// ① HUD 星级(68841232,u32 level)
await session.NotifyAsync(MethodId.SyncPlayerCrimeLevel, UxSerializer.Serialize(level), token);
// ② 派警车
await SpawnWantedPoliceAsync(session, state, level, token);
// ③ 起追捕循环(超时逃脱 / 倒地被捕)
StartWantedPursuitLoop(session, state);
session.Log.Info($"[WANTED] set level={level} value={state.WantedCrimeValue} reason={reason}");
}
// ── 出警 ───────────────────────────────────────────────────────────────────────────
private static async Task SpawnWantedPoliceAsync(
TcpSession session,
WorldEntryState state,
uint level,
CancellationToken token)
{
var settings = WantedCrimeSettings();
Vec3 playerPosition;
float playerFacing;
lock (state.SyncRoot)
{
playerPosition = state.HasLastReportedPlayerTransform
? state.LastReportedPlayerPosition
: state.WorldEntryCreateHeroPosition;
playerFacing = state.HasLastReportedPlayerTransform
? state.LastReportedPlayerRotation.Y
: state.WorldEntryCreateHeroFacing;
}
var count = Math.Clamp((int)level * Math.Max(1, settings.VehiclesPerLevel), 1,
WantedPoliceVehicleTemplates4229938.Length);
var infos = new List<GameMethods.PoliceVehicleSpawnClientInfo4229938>(count);
var ids = new List<ulong>(count);
for (var i = 0; i < count; i++)
{
var id = unchecked((ulong)IdCounterStore.Next(WantedVehicleEntityCounter, WantedVehicleEntityFloor));
var spawn = WantedPoliceSpawnPosition(playerPosition, playerFacing, i);
var facing = NormalizeWantedFacing(playerFacing + 180f);
ids.Add(id);
infos.Add(new GameMethods.PoliceVehicleSpawnClientInfo4229938
{
Id = id,
VehicleId = WantedPoliceVehicleTemplates4229938[i],
Position = new SceneMethods.UxVector3(spawn.X, spawn.Y, spawn.Z),
Facing = facing,
EulerAngles = new SceneMethods.UxVector3(0f, facing, 0f),
});
}
lock (state.SyncRoot)
{
foreach (var id in ids)
state.WantedPoliceVehicleIds.Add(id);
}
await session.NotifyAsync(MethodId.SyncSpawnPoliceVehicles, UxSerializer.Serialize(
new GameMethods.SyncSpawnPoliceVehicles4229938
{
spawnInfos = infos,
configInfo = new GameMethods.PoliceVehicleSpawnConfigInfo4229938
{
ChaseRange = 120f,
ChaseDirectlyRange = 35f,
ApprehendRange = 5f,
NavConfigId = 1002,
ChaseDirectlyConfigId = 1001,
PatrolSpeed = 10f,
ChaseSpeed = 20f,
ChaseDirectlySpeed = 26f,
},
}), token);
var targetVehicleId = GetVehicleRuntimeState(session).CurrentVehicleId;
if (ids.Count != 0)
{
await session.NotifyAsync(MethodId.SyncPlayerPoliceChasedVehicles,
UxSerializer.Serialize(ids[0]), token);
}
if (targetVehicleId != 0)
{
// 玩家在车上 ⇒ 让每台警车咬住那台车(客户端 AI 自己追)。
foreach (var id in ids)
{
await session.NotifyAsync(MethodId.SyncPoliceDispatchVehicleChase, UxSerializer.Serialize(
new GameMethods.SyncPoliceDispatchVehicleChase4229938
{
myVehicleEntityId = id,
targetVehicleEntityId = targetVehicleId,
}), token);
}
}
lock (state.SyncRoot)
state.WantedLastSync = $"hud-level:{level}; police:{ids.Count}; targetVehicle:{targetVehicleId}";
session.Log.Info($"[WANTED] spawned police count={ids.Count} targetVehicle={targetVehicleId} templates={string.Join(',', infos.Select(x => x.VehicleId))}");
}
// ── 追捕循环(警车版:只判「超时逃脱」与「倒地被捕」)─────────────────────────────
private static void StartWantedPursuitLoop(TcpSession session, WorldEntryState state)
{
CancellationTokenSource cts;
int generation;
lock (WantedPursuitLock4229938)
{
if (!WantedPursuitRuntimes4229938.TryGetValue(session, out var runtime))
{
runtime = new WantedPursuitRuntime();
WantedPursuitRuntimes4229938[session] = runtime;
}
runtime.Cancellation?.Cancel();
runtime.Cancellation?.Dispose();
cts = new CancellationTokenSource();
runtime.Cancellation = cts;
generation = ++runtime.Generation;
runtime.Loop = RunWantedPursuitLoopAsync(session, state, generation, cts.Token);
}
}
private static async Task RunWantedPursuitLoopAsync(
TcpSession session,
WorldEntryState state,
int generation,
CancellationToken token)
{
try
{
while (!token.IsCancellationRequested)
{
await Task.Delay(WantedPursuitTickMs4229938, token);
// 规则 37:客户端挂起时后台推送必须停(否则 TCP 灌满 ⇒ 判定断线)。
if (IsBackgroundPushPaused(session))
continue;
if (GetStateIfExists(session) is null)
return;
bool chaseActive;
bool playerDowned;
long startedTicks;
lock (state.SyncRoot)
{
chaseActive = state.WantedChaseActive && state.WantedLevel > 0;
playerDowned = state.PlayerDowned;
startedTicks = state.WantedStartedTicks;
}
if (!chaseActive)
continue;
lock (WantedPursuitLock4229938)
{
if (!WantedPursuitRuntimes4229938.TryGetValue(session, out var runtime) ||
runtime.Generation != generation)
return;
}
var settings = WantedCrimeSettings();
if (settings.ArrestOnPlayerDown && playerDowned)
{
session.Log.Info("[WANTED] automatic arrest (player downed)");
await CompleteWantedArrestAsync(session, state, requestedPenalty: null, CancellationToken.None);
return;
}
if (settings.AutoEscapeEnabled && startedTicks != 0 &&
Environment.TickCount64 - startedTicks >= settings.PursuitTimeoutSeconds * 1_000L)
{
session.Log.Info($"[WANTED] automatic escape (pursuit timeout {settings.PursuitTimeoutSeconds}s)");
await CompleteWantedEscapeAsync(session, state, expectedGeneration: null,
requestedReward: null, "timeout", CancellationToken.None);
return;
}
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
session.Log.Warn($"[WANTED] pursuit loop stopped: {ex.Message}");
}
}
// ── 结算 ───────────────────────────────────────────────────────────────────────────
private static async Task BeginWantedEscapeAsync(
TcpSession session,
WorldEntryState state,
int requestedSeconds,
double? requestedReward,
CancellationToken token)
{
int generation;
int seconds;
lock (state.SyncRoot)
{
if (!state.WantedChaseActive || state.WantedLevel == 0)
throw new InvalidOperationException("no active pursuit");
seconds = Math.Clamp(requestedSeconds <= 0 ? 10 : requestedSeconds, 3, 120);
generation = ++state.WantedEscapeGeneration;
state.WantedEscapeActive = true;
state.WantedEscapeDurationSeconds = seconds;
state.WantedEscapeDeadlineTicks = Environment.TickCount64 + seconds * 1_000L;
state.WantedPendingReward = requestedReward ?? state.WantedLevel * WantedCrimeSettings().RewardPerLevel;
state.WantedLastOutcome = "escape-countdown";
}
_ = CompleteWantedEscapeAfterDelayAsync(session, generation, seconds);
await Task.CompletedTask;
session.Log.Info($"[WANTED] escape started seconds={seconds} generation={generation}");
}
private static async Task CompleteWantedEscapeAfterDelayAsync(TcpSession session, int generation, int seconds)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(seconds));
var state = GetStateIfExists(session);
if (state is not null)
await CompleteWantedEscapeAsync(session, state, generation, null, "timer", CancellationToken.None);
}
catch (Exception ex)
{
session.Log.Warn($"[WANTED] escape timer failed: {ex.Message}");
}
}
private static async Task CompleteWantedEscapeAsync(
TcpSession session,
WorldEntryState state,
int? expectedGeneration,
double? requestedReward,
string reason,
CancellationToken token)
{
double reward;
lock (state.SyncRoot)
{
if (expectedGeneration.HasValue &&
(!state.WantedEscapeActive || state.WantedEscapeGeneration != expectedGeneration.Value))
return;
if (!state.WantedChaseActive || state.WantedLevel == 0)
return;
reward = requestedReward ?? (state.WantedEscapeActive
? state.WantedPendingReward
: state.WantedLevel * WantedCrimeSettings().RewardPerLevel);
reward = Math.Max(0d, double.IsFinite(reward) ? reward : 0d);
state.WantedRewardTotal += reward;
state.WantedEscapeCount++;
state.WantedLastOutcome = "escape-success";
CancelWantedEscapeLocked(state);
}
await ClearWantedDispatchAsync(session, state, $"escape:{reason}", token);
lock (state.SyncRoot)
{
state.WantedCrimeValue = 0;
state.WantedLevel = 0;
}
await session.NotifyAsync(MethodId.SyncPlayerCrimeLevel, UxSerializer.Serialize(0u), token);
if (reward > 0)
{
var changes = new EconomyChanges4229938 { MoneyDelta = reward };
await ApplyEconomy4229938Async(new Connection(session, token), state, changes, "wanted-escape");
}
session.Log.Info($"[WANTED] escaped reward={reward:0.##}");
}
private static async Task CompleteWantedArrestAsync(
TcpSession session,
WorldEntryState state,
double? requestedPenalty,
CancellationToken token)
{
double penalty;
ulong arrestingVehicle;
lock (state.SyncRoot)
{
if (!state.WantedChaseActive || state.WantedLevel == 0)
throw new InvalidOperationException("no active pursuit");
penalty = requestedPenalty ?? state.WantedLevel * WantedCrimeSettings().PenaltyPerLevel;
penalty = Math.Max(0d, double.IsFinite(penalty) ? penalty : 0d);
state.WantedPenaltyTotal += penalty;
state.WantedArrestCount++;
state.WantedLastOutcome = "arrested";
arrestingVehicle = state.WantedPoliceVehicleIds.Order().FirstOrDefault();
CancelWantedEscapeLocked(state);
}
if (arrestingVehicle != 0)
{
await session.NotifyAsync(MethodId.SyncPoliceBeginArrest, UxSerializer.Serialize(
new GameMethods.SyncPoliceBeginArrest4229938
{
agentId = arrestingVehicle,
targetPid = Profile.PlayerPid,
ArrestType = 1,
}), token);
}
await ClearWantedDispatchAsync(session, state, "arrest", token);
lock (state.SyncRoot)
{
state.WantedCrimeValue = 0;
state.WantedLevel = 0;
}
await session.NotifyAsync(MethodId.SyncPlayerCrimeLevel, UxSerializer.Serialize(0u), token);
if (penalty > 0)
{
var changes = new EconomyChanges4229938 { MoneyDelta = -penalty };
await ApplyEconomy4229938Async(new Connection(session, token), state, changes, "wanted-arrest");
}
session.Log.Info($"[WANTED] arrested penalty={penalty:0.##}");
}
private static async Task ClearWantedAsync(
TcpSession session,
WorldEntryState state,
string reason,
CancellationToken token)
{
await ClearWantedDispatchAsync(session, state, reason, token);
lock (state.SyncRoot)
{
state.WantedCrimeValue = 0;
state.WantedLevel = 0;
state.WantedStartedTicks = 0;
state.WantedLastOutcome = "cleared";
CancelWantedEscapeLocked(state);
}
await session.NotifyAsync(MethodId.SyncPlayerCrimeLevel, UxSerializer.Serialize(0u), token);
}
private static async Task ClearWantedDispatchAsync(
TcpSession session,
WorldEntryState state,
string reason,
CancellationToken token)
{
ulong[] ids;
bool hadChase;
lock (state.SyncRoot)
{
ids = state.WantedPoliceVehicleIds.Order().ToArray();
hadChase = state.WantedChaseActive || ids.Length != 0;
state.WantedPoliceVehicleIds.Clear();
state.WantedStartedTicks = 0;
state.WantedChaseActive = false;
}
CancellationTokenSource? cancellation = null;
lock (WantedPursuitLock4229938)
{
if (WantedPursuitRuntimes4229938.TryGetValue(session, out var runtime))
{
cancellation = runtime.Cancellation;
runtime.Cancellation = null;
runtime.Generation++;
}
}
cancellation?.Cancel();
cancellation?.Dispose();
foreach (var id in ids)
{
await session.NotifyAsync(MethodId.SyncPoliceDispatchVehicleChaseSt, UxSerializer.Serialize(
new GameMethods.SyncPoliceDispatchVehicleChaseStop4229938 { myVehicleEntityId = id }), token);
}
if (ids.Length != 0)
{
await session.NotifyAsync(MethodId.SyncDestroyPoliceVehicles, UxSerializer.Serialize(
new GameMethods.SyncDestroyPoliceVehicles4229938 { entityIds = ids.ToList() }), token);
}
if (hadChase)
await session.NotifyAsync(MethodId.SyncPlayerPoliceChaseFinish, Array.Empty<byte>(), token);
lock (state.SyncRoot)
state.WantedLastSync = $"cleared:{reason}; vehicles:{ids.Length}";
session.Log.Info($"[WANTED] dispatch cleared reason={reason} vehicles={ids.Length}");
}
private static void CancelWantedEscapeLocked(WorldEntryState state)
{
state.WantedEscapeActive = false;
state.WantedEscapeDeadlineTicks = 0;
state.WantedEscapeDurationSeconds = 0;
state.WantedPendingReward = 0;
state.WantedEscapeGeneration++;
}
private static uint WantedLevelForValue4229938(uint value) => value switch
{
< 50 => 0,
< 100 => 1,
< 200 => 2,
< 400 => 3,
< 800 => 4,
_ => 5,
};
private static uint MinimumWantedValue4229938(uint level) => level switch
{
1 => 50,
2 => 100,
3 => 200,
4 => 400,
_ => 800,
};
/// <summary>警车刷在玩家背后 30~54 m(背后 = 玩家朝向的反方向),横向错开 ±7 m。</summary>
private static Vec3 WantedPoliceSpawnPosition(Vec3 target, float facing, int ordinal)
{
var radians = facing * MathF.PI / 180f;
var forward = new Vec3(MathF.Sin(radians), 0f, MathF.Cos(radians));
var right = new Vec3(MathF.Cos(radians), 0f, -MathF.Sin(radians));
var lateral = ordinal == 0 ? 0f : (ordinal % 2 == 1 ? 7f : -7f);
var distance = 30f + ordinal * 12f;
return new Vec3(
target.X - forward.X * distance + right.X * lateral,
target.Y,
target.Z - forward.Z * distance + right.Z * lateral);
}
private static float NormalizeWantedFacing(float value)
{
var result = value % 360f;
return result < 0f ? result + 360f : result;
}
}