413 lines
20 KiB
C#
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);
|
|
}
|
|
}
|