Files

1460 lines
61 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Text.Json;
using Ananta.SDK.Network;
using Ananta.Server.ClientData.Client4229938;
namespace Ananta.Server.Gameplay.Traffic;
/// <summary>
/// Autonomous city traffic engine. Keeps a dynamic ring of AI vehicles alive around every
/// connected player, drives them along the pre-baked lane graph with Intelligent Driver Model
/// (IDM) car-following, brakes for signalized intersections from the CBT TrafficSignals dump,
/// and detaches vehicles the moment a player boards them.
/// Vehicles are spawned per session (per player world) and moved through SyncVehicleMove
/// notifications; the simulation runs on a fixed 10 Hz loop with a measured, clamped delta time.
/// </summary>
public sealed class CityTrafficEngine
{
public static CityTrafficEngine Instance { get; } = new();
// ------------------------------------------------------------------
// Intelligent Driver Model parameters (Treiber/Helbing, 2000)
// ------------------------------------------------------------------
internal const float IdmMinGap = 4.0f; // s0: standstill bumper-to-bumper gap [m]
internal const float IdmTimeHeadway = 1.2f; // T: desired time headway [s]
internal const float IdmMaxAccel = 2.5f; // a_max: maximum acceleration [m/s^2]
internal const float IdmComfortableDecel = 3.0f; // b_comf: comfortable deceleration [m/s^2]
internal const float HardDecelCap = 6.0f; // emergency braking clamp [m/s^2]
internal const float VehicleLength = 4.5f; // average car length for bumper-gap math [m]
internal const float DefaultSpeedLimit = 11.11f; // 40 km/h fallback when a lane has no speed [m/s]
private const int TickIntervalMs = 100; // 10 Hz simulation loop
private const int SignalCycleSeconds = 30; // deterministic light cycle length
private const float StopLineBuffer = 1.5f; // halt this far before an indexed stop line [m]
private const float SpawnMinLaneLength = 30f; // lanes shorter than this are not spawn candidates
// AI-task cars are client-driven; the server's lifetime recycling is the only despawn —
// 5 minutes churned visible cars out of existence mid-screen, 15 keeps a stable ring.
private const double ClientDrivenLifetimeSeconds = 900;
private const int SpawnMinChainHops = 3; // AI-task cruise needs at least this many lane hops ahead
private const float SpawnSameLaneGap = 30f; // no other car this close behind/ahead on the same lane
private const float SpawnNextLaneGap = 15f; // no car this close to the junction on any next lane
private const float SpawnWorldOverlap = 4f; // 4m world-distance overlap guard
// Dynamic ring configuration (tunable live via the debug panel).
public bool Enabled { get; set; } = true;
public int TargetDensity { get; set; } = 20;
public float SpeedScale { get; set; } = 1.0f;
public float MinSpawnDistance { get; set; } = 50f;
public float MaxSpawnDistance { get; set; } = 160f;
public float DespawnDistance { get; set; } = 420f;
public int MaxSpawnsPerTick { get; set; } = 2;
/// <summary>
/// Native client-driven traffic (Aether lane protocol): the client simulates/renders cars
/// along its own zone graph from lane handles. False falls back to the legacy scene-entity
/// path (SyncSpawnVehicle + per-tick position streaming, which stutters).
/// </summary>
public bool UseAetherProtocol { get; set; } = true;
/// <summary>
/// Server-tracked Aether vehicles (AddData + lane sync). ON by default: wire order verified
/// against the client's RPCSerializeAuto.lua (base fields LAST, ControlType as byte).
/// The ambient MassTraffic experiment stays off (see AmbientIntersectionInit).
/// </summary>
public bool ServerSpawnedVehicles { get; set; } = true;
/// <summary>
/// Pushes intersection period changes (SyncAetherAIIntersectionUpdateData) from our own cycle.
/// OFF by default: the client runs its own baked light-cycle tables once the init carries the
/// intersection list — our synthetic period timings made ambient traffic jam. Turn on only to
/// experiment with server-authoritative lights.
/// </summary>
public bool PushIntersectionUpdates { get; set; } = false;
/// <summary>
/// Sends the city's signalized-intersection zones (InterZones.json, 171 intersections /
/// 224 zones) inside SyncAetherAIInitDatas — this is what wakes the client's MassTraffic
/// ambient spawner. ZoneIndex per entry is the REAL zone-graph zone index resolved from
/// the world TrafficLights dump; the 2026-09-19 stacked-at-origin failure came from
/// sending ZoneIndex=0 for every intersection.
/// </summary>
public bool AmbientIntersectionInit { get; set; } = false;
// VehicleLaneDataStatus flags (client UX.Game enum, byte).
private const byte VehicleLaneStatusNormal = 0;
private const byte VehicleLaneStatusTeleport = 4;
private const byte VehicleLaneStatusStandBy = 8;
// MassTrafficIntersectionState (client UX.Game enum, byte).
private const byte MassTrafficStateStart = 0;
private const byte MassTrafficStateTransition = 1;
private readonly ConcurrentDictionary<ulong, SimulatedVehicle> _simulatedVehicles = new();
private readonly Dictionary<int, RoadLaneData> _laneLookup = new();
private readonly Dictionary<int, List<LaneStopLine>> _laneStopLines = new();
// Sessions whose socket/lock has been disposed (client disconnected). The session hub
// keeps returning the last session even after it dies, so track corpses here and stop
// touching them; they are replaced when a new client connects.
private readonly HashSet<TcpSession> _deadSessions = new();
// Maps dump region name -> its first global lane id, so Aether lane handles can be
// expressed as zone-graph-local indexes (handle = lane.Id - region base).
private readonly Dictionary<string, int> _regionLaneBase = new();
// City intersection -> zone-graph zone indexes (InterZones.json). The client's
// AetherIntersectionManager resolves init/update entries by this ZoneIndex against its
// baked StaticIntersectionData blob; wrong values make ambient traffic collapse.
private readonly Dictionary<uint, List<int>> _intersectionZones = new();
// The recovered client StaticIntersectionData.json: authoritative per-zone period table
// lengths (2-7 rows) used to cycle the light periods within real bounds.
private List<StaticZoneInfoEntry> _staticZones = [];
private ulong _nextAetherEntityId = 750_000_100_000L; // id base matching the working reference server HUD
private readonly Dictionary<ulong, (int Current, int Next)> _lastPushedIntersectionPhases = new();
private bool _intersectionBootstrapPushed;
private RoadNetworkFile? _roadNetwork;
private TrafficSignalFile? _signalData;
private ITrafficVehicleDriver? _driver;
private Func<IEnumerable<TcpSession>>? _sessionProvider;
private readonly object _lifecycleLock = new();
private CancellationTokenSource? _cts;
private volatile bool _running;
private long _lastTickMs;
// Pool of verified vehicle configs with full models and multi-seat support (no damaged/wrecked models).
private static readonly uint[] TrafficVehiclePool =
[
81001001, // Sunset Skywing (Sedan)
81001002, // Korou RV6 (SUV)
81001003, // Kazama Voyage (Sedan)
81000007, // Sunset GT-X Specter (Sports Car)
81004001, // Kazama CRN6 (Taxi)
81004027, // Kazama Sandstorm (Police Cruiser)
];
// Fractions of a lane probed when hunting for a spawn point inside the player ring.
private static readonly float[] SpawnFractions = [0.15f, 0.4f, 0.65f, 0.9f];
private static uint[]? _citizenFormworks;
/// <summary>
/// Picks a vehicle-driver/passenger NPC form. The client's ClientVehicleNpcInitData
/// NpcFormworkId resolves against the AgentConfig catalog, so the id must come from
/// there (a plain citizen); falls back to the spawn-table persona id when the catalog
/// has no usable entry.
/// </summary>
private static uint RandomNpcFormwork(uint fallback)
{
_citizenFormworks ??= ClientData.Client4229938.NpcCatalog4229938.All
.Where(n => n.Category == "citizen" && n.GeneralModelId != 0)
.Select(n => n.Id)
.ToArray();
var pool = _citizenFormworks;
if (pool.Length == 0)
{
_citizenFormworks = [fallback];
return fallback;
}
return pool[Random.Shared.Next(pool.Length)];
}
private enum LightPhase
{
Green,
Yellow,
Red,
}
/// <summary>A signalized stop line mapped onto the end of one specific lane.</summary>
private sealed record LaneStopLine(TrafficLightEntry Light, float StopDistance);
public sealed class SimulatedVehicle
{
public ulong EntityId { get; set; }
public uint ConfigId { get; set; }
public TcpSession? OwnerSession { get; set; }
public int CurrentLaneId { get; set; }
public int LaneHandle { get; set; }
public float DistanceOnLane { get; set; }
public float Speed { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public float Yaw { get; set; }
public float Vx { get; set; }
public float Vz { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>True when the client's own traffic AI drives this car (AI-task mode) —
/// the server's simulated position drifts from reality, so distance-based despawn
/// must not apply; lifetime-based recycling is used instead.</summary>
public bool ClientDriven { get; set; }
public bool HijackedByPlayer { get; set; }
public bool PendingTeleport { get; set; }
public ulong DriverNpcId { get; set; }
public List<ulong> PassengerNpcIds { get; } = new();
}
// ------------------------------------------------------------------
// Initialization
// ------------------------------------------------------------------
public void Initialize(string clientDataPath, ITrafficVehicleDriver driver, Func<IEnumerable<TcpSession>> sessionProvider)
{
ArgumentNullException.ThrowIfNull(driver);
ArgumentNullException.ThrowIfNull(sessionProvider);
_driver = driver;
_sessionProvider = sessionProvider;
_laneLookup.Clear();
_laneStopLines.Clear();
_roadNetwork = null;
_signalData = null;
var roadPath = Path.Combine(clientDataPath, "World", "RoadNetwork.json");
try
{
if (!File.Exists(roadPath))
{
Console.WriteLine($"[TRAFFIC] RoadNetwork.json not found at {roadPath}; traffic will stay idle.");
}
else
{
Console.WriteLine($"[TRAFFIC] Loading road network from {roadPath} ...");
using var stream = File.OpenRead(roadPath);
_roadNetwork = JsonSerializer.Deserialize<RoadNetworkFile>(stream);
if (_roadNetwork is null || _roadNetwork.Lanes.Count == 0)
{
Console.WriteLine("[TRAFFIC] Road network file is empty; traffic will stay idle.");
_roadNetwork = null;
}
else
{
foreach (var lane in _roadNetwork.Lanes)
{
lane.Precompute();
_laneLookup[lane.Id] = lane;
if (!_regionLaneBase.TryGetValue(lane.Region, out var regionBase) || lane.Id < regionBase)
_regionLaneBase[lane.Region] = lane.Id;
}
Console.WriteLine($"[TRAFFIC] Road network ready: {_laneLookup.Count} lanes precomputed across {_roadNetwork.SpatialGrid.Count} spatial cells.");
}
}
}
catch (Exception ex)
{
_roadNetwork = null;
Console.WriteLine($"[TRAFFIC-ERROR] Failed to load road network: {ex.Message}");
}
var signalPath = Path.Combine(clientDataPath, "World", "TrafficSignals.json");
try
{
if (!File.Exists(signalPath))
{
Console.WriteLine($"[TRAFFIC] TrafficSignals.json not found at {signalPath}; lights disabled.");
}
else
{
using var stream = File.OpenRead(signalPath);
_signalData = JsonSerializer.Deserialize<TrafficSignalFile>(stream);
Console.WriteLine($"[TRAFFIC] Loaded {_signalData?.Lights.Count ?? 0} traffic lights from {signalPath}.");
}
}
catch (Exception ex)
{
_signalData = null;
Console.WriteLine($"[TRAFFIC-ERROR] Failed to load traffic signals: {ex.Message}");
}
IndexTrafficSignals();
LoadIntersectionZones(clientDataPath);
LoadStaticIntersectionData(clientDataPath);
Start();
}
/// <summary>
/// Loads the recovered client StaticIntersectionData.json (L50 artifact 111). These 171
/// zone entries are the client's authoritative intersection set — the ambient init sends
/// one entry per zone with the real ZoneIndex, and light periods cycle within each zone's
/// real period-table length.
/// </summary>
private void LoadStaticIntersectionData(string clientDataPath)
{
_staticZones = [];
var path = Path.Combine(clientDataPath, "World", "StaticIntersectionData.json");
try
{
if (!File.Exists(path))
{
Console.WriteLine("[TRAFFIC] StaticIntersectionData.json not found; falling back to InterZones.json mapping.");
return;
}
using var stream = File.OpenRead(path);
var file = JsonSerializer.Deserialize<StaticIntersectionFile>(stream);
if (file is null || file.ZoneInfos.Count == 0)
return;
_staticZones = file.ZoneInfos.Where(z => z.ZoneIndex >= 0).ToList();
Console.WriteLine($"[TRAFFIC] Static intersection data ready: {_staticZones.Count} zones (period rows {_staticZones.Min(z => z.TrafficLightPeriodControls.Count)}..{_staticZones.Max(z => z.TrafficLightPeriodControls.Count)}).");
}
catch (Exception ex)
{
_staticZones = [];
Console.WriteLine($"[TRAFFIC-ERROR] Failed to load StaticIntersectionData.json: {ex.Message}");
}
}
/// <summary>
/// Loads InterZones.json (city intersection id -> zone-graph zone indexes) used by the
/// ambient MassTraffic init/update path. Missing file just disables the ambient init list.
/// </summary>
private void LoadIntersectionZones(string clientDataPath)
{
_intersectionZones.Clear();
var path = Path.Combine(clientDataPath, "World", "InterZones.json");
try
{
if (!File.Exists(path))
{
Console.WriteLine($"[TRAFFIC] InterZones.json not found at {path}; ambient intersection init unavailable.");
return;
}
using var stream = File.OpenRead(path);
var file = JsonSerializer.Deserialize<IntersectionZoneFile>(stream);
if (file is null)
return;
foreach (var entry in file.Intersections)
{
if (entry.Id == 0 || entry.Zones.Count == 0)
continue;
_intersectionZones[entry.Id] = entry.Zones;
}
Console.WriteLine($"[TRAFFIC] Intersection zone map ready: {_intersectionZones.Count} intersections across {file.Intersections.Sum(e => e.Zones.Count)} zones.");
}
catch (Exception ex)
{
_intersectionZones.Clear();
Console.WriteLine($"[TRAFFIC-ERROR] Failed to load InterZones.json: {ex.Message}");
}
}
/// <summary>
/// Maps every traffic light onto lanes whose end point (the approach into an intersection)
/// passes near the light, facing along the lane. The result is an O(1) per-vehicle lookup:
/// laneId -> stop line distance along that lane.
/// </summary>
private void IndexTrafficSignals()
{
_laneStopLines.Clear();
var network = _roadNetwork;
var signals = _signalData;
if (network is null || signals is null || signals.Lights.Count == 0)
return;
var gridSize = network.Metadata.GridSize > 1f ? network.Metadata.GridSize : 100f;
// Bucket lights by spatial cell so the per-lane scan only touches nearby lights.
var lightBuckets = new Dictionary<long, List<TrafficLightEntry>>();
foreach (var light in signals.Lights)
{
if (light.Pos.Count < 3)
continue;
var cx = (int)MathF.Floor(light.Pos[0] / gridSize);
var cz = (int)MathF.Floor(light.Pos[2] / gridSize);
var key = PackCell(cx, cz);
if (!lightBuckets.TryGetValue(key, out var bucket))
lightBuckets[key] = bucket = [];
bucket.Add(light);
}
if (lightBuckets.Count == 0)
return;
var indexedLanes = 0;
foreach (var lane in network.Lanes)
{
if (lane.Pts.Count < 2 || lane.TotalLength <= 0.1f)
continue;
var endX = lane.Pts[^1][0];
var endZ = lane.Pts[^1][2];
// Unit tangent at the lane end from the last non-degenerate segment.
float tx = 0f, tz = 0f;
for (var i = lane.Pts.Count - 1; i > 0; i--)
{
var dx = lane.Pts[i][0] - lane.Pts[i - 1][0];
var dz = lane.Pts[i][2] - lane.Pts[i - 1][2];
var lenSq = dx * dx + dz * dz;
if (lenSq > 0.0001f)
{
var inv = 1f / MathF.Sqrt(lenSq);
tx = dx * inv;
tz = dz * inv;
break;
}
}
if (tx == 0f && tz == 0f)
continue;
var cellX = (int)MathF.Floor(endX / gridSize);
var cellZ = (int)MathF.Floor(endZ / gridSize);
TrafficLightEntry? bestLight = null;
var bestPerp = float.MaxValue;
var bestAlong = float.MaxValue;
for (var ox = -1; ox <= 1; ox++)
{
for (var oz = -1; oz <= 1; oz++)
{
if (!lightBuckets.TryGetValue(PackCell(cellX + ox, cellZ + oz), out var bucket))
continue;
foreach (var light in bucket)
{
var ddx = light.Pos[0] - endX;
var ddz = light.Pos[2] - endZ;
// Decompose the light offset into along-lane / across-lane components.
var along = ddx * tx + ddz * tz;
if (along < -8f || along > 12f)
continue;
var perpX = ddx - along * tx;
var perpZ = ddz - along * tz;
var perp = MathF.Sqrt(perpX * perpX + perpZ * perpZ);
if (perp > 8f)
continue;
// Reject lights governing the crossing street (fwd perpendicular to our tangent).
if (light.Fwd.Count >= 3)
{
var fLen = MathF.Sqrt(light.Fwd[0] * light.Fwd[0] + light.Fwd[2] * light.Fwd[2]);
if (fLen > 0.001f)
{
var dot = (light.Fwd[0] * tx + light.Fwd[2] * tz) / fLen;
if (MathF.Abs(dot) < 0.5f)
continue;
}
}
if (perp < bestPerp - 0.05f ||
(perp < bestPerp + 0.05f && MathF.Abs(along) < MathF.Abs(bestAlong)))
{
bestLight = light;
bestPerp = perp;
bestAlong = along;
}
}
}
}
if (bestLight is null)
continue;
// A light slightly behind the lane end shifts the halt point back; a light at or past
// the end stops the vehicle at the lane boundary. Always keep a small approach buffer.
var stopDistance = Math.Clamp(lane.TotalLength + MathF.Min(bestAlong, 0f) - StopLineBuffer, 0f, lane.TotalLength);
if (!_laneStopLines.TryGetValue(lane.Id, out var list))
_laneStopLines[lane.Id] = list = [];
list.Add(new LaneStopLine(bestLight, stopDistance));
indexedLanes++;
}
Console.WriteLine($"[TRAFFIC] Indexed stop lines for {indexedLanes} signalized lanes from {signals.Lights.Count} lights.");
}
private static long PackCell(int cx, int cz) => ((long)cx << 32) | (uint)cz;
// ------------------------------------------------------------------
// Lifecycle
// ------------------------------------------------------------------
public void Start()
{
lock (_lifecycleLock)
{
if (_running)
return;
_running = true;
_cts = new CancellationTokenSource();
_lastTickMs = Environment.TickCount64;
var token = _cts.Token;
_ = Task.Run(async () =>
{
try
{
await SimulationLoopAsync(token);
}
finally
{
_running = false;
}
});
Console.WriteLine("[TRAFFIC] Autonomous City Traffic Engine started (10 Hz, IDM car-following, signal-aware).");
}
}
public void Stop()
{
lock (_lifecycleLock)
{
try
{
_cts?.Cancel();
}
catch
{
// Cancel is best-effort; the loop exits on its next tick regardless.
}
}
}
private async Task SimulationLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(TickIntervalMs, token);
}
catch (OperationCanceledException)
{
break;
}
var nowMs = Environment.TickCount64;
var dt = Math.Clamp((nowMs - _lastTickMs) / 1000f, 0.05f, 0.2f);
_lastTickMs = nowMs;
try
{
if (Enabled && _driver is not null && _sessionProvider is not null && _roadNetwork is not null)
await TickAsync(dt);
}
catch (Exception ex)
{
Console.WriteLine($"[TRAFFIC-ERROR] Tick exception: {ex.Message}");
}
}
}
// ------------------------------------------------------------------
// Simulation tick
// ------------------------------------------------------------------
private async Task TickAsync(float dt)
{
var driver = _driver!;
var sessionProvider = _sessionProvider!;
var unixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var sessions = sessionProvider().Where(s => s is not null).Distinct().ToList();
if (sessions.Count == 0)
{
// No player worlds left; per-session entities are meaningless without their owners.
_simulatedVehicles.Clear();
return;
}
var liveSessions = new HashSet<TcpSession>(sessions);
var playerPos = new Dictionary<TcpSession, (bool Has, float X, float Y, float Z)>(sessions.Count);
foreach (var session in sessions)
{
var (hasPlayer, px, py, pz, _, _) = driver.GetPlayerPosition(session);
playerPos[session] = (hasPlayer && float.IsFinite(px) && float.IsFinite(pz), px, py, pz);
}
// ---- Phase 1: physics update (pure simulation, no I/O) ----
var toBroadcast = new List<SimulatedVehicle>();
var toDespawn = new List<(SimulatedVehicle Vehicle, TcpSession Owner)>();
foreach (var (entityId, vehicle) in _simulatedVehicles)
{
var owner = vehicle.OwnerSession;
if (owner is null || !liveSessions.Contains(owner) || _deadSessions.Contains(owner))
{
_simulatedVehicles.TryRemove(entityId, out _);
continue;
}
var (hasPlayer, px, _, pz) = playerPos[owner];
if (!hasPlayer)
continue; // hold the world frozen until a player transform arrives
if (driver.IsVehiclePlayerControlled(entityId))
{
DetachVehicle(entityId, vehicle, "player took control");
continue;
}
// Client-driven cars follow the client's own AI anywhere on the map — the
// server's simulated position is only bookkeeping, so recycle by LIFETIME
// instead of drifted distance (that was popping cars out on screen).
if (vehicle.ClientDriven)
{
if ((DateTime.UtcNow - vehicle.CreatedAt).TotalSeconds > ClientDrivenLifetimeSeconds)
{
if (_simulatedVehicles.TryRemove(entityId, out _))
toDespawn.Add((vehicle, owner));
}
continue;
}
var dx = vehicle.X - px;
var dz = vehicle.Z - pz;
if (dx * dx + dz * dz > DespawnDistance * DespawnDistance)
{
if (_simulatedVehicles.TryRemove(entityId, out _))
toDespawn.Add((vehicle, owner));
continue;
}
if (!UpdateVehicleMotion(vehicle, dt, unixSeconds))
{
if (_simulatedVehicles.TryRemove(entityId, out _))
toDespawn.Add((vehicle, owner));
continue;
}
toBroadcast.Add(vehicle);
}
// ---- Intersection period sync (the client's MassTraffic routes its ambient traffic by these) ----
if (PushIntersectionUpdates && AmbientIntersectionInit && _intersectionZones.Count > 0)
{
var changed = new List<AetherIntersectionZonePeriod>();
foreach (var zonePeriod in GetAetherIntersectionZonePeriods(DateTimeOffset.UtcNow.ToUnixTimeSeconds()))
{
if (!_intersectionBootstrapPushed)
{
changed.Add(zonePeriod);
}
else if (_lastPushedIntersectionPhases.TryGetValue(zonePeriod.InstanceId, out var last)
&& (last.Current != zonePeriod.CurrentPeriod || last.Next != zonePeriod.NextPeriod))
{
changed.Add(zonePeriod);
}
_lastPushedIntersectionPhases[zonePeriod.InstanceId] = (zonePeriod.CurrentPeriod, zonePeriod.NextPeriod);
}
if (changed.Count > 0)
{
_intersectionBootstrapPushed = true;
foreach (var session in sessions)
{
if (_deadSessions.Contains(session))
continue;
foreach (var zonePeriod in changed)
{
try
{
await driver.SendAetherIntersectionUpdateAsync(session, zonePeriod.InstanceId, zonePeriod.CurrentPeriod, zonePeriod.NextPeriod, MassTrafficStateStart);
}
catch (ObjectDisposedException)
{
_deadSessions.Add(session);
break;
}
catch (Exception ex)
{
Console.WriteLine($"[TRAFFIC-ERROR] intersection sync failed: {ex.Message}");
}
}
}
}
}
// ---- Phase 2: I/O broadcast ----
// AI-task drive mode: the client's own traffic AI drives each car (cruise waypoints
// assigned at spawn + ForceGo), so we do NOT stream LaneDatas — a second stream of
// positions fought the client's sim and made cars snap/collide. Our IDM sim below
// is bookkeeping only (despawn distance checks).
if (!UseAetherProtocol)
{
foreach (var vehicle in toBroadcast)
{
if (vehicle.OwnerSession is null)
continue;
try
{
await driver.SendVehicleMoveAsync(vehicle.OwnerSession, vehicle.EntityId, vehicle.X, vehicle.Y, vehicle.Z, vehicle.Yaw, vehicle.Vx, vehicle.Vz);
}
catch (Exception ex)
{
if (vehicle.OwnerSession is not null)
_deadSessions.Add(vehicle.OwnerSession);
Console.WriteLine($"[TRAFFIC-ERROR] move sync failed entity={vehicle.EntityId}: {ex.Message}");
}
}
}
foreach (var (vehicle, owner) in toDespawn)
{
try
{
if (UseAetherProtocol)
{
await driver.RemoveAetherVehicleAsync(owner, vehicle.EntityId);
if (vehicle.DriverNpcId != 0)
await driver.RemoveAetherVehicleNpcAsync(owner, vehicle.DriverNpcId);
foreach (var passengerNpcId in vehicle.PassengerNpcIds)
await driver.RemoveAetherVehicleNpcAsync(owner, passengerNpcId);
}
else
{
await driver.DestroyTrafficVehicleAsync(owner, vehicle.EntityId);
}
}
catch (Exception ex)
{
Console.WriteLine($"[TRAFFIC-ERROR] despawn failed entity={vehicle.EntityId}: {ex.Message}");
}
}
// ---- Phase 3: per-session density maintenance ----
foreach (var session in sessions)
{
var (hasPlayer, px, py, pz) = playerPos[session];
if (!hasPlayer)
continue;
var owned = CountOwned(session);
if (owned >= TargetDensity)
continue;
var budget = Math.Min(TargetDensity - owned, MaxSpawnsPerTick);
await SpawnAroundPlayerAsync(session, px, py, pz, budget);
}
}
/// <summary>
/// Advances one vehicle by dt using IDM car-following and signal handling.
/// Returns false when the vehicle must be despawned (invalid lane or dead end).
/// </summary>
private bool UpdateVehicleMotion(SimulatedVehicle vehicle, float dt, long unixSeconds)
{
if (!_laneLookup.TryGetValue(vehicle.CurrentLaneId, out var lane) || lane.TotalLength <= 0.1f)
return false;
var speedLimit = ResolveLaneSpeedLimit(lane);
var lookahead = BrakingLookahead(vehicle.Speed);
// --- IDM: free-road acceleration + car-following interaction ---
var (leaderGap, leaderSpeed) = FindNearestLeader(vehicle, lane, lookahead);
var accel = ComputeIdmAcceleration(vehicle.Speed, speedLimit, leaderGap - VehicleLength, leaderSpeed);
// --- Traffic lights: brake for active red / critical-yellow stop lines ---
var stopCap = float.MaxValue;
var holdAtLaneEnd = false;
if (_laneStopLines.TryGetValue(lane.Id, out var stopLines))
{
foreach (var stop in stopLines)
{
var distToStop = stop.StopDistance - vehicle.DistanceOnLane;
if (distToStop < -0.5f || distToStop > lookahead)
continue;
var phase = EvaluateSignalPhase(stop.Light, unixSeconds);
var mustStop = phase == LightPhase.Red;
if (phase == LightPhase.Yellow)
{
// Stop on yellow only when it is still achievable comfortably
// (no dilemma-zone slam); otherwise clear the intersection.
var brakingDistance = vehicle.Speed * vehicle.Speed / (2f * IdmComfortableDecel);
mustStop = distToStop > brakingDistance;
}
if (!mustStop)
continue;
accel = MathF.Min(accel, ComputeIdmAcceleration(vehicle.Speed, speedLimit, distToStop, 0f));
if (stop.StopDistance < stopCap)
stopCap = stop.StopDistance;
if (stop.StopDistance >= lane.TotalLength - 0.5f)
holdAtLaneEnd = true;
}
}
accel = Math.Clamp(accel, -HardDecelCap, IdmMaxAccel);
// --- Integrate speed and lane progress ---
var newSpeed = vehicle.Speed + accel * dt;
if (newSpeed < 0.05f)
newSpeed = 0f;
var newDistance = vehicle.DistanceOnLane + newSpeed * dt;
// Never roll through an active stop line.
if (stopCap > vehicle.DistanceOnLane && newDistance > stopCap)
{
newDistance = stopCap;
newSpeed = 0f;
}
// --- Lane transition / branching ---
if (newDistance >= lane.TotalLength)
{
if (holdAtLaneEnd)
{
// Red light guarding the lane end: wait at the stop bar instead of
// transitioning into the intersection.
newDistance = lane.TotalLength - 0.05f;
newSpeed = 0f;
}
else
{
var nextLane = PickNextLane(lane);
if (nextLane is null)
return false; // dead end / no valid branch -> despawn
var overshoot = newDistance - lane.TotalLength;
vehicle.CurrentLaneId = nextLane.Id;
vehicle.LaneHandle = ResolveLaneHandle(nextLane);
lane = nextLane;
newDistance = MathF.Min(overshoot, MathF.Max(0f, lane.TotalLength - 0.1f));
}
}
var (x, y, z, yaw, vx, vz) = lane.Evaluate(newDistance, newSpeed);
vehicle.DistanceOnLane = newDistance;
vehicle.Speed = newSpeed;
vehicle.X = x;
vehicle.Y = y;
vehicle.Z = z;
vehicle.Yaw = yaw;
vehicle.Vx = vx;
vehicle.Vz = vz;
return true;
}
/// <summary>
/// Standard IDM acceleration:
/// a = a_max * [1 - (v/v0)^4 - (s*/s)^2] with s* = s0 + v*T + v*dv / (2*sqrt(a_max*b_comf)).
/// gap is a bumper-to-bumper distance; leaderSpeed 0 models a stationary obstacle (stop line).
/// </summary>
private static float ComputeIdmAcceleration(float speed, float speedLimit, float gap, float leaderSpeed)
{
var limit = MathF.Max(speedLimit, 0.5f);
var freeAccel = IdmMaxAccel * (1f - MathF.Pow(MathF.Max(speed, 0f) / limit, 4f));
if (gap <= 0.05f)
return -HardDecelCap;
var deltaV = speed - leaderSpeed;
var safeGap = IdmMinGap + MathF.Max(0f,
speed * IdmTimeHeadway + speed * deltaV / (2f * MathF.Sqrt(IdmMaxAccel * IdmComfortableDecel)));
var ratio = safeGap / gap;
return Math.Clamp(freeAccel - IdmMaxAccel * ratio * ratio, -HardDecelCap, IdmMaxAccel);
}
/// <summary>
/// Finds the closest vehicle ahead on the same lane; if none, the search continues across
/// the lane boundary into connected lanes so queues spill back naturally over transitions.
/// Only vehicles of the same owner session (same player world) are considered.
/// Returns the along-lane gap (center-to-center) and the leader speed; gap == lookahead means clear.
/// </summary>
private (float Gap, float Speed) FindNearestLeader(SimulatedVehicle vehicle, RoadLaneData lane, float lookahead)
{
var bestGap = lookahead;
var bestSpeed = 0f;
var found = false;
foreach (var other in _simulatedVehicles.Values)
{
if (ReferenceEquals(other, vehicle) || other.HijackedByPlayer)
continue;
if (!ReferenceEquals(other.OwnerSession, vehicle.OwnerSession))
continue;
if (other.CurrentLaneId == vehicle.CurrentLaneId)
{
var gap = other.DistanceOnLane - vehicle.DistanceOnLane;
if (gap > 0f && gap < bestGap)
{
bestGap = gap;
bestSpeed = other.Speed;
found = true;
}
}
}
if (found)
return (bestGap, bestSpeed);
var distanceToEnd = lane.TotalLength - vehicle.DistanceOnLane;
var remaining = lookahead - distanceToEnd;
if (remaining <= 0f || lane.Next.Count == 0)
return (bestGap, bestSpeed);
foreach (var other in _simulatedVehicles.Values)
{
if (ReferenceEquals(other, vehicle) || other.HijackedByPlayer)
continue;
if (!ReferenceEquals(other.OwnerSession, vehicle.OwnerSession))
continue;
if (!lane.Next.Contains(other.CurrentLaneId))
continue;
var gap = distanceToEnd + other.DistanceOnLane;
if (gap < bestGap)
{
bestGap = gap;
bestSpeed = other.Speed;
}
}
return (bestGap, bestSpeed);
}
/// <summary>
/// Deterministic signal phase from wall-clock time. All lights of one intersection share a
/// per-intersection offset; even/odd zebra indices run as opposing approach groups with a
/// 30s cycle (green -> yellow -> red) so crossing directions never both show green.
/// </summary>
private static LightPhase EvaluateSignalPhase(TrafficLightEntry light, long unixSeconds)
{
var offset = (int)(Math.Abs(light.Inter * 7919L) % SignalCycleSeconds);
var t = (int)((unixSeconds + offset) % SignalCycleSeconds);
if ((light.Zbr & 1) == 0)
{
if (t < 14) return LightPhase.Green;
if (t < 17) return LightPhase.Yellow;
return LightPhase.Red;
}
if (t < 13) return LightPhase.Red;
if (t < 27) return LightPhase.Green;
return LightPhase.Yellow;
}
/// <summary>
/// Picks the next lane at the end of the current one: a weighted random branch from
/// lane.Next (straight continuations weighted x3), falling back to any valid branch.
/// Returns null when the lane is a dead end or all branches are invalid.
/// </summary>
private RoadLaneData? PickNextLane(RoadLaneData lane)
{
if (lane.Next.Count == 0)
return null;
RoadLaneData? lastValid = null;
var totalWeight = 0f;
foreach (var nextId in lane.Next)
{
if (!_laneLookup.TryGetValue(nextId, out var next) || next.TotalLength <= 0.1f)
continue;
totalWeight += next.Turn.Contains("Straight", StringComparison.OrdinalIgnoreCase) ? 3f : 1f;
lastValid = next;
}
if (lastValid is null || totalWeight <= 0f)
return null;
var roll = (float)(Random.Shared.NextDouble() * totalWeight);
foreach (var nextId in lane.Next)
{
if (!_laneLookup.TryGetValue(nextId, out var next) || next.TotalLength <= 0.1f)
continue;
roll -= next.Turn.Contains("Straight", StringComparison.OrdinalIgnoreCase) ? 3f : 1f;
if (roll <= 0f)
return next;
}
return lastValid;
}
private static float BrakingLookahead(float speed)
{
var braking = speed * speed / (2f * IdmComfortableDecel);
return MathF.Max(60f, braking + IdmMinGap + speed * IdmTimeHeadway + 10f);
}
private float ResolveLaneSpeedLimit(RoadLaneData lane)
{
var baseLimit = lane.Speed > 0.5f ? lane.Speed : DefaultSpeedLimit;
return MathF.Max(1f, baseLimit * Math.Clamp(SpeedScale, 0.05f, 5f));
}
/// <summary>
/// Converts a global dump lane id into the zone-graph-local handle the client resolves
/// against ZoneGraphStorage.Lanes (a flat per-region array).
/// </summary>
private int ResolveLaneHandle(RoadLaneData lane)
{
var handle = _regionLaneBase.TryGetValue(lane.Region, out var regionBase)
? lane.Id - regionBase
: lane.Id;
return handle >= 0 ? handle : lane.Id;
}
/// <summary>
/// Base for synthetic intersection InstanceIds we hand the client: 900_000_000_000 + zone
/// index. The client keys IntersectionDataEntity by this id, so updates must reuse it.
/// </summary>
public const ulong IntersectionInstanceBase = 900_000_000_000UL;
public static ulong IntersectionInstanceForZone(int zoneIndex) => IntersectionInstanceBase + (ulong)zoneIndex;
/// <summary>One intersection zone's current/next light period, addressed by InstanceId.</summary>
public sealed record AetherIntersectionZonePeriod(ulong InstanceId, int ZoneIndex, uint InterId, byte CurrentPeriod, byte NextPeriod);
/// <summary>
/// Deterministic light period for one intersection: period 0 = even-zebra group active
/// (t in [0,17)), period 1 = odd-zebra group active (t in [17,30)) on the 30s cycle.
/// </summary>
public static (byte Current, byte Next) GetIntersectionPeriod(uint intersectionId, long unixSeconds)
{
var offset = (int)(Math.Abs(intersectionId * 7919L) % SignalCycleSeconds);
var t = (int)((unixSeconds + offset) % SignalCycleSeconds);
var current = t < 17 ? 0 : 1;
return ((byte)current, (byte)(1 - current));
}
/// <summary>
/// Current period for every intersection zone. Primary source: the recovered client
/// StaticIntersectionData.json (real ZoneIndexes + real per-zone period table lengths, so
/// the cycle stays within the client's actual tables). Fallback: InterZones.json mapping.
/// One entry per zone — the client creates one IntersectionDataEntity per init entry.
/// </summary>
public List<AetherIntersectionZonePeriod> GetAetherIntersectionZonePeriods(long unixSeconds)
{
var result = new List<AetherIntersectionZonePeriod>();
if (_staticZones.Count > 0)
{
foreach (var zone in _staticZones)
{
var rows = Math.Max(1, zone.TrafficLightPeriodControls.Count);
// Deterministic soft cycle: advance a period row every 15s within the zone's
// real table length (rows alternate green/red groups in the client tables).
var current = (int)((unixSeconds / 15 + (uint)zone.ZoneIndex) % rows);
result.Add(new AetherIntersectionZonePeriod(
IntersectionInstanceForZone(zone.ZoneIndex), zone.ZoneIndex, 0,
(byte)current, (byte)((current + 1) % rows)));
}
return result;
}
foreach (var (intersectionId, zones) in _intersectionZones)
{
var (current, next) = GetIntersectionPeriod(intersectionId, unixSeconds);
foreach (var zone in zones)
result.Add(new AetherIntersectionZonePeriod(IntersectionInstanceForZone(zone), zone, intersectionId, current, next));
}
return result;
}
/// <summary>
/// Aether timestamp base experiment: 0 = unix seconds (default), 1 = unix × 15 (server game-clock
/// scale), 2 = seconds since engine start (small monotonic). The client parks incoming vehicles in
/// a pending buffer until ITS clock reaches Timestamp+0.35s — a mismatched clock base leaves them
/// pending forever (invisible despite WebUI count). Cycle modes via /api/traffic/config
/// {"timestampMode": N} then re-enter the world.
/// </summary>
public int AetherTimestampMode { get; set; } = 0;
private readonly DateTime _engineStartUtc = DateTime.UtcNow;
private double CurrentAetherTimestamp()
{
var unix = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
return AetherTimestampMode switch
{
1 => unix * 15.0, // server game-clock scale (metro lesson: server clock ×15)
2 => (DateTime.UtcNow - _engineStartUtc).TotalSeconds, // small monotonic
3 => unix / 15.0, // inverse scale
_ => unix,
};
}
private static double CurrentUnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
// ------------------------------------------------------------------
// Spawning & despawning
// ------------------------------------------------------------------
/// <summary>
/// Spawns up to budget vehicles in the [MinSpawnDistance, MaxSpawnDistance] ring around the
/// player using the lane spatial grid, rejecting occupied stretches. Returns the spawn count.
/// </summary>
private async Task<int> SpawnAroundPlayerAsync(TcpSession session, float px, float py, float pz, int budget)
{
var network = _roadNetwork;
var driver = _driver;
if (network is null || driver is null || budget <= 0 || _deadSessions.Contains(session))
return 0;
var gridSize = network.Metadata.GridSize > 1f ? network.Metadata.GridSize : 100f;
var minSq = MinSpawnDistance * MinSpawnDistance;
var maxSq = MaxSpawnDistance * MaxSpawnDistance;
var centerCx = (int)MathF.Floor(px / gridSize);
var centerCz = (int)MathF.Floor(pz / gridSize);
var cellRadius = (int)MathF.Ceiling(MaxSpawnDistance / gridSize) + 1;
var seenLanes = new HashSet<int>();
var candidates = new List<(RoadLaneData Lane, float Distance)>();
for (var cx = centerCx - cellRadius; cx <= centerCx + cellRadius; cx++)
{
for (var cz = centerCz - cellRadius; cz <= centerCz + cellRadius; cz++)
{
if (!network.SpatialGrid.TryGetValue($"{cx}_{cz}", out var laneIds))
continue;
foreach (var laneId in laneIds)
{
if (!seenLanes.Add(laneId))
continue;
if (!_laneLookup.TryGetValue(laneId, out var lane) || lane.TotalLength < SpawnMinLaneLength)
continue;
// AI-task drive needs a lane chain ahead — cars on dead-end or stub lanes
// never move (their cruise waypoints end after 1-2 hops and they sit there
// getting rammed). Require a real continuation like the working reference.
if (UseAetherProtocol && ServerSpawnedVehicles && ChainHops(lane, SpawnMinChainHops) < SpawnMinChainHops)
continue;
// Coarse bbox rejection before point sampling.
if (lane.Bbox.Count >= 6)
{
var nearX = Math.Clamp(px, lane.Bbox[0], lane.Bbox[3]);
var nearZ = Math.Clamp(pz, lane.Bbox[2], lane.Bbox[5]);
var ndx = px - nearX;
var ndz = pz - nearZ;
if (ndx * ndx + ndz * ndz > maxSq)
continue; // entire lane beyond the outer ring
}
// Probe stations along the lane; accept the first inside the ring.
foreach (var fraction in SpawnFractions)
{
var distance = lane.TotalLength * fraction;
var (sx, _, sz, _, _, _) = lane.Evaluate(distance, 0f);
var sdx = sx - px;
var sdz = sz - pz;
var dSq = sdx * sdx + sdz * sdz;
if (dSq < minSq || dSq > maxSq)
continue;
candidates.Add((lane, distance));
break;
}
}
}
}
var spawned = 0;
var batch = new List<AetherVehicleInit>();
var npcBinds = new List<(ulong NpcId, uint FormworkId, ulong VehicleId, byte Seat)>();
while (spawned < budget && candidates.Count > 0)
{
var idx = Random.Shared.Next(candidates.Count);
var (lane, distance) = candidates[idx];
candidates.RemoveAt(idx);
var (x, y, z, yaw, _, _) = lane.Evaluate(distance, 0f);
// Min-gap rejection (mirrors the working reference's "one car per lane stretch"):
// same-lane spacing, junction clearance on every next lane, plus a world-overlap
// guard for multi-lane roads where different lanes run side by side.
var blocked = false;
foreach (var other in _simulatedVehicles.Values)
{
if (!ReferenceEquals(other.OwnerSession, session))
continue;
if (other.CurrentLaneId == lane.Id && MathF.Abs(other.DistanceOnLane - distance) < SpawnSameLaneGap)
{
blocked = true;
break;
}
if (lane.Next.Contains(other.CurrentLaneId) && other.DistanceOnLane < SpawnNextLaneGap)
{
blocked = true; // junction exit occupied — spawning here rear-ends it
break;
}
var odx = other.X - x;
var odz = other.Z - z;
if (odx * odx + odz * odz < SpawnWorldOverlap * SpawnWorldOverlap)
{
blocked = true;
break;
}
}
if (blocked)
continue;
var configId = TrafficVehiclePool[Random.Shared.Next(TrafficVehiclePool.Length)];
var spawnDistance = MathF.Min(distance, MathF.Max(0f, lane.TotalLength - 0.5f));
ulong entityId;
ulong spawnedDriverNpcId = 0;
var spawnedPassengerNpcIds = new List<ulong>();
try
{
if (UseAetherProtocol && ServerSpawnedVehicles)
{
// Native client-driven path: the client simulates/renders the car on its own
// zone graph from lane handles; no scene entity is created server-side.
entityId = ++_nextAetherEntityId;
var spawnEntry = VehicleSpawnCatalog4229938.Get(configId);
var colorPartId = spawnEntry.ColorMods.Length > 0
? spawnEntry.ColorMods[Random.Shared.Next(spawnEntry.ColorMods.Length)]
: 0u;
batch.Add(new AetherVehicleInit(entityId, configId, colorPartId, ResolveLaneHandle(lane),
spawnDistance, ResolveLaneSpeedLimit(lane), CurrentAetherTimestamp(), x, y, z, yaw,
BuildCruiseWaypoints(lane, 12)));
// Every real traffic car carries a driver NPC (and optional passengers) bound to seats.
// NpcFormworkId must be an AgentConfig form id (client resolves the model from it) —
// the VehicleSpawnConfig "NpcType" persona ids (45200091) are NOT form ids and the
// client silently drops the NPC (invisible drivers).
spawnedDriverNpcId = ++_nextAetherEntityId;
npcBinds.Add((spawnedDriverNpcId, RandomNpcFormwork(spawnEntry.DriverNpcType), entityId, 0));
for (var seat = 1; seat <= spawnEntry.PassengerCount && seat <= 3; seat++)
{
var passengerNpcId = ++_nextAetherEntityId;
spawnedPassengerNpcIds.Add(passengerNpcId);
npcBinds.Add((passengerNpcId, RandomNpcFormwork(spawnEntry.PassengerNpcType), entityId, (byte)seat));
}
}
else
{
var (ok, legacyId) = await driver.SpawnTrafficVehicleAsync(session, configId, x, y, z, yaw);
if (!ok || legacyId == 0)
continue;
entityId = legacyId;
}
}
catch (ObjectDisposedException)
{
// Client disconnected mid-spawn: retire the session quietly and stop this tick.
_deadSessions.Add(session);
break;
}
catch (Exception ex)
{
Console.WriteLine($"[TRAFFIC-ERROR] spawn failed: {ex.Message}");
break;
}
var simVehicle = new SimulatedVehicle
{
EntityId = entityId,
ConfigId = configId,
OwnerSession = session,
CurrentLaneId = lane.Id,
LaneHandle = ResolveLaneHandle(lane),
DistanceOnLane = spawnDistance,
Speed = ResolveLaneSpeedLimit(lane),
X = x,
Y = y,
Z = z,
Yaw = yaw,
CreatedAt = DateTime.UtcNow,
ClientDriven = UseAetherProtocol && ServerSpawnedVehicles,
HijackedByPlayer = false,
PendingTeleport = UseAetherProtocol,
DriverNpcId = spawnedDriverNpcId,
};
simVehicle.PassengerNpcIds.AddRange(spawnedPassengerNpcIds);
_simulatedVehicles[entityId] = simVehicle;
spawned++;
}
// Flush this tick's spawns as one-call-per-vehicle SyncAetherAIVehicleAddData notifies
// (the client has no batch AddDatas method), then bind the driver/passenger NPCs into
// their seats.
if (batch.Count > 0)
{
try
{
await driver.SpawnAetherVehiclesAsync(session, batch);
foreach (var bind in npcBinds)
await driver.SpawnAetherVehicleNpcAsync(session, bind.NpcId, bind.FormworkId, bind.VehicleId, bind.Seat);
}
catch (ObjectDisposedException)
{
_deadSessions.Add(session);
}
catch (Exception ex)
{
Console.WriteLine($"[TRAFFIC-ERROR] batch spawn failed: {ex.Message}");
}
}
return spawned;
}
/// <summary>Client-side cruise waypoints: the chain of lane end points ahead of the
/// spawn lane — the client's own traffic AI drives the car through them.</summary>
private List<(float X, float Y, float Z)> BuildCruiseWaypoints(RoadLaneData lane, int hops)
{
var pts = new List<(float X, float Y, float Z)>(hops);
var cur = lane;
for (var i = 0; i < hops; i++)
{
if (cur.Pts.Count == 0)
break;
var e = cur.Pts[^1];
pts.Add((e[0], e[1], e[2]));
if (cur.Next.Count == 0)
break;
if (!_laneLookup.TryGetValue(cur.Next[0], out var next) || next.TotalLength <= 0.1f)
break;
cur = next;
}
return pts;
}
/// <summary>Number of consecutive valid lane continuations from <paramref name="lane"/>
/// (0 = dead end). Used to reject stub lanes whose cruise chain is too short to drive.</summary>
private int ChainHops(RoadLaneData lane, int maxHops)
{
var hops = 0;
var cur = lane;
while (hops < maxHops)
{
if (cur.Next.Count == 0)
break;
if (!_laneLookup.TryGetValue(cur.Next[0], out var next) || next.TotalLength <= 0.1f)
break;
cur = next;
hops++;
}
return hops;
}
private int CountOwned(TcpSession session)
{
var count = 0;
foreach (var vehicle in _simulatedVehicles.Values)
{
if (ReferenceEquals(vehicle.OwnerSession, session))
count++;
}
return count;
}
// ------------------------------------------------------------------
// Public control surface
// ------------------------------------------------------------------
/// <summary>
/// Called when a player boards a traffic vehicle. The entity is removed from the simulation
/// immediately so the engine stops pushing server-side positions and never fights the
/// player's client-side driving controls.
/// </summary>
public void NotifyVehicleBoarding(ulong entityId)
{
if (_simulatedVehicles.TryRemove(entityId, out var vehicle))
{
vehicle.HijackedByPlayer = true;
Console.WriteLine($"[TRAFFIC] Vehicle entity={entityId} boarded by player. Detached from traffic loop.");
}
}
/// <summary>
/// Borrow flow (AskAetherAIBorrowVehicle): detaches the car from the simulation and returns
/// its occupant NPC ids so the caller can remove them via SyncAetherAINpcRemove — a borrowed
/// car becomes a player-driven raid vehicle, so its AI driver must leave.
/// </summary>
public (bool Found, ulong DriverNpcId, List<ulong> PassengerNpcIds) DetachForBorrow(ulong entityId)
{
if (_simulatedVehicles.TryRemove(entityId, out var vehicle))
{
vehicle.HijackedByPlayer = true;
Console.WriteLine($"[TRAFFIC] Vehicle entity={entityId} borrowed by player. Detached from traffic loop.");
return (true, vehicle.DriverNpcId, vehicle.PassengerNpcIds);
}
return (false, 0, []);
}
/// <summary>True when the entity id belongs to a server-driven Aether traffic vehicle.</summary>
public bool IsAetherTrafficVehicle(ulong entityId) => _simulatedVehicles.ContainsKey(entityId);
/// <summary>True when the entity id belongs to an Aether traffic vehicle's driver/passenger NPC.</summary>
public bool IsAetherTrafficNpc(ulong npcId)
{
foreach (var vehicle in _simulatedVehicles.Values)
{
if (vehicle.DriverNpcId == npcId || vehicle.PassengerNpcIds.Contains(npcId))
return true;
}
return false;
}
private void DetachVehicle(ulong entityId, SimulatedVehicle vehicle, string reason)
{
vehicle.HijackedByPlayer = true;
_simulatedVehicles.TryRemove(entityId, out _);
Console.WriteLine($"[TRAFFIC] Vehicle entity={entityId} detached from simulation ({reason}).");
}
/// <summary>Debug-panel helper: spawns a burst of vehicles around the player immediately.</summary>
public async Task<int> SpawnWaveAheadAsync(TcpSession session, int count = 5)
{
if (_driver is null || _roadNetwork is null)
return 0;
var (hasPlayer, px, py, pz, _, _) = _driver.GetPlayerPosition(session);
if (!hasPlayer || !float.IsFinite(px) || !float.IsFinite(pz))
return 0;
return await SpawnAroundPlayerAsync(session, px, py, pz, Math.Max(1, count));
}
public async Task ClearAllTrafficAsync(TcpSession session)
{
var driver = _driver;
var vehicles = _simulatedVehicles.Values.ToList();
_simulatedVehicles.Clear();
if (driver is null)
return;
var destroyed = 0;
foreach (var vehicle in vehicles)
{
try
{
if (UseAetherProtocol)
{
await driver.RemoveAetherVehicleAsync(session, vehicle.EntityId);
if (vehicle.DriverNpcId != 0)
await driver.RemoveAetherVehicleNpcAsync(session, vehicle.DriverNpcId);
foreach (var passengerNpcId in vehicle.PassengerNpcIds)
await driver.RemoveAetherVehicleNpcAsync(session, passengerNpcId);
}
else
{
await driver.DestroyTrafficVehicleAsync(session, vehicle.EntityId);
}
destroyed++;
}
catch (Exception ex)
{
Console.WriteLine($"[TRAFFIC-ERROR] destroy failed entity={vehicle.EntityId}: {ex.Message}");
}
}
Console.WriteLine($"[TRAFFIC] Cleared {destroyed}/{vehicles.Count} traffic vehicles.");
}
public TrafficStatus GetStatus() => new()
{
Enabled = Enabled,
TargetDensity = TargetDensity,
SpeedScale = SpeedScale,
ActiveVehicles = _simulatedVehicles.Count,
TotalRoadLanes = _roadNetwork?.Lanes.Count ?? 0,
TotalSpatialCells = _roadNetwork?.SpatialGrid.Count ?? 0,
TotalTrafficSignals = _signalData?.Lights.Count ?? 0,
SignalizedLanes = _laneStopLines.Count,
};
}
public sealed class TrafficStatus
{
public bool Enabled { get; set; }
public int TargetDensity { get; set; }
public float SpeedScale { get; set; }
public int ActiveVehicles { get; set; }
public int TotalRoadLanes { get; set; }
public int TotalSpatialCells { get; set; }
public int TotalTrafficSignals { get; set; }
public int SignalizedLanes { get; set; }
}