using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
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 NavMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
///
/// 出租车(2026-09-30 新增)。逐字读过 v0.5-2 捐赠端
/// GameRouter.Taxi.cs(357 行) + GameRouter.TaxiNative.cs(210 行) 后,
/// 按主线自己的车辆栈重写 —— 不是照抄。
///
/// ★ 从捐赠端确认的四条硬事实(都写进了注释,别推翻):
///
/// - 目的地是配置 uint,不是车辆 uid:TaxiSystemManager 传的是
/// TaxiNavigationConfig.json 的 Id(共 18 条),所以必须查表,
/// 而且要校验 RaidId == 当前场景(表里混着 23300888 与 23300999 两个 raid)。
/// - 只有 VehicleType == 8 的车能开出租车 UI。主线 VehicleConfig.json 里
/// 正好 4 辆:81004001 / 81004004 / 81004015 / 81004038(都是 4 座)。
/// - 请求是裸定长字节:Start=8B(u32 目的地 + u32 UI 距离)、End=6B(u8 传送 + u8 + u32)、
/// Accelerate=0B、Status=11B(u64 + u8 + i16 停靠原因)。
/// ⛑ UI 那个「距离」不信任,车费只按服务端自己的里程算。
/// - 计费:Ceiling(起步价 + Max(0, 里程 - 免费里程) * 单价),
/// 捐赠端原值 300 / 200m / 0.4 每米。
///
///
/// ★ 与捐赠端的故意差异(照抄会踩坑):
///
/// - 不自己拼 Cruise 包:主线 GameRouter.VehicleNav.AssignAutonomousCruiseAsync
/// 发的就是同一个 AssignVehicleControl<VehicleCruiseParameters>,而且它还负责
/// ① 抢主控权 ② 记住 token ③ 主动 Acknowledge 一次(不确认车就不动,2026-09-28 第三轮的坑)。
/// ⇒ 直接复用,只多传一个速度覆盖参数。
/// - 里程不自己挂钩:主线 GameRouter.UnhandledBatch.cs:41 已经在累加
/// VehicleRecord.DistanceDriven。捐赠端的 RecordTaxiMovement 不需要搬。
/// - 路线用 SolveRoute(车道图 Dijkstra),不是捐赠端的
/// FindNavigation —— 主线本来就有,且返回 DrivePoints(车→终点正向)。
/// - 不做「长按跳过 → 传送」(阶段一)。那要接主线 GameRouter.Teleport.cs
/// 的 ReportPreTeleportFinish 链,耦合深;AskEndTaxiTeleport 暂留在
/// GameRouter.MiscAcks.cs 当占位。
///
///
/// ⛑ 回滚:gameplay.taxi.enabled=false ⇒ 四个 handler 全部直接回失败码
///(注册与日志仍在,可观测)。要彻底回到占位 AckVoid,把
/// GameRouter.MiscAcks.cs 里那两条注释掉的 AckVoid 补回来并删掉本文件。
///
internal sealed partial class GameRouter
{
private static TaxiSettings TaxiCfg => PrivateServerConfigStore.Current.Gameplay.Taxi;
/// 出租车车型。VehicleConfig.json 里 VehicleType == 8 的 4 辆。
private const int TaxiVehicleType = 8;
// ------------------------------------------------------------------ 行程
internal sealed class TaxiTrip4229938
{
public ulong VehicleId;
public uint DestinationId;
public uint Raid;
public float TargetX, TargetY, TargetZ;
/// 起点里程(VehicleRecord.DistanceDriven)。车费 = 当前里程 - 它。
public double StartOdometer;
/// 上次观察到的里程,用来判断「还在不在动」。
public double LastOdometer;
public bool Accelerated;
public bool Arrived;
public bool Ended;
public bool FareSettled;
public bool StallNotified;
///
/// 当前是否已经下发过 Cruise 任务。对应捐赠端的 trip.Token != 0
/// (主线不自己管 token,由 AssignAutonomousCruiseAsync 内部 mint)。
/// ⛑ 捐赠端靠它决定「什么时候该重派」:Token == 0(从没派过/刚被停)
/// 或收到 Resume(TaxiInteract=64) 才派。
///
public bool Dispatched;
public long StartedTick;
public long LastMovementTick;
}
///
/// 能不能开出租车。判据逐条来自捐赠端 CanTakeTaxi4229938:
/// 人在车上的乘客座(Seat > 0,主驾是 0)、不在下车流程中、
/// 车可交互且已注册 Aether、车型是出租车。
///
/// ⛑⛑ 最后一条()是
/// 主线自己加的,捐赠端没有,但不加就一定踩坑(2026-09-30 实锤):
/// 客户端 AetherVehicleManager.AddVehicleDataEntity 按 cfgId 在
/// VehicleSpawnConfig 里查车辆模板,查不到就整台丢掉 ——
/// Player.log 三连:VehicleInit_Drop_NoConfig: vid=… cfgId=81004001 not found in VehicleSpawn
/// → Attach failed: VDE is null → Flush failed to create VDE。
/// 后果是 没有 VehicleDataEntity ⇒ 车不会动、上下车没有动画
/// (同一次实测:[TAXI] settled meters=0.0,车一米没走)。
/// 4 辆 VehicleType == 8 里 81004001 不在那张表,只有
/// 81004004 / 81004015 / 81004038 能用。
///
private static bool CanTakeTaxi(VehicleRuntimeState runtime, VehicleRecord v)
=> runtime.CurrentVehicleId == v.EntityId
&& runtime.CurrentVehicleSeat > 0 && runtime.CurrentVehicleSeat < v.SeatCount
&& !runtime.StoryExitInProgress
&& v.Interactable && v.AetherRegistered
&& VehicleCatalog4229938.TryGet(v.ConfigId, out var cfg) && cfg.VehicleType == TaxiVehicleType
&& VehicleSpawnCatalog4229938.IsTrafficVehicle(v.ConfigId);
///
/// 客户端认得的出租车车型(VehicleType == 8 且在 VehicleSpawnConfig 里)。
/// 只用于日志/报错提示,让玩家知道该召哪一辆。
///
private static string UsableTaxiConfigIds()
{
var ids = VehicleCatalog4229938.AllSummonable
.Where(e => e.VehicleType == TaxiVehicleType && VehicleSpawnCatalog4229938.IsTrafficVehicle(e.Id))
.Select(e => e.Id.ToString())
.ToArray();
return ids.Length > 0 ? string.Join(" / ", ids) : "(none)";
}
/// 车费。沿用捐赠端 TaxiFare4229938:Ceiling(Base + Max(0, d-Free) * PerMeter)。
private static double TaxiFare(double meters)
{
var cfg = TaxiCfg;
return Math.Ceiling(cfg.BaseFare + Math.Max(0, meters - cfg.FreeMeters) * cfg.FarePerMeter);
}
private static float TaxiSpeedMs(bool accelerated)
{
var cfg = TaxiCfg;
return (accelerated ? cfg.AcceleratedSpeedKmh : cfg.NormalSpeedKmh) / 3.6f;
}
// ------------------------------------------------------------------ 派单 / 收车
///
/// ⛑⛑ 最关键的一步(2026-09-30 第二轮补上,之前漏了 ⇒ 所有出租车原地不动):
/// 玩家在出租车 UI 里确认目的地时,客户端走
/// TaxiSystemManager.StartTaxiOrder → SetTaxiVehicleControl(true) → City.SetAetherVehicleControl
/// 把车切成 AetherVehicle=1;而 Cruise 与 SyncTeleportVehicle 都要求
/// RaidVehicle=2 —— 捐赠端 GameRouter.TaxiNative.cs:33-35 的原话:
/// Cruise and SyncTeleportVehicle need RaidVehicle=2; a controller PID alone is insufficient.
///
/// 只发 SyncChangeVehicleController 不够:车仍归 Aether 车道模拟管,
/// 位移每帧被车道覆盖 ⇒ 表现就是「轮胎在转,车在原地进行」。
///
/// 反向验证:81004001 之前反而能开到目的地 —— 因为它不在客户端
/// VehicleSpawnConfig 里、压根没建成 VDE,客户端切不动 Aether,
/// 车一直留在 Raid 控制下 ⇒ Cruise 生效(代价是没有 VDE ⇒ 没有上下车动画)。
/// 两台车一个能跑没动画、一个有动画不能跑,正好各缺一半,两边证据互相印证。
///
private static Task SetTaxiRaidControlAsync(TcpSession session, ulong vehicleEntityId)
// Args68321386.Read (RVA 0x9B237B0):u64 + **一个** u8(C# 侧是 int 枚举,但线上只读 1 字节)。
=> session.NotifyAsync(MethodId.SyncAetherAIChangeVehicleControl,
new UxWriter().U64(vehicleEntityId).U8(2).ToArray(), CancellationToken.None);
///
/// 派一次 Cruise。⛑ 顺序与 GameRouter.VehicleNav.cs 的自动驾驶一致:
/// 状态包 → AI 任务(2026-09-30 §43.1 的血泪教训:顺序反了就是「车门消失/角色缺模」)。
/// 派单前还要先 把车从 Aether 切回 Raid。
///
private static async Task DispatchTaxiAsync(TcpSession session, Connection conn,
VehicleRuntimeState runtime, TaxiTrip4229938 trip, List? path)
{
// ⛑ 捐赠端:停靠原因没清干净就**不派**(客人还在上下车 / 车被拦停),等 Resume 再派。
// 实测序列(21:37:40-48):上车时 reason 2 → +64(TaxiInteract) → 清 2 剩 64
// → 玩家选目的地,此时 Reasons=64 ≠ 0 ⇒ 这一拍**派单无效**,必须等客户端发
// Resume(64) 把原因清成 0 之后再派。以前没这条,等于白派一次。
var reasons = TaxiStopReasons(runtime, trip.VehicleId);
if (reasons != 0)
{
session.Log.Info($"[TAXI] dispatch deferred vehicle={trip.VehicleId} " +
$"stopReasons={reasons} (waiting for native Resume)");
return;
}
await SetTaxiRaidControlAsync(session, trip.VehicleId);
await PushVehicleAutonomousStateAsync(conn, trip.VehicleId, true);
if (path is { Count: >= 2 })
{
await AssignAutonomousCruiseAsync(session, runtime, trip.VehicleId, path, TaxiSpeedMs(trip.Accelerated));
trip.Dispatched = true;
}
trip.LastMovementTick = Environment.TickCount64;
session.Log.Info($"[TAXI] dispatch vehicle={trip.VehicleId} dest={trip.DestinationId} " +
$"speed={(trip.Accelerated ? TaxiCfg.AcceleratedSpeedKmh : TaxiCfg.NormalSpeedKmh):0.#}km/h " +
$"points={path?.Count ?? 0} raidControl=2");
}
private static async Task StopTaxiTaskAsync(TcpSession session, VehicleRuntimeState runtime, TaxiTrip4229938 trip)
{
trip.Dispatched = false;
if (!runtime.Vehicles.TryGetValue(trip.VehicleId, out var v))
return;
await AssignAutonomousStopAsync(session, runtime, trip.VehicleId);
await PushVehicleAutonomousStateAsync2(session, trip.VehicleId, false);
_ = v;
}
/// 取这台车的停靠原因位(0 = 没有停靠原因,可以开车)。
private static ushort TaxiStopReasons(VehicleRuntimeState runtime, ulong vehicleId)
=> TaxiStops.TryGetValue(vehicleId, out var st) ? st.Reasons : (ushort)0;
///
/// PushVehicleAutonomousStateAsync 需要 Connection,而收车路径常常只有
/// TcpSession。这里用 session 直发同一条包(IGameSceneToClient_SyncVehicleAutonomousDrivingState)。
///
private static Task PushVehicleAutonomousStateAsync2(TcpSession session, ulong vehicleEntityId, bool isStart)
=> session.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleAutonomousDrivingState,
UxSerializer.Serialize(new NavMethods.SyncVehicleAutonomousDrivingState4229938
{
vehicleEntityId = vehicleEntityId,
isStart = isStart,
}), CancellationToken.None);
/// 到达:停任务 → 推 SyncTaxiReachDestination → 结算。
private static async Task TaxiArrivedAsync(TcpSession session, Connection conn,
VehicleRuntimeState runtime, TaxiTrip4229938 trip)
{
await StopTaxiTaskAsync(session, runtime, trip);
trip.Arrived = true;
await session.NotifyAsync(MethodId.SyncTaxiReachDestination,
new UxWriter().U64(trip.VehicleId).ToArray(), CancellationToken.None);
await SettleTaxiAsync(conn, session, runtime, trip, "arrived");
session.Log.Info($"[TAXI] arrived vehicle={trip.VehicleId} dest={trip.DestinationId}");
}
///
/// 扣车费。⛑ FareSettled 必须先置位再动钱包:
/// 到达/手动结束/异常三条路径可能并发,重复扣钱比少扣钱难查。
///
private static async Task SettleTaxiAsync(Connection conn, TcpSession session,
VehicleRuntimeState runtime, TaxiTrip4229938 trip, string reason)
{
if (trip.FareSettled) return;
trip.FareSettled = true;
var state = GetStateIfExists(session);
if (state is null) return;
double meters = 0, fare = 0;
lock (runtime.SyncRoot)
{
if (runtime.Vehicles.TryGetValue(trip.VehicleId, out var v))
meters = Math.Max(0, v.DistanceDriven - trip.StartOdometer);
}
fare = Math.Min(Math.Max(0, state.WalletMoney), TaxiFare(meters));
if (fare <= 0)
{
conn.Log.Info($"[TAXI] settle skipped meters={meters:F1} wallet={state.WalletMoney} reason={reason}");
return;
}
await ApplyEconomy4229938Async(conn, state, new EconomyChanges4229938 { MoneyDelta = -fare }, "native-taxi");
conn.Log.Info($"[TAXI] settled meters={meters:F1} fare={fare} accelerated={trip.Accelerated} reason={reason}");
}
/// 结束行程(手动/异常/到达都走这里)。
private static async Task FinishTaxiAsync(TcpSession session, Connection conn,
VehicleRuntimeState runtime, TaxiTrip4229938 trip, string reason)
{
if (trip.Ended) return;
trip.Ended = true;
await StopTaxiTaskAsync(session, runtime, trip);
await SettleTaxiAsync(conn, session, runtime, trip, reason);
lock (runtime.SyncRoot) runtime.TaxiTrip = null;
session.Log.Info($"[TAXI] finished vehicle={trip.VehicleId} reason={reason}");
}
// ========================================================================================
// AskStartTaxiNavigate(67363555) —— C2S Invoke,8B:u32 目的地 + u32 UI 距离(不信任)
// ========================================================================================
[Handler(MethodId.AskStartTaxiNavigate, HandlerPacketKind.Invoke)]
private async Task StartTaxi4229938(Connection conn, UxRpcMessage msg)
{
if (!TaxiCfg.Enabled || msg.Body.Length != 8)
{
await conn.ReturnEmptyAsync(msg, 1);
return;
}
uint destinationId = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body);
uint uiDistance = BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(4));
var runtime = GetVehicleRuntimeState(msg.Context.Session);
var state = GetStateIfExists(conn.Session);
await runtime.HackDispatchGate.WaitAsync();
try
{
VehicleRecord? v;
lock (runtime.SyncRoot) runtime.Vehicles.TryGetValue(runtime.CurrentVehicleId, out v);
if (state is null || !state.Ready || v is null || !CanTakeTaxi(runtime, v)
|| !TaxiCatalog4229938.TryGet(destinationId, out var dest) || dest.RaidId != state.ActiveRaidId)
{
conn.Log.Info($"[TAXI] start rejected dest={destinationId} vehicle={runtime.CurrentVehicleId} " +
$"cfg={v?.ConfigId ?? 0} seat={runtime.CurrentVehicleSeat} raid={state?.ActiveRaidId ?? 0} " +
$"usableTaxis={UsableTaxiConfigIds()}" +
// ⛑ 最常见的两个拒单原因,直接在日志里点破,省得玩家对着 error:1 猜:
// ① seat=0(坐主驾)—— 出租车必须坐乘客座;
// ② cfg=81004001 —— 客户端 VehicleSpawnConfig 里没这台车,召了也不会动。
(runtime.CurrentVehicleSeat == 0 ? " (hint: taxi needs a PASSENGER seat, not seat 0)" : "")
+ (v is not null && !VehicleSpawnCatalog4229938.IsTrafficVehicle(v.ConfigId)
? " (hint: this configId is missing from client VehicleSpawnConfig => no VDE => no animation, no movement)" : ""));
await conn.ReturnEmptyAsync(msg, 1);
return;
}
// ⛑ SolveRoute 返回 **可空** NavRoute(解不出路就是 null)——
// 必须用 `is { Ok: true } ok` 的模式匹配拿值,别直接 .Ok(VehicleNav.cs 同写法)。
var route = SolveRoute(conn, v.Position.X, v.Position.Y, v.Position.Z,
dest.Position.X, dest.Position.Y, dest.Position.Z, needCenter: false, "taxi-cruise");
if (route is not { Ok: true } okRoute || okRoute.DrivePoints.Count < 2)
{
conn.Log.Warn($"[TAXI] no route dest={destinationId} ({dest.Name}) " +
$"note={route?.Note ?? "null-route"}");
await conn.ReturnEmptyAsync(msg, 1);
return;
}
// ⛑ 预计车费按「已跑 + 剩余路程」估,不够钱直接拒(与捐赠端同策略)。
bool continuing = runtime.TaxiTrip is { Ended: false } t0 && t0.VehicleId == v.EntityId;
double travelled = continuing ? Math.Max(0, v.DistanceDriven - runtime.TaxiTrip!.StartOdometer) : 0;
var estimated = TaxiFare(travelled + okRoute.Length);
if (state.WalletMoney < estimated)
{
conn.Log.Info($"[TAXI] start rejected: wallet={state.WalletMoney:F0} < estimate={estimated:F0}");
await conn.ReturnEmptyAsync(msg, 1);
return;
}
if (runtime.TaxiTrip is { Ended: false } old)
await FinishTaxiAsync(conn.Session, conn, runtime, old, "restart");
var trip = new TaxiTrip4229938
{
VehicleId = v.EntityId,
DestinationId = destinationId,
Raid = state.ActiveRaidId,
TargetX = dest.Position.X, TargetY = dest.Position.Y, TargetZ = dest.Position.Z,
StartOdometer = v.DistanceDriven,
LastOdometer = v.DistanceDriven,
StartedTick = Environment.TickCount64,
LastMovementTick = Environment.TickCount64,
};
lock (runtime.SyncRoot) runtime.TaxiTrip = trip;
await DispatchTaxiAsync(conn.Session, conn, runtime, trip, okRoute.DrivePoints);
await conn.ReturnEmptyAsync(msg);
_ = WatchTaxiAsync(conn, runtime, trip);
}
catch (Exception ex)
{
conn.Log.Warn($"[TAXI] start failed: {ex.Message}");
await conn.ReturnEmptyAsync(msg, 1);
}
finally { runtime.HackDispatchGate.Release(); }
}
// ========================================================================================
// AskEndTaxiNavigate(67960304) —— C2S Invoke,6B:u8 传送 + u8 + u32
// ========================================================================================
[Handler(MethodId.AskEndTaxiNavigate, HandlerPacketKind.Invoke)]
private async Task EndTaxi4229938(Connection conn, UxRpcMessage msg)
{
if (!TaxiCfg.Enabled || msg.Body.Length != 6 || msg.Body[0] > 1 || msg.Body[1] > 1)
{
await conn.ReturnEmptyAsync(msg, 1);
return;
}
bool teleport = msg.Body[0] != 0; // 捐赠端:原生 Skip 发的是 teleport=true, skipped=false
// ⛑ 别拿第二个 bool 当「已跳过」判据。
if (teleport)
{
// 阶段一没做跳过传送 —— 回失败让 UI 保持原样,好过收了钱却不动。
conn.Log.Info("[TAXI] skip requested but teleport-skip is not implemented (phase 1)");
await conn.ReturnEmptyAsync(msg, 1);
return;
}
var runtime = GetVehicleRuntimeState(msg.Context.Session);
await runtime.HackDispatchGate.WaitAsync();
try
{
var trip = runtime.TaxiTrip;
if (trip is null || trip.Ended)
{
await conn.ReturnEmptyAsync(msg);
return;
}
await FinishTaxiAsync(conn.Session, conn, runtime, trip, "end-request");
await conn.ReturnEmptyAsync(msg);
}
finally { runtime.HackDispatchGate.Release(); }
}
// ========================================================================================
// AskTaxiAccelerate(67753061) —— C2S Invoke,0B
// ========================================================================================
[Handler(MethodId.AskTaxiAccelerate, HandlerPacketKind.Invoke)]
private async Task AccelerateTaxi4229938(Connection conn, UxRpcMessage msg)
{
if (!TaxiCfg.Enabled || msg.Body.Length != 0)
{
await conn.ReturnEmptyAsync(msg, 1);
return;
}
var runtime = GetVehicleRuntimeState(msg.Context.Session);
await runtime.HackDispatchGate.WaitAsync();
try
{
var trip = runtime.TaxiTrip;
if (trip is null || trip.Ended || trip.Arrived || trip.Accelerated
|| !runtime.Vehicles.TryGetValue(trip.VehicleId, out var v) || !CanTakeTaxi(runtime, v))
{
await conn.ReturnEmptyAsync(msg, 1);
return;
}
trip.Accelerated = true;
// ⛑ 速度写在 Cruise 参数里 ⇒ 加速 = 用新速度重发一次任务,不是改某个状态位。
// ⛑⛑ 必须**重新带一条真实路线**:AssignAutonomousCruiseAsync 在
// `VehicleNavCfg.CruiseSingleTarget=true` 时会取 `path[^1]`,空列表直接
// IndexOutOfRange;就算关掉单目标,0 点的巡航包也没意义。车已经跑了一段,
// 所以从**当前位置**重解一次到终点的路,而不是复用出发时那条。
var route = SolveRoute(conn, v.Position.X, v.Position.Y, v.Position.Z,
trip.TargetX, trip.TargetY, trip.TargetZ, needCenter: false, "taxi-accelerate");
if (route is not { Ok: true } okR || okR.DrivePoints.Count < 2)
{
conn.Log.Warn($"[TAXI] accelerate rejected: no route note={route?.Note ?? "null-route"}");
await conn.ReturnEmptyAsync(msg, 1);
return;
}
await DispatchTaxiAsync(conn.Session, conn, runtime, trip, okR.DrivePoints);
conn.Log.Info($"[TAXI] accelerated vehicle={trip.VehicleId} speed={TaxiCfg.AcceleratedSpeedKmh:0.#}km/h " +
$"points={okR.DrivePoints.Count}");
await conn.ReturnEmptyAsync(msg);
}
finally { runtime.HackDispatchGate.Release(); }
}
// ========================================================================================
// AskAetherAISetVehicleStatus(67541089) —— C2S **Notify**,11B:u64 uid + u8 status + i16 停靠原因
// ========================================================================================
private sealed class TaxiStopState { public ushort Reasons; }
///
/// 每辆出租车的停靠原因位(AskAetherAISetVehicleStatus 的 i16 字段累加)。
/// ⛑ key 用 vehicleId 而不是 VehicleRecord:捐赠端用
/// ConditionalWeakTable<VehicleRecord,…> 是因为它全程握着 trip.Vehicle,
/// 而主线派单路径常常只有 uid。代价是车被 DestroyVehicleAsync 后残留一条,
/// 但 uid 不复用、表也就几十条,无所谓。用并发字典:这是跨 session 的 static 表。
///
private static readonly ConcurrentDictionary TaxiStops = new();
[Handler(MethodId.AskAetherAISetVehicleStatus, HandlerPacketKind.Notify)]
private async Task TaxiVehicleStatus4229938(Connection conn, UxRpcMessage msg)
{
if (!TaxiCfg.Enabled || msg.Body.Length != 11) return;
ulong id = BinaryPrimitives.ReadUInt64LittleEndian(msg.Body);
byte status = msg.Body[8];
ushort reason = BinaryPrimitives.ReadUInt16LittleEndian(msg.Body.AsSpan(9));
if (status > 1 || reason == 0 || (reason & ~1023) != 0) return;
var runtime = GetVehicleRuntimeState(msg.Context.Session);
await runtime.HackDispatchGate.WaitAsync();
try
{
if (!runtime.Vehicles.TryGetValue(id, out var v) || !v.AetherRegistered
|| !VehicleCatalog4229938.TryGet(v.ConfigId, out var cfg) || cfg.VehicleType != TaxiVehicleType)
return;
var stop = TaxiStops.GetOrAdd(id, _ => new TaxiStopState());
stop.Reasons = status == 1 ? (ushort)(stop.Reasons | reason) : (ushort)(stop.Reasons & ~reason);
var trip = runtime.TaxiTrip;
if (trip is { Ended: false, Arrived: false } && trip.VehicleId == id)
{
// ⛑ 与捐赠端 `TaxiNative.cs:62-68` 逐字对齐:
// ① 还有停靠原因 ⇒ 停任务(客人上下车 / 车被拦停,这时开不动);
// ② 原因全清了,且「从没派过」或收到 Resume(TaxiInteract=64) ⇒ 派单。
// Resume 是客户端把控制权切回 Aether **之后**才发的,
// 所以我们必须在它之后补一次 RaidControl + Cruise,否则车永远停在 Aether 手里。
if (stop.Reasons != 0)
{
await StopTaxiTaskAsync(conn.Session, runtime, trip);
}
else if (!trip.Dispatched || (status == 0 && (reason & 64) != 0))
{
var route = SolveRoute(conn, v.Position.X, v.Position.Y, v.Position.Z,
trip.TargetX, trip.TargetY, trip.TargetZ, needCenter: false, "taxi-resume");
await DispatchTaxiAsync(conn.Session, conn, runtime, trip,
route is { Ok: true } r2 ? r2.DrivePoints : null);
}
}
conn.Log.Info($"[TAXI] native-stop vehicle={id} status={status} reason={reason} remaining={stop.Reasons}");
}
finally { runtime.HackDispatchGate.Release(); }
}
// ------------------------------------------------------------------ 监控
///
/// 每秒看一次:里程、是否到达、是否卡住。
/// ⛑ 到不了就结束行程而不是无限等 —— 捐赠端是「保留行程让玩家换目的地」,
/// 但那要配合跳过传送;阶段一没有跳过,卡住就按当前里程结算收工,免得钱一直挂着。
///
private static async Task WatchTaxiAsync(Connection conn, VehicleRuntimeState runtime, TaxiTrip4229938 trip)
{
var session = conn.Session;
try
{
while (session.Client.Connected)
{
await Task.Delay(1000);
await runtime.HackDispatchGate.WaitAsync();
try
{
if (!ReferenceEquals(runtime.TaxiTrip, trip) || trip.Ended) return;
var state = GetStateIfExists(session);
if (state is null || !state.Ready || state.ActiveRaidId != trip.Raid
|| !runtime.Vehicles.TryGetValue(trip.VehicleId, out var v))
{
await FinishTaxiAsync(session, conn, runtime, trip, "scene-or-vehicle-changed");
return;
}
if (!CanTakeTaxi(runtime, v))
{
await FinishTaxiAsync(session, conn, runtime, trip, "passenger-left");
return;
}
var odo = v.DistanceDriven;
if (odo > trip.LastOdometer + 0.01)
{
trip.LastOdometer = odo;
trip.LastMovementTick = Environment.TickCount64;
trip.StallNotified = false;
}
// ⛑ 到达半径必须**大于** AI 的停车距离:AssignedCruise 的
// ArrivalDistance = VehicleNavCfg.CruiseArrivalMeters(默认 3),车会在
// 距终点 ~3 m 处停下;阈值也用 3 就会卡在边界上永远判不了到达
// ⇒ 默认 20 m(与捐赠端 `DistanceSquared < 400` 同量级)。
var dx = v.Position.X - trip.TargetX;
var dy = v.Position.Y - trip.TargetY;
var dz = v.Position.Z - trip.TargetZ;
var arrive = Math.Max(TaxiCfg.ArrivalMeters, VehicleNavCfg.CruiseArrivalMeters + 1f);
if (dx * dx + dy * dy + dz * dz <= arrive * arrive)
{
await TaxiArrivedAsync(session, conn, runtime, trip);
return;
}
long now = Environment.TickCount64;
if (now - trip.StartedTick > TaxiCfg.MaxTripMs)
{
await FinishTaxiAsync(session, conn, runtime, trip, "max-duration");
return;
}
if (now - trip.LastMovementTick > TaxiCfg.StalledAfterMs && !trip.StallNotified)
{
trip.StallNotified = true;
conn.Log.Warn($"[TAXI] stalled vehicle={trip.VehicleId} dest={trip.DestinationId} " +
$"odo={odo - trip.StartOdometer:F1}m; finishing trip");
await FinishTaxiAsync(session, conn, runtime, trip, "stalled");
return;
}
}
finally { runtime.HackDispatchGate.Release(); }
}
}
catch (OperationCanceledException) { }
catch (Exception ex) { session.Log.Warn($"[TAXI] monitor stopped: {ex.Message}"); }
}
}