using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// S011 vehicle boarding story channel (build 4229938). Ported from the proven /// Server_V2 flow: the client only offers the F-enter prompt and drives the /// enter/exit animation phases once the S011 root node exists; without the root /// bootstrap, pressing F near a spawned vehicle sends nothing. /// Flow: root bootstrap (once per session) → client S011PlayerNetEnterVehicleRequest → /// enter child + controller + status 2 → phase 2 (status 3) → phase 3 (status 4, /// seated) → exit request → exit child + status 5 → phase 1 (status 0, done). /// Vehicle records live in the shared static SummonedVehicles registry (extended /// with seat state); the boarding transaction itself is per-session. /// Lock order everywhere: story state, then SummonedVehiclesSync. /// internal sealed partial class GameRouter { private const string VehicleStoryKey = "client4229938-vehicle-story"; private const uint VehicleStoryRootNid = 1; private const uint VehicleStoryEnterNid = 2; private const uint VehicleStoryExitNid = 3; internal sealed class VehicleStoryState { internal object SyncRoot { get; } = new(); internal bool RootSent; internal bool SeenAnyRpc; internal long HighestConfirmedRpcId = -1; internal SortedSet PendingRpcIds { get; } = new(); internal HashSet CommittedRpcIds { get; } = new(); internal ulong EnterVehicleId; internal byte EnterSeat; internal bool ExitInProgress; internal ulong CurrentVehicleId; internal int CurrentSeat = -1; internal ulong BoardingUnitId; } internal static VehicleStoryState GetVehicleStoryState(TcpSession session) { if (session.Items.TryGetValue(VehicleStoryKey, out var raw) && raw is VehicleStoryState existing) return existing; var created = new VehicleStoryState(); session.Items[VehicleStoryKey] = created; return created; } internal static WorldEntryState? GetVehicleWorldState(TcpSession session) => session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing ? existing : null; internal static (ulong VehicleId, int Seat) VehicleSeatSnapshot(TcpSession session) { var story = GetVehicleStoryState(session); lock (story.SyncRoot) return (story.CurrentVehicleId, story.CurrentSeat); } internal static async Task EnsureVehicleStoryRoot(RpcContext ctx) { if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled) return; var story = GetVehicleStoryState(ctx.Session); long confirm; lock (story.SyncRoot) { if (story.RootSent) return; story.RootSent = true; story.BoardingUnitId = GetVehicleWorldState(ctx.Session)?.ActiveSpiritUnitId ?? Profile.InitialUnitId; confirm = story.SeenAnyRpc ? Math.Max(0, story.HighestConfirmedRpcId) : -1; } await ctx.NotifyAsync(MethodId.SyncStoryCoreServerInfo, StoryS011Codec4229938.BuildRootBootstrap(VehicleStoryRootNid, confirm)); ctx.Session.Log.Info($"[VEHICLE-STORY] S011 root ready nid={VehicleStoryRootNid} confirm={confirm}"); } internal static void ResetVehicleStoryForEntity(TcpSession session, ulong entityId) { var story = GetVehicleStoryState(session); lock (story.SyncRoot) { if (story.CurrentVehicleId == entityId) { story.CurrentVehicleId = 0; story.CurrentSeat = -1; } if (story.EnterVehicleId == entityId) story.EnterVehicleId = 0; story.ExitInProgress = false; } } /// /// Fresh scene load (login / airport travel): the client builds a new story /// instance and drops all server-made nodes, so the S011 root must be sent /// again by the post-load finalization. Called from OnLoadingFinished for each /// accepted generation, next to the garage/aether one-shot resets. The early /// root pushed on the pre-load story hello (if any) is harmless — the client /// discards it with the old story instance. /// internal static void ResetVehicleStory(TcpSession session) { var story = GetVehicleStoryState(session); lock (story.SyncRoot) { story.RootSent = false; story.SeenAnyRpc = false; story.HighestConfirmedRpcId = -1; story.PendingRpcIds.Clear(); story.CommittedRpcIds.Clear(); story.EnterVehicleId = 0; story.EnterSeat = 0; story.ExitInProgress = false; story.CurrentVehicleId = 0; story.CurrentSeat = -1; } } [Handler(MethodId.SyncStoryCoreClientInfo, HandlerPacketKind.Notify)] private async Task SyncStoryCoreClientInfo(Connection conn, UxRpcMessage msg) { await EnsureVehicleStoryRoot(msg.Context); var story = GetVehicleStoryState(msg.Context.Session); lock (story.SyncRoot) story.BoardingUnitId = GetVehicleWorldState(msg.Context.Session)?.ActiveSpiritUnitId ?? Profile.InitialUnitId; var commands = StoryVehicleCodec4229938.Parse(msg.Body, out var full); if (!full) conn.Log.Warn($"[VEHICLE-STORY] partial parse commands={commands.Count} bytes={msg.Body.Length} hex={BitConverter.ToString(msg.Body)}"); foreach (var summary in commands) { // Non-heartbeat story traffic is rare; log it so new flows are visible. if (summary.Name != "Rpc3" || summary.RpcId < 0) conn.Log.Info($"[VEHICLE-STORY] cmd mark={summary.TypeMark} nid={summary.Nid} name={summary.Name} rpc={summary.RpcId} payload={summary.PayloadKind} vehicle={summary.PayloadVehicleId} seats=[{(summary.PayloadSeatIndices is null ? "" : string.Join(",", summary.PayloadSeatIndices))}] bool={summary.PayloadBool} int={summary.PayloadInt}"); } foreach (var cmd in commands) { var duplicateCommitted = false; long duplicateConfirm = -1; if (cmd.HasRpcId && cmd.RpcId >= 0) { lock (story.SyncRoot) { story.SeenAnyRpc = true; if (cmd.RpcId <= story.HighestConfirmedRpcId) { duplicateCommitted = true; duplicateConfirm = story.HighestConfirmedRpcId; } else { story.PendingRpcIds.Add(cmd.RpcId); } } } if (duplicateCommitted) { await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildConfirmRpc(duplicateConfirm)); continue; } if (cmd.TypeMark is 3 or 6) { await ConfirmVehicleStoryRpc(conn, story, cmd.HasRpcId ? cmd.RpcId : null); continue; } if (cmd.Name == "S011PlayerNetEnterVehicleRequest" && cmd.PayloadKind == "S011EnterVehicleRequest") await BeginVehicleEnter(conn, story, cmd); else if (cmd.Name == "S011PlayerNetExitVehicleRequest" && cmd.PayloadKind == "Bool") await BeginVehicleExit(conn, story, cmd.PayloadBool ?? true); else if (cmd.Nid == VehicleStoryEnterNid && cmd.Name == "MsgrChangePhaseRequest" && cmd.PayloadKind is "Byte" or "Int") await AdvanceVehicleEnter(conn, story, (int)(cmd.PayloadInt ?? 0)); else if (cmd.Nid == VehicleStoryExitNid && cmd.Name == "MsgrChangePhaseRequest" && cmd.PayloadKind is "Byte" or "Int") await AdvanceVehicleExit(conn, story, (int)(cmd.PayloadInt ?? 0)); await ConfirmVehicleStoryRpc(conn, story, cmd.HasRpcId ? cmd.RpcId : null); } } private async Task BeginVehicleEnter(Connection conn, VehicleStoryState story, StoryClientCommand4229938 cmd) { var vehicleId = cmd.PayloadVehicleId ?? 0; var seat = cmd.PayloadSeatIndices is { Count: > 0 } ? cmd.PayloadSeatIndices[0] : (byte)0; var pid = Profile.PlayerPid; ulong unitId; var ok = false; var lazyRegistered = false; lock (story.SyncRoot) { unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId; lock (SummonedVehiclesSync) { // GM-console cars are spawned client-locally with client-side ids the // server has never seen: adopt them on first enter attempt instead of // leaving the client stuck with a hidden prompt. Seat layout is grown // from the requested index; real counts arrive via SyncSpawnVehicle. if (vehicleId != 0 && !SummonedVehicles.ContainsKey(vehicleId)) { SummonedVehicles[vehicleId] = new SummonedVehicle { SeatCount = (int)Math.Min(16, Math.Max(2, (int)seat + 1)), Interactable = true, }; lazyRegistered = true; } if (vehicleId != 0 && story.CurrentVehicleId == 0 && story.EnterVehicleId == 0 && !story.ExitInProgress && SummonedVehicles.TryGetValue(vehicleId, out var vehicle) && vehicle.Interactable && seat < vehicle.SeatCount && !vehicle.SeatReservations.ContainsKey(seat) && !vehicle.SeatOccupants.ContainsKey(seat)) { vehicle.SeatReservations[seat] = pid; story.EnterVehicleId = vehicleId; story.EnterSeat = seat; vehicle.ControllerPid = pid; ok = true; } } } if (!ok) { conn.Log.Warn($"[VEHICLE-STORY] enter rejected vehicle={vehicleId} seat={seat}"); return; } await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildEnterChildBootstrap(VehicleStoryEnterNid, vehicleId, seat)); await conn.NotifyAsync(MethodId.SyncChangeVehicleController, new SceneMethods.SyncChangeVehicleController { VehicleInstanceId = vehicleId, ControllerPid = pid }); await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 2); conn.Log.Info($"[VEHICLE-STORY] enter begin vehicle={vehicleId} seat={seat} unit={unitId} status=2 lazy={lazyRegistered}"); await ForceEnterVehicleAsync(conn.Session, vehicleId); } private async Task AdvanceVehicleEnter(Connection conn, VehicleStoryState story, int phase) { ulong vehicleId; byte seat; ulong unitId; lock (story.SyncRoot) { vehicleId = story.EnterVehicleId; seat = story.EnterSeat; unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId; } if (vehicleId == 0) return; switch (phase) { case 0: case 1: return; case 2: { var committed = false; lock (SummonedVehiclesSync) { if (SummonedVehicles.TryGetValue(vehicleId, out var vehicle) && vehicle.SeatReservations.TryGetValue(seat, out var owner) && owner == Profile.PlayerPid && !vehicle.SeatOccupants.ContainsKey(seat)) { vehicle.SeatReservations.Remove(seat); vehicle.SeatOccupants[seat] = Profile.PlayerPid; committed = true; } } if (!committed) { conn.Log.Warn($"[VEHICLE-STORY] enter phase=2 commit failed vehicle={vehicleId} seat={seat}"); return; } await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 3); conn.Log.Info($"[VEHICLE-STORY] enter phase=2 vehicle={vehicleId} seat={seat} status=3"); return; } case 3: await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 4); await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid)); lock (story.SyncRoot) { story.CurrentVehicleId = vehicleId; story.CurrentSeat = seat; story.EnterVehicleId = 0; lock (SummonedVehiclesSync) { if (SummonedVehicles.TryGetValue(vehicleId, out var v)) v.ControllerPid = Profile.PlayerPid; } } conn.Log.Info($"[VEHICLE-STORY] enter complete vehicle={vehicleId} seat={seat} status=4"); return; case 4: await SendVehicleBoardingStatus(conn, unitId, vehicleId, 0, 0); await conn.NotifyAsync(MethodId.SyncChangeVehicleController, new SceneMethods.SyncChangeVehicleController { VehicleInstanceId = vehicleId, ControllerPid = 0 }); await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid)); lock (story.SyncRoot) { story.EnterVehicleId = 0; lock (SummonedVehiclesSync) { if (SummonedVehicles.TryGetValue(vehicleId, out var v)) { if (v.SeatReservations.TryGetValue(seat, out var reservedBy) && reservedBy == Profile.PlayerPid) v.SeatReservations.Remove(seat); if (v.SeatOccupants.TryGetValue(seat, out var occupiedBy) && occupiedBy == Profile.PlayerPid) v.SeatOccupants.Remove(seat); v.ControllerPid = 0; } } } conn.Log.Info($"[VEHICLE-STORY] enter cancelled vehicle={vehicleId}"); return; } } private async Task BeginVehicleExit(Connection conn, VehicleStoryState story, bool stopBeforeLeave) { ulong vehicleId; byte seat; ulong unitId; lock (story.SyncRoot) { vehicleId = story.CurrentVehicleId; seat = (byte)Math.Max(0, story.CurrentSeat); unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId; if (vehicleId == 0 || story.ExitInProgress) return; story.ExitInProgress = true; } await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildExitChildBootstrap(VehicleStoryExitNid, vehicleId, seat, stopBeforeLeave)); await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 5); conn.Log.Info($"[VEHICLE-STORY] exit begin vehicle={vehicleId} seat={seat} status=5"); } private async Task AdvanceVehicleExit(Connection conn, VehicleStoryState story, int phase) { ulong vehicleId; byte seat; ulong unitId; lock (story.SyncRoot) { vehicleId = story.CurrentVehicleId; seat = (byte)Math.Max(0, story.CurrentSeat); unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId; } if (vehicleId == 0) return; if (phase == 0) return; if (phase == 1) { await SendVehicleBoardingStatus(conn, unitId, vehicleId, 0, 0); await conn.NotifyAsync(MethodId.SyncChangeVehicleController, new SceneMethods.SyncChangeVehicleController { VehicleInstanceId = vehicleId, ControllerPid = 0 }); await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryExitNid)); lock (story.SyncRoot) { story.CurrentVehicleId = 0; story.CurrentSeat = -1; story.ExitInProgress = false; lock (SummonedVehiclesSync) { if (SummonedVehicles.TryGetValue(vehicleId, out var v)) { if (v.SeatOccupants.TryGetValue(seat, out var occupiedBy) && occupiedBy == Profile.PlayerPid) v.SeatOccupants.Remove(seat); if (v.SeatReservations.TryGetValue(seat, out var reservedBy) && reservedBy == Profile.PlayerPid) v.SeatReservations.Remove(seat); v.ControllerPid = 0; v.Interactable = true; } } } conn.Log.Info($"[VEHICLE-STORY] exit complete vehicle={vehicleId} status=0"); } else if (phase == 2) { await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 4); await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryExitNid)); lock (story.SyncRoot) story.ExitInProgress = false; conn.Log.Info($"[VEHICLE-STORY] exit cancelled vehicle={vehicleId} status=4"); } } [Handler(MethodId.AskClaimVehicleSeat, HandlerPacketKind.Invoke)] private async Task AskClaimVehicleSeatStory(Connection conn, UxRpcMessage msg) { ulong vehicleId = 0; byte selected = 0; if (msg.TryGetArgs(out var args) && args is not null) { vehicleId = args.VehicleEntityId; if (args.SeatIndices is { Count: > 0 }) selected = args.SeatIndices[0]; } else { vehicleId = LastSummonedEntity(); } var story = GetVehicleStoryState(msg.Context.Session); lock (story.SyncRoot) { story.EnterVehicleId = vehicleId; story.EnterSeat = selected; } conn.Log.Info($"[VEHICLE-STORY] seat claim vehicle={vehicleId} seat={selected}"); await conn.ReturnAsync(msg, selected); await ForceEnterVehicleAsync(conn.Session, vehicleId); } private Task SendVehicleBoardingStatus(Connection conn, ulong unitId, ulong vehicleId, byte seat, byte status) => conn.NotifyAsync(MethodId.SyncUnitVehicleStatus, new SceneMethods.NewClientBoardingInfo { EntityId = unitId, Status = status, VehicleUId = vehicleId, SeatIndex = seat, ExtInfo = null, }); /// /// Server-forced enter (debug panel path): seats the player without any client /// F-press. Mirrors the enter-complete leg — move-to-seat, controller, boarding /// status seated, start/state-change/finish drive-state notifies — plus an S011 /// enter child so the story side agrees, deleted right after like a phase-3 commit. /// internal static async Task<(bool Ok, string Message)> ForceEnterVehicleAsync(TcpSession session, ulong entityId = 0) { var story = GetVehicleStoryState(session); var unitId = GetVehicleWorldState(session)?.ActiveSpiritUnitId ?? Profile.InitialUnitId; var pid = Profile.PlayerPid; ulong vehicleId = entityId != 0 ? entityId : LastSummonedEntity(); const byte seat = 0; lock (story.SyncRoot) { lock (SummonedVehiclesSync) { if (vehicleId == 0 || !SummonedVehicles.TryGetValue(vehicleId, out var vehicle)) return (false, "no spawned vehicle (spawn one first)"); if (story.CurrentVehicleId != 0) return (false, $"already in vehicle {story.CurrentVehicleId} (exit first)"); if (!vehicle.Interactable || vehicle.SeatOccupants.ContainsKey(seat)) return (false, "vehicle is not enterable right now"); vehicle.SeatReservations.Remove(seat); vehicle.SeatOccupants[seat] = pid; vehicle.ControllerPid = pid; story.CurrentVehicleId = vehicleId; story.CurrentSeat = seat; story.EnterVehicleId = 0; } } var drive = new SceneMethods.PlayerVehicleDriveStateInfo { Pid = pid, EnterOrLeave = true, VehicleEntityId = vehicleId, SeatIndex = seat, IfForce = true, OpenDoorTypeId = 0, OpenDoorActionSpeed = 0, OpenDoorActionClipLength = 0, }; try { var ct = CancellationToken.None; await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize( new SceneMethods.SyncChangeVehicleInteractable { VehicleInstanceId = vehicleId, Interactable = false }), ct); await session.NotifyAsync(MethodId.SyncPlayerMoveToDriveSeat, UxSerializer.Serialize( new SceneMethods.SyncPlayerMoveToDriveSeat { Pid = pid, VehicleEntityId = vehicleId }), ct); await session.NotifyAsync(MethodId.SyncUnitVehicleStatus, UxSerializer.Serialize( new SceneMethods.NewClientBoardingInfo { EntityId = unitId, Status = 4, VehicleUId = vehicleId, SeatIndex = seat, ExtInfo = null }), ct); await session.NotifyAsync(MethodId.SyncPlayerStartEnterOrExitVehicl, UxSerializer.Serialize(drive), ct); await session.NotifyAsync(MethodId.SyncPlayerVehicleStateChange, UxSerializer.Serialize(drive), ct); await session.NotifyAsync(MethodId.SyncChangeVehicleController, UxSerializer.Serialize( new SceneMethods.SyncChangeVehicleController { VehicleInstanceId = vehicleId, ControllerPid = pid }), ct); await session.NotifyAsync(MethodId.SyncPlayerFinishEnterOrExitVehic, UxSerializer.Serialize(drive), ct); await SendStoryAsync(session, StoryS011Codec4229938.BuildEnterChildBootstrap(VehicleStoryEnterNid, vehicleId, seat)); session.Log.Info($"[VEHICLE-STORY] force enter vehicle={vehicleId} seat={seat} unit={unitId} status=4"); return (true, $"seated in vehicle {vehicleId} (seat 0) — drive with normal keys, exit with F or the Exit button"); } catch (Exception ex) { return (false, $"enter failed: {ex.Message}"); } } /// Server-forced exit (debug panel path). internal static async Task<(bool Ok, string Message)> ForceExitVehicleAsync(TcpSession session) { var story = GetVehicleStoryState(session); var unitId = GetVehicleWorldState(session)?.ActiveSpiritUnitId ?? Profile.InitialUnitId; ulong vehicleId; lock (story.SyncRoot) { vehicleId = story.CurrentVehicleId; if (vehicleId == 0) return (false, "not in a vehicle"); } try { var ct = CancellationToken.None; await session.NotifyAsync(MethodId.SyncPlayerExitVehicle, UxSerializer.Serialize( new SceneMethods.SyncPlayerExitVehicle { VehicleEntityId = vehicleId, Force = true, StopBeforeLeave = true }), ct); await session.NotifyAsync(MethodId.SyncUnitVehicleStatus, UxSerializer.Serialize( new SceneMethods.NewClientBoardingInfo { EntityId = unitId, Status = 0, VehicleUId = vehicleId, SeatIndex = 0, ExtInfo = null }), ct); await session.NotifyAsync(MethodId.SyncChangeVehicleController, UxSerializer.Serialize( new SceneMethods.SyncChangeVehicleController { VehicleInstanceId = vehicleId, ControllerPid = 0 }), ct); await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize( new SceneMethods.SyncChangeVehicleInteractable { VehicleInstanceId = vehicleId, Interactable = true }), ct); await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid)); await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryExitNid)); lock (story.SyncRoot) { story.CurrentVehicleId = 0; story.CurrentSeat = -1; story.EnterVehicleId = 0; story.ExitInProgress = false; lock (SummonedVehiclesSync) { if (SummonedVehicles.TryGetValue(vehicleId, out var v)) { v.SeatOccupants.Clear(); v.SeatReservations.Clear(); v.ControllerPid = 0; v.Interactable = true; } } } session.Log.Info($"[VEHICLE-STORY] force exit vehicle={vehicleId}"); return (true, $"exited vehicle {vehicleId}"); } catch (Exception ex) { return (false, $"exit failed: {ex.Message}"); } } private async Task ConfirmVehicleStoryRpc(Connection conn, VehicleStoryState story, long? commitRpcId) { long highest; lock (story.SyncRoot) { if (commitRpcId is long id && id >= 0) story.CommittedRpcIds.Add(id); highest = story.HighestConfirmedRpcId; while (story.PendingRpcIds.Count > 0) { var next = highest + 1; if (next >= 0 && story.CommittedRpcIds.Contains(next)) { highest = next; story.PendingRpcIds.Remove(next); story.CommittedRpcIds.Remove(next); } else break; } if (highest <= story.HighestConfirmedRpcId) return; story.HighestConfirmedRpcId = highest; } await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildConfirmRpc(highest)); } private static Task SendStoryAsync(TcpSession session, byte[] payload) => session.NotifyAsync(MethodId.SyncStoryCoreServerInfo, payload, CancellationToken.None); }