using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Protocol.Client4229938; 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". /// /// Round 54 got the scheduling right but not the clock: see MetroDefaultTimeFactor for the round-55 fix. /// internal sealed partial class GameRouter { private const string MetroKey = "client4229938-metro"; /// /// 玩家在轨道附近时的推送周期。250 ms(4 Hz)原本是全程恒定值,实测 /// `SyncRunningMetroInfos` 占了 S2C 总字节的 54%(11 分钟 1312 包 / 682 KB,每包 37 列车)。 /// 现在 1000 ms 是基准,远离轨道时再降到 。 /// ⛑️ 安全前提:列车位置由**客户端**自己沿烘焙好的 MetroPath 推进 /// (MetroManager 负责,见本文件顶部说明),服务端推的 ElapsedTime 只是对时锚点。 /// 所以降频不会让车"卡住",只影响纠偏粒度。若观感变差,把面板的推送周期调回 250 即可。 /// private const int MetroDefaultPushMs = 1000; /// 玩家远离轨道时的推送周期。列车看不见,慢一点没有代价。 private const int MetroIdlePushMs = 3000; /// /// "看得见列车"的半径(世界单位 ≈ 米):玩家到**最近轨道段**的距离小于它就用 /// ,否则用 。 /// 按轨道段而不是车站算,因为相邻车站间距可达 1 km,只按车站判会把大半条线漏掉。 /// private const double MetroNearTrackRadius = 300.0; /// /// 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. The factor is exposed /// on the panel so the two other candidates (1 and 1/15) can be tested without a rebuild. /// private const double MetroDefaultTimeFactor = 15.0; /// /// 兜底线路:只在「当前 raid 没配 LightRailLineIds」或「还没进世界、拿不到 raid」时用。 /// 正常情况下推的是 的结果。 /// /// 为什么不能全局固定这四条:它们是新启(raid 23300888)的环线。凌云/重霄(23300999)配的是 /// 9–14(+15 画舫),客户端为那六条线烘焙的走行路径是另一套(LightRailPath_cx*_upline/downline)。 /// 在凌云里推 1–4,客户端按新启的路径跑车 ⇒ 车不在凌云的铁轨上。 /// private static readonly uint[] MetroDefaultLines = [1, 2, 3, 4]; /// /// 当前 raid 该推哪几条线。取 RaidConfig.LightRailLineIds /// (23300888 = 1–4,23300999 = 9–14 + 15,23301227 = 5–8,23301271 = 1–4), /// 取不到就落回 。 /// private static uint[] MetroLinesForRaid(TcpSession session) { var raidId = GetStateIfExists(session)?.ActiveRaidId ?? 0u; var lines = RailCatalog4229938.LinesForRaid(raidId); return lines.Count > 0 ? lines.ToArray() : MetroDefaultLines; } /// /// raid 变了就把自动选的线换掉。手动模式( = false, /// 即面板显式下发过 lines)**一个字都不动**,免得和用户手选打架。 /// 返回 true = 这次真的换过了,调用方负责打日志。 /// private static bool SyncMetroLinesToRaid(TcpSession session, MetroSessionState owner, bool force = false) { var raidId = GetStateIfExists(session)?.ActiveRaidId ?? 0u; lock (owner.SyncRoot) { if (!owner.AutoRaidLines) return false; // 已经有线且 raid 没变 ⇒ 什么都不做(避免每次 tick 都重建集合)。 if (!force && owner.AutoRaidId == raidId && owner.Lines.Count > 0) return false; var lines = RailCatalog4229938.LinesForRaid(raidId); owner.Lines = lines.Count > 0 ? [.. lines] : [.. MetroDefaultLines]; owner.AutoRaidId = raidId; return true; } } 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 (see MetroDefaultTimeFactor). internal double TimeFactor = MetroDefaultTimeFactor; /// Lines currently being pushed. internal HashSet Lines = []; /// /// 线路跟随当前 raid(RaidConfig.LightRailLineIds)自动切换。 /// 面板一旦显式下发 lines 就置 false —— 之后不再自动改,避免和用户手选打架; /// 要切回自动,面板走 auto: true。 /// internal bool AutoRaidLines = true; /// 上次自动选线时用的 raid。只在它变化时才重建 。 internal uint AutoRaidId; /// /// 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; /// /// 上一拍玩家是否处在轨道附近。只用来在切换时打一条日志,避免在控制台刷屏。 /// 初值 true = 还没拿到玩家坐标时按"近处"处理,不牺牲画面。 /// internal bool NearTrack = true; /// 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(); created.AutoRaidId = GetStateIfExists(session)?.ActiveRaidId ?? 0u; foreach (var id in MetroLinesForRaid(session)) 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. Everything before round 54 answered with the typed-default empty list, which is why no /// carriage ever appeared. /// [Handler(MethodId.AskGetAllMetroInfos)] private async Task AskGetAllMetroInfos(Connection conn, UxRpcMessage msg) { var owner = GetMetroState(msg.Context.Session); // 场景刚加载完,raid 可能是新的(切地区 / 传送)⇒ 先按新 raid 重选一次线路, // 再回答"现在有哪些车",否则会把上一个 raid 的线一路推下去。 if (SyncMetroLinesToRaid(msg.Context.Session, owner)) conn.Log.Info($"[METRO] raid -> {owner.AutoRaidId}, lines=[{MetroLineSummary(owner)}]"); lock (owner.SyncRoot) owner.Requested = true; var infos = BuildMetroInfos(owner); conn.Log.Info($"[METRO] AskGetAllMetroInfos -> {infos.Count} train(s) lines=[{MetroLineSummary(owner)}] raid={owner.AutoRaidId}"); 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 SceneMethods.MetroClientInfo { Id = (int)((lineId * 100) + (uint)i), LineId = lineId, ElapsedTime = (float)(RailCatalog4229938.ElapsedForTrain(line, i, elapsed) * timeFactor), IsFinalTrain = false, }); } } return result; } // ------------------------------------------------- 轨道邻近度(决定推送频率) /// /// 所有线路的轨道段(相邻车站连线,环线额外补一条首尾段),只按到这些线段的距离判断 /// 玩家是否看得见列车。车站只有 34 个、线路 4 条,缓存一次即可。 /// private static readonly Lazy> MetroTrackSegments = new(BuildMetroTrackSegments); private static List<(RailStation4229938 A, RailStation4229938 B)> BuildMetroTrackSegments() { var segments = new List<(RailStation4229938, RailStation4229938)>(); foreach (var line in RailCatalog4229938.AllLines) { if (!RailCatalog4229938.TryGetRoute(line.Id, out var route)) continue; var pts = new List(); foreach (var stop in route.Stops) { if (RailCatalog4229938.TryGetStation(stop.StationId, out var station)) pts.Add(station); } if (pts.Count < 2) continue; for (var i = 0; i + 1 < pts.Count; i++) segments.Add((pts[i], pts[i + 1])); if (line.RingLine && pts.Count >= 3) segments.Add((pts[^1], pts[0])); } return segments; } /// 点到轨道折线的最近距离(米)。段数为几十,每拍算一次开销可忽略。 private static double DistanceToMetroTrack(double px, double py, double pz) { var best = double.MaxValue; foreach (var (a, b) in MetroTrackSegments.Value) { var abx = b.X - a.X; var aby = b.Y - a.Y; var abz = b.Z - a.Z; var apx = px - a.X; var apy = py - a.Y; var apz = pz - a.Z; var denom = abx * abx + aby * aby + abz * abz; var t = denom > 0 ? (apx * abx + apy * aby + apz * abz) / denom : 0.0; if (t < 0) t = 0; else if (t > 1) t = 1; var dx = apx - abx * t; var dy = apy - aby * t; var dz = apz - abz * t; var d = Math.Sqrt(dx * dx + dy * dy + dz * dz); if (d < best) best = d; } return best; } /// /// 玩家是否看得见列车。拿不到坐标时返回 true(按近处处理),宁可多推也不牺牲画面。 /// private static bool IsPlayerNearMetroTrack(TcpSession session, out double metres) { metres = -1; // 轨道表没加载出来就无从判断,按近处处理(保持原频率),不要悄悄把地铁停掉。 if (MetroTrackSegments.Value.Count == 0) return true; var state = GetStateIfExists(session); if (state is null || !state.HasLastReportedPlayerTransform) return true; double px, py, pz; lock (state.SyncRoot) { px = state.LastReportedPlayerPosition.X; py = state.LastReportedPlayerPosition.Y; pz = state.LastReportedPlayerPosition.Z; } metres = DistanceToMetroTrack(px, py, pz); return metres <= MetroNearTrackRadius; } 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 { // 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。 if (!IsBackgroundPushPaused(session)) 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; // 远离轨道时降频。列车位置由客户端自己推进,ElapsedTime 只是对时锚点, // 所以慢推不会让车卡住;玩家一旦靠近,下一拍立刻恢复常速并把时间校准回来。 var nearTrack = IsPlayerNearMetroTrack(session, out var metres); bool modeChanged; lock (owner.SyncRoot) { modeChanged = owner.NearTrack != nearTrack; owner.NearTrack = nearTrack; } if (modeChanged) session.Log.Info($"[METRO] cadence -> {(nearTrack ? "normal" : "idle")} " + $"({period}ms / {MetroIdlePushMs}ms, nearest track " + (metres < 0 ? "unknown" : $"{metres:F0}m") + ")"); var sleep = nearTrack ? period : Math.Max(period, MetroIdlePushMs); try { await Task.Delay(Math.Clamp(sleep, 50, 10000), token); } catch (OperationCanceledException) { break; } } session.Log.Info("[METRO] loop exit"); } private static async Task TickMetroAsync(TcpSession session, MetroSessionState owner, CancellationToken token) { // raid 也可能在**没有**重新 Ask 的情况下变掉(GM 传送 / 跨地区切场景不打招呼), // 每拍顺手核一次。AutoRaidLines=false(面板手选过)时这里是 no-op。 if (SyncMetroLinesToRaid(session, owner)) session.Log.Info($"[METRO] raid -> {owner.AutoRaidId}, lines=[{MetroLineSummary(owner)}]"); 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 /// /// 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, enabled = false, running = false, requested = false, autoRaidLines = true, raidId = 0u, lines = MetroCatalogView([]), trains = Array.Empty(), message = "the scene has not requested the metro list yet", }; } bool enabled; int pushMs; double timeFactor; bool requested; bool autoRaidLines; uint raidId; 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; autoRaidLines = owner.AutoRaidLines; raidId = owner.AutoRaidId; 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, autoRaidLines, raidId, 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); } /// /// Panel update of the dispatcher. null keeps the current selection (that is /// how the panel changes only the push period or the time factor), an empty list turns every line off. /// internal static object ConfigureMetroLines(TcpSession? session, IReadOnlyList? lines, int? pushMs, double? timeFactor, bool? auto = null) { if (session is null) return new { ok = false, error = "session offline" }; var owner = GetMetroState(session); var known = new List(); var unknown = new List(); var seen = new HashSet(); foreach (var id in lines ?? []) { if (!seen.Add(id)) continue; if (RailCatalog4229938.TryGetLine(id, out _)) known.Add(id); else unknown.Add(id); } string lineLog; // 先把「按 raid 重选」做完再进 lock:SyncMetroLinesToRaid 自己会锁 SyncRoot 并去读 // session.Items,嵌套进来会和 tick 线程(先 session.Items 后 SyncRoot)撞成 ABBA。 if (auto == true) { lock (owner.SyncRoot) owner.AutoRaidLines = true; SyncMetroLinesToRaid(session, owner, force: true); } lock (owner.SyncRoot) { if (auto == true) lineLog = $"auto -> [{string.Join(",", owner.Lines.OrderBy(x => x))}] (raid {owner.AutoRaidId})"; else if (lines is not null) { // 显式下发过 lines ⇒ 转手动,之后 raid 变了也不再自动改,免得和用户手选打架。 owner.AutoRaidLines = false; owner.Lines = [.. known]; lineLog = $"-> [{string.Join(",", known)}] (manual, raid-follow off)"; } else { lineLog = $"kept [{string.Join(",", owner.Lines.OrderBy(x => x))}]"; } if (pushMs is > 0) owner.PushMs = Math.Clamp(pushMs.Value, 50, 2000); if (timeFactor is > 0) owner.TimeFactor = Math.Clamp(timeFactor.Value, 0.0001, 1000); } session.Log.Info($"[METRO] lines {lineLog}" + (unknown.Count > 0 ? $" (unknown ignored: {string.Join(",", unknown)})" : string.Empty) + $" push={owner.PushMs}ms timeFactor={owner.TimeFactor:0.####}"); EnsureMetroLoop(session, owner); return MetroStatus(session); } internal static object StopMetro(TcpSession? session) { if (session is null) return new { ok = false, error = "session offline" }; var owner = TryGetMetroState(session); if (owner is null) return new { ok = true, stopped = false, message = "not running" }; CancellationTokenSource? cts; lock (owner.SyncRoot) { owner.OffsetSeconds += (DateTime.UtcNow - owner.StartedUtc).TotalSeconds; owner.StartedUtc = DateTime.UtcNow; owner.Requested = false; cts = owner.Cts; owner.Cts = null; owner.Loop = null; } cts?.Cancel(); cts?.Dispose(); session.Log.Info("[METRO] stopped by the panel (the client keeps the carriages it already has)"); return new { ok = true, stopped = true, status = MetroStatus(session) }; } internal static List BuildAetherIntersectionInitList() { var result = new List(); if (!Ananta.Server.Gameplay.Traffic.CityTrafficEngine.Instance.AmbientIntersectionInit) return result; foreach (var zonePeriod in Ananta.Server.Gameplay.Traffic.CityTrafficEngine.Instance .GetAetherIntersectionZonePeriods(DateTimeOffset.UtcNow.ToUnixTimeSeconds())) { result.Add(new SceneMethods.ClientTrafficIntersectionInitInfo4229938 { InstanceId = zonePeriod.InstanceId, ZoneIndex = zonePeriod.ZoneIndex, CurrentState = 0, CurrentPeriodIndex = zonePeriod.CurrentPeriod, NextPeriodIndex = zonePeriod.NextPeriod, RailPeriodIndex = 0, }); } return result; } }