Files

945 lines
51 KiB
C#
Raw Permalink Normal View History

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