Files

946 lines
51 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.Text.Json;
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 SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Teleport: inbound client/GM requests plus server-push primitives used by the admin panel.
/// Lua (RPCSerializeAuto WriteTeleportOption/WritePreTeleportOption) is the wire truth;
/// dump.cs TeleportOption/PreTeleportOption field order is mirrored 1:1 in TeleportMethods.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>Session key holding the client's own <c>PreTeleportOption</c> (echo source).</summary>
private const string PendingTeleportOptionKey = "pending-teleport-option";
/// <summary>LoadingConfig.ConfigType 里「进出改装店」的取值(客户端 EnterExitCarShop)。</summary>
private const uint CarShopTeleportConfig = 8u;
/// <summary>
/// <c>LoadingConfig.EnterExitRoomId</c> = 景点 / 电梯传送门(<c>MapentranceConfig.Type == 1</c>)
/// 的 <c>AskTeleport</c> config 值。客户端 <c>RpcUtils.lua:88</c> 就是拿它发起电梯事务的。
/// </summary>
private const uint JiguanTeleportConfig = 2u;
/// <summary>
/// <c>LoadingConfig</c> 里「进出车库」的 <c>AskTeleport</c> config 值
/// (客户端 <c>GarageManager.lua PlayLoadingCutscene</c> 传的是
/// <c>LTConfig.LoadingConfig.EnterExitGarage</c>,实测线值 <c>7</c>;
/// 对应 <c>LoadingConfig.json</c> 的 <c>Id=7 / ConfigType=6 / PanelType=2</c>)。
///
/// <para>⛔ 进库与出库<b>共用</b>这一个 config 值,只能靠 <c>beforeResName</c> 区分方向:
/// 进库 = <c>EnterTimeline[1]</c>(<c>loading_garage_in04</c> / <c>in05</c>),
/// 出库 = <c>ExitTimeline[1]</c>(<c>loading_garage_out01</c> / <c>out03</c>)。</para>
/// </summary>
private const uint GarageExitTeleportConfig = 7u;
/// <summary>
/// 会话键:一趟电梯事务「待生效的落点」。
///
/// 写入方 = <c>AskEnterRaidByMapEntrance</c>(推 <c>SyncTeleport</c> 的同一步);
/// 消费方 = <c>ReportPostTeleportFinish</c>(过场全部结束)—— 见
/// <see cref="MapEntranceSettings.JiguanHardSet"/> 的说明:
/// 客户端不落地,所以最后一帧用硬设位置通道补上。
/// </summary>
private const string PendingJiguanLandingKey = "jiguan-pending-landing";
/// <summary>一趟电梯事务的落点(起终点门 id 只用于日志/排查)。</summary>
private sealed record PendingJiguanLanding(
float X, float Y, float Z, float Facing, uint SourceEntranceId, uint DestEntranceId);
// ----------------------------------------------------------------------------------
// 改装店(ApplyCar / RepairShop)—— 2026-09-25 从俄语上游 donor 的 repair-shop.zip 移植。
//
// 入场链路(客户端 ApplyCarManager.lua:152 EnterRepairShop):
// 触发区(AskVehicleEnterArea) → CheckCanEnterCarShop → AskTeleport(config=8)
// → 服务端 echo SyncPreTeleportOption → 客户端播 beforeResName 时间轴
// → ReportPreTeleportFinish → **AskTeleportToParkingWaypoint**
// → 服务端推 SyncTeleport(人) + SyncTeleportVehicle(车) → ReportPostTeleportFinish。
//
// ⛑⛑ 两处 donor 用实机日志证明、不看代码绝对猜不出来的坑:
// ① config=8 的 option.position / afterPosition 客户端填的是 (0,0,0),
// 过场结束时客户端会「应用 afterPosition」⇒ 不改写就把人和车甩到世界原点
// (donor 2026-09-22 18:28 实测, SyncChangeIndoor(4) 进店成功 2 秒后掉回家)。
// ② 关键一步是分布 AskTeleportToParkingWaypoint / LeavingWaypoint 里推 *人的*
// SyncTeleport:只 ack 不推 ⇒ 客户端永远停在黑屏(17:11 / 17:31 实测),
// post-finish 永远不来。而且 pre-finish 里不能推(会把驾驶中的玩家拽到半空)。
// ----------------------------------------------------------------------------------
/// <summary>一间 Smokey Drift 的四个落成点(坐标见 <see cref="ShopInteriors"/>)。</summary>
private sealed record ShopAnchor(
GameMethods.TeleportVec3 Entrance,
GameMethods.TeleportVec3 Interior,
float Facing,
GameMethods.TeleportVec3 EnterPos,
float EnterYaw,
GameMethods.TeleportVec3 Leave,
float LeaveYaw);
/// <summary>
/// 两间 Smokey Drift 的落成坐标(donor 从客户端 waypoint 表导出,不是手调):
/// 西门 CarShopConfig.Id=1(indoorID 4)、海边 Id=2(indoorID 47)。
/// Interior = ParkingWaypoint(地下工位,y&lt;0),EnterPos = StopCarWaypoint(过场起点),
/// Leave = LeaveWaypoint(离场落地点),Entrance 只用于「离玩家最近的是哪家店」。
/// </summary>
private static readonly ShopAnchor[] ShopInteriors =
[
new ShopAnchor(new(1439.10803f, 1.5f, 2379.6001f), new(1443.23f, -19.992f, 2351.428f), 180f,
new(1434.62f, -0.01f, 2378.78f), 90f,
new(1414.36f, 0f, 2379.78f), 176.5f), // CarShopConfig 1 — 西门
new ShopAnchor(new(2791.15601f, 1.37899995f, 1286.39001f), new(2783.912f, -11.92f, 1262.419f), 0f,
new(2790.46f, -0.01f, 1290.93f), 180f,
new(2817.31f, 0f, 1296.75f), 270f), // CarShopConfig 2 — 海边
];
/// <summary>按「离玩家最近的那家店」选店(没有位置信息时兜底西门)。</summary>
private static ShopAnchor NearestShop(Vec3? playerPos)
{
if (playerPos is not { } p)
return ShopInteriors[0];
var best = ShopInteriors[0];
var bestD = float.MaxValue;
foreach (var s in ShopInteriors)
{
var dx = s.Entrance.X - p.X;
var dz = s.Entrance.Z - p.Z;
var d = dx * dx + dz * dz;
if (d < bestD) { bestD = d; best = s; }
}
return best;
}
/// <summary>入口与出口共用 config=8,靠 beforeResName 里的 in/out 区分方向。</summary>
private static bool IsExitCutscene(GameMethods.PreTeleportOption option)
=> option.beforeResName is not null && option.beforeResName.Contains("out", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// config=8 且 position 是全零时,照 donor 的做法用真实目标点填空:
/// 进场填 Interior(工位)、离场填 Leave(街边),并同步 afterPosition / afterRot。
/// </summary>
private ShopAnchor? PrepareCarShopOption(Connection conn, GameMethods.PreTeleportOption option, WorldEntryState state)
{
if (option.configId != CarShopTeleportConfig)
return null;
// 4s 防抖:客户端自己的触发区在离场落地后 0.3s 会补一发 garage_in* 的 AskTeleport,
// 老实 echo 就把人/车又拽回店里,无限循环(donor 13:35 实测)。真正的「再进一次」
// 需要真的开车压一次触发区,耗时远大于 4s。
var isExit = IsExitCutscene(option);
if (!isExit && state.LastShopExitTicks != 0
&& Environment.TickCount64 - state.LastShopExitTicks < 4000)
{
conn.Log.Info("[TELEPORT] car-shop enter suppressed (within 4s of exit — client trigger re-fire)");
return null;
}
// 选店用「车的实时位置」,不用玩家坐标:AskVehicleMove 不更新玩家坐标,开车时它是过期的。
var shop = NearestShop(LastDrivenVehicleFix(conn.Session) is { } f
? new Vec3(f.X, f.Y, f.Z)
: state.HasLastReportedPlayerTransform ? state.LastReportedPlayerPosition : null);
if (option.position.X == 0f && option.position.Y == 0f && option.position.Z == 0f)
{
var dest = isExit ? shop.Leave : shop.Interior;
var destYaw = isExit ? shop.LeaveYaw : shop.Facing;
option.position = dest;
option.facing = destYaw;
option.afterPosition = new GameMethods.TeleportVec3(dest.X, dest.Y, dest.Z);
option.afterRot = new GameMethods.TeleportVec3(0f, destYaw, 0f);
}
// 没有 spoon 图(我们 SyncEnterScene 的 SceneSpoonNames 是空的)时
// SpoonRaidMgr.GetWayPointPosition 返回 Vector3.zero 而不是 nil ⇒ 客户端「自以为成功」
// 并带上 customBeforeTrans=true + beforePosition=(0,0,0)。用落成坐标覆盖。
if (option.customBeforeTrans
&& option.beforePosition.X == 0f && option.beforePosition.Y == 0f && option.beforePosition.Z == 0f)
{
var anchorPos = isExit ? shop.Interior : shop.EnterPos;
var anchorYaw = isExit ? shop.Facing : shop.EnterYaw;
option.beforePosition = new GameMethods.TeleportVec3(anchorPos.X, anchorPos.Y, anchorPos.Z);
option.beforeRot = new GameMethods.TeleportVec3(0f, anchorYaw, 0f);
conn.Log.Info($"[TELEPORT] rewrote zero beforePosition ({(isExit ? "exit" : "enter")}) → ({anchorPos.X:F1},{anchorPos.Y:F1},{anchorPos.Z:F1}) yaw={anchorYaw:F0}");
}
// 非 null = 「这是改装店事务,调用方去 echo」;坐标改写是原地作用在 option 上的。
return shop;
}
/// <summary>
/// ★★ 景点 / 电梯传送门(<c>config == 2</c>,<c>MapentranceConfig.Type == 1</c>)——
/// 2026-09-30 三修:<b>「进电梯不黑屏、但落地还在原地」的真正元凶在这里</b>。
///
/// <h3>前两修都改错了地方</h3>
/// 一修:开始推 <c>SyncTeleport</c> ⇒ 黑屏解了(<c>ReportPostTeleportFinish result=0</c>)。
/// 二修:<c>SyncTeleport.MapEntranceId</c> 从起点门改成<b>落点门</b>(343)⇒ <b>依旧原地</b>
/// (13:38:43 实机:<c>下发门=343 落点=(2277.54,160.60,1412.50)</c>,玩家仍在
/// <c>[INDOOR] indoorId=332 绿屿公馆大厅</c>)。
///
/// <h3>真因(捐赠端在改装店上用实机日志证明过,见本文件顶部注释 ①)</h3>
/// <blockquote>
/// config=8 的 <c>option.position / afterPosition</c> 客户端填的是 (0,0,0),
/// <b>过场结束时客户端会「应用 afterPosition」</b> ⇒ 不改写就把人和车甩到世界原点
/// (donor 2026-09-22 18:28 实测,<c>SyncChangeIndoor(4)</c> 进店成功 2 秒后掉回家)。
/// </blockquote>
/// 电梯这条链路一模一样:<c>RpcUtils.lua:88</c> 只填了
/// <c>beforePosition = cfg.Coordinate</c>、<c>afterResName = JiguanEndTimeline</c>,
/// <b><c>afterPosition</c> 是全零、<c>customAfterTrans = false</c></b>。
/// 而 <c>SyncPreTeleportOption</c> 我们一直是<b>原样回显</b>客户端那份 ⇒ 过场一结束,
/// 客户端就把全零(或退化后的当前点)当成落点应用 —— 跟我们推的 <c>SyncTeleport</c> 无关。
///
/// <h3>修法(照改装店 <see cref="PrepareCarShopOption"/> 的成型做法)</h3>
/// 在 echo 之前原地改写 <c>option</c>:<c>position</c> / <c>afterPosition</c> 都填推导出来的
/// 落点,<c>customAfterTrans = true</c>。目的地仍由
/// <see cref="JiguanElevatorCatalog4229938"/> 从 <c>MapentranceConfig</c> 推。
/// 起点门用 <c>beforePosition</c> 反查(客户端就是拿 <c>cfg.Coordinate</c> 填的,
/// 不依赖 <c>extParams</c> 的解析 —— 那份契约我们还没有)。
///
/// <c>SyncTeleport</c> 那一帧保留(它是解开 loading 的必要条件),但不再是落点来源。
///
/// <h3>★★ 起点门从哪来:<c>extParams</c> 里的 JSON(2026-09-30 实机取证)</h3>
/// 客户端 <c>RpcUtils.lua:90-105</c> 会把 <c>extParams.mapEntranceId</c> 一起塞进
/// <c>PreTeleportOption.extParams</c>(一个 <b>JSON 字符串</b>)。13:38:37 实机报文:
/// <code>
/// {"configId":2, ..., "customAfterTrans":false, "afterPosition":{"X":0,"Y":0,"Z":0},
/// "beforePosition":{"X":2257.781,"Y":0,"Z":1458.242}, "beforeRot":{"X":0,"Y":90,"Z":0},
/// "extParams":"{\"mapEntranceId\":337}"}
/// </code>
/// ⛑ <b>光靠 <c>beforePosition</c> 是分不开的</b>:337 与 340 的 <c>Coordinate</c> 完全相同
/// (大厅同一个门位上下两个按钮),只有 <c>EntranceFacing</c> 差 180°(90 / -90)。
/// 所以优先读 <c>extParams</c>;读不到才退回「位置 + <c>beforeRot.Y</c> 朝向」匹配。
///
/// 回滚 = <c>gameplay.mapEntrance.jiguanElevator=false</c>。
/// </summary>
/// <returns>true = 这是电梯事务且已改写,调用方去 echo。</returns>
private static bool PrepareJiguanOption(Connection conn, GameMethods.PreTeleportOption option)
{
if (option.configId != JiguanTeleportConfig)
return false;
var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance;
if (!cfg.Enabled || !cfg.JiguanElevator)
{
conn.Log.Info($"[TELEPORT] jiguan 跳过 原因=disabled enabled={cfg.Enabled} jiguanElevator={cfg.JiguanElevator}");
return false;
}
var entranceFromExt = TryReadMapEntranceIdFromExtParams(option.extParams);
MapEntranceEntry4229938? source = null;
var how = string.Empty;
if (entranceFromExt != 0)
{
source = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == entranceFromExt && e.IsJiguan);
how = source is null ? $"extParams={entranceFromExt}(表里没有)" : $"extParams={entranceFromExt}";
}
if (source is null)
{
source = FindJiguanDoorByPosition(option.beforePosition, option.beforeRot.Y);
how = source is null
? $"位置({option.beforePosition.X:F2},{option.beforePosition.Y:F2},{option.beforePosition.Z:F2})"
+ $"+朝向{option.beforeRot.Y:F0}"
: $"位置+朝向={source.Id}";
}
if (source is null)
{
conn.Log.Info($"[TELEPORT] jiguan 跳过 原因=no-source-door {how} extParams={option.extParams ?? "-"} "
+ "既没有可用的 mapEntranceId,也没有 Type==1 的入口坐标与之吻合");
return false;
}
if (!JiguanElevatorCatalog4229938.TryResolve(source.Id, cfg.JiguanGroupRadius,
cfg.JiguanLandingOverrides, out var dest, out var note))
{
conn.Log.Info($"[TELEPORT] jiguan 跳过 原因=unknown-id entrance={source.Id} {note}");
return false;
}
var to = new GameMethods.TeleportVec3(dest.X, dest.Y, dest.Z);
option.position = to;
option.facing = dest.Facing;
option.afterPosition = to;
option.afterRot = new GameMethods.TeleportVec3(0f, dest.Facing, 0f);
option.customAfterTrans = true;
conn.Log.Info($"[TELEPORT] jiguan 改写 afterPosition 起点门={source.Id}({how}) "
+ $"落点门={dest.EntranceId} 落点=({dest.X:F2},{dest.Y:F2},{dest.Z:F2}) facing={dest.Facing:F0} "
+ $"resolved={dest.Resolved} {note}");
return true;
}
/// <summary>
/// 从 <c>PreTeleportOption.extParams</c> 里取 <c>mapEntranceId</c>。
/// 客户端塞的是一个 JSON 字符串(实测 <c>{"mapEntranceId":337}</c>;不是电梯事务时是 <c>{}</c>)。
/// 解析失败一律返回 0,由调用方退回坐标匹配。
/// </summary>
private static uint TryReadMapEntranceIdFromExtParams(string? extParams)
{
if (string.IsNullOrWhiteSpace(extParams))
return 0u;
try
{
using var doc = JsonDocument.Parse(extParams);
if (doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("mapEntranceId", out var el)
&& el.ValueKind == JsonValueKind.Number
&& el.TryGetUInt32(out var id))
return id;
}
catch (JsonException)
{
// 客户端换了格式也无所谓 —— 退回坐标匹配即可。
}
return 0u;
}
/// <summary>
/// 按坐标 + 朝向反查景点 / 电梯门(客户端把 <c>cfg.Coordinate</c> 填进 <c>beforePosition</c>、
/// 把 <c>cfg.EntranceFacing</c> 填进 <c>beforeRot.Y</c>)。
///
/// ⛑ 必须先按「位置 + 朝向」筛:绿屿大厅 337 与 340 的 <c>Coordinate</c> 完全相同
/// (同一个门位的两个按钮),只有 <c>EntranceFacing</c> 差 180°(90 / -90),
/// 而它俩的目的地一个是高楼层、一个是车库。只看坐标会挑到列表里靠后的那个。
/// </summary>
private static MapEntranceEntry4229938? FindJiguanDoorByPosition(GameMethods.TeleportVec3 p, float facing)
{
if (!float.IsFinite(p.X) || !float.IsFinite(p.Y) || !float.IsFinite(p.Z))
return null;
if (p.X == 0f && p.Y == 0f && p.Z == 0f)
return null;
const float tol = 0.5f; // 配置坐标是浮点,0.5m 足够且不会误并(最近的另一条门 ≥ 4m)
MapEntranceEntry4229938? nearest = null;
var nearestD = tol;
MapEntranceEntry4229938? nearestFacing = null;
var nearestFacingD = tol;
foreach (var e in SceneCatalog4229938.MapEntrances)
{
if (!e.IsJiguan)
continue;
var dx = e.X - p.X;
var dy = e.Y - p.Y;
var dz = e.Z - p.Z;
var d = MathF.Sqrt(dx * dx + dy * dy + dz * dz);
if (d > tol)
continue;
if (d <= nearestD)
{
nearestD = d;
nearest = e;
}
// 朝向按「模 360 的最短夹角」:下面这个式子得到 [-180,180) 的有符号差,取绝对值即夹角。
// ⛑ 判据是 `diff <= 1f`(夹角足够小)。别写成 `180f - diff <= 1f` —— 那匹配的是「朝向相反」。
var diff = MathF.Abs(((e.Facing - facing) % 360f + 540f) % 360f - 180f);
if (diff <= 1f && d <= nearestFacingD)
{
nearestFacingD = d;
nearestFacing = e;
}
}
return nearestFacing ?? nearest;
}
/// <summary>
/// ★★ 车库<b>出库</b>事务(<c>config == 7</c> 且 <c>beforeResName</c> 含 <c>out</c>)的落点改写。
/// 2026-09-30 —— 与景点电梯三修、改装店 <see cref="PrepareCarShopOption"/> 是同一个坑:
/// <b>过场结束时客户端应用的是 <c>afterPosition</c>,而客户端自己那份算不出来</b>。
///
/// <h3>为什么客户端算不出来</h3>
/// <c>GarageManager.lua PlayLoadingCutscene</c> 里(反编译后比较符方向已按语义还原):
/// <code>
/// afterWaypointName = houseCfg.GarageOutWaypoint -- Spoon 航点 id
/// afterTLName = houseCfg.ExitTimeline[2]
/// if afterWaypointName and afterTLName and afterTLName ~= "" then
/// local afterPos = gSpoonMgr:GetWayPointPositionByNameOrId(afterWaypointName)
/// if afterPos then ... preTeleportOption.afterPosition = afterPos ... end
/// end
/// </code>
/// 两条都断:① 服务端没有 Spoon 位置表,客户端在私服里也查不到航点;
/// ② <b>素光小筑(11200006)的 <c>ExitTimeline</c> 只有 1 个元素</b>
/// (<c>["loading_garage_out03"]</c>,别的房都是 <c>["out01","out03"]</c>)⇒
/// <c>afterTLName = ExitTimeline[2] = nil</c> ⇒ 整块跳过 ⇒
/// <c>afterPosition</c> 留全零(实测 23:38:24 报文
/// <c>afterPosition:(0,0,0) customAfterTrans:false</c>)⇒ 过场一结束就被甩到世界原点 / 黑屏。
///
/// <h3>修法</h3>
/// 原地改写 <c>option</c>,然后由调用方自己 echo 一份
/// <c>SyncPreTeleportOption</c>(<b>不走</b>那个受 <c>gameplay.mapEntrance</c> 开关约束的
/// 通用 echo 分支 —— 车库不该被景点配置牵连):<c>position</c> / <c>afterPosition</c> 都填
/// <see cref="ResolveGarageExitLanding"/> 推出来的落点、<c>customAfterTrans = true</c>。
/// 过场起点(<c>beforePosition</c>)客户端同样拿不到 Spoon 航点(也是全零),
/// 用玩家最近上报的位置兜底 —— 出库时玩家正坐在车位上的车里,那就是车位格。
///
/// <para>⚠️ <c>PreTeleportOption</c> <b>没有</b> <c>MapEntranceId</c> 字段(那是
/// <c>SyncTeleport</c> 的 <c>TeleportOption</c> 才有),落点门 id 只在
/// <c>OnAskTeleportFromHouseGarage</c> 推 <c>SyncTeleport</c> 那一步用。</para>
///
/// 回滚 = <c>gameplay.houseGarage.enabled=false</c>(本方法直接返回,option 逐字节不变)。
/// </summary>
/// <returns>true = 这是车库出库事务且已改写,调用方去 echo。</returns>
private static bool PrepareGarageExitOption(Connection conn, GameMethods.PreTeleportOption option)
{
if (option.configId != GarageExitTeleportConfig)
return false;
if (!IsExitCutscene(option)) // 进库共用 config=7,靠 beforeResName 里的 out 区分
return false;
if (!PrivateServerConfigStore.Current.Gameplay.HouseGarage.Enabled)
{
conn.Log.Info("[GARAGE] 出库事务跳过落点改写 原因=houseGarage.enabled=false");
return false;
}
var houseId = LastGarageHouseId(conn.Session);
var landing = ResolveGarageExitLanding(houseId, conn.Log);
var to = new GameMethods.TeleportVec3(landing.X, landing.Y, landing.Z);
option.position = to;
option.facing = landing.Facing;
option.afterPosition = to;
option.afterRot = new GameMethods.TeleportVec3(0f, landing.Facing, 0f);
option.customAfterTrans = true;
// 过场起点:客户端填的是 (0,0,0) + customBeforeTrans=true(Spoon 反查退化,
// 见 PrepareCarShopOption 里同样的说明)。出库时玩家正坐在车位的车里 ⇒
// 最近上报的位置就是车位格。拿不到位置时才退回落点。
// ⛔ 注意 LastReportedPlayerPosition 是 RpcTypes.Vec3,不是 Game.TeleportVec3,
// 不能直接赋值(CS0029)—— 拆成三个 float 再构造。
var before = option.beforePosition;
if (option.customBeforeTrans && before.X == 0f && before.Y == 0f && before.Z == 0f)
{
var state = GetStateIfExists(conn.Session);
if (state is not null && state.HasLastReportedPlayerTransform)
{
var reported = state.LastReportedPlayerPosition;
before = new GameMethods.TeleportVec3(reported.X, reported.Y, reported.Z);
option.beforeRot = new GameMethods.TeleportVec3(0f, landing.Facing, 0f);
}
else
{
before = to;
}
option.beforePosition = before;
}
conn.Log.Info($"[GARAGE] 出库事务改写落点 house={houseId} "
+ $"after=({landing.X:F2},{landing.Y:F2},{landing.Z:F2}) facing={landing.Facing:F0} "
+ $"门={landing.EntranceId}({landing.Source}) "
+ $"before=({before.X:F1},{before.Y:F1},{before.Z:F1}) customAfterTrans=true");
return true;
}
/// <summary>把人 + 车一起挪到 dst/yaw(改装店进场/离场共用)。</summary>
private async Task PushPlayerAndVehicleAsync(Connection conn, WorldEntryState state,
GameMethods.TeleportVec3 dst, float yaw, string reason)
{
await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption
{
teleportId = 0,
Position = dst,
Facing = yaw,
IsSwitchScene = false,
WaitTaskResource = false,
MapEntranceId = 0,
});
var vehicleId = ResolveShopVehicleId(conn.Session);
if (vehicleId != 0)
{
await conn.NotifyAsync(MethodId.SyncTeleportVehicle, new SceneMethods.SyncTeleportVehicle4229938
{
EntityId = vehicleId,
Position = new SceneMethods.UxVector3(dst.X, dst.Y, dst.Z),
Rotation = new SceneMethods.UxVector3(0f, yaw, 0f),
Velocity = 0f,
Reset = true,
MoveToken = 0,
});
}
conn.Log.Info($"[TELEPORT] {reason} → pos=({dst.X:F1},{dst.Y:F1},{dst.Z:F1}) yaw={yaw:F0} vehicle={vehicleId}");
}
/// <summary>
/// 「这个 <c>AskTeleport</c> 事务要播 timeline」的判据:任一 timeline 资源名非空。
/// 决定是否把客户端发来的 option 原样回显(见 <c>MapEntranceSettings.EchoPreTeleport</c>)。
/// 实测数据点:地铁 <c>config=5</c> → <c>after=loading_metro_end_1</c>;改装店
/// <c>config=8</c> → <c>loading_garage_in02</c>(后者走它自己的分支,不经过这里)。
/// </summary>
private static bool HasTimeline(GameMethods.PreTeleportOption option)
=> !string.IsNullOrEmpty(option.beforeResName)
|| !string.IsNullOrEmpty(option.loadingResName)
|| !string.IsNullOrEmpty(option.afterResName);
/// <summary>玩家最近开过的那辆车(服务端 Proxy 生成/F1 召唤才有)。</summary>
private static (float X, float Y, float Z, float Yaw)? LastDrivenVehicleFix(TcpSession session)
{
var runtime = GetVehicleRuntimeState(session);
lock (runtime.SyncRoot)
{
var id = runtime.CurrentVehicleId != 0 ? runtime.CurrentVehicleId : runtime.LastDrivenVehicleId;
if (id == 0 || !runtime.Vehicles.TryGetValue(id, out var rec))
return null;
return (rec.Position.X, rec.Position.Y, rec.Position.Z, rec.Facing);
}
}
private static ulong ResolveShopVehicleId(TcpSession session)
{
var runtime = GetVehicleRuntimeState(session);
lock (runtime.SyncRoot)
return runtime.CurrentVehicleId != 0 ? runtime.CurrentVehicleId : runtime.LastDrivenVehicleId;
}
internal static WorldEntryState? GetStateIfExists(TcpSession session)
=> session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing
? existing
: null;
// Client -> server: native loading-flow request. Accept it, remember the correlation
// ids, and let the client drive its own flow; admin pushes use the outbound path below.
[Handler(MethodId.AskTeleport)]
private async Task AskTeleport(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.AskTeleportArgs>(out var args) && args?.option is not null)
{
var option = args.option;
var state = GetWorldState(msg.Context);
lock (state.SyncRoot)
{
state.PendingTeleportId = option.teleportId;
state.PendingTeleportPreFinished = false;
state.PendingTeleportSyncSent = false;
}
msg.Context.Session.Items[PendingTeleportOptionKey] = option;
// 新事务开始 ⇒ 上一趟电梯没走完(客户端 86s 后放弃 result=2)留下的落点必须作废,
// 否则下一个无关的 ReportPostTeleportFinish 会把人硬拽到过期坐标。
// 车库出库落点同理:AskTeleport(config=7) 在前、AskTeleportFromHouseGarage 在后
// (后者才写这个键),所以在这里清是安全的。
msg.Context.Session.Items.Remove(PendingJiguanLandingKey);
msg.Context.Session.Items.Remove(PendingGarageLandingKey);
conn.Log.Info($"[TELEPORT] AskTeleport config={option.configId} teleportId={option.teleportId} pos=({option.position.X:F1},{option.position.Y:F1},{option.position.Z:F1}) facing={option.facing:F2} before=(res={option.beforeResName} custom={option.customBeforeTrans} {option.beforePosition.X:F1},{option.beforePosition.Y:F1},{option.beforePosition.Z:F1}) after=({option.afterPosition.X:F1},{option.afterPosition.Y:F1},{option.afterPosition.Z:F1} res={option.afterResName})");
// 改装店:服务端必须 echo 同一份 option(客户端冷推是不吃的 —— donor 证明只认
// 「自己发过的 AskTeleport 的回显」),并且 config=8 的全零坐标要在这里补齐。
var echo = PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled
? PrepareCarShopOption(conn, option, state)
: null;
await conn.ReturnEmptyOkAsync(msg);
if (echo is not null)
{
await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option);
conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (config={option.configId} teleport={option.teleportId})");
return;
}
// ★★ 2026-09-30 三修:景点 / 电梯(config=2)的落点必须写在 afterPosition 里。
// 见 PrepareJiguanOption 上的长注释 —— 真正的元凶是「过场结束时客户端应用
// afterPosition」,而客户端填的是 (0,0,0);SyncTeleport 那一帧不是落点来源。
if (PrepareJiguanOption(conn, option))
{
await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option);
conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (景点/电梯 config={option.configId} "
+ $"after=({option.afterPosition.X:F2},{option.afterPosition.Y:F2},{option.afterPosition.Z:F2}) "
+ $"customAfterTrans={option.customAfterTrans})");
return;
}
// ★★ 2026-09-30:车库出库事务(config=7 + beforeResName 含 out)的落点改写。
// 与景点电梯三修同一个坑:客户端算不出 afterPosition(GarageOutWaypoint 是
// Spoon 航点 id,且素光小筑的 ExitTimeline 只有 1 个元素 ⇒ 连 Spoon 反查都不做)
// ⇒ 必须由服务端回显一份带真实落点的 option。这里自己 echo、不走下面那个
// 受 gameplay.mapEntrance 开关约束的通用分支(车库不该被景点配置牵连)。
if (PrepareGarageExitOption(conn, option))
{
await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option);
conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (车库出库 config={option.configId} "
+ $"after=({option.afterPosition.X:F2},{option.afterPosition.Y:F2},{option.afterPosition.Z:F2}) "
+ $"customAfterTrans={option.customAfterTrans})");
return;
}
// ⛑⛑ 2026-09-29:非改装店的「带 timeline 的传送事务」也要回显,否则客户端 loading
// 流程停在第 2 步(AskTeleport → SyncPreTeleportOption → ReportPreTeleportFinish →
// domain RPC),回调不执行 ⇒ domain RPC 一个都不发。
// 实测那一帧(10:22:22):AskTeleport config=5 after=(1878.8,-1.1,2250.5
// res=loading_metro_end_1) —— 地铁乘车动画事务,只 ack 不回显 ⇒「长按 F 没反应」。
if (PrivateServerConfigStore.Current.Gameplay.MapEntrance.Enabled
&& PrivateServerConfigStore.Current.Gameplay.MapEntrance.EchoPreTeleport
&& HasTimeline(option))
{
await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option);
conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (timeline 事务 config={option.configId} "
+ $"before={option.beforeResName ?? "-"} loading={option.loadingResName ?? "-"} after={option.afterResName ?? "-"})");
}
return;
}
else
{
conn.Log.Warn($"[TELEPORT] AskTeleport undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 64))}");
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskTeleportToParkingWaypoint, HandlerPacketKind.Invoke)]
private async Task AskTeleportToParkingWaypoint(Connection conn, UxRpcMessage msg)
{
if (!PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
if (!msg.TryGetArgs<SceneMethods.AskTeleportToParkingWaypointArgs4229938>(out var args) || args is null)
{
conn.Log.Warn($"[TELEPORT] parking-waypoint bad args bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 16))}");
await conn.ReturnEmptyOkAsync(msg);
return;
}
// ⛑⛑ 这一步的本质就是「由服务端给出坐标」:只 ack 不推 ⇒ 客户端永远停在 loading
// 黑屏且 post-finish 永不来(donor 17:11 / 17:31 实测)。目标是地下工位,
// 不是街上的入口 marker。到位后客户端自动把玩家请下车(retail 行为:
// ApplyCarManager 的 SetSeatInteractOperation(0,6) —— 车被抬上升降机)。
await conn.ReturnEmptyOkAsync(msg);
var shop = args.waypointId is 2 or 1029714 or 1029715 or 1029716 ? ShopInteriors[1] : ShopInteriors[0];
await PushPlayerAndVehicleAsync(conn, GetWorldState(msg.Context), shop.Interior, shop.Facing, $"parking waypoint={args.waypointId}");
}
[Handler(MethodId.AskTeleportToLeavingWaypoint, HandlerPacketKind.Invoke)]
private async Task AskTeleportToLeavingWaypoint(Connection conn, UxRpcMessage msg)
{
if (!PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
if (!msg.TryGetArgs<SceneMethods.AskTeleportToLeavingWaypointArgs4229938>(out var args) || args is null)
{
conn.Log.Warn($"[TELEPORT] leaving-waypoint bad args bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 16))}");
await conn.ReturnEmptyOkAsync(msg);
return;
}
// ok 回执让客户端重置店内状态(OnLeaveRepairShopComplete / ParkingVehicleId=0),
// 真正的位移 = 下面的 SyncTeleport + SyncTeleportVehicle。
await conn.ReturnEmptyOkAsync(msg);
var state = GetWorldState(msg.Context);
var shop = args.carshopid == 2u ? ShopInteriors[1] : ShopInteriors[0];
var leave = new GameMethods.TeleportVec3(
shop.Leave.X + PrivateServerConfigStore.Current.Gameplay.RepairShop.ExitOffsetX,
shop.Leave.Y,
shop.Leave.Z + PrivateServerConfigStore.Current.Gameplay.RepairShop.ExitOffsetZ);
state.LastShopExitTicks = Environment.TickCount64;
await PushPlayerAndVehicleAsync(conn, state, leave, shop.LeaveYaw, $"leaving waypoint carshop={args.carshopid}");
}
[Handler(MethodId.ReportPreTeleportFinish)]
private async Task ReportPreTeleportFinish(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.ReportPreTeleportFinishArgs>(out var args) && args is not null)
{
var state = GetWorldState(msg.Context);
lock (state.SyncRoot)
{
if (args.teleportId == state.PendingTeleportId)
state.PendingTeleportPreFinished = true;
}
conn.Log.Info($"[TELEPORT] ReportPreTeleportFinish teleportId={args.teleportId} pending={state.PendingTeleportId}");
}
else
{
conn.Log.Warn($"[TELEPORT] ReportPreTeleportFinish undecodable bytes={msg.Body.Length}");
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.ReportPostTeleportFinish, HandlerPacketKind.Notify)]
private async Task ReportPostTeleportFinish(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.ReportPostTeleportFinishArgs>(out var args) && args is not null)
{
// result = lua 的 CheckEnum(result, 39, 0)(1 字节)。0 = 成功;非 0 是**客户端自己
// 认为传送没成功**的唯一信号,所以非 0 单独用 Warn 打出来,别混在 Info 里被刷掉。
if (args.result == 0)
conn.Log.Info($"[TELEPORT] ReportPostTeleportFinish teleportId={args.teleportId} result=0(ok)");
else
conn.Log.Warn($"[TELEPORT] ReportPostTeleportFinish teleportId={args.teleportId} result={args.result}「非0 ⇒ 客户端认为传送未成功」");
}
else
{
conn.Log.Warn($"[TELEPORT] ReportPostTeleportFinish undecodable bytes={msg.Body.Length} "
+ $"hex={Convert.ToHexString(msg.Body ?? [], 0, Math.Min(msg.Body?.Length ?? 0, 16))}");
}
// ★★ 2026-09-30 四修:电梯落点兜底。
//
// 前三修把服务端侧做到了逐字节正确,客户端仍然一帧都不采纳(实测 14:19:12/:17/:22
// 三次 [INDOOR] indoorId=332 = 绿屿大厅,玩家原地)。所以这里不再依赖客户端的 loading
// 流程,直接用「已被实测证明有效」的硬设位置通道补最后一帧:
// 同一会话 14:16:48 后台瞬移 → (2627.48,188.74,2626.95) → 14:17:15 客户端报
// [INDOOR] 进入 indoorId=298(观澜公馆主体)⇒ 高楼层可达、通道有效。
//
// 落点由 AskEnterRaidByMapEntrance 在推 SyncTeleport 的同一步写进会话;
// 这里读一次就删(一趟事务只补一次)。
if (conn.Session.Items.TryGetValue(PendingJiguanLandingKey, out var rawLanding)
&& rawLanding is PendingJiguanLanding landing)
{
conn.Session.Items.Remove(PendingJiguanLandingKey);
if (PrivateServerConfigStore.Current.Gameplay.MapEntrance.JiguanHardSet)
{
// 立刻一帧:客户端若已经收尾完成就会直接生效。
await TeleportPlayerInstantAsync(msg.Context, landing.X, landing.Y, landing.Z,
landing.Facing, $"jiguan landing door={landing.DestEntranceId}");
// 500ms 后再补一帧:loading 面板还在淡出时第一帧会被吞掉(照 ture 分支
// ReportPostTeleportFinish 里 seat re-assert 的 retry 做法)。同坐标重复下发无副作用。
// ⛑ 这里用「会话级」发送 + CancellationToken.None,不用 msg.Context ——
// 本方法返回后 RpcContext 的 token 可能已被取消,延迟发送会直接失败。
_ = ReassertJiguanLandingAsync(conn.Session, ResolveActiveUnitId(conn.Session), landing);
conn.Log.Info($"[JIGUAN] 硬设落点 起点门={landing.SourceEntranceId} 落点门={landing.DestEntranceId} "
+ $"pos=({landing.X:F2},{landing.Y:F2},{landing.Z:F2}) facing={landing.Facing:F0} 原因=客户端不采纳 SyncTeleport");
}
else
{
conn.Log.Info($"[JIGUAN] 硬设落点 跳过 原因=jiguanHardSet=false "
+ $"落点=({landing.X:F2},{landing.Y:F2},{landing.Z:F2})(只推 SyncTeleport)");
}
}
// ★★ 2026-09-30:车库出库落点兜底(与上面景点电梯四修同一个套路)。
// 出库事务(AskTeleport config=7 beforeResName=loading_garage_out*)的落点客户端自己
// 算不出来:HouseConfig.GarageOutWaypoint 是 Spoon 航点 id,服务端没有 Spoon 位置表;
// 而 ExitTimeline 只有 1 个元素的房(素光小筑)客户端连 Spoon 反查都不做 ⇒
// afterPosition 全零。服务端在 AskTeleportFromHouseGarage 推了 SyncTeleport +
// VisitorExit 落点,这里再过场收尾那一帧硬设一次玩家 + 车辆位置(客户端不一定采纳
// SyncTeleport 的 Position,见景点电梯三/四修)。
if (conn.Session.Items.TryGetValue(PendingGarageLandingKey, out var rawGarageLanding)
&& rawGarageLanding is PendingGarageLanding garageLanding)
{
conn.Session.Items.Remove(PendingGarageLandingKey);
if (PrivateServerConfigStore.Current.Gameplay.HouseGarage.Enabled)
{
await TeleportPlayerInstantAsync(msg.Context, garageLanding.X, garageLanding.Y, garageLanding.Z,
garageLanding.Facing, $"garage-exit landing house={garageLanding.HouseId}");
if (garageLanding.VehicleId != 0)
{
await conn.NotifyAsync(MethodId.SyncTeleportVehicle, new SceneMethods.SyncTeleportVehicle4229938
{
EntityId = garageLanding.VehicleId,
Position = new SceneMethods.UxVector3(garageLanding.X, garageLanding.Y, garageLanding.Z),
Rotation = new SceneMethods.UxVector3(0f, garageLanding.Facing, 0f),
Velocity = 0f,
Reset = true,
MoveToken = 0,
});
}
// 500ms 后再补一帧(loading 面板淡出时第一帧会被吞掉,同景点电梯四修)。
// ⛑ 用会话级发送 + CancellationToken.None —— 本方法返回后 msg.Context 的
// token 可能已取消,延迟发送会直接失败。
_ = ReassertGarageLandingAsync(conn.Session, ResolveActiveUnitId(conn.Session), garageLanding);
conn.Log.Info($"[GARAGE] 出库硬设落点 house={garageLanding.HouseId} "
+ $"pos=({garageLanding.X:F2},{garageLanding.Y:F2},{garageLanding.Z:F2}) "
+ $"facing={garageLanding.Facing:F0} 车={garageLanding.VehicleId}");
}
}
}
/// <summary>
/// 延迟补发一帧落点(<c>jiguanHardSet</c> 的第二发)。失败只记日志,不影响主流程。
/// 走 <see cref="TcpSession.NotifyAsync(uint, byte[], CancellationToken)"/> +
/// <c>CancellationToken.None</c>:本方法的调用者(handler)已经返回,请求上下文可能已失效。
/// </summary>
private static async Task ReassertJiguanLandingAsync(
TcpSession session, ulong unitId, PendingJiguanLanding landing)
{
try
{
await Task.Delay(500);
// 手动序列化:TcpSession 只有 byte[] 重载,而且这里必须显式传 CancellationToken.None
// (见方法注释)。
var payload = WorldCodec.PositionAndFacing(unitId, new Vec3(landing.X, landing.Y, landing.Z), landing.Facing);
await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
UxSerializer.Serialize(payload), CancellationToken.None);
session.Log.Info($"[JIGUAN] 硬设落点 补发 door={landing.DestEntranceId} unit={unitId} "
+ $"pos=({landing.X:F2},{landing.Y:F2},{landing.Z:F2})");
}
catch (Exception ex)
{
session.Log.Warn($"[JIGUAN] 硬设落点 补发失败: {ex.Message}");
}
}
/// <summary>
/// 延迟补发一帧车库出库落点(<c>ReportPostTeleportFinish</c> 硬设之后 500ms 的第二发)。
/// 与景点电梯四修同一套路:loading 面板还在淡出时第一帧会被吞掉。
/// 同坐标重复下发无副作用;失败只记日志,不影响主流程。
/// 走 <see cref="TcpSession.NotifyAsync(uint, byte[], CancellationToken)"/> +
/// <c>CancellationToken.None</c>:调用它的 handler 已经返回,请求上下文可能已失效。
/// </summary>
private static async Task ReassertGarageLandingAsync(
TcpSession session, ulong unitId, PendingGarageLanding landing)
{
try
{
await Task.Delay(500);
var payload = WorldCodec.PositionAndFacing(unitId, new Vec3(landing.X, landing.Y, landing.Z), landing.Facing);
await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
UxSerializer.Serialize(payload), CancellationToken.None);
if (landing.VehicleId != 0)
{
await session.NotifyAsync(MethodId.SyncTeleportVehicle,
UxSerializer.Serialize(new SceneMethods.SyncTeleportVehicle4229938
{
EntityId = landing.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,
}), CancellationToken.None);
}
session.Log.Info($"[GARAGE] 出库硬设落点 补发 house={landing.HouseId} unit={unitId} "
+ $"pos=({landing.X:F2},{landing.Y:F2},{landing.Z:F2}) 车={landing.VehicleId}");
}
catch (Exception ex)
{
session.Log.Warn($"[GARAGE] 出库硬设落点 补发失败: {ex.Message}");
}
}
/// <summary>
/// 当前该用哪个单位 id 发 <c>SyncUnitPositionAndFacing</c>。
/// 与 <see cref="TeleportPlayerInstantAsync"/> 取同一个来源,保证补发那一帧打在同一实体上。
/// </summary>
private static ulong ResolveActiveUnitId(TcpSession session)
{
var state = GetStateIfExists(session);
if (state is null)
return Profile.InitialUnitId;
lock (state.SyncRoot)
return state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
}
// GM service Teleport(x, z): UXRPCMethodArgs62733679. Log it and treat it as a
// same-map XZ jump hint (Y stays at the last reported height).
[Handler(MethodId.Teleport)]
private async Task GmTeleportXZ(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.GmTeleportXZArgs>(out var args) && args is not null)
{
conn.Log.Info($"[TELEPORT] GM Teleport x={args.x:F1} z={args.z:F1}");
var state = GetWorldState(msg.Context);
float y;
lock (state.SyncRoot)
y = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerPosition.Y : 0f;
await TeleportPlayerInstantAsync(msg.Context, args.x, y, args.z, null, "gm-Teleport-xz");
}
else
{
conn.Log.Warn($"[TELEPORT] GM Teleport undecodable bytes={msg.Body.Length}");
}
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// Instant move without a loading screen: SyncUnitPositionAndFacing (68030312) with the default
/// SetPositionType.SwitchSpirit (3). Updates the tracked transform optimistically.
///
/// ⚠️ Not SyncUnitPosition_P (68233969): that one is refused for the local avatar —
/// NetworkGhostModule.ForceSetPositionAndFacing needs NetworkRoleType.Proxy, and the main
/// character is not a proxy (Player.log: "Role(None)不可强设位置!!").
/// </summary>
internal static async Task TeleportPlayerInstantAsync(
RpcContext ctx, float x, float y, float z, float? facing, string reason)
{
var state = GetWorldStateStatic(ctx);
ulong unitId;
float face;
lock (state.SyncRoot)
{
unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
face = facing ?? (state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : Profile.WorldFacing);
state.LastReportedPlayerPosition = new Vec3(x, y, z);
state.LastReportedPlayerRotation = new Vec3(0f, face, 0f);
state.HasLastReportedPlayerTransform = true;
}
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing, WorldCodec.PositionAndFacing(unitId, new Vec3(x, y, z), face));
ctx.Session.Log.Info($"[TELEPORT] instant reason={reason} unit={unitId} pos=({x:F2},{y:F2},{z:F2}) facing={face:F2}");
}
/// <summary>
/// Loading-flow move: SyncPreTeleportOption (64167855) + SyncTeleport (64030755)
/// with a fresh teleport id. The client is expected to answer with
/// ReportPreTeleportFinish / ReportPostTeleportFinish (logged by handlers above).
/// </summary>
internal static async Task<ulong> TeleportPlayerWithLoadingAsync(
RpcContext ctx, float x, float y, float z, float facing, string reason)
{
var state = GetWorldStateStatic(ctx);
ulong unitId;
ulong teleportId;
lock (state.SyncRoot)
{
unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
teleportId = (ulong)Interlocked.Increment(ref s_teleportIdCounter);
state.PendingTeleportId = teleportId;
state.PendingTeleportPreFinished = false;
state.PendingTeleportSyncSent = true;
state.LastReportedPlayerPosition = new Vec3(x, y, z);
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
state.HasLastReportedPlayerTransform = true;
}
var pre = new GameMethods.PreTeleportOption
{
configId = 0,
teleportId = teleportId,
ForceClear = false,
TimeLineDration = 0,
position = new GameMethods.TeleportVec3(x, y, z),
facing = facing,
beforeResName = string.Empty,
customBeforeTrans = false,
beforePosition = new GameMethods.TeleportVec3(x, y, z),
beforeRot = new GameMethods.TeleportVec3(0f, facing, 0f),
loadingResName = string.Empty,
afterResName = string.Empty,
customAfterTrans = false,
afterPosition = new GameMethods.TeleportVec3(x, y, z),
afterRot = new GameMethods.TeleportVec3(0f, facing, 0f),
extParams = string.Empty,
};
await ctx.NotifyAsync(MethodId.SyncPreTeleportOption, pre);
await ctx.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption
{
teleportId = teleportId,
Position = new GameMethods.TeleportVec3(x, y, z),
Facing = facing,
IsSwitchScene = false,
WaitTaskResource = false,
MapEntranceId = 0,
});
ctx.Session.Log.Info($"[TELEPORT] loading-flow reason={reason} unit={unitId} teleportId={teleportId} pos=({x:F2},{y:F2},{z:F2}) facing={facing:F2}");
return teleportId;
}
private static long s_teleportIdCounter = 900_000;
private static WorldEntryState GetWorldStateStatic(RpcContext ctx)
{
if (ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing)
return existing;
var created = new WorldEntryState();
ctx.Session.Items[WorldStateKey] = created;
return created;
}
}