using System.Collections.Concurrent; using System.Net; using System.Text.Json; using System.Text.Json.Nodes; namespace CommonwealthOnline.Server; internal readonly record struct ScopeKey(string CellId, string WorldspaceId) : IComparable { public int CompareTo(ScopeKey other) { var cell = string.Compare(CellId, other.CellId, StringComparison.Ordinal); return cell != 0 ? cell : string.Compare(WorldspaceId, other.WorldspaceId, StringComparison.Ordinal); } } internal readonly record struct StateScope(string CellId, string WorldspaceId, double X, double Y); internal readonly record struct AuthorityAssignment(ScopeKey Scope, uint PlayerId, uint Epoch); internal readonly record struct AuthorityChange(ScopeKey Scope, uint PreviousPlayerId, uint PlayerId, uint Epoch); internal sealed class NpcAuthorityManager { private readonly Dictionary _assignments = new(); private readonly Dictionary _lastEpoch = new(); public void Clear() { _assignments.Clear(); _lastEpoch.Clear(); } public AuthorityAssignment? Get(ScopeKey scope) => _assignments.TryGetValue(scope, out var value) ? value : null; public IReadOnlyCollection Assignments => _assignments.OrderBy(x => x.Key).Select(x => x.Value).ToArray(); public bool Authorize(uint playerId, ScopeKey scope, uint epoch) => _assignments.TryGetValue(scope, out var assignment) && assignment.PlayerId == playerId && assignment.Epoch == epoch; public IReadOnlyList Reconcile(IEnumerable<(uint PlayerId, ScopeKey Scope)> players) { var desired = players .Where(x => x.PlayerId > 0) .GroupBy(x => x.Scope) .ToDictionary(group => group.Key, group => group.Min(x => x.PlayerId)); var scopes = _assignments.Keys.Concat(desired.Keys).Distinct().OrderBy(x => x).ToArray(); var changes = new List(); foreach (var scope in scopes) { var previous = _assignments.TryGetValue(scope, out var oldAssignment) ? oldAssignment.PlayerId : 0; var next = desired.TryGetValue(scope, out var nextPlayer) ? nextPlayer : 0; if (previous == next) continue; var epoch = _lastEpoch.TryGetValue(scope, out var last) ? checked(last + 1) : 1; _lastEpoch[scope] = epoch; if (next == 0) _assignments.Remove(scope); else _assignments[scope] = new AuthorityAssignment(scope, next, epoch); changes.Add(new AuthorityChange(scope, previous, next, epoch)); } return changes; } } internal sealed class ClientSession { private readonly object _gate = new(); public ClientSession(IGameConnection connection, uint playerId) { Connection = connection; PlayerId = playerId; ConnectedAt = JsonHelpers.UnixTime(); RateWindowStart = MonotonicClock.Now; } public IGameConnection Connection { get; } public uint PlayerId { get; } public IPEndPoint RemoteEndpoint => Connection.RemoteEndpoint; public string Label => $"{RemoteEndpoint.Address}:{RemoteEndpoint.Port}"; public double ConnectedAt { get; } public double? LastPacketAt { get; private set; } public JsonObject? LastTransform { get; private set; } public double? LastTransformMonotonic { get; private set; } public long PacketsReceived { get; private set; } public long PacketsSent { get; private set; } public long PacketsBroadcast { get; private set; } public bool GameplayActive { get; private set; } public int ProtocolVersion { get; private set; } public double RateWindowStart { get; set; } public int RateWindowCount { get; set; } public int RateViolations { get; set; } public bool RateWindowBlocked { get; set; } public uint LastCombatSequence { get; set; } public void RecordReceived() { lock (_gate) { LastPacketAt = JsonHelpers.UnixTime(); PacketsReceived++; } } public void RecordTransform(JsonObject packet, double acceptedMonotonic) { lock (_gate) { LastTransform = JsonHelpers.CloneObject(packet); LastTransformMonotonic = acceptedMonotonic; } } public (JsonObject? Transform, double? Monotonic) TransformAnchor() { lock (_gate) return (LastTransform is null ? null : JsonHelpers.CloneObject(LastTransform), LastTransformMonotonic); } public void RecordSent(bool broadcast) { lock (_gate) { PacketsSent++; if (broadcast) PacketsBroadcast++; } } public bool Activate(int protocolVersion) { lock (_gate) { if (GameplayActive) return false; GameplayActive = true; ProtocolVersion = protocolVersion; return true; } } public JsonObject Snapshot() { lock (_gate) { return new JsonObject { ["playerId"] = PlayerId, ["address"] = RemoteEndpoint.Address.ToString(), ["port"] = RemoteEndpoint.Port, ["connectedAt"] = ConnectedAt, ["lastPacketAt"] = LastPacketAt, ["lastTransform"] = LastTransform?.DeepClone(), ["packetsReceived"] = PacketsReceived, ["packetsSent"] = PacketsSent, ["packetsBroadcast"] = PacketsBroadcast, ["gameplayActive"] = GameplayActive, ["protocolVersion"] = ProtocolVersion }; } } } internal readonly record struct BanEntry(string Ip, string Reason, double BannedAt); internal sealed class BanStore { private readonly object _gate = new(); private readonly string _path; private Dictionary _bans = new(StringComparer.Ordinal); public BanStore(string path) { _path = Path.GetFullPath(path); Load(); } public BanEntry? GetBan(string ip) { if (!TryNormalizeIp(ip, out var normalized)) return null; lock (_gate) return _bans.TryGetValue(normalized, out var entry) ? entry : null; } public IReadOnlyList List() { lock (_gate) return _bans.Values.OrderBy(x => x.Ip, StringComparer.Ordinal).ToArray(); } public BanEntry Ban(string ip, string reason) { if (!TryNormalizeIp(ip, out var normalized)) throw new ArgumentException($"Invalid IP address: '{ip}'", nameof(ip)); var entry = new BanEntry(normalized, (reason ?? string.Empty)[..Math.Min((reason ?? string.Empty).Length, 1024)], JsonHelpers.UnixTime()); lock (_gate) { _bans[normalized] = entry; SaveLocked(); } return entry; } public bool Unban(string ip) { if (!TryNormalizeIp(ip, out var normalized)) return false; lock (_gate) { if (!_bans.Remove(normalized)) return false; SaveLocked(); return true; } } private void Load() { lock (_gate) { if (!File.Exists(_path)) { _bans = new(StringComparer.Ordinal); return; } JsonObject root; try { root = JsonNode.Parse(File.ReadAllBytes(_path)) as JsonObject ?? throw new InvalidDataException("root is not an object"); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) { throw new InvalidOperationException($"Could not load ban file {_path}: {ex.Message}", ex); } if (root["banned_ips"] is not JsonArray array) throw new InvalidOperationException($"Ban file {_path} has an invalid schema."); var bans = new Dictionary(StringComparer.Ordinal); var invalid = 0; foreach (var node in array) { if (node is not JsonObject item || !TryNormalizeIp(JsonHelpers.String(item["ip"]) ?? string.Empty, out var ip)) { invalid++; continue; } var reason = JsonHelpers.String(item["reason"]) ?? string.Empty; if (reason.Length > 1024) reason = reason[..1024]; var bannedAt = JsonHelpers.TryDouble(item["bannedAt"] ?? item["banned_at"], double.MinValue, double.MaxValue, out var value) ? value : 0.0; bans[ip] = new BanEntry(ip, reason, bannedAt); } if (invalid > 0) throw new InvalidOperationException($"Ban file {_path} contains {invalid} invalid {(invalid == 1 ? "entry" : "entries")}."); _bans = bans; } } private void SaveLocked() { var directory = Path.GetDirectoryName(_path)!; Directory.CreateDirectory(directory); var root = new JsonObject { ["banned_ips"] = new JsonArray(_bans.Values.OrderBy(x => x.Ip, StringComparer.Ordinal).Select(entry => (JsonNode)new JsonObject { ["ip"] = entry.Ip, ["reason"] = entry.Reason, ["bannedAt"] = entry.BannedAt }).ToArray()) }; var temp = Path.Combine(directory, $".{Path.GetFileName(_path)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) { JsonSerializer.Serialize(stream, root, new JsonSerializerOptions { WriteIndented = true }); stream.WriteByte((byte)'\n'); stream.Flush(true); } TryRestrictPermissions(temp); File.Move(temp, _path, true); TryRestrictPermissions(_path); } private static bool TryNormalizeIp(string value, out string normalized) { normalized = string.Empty; if (!IPAddress.TryParse(value.Trim(), out var address)) return false; normalized = address.ToString(); return true; } internal static void TryRestrictPermissions(string path) { if (OperatingSystem.IsWindows()) return; try { File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { } } } internal static class MonotonicClock { private static readonly double Frequency = System.Diagnostics.Stopwatch.Frequency; public static double Now => System.Diagnostics.Stopwatch.GetTimestamp() / Frequency; } internal static class WorldStatePresets { public static readonly IReadOnlyList<(string Label, string Value)> Weather = new (string, string)[] { ("Clear", "0002b52a"), ("Cloudy", "001cc186"), ("Overcast", "001c8556"), ("Fog", "001c3473"), ("Rain", "001ca7e4"), ("Radstorm", "001c3d5e"), ("Glowing Sea", "000f1033") }; public static readonly IReadOnlyList<(string Label, string Value)> Time = new (string, string)[] { ("Midnight", "0000"), ("Dawn", "0600"), ("Morning", "0900"), ("Noon", "1200"), ("Afternoon", "1500"), ("Evening", "1800"), ("Dusk (7 PM)", "1900"), ("Night", "2200") }; public static string NormalizeWeatherConsoleArg(string value) { var text = value.Trim(); if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) text = text[2..]; if (!uint.TryParse(text, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) throw new ArgumentException("invalid weather form id", nameof(value)); return parsed.ToString("x8", System.Globalization.CultureInfo.InvariantCulture); } public static string RelayWeatherFormId(string value) => uint.Parse(value, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture).ToString("X8", System.Globalization.CultureInfo.InvariantCulture); public static double? HhmmToGameHour(string hhmm) { var text = hhmm.Trim(); if (text.Length is < 1 or > 4 || !text.All(char.IsDigit)) return null; text = text.PadLeft(4, '0'); var hours = int.Parse(text[..2]); var minutes = int.Parse(text[2..]); return hours > 23 || minutes > 59 ? null : hours + minutes / 60.0; } }