515 lines
20 KiB
C#
515 lines
20 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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".
|
|
/// </summary>
|
|
internal sealed partial class GameRouter
|
|
{
|
|
private const string MetroKey = "client4229938-metro";
|
|
private const int MetroDefaultPushMs = 250;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private const double MetroDefaultTimeFactor = 15.0;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static readonly uint[] MetroDefaultLines = [1, 2, 3, 4];
|
|
|
|
internal sealed class MetroSessionState
|
|
{
|
|
internal object SyncRoot { get; } = new();
|
|
|
|
/// <summary>Master switch. When false the loop idles without stopping, so nothing has to restart.</summary>
|
|
internal bool Enabled = true;
|
|
|
|
/// <summary>Push cadence in milliseconds.</summary>
|
|
internal int PushMs = MetroDefaultPushMs;
|
|
|
|
/// <summary>Multiplier applied to the timetable second before it goes out as ElapsedTime.</summary>
|
|
internal double TimeFactor = MetroDefaultTimeFactor;
|
|
|
|
/// <summary>Lines currently being pushed.</summary>
|
|
internal HashSet<uint> Lines = [];
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal bool Requested;
|
|
|
|
/// <summary>Seconds of service folded away by a previous stop, so a restart does not jump the trains.</summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<GameMethods.MetroClientInfo> BuildMetroInfos(MetroSessionState owner)
|
|
{
|
|
List<uint> 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<GameMethods.MetroClientInfo>();
|
|
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)
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<object>(),
|
|
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<uint> 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<uint> enabled)
|
|
{
|
|
var result = new List<object>();
|
|
foreach (var line in RailCatalog4229938.AllLines)
|
|
{
|
|
var stops = RailCatalog4229938.TryGetRoute(line.Id, out var route)
|
|
? route.Stops
|
|
: Array.Empty<RailStop4229938>();
|
|
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<uint> lines;
|
|
double elapsed;
|
|
lock (owner.SyncRoot)
|
|
{
|
|
lines = owner.Lines.OrderBy(id => id).ToList();
|
|
elapsed = owner.ElapsedSeconds;
|
|
}
|
|
|
|
var result = new List<object>();
|
|
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);
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal static List<SceneMethods.ClientTrafficIntersectionInitInfo4229938> BuildAetherIntersectionInitList()
|
|
{
|
|
var result = new List<SceneMethods.ClientTrafficIntersectionInitInfo4229938>();
|
|
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;
|
|
}
|
|
}
|