using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.Gameplay.Crowd; using Ananta.Server.Gameplay.Traffic; using Ananta.Server.Gameplay.World; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; internal sealed class TrafficAndCrowdDriver : ITrafficVehicleDriver, IUrbanCrowdDriver, IGadgetStreamDriver { public static TrafficAndCrowdDriver Instance { get; } = new(); public async Task SendGadgetGridAoiAsync( TcpSession session, IReadOnlyList<(int X, int Z)> cells, IReadOnlyList sectorIds, (int X, int Z) playerCell, bool firstPush) { var packet = new SceneMethods.SyncGadgetGridAOIDecrease4229938 { AoiInfo = new SceneMethods.GadgetGridAOIIncrease4229938 { PlayerStandardIndex = new SceneMethods.GadgetGridIndex4229938(playerCell.Item1, playerCell.Item2), IndexList = cells.Select(c => new SceneMethods.GadgetGridIndex4229938(c.X, c.Z)).ToList(), // Init on the first push of a session, AOIMove afterwards. Reason = firstPush ? (byte)0 : (byte)2, }, // Sector list comes from the baked cell map (CellSectors.json) so indoor cells // address their building's sector; falls back to outdoor sector 0. AddGridInfo = new SceneMethods.GridAOIDecrease4229938 { SectorIdList = sectorIds.ToList(), BuildingId = 0, StandardIndexList = cells.Select(c => new SceneMethods.GadgetGridIndex4229938(c.X, c.Z)).ToList(), }, }; await session.NotifyAsync(MethodId.SyncGadgetGridAOIDecrease, UxSerializer.Serialize(packet), CancellationToken.None); } public async Task SendSceneItemGridAoiAsync( TcpSession session, IReadOnlyList<(int X, int Z)> cells, (int X, int Z) playerCell, bool firstPush) { var packet = new SceneMethods.SyncDestructibleGridAOIIncrease4229938 { AoiInfo = new SceneMethods.DestructibleGridAOIIncrease4229938 { PlayerStandardIndex = new SceneMethods.GadgetGridIndex4229938(playerCell.Item1, playerCell.Item2), IndexList = cells.Select(c => new SceneMethods.GadgetGridIndex4229938(c.X, c.Z)).ToList(), Reason = firstPush ? (byte)0 : (byte)2, }, }; await session.NotifyAsync(MethodId.SyncDestructibleGridAOIIncrease, UxSerializer.Serialize(packet), CancellationToken.None); } public async Task<(bool Ok, ulong EntityId)> SpawnTrafficVehicleAsync( TcpSession session, uint configId, float x, float y, float z, float yaw) { var result = await GameRouter.SpawnDirectAsync( session, configId, new Vec3(x, y, z), yaw, rightOffset: 0f, sourceType: 2, reason: "city-traffic"); return (result.Ok, result.EntityId); } public async Task DestroyTrafficVehicleAsync(TcpSession session, ulong entityId) { await GameRouter.DestroyDirectAsync(session, entityId, "traffic-despawn"); } private readonly System.Collections.Concurrent.ConcurrentDictionary _moveTokens = new(); /// /// Reset flag for the SyncTeleportVehicle reposition channel. true mirrors the proven /// panel "To me" teleport semantics (hard snap each packet); false lets the client blend /// using the scalar Velocity. Toggle live via POST /api/traffic/config {"moveReset":false}. /// public static bool VehicleMoveReset = true; public async Task SendVehicleMoveAsync( TcpSession session, ulong entityId, float x, float y, float z, float yaw, float vx, float vz) { var token = _moveTokens.AddOrUpdate(entityId, 1, (_, cur) => unchecked(cur + 1)); var moveData = new SceneMethods.RaidVehicleSyncData { Id = entityId, Position = new SceneMethods.UxVector3(x, y, z), FacingDirection = yaw, EulerAngles = new SceneMethods.UxVector3(0f, yaw, 0f), Velocity = new SceneMethods.UxVector3(vx, 0f, vz), Bits = [1, 0, 0, 0], MoveToken = token, }; await session.NotifyAsync(MethodId.SyncVehicleMove, UxSerializer.Serialize(moveData), CancellationToken.None); // Channel B: SyncTeleportVehicle — the server->client reposition packet the client is // known to apply (same channel as the panel's "To me"). Sent alongside the mirror // packet above so whichever channel this client build honors moves the car. var teleport = new SceneMethods.SyncTeleportVehicle { EntityId = entityId, Position = new SceneMethods.UxVector3(x, y, z), Rotation = new SceneMethods.UxVector3(0f, yaw, 0f), Velocity = MathF.Sqrt(vx * vx + vz * vz), Reset = VehicleMoveReset, MoveToken = token, }; await session.NotifyAsync(MethodId.SyncTeleportVehicle, UxSerializer.Serialize(teleport), CancellationToken.None); GameRouter.TrackVehicleMove(entityId, x, y, z, yaw); } public async Task SpawnAetherVehicleAsync( TcpSession session, ulong entityId, uint configId, uint colorPartId, int laneHandle, float distanceAlongLane, float speed, double timestamp, float x, float y, float z, float yaw) { // Aether vehicles live only in the client's traffic simulation (VehicleControlType.AetherVehicle); // they are intentionally NOT registered as scene entities / SummonedVehicles. await session.NotifyAsync(MethodId.SyncAetherAIVehicleAddData, UxSerializer.Serialize( new SceneMethods.ClientVehicleInitData4229938 { Id = entityId, Position = new SceneMethods.UxVector3(x, y, z), Facing = yaw, EulerAngles = new SceneMethods.UxVector3(0f, yaw, 0f), VehicleConfigId = configId, VehicleColorPartId = colorPartId, VehicleLightState = 0, LaneHandle = laneHandle, DistanceAlongLane = distanceAlongLane, NextVehicleId = 0, Timestamp = timestamp, ControlType = 1, Speed = speed, DustRatio = 0f, Parts = [], SuitId = 0, RandomFraction = Random.Shared.NextSingle(), }), CancellationToken.None); } public async Task SpawnAetherVehiclesAsync(TcpSession session, IReadOnlyList vehicles) { // The 4229938 client only implements the SINGLE SyncAetherAIVehicleAddData (68916691); // a previous batch id (68571034) does not exist in the client's method table and its // packets were dropped, so flush each spawn as its own notify. // // Full sequence per vehicle (battle-tested in the PrivateServer1 pack): the LEGACY // scene-entity spawn (SyncLogicVehicleEnter + SyncSpawnVehicle) is what creates the // drive-layer BaseVehicle — without it AetherVehicleLifecycle flush drops every car // with "Flush dropped missing BaseVehicle". ChangeVehicleControl pins it to the // aether AI and ForceGo starts the drive immediately. foreach (var v in vehicles) { var pos = new SceneMethods.UxVector3(v.X, v.Y, v.Z); var euler = new SceneMethods.UxVector3(0f, v.Yaw, 0f); await session.NotifyAsync(MethodId.SyncLogicVehicleEnter, UxSerializer.Serialize( new SceneMethods.SyncLogicVehicleEnter { EntityId = v.EntityId, VehicleConfigId = v.ConfigId, CreateSourceType = 0, Parts = [], SuitId = 0, LicensePlate = string.Empty, Interactable = false, MoveToken = 0, Position = pos, EulerAngles = euler, VehicleSpoonName = null, }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncSpawnVehicle, UxSerializer.Serialize( new SceneMethods.VehicleClientInfo { ControllerPid = 0, CreateSourceType = 0, EntityId = v.EntityId, VehicleConfigId = v.ConfigId, Parts = [], SuitId = 0, Position = pos, Facing = v.Yaw, EulerAngles = euler, Velocity = v.Speed, IsStatic = false, DeformStatus = 0, SeatInfos = [], SpoonId = 0, IsDynamicGo = false, VehicleEnemyId = 0, DisableNavigation = false, Interactable = false, MoveToken = 0, LicensePlate = string.Empty, }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncAetherAIVehicleAddData, UxSerializer.Serialize( new SceneMethods.ClientVehicleInitData4229938 { VehicleConfigId = v.ConfigId, VehicleColorPartId = v.ColorPartId, VehicleLightState = 0, LaneHandle = v.LaneHandle, DistanceAlongLane = v.Distance, NextVehicleId = 0, Timestamp = v.Timestamp, ControlType = 1, Speed = v.Speed, DustRatio = 0f, Parts = [], SuitId = 0, RandomFraction = Random.Shared.NextSingle(), Id = v.EntityId, Position = new SceneMethods.UxVector3(v.X, v.Y, v.Z), Facing = v.Yaw, EulerAngles = new SceneMethods.UxVector3(0f, v.Yaw, 0f), }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncAetherAIChangeVehicleControl, UxSerializer.Serialize( new SceneMethods.SyncAetherAIChangeVehicleControlType4229938 { VehicleId = v.EntityId, ControlType = 1, // VehicleControlType.AetherVehicle }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncAetherAIVehicleForceGo, UxSerializer.Serialize( new SceneMethods.SyncAetherAIVehicleForceGo4229938 { instanceId = v.EntityId, forceGo = true, }), CancellationToken.None); // Hand driving to the client's own traffic AI: cruise through the lane chain // (stop for cars/peds/lights, steer around obstacles) — the server does not // stream lane positions in this mode. if (v.CruisePoints is { Count: > 0 }) { var cruise = new SceneMethods.CruiseAITaskParameters4229938 { TargetPointList = v.CruisePoints.Select(p => new Ananta.Server.RpcTypes.Client4229938.Auto.UXVector3 { X = p.X, Y = p.Y, Z = p.Z }).ToList(), CruiseType = (byte)SceneMethods.E_TaskVehicleCruiseType4229938.GoToTargetInOrderLoop, configFlags = (byte)(SceneMethods.TaskVehicleCruiseConfigFlags4229938.SlowDownWhenArriveTarget | SceneMethods.TaskVehicleCruiseConfigFlags4229938.DynamicSpeed), minSpeed = MathF.Max(1f, v.Speed * 0.5f), maxSpeed = MathF.Max(2f, v.Speed * 1.3f), ArrivalDistance = 6f, Token = (ulong)Interlocked.Increment(ref _aiTaskTokenSeq), taskAIConfigId = AiConfigPool[Random.Shared.Next(AiConfigPool.Length)], defaultSpeed = v.Speed, drivingFlags = (int)(SceneMethods.VehicleTaskDrivingFlags4229938.DFStopForCars | SceneMethods.VehicleTaskDrivingFlags4229938.DFStopForPeds | SceneMethods.VehicleTaskDrivingFlags4229938.DFStopAtLights | SceneMethods.VehicleTaskDrivingFlags4229938.DFSteerAroundObjects), initSpeed = v.Speed, }; await session.NotifyAsync(MethodId.SyncAssignVehicleAITask, UxSerializer.Serialize( new SceneMethods.SyncAssignVehicleAITask4229938 { vehicleUId = v.EntityId, parameters = cruise, }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncChangeVehicleAITaskState, UxSerializer.Serialize( new SceneMethods.SyncChangeVehicleAITaskState4229938 { vehicleUID = v.EntityId, taskToken = cruise.Token, status = (byte)SceneMethods.VehicleAIStatus4229938.Running, }), CancellationToken.None); } } } private static readonly uint[] AiConfigPool = [16, 20, 21, 22, 23, 24, 26, 27, 28, 29, 30]; private static long _aiTaskTokenSeq; public async Task SpawnAetherVehicleNpcAsync( TcpSession session, ulong npcId, uint npcFormworkId, ulong vehicleId, byte seatIndex) { await session.NotifyAsync(MethodId.SyncAetherAIVehicleNpcAdd, UxSerializer.Serialize( new SceneMethods.SyncAetherAIVehicleNpcAdd4229938 { initData = new SceneMethods.ClientVehicleNpcInitData4229938 { Id = npcId, NpcFormworkId = npcFormworkId, BindVehicleId = vehicleId, SeatIndex = seatIndex, }, }), CancellationToken.None); // Give the driver its drive behavior (PrivateServer1: VehicleNpcTaskId from the // behavior-task catalog) — without it the driver NPC is invisible/passive. var taskId = Ananta.Server.ClientData.Client4229938.BehaviorTaskCatalog.VehicleNpcTaskId; if (taskId != 0) { await session.NotifyAsync(MethodId.SyncDoAetherAgentBehaviorTaskDef, UxSerializer.Serialize( new SceneMethods.SyncDoAetherAgentBehaviorTaskDefine4229938 { agentEntityId = npcId, taskId = (ulong)Interlocked.Increment(ref _behaviorTaskSeq), behaviorTaskId = taskId, }), CancellationToken.None); } } private static long _behaviorTaskSeq; public Task RemoveAetherVehicleNpcAsync(TcpSession session, ulong npcId) { // SyncAetherAINpcRemove (68332284) is the only Aether NPC remove in the client's method // table; the previously used id 68173578 ("SyncAetherAIVehicleNpcRemove") does not exist. return session.NotifyAsync(MethodId.SyncAetherAINpcRemove, UxSerializer.Serialize( new SceneMethods.SyncAetherAINpcRemove4229938 { Pid = npcId }), CancellationToken.None); } public Task SendAetherIntersectionUpdateAsync( TcpSession session, ulong intersectionInstanceId, int currentPeriod, int nextPeriod, byte state) { return session.NotifyAsync(MethodId.SyncAetherAIIntersectionUpdateData, UxSerializer.Serialize( new SceneMethods.ClientTrafficIntersectionPeriodUpdateInfo4229938 { IntersectionIndex = intersectionInstanceId, CurrentState = state, CurrentPeriodIndex = (byte)currentPeriod, NextPeriodIndex = (byte)nextPeriod, RailPeriodIndex = 0, }), CancellationToken.None); } public Task SendAetherLaneDatasAsync( TcpSession session, List samples, double timestamp) { var packet = new SceneMethods.SyncAetherAIVehicleLaneDatas4229938 { Data = samples.Select(s => new SceneMethods.ClientVehicleLaneData4229938 { Id = s.EntityId, LaneHandle = s.LaneHandle, DistanceAlongLane = s.Distance, Status = s.Status, }).ToList(), LaneChangeData = [], TimeStamp = timestamp, }; return session.NotifyAsync(MethodId.SyncAetherAIVehicleLaneDatas, UxSerializer.Serialize(packet), CancellationToken.None); } public async Task RemoveAetherVehicleAsync(TcpSession session, ulong entityId) { // Aether unregister + legacy scene-entity destroy (the BaseVehicle pair). await session.NotifyAsync(MethodId.SyncAetherAIVehicleRemove, UxSerializer.Serialize( new SceneMethods.SyncAetherAIVehicleRemove4229938 { Pid = entityId }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncDestroyVehicle, UxSerializer.Serialize( new SceneMethods.DestroyVehicle { VehicleEntityId = entityId, VehicleDestroyType = 0, Distance = 0, DynamicGoId = 0, }), CancellationToken.None); } public bool IsVehiclePlayerControlled(ulong entityId) { return GameRouter.TryGetSummoned(entityId, out var summoned) && summoned?.ControllerPid != 0; } public (bool HasPlayer, float X, float Y, float Z, float Yaw, uint RaidId) GetPlayerPosition(TcpSession session) { // Sessions disposed right after a client disconnect are probed by the background // loops for a few ticks — treat them as "no player" instead of throwing. try { var state = GameRouter.GetStateIfExists(session); if (state is null) return (false, 0, 0, 0, 0, 0); lock (state.SyncRoot) { if (!state.Ready || !state.HasLastReportedPlayerTransform) return (false, 0, 0, 0, 0, 0); var pos = state.LastReportedPlayerPosition; var rot = state.LastReportedPlayerRotation; return (true, pos.X, pos.Y, pos.Z, rot.Y, state.ActiveRaidId); } } catch (ObjectDisposedException) { return (false, 0, 0, 0, 0, 0); } catch (Exception) { return (false, 0, 0, 0, 0, 0); } } public async Task<(bool Ok, ulong[] EntityIds)> SpawnCrowdPedestriansAsync( TcpSession session, uint npcFormworkId, uint poiActionId, float x, float y, float z, float facing) { var result = await GameRouter.SpawnStaticNpcAtAsync(session, npcFormworkId, poiActionId, x, y, z, facing); return (result.Ok, result.Ids); } public async Task DespawnCrowdPedestrianAsync(TcpSession session, ulong entityId) { await session.NotifyAsync(MethodId.SyncLogicAgentLeave, UxSerializer.Serialize(WorldCodec.LogicAgentLeave(entityId)), CancellationToken.None); session.Log.Info($"[CROWD] despawn pedestrian entity={entityId}"); } public async Task SendPedestrianMoveAsync( TcpSession session, ulong entityId, float x, float y, float z, float facing, byte moveId) { var packet = WorldCodec.PositionAndFacing( entityId, new Vec3(x, y, z), facing, WorldCodec.SetPositionType.Gm, continueMove: true, moveId: moveId); await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, UxSerializer.Serialize(packet), CancellationToken.None); } }