860 lines
36 KiB
C#
860 lines
36 KiB
C#
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;
|
||
}
|
||
}
|