593 lines
31 KiB
C#
593 lines
31 KiB
C#
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<VehicleCruiseParameters></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 > 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<VehicleRecord,…></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}"); }
|
||
}
|
||
}
|