Files

593 lines
31 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 出租车(2026-09-30 新增)。逐字读过 v0.5-2 捐赠端
/// <c>GameRouter.Taxi.cs</c>(357 行) + <c>GameRouter.TaxiNative.cs</c>(210 行) 后,
/// 按<b>主线自己的车辆栈</b>重写 —— 不是照抄。
///
/// <para>★ 从捐赠端确认的四条硬事实(都写进了注释,别推翻):</para>
/// <list type="number">
/// <item><b>目的地是配置 uint,不是车辆 uid</b>:<c>TaxiSystemManager</c> 传的是
/// <c>TaxiNavigationConfig.json</c> 的 <c>Id</c>(共 18 条),所以必须查表,
/// 而且要校验 <c>RaidId == 当前场景</c>(表里混着 23300888 与 23300999 两个 raid)。</item>
/// <item><b>只有 <c>VehicleType == 8</c> 的车能开出租车 UI</b>。主线 VehicleConfig.json 里
/// 正好 4 辆:<b>81004001 / 81004004 / 81004015 / 81004038</b>(都是 4 座)。</item>
/// <item><b>请求是裸定长字节</b>:Start=8B(u32 目的地 + u32 UI 距离)、End=6B(u8 传送 + u8 + u32)、
/// Accelerate=0B、Status=11B(u64 + u8 + i16 停靠原因)。
/// ⛑ UI 那个「距离」<b>不信任</b>,车费只按服务端自己的里程算。</item>
/// <item><b>计费</b>:<c>Ceiling(起步价 + Max(0, 里程 - 免费里程) * 单价)</c>,
/// 捐赠端原值 300 / 200m / 0.4 每米。</item>
/// </list>
///
/// <para>★ 与捐赠端的<b>故意差异</b>(照抄会踩坑):</para>
/// <list type="bullet">
/// <item><b>不自己拼 Cruise 包</b>:主线 <c>GameRouter.VehicleNav.AssignAutonomousCruiseAsync</c>
/// 发的就是同一个 <c>AssignVehicleControl&lt;VehicleCruiseParameters&gt;</c>,而且它还负责
/// ① 抢主控权 ② 记住 token ③ <b>主动 Acknowledge 一次</b>(不确认车就不动,2026-09-28 第三轮的坑)。
/// ⇒ 直接复用,只多传一个速度覆盖参数。</item>
/// <item><b>里程不自己挂钩</b>:主线 <c>GameRouter.UnhandledBatch.cs:41</c> 已经在累加
/// <c>VehicleRecord.DistanceDriven</c>。捐赠端的 <c>RecordTaxiMovement</c> 不需要搬。</item>
/// <item><b>路线用 <c>SolveRoute</c></b>(车道图 Dijkstra),不是捐赠端的
/// <c>FindNavigation</c> —— 主线本来就有,且返回 <c>DrivePoints</c>(车→终点正向)。</item>
/// <item><b>不做「长按跳过 → 传送」</b>(阶段一)。那要接主线 <c>GameRouter.Teleport.cs</c>
/// 的 <c>ReportPreTeleportFinish</c> 链,耦合深;<c>AskEndTaxiTeleport</c> 暂留在
/// <c>GameRouter.MiscAcks.cs</c> 当占位。</item>
/// </list>
///
/// <para>⛑ 回滚:<c>gameplay.taxi.enabled=false</c> ⇒ 四个 handler 全部直接回失败码
///(注册与日志仍在,可观测)。要彻底回到占位 AckVoid,把
/// <c>GameRouter.MiscAcks.cs</c> 里那两条注释掉的 AckVoid 补回来并删掉本文件。</para>
/// </summary>
internal sealed partial class GameRouter
{
private static TaxiSettings TaxiCfg => PrivateServerConfigStore.Current.Gameplay.Taxi;
/// <summary>出租车车型。<c>VehicleConfig.json</c> 里 <c>VehicleType == 8</c> 的 4 辆。</summary>
private const int TaxiVehicleType = 8;
// ------------------------------------------------------------------ 行程
internal sealed class TaxiTrip4229938
{
public ulong VehicleId;
public uint DestinationId;
public uint Raid;
public float TargetX, TargetY, TargetZ;
/// <summary>起点里程(<c>VehicleRecord.DistanceDriven</c>)。车费 = 当前里程 - 它。</summary>
public double StartOdometer;
/// <summary>上次观察到的里程,用来判断「还在不在动」。</summary>
public double LastOdometer;
public bool Accelerated;
public bool Arrived;
public bool Ended;
public bool FareSettled;
public bool StallNotified;
/// <summary>
/// 当前是否<b>已经下发过</b> Cruise 任务。对应捐赠端的 <c>trip.Token != 0</c>
/// (主线不自己管 token,由 <c>AssignAutonomousCruiseAsync</c> 内部 mint)。
/// ⛑ 捐赠端靠它决定「什么时候该重派」:<c>Token == 0</c>(从没派过/刚被停)
/// 或收到 <c>Resume(TaxiInteract=64)</c> 才派。
/// </summary>
public bool Dispatched;
public long StartedTick;
public long LastMovementTick;
}
/// <summary>
/// 能不能开出租车。判据逐条来自捐赠端 <c>CanTakeTaxi4229938</c>:
/// 人在车上的<b>乘客座</b>(<c>Seat &gt; 0</c>,主驾是 0)、不在下车流程中、
/// 车可交互且已注册 Aether、车型是出租车。
///
/// <para>⛑⛑ 最后一条(<see cref="VehicleSpawnCatalog4229938.IsTrafficVehicle"/>)是
/// <b>主线自己加的</b>,捐赠端没有,但不加就一定踩坑(2026-09-30 实锤):
/// 客户端 <c>AetherVehicleManager.AddVehicleDataEntity</c> 按 cfgId 在
/// <c>VehicleSpawnConfig</c> 里查车辆模板,查不到就<b>整台丢掉</b> ——
/// Player.log 三连:<c>VehicleInit_Drop_NoConfig: vid=… cfgId=81004001 not found in VehicleSpawn</c>
/// → <c>Attach failed: VDE is null</c> → <c>Flush failed to create VDE</c>。
/// 后果是 <b>没有 VehicleDataEntity ⇒ 车不会动、上下车没有动画</b>
/// (同一次实测:<c>[TAXI] settled meters=0.0</c>,车一米没走)。
/// 4 辆 <c>VehicleType == 8</c> 里 <b>81004001 不在那张表</b>,只有
/// 81004004 / 81004015 / 81004038 能用。</para>
/// </summary>
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);
/// <summary>
/// 客户端认得的出租车车型(<c>VehicleType == 8</c> 且在 <c>VehicleSpawnConfig</c> 里)。
/// 只用于日志/报错提示,让玩家知道该召哪一辆。
/// </summary>
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)";
}
/// <summary>车费。沿用捐赠端 <c>TaxiFare4229938</c>:<c>Ceiling(Base + Max(0, d-Free) * PerMeter)</c>。</summary>
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;
}
// ------------------------------------------------------------------ 派单 / 收车
/// <summary>
/// ⛑⛑ <b>最关键的一步</b>(2026-09-30 第二轮补上,之前漏了 ⇒ 所有出租车原地不动):
/// 玩家在出租车 UI 里确认目的地时,客户端走
/// <c>TaxiSystemManager.StartTaxiOrder → SetTaxiVehicleControl(true) → City.SetAetherVehicleControl</c>
/// 把车切成 <b>AetherVehicle=1</b>;而 <c>Cruise</c> 与 <c>SyncTeleportVehicle</c> 都要求
/// <b>RaidVehicle=2</b> —— 捐赠端 <c>GameRouter.TaxiNative.cs:33-35</c> 的原话:
/// <c>Cruise and SyncTeleportVehicle need RaidVehicle=2; a controller PID alone is insufficient.</c>
///
/// 只发 <c>SyncChangeVehicleController</c> 不够:车仍归 Aether 车道模拟管,
/// 位移每帧被车道覆盖 ⇒ 表现就是「<b>轮胎在转,车在原地进行</b>」。
///
/// 反向验证:81004001 之前反而<b>能</b>开到目的地 —— 因为它不在客户端
/// <c>VehicleSpawnConfig</c> 里、压根没建成 VDE,客户端切不动 Aether,
/// 车一直留在 Raid 控制下 ⇒ Cruise 生效(代价是没有 VDE ⇒ 没有上下车动画)。
/// 两台车一个能跑没动画、一个有动画不能跑,正好各缺一半,两边证据互相印证。
/// </summary>
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);
/// <summary>
/// 派一次 Cruise。⛑ 顺序与 <c>GameRouter.VehicleNav.cs</c> 的自动驾驶一致:
/// <b>状态包 → AI 任务</b>(2026-09-30 §43.1 的血泪教训:顺序反了就是「车门消失/角色缺模」)。
/// 派单前还要先 <see cref="SetTaxiRaidControlAsync"/> 把车从 Aether 切回 Raid。
/// </summary>
private static async Task DispatchTaxiAsync(TcpSession session, Connection conn,
VehicleRuntimeState runtime, TaxiTrip4229938 trip, List<NavMethods.UxVector3>? 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;
}
/// <summary>取这台车的停靠原因位(<c>0</c> = 没有停靠原因,可以开车)。</summary>
private static ushort TaxiStopReasons(VehicleRuntimeState runtime, ulong vehicleId)
=> TaxiStops.TryGetValue(vehicleId, out var st) ? st.Reasons : (ushort)0;
/// <summary>
/// <c>PushVehicleAutonomousStateAsync</c> 需要 <c>Connection</c>,而收车路径常常只有
/// <c>TcpSession</c>。这里用 session 直发同一条包(<c>IGameSceneToClient_SyncVehicleAutonomousDrivingState</c>)。
/// </summary>
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);
/// <summary>到达:停任务 → 推 <c>SyncTaxiReachDestination</c> → 结算。</summary>
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}");
}
/// <summary>
/// 扣车费。⛑ <c>FareSettled</c> 必须<b>先置位再动钱包</b>:
/// 到达/手动结束/异常三条路径可能并发,重复扣钱比少扣钱难查。
/// </summary>
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}");
}
/// <summary>结束行程(手动/异常/到达都走这里)。</summary>
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; }
/// <summary>
/// 每辆出租车的<b>停靠原因位</b>(<c>AskAetherAISetVehicleStatus</c> 的 i16 字段累加)。
/// ⛑ key 用 <c>vehicleId</c> 而不是 <c>VehicleRecord</c>:捐赠端用
/// <c>ConditionalWeakTable&lt;VehicleRecord,…&gt;</c> 是因为它全程握着 <c>trip.Vehicle</c>,
/// 而主线派单路径常常只有 uid。代价是车被 <c>DestroyVehicleAsync</c> 后残留一条,
/// 但 uid 不复用、表也就几十条,无所谓。用并发字典:这是跨 session 的 static 表。
/// </summary>
private static readonly ConcurrentDictionary<ulong, TaxiStopState> 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(); }
}
// ------------------------------------------------------------------ 监控
/// <summary>
/// 每秒看一次:里程、是否到达、是否卡住。
/// ⛑ 到不了就<b>结束行程</b>而不是无限等 —— 捐赠端是「保留行程让玩家换目的地」,
/// 但那要配合跳过传送;阶段一没有跳过,卡住就按当前里程结算收工,免得钱一直挂着。
/// </summary>
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}"); }
}
}