260 lines
14 KiB
C#
260 lines
14 KiB
C#
using System.Buffers.Binary;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Gameplay;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
// T1-16 载具骇入 — ported from donor_7k GameRouter.VehicleHacking.cs.
|
||
//
|
||
// Deviation from donor: it gates every hack on CharacterCapabilities.IsSeymour(), a hard-coded
|
||
// list of three spirit ids (one of which, 15022061, is actually jobless). We gate on the authored
|
||
// urban job instead — FightSpiritConfig.DefaultUrbanJob == 401 — so any character the config
|
||
// marks as a hacker works, without touching this file again.
|
||
internal sealed partial class GameRouter
|
||
{
|
||
private static async Task RegisterHackVehicleAsync(TcpSession session, VehicleRuntimeState runtime, VehicleRecord vehicle)
|
||
{
|
||
// Do not feed aircraft, boats or model-less test props to road-traffic AI.
|
||
//
|
||
// MilkCar (VehicleSerializedType.MilkCar == 13) is excluded as well. It is the player's
|
||
// personal base vehicle (wardrobe + armory), not road traffic, and the stock client's
|
||
// VehicleSpawnConfig has no row for it. Registering it anyway makes the client drop the
|
||
// whole entity at AetherCity.Manager.AetherVehicleManager:AddVehicleDataEntity —
|
||
// Player.log shows "VehicleInit_Drop_NoConfig ... not found in VehicleSpawn" followed by
|
||
// "Attach failed: VDE is null" — so no BaseVehicle view and no interact point is ever
|
||
// built. Skipping the registration keeps the entity alive and leaves it unhackable and
|
||
// undrivable, which is what MilkCar is supposed to be anyway.
|
||
if (!VehicleCatalog4229938.TryGet(vehicle.ConfigId, out var config)
|
||
|| config.VehicleType is < 2 or > 18
|
||
|| config.VehicleType == 13
|
||
|| string.IsNullOrWhiteSpace(config.Model))
|
||
return;
|
||
|
||
// ⛑⛑ 2026-09-30:上面那段注释讲的坑,以前只有注释、代码不检查 ⇒ 玩家召到
|
||
// 「客户端 VehicleSpawnConfig 里没有」的车时,日志一片正常、游戏里却是
|
||
// 一台不会动、上下车没动画的摆设,完全查不出原因。这里补一条显式告警。
|
||
// 典型受害者:**81004001**(4 辆 VehicleType==8 出租车里唯一不在表里的那辆),
|
||
// 实测 Player.log 三连 VehicleInit_Drop_NoConfig → VDE is null → Flush failed,
|
||
// 表现为「坐得进去、开不起来、没上下车动画」。可用的是 81004004 / 81004015 / 81004038。
|
||
//
|
||
// ⛑ 只告警、**不改行为**:不注册会让车变成「不可骇入不可开」,注册了客户端会 drop 整台
|
||
// 实体 —— 两种都是坏结果,而哪些车依赖注册还跑得通没有全量验证过,先不动既有行为。
|
||
if (!VehicleSpawnCatalog4229938.IsTrafficVehicle(vehicle.ConfigId))
|
||
session.Log.Warn($"[VEHICLE-HACK] config={vehicle.ConfigId} is NOT in client VehicleSpawnConfig " +
|
||
"=> client will drop the entity (VehicleInit_Drop_NoConfig / VDE is null) " +
|
||
"=> no movement, no enter/exit animation. Spawn a different configId.");
|
||
GameMethods.ClientVehicleInitData4229938 data;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
if (!runtime.Vehicles.TryGetValue(vehicle.EntityId, out var current) || !ReferenceEquals(current, vehicle))
|
||
return;
|
||
data = new()
|
||
{
|
||
VehicleConfigId = vehicle.ConfigId, Id = vehicle.EntityId,
|
||
Position = new(vehicle.Position.X, vehicle.Position.Y, vehicle.Position.Z),
|
||
Facing = vehicle.Facing, EulerAngles = new(0f, vehicle.Facing, 0f),
|
||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0,
|
||
LaneHandle = -1, ControlType = 2,
|
||
};
|
||
}
|
||
// Stock client attaches Aether data/view to the existing BaseVehicle with
|
||
// this same ID. ControlType=RaidVehicle avoids activating lane simulation.
|
||
await session.NotifyAsync(MethodId.SyncAetherAIVehicleAddData,
|
||
UxSerializer.Serialize(data), CancellationToken.None);
|
||
lock (runtime.SyncRoot)
|
||
vehicle.AetherRegistered = true;
|
||
session.Log.Info($"[VEHICLE-HACK] aether-registration sent entity={vehicle.EntityId} config={vehicle.ConfigId} control=raid lane=-1");
|
||
}
|
||
|
||
private static async Task CompleteHackVehicleInitializationAsync(TcpSession session, VehicleRuntimeState runtime, VehicleRecord vehicle)
|
||
{
|
||
try
|
||
{
|
||
// Model loading and Aether's pending-create queue are asynchronous.
|
||
// Only replay initial HP once, not a permanent healing/background loop.
|
||
await Task.Delay(2000);
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
if (!runtime.Vehicles.TryGetValue(vehicle.EntityId, out var current) || !ReferenceEquals(current, vehicle)
|
||
|| vehicle.MoveLogged || vehicle.IsDriving || vehicle.SeatOccupants.Count != 0)
|
||
return;
|
||
}
|
||
await session.NotifyAsync(MethodId.SyncVehicleBrokenCollision, UxSerializer.Serialize(
|
||
new GameMethods.VehicleBrokenCollisionInfo4229938
|
||
{
|
||
VehicleEntityId = vehicle.EntityId, CurrentHp = 10_000f, MaxHp = 10_000f,
|
||
}), CancellationToken.None);
|
||
session.Log.Info($"[VEHICLE-HACK] deferred initial HP sent entity={vehicle.EntityId}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[VEHICLE-HACK] initialization stopped entity={vehicle.EntityId}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
[Handler(MethodId.AskHack, HandlerPacketKind.Invoke)]
|
||
private async Task AskHack(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var state = GetWorldState(msg.Context);
|
||
var templateId = state.ActiveSpiritTemplateId;
|
||
var args = msg.GetArgs<GameMethods.AskHack4229938>();
|
||
|
||
// The client selects the target; vehicle actions then arrive as AskHackVehicle.
|
||
// Here the server authorizes the energy spend, acknowledges the invoke,
|
||
// and refreshes the six-point HackerConfig battery so the private-server sandbox
|
||
// remains usable without a missing retail recovery scheduler.
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
if (!CharacterCapabilities.CanHack(templateId))
|
||
{
|
||
conn.Log.Warn($"[HACK] accepted without hacker job job={CharacterCapabilities.UrbanJobOf(templateId)} template={templateId} type={args.hackType}");
|
||
return;
|
||
}
|
||
|
||
await conn.NotifyAsync(MethodId.SyncHackerBatteryCurrentAndTotal,
|
||
new GameMethods.HackerBatteryCurrentAndTotalCount4229938
|
||
{
|
||
BatteryTotalCount = 6,
|
||
BatteryCurrentCount = 6
|
||
});
|
||
conn.Log.Info($"[HACK] accepted template={templateId} job=401 type={args.hackType} battery=6/6");
|
||
}
|
||
|
||
// UI label: 加速. RushForwardSkill sends u64 vehicleId + u8 actionType(1) + null parameter(0).
|
||
// Reject unsupported variants instead of acknowledging an action we did not run.
|
||
internal static bool TryDecodeVehicleRush(byte[] body, out ulong vehicleId)
|
||
{
|
||
vehicleId = 0;
|
||
if (body.Length != 10 || body[8] != 1 || body[9] != 0)
|
||
return false;
|
||
vehicleId = BinaryPrimitives.ReadUInt64LittleEndian(body);
|
||
return vehicleId != 0;
|
||
}
|
||
|
||
internal static bool CanRushVehicle(VehicleRuntimeState runtime, VehicleRecord vehicle,
|
||
float playerX, float playerY, float playerZ, DateTime now)
|
||
{
|
||
var dx = vehicle.Position.X - playerX;
|
||
var dy = vehicle.Position.Y - playerY;
|
||
var dz = vehicle.Position.Z - playerZ;
|
||
var distanceSquared = dx * dx + dy * dy + dz * dz;
|
||
return vehicle.AetherRegistered && vehicle.Interactable
|
||
&& (vehicle.ControllerPid == 0 || vehicle.ControllerPid == Profile.PlayerPid)
|
||
&& runtime.CurrentVehicleId != vehicle.EntityId
|
||
&& vehicle.SeatOccupants.Count == 0 && vehicle.SeatReservations.Count == 0
|
||
&& !vehicle.IsDriving && vehicle.HackBusyUntilUtc <= now
|
||
&& float.IsFinite(distanceSquared) && distanceSquared <= 30f * 30f;
|
||
}
|
||
|
||
// Called under runtime.SyncRoot. Network physics ownership is independent of
|
||
// seat occupancy: hacking must not board the player or mark them as driving.
|
||
internal static bool TryPrepareVehicleRush(VehicleRuntimeState runtime, VehicleRecord vehicle,
|
||
float playerX, float playerY, float playerZ, DateTime now, ulong token)
|
||
{
|
||
if (token == 0 || !CanRushVehicle(runtime, vehicle, playerX, playerY, playerZ, now))
|
||
return false;
|
||
vehicle.ControllerPid = Profile.PlayerPid;
|
||
vehicle.TaskToken = token;
|
||
vehicle.HackTaskToken = token;
|
||
vehicle.HackTaskStatus = 0;
|
||
vehicle.HackBusyUntilUtc = now.AddSeconds(2);
|
||
return true;
|
||
}
|
||
|
||
[Handler(MethodId.AskHackVehicle, HandlerPacketKind.Invoke)]
|
||
private async Task AskHackVehicle(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var state = GetWorldState(msg.Context);
|
||
if (!CharacterCapabilities.CanHack(state.ActiveSpiritTemplateId)
|
||
|| !state.HasLastReportedPlayerTransform
|
||
|| !TryDecodeVehicleControl(msg.Body, out var vehicleId, out var action, out var targetId))
|
||
{
|
||
conn.Log.Warn($"[VEHICLE-HACK] rejected invalid request template={state.ActiveSpiritTemplateId} bytes={msg.Body.Length}");
|
||
await conn.ReturnEmptyAsync(msg, 1);
|
||
return;
|
||
}
|
||
var result = await ExecuteVehicleControlAsync(conn.Session, vehicleId, action, targetId);
|
||
if (!result.Ok) conn.Log.Warn($"[VEHICLE-HACK] rejected action={action} entity={vehicleId}: {result.Message}");
|
||
await conn.ReturnEmptyAsync(msg, result.Ok ? 0 : 1);
|
||
}
|
||
|
||
internal static bool IsAllowedHackTaskTransition(byte current, byte next)
|
||
=> current switch
|
||
{
|
||
0 => next is 1 or 4 or 5, // Pending -> Running / Abort / Override
|
||
1 => next is 2 or 3 or 4 or 5,
|
||
3 => next is 1 or 4 or 5, // Resume, or terminate a paused task.
|
||
_ => false, // Terminal tasks cannot be restarted by stale packets.
|
||
};
|
||
|
||
internal static bool TryApplyHackTaskStatus(VehicleRecord vehicle, ulong token, byte next)
|
||
{
|
||
if (token == 0 || token != vehicle.HackTaskToken || token != vehicle.TaskToken
|
||
|| !IsAllowedHackTaskTransition(vehicle.HackTaskStatus, next))
|
||
return false;
|
||
vehicle.HackTaskStatus = next;
|
||
return true;
|
||
}
|
||
|
||
[Handler(MethodId.AskUpdateVehicleAITaskStatus, HandlerPacketKind.Notify)]
|
||
private async Task AskUpdateVehicleAITaskStatus(Connection conn, UxRpcMessage msg)
|
||
{
|
||
// Native wire: vehicle u64 + task token u64 + VehicleAIStatus byte, no marker.
|
||
// The stock client requests Running(1), but keeps its task Pending until
|
||
// SyncChangeVehicleAITaskState acknowledges the SAME vehicle and task token.
|
||
if (msg.Body.Length != 17
|
||
|| !msg.TryGetArgs<GameMethods.VehicleTaskState4229938>(out var args) || args is null)
|
||
{
|
||
conn.Log.Warn($"[VEHICLE-HACK] invalid AI status packet bytes={msg.Body.Length}");
|
||
return;
|
||
}
|
||
var runtime = GetVehicleRuntime(msg.Context);
|
||
await runtime.HackDispatchGate.WaitAsync();
|
||
try
|
||
{
|
||
bool accepted;
|
||
bool isAutoDrive;
|
||
bool isTrafficCruise;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
accepted = runtime.Vehicles.TryGetValue(args.VehicleEntityId, out var vehicle)
|
||
&& TryApplyHackTaskStatus(vehicle, args.TaskToken, args.Status);
|
||
// ★ 2026-09-28:自驾下发的 Cruise 任务也走这条回路。之前只认骇入的
|
||
// HackTaskToken,自驾 token 被当成陌生包 ignore ⇒ 任务卡在 Pending ⇒ 车不动。
|
||
isAutoDrive = !accepted && TryApplyAutoDriveTaskStatus(runtime, args.TaskToken, args.Status);
|
||
accepted |= isAutoDrive;
|
||
// ★ 2026-10-01 方案 A:**车流 Cruise** 任务也走这条回路。
|
||
// 不认领的话它们被当成陌生包丢掉(日志刷 `ignored stale/... status=2`),
|
||
// 于是车开到目标点后服务端不知道,只能干等 trafficCruiseRefreshSec 才续发。
|
||
// 认领后车流侧会把刷新时刻归零 ⇒ 下一 tick 立刻续发目标点。
|
||
isTrafficCruise = !accepted
|
||
&& TryApplyTrafficCruiseStatus(msg.Context.Session, args.TaskToken, args.Status);
|
||
accepted |= isTrafficCruise;
|
||
}
|
||
if (!accepted)
|
||
{
|
||
conn.Log.Info($"[VEHICLE-HACK] ignored stale/duplicate/unowned AI status entity={args.VehicleEntityId} token={args.TaskToken} status={args.Status}");
|
||
return;
|
||
}
|
||
await conn.NotifyAsync(MethodId.SyncChangeVehicleAITaskState, args);
|
||
conn.Log.Info($"{(isTrafficCruise ? "[TRAFFIC]" : isAutoDrive ? "[VEHNAV]" : "[VEHICLE-HACK]")}" +
|
||
$" AI status acknowledged entity={args.VehicleEntityId} token={args.TaskToken} status={args.Status}");
|
||
|
||
// ★ 2026-10-03:召唤车「从车道开过来」到达(VehicleAIStatus=2 Success)之后收尾:
|
||
// 撤 AI 任务 → 关自驾状态 → 位置对齐 → 重开交互。
|
||
// 不做 ⇒ 车开不动 / 下车后再上不去 / "叫车小助手忙"叫不了第二台
|
||
// (三个症状同源:召唤演出从没结束过,见
|
||
// <see cref="CompleteSummonDriveInAsync"/> 的根因注释)。
|
||
// ⛑ 只认「自驾通道 + 状态 2」,且 helper 内部再校验这是不是当前那台召唤车
|
||
// ⇒ 出租车 / 车流 / 骇入的到达都不受影响。
|
||
if (isAutoDrive && args.Status == 2)
|
||
await CompleteSummonDriveInAsync(conn, runtime, args.VehicleEntityId);
|
||
}
|
||
finally { runtime.HackDispatchGate.Release(); }
|
||
}
|
||
}
|