using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Gameplay.Traffic; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// Light rail / metro scheduling. /// /// The client owns the whole carriage simulation: MetroManager builds the carriages, drives them /// along the baked MetroPath of a line and plays the gate/door animation. All the server has to do is /// answer AskGetAllMetroInfos and then keep telling the client "train X of line Y has been running for /// Z seconds" through SyncRunningMetroInfos - MetroClientInfo has no position field at all /// (Id, LineId, ElapsedTime, IsFinalTrain). That is why this feature needs no client patch. /// /// The schedule comes straight out of the client's own Rail*Config tables (RailLineConfig / /// RailStopConfig / RailStationConfig), which the panel exposes as "where is the train, which stop is /// it at, how long / how far until the next one". /// internal sealed partial class GameRouter { private const string MetroKey = "client4229938-metro"; private const int MetroDefaultPushMs = 250; /// /// ElapsedTime is a reading of the *server* clock, not a scene second count. The client converts it /// with Metro.GetClientTimeFromServerTime(t) = t * INV_SERVER_TIME_FACTOR = t / 15, because the /// in-game clock the server reports runs SERVER_TIME_FACTOR = 15 times faster than real time. /// The baked MetroPath timeline - and therefore the RailStopConfig arrival seconds we schedule with - /// is real seconds (the Bayshore ring takes 1123 s for roughly 12 m/s between its stations), so what /// we push has to be the timetable second multiplied by 15. /// Pushing the raw timetable second made every carriage sit around the beginning of its own line /// crawling at 1/15 speed, which is exactly what round 55 was called in to fix. /// private const double MetroDefaultTimeFactor = 15.0; /// /// Lines enabled on a fresh session: the four city light-rail rings /// (1 = Bayshore inner, 2 = Bayshore outer, 3 = Central, 4 = East). /// The Chongxiao lines 9..14 and the developer test paths 5..8 belong to other scenes, so they stay /// off until the panel turns them on - the client cannot find a MetroPath for a line the loaded /// scene does not contain. /// private static readonly uint[] MetroDefaultLines = [1, 2, 3, 4]; internal sealed class MetroSessionState { internal object SyncRoot { get; } = new(); /// Master switch. When false the loop idles without stopping, so nothing has to restart. internal bool Enabled = true; /// Push cadence in milliseconds. internal int PushMs = MetroDefaultPushMs; /// Multiplier applied to the timetable second before it goes out as ElapsedTime. internal double TimeFactor = MetroDefaultTimeFactor; /// Lines currently being pushed. internal HashSet Lines = []; /// /// The scene has actually asked for the metro list at least once. Pushes stay silent until then: /// the client only has its MetroPaths after it has loaded the scene, so an early /// SyncRunningMetroInfos could reach a half-built MetroManager. /// internal bool Requested; /// Seconds of service folded away by a previous stop, so a restart does not jump the trains. internal double OffsetSeconds; internal DateTime StartedUtc = DateTime.UtcNow; internal CancellationTokenSource? Cts; internal Task? Loop; internal long Ticks; internal long Pushes; internal long TrainsSent; internal DateTime LastPushUtc = DateTime.MinValue; internal string? LastError; internal double ElapsedSeconds => OffsetSeconds + (DateTime.UtcNow - StartedUtc).TotalSeconds; } // ---------------------------------------------------------------- state private static MetroSessionState? TryGetMetroState(TcpSession session) => session.Items.TryGetValue(MetroKey, out var raw) && raw is MetroSessionState state ? state : null; private static MetroSessionState GetMetroState(TcpSession session) { lock (session.Items) { if (session.Items.TryGetValue(MetroKey, out var raw) && raw is MetroSessionState existing) return existing; var created = new MetroSessionState(); foreach (var id in MetroDefaultLines) created.Lines.Add(id); session.Items[MetroKey] = created; return created; } } private static void EnsureMetroLoop(TcpSession session, MetroSessionState owner) { lock (owner.SyncRoot) { if (owner.Loop is { IsCompleted: false }) return; // Carry the service time over so a restart continues the same timetable instead of resetting. owner.OffsetSeconds += (DateTime.UtcNow - owner.StartedUtc).TotalSeconds; owner.StartedUtc = DateTime.UtcNow; owner.Cts?.Dispose(); owner.Cts = new CancellationTokenSource(); owner.Loop = Task.Run(() => RunMetroLoopAsync(session, owner, owner.Cts.Token)); } } // ---------------------------------------------------------------- protocol /// /// The scene asks once per load, with no arguments, and expects the list of metros currently /// running. Answering with an empty/placeholder list is why no carriage ever appeared before. /// [Handler(MethodId.AskGetAllMetroInfos, HandlerPacketKind.Invoke)] private async Task AskGetAllMetroInfos(Connection conn, UxRpcMessage msg) { var owner = GetMetroState(msg.Context.Session); lock (owner.SyncRoot) owner.Requested = true; var infos = BuildMetroInfos(owner); conn.Log.Info($"[METRO] AskGetAllMetroInfos -> {infos.Count} train(s) lines=[{MetroLineSummary(owner)}]"); await conn.ReturnAsync(msg, infos); EnsureMetroLoop(msg.Context.Session, owner); } private static List BuildMetroInfos(MetroSessionState owner) { List lines; double elapsed; double timeFactor; lock (owner.SyncRoot) { lines = owner.Lines.OrderBy(id => id).ToList(); elapsed = owner.ElapsedSeconds; timeFactor = owner.TimeFactor; } var result = new List(); foreach (var lineId in lines) { if (!RailCatalog4229938.TryGetLine(lineId, out var line)) continue; // Line 2 is the same track as line 1 but counter-clockwise, so both are pushed and the // client draws the two directions of the ring. var count = line.EffectiveTrainCount; for (var i = 0; i < count; i++) { result.Add(new GameMethods.MetroClientInfo { Id = (int)((lineId * 100) + (uint)i), LineId = lineId, ElapsedTime = (float)(RailCatalog4229938.ElapsedForTrain(line, i, elapsed) * timeFactor), IsFinalTrain = false, }); } } return result; } private static string MetroLineSummary(MetroSessionState owner) { lock (owner.SyncRoot) return string.Join(",", owner.Lines.OrderBy(id => id)); } // ---------------------------------------------------------------- the loop private static async Task RunMetroLoopAsync(TcpSession session, MetroSessionState owner, CancellationToken token) { session.Log.Info("[METRO] loop started"); while (!token.IsCancellationRequested) { try { await TickMetroAsync(session, owner, token); } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { session.Log.Info("[METRO] loop stopped: the session was disposed"); break; } catch (Exception ex) { lock (owner.SyncRoot) owner.LastError = ex.Message; session.Log.Warn($"[METRO] tick failed: {ex.Message}"); } int period; lock (owner.SyncRoot) period = owner.PushMs; try { await Task.Delay(Math.Clamp(period, 50, 2000), token); } catch (OperationCanceledException) { break; } } session.Log.Info("[METRO] loop exit"); } private static async Task TickMetroAsync(TcpSession session, MetroSessionState owner, CancellationToken token) { lock (owner.SyncRoot) { owner.Ticks++; if (!owner.Enabled || !owner.Requested) return; } var infos = BuildMetroInfos(owner); if (infos.Count == 0) return; await session.NotifyAsync(MethodId.SyncRunningMetroInfos, UxSerializer.Serialize(infos), token); long pushes; lock (owner.SyncRoot) { owner.Pushes++; owner.TrainsSent += infos.Count; owner.LastPushUtc = DateTime.UtcNow; pushes = owner.Pushes; } if (pushes <= 3 || pushes % 400 == 0) session.Log.Info($"[METRO] push #{pushes} trains={infos.Count} lines=[{MetroLineSummary(owner)}]"); } // ---------------------------------------------------------------- panel API (future wiring) /// /// Read-only snapshot for the web panel: the whole catalog plus, for every running train, the stop /// it is calling at, the next stop, and the remaining time / straight-line distance to it. /// internal static object MetroStatus(TcpSession? session) { if (session is null) return new { ok = false, online = false, error = "session offline" }; var owner = TryGetMetroState(session); if (owner is null) { return new { ok = true, online = true, started = false, lines = MetroCatalogView([]), trains = Array.Empty(), message = "the scene has not requested the metro list yet", }; } bool enabled; int pushMs; double timeFactor; bool requested; long ticks, pushes, trainsSent; string? lastError; double elapsed; HashSet lines; bool running; lock (owner.SyncRoot) { enabled = owner.Enabled; pushMs = owner.PushMs; timeFactor = owner.TimeFactor; requested = owner.Requested; ticks = owner.Ticks; pushes = owner.Pushes; trainsSent = owner.TrainsSent; lastError = owner.LastError; elapsed = owner.ElapsedSeconds; lines = [.. owner.Lines]; running = owner.Loop is { IsCompleted: false }; } var trains = MetroTrainView(owner, out var stoppedSeconds); return new { ok = true, online = true, started = true, enabled, running, requested, pushMs, timeFactor = Math.Round(timeFactor, 4), ticks, pushes, trainsSent, lastPushUtc = owner.LastPushUtc == DateTime.MinValue ? null : owner.LastPushUtc.ToString("HH:mm:ss"), lastError, serviceSeconds = Math.Round(elapsed, 1), lines = MetroCatalogView(lines), trains, stoppedSeconds, }; } private static object[] MetroCatalogView(HashSet enabled) { var result = new List(); foreach (var line in RailCatalog4229938.AllLines) { var stops = RailCatalog4229938.TryGetRoute(line.Id, out var route) ? route.Stops : Array.Empty(); var stationList = stops .Select(stop => { var name = RailCatalog4229938.TryGetStation(stop.StationId, out var station) ? station.DisplayName(false) : stop.StopName; return new { stopId = stop.Id, name, chinese = RailCatalog4229938.TryGetStation(stop.StationId, out var Chinese) ? Chinese.DisplayName(true) : stop.StopName, arrivalSeconds = Math.Round(stop.ArrivalTime, 1), dwellSeconds = Math.Round(stop.InsertTime, 1), defaultExit = stop.DefaultExitStop, stationId = stop.StationId, }; }) .ToArray(); result.Add(new { id = line.Id, name = line.LineName, prefab = line.BlobName, ring = line.RingLine, isTestPath = line.IsTestPath, trainTemplateId = line.TrainTemplateId, totalTravelSeconds = Math.Round(line.TotalTravelDuration, 1), departureIntervalSeconds = line.DepartureInterval, maxTrainAmount = line.MaxTrainAmount, trainCount = line.EffectiveTrainCount, firstTrain = $"{line.FirstTrainHour:D2}:{line.FirstTrainMinute:D2}", lastTrain = $"{line.LastTrainHour:D2}:{line.LastTrainMinute:D2}", defaultEnabled = MetroDefaultLines.Contains(line.Id), enabled = enabled.Contains(line.Id), stopCount = stops.Count, stations = stationList, }); } return result.ToArray(); } private static object[] MetroTrainView(MetroSessionState owner, out int stoppedSeconds) { List lines; double elapsed; lock (owner.SyncRoot) { lines = owner.Lines.OrderBy(id => id).ToList(); elapsed = owner.ElapsedSeconds; } var result = new List(); stoppedSeconds = 0; foreach (var lineId in lines) { if (!RailCatalog4229938.TryGetLine(lineId, out var line) || !RailCatalog4229938.TryGetRoute(lineId, out var route)) continue; var count = line.EffectiveTrainCount; for (var i = 0; i < count; i++) { var trainElapsed = RailCatalog4229938.ElapsedForTrain(line, i, elapsed); var progress = RailCatalog4229938.ProgressAt(line, trainElapsed); if (progress is null) continue; if (progress.AtStation) stoppedSeconds++; var position = MetroInterpolate(progress); result.Add(new { id = (int)((lineId * 100) + (uint)i), index = i, lineId, lineName = line.LineName, prefab = line.BlobName, elapsedSeconds = Math.Round(trainElapsed, 1), atStation = progress.AtStation, // Index along the line (1-based for humans), which is what the timetable uses. stopIndex = progress.StopIndex + 1, stopName = StopName(progress.CurrentStop), stopStation = progress.CurrentStation?.EnglishName ?? string.Empty, stopStationChinese = progress.CurrentStation?.ChineseName ?? string.Empty, nextStopIndex = progress.NextStopIndex >= 0 ? progress.NextStopIndex + 1 : 0, nextStopName = progress.NextStop is null ? string.Empty : StopName(progress.NextStop), nextStation = progress.NextStation?.EnglishName ?? string.Empty, nextStationChinese = progress.NextStation?.ChineseName ?? string.Empty, secondsToNext = Math.Round(progress.SecondsToNext, 1), metresToNext = Math.Round(progress.MetresToNext, 1), isLastStop = progress.NextStop is null, x = position?.X, y = position?.Y, z = position?.Z, }); } } return result.ToArray(); } private static string StopName(RailStop4229938 stop) { if (RailCatalog4229938.TryGetStation(stop.StationId, out var station)) return station.DisplayName(false); return string.IsNullOrWhiteSpace(stop.StopName) ? $"Stop {stop.Id}" : stop.StopName; } private readonly record struct MetroPoint(double X, double Y, double Z); /// /// Where the train is between two platforms. The stop tables only carry arrival times, so the fill /// is a straight line between the two station anchors scaled by the remaining fraction of the leg - /// good enough for a status readout, and it is the same information the client itself animates from. /// Null when the line has no station anchors (the depot ends of the ring, or the test paths). /// private static MetroPoint? MetroInterpolate(RailTrainProgress progress) { var current = progress.CurrentStation; if (current is null) return null; var next = progress.NextStation; if (next is null || progress.AtStation || progress.NextStop is null) return new MetroPoint(Math.Round(current.X, 1), Math.Round(current.Y, 1), Math.Round(current.Z, 1)); var leg = progress.NextStop.ArrivalTime - progress.CurrentStop.ArrivalTime; if (leg <= 0.001) return new MetroPoint(Math.Round(current.X, 1), Math.Round(current.Y, 1), Math.Round(current.Z, 1)); var fraction = Math.Clamp(progress.SecondsToNext / leg, 0, 1); return new MetroPoint( Math.Round(next.X + ((current.X - next.X) * fraction), 1), Math.Round(next.Y + ((current.Y - next.Y) * fraction), 1), Math.Round(next.Z + ((current.Z - next.Z) * fraction), 1)); } internal static object SetMetroEnabled(TcpSession? session, bool enabled) { if (session is null) return new { ok = false, error = "session offline" }; var owner = GetMetroState(session); lock (owner.SyncRoot) owner.Enabled = enabled; session.Log.Info($"[METRO] master switch -> {(enabled ? "on" : "off")}"); EnsureMetroLoop(session, owner); return MetroStatus(session); } /// /// Initial intersection list for SyncAetherAIInitDatas: one entry per mapped intersection /// ZONE (InterZones.json — real city zone-graph indexes), carrying its current light /// period, so the client's MassTraffic can resolve each entry against its baked /// StaticIntersectionData blob and run ambient traffic from the moment the scene loads. /// internal static List BuildAetherIntersectionInitList() { var result = new List(); if (!CityTrafficEngine.Instance.AmbientIntersectionInit) return result; foreach (var zonePeriod in CityTrafficEngine.Instance .GetAetherIntersectionZonePeriods(DateTimeOffset.UtcNow.ToUnixTimeSeconds())) { result.Add(new SceneMethods.ClientTrafficIntersectionInitInfo4229938 { InstanceId = zonePeriod.InstanceId, ZoneIndex = zonePeriod.ZoneIndex, CurrentState = 0, // MassTrafficIntersectionState.Start CurrentPeriodIndex = zonePeriod.CurrentPeriod, NextPeriodIndex = zonePeriod.NextPeriod, RailPeriodIndex = 0, }); } return result; } }