Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/TrafficAndCrowdDriver.cs
T

413 lines
20 KiB
C#

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<int> 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<ulong, int> _moveTokens = new();
/// <summary>
/// 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}.
/// </summary>
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<AetherVehicleInit> 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<AetherLaneSample> 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);
}
}