1460 lines
61 KiB
C#
1460 lines
61 KiB
C#
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; }
|
||
}
|