1155 lines
62 KiB
C#
1155 lines
62 KiB
C#
using Ananta.SDK.Network;
|
||||
|
|
using Ananta.SDK.Rpc;
|
|||
|
|
using Ananta.SDK.Serialization;
|
|||
|
|
using Ananta.Server.ClientData.Client4229938;
|
|||
|
|
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>
|
|||
|
|
/// Vehicle ownership + direct summon (Phase 2). Ported from the proven newcityps
|
|||
|
|
/// (4091149) direct-scene-spawn path, with 4229938 wire deltas applied
|
|||
|
|
/// (LogicVehicleClientInfo.VehicleSpoonName, PlayerVehicleClientDetail.IsPersistent).
|
|||
|
|
/// No road-planner/AI-task leg: the vehicle materializes beside the player,
|
|||
|
|
/// already interactable, and the stock client seat/drive lifecycle takes over.
|
|||
|
|
/// Enter/exit/move notifies are logged + tracked; pushes for those come after
|
|||
|
|
/// live-traffic observation (see KNOWLEDGEBASE.md §4).
|
|||
|
|
/// </summary>
|
|||
|
|
internal sealed partial class GameRouter
|
|||
|
|
{
|
|||
|
|
internal static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, VehicleRecord> GlobalVehicles = new();
|
|||
|
|
|
|||
|
|
|
|||
|
|
internal sealed record SummonedVehicleSnapshot(ulong EntityId, uint ConfigId, float X, float Y, float Z, float Yaw, bool HasFix);
|
|||
|
|
|
|||
|
|
internal static List<SummonedVehicleSnapshot> SummonedSnapshot()
|
|||
|
|
{
|
|||
|
|
return GlobalVehicles.Values
|
|||
|
|
.Select(v => new SummonedVehicleSnapshot(v.EntityId, v.ConfigId, v.Position.X, v.Position.Y, v.Position.Z, v.Facing, true))
|
|||
|
|
.ToList();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal static (bool InVehicle, byte SeatIndex) VehicleSeatSnapshot(TcpSession? session)
|
|||
|
|
{
|
|||
|
|
if (session is null) return (false, 0);
|
|||
|
|
var runtime = GetVehicleRuntimeState(session);
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
return (runtime.CurrentVehicleId != 0, (byte)Math.Max(0, runtime.CurrentVehicleSeat));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal static async Task DestroyDirectAsync(TcpSession session, ulong entityId, string reason)
|
|||
|
|
{
|
|||
|
|
var runtime = GetVehicleRuntimeState(session);
|
|||
|
|
await DestroyVehicleAsync(session, runtime, entityId, reason);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal static async Task<(bool Ok, string Message, ulong EntityId, ulong Token, Vec3 SpawnPos)> SpawnDirectAsync(
|
|||
|
|
TcpSession session, uint configId, Vec3 pos, float facing, float rightOffset, int sourceType, string reason)
|
|||
|
|
{
|
|||
|
|
var yaw = facing * (MathF.PI / 180f);
|
|||
|
|
var spawn = new Vec3(pos.X + MathF.Cos(yaw) * rightOffset, pos.Y + 0.15f, pos.Z - MathF.Sin(yaw) * rightOffset);
|
|||
|
|
var res = await SpawnDirectAsync(session, configId, pos, facing, reason, exactPos: spawn, createSourceType: sourceType, destroyPrevious: false);
|
|||
|
|
return (res.Ok, res.Message, res.EntityId, res.Token, spawn);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal static bool TryGetSummoned(ulong entityId, out VehicleRecord? record)
|
|||
|
|
=> GlobalVehicles.TryGetValue(entityId, out record);
|
|||
|
|
|
|||
|
|
internal static void TrackVehicleMove(ulong entityId, float x, float y, float z, float yaw)
|
|||
|
|
{
|
|||
|
|
if (GlobalVehicles.TryGetValue(entityId, out var rec))
|
|||
|
|
{
|
|||
|
|
rec.Position = new Vec3(x, y, z);
|
|||
|
|
rec.Facing = yaw;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private const string VehicleRuntimeKey = "client4229938-vehicles";
|
|||
|
|
// 高水位落盘(IdCounterStore §6.10):entityId / taskToken 重启后不能复用。
|
|||
|
|
private const long VehicleEntityFloor = 8_200_000_000_000_000_000L;
|
|||
|
|
private const string VehicleEntityCounter = "Vehicle.EntityId";
|
|||
|
|
private const long VehicleTokenFloor = 7_100_000_000_000_000_000L;
|
|||
|
|
private const string VehicleTokenCounter = "Vehicle.TaskToken";
|
|||
|
|
|
|||
|
|
// Tracks the NPC/entity the player currently has targeted (from AskInteractCmd cmd=33 s1).
|
|||
|
|
// Used as the inviteePid when the player sends AskInvitePlayerInteractionAction.
|
|||
|
|
private ulong _lastInteractTargetUnitId;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// <see cref="_lastInteractTargetUnitId"/> 的<b>静态镜像</b>,在下面同一处赋值。
|
|||
|
|
///
|
|||
|
|
/// 为什么要有两份:<c>_lastInteractTargetUnitId</c> 是实例字段,而
|
|||
|
|
/// <c>GameRouter.CollectPoliceTargets</c>(GM 面板 / <c>/api/police/targets</c>)是静态方法,
|
|||
|
|
/// 拿不到实例 ⇒ 只能读镜像。用镜像而不是把原字段改成 static,是为了不动
|
|||
|
|
/// <c>AskInvitePlayerInteractionAction</c> 那条既有链路的可见性语义。
|
|||
|
|
/// </summary>
|
|||
|
|
private static ulong s_lastInteractTargetUnitId;
|
|||
|
|
|
|||
|
|
// True while a two-person interaction animation is playing. Detect the end by watching
|
|||
|
|
// AskInteractCmd cmd=34: its offset-24 u32 is !=1 during the action and returns to 1 when done.
|
|||
|
|
// The value can briefly flicker to 1 during animation transitions, so require a quiet window.
|
|||
|
|
private static bool _interactionActive;
|
|||
|
|
private static DateTime _lastInteractingUtc = DateTime.MinValue;
|
|||
|
|
|
|||
|
|
/// <summary>Call when despawning NPCs or resetting state to abort any active interaction monitor.</summary>
|
|||
|
|
public static void ResetInteractionState()
|
|||
|
|
{
|
|||
|
|
_interactionActive = false;
|
|||
|
|
_lastInteractingUtc = DateTime.MinValue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal sealed class VehicleRecord
|
|||
|
|
{
|
|||
|
|
public ulong EntityId;
|
|||
|
|
public uint ConfigId;
|
|||
|
|
public int SeatCount;
|
|||
|
|
public Vec3 Position;
|
|||
|
|
public float Facing;
|
|||
|
|
public ulong ControllerPid;
|
|||
|
|
public bool Interactable;
|
|||
|
|
public ulong TaskToken;
|
|||
|
|
public List<byte> LastBits = [];
|
|||
|
|
public bool MoveLogged;
|
|||
|
|
public double DistanceDriven;
|
|||
|
|
public bool IsDriving;
|
|||
|
|
public float LastNitroValue;
|
|||
|
|
public float TopSpeed;
|
|||
|
|
public bool HasTopSpeed;
|
|||
|
|
public Dictionary<byte, ulong> SeatReservations { get; } = new();
|
|||
|
|
public Dictionary<byte, ulong> SeatOccupants { get; } = new();
|
|||
|
|
|
|||
|
|
// T1-16 载具骇入 (ported from donor_7k). AetherRegistered is what makes a spawned
|
|||
|
|
// car selectable by HackTargetSelector: it iterates AetherVehicleManager's
|
|||
|
|
// VehicleId2IndexDict, which SyncSpawnVehicle alone never populates.
|
|||
|
|
public bool AetherRegistered;
|
|||
|
|
public DateTime HackBusyUntilUtc;
|
|||
|
|
public ulong HackTaskToken;
|
|||
|
|
public byte HackTaskStatus;
|
|||
|
|
|
|||
|
|
// ★ 真实车流(T3-7):由 GameRouter.Traffic.cs 生成、每 tick 被车道流推着跑的车。
|
|||
|
|
// true ⇒ 仍在车道流里,服务端每 tick 用 SyncAetherAIVehicleLaneDatas 覆盖它的里程。
|
|||
|
|
// false ⇒ 已被玩家骇入接管,脱离车道流(由 DetachTrafficVehicleFromLaneAsync 翻转)。
|
|||
|
|
//
|
|||
|
|
// 为什么必须能翻转:骇入下发的 SyncAssignVehicleAITask 是 RaidVehicle 通道,
|
|||
|
|
// 而车道流每 50ms 覆盖一次里程 —— 两套驱动源同时作用的话,
|
|||
|
|
// 车会表现为「加速一瞬间就被拽回原处」。所以接管的第一件事是把车摘出车道流。
|
|||
|
|
public bool IsTraffic;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal sealed class VehicleRuntimeState
|
|||
|
|
{
|
|||
|
|
public object SyncRoot { get; } = new();
|
|||
|
|
public HashSet<uint> Unlocked { get; } = new();
|
|||
|
|
public Dictionary<ulong, VehicleRecord> Vehicles { get; } = new();
|
|||
|
|
public ulong SummonedEntity;
|
|||
|
|
public ulong CurrentVehicleId;
|
|||
|
|
public int CurrentVehicleSeat = -1;
|
|||
|
|
/// <summary>
|
|||
|
|
/// 自驾状态(<c>GameRouter.VehicleNav.cs</c>,2026-09-27)。
|
|||
|
|
/// 客户端的自动驾驶是「一辆车」的开关:开关状态只由 S2C
|
|||
|
|
/// <c>SyncVehicleAutonomousDrivingState</c> 决定(<c>DriveVehiclesManager.autoDrivingVehicleDict</c>),
|
|||
|
|
/// 所以服务端必须记住「哪辆车正开着」,才能在 Stop / 下车时回推正确的 false。
|
|||
|
|
/// 存 session 而不是持久化:重进场景客户端会重新发 Start。
|
|||
|
|
/// </summary>
|
|||
|
|
public ulong AutoDrivingVehicleId;
|
|||
|
|
public bool AutoDriving;
|
|||
|
|
/// <summary>
|
|||
|
|
/// 自驾下发的最后一个 AI 任务 token(2026-09-28 第三轮)。
|
|||
|
|
/// ⛑️ 必须记住它:客户端收到 SyncAssignVehicleAITask 后会回 AskUpdateVehicleAITaskStatus,
|
|||
|
|
/// 但**会一直把任务停在 Pending,直到服务端用 SyncChangeVehicleAITaskState 确认同一个
|
|||
|
|
/// entity + token**。不确认 ⇒ 任务永远不进 Running ⇒ 车纹丝不动。
|
|||
|
|
/// </summary>
|
|||
|
|
public ulong AutoDriveTaskToken;
|
|||
|
|
public byte AutoDriveTaskStatus;
|
|||
|
|
public bool AutoDriveHasTarget;
|
|||
|
|
public float AutoDriveTargetX;
|
|||
|
|
public float AutoDriveTargetY;
|
|||
|
|
public float AutoDriveTargetZ;
|
|||
|
|
/// <summary>
|
|||
|
|
/// 上次真正下发的巡航点集(2026-09-30,GM 调速用)。
|
|||
|
|
/// 改速度必须重发 Cruise 任务(速度写在任务参数里),而重发需要点集;
|
|||
|
|
/// 现算要走 <c>SolveRoute</c>,那是实例方法且要 Connection,GM 侧两者都没有。
|
|||
|
|
/// ⇒ 下发时缓存一份,热应用直接复用,顺带保证「换速度不换路线」。
|
|||
|
|
/// </summary>
|
|||
|
|
public List<SceneMethods.UxVector3>? AutoDriveLastPoints;
|
|||
|
|
/// <summary>上次下发时用的速度(km/h)。值没变就不重发,避免 GM 手抖连点刷包。</summary>
|
|||
|
|
public float AutoDriveSpeedKmh;
|
|||
|
|
/// <summary>
|
|||
|
|
/// 出租车行程(2026-09-30 新增,<c>GameRouter.Taxi.cs</c>)。<b>null = 当前没有行程</b>。
|
|||
|
|
///
|
|||
|
|
/// <para>⛑ 只记「服务端自己派发的那一次」。客户端的原生出租车 UI(选目的地面板、
|
|||
|
|
/// 到达提示)状态全在客户端,服务端只负责:认单 → 派 Cruise → 判到达 → 收车费。</para>
|
|||
|
|
///
|
|||
|
|
/// <para>⛑ 与 <see cref="AutoDriveTaskToken"/> 的关系:出租车复用自驾那条
|
|||
|
|
/// <c>SyncAssignVehicleAITask</c> 通道,所以行程的 token 就是 <c>AutoDriveTaskToken</c>;
|
|||
|
|
/// 本字段额外记住「这趟是出租车」+ 目的地 + 起始里程,用来算车费和判到达。</para>
|
|||
|
|
/// </summary>
|
|||
|
|
public TaxiTrip4229938? TaxiTrip;
|
|||
|
|
/// <summary>
|
|||
|
|
/// 玩家最近<b>开过</b>的车(上车时写,下车不清)。改装店事务要它:位移前客户端已经
|
|||
|
|
/// 把玩家自动请下车了(retail 的 SetSeatInteractOperation(0,6)),此时
|
|||
|
|
/// <see cref="CurrentVehicleId"/> 已被客户端的 exit 通知清零,只有这个还记得是谁。
|
|||
|
|
/// </summary>
|
|||
|
|
public ulong LastDrivenVehicleId;
|
|||
|
|
public bool OwnershipSent;
|
|||
|
|
public bool AetherInitSent;
|
|||
|
|
|
|||
|
|
// T1-16 载具骇入: serializes hack dispatch so a stale status notify cannot
|
|||
|
|
// retarget a newer task while its SyncAssignVehicleAITask is still in flight.
|
|||
|
|
public SemaphoreSlim HackDispatchGate { get; } = new(1, 1);
|
|||
|
|
|
|||
|
|
// S011 story/boarding state (single-player: one active enter/exit transaction).
|
|||
|
|
public bool StoryRootSent;
|
|||
|
|
public bool StorySeenAnyRpc;
|
|||
|
|
public long StoryHighestConfirmedRpcId = -1;
|
|||
|
|
public SortedSet<long> StoryPendingRpcIds { get; } = new();
|
|||
|
|
public HashSet<long> StoryCommittedRpcIds { get; } = new();
|
|||
|
|
public ulong StoryEnterVehicleId;
|
|||
|
|
public byte StoryEnterSeat;
|
|||
|
|
public bool StoryExitInProgress;
|
|||
|
|
public ulong StoryBoardingUnitId;
|
|||
|
|
|
|||
|
|
public void Clear()
|
|||
|
|
{
|
|||
|
|
lock (SyncRoot)
|
|||
|
|
{
|
|||
|
|
Vehicles.Clear();
|
|||
|
|
SummonedEntity = 0;
|
|||
|
|
CurrentVehicleId = 0;
|
|||
|
|
CurrentVehicleSeat = -1;
|
|||
|
|
OwnershipSent = false;
|
|||
|
|
AetherInitSent = false;
|
|||
|
|
StoryRootSent = false;
|
|||
|
|
StorySeenAnyRpc = false;
|
|||
|
|
StoryHighestConfirmedRpcId = -1;
|
|||
|
|
StoryEnterVehicleId = 0;
|
|||
|
|
StoryEnterSeat = 0;
|
|||
|
|
StoryExitInProgress = false;
|
|||
|
|
StoryPendingRpcIds.Clear();
|
|||
|
|
StoryCommittedRpcIds.Clear();
|
|||
|
|
AutoDrivingVehicleId = 0;
|
|||
|
|
AutoDriving = false;
|
|||
|
|
AutoDriveTaskToken = 0;
|
|||
|
|
AutoDriveTaskStatus = 0;
|
|||
|
|
AutoDriveHasTarget = false;
|
|||
|
|
AutoDriveLastPoints = null;
|
|||
|
|
AutoDriveSpeedKmh = 0f;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal static VehicleRuntimeState GetVehicleRuntimeState(TcpSession session)
|
|||
|
|
{
|
|||
|
|
if (session.Items.TryGetValue(VehicleRuntimeKey, out var raw) && raw is VehicleRuntimeState existing)
|
|||
|
|
return existing;
|
|||
|
|
var created = new VehicleRuntimeState();
|
|||
|
|
foreach (var vehicle in VehicleCatalog4229938.AllSummonable)
|
|||
|
|
created.Unlocked.Add(vehicle.Id);
|
|||
|
|
session.Items[VehicleRuntimeKey] = created;
|
|||
|
|
return created;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static VehicleRuntimeState GetVehicleRuntime(RpcContext ctx) => GetVehicleRuntimeState(ctx.Session);
|
|||
|
|
|
|||
|
|
private static List<GameMethods.PlayerVehicleClientDetail4229938> VehicleOwnershipSnapshot(VehicleRuntimeState runtime)
|
|||
|
|
{
|
|||
|
|
// Full summonable catalog (237 vehicles) so the "owned vehicles" view and the phone
|
|||
|
|
// F1 car-call list every drivable vehicle in the game. User request: 所有车辆.
|
|||
|
|
// IsPersistent=true matches the proven sibling 08.05 contract (fleetIds-only with
|
|||
|
|
// IsPersistent=false never produced a client F1 summon).
|
|||
|
|
return VehicleCatalog4229938.AllSummonable
|
|||
|
|
.Select(v => new GameMethods.PlayerVehicleClientDetail4229938 { Id = v.Id, Parts = [], SuitId = 0, IsPersistent = true })
|
|||
|
|
.ToList();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal static async Task PublishVehicleOwnership(RpcContext ctx)
|
|||
|
|
{
|
|||
|
|
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
|
|||
|
|
return;
|
|||
|
|
var runtime = GetVehicleRuntime(ctx);
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (runtime.OwnershipSent)
|
|||
|
|
return;
|
|||
|
|
runtime.OwnershipSent = true;
|
|||
|
|
}
|
|||
|
|
var snapshot = VehicleOwnershipSnapshot(runtime);
|
|||
|
|
await ctx.NotifyAsync(MethodId.SyncAllUnlockedVehicles, snapshot);
|
|||
|
|
ctx.Session.Log.Info($"[VEHICLE] ownership published count={snapshot.Count}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal static async Task EnsureAetherVehicleInit(RpcContext ctx)
|
|||
|
|
{
|
|||
|
|
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
|
|||
|
|
return;
|
|||
|
|
var runtime = GetVehicleRuntime(ctx);
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (runtime.AetherInitSent)
|
|||
|
|
return;
|
|||
|
|
runtime.AetherInitSent = true;
|
|||
|
|
}
|
|||
|
|
var settings = PrivateServerConfigStore.Current.Gameplay.Vehicles;
|
|||
|
|
var activeRaidId = GetStateIfExists(ctx.Session)?.ActiveRaidId ?? Profile.RaidId;
|
|||
|
|
var hasZoneGraph = activeRaidId == PrivateServerConfigStore.Current.World.RaidId;
|
|||
|
|
var handle = hasZoneGraph ? settings.ZoneStorageDataHandle : 0;
|
|||
|
|
var variants = PrivateServerConfigStore.Current.Gameplay.Aether.InitVariants;
|
|||
|
|
if (variants is null || variants.Count == 0)
|
|||
|
|
variants = ["top-int32"];
|
|||
|
|
// 带 -isec- 的变体用它;其余变体忽略(保持空表)。
|
|||
|
|
var isecCount = PrivateServerConfigStore.Current.Gameplay.Aether.InitIntersectionProbeCount;
|
|||
|
|
// 真路口(-real 变体)一次装载一次记录,便于和客户端日志对账。
|
|||
|
|
var realRows = GameAdminBridge.RealIntersectionRows();
|
|||
|
|
ctx.Session.Log.Info($"[VEHICLE] aether-init intersections real=" +
|
|||
|
|
$"{PrivateServerConfigStore.Current.Gameplay.Aether.InitIntersectionsReal} " +
|
|||
|
|
$"rows={realRows.Length}" +
|
|||
|
|
(realRows.Length > 0
|
|||
|
|
? $" first={realRows[0].InstanceId}/zone{realRows[0].ZoneIndex} " +
|
|||
|
|
$"last={realRows[^1].InstanceId}/zone{realRows[^1].ZoneIndex}"
|
|||
|
|
: " (empty)") +
|
|||
|
|
$" catalog={IntersectionCatalog4229938.Count}");
|
|||
|
|
foreach (var variant in variants)
|
|||
|
|
{
|
|||
|
|
var wire = GameAdminBridge.BuildAetherInitWire(variant, activeRaidId, hasZoneGraph, handle, isecCount);
|
|||
|
|
if (wire is null)
|
|||
|
|
{
|
|||
|
|
ctx.Session.Log.Info($"[VEHICLE] aether-init unknown variant '{variant}' (skipped)");
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
await ctx.NotifyAsync(MethodId.SyncAetherAIInitDatas, wire);
|
|||
|
|
ctx.Session.Log.Info($"[VEHICLE] aether-init variant={variant} raid={activeRaidId} zoneGraph={hasZoneGraph} " +
|
|||
|
|
$"handle={handle} bytes={wire.Length} hex={Convert.ToHexString(wire)}");
|
|||
|
|
if (variants.Count > 1) await Task.Delay(800, CancellationToken.None);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ★★ 2026-10-02:路口相位 / 红绿灯 —— init 把路口表交给客户端之后,
|
|||
|
|
// ① 给每个路口推一次初始相位(灯从"没被接管"变成"亮"),
|
|||
|
|
// ② 起周期切档循环。
|
|||
|
|
// ⛑⛑ **独立于车流开关**:用户 19:38 把 trafficEnabled 关了,灯照样要亮
|
|||
|
|
// ⇒ 挂在这里(Aether init 链路)而不是 TrafficLoop 里。
|
|||
|
|
var (isecTotal, isecMulti, isecLanes, isecSample) = ProbeIntersections();
|
|||
|
|
ctx.Session.Log.Info($"[SIGNAL] 路口表 路口={isecTotal} 多相位={isecMulti} 车道={isecLanes} 首行={isecSample}");
|
|||
|
|
await PushIntersectionUpdatesAsync(ctx.Session, CancellationToken.None).ConfigureAwait(false);
|
|||
|
|
StartIntersectionSignals(ctx.Session);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
// --- Aether city simulation: C2S calls -------------------------------------------
|
|||
|
|
// The client owns the simulation (pedestrians / static NPCs / traffic). These handlers
|
|||
|
|
// exist so the tasks it awaits after SyncAetherAIInitDatas are answered instead of
|
|||
|
|
// stalling the spawner.
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskMassHideAreas, HandlerPacketKind.Invoke)]
|
|||
|
|
private Task AskMassHideAreas(Connection conn, UxRpcMessage msg)
|
|||
|
|
{
|
|||
|
|
var reply = ParseHexBytes(PrivateServerConfigStore.Current.Gameplay.Aether.MassHideAreasReplyHex);
|
|||
|
|
conn.Log.Info($"[AETHER] mass-hide-areas -> {reply.Length}b hex={Convert.ToHexString(reply)}");
|
|||
|
|
return conn.ReturnRawAsync(msg, reply);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskAetherChangeQuality, HandlerPacketKind.Notify)]
|
|||
|
|
private Task AskAetherChangeQuality(Connection conn, UxRpcMessage msg)
|
|||
|
|
{
|
|||
|
|
var characterQ = msg.Body.Length >= 4 ? BitConverter.ToInt32(msg.Body, 0) : -1;
|
|||
|
|
var vehicleQ = msg.Body.Length >= 8 ? BitConverter.ToInt32(msg.Body, 4) : -1;
|
|||
|
|
conn.Log.Info($"[AETHER] quality character={characterQ} vehicle={vehicleQ} bytes={msg.Body.Length}");
|
|||
|
|
return Task.CompletedTask;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskAetherAIBorrowVehicle, HandlerPacketKind.Invoke)]
|
|||
|
|
private Task AskAetherAIBorrowVehicle(Connection conn, UxRpcMessage msg)
|
|||
|
|
=> conn.ReturnEmptyAsync(msg);
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskChangePedToVehicleNpc, HandlerPacketKind.Invoke)]
|
|||
|
|
private Task AskChangePedToVehicleNpc(Connection conn, UxRpcMessage msg)
|
|||
|
|
=> conn.ReturnEmptyAsync(msg);
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskReportAetherLeaflet, HandlerPacketKind.Invoke)]
|
|||
|
|
private Task AskReportAetherLeaflet(Connection conn, UxRpcMessage msg)
|
|||
|
|
=> conn.ReturnEmptyAsync(msg);
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskAetherAICreateRaidVehicleAndClaimSeatByAether, HandlerPacketKind.Invoke)]
|
|||
|
|
private Task AskAetherAICreateRaidVehicleAndClaimSeatByAether(Connection conn, UxRpcMessage msg)
|
|||
|
|
=> conn.ReturnAsync(msg, (byte)0);
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskAetherAIRaidVehicleRegressAether, HandlerPacketKind.Invoke)]
|
|||
|
|
private Task AskAetherAIRaidVehicleRegressAether(Connection conn, UxRpcMessage msg)
|
|||
|
|
=> conn.ReturnEmptyAsync(msg);
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskRaidVehicleConvertToAether, HandlerPacketKind.Invoke)]
|
|||
|
|
private Task AskRaidVehicleConvertToAether(Connection conn, UxRpcMessage msg)
|
|||
|
|
=> conn.ReturnEmptyAsync(msg);
|
|||
|
|
|
|||
|
|
private static byte[] ParseHexBytes(string? hex)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrWhiteSpace(hex)) return [];
|
|||
|
|
var clean = hex.Replace(" ", string.Empty).Replace("-", string.Empty);
|
|||
|
|
if (clean.Length % 2 != 0) return [];
|
|||
|
|
try { return Convert.FromHexString(clean); }
|
|||
|
|
catch (FormatException) { return []; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ⛑ 2026-10-02 对齐捐赠端:原来这里的 `FireExamAuto`(cmd=33/34 ⇒ 自动开盘查)已随
|
|||
|
|
// `GameRouter.PoliceExamAuto.cs` 一起移除 —— 捐赠端**没有**"准星对准/对话 ⇒ 自动盘查"这条链,
|
|||
|
|
// 它的入口只有原生 F(`PoliceFRequest`)和 GM `/api/lixi/police`。
|
|||
|
|
// cmd=33/34 现在只保留 uid 记录与日志,不再触发盘查。
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Interact heartbeat/sync channel. Decoded + logged so a future F-press via the
|
|||
|
|
/// interact system is visible; currently accepted without side effects.
|
|||
|
|
/// </summary>
|
|||
|
|
[Handler(MethodId.AskInteractCmd, HandlerPacketKind.Notify)]
|
|||
|
|
private async Task AskInteractCmd(Connection conn, UxRpcMessage msg)
|
|||
|
|
{
|
|||
|
|
if (msg.TryGetArgs<GameMethods.AskInteractCmdArgs4229938>(out var args) && args is not null)
|
|||
|
|
{
|
|||
|
|
// Heartbeat CmdType=19 from pid 666 arrives ~2x/sec; log only anything else.
|
|||
|
|
if (args.CmdType != 19)
|
|||
|
|
{
|
|||
|
|
var hex = args.CommandData is { Count: > 0 } ? Convert.ToHexString(args.CommandData.ToArray()) : "-";
|
|||
|
|
conn.Log.Info($"[INTERACT] cmd={args.CmdType} sender={args.sender} receiver={args.receiver} broadcast={args.broadCastType} data={args.CommandData?.Count ?? -1}b hex={hex} s1={args.stringParam1} s2={args.stringParam2}");
|
|||
|
|
|
|||
|
|
// cmd=33 = target selection; s1 is the targeted unit id (NPC or self).
|
|||
|
|
if (args.CmdType == 33 && ulong.TryParse(args.stringParam1, out var tid))
|
|||
|
|
{
|
|||
|
|
_lastInteractTargetUnitId = tid;
|
|||
|
|
s_lastInteractTargetUnitId = tid; // 静态镜像,见字段注释
|
|||
|
|
}
|
|||
|
|
// cmd=34 是交互心跳,offset-24 u32 是交互状态;见下面那段。
|
|||
|
|
|
|||
|
|
// cmd=34 = interaction heartbeat. Offset 24 u32 is the interaction state:
|
|||
|
|
// 1 = idle/not interacting, !=1 = ensemble action playing.
|
|||
|
|
// Update the last-interacting timestamp; a background monitor detects the end.
|
|||
|
|
if (args.CmdType == 34 && args.CommandData is { Count: >= 28 })
|
|||
|
|
{
|
|||
|
|
var interactState = BitConverter.ToUInt32(args.CommandData.ToArray(), 24);
|
|||
|
|
if (interactState != 1)
|
|||
|
|
{
|
|||
|
|
_lastInteractingUtc = DateTime.UtcNow;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
conn.Log.Warn($"[INTERACT] AskInteractCmd undecodable bytes={msg.Body.Length}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Two-person interaction (双人 ACTION) invite channel. The client sends
|
|||
|
|
// AskInvitePlayerInteractionAction as an Invoke (12b = inviteePid u64 + actionItemId u32)
|
|||
|
|
// when the player picks an action. The NPC then shows "思考中" until the server acks.
|
|||
|
|
// We auto-accept: push inviter+invitee state and SyncStartPlayerInteractionAction.
|
|||
|
|
[Handler(MethodId.AskInvitePlayerInteractionAction, HandlerPacketKind.Invoke)]
|
|||
|
|
private async Task AskInvitePlayerInteractionAction(Connection conn, UxRpcMessage msg)
|
|||
|
|
{
|
|||
|
|
// 12b payload: inviterPid (u64 LE, the player) + actionItemId (u32 LE)
|
|||
|
|
// The invitee (NPC) is tracked separately from AskInteractCmd cmd=33 s1.
|
|||
|
|
ulong inviterPid = 0;
|
|||
|
|
uint actionItemId = 0;
|
|||
|
|
if (msg.Body.Length >= 12)
|
|||
|
|
{
|
|||
|
|
inviterPid = BitConverter.ToUInt64(msg.Body, 0);
|
|||
|
|
actionItemId = BitConverter.ToUInt32(msg.Body, 8);
|
|||
|
|
}
|
|||
|
|
var inviteePid = _lastInteractTargetUnitId;
|
|||
|
|
conn.Log.Info($"[INTERACT-INVITE] AskInvite bytes={msg.Body.Length} inviterPid={inviterPid} inviteePid={inviteePid} actionItemId={actionItemId} hex={Convert.ToHexString(msg.Body.ToArray())}");
|
|||
|
|
|
|||
|
|
// Invoke must return something.
|
|||
|
|
await conn.ReturnEmptyAsync(msg, 0);
|
|||
|
|
|
|||
|
|
var session = msg.Context.Session;
|
|||
|
|
|
|||
|
|
// Step 1: tell the inviter the invite is outstanding.
|
|||
|
|
// SyncInviterPlayerInteractionAction (64201952): inviterState(byte) + inviteePid(u64) + actionItemId(u32)
|
|||
|
|
var inviterPayload = new byte[13];
|
|||
|
|
inviterPayload[0] = 1; // inviterState = inviting
|
|||
|
|
BitConverter.TryWriteBytes(inviterPayload.AsSpan(1, 8), inviteePid);
|
|||
|
|
BitConverter.TryWriteBytes(inviterPayload.AsSpan(9, 4), actionItemId);
|
|||
|
|
await session.NotifyAsync(64201952, inviterPayload, CancellationToken.None);
|
|||
|
|
|
|||
|
|
// Step 2: tell the invitee (NPC) it received an invite.
|
|||
|
|
// SyncInviteeInvitePlayerInteractionAction (64752919): inviteePid(u64) + actionItemId(u32)
|
|||
|
|
var inviteeInvitePayload = new byte[12];
|
|||
|
|
BitConverter.TryWriteBytes(inviteeInvitePayload.AsSpan(0, 8), inviteePid);
|
|||
|
|
BitConverter.TryWriteBytes(inviteeInvitePayload.AsSpan(8, 4), actionItemId);
|
|||
|
|
await session.NotifyAsync(64752919, inviteeInvitePayload, CancellationToken.None);
|
|||
|
|
|
|||
|
|
// Step 3: NPC auto-accepts — set invitee state to accepted.
|
|||
|
|
// SyncInviteePlayerInteractionAction (64485670): inviteeState(byte) + inviterPid(u64) + actionItemId(u32)
|
|||
|
|
var inviteePayload = new byte[13];
|
|||
|
|
inviteePayload[0] = 2; // inviteeState = accepted
|
|||
|
|
BitConverter.TryWriteBytes(inviteePayload.AsSpan(1, 8), inviterPid);
|
|||
|
|
BitConverter.TryWriteBytes(inviteePayload.AsSpan(9, 4), actionItemId);
|
|||
|
|
await session.NotifyAsync(64485670, inviteePayload, CancellationToken.None);
|
|||
|
|
|
|||
|
|
// Step 4: tell the inviter the invitee replied (accepted).
|
|||
|
|
// SyncInviterReplyInvitePlayerInteractionAction (64298537): replyState(byte)
|
|||
|
|
var replyPayload = new byte[1];
|
|||
|
|
replyPayload[0] = 0; // replyState = accepted (0=accept, 1=reject)
|
|||
|
|
await session.NotifyAsync(64298537, replyPayload, CancellationToken.None);
|
|||
|
|
|
|||
|
|
// Carry/bind-type actions (Bridal Carry 99320003, Hand Hold 99320039) have baked client
|
|||
|
|
// config (MultiInteractType 1/59, CanJoyStickBreakAction=false) that locks input and
|
|||
|
|
// sticks pose after any server-side cancel — no known RPC can reset the client local
|
|||
|
|
// anim state. Skip SyncStart so the client never enters the interaction lock; the invite
|
|||
|
|
// UI still shows accepted but the player can move freely.
|
|||
|
|
var carryBindActions = new HashSet<uint> { 99320003, 99320039 };
|
|||
|
|
if (carryBindActions.Contains(actionItemId))
|
|||
|
|
{
|
|||
|
|
conn.Log.Info($"[INTERACT-INVITE] carry/bind action {actionItemId}, skipped SyncStart to avoid pose-lock");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Step 5: start the ensemble animation.
|
|||
|
|
// SyncStartPlayerInteractionAction (64579648): empty payload
|
|||
|
|
await session.NotifyAsync(64579648, Array.Empty<byte>(), CancellationToken.None);
|
|||
|
|
|
|||
|
|
conn.Log.Info($"[INTERACT-INVITE] auto-accepted: inviter={inviterPid} invitee={inviteePid} action={actionItemId}");
|
|||
|
|
|
|||
|
|
// Capture player transform for cleanup teleport — loop actions like Bridal Carry
|
|||
|
|
// attach the player to the NPC and only a position reset breaks the pose.
|
|||
|
|
var pState = GetStateIfExists(conn.Session);
|
|||
|
|
// pState?.X != 0 is true when pState is null (null != 0), so the old ternary dereferenced
|
|||
|
|
// a null state in its true branch — a real NullReferenceException, not just a CS8602.
|
|||
|
|
var playerUnitId = pState?.ActiveSpiritUnitId is { } spiritUnitId && spiritUnitId != 0
|
|||
|
|
? spiritUnitId
|
|||
|
|
: 100000000000UL;
|
|||
|
|
var playerPos = pState?.LastReportedPlayerPosition ?? new Vec3(0, 0, 0);
|
|||
|
|
var playerFace = pState != null ? pState.LastReportedPlayerRotation.Y : 0f;
|
|||
|
|
|
|||
|
|
// Mark interaction active; cmd=34 updates _lastInteractingUtc while the action plays.
|
|||
|
|
// A background monitor detects the end (quiet window for one-shot actions, fixed
|
|||
|
|
// timeout for loop actions like Bridal Carry / Salute / Kneel / instruments).
|
|||
|
|
_interactionActive = true;
|
|||
|
|
_lastInteractingUtc = DateTime.UtcNow;
|
|||
|
|
var monitorSession = session;
|
|||
|
|
var monitorLog = conn.Log;
|
|||
|
|
var monitorInvitee = inviteePid;
|
|||
|
|
// Loop / carry-type actions: client config is baked (IsLoopAction / CanJoyStickBreakAction
|
|||
|
|
// / MultiInteractType cannot be changed server-side), so force-cancel after 3s + parkour reset.
|
|||
|
|
// Includes true loops (Salute/Kneel/ThrowMoney/Champagne/instruments), Bridal Carry (carry),
|
|||
|
|
// and Hand Hold test (MultiInteractType=59 bind).
|
|||
|
|
var loopActionIds = new HashSet<uint> { 99320000, 99320003, 99320004, 99320007, 99320008, 99323001, 99323004, 99320039 };
|
|||
|
|
var isLoop = loopActionIds.Contains(actionItemId);
|
|||
|
|
_ = Task.Run(async () =>
|
|||
|
|
{
|
|||
|
|
var start = DateTime.UtcNow;
|
|||
|
|
while (_interactionActive)
|
|||
|
|
{
|
|||
|
|
await Task.Delay(500);
|
|||
|
|
var elapsed = (DateTime.UtcNow - start).TotalSeconds;
|
|||
|
|
var quiet = (DateTime.UtcNow - _lastInteractingUtc).TotalSeconds;
|
|||
|
|
// Loop actions: 8s timeout. One-shot: 5s quiet window or 20s hard cap.
|
|||
|
|
bool shouldCancel = isLoop
|
|||
|
|
? elapsed > 8.0
|
|||
|
|
: quiet > 5.0 || elapsed > 20.0;
|
|||
|
|
if (shouldCancel)
|
|||
|
|
{
|
|||
|
|
if (!_interactionActive) break;
|
|||
|
|
_interactionActive = false;
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
// Simple cancel: both sides. No staged delays / parkour resets — those
|
|||
|
|
// were found to leave the client stuck after normal one-shot actions.
|
|||
|
|
await monitorSession.NotifyAsync(64853035, new byte[] { 1 }, CancellationToken.None);
|
|||
|
|
await monitorSession.NotifyAsync(64112304, new byte[] { 1 }, CancellationToken.None);
|
|||
|
|
monitorLog.Info($"[INTERACT] action ended ({(isLoop ? "loop 8s" : "quiet")} {elapsed:F1}s), cancelled invitee={monitorInvitee}");
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
monitorLog.Warn($"[INTERACT] monitor cancel failed: {ex.Message}");
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskStartPlayerInteractionAction, HandlerPacketKind.Invoke)]
|
|||
|
|
private async Task AskStartPlayerInteractionAction(Connection conn, UxRpcMessage msg)
|
|||
|
|
{
|
|||
|
|
conn.Log.Info($"[INTERACT-START] AskStart bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body.ToArray())}");
|
|||
|
|
|
|||
|
|
// 单人 pose(乐器独奏/普通表情,ActionItemConfig 里 MultiInteractType=0 的条目,
|
|||
|
|
// 含 Type=3 的 12 件乐器 99323001..99323015)从动作菜单选完后就发这一个空包,
|
|||
|
|
// 只有收到 SyncStartPlayerInteractionAction(64579648) 才真正播放并广播 ——
|
|||
|
|
// 客户端 reader 是空函数(RPCDeserializeAuto.lua:3571),所以 payload 必须是空。
|
|||
|
|
// ⛑ 双人链路不能重复 push:AskInvite 第 5 步已经推过同一个 Sync,而客户端在
|
|||
|
|
// AskReply 之后仍会再发本包(packets-20260917-151512.log:38870-38874 实证),
|
|||
|
|
// ⇒ 用 _interactionActive 去重,否则动作会被二次触发。
|
|||
|
|
if (!_interactionActive)
|
|||
|
|
{
|
|||
|
|
await conn.NotifyAsync(MethodId.SyncStartPlayerInteractionAction, Array.Empty<byte>());
|
|||
|
|
conn.Log.Info("[INTERACT-START] solo pose -> echo SyncStartPlayerInteractionAction(64579648) empty payload");
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
conn.Log.Info("[INTERACT-START] two-person flow already pushed SyncStart, skipped (no double push)");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await conn.ReturnEmptyAsync(msg, 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskReplyInvitePlayerInteractionAction, HandlerPacketKind.Invoke)]
|
|||
|
|
private Task AskReplyInvitePlayerInteractionAction(Connection conn, UxRpcMessage msg)
|
|||
|
|
{
|
|||
|
|
conn.Log.Info($"[INTERACT-REPLY] AskReply bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body.ToArray())}");
|
|||
|
|
return conn.ReturnEmptyAsync(msg, 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskGetUnlockedVehicles, HandlerPacketKind.Invoke)]
|
|||
|
|
private Task AskGetUnlockedVehicles(Connection conn, UxRpcMessage msg)
|
|||
|
|
=> conn.ReturnAsync(msg, VehicleOwnershipSnapshot(GetVehicleRuntime(msg.Context)));
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskSummonVehicle, HandlerPacketKind.Invoke)]
|
|||
|
|
private async Task AskSummonVehicle(Connection conn, UxRpcMessage msg)
|
|||
|
|
{
|
|||
|
|
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
|
|||
|
|
{
|
|||
|
|
await conn.ReturnEmptyAsync(msg, 1);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!msg.TryGetArgs<GameMethods.AskSummonVehicleArgs4229938>(out var args) || args is null
|
|||
|
|
|| msg.Body.Length != 20 || args.vehicleconfigid == 0
|
|||
|
|
|| !float.IsFinite(args.position.X) || !float.IsFinite(args.position.Y) || !float.IsFinite(args.position.Z)
|
|||
|
|
|| !float.IsFinite(args.facingdirection))
|
|||
|
|
{
|
|||
|
|
conn.Log.Warn($"[VEHICLE] AskSummonVehicle bad args bytes={msg.Body.Length}");
|
|||
|
|
await conn.ReturnEmptyAsync(msg, 1);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
// Unknown / model-less ids are allowed with a warning (newer client data may know
|
|||
|
|
// more vehicles than our dump); the client validates against its own configs. Proven
|
|||
|
|
// behavior of the sibling 08.05 build — rejecting here would break client F1 summon.
|
|||
|
|
|
|||
|
|
var playerPos = new Vec3(args.position.X, args.position.Y, args.position.Z);
|
|||
|
|
|
|||
|
|
// ★★ 2026-10-03「召唤载具从车道开过来」:车不再凭空出现在玩家右手边,而是
|
|||
|
|
// ① 先落在附近一条车道上(距玩家 [min,max] 米),② 再给它一个 Cruise 任务
|
|||
|
|
// 开到玩家身边的**路边**停好(终点取车道上离玩家最近的点,不是玩家脚下)。
|
|||
|
|
//
|
|||
|
|
// 原生本来就有这一半:<c>SummonVehicleResult.TaskToken</c> 回执之后,客户端
|
|||
|
|
// <c>SummonVehicleManager</c> 拿同一个 token 去 <c>RegisterTaskAIStartedEvent</c>
|
|||
|
|
// ⇒ 命中才播 <c>OnSummonShowStart</c>。所以我们给到达任务<b>复用同一个 token</b>。
|
|||
|
|
//
|
|||
|
|
// ⛑⛑ 这里任何一步失败(没车道 / 室内 / 孤岛车道 / 落车失败)都<b>退回旧路径</b>
|
|||
|
|
// (刷在身边 5.5m)—— 宁可没演出,也不能让玩家叫不出车。
|
|||
|
|
var driveInCfgs = PrivateServerConfigStore.Current.Gameplay.Vehicles.SummonDriveIn;
|
|||
|
|
if (driveInCfgs.Enabled && VehicleLaneGraph.Available
|
|||
|
|
&& VehicleLaneGraph.TrySnapLane(playerPos.X, playerPos.Z, driveInCfgs.StopSnapMeters,
|
|||
|
|
driveInCfgs.MaxLateralDeviationMeters, out var stopSnap)
|
|||
|
|
&& VehicleLaneGraph.TryPickDriveInSpawn(playerPos.X, playerPos.Z,
|
|||
|
|
driveInCfgs.MinDistanceMeters, driveInCfgs.MaxDistanceMeters,
|
|||
|
|
driveInCfgs.MaxLateralDeviationMeters, Random.Shared,
|
|||
|
|
out var sx, out var sy, out var sz, out var spawnFacing))
|
|||
|
|
{
|
|||
|
|
var spawnPos = new Vec3(sx, sy, sz);
|
|||
|
|
var driveIn = await SpawnDirectAsync(msg.Context.Session, args.vehicleconfigid, playerPos,
|
|||
|
|
spawnFacing, "client-summon-drivein", exactPos: spawnPos);
|
|||
|
|
if (driveIn.Ok)
|
|||
|
|
{
|
|||
|
|
await StartSummonDriveInAsync(conn, msg.Context.Session, driveIn.EntityId, driveIn.Token,
|
|||
|
|
spawnPos, new Vec3(stopSnap.X, stopSnap.Y, stopSnap.Z), driveInCfgs);
|
|||
|
|
await conn.ReturnAsync(msg, new GameMethods.SummonVehicleResult4229938
|
|||
|
|
{
|
|||
|
|
VehicleEntityId = driveIn.EntityId,
|
|||
|
|
TaskToken = driveIn.Token,
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!driveInCfgs.FallbackToBesidePlayer)
|
|||
|
|
{
|
|||
|
|
conn.Log.Warn($"[VEHICLE] summon drive-in spawn failed: {driveIn.Message}");
|
|||
|
|
await conn.ReturnEmptyAsync(msg, 3);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
conn.Log.Warn($"[VEHICLE] summon drive-in spawn failed, falling back to beside-player: {driveIn.Message}");
|
|||
|
|
}
|
|||
|
|
else if (driveInCfgs.Enabled && driveInCfgs.LogEverySummon)
|
|||
|
|
{
|
|||
|
|
// 车道表没载入 / 附近没合格车道 ⇒ 静默走旧路径,但留一条日志好排查。
|
|||
|
|
conn.Log.Info($"[VEHICLE] summon drive-in unavailable (lanes={VehicleLaneGraph.Available}, "
|
|||
|
|
+ $"player=({playerPos.X:F0},{playerPos.Y:F0},{playerPos.Z:F0})) -> beside-player");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var result = await SpawnDirectAsync(msg.Context.Session, args.vehicleconfigid, playerPos, args.facingdirection, "client-summon");
|
|||
|
|
if (!result.Ok)
|
|||
|
|
{
|
|||
|
|
await conn.ReturnEmptyAsync(msg, 3);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
await conn.ReturnAsync(msg, new GameMethods.SummonVehicleResult4229938
|
|||
|
|
{
|
|||
|
|
VehicleEntityId = result.EntityId,
|
|||
|
|
TaskToken = result.Token,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 召唤车「开过来」到达之后,把权威位置钉死在<b>它现在真正的位置</b>(2026-10-03 实机修瞬移)。
|
|||
|
|
///
|
|||
|
|
/// <para>症状:车能开到身边,玩家一上车就瞬移回生成点。</para>
|
|||
|
|
///
|
|||
|
|
/// <para>根因:服务端在生成时下发了 <c>SyncAetherAIVehicleAddData</c>(<c>ClientVehicleInitData</c>),
|
|||
|
|
/// 里面写的是<b>生成点坐标</b>且 <c>LaneHandle=-1</c>(不参与车道推进)⇒ 这份 Aether 坐标
|
|||
|
|
/// 之后再也没人更新。车是被 <c>SyncAssignVehicleAITask</c> 的 Cruise(Raid 通道)开过来的,
|
|||
|
|
/// 客户端两套表示就此分叉。玩家上车时客户端拿 Aether 那份数据对齐实体 ⇒ 车被拽回生成点。
|
|||
|
|
/// 服务端上车那几秒<b>一条 S2C 位置包都没发</b>(14:46:42–45 全是 C2S),所以不是我们回填错了,
|
|||
|
|
/// 是客户端自己的那份旧值。</para>
|
|||
|
|
///
|
|||
|
|
/// <para>对策:<c>SyncTeleportVehicle</c>(68088911) 是 Raid 通道的权威位置包(赛车 R309 /
|
|||
|
|
/// 改装店钉车 / 传送都在用,<c>Reset=true</c> = 客户端直接落位不插值)。位置取
|
|||
|
|
/// <c>AskVehicleMove</c> 上报的最新值 —— AI 开车期间客户端每秒都在报,所以这一发
|
|||
|
|
/// 视觉上是「原地不动」,只是把两边强行对齐。</para>
|
|||
|
|
/// </summary>
|
|||
|
|
internal static async Task CompleteSummonDriveInAsync(
|
|||
|
|
Connection conn, VehicleRuntimeState runtime, ulong entityId)
|
|||
|
|
{
|
|||
|
|
var cfg = PrivateServerConfigStore.Current.Gameplay.Vehicles.SummonDriveIn;
|
|||
|
|
if (!cfg.Enabled)
|
|||
|
|
return;
|
|||
|
|
|
|||
|
|
ulong token;
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
// 只管「玩家刚叫的那台车」:出租车 / 车流 / 骇入的到达不该被我们收尾。
|
|||
|
|
if (entityId == 0 || entityId != runtime.SummonedEntity)
|
|||
|
|
return;
|
|||
|
|
if (!runtime.Vehicles.ContainsKey(entityId))
|
|||
|
|
return;
|
|||
|
|
token = runtime.AutoDriveTaskToken;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var session = conn.Session;
|
|||
|
|
|
|||
|
|
// ① 撤掉 Cruise 任务(= 出租车的 StopTaxiTaskAsync)。
|
|||
|
|
// ⛑⛑ 这一步是三个症状的共同解药:客户端 SummonVehicleManager 用
|
|||
|
|
// RegisterTaskAIDeletedEvent(token) 等**任务被删除**才走 OnSummonShowFinish;
|
|||
|
|
// 任务不删 ⇒ 演出永远不结束 ⇒ ① 车还在 AI 手里,玩家方向盘无效("车开不动")
|
|||
|
|
// ② 客户端认为这台车还在召唤流程中,不给再上车 ③ "叫车小助手忙",不能再叫一台。
|
|||
|
|
if (cfg.EndTaskOnArrival)
|
|||
|
|
{
|
|||
|
|
await AssignAutonomousStopAsync(session, runtime, entityId);
|
|||
|
|
|
|||
|
|
// ②⛑⛑ 交还主控权("车开不动"的真凶)。
|
|||
|
|
// Cruise 下发时我们把控制权锁成了 Profile.PlayerPid(不锁的话玩家方向盘会盖掉
|
|||
|
|
// AI 任务,车开不过来);到达后必须还原成 0,否则主控权一直在服务端手里,
|
|||
|
|
// 玩家即使坐在主驾(seat=0)也只算乘客 —— 18:38:55 那次坐 seat=0 一样开不动,
|
|||
|
|
// 就是这条没做。
|
|||
|
|
if (cfg.ReleaseControlOnArrival)
|
|||
|
|
{
|
|||
|
|
await session.NotifyAsync(MethodId.SyncChangeVehicleController, UxSerializer.Serialize(
|
|||
|
|
new GameMethods.SyncChangeVehicleController4229938
|
|||
|
|
{
|
|||
|
|
vehicleInstanceId = entityId,
|
|||
|
|
controllerPid = 0,
|
|||
|
|
}), CancellationToken.None);
|
|||
|
|
conn.Log.Info($"[VEHICLE] SUMMON_DRIVE_IN release control entity={entityId} controllerPid=0");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ③ 关掉自驾状态。顺序照抄出租车:先 Stop 任务,再推 false。
|
|||
|
|
// 不关 ⇒ 客户端一直当这车在自动驾驶中,玩家输入被 AI 盖掉。
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
runtime.AutoDriving = false;
|
|||
|
|
runtime.AutoDrivingVehicleId = 0;
|
|||
|
|
runtime.AutoDriveTaskToken = 0;
|
|||
|
|
runtime.AutoDriveHasTarget = false;
|
|||
|
|
runtime.AutoDriveLastPoints = null;
|
|||
|
|
}
|
|||
|
|
await PushVehicleAutonomousStateAsync(conn, entityId, false);
|
|||
|
|
conn.Log.Info($"[VEHICLE] SUMMON_DRIVE_IN finish entity={entityId} token={token} "
|
|||
|
|
+ "(stop AI task + auto-drive state off)");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ④ 位置对齐放在最后:Stop / 控制权重置 / 状态翻转都可能让客户端拿旧坐标复位,
|
|||
|
|
// 这一发把权威位置钉死在真实位置,覆盖掉任何复位。
|
|||
|
|
if (cfg.SyncPositionOnArrival)
|
|||
|
|
{
|
|||
|
|
float px, py, pz, facing;
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (!runtime.Vehicles.TryGetValue(entityId, out var rec))
|
|||
|
|
return;
|
|||
|
|
px = rec.Position.X; py = rec.Position.Y; pz = rec.Position.Z; facing = rec.Facing;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await session.NotifyAsync(MethodId.SyncTeleportVehicle, UxSerializer.Serialize(
|
|||
|
|
new SceneMethods.SyncTeleportVehicle4229938
|
|||
|
|
{
|
|||
|
|
EntityId = entityId,
|
|||
|
|
Position = new SceneMethods.UxVector3(px, py, pz),
|
|||
|
|
Rotation = new SceneMethods.UxVector3(0f, facing, 0f),
|
|||
|
|
Velocity = 0f,
|
|||
|
|
Reset = true, // 真跳变:客户端直接落位,不插值(与赛车/改装店一致)
|
|||
|
|
MoveToken = 0,
|
|||
|
|
}), CancellationToken.None);
|
|||
|
|
|
|||
|
|
conn.Log.Info($"[VEHICLE] SUMMON_DRIVE_IN align entity={entityId} "
|
|||
|
|
+ $"at=({px:F1},{py:F1},{pz:F1}) yaw={facing:F1} (SyncTeleportVehicle 68088911 Reset=true)");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ⑤ 把交互重新打开:车开过来的过程中客户端可能把它标成不可交互
|
|||
|
|
// (AI 接管中的车不给按 F),到站了必须还回来,否则下车后就再也上不去。
|
|||
|
|
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
|
|||
|
|
new GameMethods.SyncChangeVehicleInteractable4229938
|
|||
|
|
{
|
|||
|
|
vehicleInstanceId = entityId,
|
|||
|
|
interactable = true,
|
|||
|
|
}), CancellationToken.None);
|
|||
|
|
conn.Log.Info($"[VEHICLE] SUMMON_DRIVE_IN interactable=true entity={entityId}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 召唤载具的「开过来」那一半(2026-10-03,<see cref="SummonDriveInSettings"/>)。
|
|||
|
|
///
|
|||
|
|
/// <para>三步,<b>顺序不能改</b>:
|
|||
|
|
/// <list type="number">
|
|||
|
|
/// <item><c>SyncAetherAIChangeVehicleControl(2)</c> —— 把车切回 <b>RaidVehicle</b>。
|
|||
|
|
/// 客户端会把有 VDE 的车交给 Aether 车道模拟,位移每帧被车道覆盖 ⇒ Cruise 变成
|
|||
|
|
/// 「轮胎在转、车在原地进行」(<c>GameRouter.Taxi.cs:147-166</c>,出租车踩过的)。</item>
|
|||
|
|
/// <item><c>SyncVehicleAutonomousDrivingState(true)</c> —— 必须<b>早于</b> AI 任务,
|
|||
|
|
/// 否则客户端把这条任务当成「别人的车被接管」渲染 ⇒ 车门消失 / 角色缺模
|
|||
|
|
/// (2026-09-30 §43.1)。</item>
|
|||
|
|
/// <item><c>SyncAssignVehicleAITask</c> + Cruise,<b>token 复用召唤回执那个</b>。</item>
|
|||
|
|
/// </list>
|
|||
|
|
/// </para>
|
|||
|
|
///
|
|||
|
|
/// <para>⛑ 终点是「车道上离玩家最近的点」(靠边停车),不是玩家脚下:自定义标点吸附失败时
|
|||
|
|
/// 客户端一条 Cruise 都不下发,表现是「叫了车没反应」。解不出车道路径就只给终点,
|
|||
|
|
/// 交给客户端 C# <c>NavigationHelper</c> 自己导航(自驾已验证能到任意 POI)。</para>
|
|||
|
|
/// </summary>
|
|||
|
|
private async Task StartSummonDriveInAsync(Connection conn, TcpSession session,
|
|||
|
|
ulong entityId, ulong token, Vec3 spawn, Vec3 stop, SummonDriveInSettings cfg)
|
|||
|
|
{
|
|||
|
|
var runtime = GetVehicleRuntimeState(session);
|
|||
|
|
|
|||
|
|
if (cfg.ForceRaidControl)
|
|||
|
|
await session.NotifyAsync(MethodId.SyncAetherAIChangeVehicleControl,
|
|||
|
|
new UxWriter().U64(entityId).U8(2).ToArray(), CancellationToken.None);
|
|||
|
|
|
|||
|
|
await PushVehicleAutonomousStateAsync(conn, entityId, true);
|
|||
|
|
|
|||
|
|
var path = new List<SceneMethods.UxVector3> { new(stop.X, stop.Y, stop.Z) };
|
|||
|
|
var laneRoute = false;
|
|||
|
|
if (cfg.UseLaneRoute)
|
|||
|
|
{
|
|||
|
|
var route = SolveRoute(conn, spawn.X, spawn.Y, spawn.Z, stop.X, stop.Y, stop.Z,
|
|||
|
|
needCenter: false, "summon-drivein");
|
|||
|
|
if (route is not null && route.Value.Ok && route.Value.DrivePoints.Count >= 2)
|
|||
|
|
{
|
|||
|
|
path = route.Value.DrivePoints;
|
|||
|
|
laneRoute = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await AssignAutonomousCruiseAsync(session, runtime, entityId, path, cfg.SpeedKmh / 3.6f,
|
|||
|
|
tokenOverride: token);
|
|||
|
|
|
|||
|
|
var flat = MathF.Sqrt((spawn.X - stop.X) * (spawn.X - stop.X)
|
|||
|
|
+ (spawn.Z - stop.Z) * (spawn.Z - stop.Z));
|
|||
|
|
session.Log.Info($"[VEHICLE] SUMMON_DRIVE_IN entity={entityId} token={token} "
|
|||
|
|
+ $"spawn=({spawn.X:F1},{spawn.Y:F1},{spawn.Z:F1}) "
|
|||
|
|
+ $"stop=({stop.X:F1},{stop.Y:F1},{stop.Z:F1}) straight={flat:F1}m "
|
|||
|
|
+ $"points={path.Count} laneRoute={laneRoute} speed={cfg.SpeedKmh:0.#}km/h");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Direct scene spawn shared by client summon and the admin panel:
|
|||
|
|
/// SyncLogicVehicleEnter + SyncSpawnVehicle (interactable) + SyncChangeVehicleInteractable.
|
|||
|
|
/// Returns the new entity id + task token. Replaces the previous summoned vehicle.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="exactPos">
|
|||
|
|
/// 给了就<b>原样</b>落在这(只 +0.15m 抬高),不再做「玩家右手边 5.5m」的偏移。
|
|||
|
|
/// 改装店换展示车必须走这条:工位在地下(y<0),按玩家朝向偏移会把车摆进墙里
|
|||
|
|
/// (donor 13:38 实测「车在你看的地方/卡墙」)。
|
|||
|
|
/// </param>
|
|||
|
|
internal static async Task<(bool Ok, string Message, ulong EntityId, ulong Token)> SpawnDirectAsync(
|
|||
|
|
TcpSession session, uint vehicleId, Vec3 nearPos, float facing, string reason, Vec3? exactPos = null,
|
|||
|
|
int createSourceType = 2, bool destroyPrevious = true)
|
|||
|
|
{
|
|||
|
|
if (vehicleId == 0)
|
|||
|
|
return (false, "vehicle 0 does not exist", 0, 0);
|
|||
|
|
|
|||
|
|
// Unknown / model-less ids are allowed with a warning (newer client data may know
|
|||
|
|
// more vehicles than our dump); the client validates against its own configs.
|
|||
|
|
var seatCount = 2;
|
|||
|
|
if (VehicleCatalog4229938.TryGet(vehicleId, out var config))
|
|||
|
|
{
|
|||
|
|
if (config.SeatCount is >= 1 and <= 16)
|
|||
|
|
seatCount = config.SeatCount;
|
|||
|
|
if (string.IsNullOrWhiteSpace(config.Model))
|
|||
|
|
session.Log.Warn($"[VEHICLE] config {vehicleId} has no prefab model in dump, trying anyway");
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
session.Log.Warn($"[VEHICLE] config {vehicleId} not in VehicleConfig dump, trying with {seatCount} seats");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var runtime = GetVehicleRuntimeState(session);
|
|||
|
|
ulong oldEntity;
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
oldEntity = runtime.SummonedEntity;
|
|||
|
|
if (oldEntity != 0 && runtime.CurrentVehicleId == oldEntity)
|
|||
|
|
return (false, "current summoned vehicle is occupied (exit it first)", 0, 0);
|
|||
|
|
}
|
|||
|
|
if (destroyPrevious && oldEntity != 0)
|
|||
|
|
await DestroyVehicleAsync(session, runtime, oldEntity, "replacement");
|
|||
|
|
|
|||
|
|
// Spawn beside the player: 5.5m to the right, +0.15m up (proven offsets).
|
|||
|
|
// exactPos 走改装店工位:不偏移(见参数注释)。
|
|||
|
|
var yaw = facing * (MathF.PI / 180f);
|
|||
|
|
var spawn = exactPos is { } exact
|
|||
|
|
? new Vec3(exact.X, exact.Y + 0.15f, exact.Z)
|
|||
|
|
: new Vec3(nearPos.X + MathF.Cos(yaw) * 5.5f, nearPos.Y + 0.15f, nearPos.Z - MathF.Sin(yaw) * 5.5f);
|
|||
|
|
var entityId = unchecked((ulong)IdCounterStore.Next(VehicleEntityCounter, VehicleEntityFloor));
|
|||
|
|
var token = unchecked((ulong)IdCounterStore.Next(VehicleTokenCounter, VehicleTokenFloor));
|
|||
|
|
var record = new VehicleRecord
|
|||
|
|
{
|
|||
|
|
EntityId = entityId, ConfigId = vehicleId, SeatCount = seatCount,
|
|||
|
|
Position = spawn, Facing = facing, ControllerPid = 0, Interactable = true, TaskToken = token,
|
|||
|
|
};
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
runtime.Vehicles[entityId] = record;
|
|||
|
|
GlobalVehicles[entityId] = record;
|
|||
|
|
runtime.SummonedEntity = entityId;
|
|||
|
|
}
|
|||
|
|
var state = GetStateIfExists(session);
|
|||
|
|
if (state is not null)
|
|||
|
|
{
|
|||
|
|
lock (state.SyncRoot)
|
|||
|
|
{
|
|||
|
|
state.LastAdminVehicleId = vehicleId;
|
|||
|
|
state.LastSpawnedVehicleUid = entityId;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var euler = new SceneMethods.UxVector3(0f, facing, 0f);
|
|||
|
|
var pos = new SceneMethods.UxVector3(spawn.X, spawn.Y, spawn.Z);
|
|||
|
|
// 改装店外观回声(2026-09-26 自 repair-shop.zip 移植):默认套件展开成底座
|
|||
|
|
// (Suit[] + 两条轮轴 + 套件车漆),买过的件按 PartTag 顶替。
|
|||
|
|
// ⛑ 只在改装店开关打开时生效 —— 关掉就是改造前的样子(Parts=[] / SuitId=0,
|
|||
|
|
// 客户端回落到配置默认),既保住现有可用行为,也是一个干净的回滚开关。
|
|||
|
|
// ⛑ 纯数值件默认不进刷车包(StatOnlyPartTags),开关 repairShop.sendStatParts 做 A/B。
|
|||
|
|
var tuningOn = PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled;
|
|||
|
|
BuildAppearance(tuningOn ? GetStateIfExists(session) : null, vehicleId,
|
|||
|
|
out var rememberedParts, out var rememberedSuit,
|
|||
|
|
includeStatParts: PrivateServerConfigStore.Current.Gameplay.RepairShop.SendStatParts);
|
|||
|
|
if (!tuningOn)
|
|||
|
|
{
|
|||
|
|
rememberedParts = [];
|
|||
|
|
rememberedSuit = 0;
|
|||
|
|
}
|
|||
|
|
if (tuningOn)
|
|||
|
|
session.Log.Info($"[VEHICLE] spawn appearance config={vehicleId} suit={rememberedSuit} "
|
|||
|
|
+ $"parts=[{string.Join(",", rememberedParts.Select(p => p.Type + ":" + p.ConfigId))}]");
|
|||
|
|
await session.NotifyAsync(MethodId.SyncLogicVehicleEnter, UxSerializer.Serialize(new GameMethods.LogicVehicleClientInfo4229938
|
|||
|
|
{
|
|||
|
|
EntityId = entityId, VehicleConfigId = vehicleId, CreateSourceType = (byte)createSourceType,
|
|||
|
|
Parts = rememberedParts, SuitId = rememberedSuit, LicensePlate = string.Empty, Interactable = true, MoveToken = 0,
|
|||
|
|
Position = pos, EulerAngles = euler, VehicleSpoonName = null,
|
|||
|
|
}), CancellationToken.None);
|
|||
|
|
await session.NotifyAsync(MethodId.SyncSpawnVehicle, UxSerializer.Serialize(new GameMethods.VehicleClientInfo4229938
|
|||
|
|
{
|
|||
|
|
ControllerPid = 0, CreateSourceType = (byte)createSourceType, EntityId = entityId, VehicleConfigId = vehicleId,
|
|||
|
|
Parts = rememberedParts, SuitId = rememberedSuit, Position = pos, Facing = facing, EulerAngles = euler, Velocity = 0,
|
|||
|
|
IsStatic = false, DeformStatus = 0,
|
|||
|
|
SeatInfos = Enumerable.Range(0, seatCount).Select(i => new GameMethods.RaidVehicleSeatInfo4229938
|
|||
|
|
{ EntityId = 0, SeatIndex = (byte)i, SeatState = 0, DestroyRelated = false }).ToList(),
|
|||
|
|
SpoonId = 0, IsDynamicGo = false, VehicleEnemyId = 0, DisableNavigation = false,
|
|||
|
|
Interactable = true, MoveToken = 0, LicensePlate = string.Empty,
|
|||
|
|
}), CancellationToken.None);
|
|||
|
|
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
|
|||
|
|
new GameMethods.SyncChangeVehicleInteractable4229938 { vehicleInstanceId = entityId, interactable = true }),
|
|||
|
|
CancellationToken.None);
|
|||
|
|
// 改装店门禁(2026-09-25 移植):客户端 ApplyCarManager.IsMySummonVehicle 读的是
|
|||
|
|
// DriveVehiclesManager.CurrentSummonVehicle,而它只在收到 68681875 时才写 —— 光有
|
|||
|
|
// SyncSpawnVehicle 不算「自己的车」,进店第一关就弹「只能驾驶自己的载具进入」。
|
|||
|
|
// 载荷 = SummonVehicleResult(u64 entity + u64 token),客户端 C# 原生接收(CSharpOnly,
|
|||
|
|
// Lua 侧没有 reader)。回滚:gameplay.repairShop.pushSummonStart=false。
|
|||
|
|
if (PrivateServerConfigStore.Current.Gameplay.RepairShop is { Enabled: true, PushSummonStart: true })
|
|||
|
|
{
|
|||
|
|
await session.NotifyAsync(MethodId.SyncStartSummonVehicle, UxSerializer.Serialize(
|
|||
|
|
new GameMethods.SummonVehicleResult4229938 { VehicleEntityId = entityId, TaskToken = token }),
|
|||
|
|
CancellationToken.None);
|
|||
|
|
session.Log.Info($"[VEHICLE] SyncStartSummonVehicle pushed entity={entityId} token={token} (repair-shop gate)");
|
|||
|
|
}
|
|||
|
|
// T1-16 载具骇入 (ported from donor_7k): a spawned car is not hackable until three
|
|||
|
|
// things are true — it has HP, it is flagged hackable, and Aether knows it.
|
|||
|
|
// CanHack() requires CurrentHp > 0; SyncSpawnVehicle carries no HP fields.
|
|||
|
|
await session.NotifyAsync(MethodId.SyncVehicleBrokenCollision, UxSerializer.Serialize(
|
|||
|
|
new GameMethods.VehicleBrokenCollisionInfo4229938
|
|||
|
|
{
|
|||
|
|
VehicleEntityId = entityId, CurrentHp = 10_000f, MaxHp = 10_000f,
|
|||
|
|
}), CancellationToken.None);
|
|||
|
|
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleHackableState, UxSerializer.Serialize(
|
|||
|
|
new SceneMethods.SyncVehicleHackableState4229938
|
|||
|
|
{
|
|||
|
|
vehicleEntityId = entityId,
|
|||
|
|
isHackable = true,
|
|||
|
|
}), CancellationToken.None);
|
|||
|
|
// HackTargetSelector iterates AetherVehicleManager.VehicleId2IndexDict and
|
|||
|
|
// requires a VehicleViewEntity. SyncSpawnVehicle alone never populates it.
|
|||
|
|
await RegisterHackVehicleAsync(session, runtime, record);
|
|||
|
|
_ = CompleteHackVehicleInitializationAsync(session, runtime, record);
|
|||
|
|
session.Log.Info($"[VEHICLE] DIRECT_SPAWN reason={reason} config={vehicleId} entity={entityId} token={token} seats={seatCount} spawn=({spawn.X:F1},{spawn.Y:F1},{spawn.Z:F1})");
|
|||
|
|
return (true, $"spawned vehicle {vehicleId} (entity={entityId}) beside you — walk up and press F", entityId, token);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal static async Task DestroyVehicleAsync(TcpSession session, VehicleRuntimeState runtime, ulong entityId, string reason)
|
|||
|
|
{
|
|||
|
|
// ★ 捐赠端 GameRouter.Vehicle.cs:562:车没了要把还挂在车上的警察乘客一并释放,
|
|||
|
|
// 否则押送状态机永远等不到"下车",座位预约也解不掉。
|
|||
|
|
await ReleasePoliceVehiclePassengers4229938(session, entityId);
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
runtime.Vehicles.Remove(entityId);
|
|||
|
|
GlobalVehicles.TryRemove(entityId, out _);
|
|||
|
|
if (runtime.SummonedEntity == entityId)
|
|||
|
|
runtime.SummonedEntity = 0;
|
|||
|
|
if (runtime.CurrentVehicleId == entityId)
|
|||
|
|
{
|
|||
|
|
runtime.CurrentVehicleId = 0;
|
|||
|
|
runtime.CurrentVehicleSeat = -1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
await session.NotifyAsync(MethodId.SyncDestroyVehicle, UxSerializer.Serialize(
|
|||
|
|
new GameMethods.DestroyVehicle4229938 { VehicleEntityId = entityId, VehicleDestroyType = 0, Distance = 0, DynamicGoId = 0 }),
|
|||
|
|
CancellationToken.None);
|
|||
|
|
session.Log.Info($"[VEHICLE] destroy entity={entityId} reason={reason}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Enter / exit / move tracking (notifies; logged + recorded, no speculative pushes yet) ---
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskPlayerStartEnterOrExitVehicle)]
|
|||
|
|
private async Task AskPlayerStartEnterOrExitVehicle(Connection conn, UxRpcMessage msg)
|
|||
|
|
{
|
|||
|
|
if (msg.TryGetArgs<GameMethods.PlayerVehicleDriveStateInfo4229938>(out var args) && args is not null)
|
|||
|
|
{
|
|||
|
|
var runtime = GetVehicleRuntime(msg.Context);
|
|||
|
|
VehicleRecord? boarded = null;
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (args.EnterOrLeave)
|
|||
|
|
{
|
|||
|
|
runtime.CurrentVehicleId = args.VehicleEntityId;
|
|||
|
|
runtime.CurrentVehicleSeat = args.SeatIndex;
|
|||
|
|
runtime.LastDrivenVehicleId = args.VehicleEntityId;
|
|||
|
|
if (runtime.Vehicles.TryGetValue(args.VehicleEntityId, out var record))
|
|||
|
|
{
|
|||
|
|
record.ControllerPid = args.Pid;
|
|||
|
|
// ★ 车流车:坐进去之前先摘出车道流,否则玩家会被车道拽着跑,
|
|||
|
|
// 方向盘完全不起作用(每 50ms 里程被服务端覆盖一次)。
|
|||
|
|
if (record.IsTraffic)
|
|||
|
|
boarded = record;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else if (runtime.CurrentVehicleId == args.VehicleEntityId)
|
|||
|
|
{
|
|||
|
|
runtime.CurrentVehicleId = 0;
|
|||
|
|
runtime.CurrentVehicleSeat = -1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (boarded is not null)
|
|||
|
|
await DetachTrafficVehicleFromLaneAsync(conn.Session, boarded);
|
|||
|
|
conn.Log.Info($"[VEHICLE] {(args.EnterOrLeave ? "ENTER" : "EXIT")} pid={args.Pid} vehicle={args.VehicleEntityId} seat={args.SeatIndex} force={args.IfForce}");
|
|||
|
|
|
|||
|
|
// ★ 赛车(2026-10-03 移植):上车 = 「等玩家上车才开赛」那道闸的放行信号。
|
|||
|
|
// 只在 config/racing-ablation.txt 写了 `boarding` 时那道闸才存在;
|
|||
|
|
// 没有等待中的比赛时本调用是**静默 no-op**,不影响任何既有行为。
|
|||
|
|
// 两个包(Start / Finish)都监听是有意的:谁先到用谁,闸本身幂等。
|
|||
|
|
if (args.EnterOrLeave)
|
|||
|
|
SignalRacingBoarding(
|
|||
|
|
msg.Context, args.VehicleEntityId, args.SeatIndex, "AskPlayerStartEnterOrExitVehicle");
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
conn.Log.Warn($"[VEHICLE] AskPlayerStartEnterOrExitVehicle undecodable bytes={msg.Body.Length}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskPlayerFinishEnterOrExitVehicle)]
|
|||
|
|
private Task AskPlayerFinishEnterOrExitVehicle(Connection conn, UxRpcMessage msg)
|
|||
|
|
{
|
|||
|
|
if (msg.TryGetArgs<GameMethods.PlayerVehicleDriveStateInfo4229938>(out var args) && args is not null)
|
|||
|
|
{
|
|||
|
|
conn.Log.Info($"[VEHICLE] FINISH {(args.EnterOrLeave ? "enter" : "exit")} vehicle={args.VehicleEntityId} seat={args.SeatIndex}");
|
|||
|
|
|
|||
|
|
// ★ 赛车:同 Start 那条 —— 上车动画**完成**(人已坐好)也放行(见上方注释)。
|
|||
|
|
if (args.EnterOrLeave)
|
|||
|
|
SignalRacingBoarding(
|
|||
|
|
msg.Context, args.VehicleEntityId, args.SeatIndex, "AskPlayerFinishEnterOrExitVehicle");
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
conn.Log.Warn($"[VEHICLE] AskPlayerFinishEnterOrExitVehicle undecodable bytes={msg.Body.Length}");
|
|||
|
|
return Task.CompletedTask;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskVehicleMove)]
|
|||
|
|
private Task AskVehicleMove(Connection conn, UxRpcMessage msg)
|
|||
|
|
{
|
|||
|
|
if (msg.TryGetArgs<GameMethods.RaidVehicleSyncData4229938>(out var args) && args is not null)
|
|||
|
|
{
|
|||
|
|
var runtime = GetVehicleRuntime(msg.Context);
|
|||
|
|
bool firstLog = false;
|
|||
|
|
lock (runtime.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (runtime.Vehicles.TryGetValue(args.Id, out var record))
|
|||
|
|
{
|
|||
|
|
record.Position = new Vec3(args.Position.X, args.Position.Y, args.Position.Z);
|
|||
|
|
record.Facing = args.facingDirection;
|
|||
|
|
record.LastBits = args.Bits ?? [];
|
|||
|
|
firstLog = !record.MoveLogged;
|
|||
|
|
record.MoveLogged = true;
|
|||
|
|
}
|
|||
|
|
// While driving, the vehicle IS the player position (admin panel follows).
|
|||
|
|
if (runtime.CurrentVehicleId == args.Id)
|
|||
|
|
{
|
|||
|
|
var state = GetStateIfExists(msg.Context.Session);
|
|||
|
|
if (state is not null)
|
|||
|
|
{
|
|||
|
|
state.LastReportedPlayerPosition = new Vec3(args.Position.X, args.Position.Y, args.Position.Z);
|
|||
|
|
state.HasLastReportedPlayerTransform = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (firstLog)
|
|||
|
|
conn.Log.Info($"[VEHICLE] move-start entity={args.Id} pos=({args.Position.X:F1},{args.Position.Y:F1},{args.Position.Z:F1})");
|
|||
|
|
}
|
|||
|
|
return Task.CompletedTask;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// AskClaimVehicleSeat lives in GameRouter.VehicleStory.cs (story-aware seat re-pin).
|
|||
|
|
|
|||
|
|
// --- Drive-loop accepts (proven set from the sibling 08.05 build) ---
|
|||
|
|
// The client keeps driving client-side; the server only has to accept the drive-loop
|
|||
|
|
// notifies and answer the drive-state invokes with an empty OK so the loop never stalls.
|
|||
|
|
|
|||
|
|
[Handler(MethodId.AskVehicleStartMove)]
|
|||
|
|
[Handler(MethodId.AskVehicleStopMove)]
|
|||
|
|
[Handler(MethodId.AskEnterVehicleIndoor)]
|
|||
|
|
[Handler(MethodId.AskExitVehicleIndoor)]
|
|||
|
|
[Handler(MethodId.AskKillVehicle)]
|
|||
|
|
private Task VehicleNotifyAccept(Connection conn, UxRpcMessage msg) => Task.CompletedTask;
|
|||
|
|
|
|||
|
|
// AskVehicleHorn / ReportDrivingVehicle / AskVehicleNitro already have logging/tracking
|
|||
|
|
// handlers in GameRouter.UnhandledBatch.cs — do not re-register them here.
|
|||
|
|
|
|||
|
|
[Handler(MethodId.VehicleDriveStateChange)]
|
|||
|
|
[Handler(MethodId.AskChangeGoVehicleDriveState)]
|
|||
|
|
[Handler(MethodId.AskVehicleDeadEnd)]
|
|||
|
|
[Handler(MethodId.AskVehicleStuck)]
|
|||
|
|
[Handler(MethodId.AskVehicleHit)]
|
|||
|
|
[Handler(MethodId.AskVehicleHitEnd)]
|
|||
|
|
private Task VehicleInvokeAccept(Connection conn, UxRpcMessage msg)
|
|||
|
|
=> conn.ReturnEmptyOkAsync(msg);
|
|||
|
|
}
|