using System.Collections.Concurrent; using System.Text.Json; using Ananta.SDK.Network; using Ananta.Server.ClientData.Client4229938; namespace Ananta.Server.Gameplay.Traffic; /// /// 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. /// 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; /// /// 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). /// public bool UseAetherProtocol { get; set; } = true; /// /// 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). /// public bool ServerSpawnedVehicles { get; set; } = true; /// /// 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. /// public bool PushIntersectionUpdates { get; set; } = false; /// /// 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. /// 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 _simulatedVehicles = new(); private readonly Dictionary _laneLookup = new(); private readonly Dictionary> _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 _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 _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> _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 _staticZones = []; private ulong _nextAetherEntityId = 750_000_100_000L; // id base matching the working reference server HUD private readonly Dictionary _lastPushedIntersectionPhases = new(); private bool _intersectionBootstrapPushed; private RoadNetworkFile? _roadNetwork; private TrafficSignalFile? _signalData; private ITrafficVehicleDriver? _driver; private Func>? _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; /// /// 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. /// 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, } /// A signalized stop line mapped onto the end of one specific lane. 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; } /// 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. public bool ClientDriven { get; set; } public bool HijackedByPlayer { get; set; } public bool PendingTeleport { get; set; } public ulong DriverNpcId { get; set; } public List PassengerNpcIds { get; } = new(); } // ------------------------------------------------------------------ // Initialization // ------------------------------------------------------------------ public void Initialize(string clientDataPath, ITrafficVehicleDriver driver, Func> 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(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(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(); } /// /// 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. /// 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(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}"); } } /// /// 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. /// 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(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}"); } } /// /// 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. /// 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>(); 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(sessions); var playerPos = new Dictionary(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(); 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(); 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); } } /// /// 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). /// 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; } /// /// 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). /// 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); } /// /// 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. /// 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); } /// /// 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. /// 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; } /// /// 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. /// 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)); } /// /// Converts a global dump lane id into the zone-graph-local handle the client resolves /// against ZoneGraphStorage.Lanes (a flat per-region array). /// 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; } /// /// 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. /// public const ulong IntersectionInstanceBase = 900_000_000_000UL; public static ulong IntersectionInstanceForZone(int zoneIndex) => IntersectionInstanceBase + (ulong)zoneIndex; /// One intersection zone's current/next light period, addressed by InstanceId. public sealed record AetherIntersectionZonePeriod(ulong InstanceId, int ZoneIndex, uint InterId, byte CurrentPeriod, byte NextPeriod); /// /// 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. /// 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)); } /// /// 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. /// public List GetAetherIntersectionZonePeriods(long unixSeconds) { var result = new List(); 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; } /// /// 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. /// 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 // ------------------------------------------------------------------ /// /// 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. /// private async Task 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(); 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(); 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(); 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; } /// 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. 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; } /// Number of consecutive valid lane continuations from /// (0 = dead end). Used to reject stub lanes whose cruise chain is too short to drive. 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 // ------------------------------------------------------------------ /// /// 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. /// 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."); } } /// /// 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. /// public (bool Found, ulong DriverNpcId, List 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, []); } /// True when the entity id belongs to a server-driven Aether traffic vehicle. public bool IsAetherTrafficVehicle(ulong entityId) => _simulatedVehicles.ContainsKey(entityId); /// True when the entity id belongs to an Aether traffic vehicle's driver/passenger NPC. 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})."); } /// Debug-panel helper: spawns a burst of vehicles around the player immediately. public async Task 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; } }