Files

1009 lines
46 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 Ananta.SDK.Logging;
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 SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 车库 / 停车(House Garage)—— 2026-09-30。
///
/// <para><b>用户报的症状(3 条)</b>:① 靠近车位按 F「调取载具」能开选车面板(纯客户端 UI),
/// 选完车后按 F「驶离车库」<b>毫无后续</b>;② 车的模型<b>不出现在车位</b>;③ 车库<b>大门不开</b>。</para>
///
/// <para><b>根因</b>(客户端 <c>GarageManager.lua</c> 逐行读出来的):</para>
/// <list type="number">
/// <item>⛔⛔ <b>2026-09-30 追加:真正的第一道墙在客户端配置里。</b>
/// <c>HouseConfig[11200003].GarageIndoorId</c> 官方写成了 <c>301</c>(= 绿屿公馆<b>主体</b>),
/// 真实车库 indoor 是 <c>302</c>(<c>IndoorConfig</c>:301 绿屿公馆 / 302 绿屿公馆车库)。
/// <c>ChangeIndoor</c> 建的是 <c>garageIndoorIdList[GarageIndoorId] = houseId</c>,
/// 而 <c>LoadingFinish</c> 用<b>当前</b> <c>gMapManager.IndoorId</c> 查它 ⇒ 站在车库里
/// (实测客户端只报 332 大厅 → 302 车库,<b>从不经过 301</b>)永远查不到 ⇒
/// <c>CurrentHouseId</c> 恒为 0 ⇒ <c>SwitchCarprotVehicle</c> 第一行
/// <c>parkinginfolist[0]</c> 为空直接 <c>FatalLog("换车失败,该房屋未购买或数据异常, houseId=0")</c>
/// + <c>return</c> ⇒ 连 <c>AskHouseMoveParkingSpace</c> 都发不出去(车不出、也没有「驶离车库」)。
/// 5 套房里另外 4 套的 <c>GarageIndoorId</c> 都等于各自车库 indoor(201/300/299/379),只有绿屿错。
/// <b>服务端改不了</b>(<c>CurrentHouseId</c> / <c>garageIndoorIdList</c> / <c>parkinginfolist</c>
/// 全是纯客户端状态,没有任何 RPC 能写;<c>HouseConfig</c> 是客户端自带表,服务端那份不下发)
/// ⇒ 走客户端 fastpatch <c>tools/garage_indoor_fix.lua</c>(<c>rawset(getmetatable(row),...)</c>,
/// 非 BepInEx)。本文件的所有 handler 只有在那一行修好之后才可能被调用。</item>
/// <item>这一族 9 条 RPC 在服务端一直是 <c>DefaultReturnCatalog4229938</c> 的
/// <c>shape="void"</c> 空 body 兜底 —— 客户端发出去的请求永远等不到有意义的回应。</item>
/// <item>两个关键 S2C <b>服务端从来不发</b>:
/// <c>SyncHouseParking</c>(64277819)(车位→车的清单,触发进库演出 + 车辆生成)
/// 和 <c>SyncHouseParkingEntityBinding</c>(64060781)(每辆车的 <c>EntityId</c>)。</item>
/// <item><c>LeaveGarage</c>(行 156) 第一步就读 <c>currentParkingInfo.ParkingVehicleUid[parkingId]</c>,
/// 而这个表<b>只能</b>由 <c>SyncHouseParkingEntityBinding</c> 填 ⇒ 为空 ⇒
/// 行 164 <c>FatalLog("离开车库","ParkingVehicleUid为空")</c> + 行 165 <c>return</c> ⇒
/// 连 <c>AskTeleportFromHouseGarage</c> 都没发出去 —— 这就是「按 F 没后续」。</item>
/// </list>
///
/// <para><b>本文件做的事</b>:把 9 条 RPC 全部接起来,按客户端 <c>GarageManager</c> 的期望回包:</para>
/// <list type="bullet">
/// <item><c>AskHouseParking</c> / <c>AskHouseMoveParkingSpace</c> / <c>GmHouseParking</c>:
/// 记账(<c>Progress.GarageParking</c>)+ 落盘 + 回推 <c>SyncHouseParking(addInfoList)</c>。
/// ⛔ <c>AskHouseMoveParkingSpace</c>(换车 / 调取载具)还要**自己 spawn 车 + 推
/// <c>SyncHouseParkingEntityBinding</c>** —— 客户端在这条链路上 <c>IsSwitchingCar=true</c>,
/// <c>SyncHouseParking</c> 的处理会跳过进库演出,因而永远不会自己发
/// <c>AskCreateHouseParkingVehicles</c>(详见 <c>EnsureParkingVehiclesAsync</c>)。
/// 而 <c>AskHouseParking</c>(开自己的车进库)不推 <c>SyncHouseParking</c>:客户端回调里自己会发
/// <c>AskCreateHouseParkingVehicles</c>(<c>GarageManager.lua:124</c>),再推一次会把进库演出播两遍。</item>
/// <item><c>AskCreateHouseParkingVehicles</c>:<b>命门</b> —— 为该房每个已指派车位
/// <c>SpawnDirectAsync</c> 出一辆车(<c>CreateSourceType = 10</c> HouseParking、
/// <c>destroyPrevious:false</c> 以便多车位共存),再回推
/// <c>SyncHouseParkingEntityBinding</c>(填 <c>ParkingVehicleUid</c>)。</item>
/// <item><c>AskDestroyHouseParkingVehicles</c>:出库销毁本房车辆。</item>
/// <item><c>AskTeleportToHouseGarage</c>:只回 OK(进库演出由客户端自己播)。</item>
/// <item><c>AskTeleportFromHouseGarage</c>:<b>开门</b>(复用 <c>PushDoorOpen</c> + 缓存的门 id)+ 回 OK。</item>
/// <item><c>AskHouseCancelParking</c> / <c>GmHouseCancelParking</c>:取消指派 + 回推 <c>SyncHouseParking(cancelInfoList)</c>。</item>
/// </list>
///
/// <para><b>车辆落点</b>:<c>HouseConfig.ParkingSpaceWaypoints</c> 是 <b>Spoon 航点 id</b>
/// (如 <c>1893051</c>),服务端没有 Spoon 位置表(<c>E:\ConfigDump</c> 里查不到),
/// 所以无法把车精确摆到车位格。退而求其次:<b>落在玩家当前位置</b>(进库演出刚把玩家放到车位,
/// 或玩家就站在车库里),前向偏移由 <c>gameplay.houseGarage.spawnForwardOffset</c> 热调。
/// 车一旦生成,客户端就能按 F 上车 / 驶离。</para>
///
/// <para><b>回滚</b>:<c>gameplay.houseGarage.enabled=false</c>(全部回空 body 兜底),
/// 或把 9 条 mid 从 <c>Enabled4229938MethodIds</c> 删掉。两者都与改动前逐字节一致。</para>
/// </summary>
internal sealed partial class GameRouter
{
private const string GarageRuntimeKey = "client4229938-garage";
/// <summary>本会话运行时的车库实体表:<c>houseId → (车位号(0 基) → 车辆 entityId)</c>。</summary>
private sealed class GarageRuntimeState
{
public object SyncRoot { get; } = new();
public Dictionary<uint, Dictionary<int, ulong>> EntitiesByHouse { get; } = new();
/// <summary>
/// 最近一次车库 RPC 里出现的 <c>houseId</c>。给「出库传送」用:<c>AskTeleport(config=7)</c>
/// 的 <c>PreTeleportOption</c> 里<b>没有</b> houseId(客户端只填了 timeline 资源名),
/// 而它前面必定先来过一条带 houseId 的车库 RPC(<c>AskDestroyHouseParkingVehicles</c> 4B /
/// <c>AskCreateHouseParkingVehicles</c> 4B / <c>AskHouseMoveParkingSpace</c> 的元素里),
/// 所以在这里记一份。
/// </summary>
public uint LastHouseId;
}
/// <summary>记下最近一次车库事务的 houseId(出库传送要用来查落点)。</summary>
private static void RememberGarageHouse(TcpSession session, uint houseId)
{
if (houseId == 0)
return;
var runtime = GetGarageRuntime(session);
lock (runtime.SyncRoot)
runtime.LastHouseId = houseId;
}
/// <summary>本会话最近一次车库事务的 houseId;没有则 0。</summary>
private static uint LastGarageHouseId(TcpSession session)
{
var runtime = GetGarageRuntime(session);
lock (runtime.SyncRoot)
return runtime.LastHouseId;
}
/// <summary>
/// 玩家<b>此刻</b>正在开的车(<c>entityId</c>)。出库销毁必须跳过它 ——
/// 客户端一坐进车位上的车就会走「驶离车库」流程、发 <c>AskDestroyHouseParkingVehicles</c>,
/// 无差别销毁会把玩家正坐着的车一起拆掉(2026-09-30 实测:23:38:20 上车,
/// 同秒 <c>SyncDestroyVehicle</c>,模型当场消失、角色保持开车姿势悬空,4 秒后
/// <c>LeaveGarage</c> 再也找不到车辆实例 ⇒ 出库演出卡死 ⇒ 黑屏)。
///
/// <para>三个信号取并集,都在「当前」语义上:
/// <c>CurrentVehicleId</c>(<c>AskPlayerStartEnterOrExitVehicle</c> 写)、
/// <c>StoryEnterVehicleId</c>(剧情通道 <c>S011PlayerNetEnterVehicleRequest</c> 写,实测走的是这条)、
/// <c>Vehicles[id].ControllerPid == 本玩家 pid</c>。
/// <b>不用</b> <c>LastDrivenVehicleId</c>:它下车不清,会把早先停好的车误判成「正开着」。</para>
/// </summary>
private static HashSet<ulong> ResolveDrivenVehicleIds(TcpSession session)
{
var driven = new HashSet<ulong>();
var runtime = GetVehicleRuntimeState(session);
var pid = Profile.PlayerPid;
lock (runtime.SyncRoot)
{
if (runtime.CurrentVehicleId != 0)
driven.Add(runtime.CurrentVehicleId);
if (runtime.StoryEnterVehicleId != 0)
driven.Add(runtime.StoryEnterVehicleId);
if (pid != 0)
{
foreach (var (id, rec) in runtime.Vehicles)
{
if (rec.ControllerPid == pid)
driven.Add(id);
}
}
}
return driven;
}
private static GarageRuntimeState GetGarageRuntime(TcpSession session)
{
if (session.Items.TryGetValue(GarageRuntimeKey, out var raw) && raw is GarageRuntimeState existing)
return existing;
var created = new GarageRuntimeState();
session.Items[GarageRuntimeKey] = created;
return created;
}
private static HouseGarageSettings GarageCfg => PrivateServerConfigStore.Current.Gameplay.HouseGarage;
// =========================================================================================
// 1) AskHouseParking(63154177) —— 「开着自己的车进车库」
// =========================================================================================
[Handler(MethodId.AskHouseParking, HandlerPacketKind.Invoke)]
private async Task OnAskHouseParking(Connection conn, UxRpcMessage msg)
{
if (!GarageCfg.Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
if (!TryReadGarageArgs(conn, msg, out SceneMethods.AskHouseParkingArgs4229938 args))
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var world = GetStateIfExists(conn.Session);
if (world is null)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var added = new List<SceneMethods.HouseVehicleParkingInfo>();
lock (world.SyncRoot)
{
foreach (var item in args.parkingInfoList)
{
if (item.HouseId == 0 || item.VehicleId == 0)
continue;
var index = AssignFreeSpace(world, item.HouseId, item.VehicleId);
if (index < 0)
{
conn.Log.Warn($"[GARAGE] 入库失败:house={item.HouseId} 没有空车位(车={item.VehicleId})");
continue;
}
added.Add(new SceneMethods.HouseVehicleParkingInfo
{
HouseId = item.HouseId,
VehicleId = item.VehicleId,
ParkingSpaceIndex = index,
});
}
}
if (added.Count > 0)
PlayerProgressStore.Save(world, conn.Log, quiet: true);
if (added.Count > 0)
RememberGarageHouse(conn.Session, added[0].HouseId);
// ⛔ 这里**不推** SyncHouseParking:客户端 AskHouseParking 的回调里自己会发
// AskCreateHouseParkingVehicles(见 GarageManager.lua:124),再推一次会把进库演出
// 触发两遍。落盘 + 回 OK 即可。
conn.Log.Info($"[GARAGE] AskHouseParking 入库 {added.Count} 辆 "
+ $"({string.Join(",", added.Select(a => $"house={a.HouseId}/space={a.ParkingSpaceIndex}/veh={a.VehicleId}"))})");
await conn.ReturnEmptyOkAsync(msg);
}
// =========================================================================================
// 2) AskHouseMoveParkingSpace(63780060) —— 「调取载具 / 换车」(用户场景的主链路)
// =========================================================================================
[Handler(MethodId.AskHouseMoveParkingSpace, HandlerPacketKind.Invoke)]
private async Task OnAskHouseMoveParkingSpace(Connection conn, UxRpcMessage msg)
{
if (!GarageCfg.Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
if (!TryReadGarageArgs(conn, msg, out SceneMethods.AskHouseMoveParkingSpaceArgs4229938 args))
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var world = GetStateIfExists(conn.Session);
if (world is null)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var cancel = new List<SceneMethods.HouseVehicleParkingInfo>();
var add = new List<SceneMethods.HouseVehicleParkingInfo>();
// 落位后要确保「车位上有活着的车」的清单:(houseId, 车位号, 车辆, 这辆车在本房的旧车位)。
// 旧车位用来把运行时的实体搬过来复用 —— 换车位不该重新生成一辆车。
var wanted = new List<(uint HouseId, int Space, uint VehicleId, int PreviousSpace)>();
lock (world.SyncRoot)
{
foreach (var item in args.moveParkingSpaceInfoList)
{
if (item.HouseId == 0 || item.ParkingSpaceIndex < 0)
continue;
if (!world.Progress.GarageParking.TryGetValue(item.HouseId, out var spaces))
{
spaces = new Dictionary<int, uint>();
world.Progress.GarageParking[item.HouseId] = spaces;
}
if (item.VehicleId == 0)
{
// 清空某个车位。
if (spaces.TryGetValue(item.ParkingSpaceIndex, out var old) && old != 0)
{
spaces.Remove(item.ParkingSpaceIndex);
cancel.Add(new SceneMethods.HouseVehicleParkingInfo
{
HouseId = item.HouseId,
VehicleId = old,
ParkingSpaceIndex = item.ParkingSpaceIndex,
});
}
continue;
}
// 同一辆车先从这个房的其它车位挪走(避免一辆车占两格)。
var previousSpace = -1;
foreach (var key in spaces.Where(kv => kv.Value == item.VehicleId).Select(kv => kv.Key).ToList())
{
spaces.Remove(key);
previousSpace = key;
cancel.Add(new SceneMethods.HouseVehicleParkingInfo
{
HouseId = item.HouseId,
VehicleId = item.VehicleId,
ParkingSpaceIndex = key,
});
}
// 目标格若已有别的车,先请出去。
if (spaces.TryGetValue(item.ParkingSpaceIndex, out var occupant)
&& occupant != 0 && occupant != item.VehicleId)
{
spaces.Remove(item.ParkingSpaceIndex);
cancel.Add(new SceneMethods.HouseVehicleParkingInfo
{
HouseId = item.HouseId,
VehicleId = occupant,
ParkingSpaceIndex = item.ParkingSpaceIndex,
});
}
spaces[item.ParkingSpaceIndex] = item.VehicleId;
add.Add(new SceneMethods.HouseVehicleParkingInfo
{
HouseId = item.HouseId,
VehicleId = item.VehicleId,
ParkingSpaceIndex = item.ParkingSpaceIndex,
});
wanted.Add((item.HouseId, item.ParkingSpaceIndex, item.VehicleId, previousSpace));
}
}
PlayerProgressStore.Save(world, conn.Log, quiet: true);
if (wanted.Count > 0)
RememberGarageHouse(conn.Session, wanted[0].HouseId);
await PushSyncHouseParkingAsync(conn.Session, cancel, add, conn.Log);
// ⛔⛔ 这里**必须自己生成车**。客户端 SwitchCarprotVehicle 在发这条请求前就把
// IsSwitchingCar 置了 true,于是 SyncHouseParking 的处理里
// (GarageManager.lua:641 `if self.IsSwitchingCar then ... return`)会跳过进库演出,
// 也就永远不会发 AskCreateHouseParkingVehicles ⇒ 车位上有数据、但场上没有模型。
// 所以换车/调取载具这条链路,spawn + SyncHouseParkingEntityBinding 都得服务端自己来。
foreach (var group in wanted.GroupBy(w => w.HouseId))
{
var bindings = await EnsureParkingVehiclesAsync(
conn, group.Key, group.Select(w => (w.Space, w.VehicleId, w.PreviousSpace)).ToList());
if (bindings.Count > 0)
await PushSyncHouseParkingEntityBindingAsync(conn.Session, bindings, conn.Log);
}
conn.Log.Info($"[GARAGE] AskHouseMoveParkingSpace 取消={cancel.Count} 新增={add.Count}");
await conn.ReturnEmptyOkAsync(msg);
}
// =========================================================================================
// 3) AskHouseCancelParking(63495236) / GmHouseCancelParking(65601170) —— 按车辆 id 取消
// =========================================================================================
[Handler(MethodId.AskHouseCancelParking, HandlerPacketKind.Invoke)]
private async Task OnAskHouseCancelParking(Connection conn, UxRpcMessage msg)
{
if (!GarageCfg.Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
if (!TryReadGarageArgs(conn, msg, out SceneMethods.AskHouseCancelParkingArgs4229938 args))
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var cancel = CancelVehicles(conn, args.vehicleIdList);
await conn.ReturnEmptyOkAsync(msg);
conn.Log.Info($"[GARAGE] AskHouseCancelParking 取消={cancel.Count}");
}
[Handler(MethodId.GmHouseCancelParking, HandlerPacketKind.Invoke)]
private async Task OnGmHouseCancelParking(Connection conn, UxRpcMessage msg)
{
if (!GarageCfg.Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
if (!TryReadGarageArgs(conn, msg, out SceneMethods.GmHouseCancelParkingArgs4229938 args))
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var cancel = CancelVehicles(conn, args.vehicleIdList);
await conn.ReturnEmptyOkAsync(msg);
conn.Log.Info($"[GARAGE] GmHouseCancelParking 取消={cancel.Count}");
}
// =========================================================================================
// 4) GmHouseParking(65027125) —— GM:把 houseIdList[i] 的车 vehicleIdList[i] 停进去
// =========================================================================================
[Handler(MethodId.GmHouseParking, HandlerPacketKind.Invoke)]
private async Task OnGmHouseParking(Connection conn, UxRpcMessage msg)
{
if (!GarageCfg.Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
if (!TryReadGarageArgs(conn, msg, out SceneMethods.GmHouseParkingArgs4229938 args))
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var world = GetStateIfExists(conn.Session);
if (world is null)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var add = new List<SceneMethods.HouseVehicleParkingInfo>();
lock (world.SyncRoot)
{
var count = Math.Min(args.houseIdList.Count, args.vehicleIdList.Count);
for (var i = 0; i < count; i++)
{
var houseId = args.houseIdList[i];
var vehicleId = args.vehicleIdList[i];
if (houseId == 0 || vehicleId == 0)
continue;
var index = AssignFreeSpace(world, houseId, vehicleId);
if (index < 0)
continue;
add.Add(new SceneMethods.HouseVehicleParkingInfo
{
HouseId = houseId,
VehicleId = vehicleId,
ParkingSpaceIndex = index,
});
}
}
if (add.Count > 0)
{
PlayerProgressStore.Save(world, conn.Log, quiet: true);
await PushSyncHouseParkingAsync(conn.Session, [], add, conn.Log);
}
await conn.ReturnEmptyOkAsync(msg);
conn.Log.Info($"[GARAGE] GmHouseParking 新增={add.Count}");
}
// =========================================================================================
// 5) AskCreateHouseParkingVehicles(63479109) —— 命门:spawn 车 + 回填 EntityId
// =========================================================================================
[Handler(MethodId.AskCreateHouseParkingVehicles, HandlerPacketKind.Invoke)]
private async Task OnAskCreateHouseParkingVehicles(Connection conn, UxRpcMessage msg)
{
var houseId = msg.Body.Length >= 4 ? BitConverter.ToUInt32(msg.Body, 0) : 0u;
if (!GarageCfg.Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var world = GetStateIfExists(conn.Session);
if (world is null || houseId == 0)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
RememberGarageHouse(conn.Session, houseId);
Dictionary<int, uint>? spaces = null;
lock (world.SyncRoot)
{
if (world.Progress.GarageParking.TryGetValue(houseId, out var stored) && stored.Count > 0)
spaces = new Dictionary<int, uint>(stored);
}
if (spaces is null)
{
conn.Log.Info($"[GARAGE] AskCreateHouseParkingVehicles house={houseId} 无车位指派 ⇒ 不生成车");
await conn.ReturnEmptyOkAsync(msg);
return;
}
var wanted = spaces.OrderBy(kv => kv.Key)
.Select(kv => (kv.Key, kv.Value, -1))
.ToList();
var binding = await EnsureParkingVehiclesAsync(conn, houseId, wanted);
if (binding.Count > 0)
await PushSyncHouseParkingEntityBindingAsync(conn.Session, binding, conn.Log);
conn.Log.Info($"[GARAGE] AskCreateHouseParkingVehicles house={houseId} 生成/回填 {binding.Count} 辆");
await conn.ReturnEmptyOkAsync(msg);
}
// =========================================================================================
// 6) AskDestroyHouseParkingVehicles(63266373) —— 出库销毁本房车辆
// =========================================================================================
[Handler(MethodId.AskDestroyHouseParkingVehicles, HandlerPacketKind.Invoke)]
private async Task OnAskDestroyHouseParkingVehicles(Connection conn, UxRpcMessage msg)
{
var houseId = msg.Body.Length >= 4 ? BitConverter.ToUInt32(msg.Body, 0) : 0u;
if (!GarageCfg.Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var runtime = GetGarageRuntime(conn.Session);
List<ulong>? entities = null;
lock (runtime.SyncRoot)
{
runtime.LastHouseId = houseId != 0 ? houseId : runtime.LastHouseId;
if (runtime.EntitiesByHouse.TryGetValue(houseId, out var map))
{
entities = map.Values.Where(id => id != 0).Distinct().ToList();
// ⛔ 只清「车位 → 实体」映射,**不销毁**玩家正开着的那辆车。
// 被跳过的那辆保持映射,下次进库 EnsureParkingVehiclesAsync 会复用它,
// 不会重复生成。详见 ResolveDrivenVehicleIds 的注释。
var driven = ResolveDrivenVehicleIds(conn.Session);
foreach (var (space, id) in map.Where(kv => driven.Contains(kv.Value)).ToList())
{
map.Remove(space);
conn.Log.Info($"[GARAGE] 出库保留玩家正开的车 entity={id} house={houseId} space={space}(不销毁)");
}
}
}
// ⛑ await 不能写在 lock 体内(CS1996)—— 先出锁再销毁。
if (entities is null)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
// 玩家正开着的车不销毁:客户端一坐进车位上的车就会走「驶离车库」流程并发这条 RPC,
// 无差别销毁会把玩家正坐着的车一起拆掉 ⇒ 模型当场消失、角色保持开车姿势悬空
// (2026-09-30 23:38:20 实测),随后 LeaveGarage 找不到车辆实例 ⇒ 出库演出卡死黑屏。
var keep = ResolveDrivenVehicleIds(conn.Session);
var destroyed = 0;
var vehicleRuntime = GetVehicleRuntimeState(conn.Session);
foreach (var entityId in entities)
{
if (keep.Contains(entityId))
{
conn.Log.Info($"[GARAGE] 出库跳过销毁 entity={entityId}(玩家正开着)");
continue;
}
await DestroyVehicleAsync(conn.Session, vehicleRuntime, entityId, $"garage-exit house={houseId}");
destroyed++;
}
conn.Log.Info($"[GARAGE] AskDestroyHouseParkingVehicles house={houseId} 销毁 {destroyed}/{entities.Count} 辆"
+ $"(保留玩家正开的 {entities.Count - destroyed} 辆)");
await conn.ReturnEmptyOkAsync(msg);
}
// =========================================================================================
// 7) AskTeleportToHouseGarage(63099218) —— 进库(演出客户端自己播,回 OK 即可)
// =========================================================================================
[Handler(MethodId.AskTeleportToHouseGarage, HandlerPacketKind.Invoke)]
private async Task OnAskTeleportToHouseGarage(Connection conn, UxRpcMessage msg)
{
var houseId = msg.Body.Length >= 4 ? BitConverter.ToUInt32(msg.Body, 0) : 0u;
var spaceIndex = msg.Body.Length >= 8 ? BitConverter.ToInt32(msg.Body, 4) : -1;
RememberGarageHouse(conn.Session, houseId);
conn.Log.Info($"[GARAGE] AskTeleportToHouseGarage house={houseId} space={spaceIndex} ⇒ ok");
await conn.ReturnEmptyOkAsync(msg);
}
// =========================================================================================
// 8) AskTeleportFromHouseGarage(63203581) —— 出库:开门 + **推 SyncTeleport 落点** + 回 OK
// =========================================================================================
/// <summary>
/// 一趟出库事务待落的点(写于 <see cref="OnAskTeleportFromHouseGarage"/>,
/// 消费于 <c>ReportPostTeleportFinish</c> 的硬设兜底 —— 与景点电梯四修同一个套路)。
/// </summary>
internal sealed record PendingGarageLanding(
uint HouseId, float X, float Y, float Z, float Facing, ulong VehicleId);
internal const string PendingGarageLandingKey = "client4229938-garage-landing";
[Handler(MethodId.AskTeleportFromHouseGarage, HandlerPacketKind.Invoke)]
private async Task OnAskTeleportFromHouseGarage(Connection conn, UxRpcMessage msg)
{
var houseId = msg.Body.Length >= 4 ? BitConverter.ToUInt32(msg.Body, 0) : 0u;
var spaceIndex = msg.Body.Length >= 8 ? BitConverter.ToInt32(msg.Body, 4) : -1;
var vehicleConfigId = msg.Body.Length >= 12 ? BitConverter.ToUInt32(msg.Body, 8) : 0u;
RememberGarageHouse(conn.Session, houseId);
if (GarageCfg.Enabled && GarageCfg.OpenDoorOnExit)
OpenCachedGarageDoor(conn);
// ⛔⛔ 这里**必须推一帧 SyncTeleport**,否则就是黑屏。
// 客户端 LeaveGarage 走的是原生 loading 事务:
// AskTeleport(config=7, beforeResName=loading_garage_out*) ← 播离库过场
// → ReportPreTeleportFinish
// → AskTeleportFromHouseGarage(本方法)
// 而它的回调(GarageManager.lua PlayLoadingCutscene 内)**只在失败时**才
// FatalLog + StopLoading,成功分支什么都不做 —— 也就是说「结束加载画面」
// 这件事完全交给服务端。原实现只 ReturnEmptyOkAsync ⇒ 客户端永远停在
// loading ⇒ 过场播完就黑屏(2026-09-30 23:38 实测:23:38:25 收到本 RPC 并 ack,
// 之后再没有 ReportPostTeleportFinish,indoor 于 23:38:27 归零后一直黑)。
// 与景点电梯「一修」是同一个根因,同一个修法。
if (!GarageCfg.Enabled)
{
conn.Log.Info($"[GARAGE] AskTeleportFromHouseGarage house={houseId} space={spaceIndex} "
+ "⇒ ok(enabled=false,不推 SyncTeleport,逐字节等于改动前)");
await conn.ReturnEmptyOkAsync(msg);
return;
}
var landing = ResolveGarageExitLanding(houseId, conn.Log);
var state = GetStateIfExists(conn.Session);
ulong teleportId = 0;
if (state is not null)
{
lock (state.SyncRoot)
{
teleportId = state.PendingTeleportId;
state.PendingTeleportSyncSent = true;
state.LastReportedPlayerPosition = new Vec3(landing.X, landing.Y, landing.Z);
state.LastReportedPlayerRotation = new Vec3(0f, landing.Facing, 0f);
state.HasLastReportedPlayerTransform = true;
}
}
var driven = ResolveDrivenVehicleIds(conn.Session);
var vehicleId = driven.Count > 0 ? driven.First() : 0UL;
// ⛑ 先 ack、再推 —— 与 donor 在改装店验证过的顺序一致
// (GameRouter.Teleport.cs 的 AskTeleportToLeavingWaypoint:ReturnEmptyOkAsync 之后
// 才 PushPlayerAndVehicleAsync)。ack 让客户端加载流程走到「等落点」那一步,
// 随后推的 SyncTeleport 才会被采纳;反序会把 SyncTeleport 吃在流程还没就位的时候。
await conn.ReturnEmptyOkAsync(msg);
await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption
{
teleportId = teleportId,
Position = new GameMethods.TeleportVec3(landing.X, landing.Y, landing.Z),
Facing = landing.Facing,
IsSwitchScene = false,
WaitTaskResource = false,
MapEntranceId = landing.EntranceId,
});
if (vehicleId != 0)
{
await conn.NotifyAsync(MethodId.SyncTeleportVehicle, new SceneMethods.SyncTeleportVehicle4229938
{
EntityId = vehicleId,
Position = new SceneMethods.UxVector3(landing.X, landing.Y, landing.Z),
Rotation = new SceneMethods.UxVector3(0f, landing.Facing, 0f),
Velocity = 0f,
Reset = true,
MoveToken = 0,
});
}
// 交给 ReportPostTeleportFinish 的硬设兜底(客户端可能不采纳 SyncTeleport 的落点,
// 见景点电梯四修)。新事务开始前会被覆盖。
conn.Session.Items[PendingGarageLandingKey] =
new PendingGarageLanding(houseId, landing.X, landing.Y, landing.Z, landing.Facing, vehicleId);
conn.Log.Info($"[GARAGE] AskTeleportFromHouseGarage house={houseId} space={spaceIndex} "
+ $"veh={vehicleConfigId} ⇒ SyncTeleport 落点=({landing.X:F1},{landing.Y:F1},{landing.Z:F1}) "
+ $"facing={landing.Facing:F0} 门={landing.EntranceId}({landing.Source}) 车={vehicleId} teleportId={teleportId}");
}
/// <summary>出库落点 + 该填进 <c>SyncTeleport.MapEntranceId</c> 的入口 id。</summary>
private readonly record struct GarageExitLanding(
float X, float Y, float Z, float Facing, uint EntranceId, string Source);
/// <summary>
/// 出库落点推导,优先级:配置 <c>exitLandingOverrides</c> &gt;
/// <c>HouseConfig.VisitorExit</c>(绝对坐标)&gt; 该房 <c>MapentranceConfig</c> 那一行 &gt;
/// 玩家当前上报位置(原地,宁可不动也不能黑屏)。
///
/// <para>为什么不能用客户端自己那份:出库落点客户端取的是
/// <c>HouseConfig.GarageOutWaypoint</c>(<b>Spoon 航点 id</b>,服务端没有 Spoon 位置表),
/// 而且 <c>ExitTimeline</c> 只有 1 个元素的房(素光小筑)连 Spoon 反查都不做 ⇒
/// <c>afterPosition</c> 全零。</para>
/// </summary>
private static GarageExitLanding ResolveGarageExitLanding(uint houseId, ServerLogger log)
{
uint entranceId = 0;
if (HouseCatalog4229938.TryGet(houseId, out var house) && house is not null)
{
entranceId = house.MapEntrance;
foreach (var o in GarageCfg.ExitLandingOverrides)
{
if (o.HouseId == houseId)
{
log.Info($"[GARAGE] 出库落点=配置覆盖 house={houseId} ({o.X:F1},{o.Y:F1},{o.Z:F1}) facing={o.Facing:F0}");
return new GarageExitLanding(o.X, o.Y, o.Z, o.Facing, entranceId, "override");
}
}
if (house.VisitorExit.IsUsable)
{
log.Info($"[GARAGE] 出库落点=HouseConfig.VisitorExit house={houseId} "
+ $"({house.VisitorExit.X:F1},{house.VisitorExit.Y:F1},{house.VisitorExit.Z:F1}) "
+ $"facing={house.VisitorExit.Facing:F0} 门={entranceId}");
return new GarageExitLanding(house.VisitorExit.X, house.VisitorExit.Y, house.VisitorExit.Z,
house.VisitorExit.Facing, entranceId, "VisitorExit");
}
}
if (entranceId != 0)
{
foreach (var e in SceneCatalog4229938.MapEntrances)
{
if (e.Id != entranceId)
continue;
log.Info($"[GARAGE] 出库落点=MapentranceConfig house={houseId} 门={entranceId} "
+ $"({e.X:F1},{e.Y:F1},{e.Z:F1}) facing={e.Facing:F0}");
return new GarageExitLanding(e.X, e.Y, e.Z, e.Facing, entranceId, "MapentranceConfig");
}
}
log.Warn($"[GARAGE] 出库落点解析失败 house={houseId}(VisitorExit 全零且门 {entranceId} 不在表里)⇒ 退化成原地");
return new GarageExitLanding(0f, 0f, 0f, 0f, entranceId, "none");
}
// =========================================================================================
// helpers
// =========================================================================================
/// <summary>
/// 确保「本房这些车位」上各有一辆活着的车,并返回可直接推给客户端的
/// <c>SyncHouseParkingEntityBinding</c> 列表。
///
/// <para>复用顺序:① 同一辆车换车位(<c>PreviousSpace &gt;= 0</c>)时把运行时里旧车位的实体搬过来,
/// 不重新生成;② 目标车位已有活着的实体直接复用;③ 都没有才 <c>SpawnDirectAsync</c>。</para>
///
/// <para>两条链路都用它:<c>AskCreateHouseParkingVehicles</c>(进库演出结束后客户端主动要)
/// 和 <c>AskHouseMoveParkingSpace</c>(换车 / 调取载具 —— 那条链路上客户端因为
/// <c>IsSwitchingCar</c> 不会自己发前者,所以服务端必须自己生成)。</para>
/// </summary>
private async Task<List<SceneMethods.HouseParkingEntityBinding>> EnsureParkingVehiclesAsync(
Connection conn,
uint houseId,
IReadOnlyList<(int Space, uint VehicleId, int PreviousSpace)> wanted)
{
var bindings = new List<SceneMethods.HouseParkingEntityBinding>();
if (wanted.Count == 0)
return bindings;
var state = GetStateStatic(conn.Session);
var anchor = GarageSpawnPoint(state, GarageCfg.SpawnForwardOffset);
var facing = state.LastReportedPlayerRotation.Y;
var runtime = GetGarageRuntime(conn.Session);
foreach (var (space, vehicleId, previousSpace) in wanted)
{
if (vehicleId == 0)
continue;
ulong entity = 0;
lock (runtime.SyncRoot)
{
if (!runtime.EntitiesByHouse.TryGetValue(houseId, out var map))
{
map = new Dictionary<int, ulong>();
runtime.EntitiesByHouse[houseId] = map;
}
// ① 同一辆车换车位:旧车位的实体搬过来。
if (previousSpace >= 0 && previousSpace != space
&& map.TryGetValue(previousSpace, out var moved) && moved != 0)
{
map.Remove(previousSpace);
entity = moved;
}
// ② 目标车位已有活着的车(客户端会多次调这条,必须幂等)。
else if (map.TryGetValue(space, out var existing) && existing != 0
&& IsVehicleAlive(conn.Session, existing))
{
entity = existing;
}
else
{
// ②b 这辆车玩家**正开着**(开出车库后又开回来入库)。
// 出库时刻意没销毁它(见 OnAskDestroyHouseParkingVehicles),实体还在
// 世界里活着 ⇒ 直接复用,否则同一个 vehicleConfigId 会有两份实体。
var drivenEntity = FindDrivenVehicleEntity(conn.Session, vehicleId);
if (drivenEntity != 0)
{
entity = drivenEntity;
conn.Log.Info($"[GARAGE] 复用玩家正开的车 entity={drivenEntity} veh={vehicleId} "
+ $"house={houseId} space={space}(不重复生成)");
}
}
if (entity != 0)
map[space] = entity;
}
if (entity == 0)
{
// ⛑ await 不能在 lock 体内(CS1996)—— 上面已经出锁了。
var (ok, message, spawned, _) = await SpawnDirectAsync(
conn.Session, vehicleId, anchor, facing,
$"garage house={houseId} space={space}", anchor,
createSourceType: 10, destroyPrevious: false);
if (!ok)
{
conn.Log.Warn($"[GARAGE] 生成车失败 house={houseId} space={space} veh={vehicleId}: {message}");
continue;
}
entity = spawned;
lock (runtime.SyncRoot)
runtime.EntitiesByHouse[houseId][space] = entity;
}
bindings.Add(new SceneMethods.HouseParkingEntityBinding
{
VehicleId = vehicleId,
EntityId = entity,
ParkingSpaceIndex = space,
});
}
return bindings;
}
private static bool TryReadGarageArgs<T>(Connection conn, UxRpcMessage msg, out T args)
{
try
{
args = UxSerializer.Deserialize<T>(msg.Body);
return args is not null;
}
catch (Exception ex)
{
conn.Log.Warn($"[GARAGE] {typeof(T).Name} 解析失败 ({ex.GetType().Name}: {ex.Message}) "
+ $"bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}");
args = default!;
return false;
}
}
/// <summary>把 <paramref name="vehicleId"/> 放进该房第一个空车位,返回车位号(0 基);无空位返回 -1。
/// 该车已在本房某个车位时返回原位置(幂等)。调用方必须持有 <c>world.SyncRoot</c>。</summary>
private static int AssignFreeSpace(WorldEntryState world, uint houseId, uint vehicleId)
{
var count = 2u;
if (HouseCatalog4229938.TryGet(houseId, out var house) && house.ParkingSpaceCount > 0)
count = house.ParkingSpaceCount;
if (!world.Progress.GarageParking.TryGetValue(houseId, out var spaces))
{
spaces = new Dictionary<int, uint>();
world.Progress.GarageParking[houseId] = spaces;
}
foreach (var (index, existing) in spaces)
{
if (existing == vehicleId)
return index;
}
for (var i = 0; i < count; i++)
{
if (!spaces.ContainsKey(i))
{
spaces[i] = vehicleId;
return i;
}
}
return -1;
}
/// <summary>按车辆 id 取消车位指派(跨所有房),落盘并回推 cancelInfoList。</summary>
private static List<SceneMethods.HouseVehicleParkingInfo> CancelVehicles(Connection conn, List<uint> vehicleIds)
{
var cancel = new List<SceneMethods.HouseVehicleParkingInfo>();
var world = GetStateIfExists(conn.Session);
if (world is null || vehicleIds.Count == 0)
return cancel;
var wanted = vehicleIds.Where(id => id != 0).ToHashSet();
lock (world.SyncRoot)
{
foreach (var (houseId, spaces) in world.Progress.GarageParking)
{
foreach (var key in spaces.Where(kv => wanted.Contains(kv.Value)).Select(kv => kv.Key).ToList())
{
var vid = spaces[key];
spaces.Remove(key);
cancel.Add(new SceneMethods.HouseVehicleParkingInfo
{
HouseId = houseId,
VehicleId = vid,
ParkingSpaceIndex = key,
});
}
}
}
if (cancel.Count > 0)
{
PlayerProgressStore.Save(world, conn.Log, quiet: true);
_ = PushSyncHouseParkingAsync(conn.Session, cancel, [], conn.Log);
}
return cancel;
}
private static async Task PushSyncHouseParkingAsync(
TcpSession session,
List<SceneMethods.HouseVehicleParkingInfo> cancel,
List<SceneMethods.HouseVehicleParkingInfo> add,
ServerLogger log)
{
var args = new SceneMethods.SyncHouseParkingArgs4229938
{
cancelInfoList = cancel,
addInfoList = add,
};
var body = UxSerializer.Serialize(args);
await session.NotifyAsync(MethodId.SyncHouseParking, body, CancellationToken.None);
log.Info($"[GARAGE] -> SyncHouseParking(64277819) cancel={cancel.Count} add={add.Count} body={body.Length}B");
}
private static async Task PushSyncHouseParkingEntityBindingAsync(
TcpSession session,
List<SceneMethods.HouseParkingEntityBinding> binding,
ServerLogger log)
{
var args = new SceneMethods.SyncHouseParkingEntityBindingArgs4229938 { bindingList = binding };
var body = UxSerializer.Serialize(args);
await session.NotifyAsync(MethodId.IGameToClient_SyncHouseParkingEntityBinding, body, CancellationToken.None);
log.Info($"[GARAGE] -> SyncHouseParkingEntityBinding(64060781) n={binding.Count} body={body.Length}B");
}
/// <summary>车辆落点:玩家当前位置(+ 配置的前向偏移)。</summary>
private static Vec3 GarageSpawnPoint(WorldEntryState state, float forwardOffset)
{
var p = state.LastReportedPlayerPosition;
if (forwardOffset == 0f)
return p;
var rad = state.LastReportedPlayerRotation.Y * (MathF.PI / 180f);
return new Vec3(p.X + MathF.Sin(rad) * forwardOffset, p.Y, p.Z + MathF.Cos(rad) * forwardOffset);
}
private static bool IsVehicleAlive(TcpSession session, ulong entityId)
{
var runtime = GetVehicleRuntimeState(session);
lock (runtime.SyncRoot)
return runtime.Vehicles.ContainsKey(entityId);
}
/// <summary>
/// 本玩家<b>正开着</b>的、<c>VehicleRecord.ConfigId == vehicleConfigId</c> 的那个实体;没有则 0。
///
/// <para>用途:玩家把车库里的车开出去、又开回来入库时复用同一个实体
/// (<c>EnsureParkingVehiclesAsync</c> 的步骤 ②b)。出库时刻意不销毁玩家正开的车,
/// 所以它仍在世界里活着;不复用就会给同一辆车生成第二份实体。</para>
///
/// <para>锁序:调用方持有 <c>GarageRuntimeState.SyncRoot</c> 时本方法会再取
/// <c>VehicleRuntimeState.SyncRoot</c> ⇒ 顺序恒为 Garage → Vehicle(与
/// <c>OnAskDestroyHouseParkingVehicles</c> 一致),不会反向嵌套。</para>
/// </summary>
private static ulong FindDrivenVehicleEntity(TcpSession session, uint vehicleConfigId)
{
if (vehicleConfigId == 0)
return 0;
var driven = ResolveDrivenVehicleIds(session);
if (driven.Count == 0)
return 0;
var runtime = GetVehicleRuntimeState(session);
lock (runtime.SyncRoot)
{
foreach (var id in driven)
{
if (runtime.Vehicles.TryGetValue(id, out var rec) && rec.ConfigId == vehicleConfigId)
return id;
}
}
return 0;
}
}