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}"); } } }