feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio
This commit is contained in:
1 parent
74e2963a12
commit
3e5adf1dc5
1571 files changed
+336468
-6348
No files matched your search
@@ -0,0 +1,859 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user