using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Handlers; using Ananta.Server.Protocol.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// Lifts (MobilePlatform). /// /// Why lifts never worked here: the client's call button is only a *request*. The cab actually /// moves when the server echoes SyncMobilePlatformInfo(68705244) back — without it the /// press is acked "ok" into the void and nothing happens. Handoff round 16B identified this and /// 16F confirmed it live (Bassline 808: full GetOn/Move/GetOff round trip, error=0) once the body /// stopped being rejected — see the wire note on /// for the one-byte count bug that doomed the /// first attempt. /// /// Two pushes per ride, matching the donor's proven sequence: /// 1. departure — CurLevel=from, TgtLevel=target (the cab starts moving); /// 2. arrival — CurLevel=TgtLevel=target after the travel delay. /// /// The cab position is the player's last reported transform: we have no authored floor heights, and /// the donor used the same anchor. The client keeps its own shaft geometry. /// internal sealed partial class GameRouter { private const string ElevatorKey = "client4229938-elevators"; internal sealed class ElevatorSessionState { internal object SyncRoot { get; } = new(); /// platformId -> the level the cab is parked at. internal Dictionary Level { get; } = new(); internal int Rides; } private static ElevatorSessionState ElevatorStateOf(TcpSession session) { if (session.Items.TryGetValue(ElevatorKey, out var existing) && existing is ElevatorSessionState typed) return typed; var created = new ElevatorSessionState(); session.Items[ElevatorKey] = created; return created; } /// /// Client invoke AskMoveMobilePlatform(63185921): (ulong mobilePlatformId, int targetIndex). /// [Handler(MethodId.AskMoveMobilePlatform, HandlerPacketKind.Invoke)] private async Task OnAskMoveMobilePlatform(Connection conn, UxRpcMessage msg) { var cfg = PrivateServerConfigStore.Current.World.Content.Elevators; if (!cfg.Enabled) { await conn.ReturnEmptyOkAsync(msg); return; } if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[ELEVATOR] AskMoveMobilePlatform undecodable bytes={msg.Body.Length} " + $"hex={Convert.ToHexString(msg.Body)}"); await conn.ReturnEmptyOkAsync(msg); return; } var owner = ElevatorStateOf(conn.Session); int from; lock (owner.SyncRoot) { // ⛑️ Never default the cab's floor to 0. Floor tables are per-lift and this one has no // floor 0: Player.log 22:42:59 showed // [error]LiftComponent GetFloorPos NotExistFloorData 0 11034023767650314226 // at LiftComponent:GetFloorPos(Int32) <- LiftInfo:RefreshMoveInfo <- LiftMgr:RefreshLiftState // and the whole refresh aborted there, so the cab never moved and the doors never ran // their state machine. With no history, treat the cab as already being on the floor the // player is asking for (the F-press is a hall call): start == target is a legal "here // it is" refresh, 0 is not. from = owner.Level.TryGetValue(args.mobilePlatformId, out var lvl) ? lvl : args.targetIndex; owner.Level[args.mobilePlatformId] = args.targetIndex; owner.Rides++; } conn.Log.Info($"[ELEVATOR] move platform={args.mobilePlatformId} from={from} to={args.targetIndex}"); await PushPlatformAsync(conn, args.mobilePlatformId, from, args.targetIndex, force: false, doorMode: 0); // Normal while the cab is travelling: doors shut. // Arrival. Sent on its own clock so the ride is visible instead of instantaneous; the // donor's second push is what finally parks the cab and lets GetOff succeed. // ⛑️ Arrival carries cfg.ArrivalDoorMode (default StayOpen=1): the F-press is only a hall // call, there is no door RPC, and LiftMgr keeps its own per-lift door mode — parking with // Normal leaves the doors shut and the player can never step in. var travel = Math.Clamp(cfg.TravelMs, 200, 15000); var arrivalDoorMode = cfg.ArrivalDoorMode; var openDoors = cfg.OpenDoorsOnArrival; _ = Task.Run(async () => { try { await Task.Delay(travel); await PushPlatformAsync(conn, args.mobilePlatformId, args.targetIndex, args.targetIndex, force: false, doorMode: arrivalDoorMode); // ⛑️ The door is a SEPARATE rpc. Parking the cab is not enough: LiftMgr holds a // per-lift LiftDoorMode of its own and defaults to shut. Ask for StayOpen right // after arrival (SyncLiftDoorMode 68799106, 9 bytes) — see the note on // SyncLiftDoorModeArgs4229938 for the dump + lua evidence. if (openDoors) { await PushDoorModeAsync(conn, args.mobilePlatformId, arrivalDoorMode); } } catch (ObjectDisposedException) { } catch (Exception ex) { conn.Log.Info($"[ELEVATOR] arrival push failed: {ex.Message}"); } }); await conn.ReturnEmptyOkAsync(msg); } /// Client invoke AskGetOnMobilePlatform(63687984): (ulong mobilePlatformId). [Handler(MethodId.AskGetOnMobilePlatform, HandlerPacketKind.Invoke)] private async Task OnAskGetOnMobilePlatform(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { conn.Log.Info($"[ELEVATOR] get-on platform={args.mobilePlatformId}"); // Restate where the cab currently is: the client asks right before it rides. var owner = ElevatorStateOf(conn.Session); int level; lock (owner.SyncRoot) { // Same law as the hall call: 0 is not a floor this lift has (see the // NotExistFloorData evidence above), so fall back to the last known level and, if // there is none, leave the cab where the caller last asked it to be. level = owner.Level.TryGetValue(args.mobilePlatformId, out var lvl) ? lvl : 1; } // Get-on is the "I am stepping in" moment: hold the doors open. await PushPlatformAsync(conn, args.mobilePlatformId, level, level, force: false, doorMode: PrivateServerConfigStore.Current.World.Content.Elevators.ArrivalDoorMode); } else { conn.Log.Warn($"[ELEVATOR] AskGetOnMobilePlatform undecodable bytes={msg.Body.Length} " + $"hex={Convert.ToHexString(msg.Body)}"); } await conn.ReturnEmptyOkAsync(msg); } /// Client invoke AskGetOffMobilePlatform(63974264): no arguments. [Handler(MethodId.AskGetOffMobilePlatform, HandlerPacketKind.Invoke)] private Task OnAskGetOffMobilePlatform(Connection conn, UxRpcMessage msg) { conn.Log.Info("[ELEVATOR] get-off"); return conn.ReturnEmptyOkAsync(msg); } /// SyncLiftDoorMode(68799106) — the only thing that opens a lift door. private static async Task PushDoorModeAsync(Connection conn, ulong platformId, byte doorMode) { var body = UxSerializer.Serialize(new SceneMethods.SyncLiftDoorModeArgs4229938 { mobilePlatformId = platformId, doorMode = doorMode, }); await conn.Session.NotifyAsync(MethodId.SyncLiftDoorMode, body, CancellationToken.None); conn.Log.Info($"[ELEVATOR] door-mode platform={platformId} mode={doorMode} bytes={body.Length}"); } private static async Task PushPlatformAsync( Connection conn, ulong platformId, int curLevel, int tgtLevel, bool force, byte doorMode) { var state = GetStateStatic(conn.Session); float px; float py; float pz; lock (state.SyncRoot) { px = state.LastReportedPlayerPosition.X; py = state.LastReportedPlayerPosition.Y; pz = state.LastReportedPlayerPosition.Z; } if (!float.IsFinite(px) || !float.IsFinite(py) || !float.IsFinite(pz)) { px = py = pz = 0f; } var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var info = new SceneMethods.MobilePlatformSyncInfo { CurLevel = curLevel, TgtLevel = tgtLevel, StartTime = now, Players = [], CurLevelPos = new() { X = px, Y = py, Z = pz }, TgtLevelPos = new() { X = px, Y = py, Z = pz }, InteractBanInfo = null, DoorMode = doorMode, }; var body = UxSerializer.Serialize(new SceneMethods.SyncMobilePlatformInfoArgs4229938 { mobilePlatformId = platformId, mobilePlatformInfo = info, force = force, }); await conn.Session.NotifyAsync(MethodId.SyncMobilePlatformInfo, body, CancellationToken.None); conn.Log.Info($"[ELEVATOR] push platform={platformId} cur={curLevel} tgt={tgtLevel} " + $"doorMode={doorMode} pos=({px:F1},{py:F1},{pz:F1}) bytes={body.Length}"); } }