318 lines
12 KiB
C#
318 lines
12 KiB
C#
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<ScopeKey>
|
|
{
|
|
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<ScopeKey, AuthorityAssignment> _assignments = new();
|
|
private readonly Dictionary<ScopeKey, uint> _lastEpoch = new();
|
|
|
|
public void Clear()
|
|
{
|
|
_assignments.Clear();
|
|
_lastEpoch.Clear();
|
|
}
|
|
|
|
public AuthorityAssignment? Get(ScopeKey scope) => _assignments.TryGetValue(scope, out var value) ? value : null;
|
|
public IReadOnlyCollection<AuthorityAssignment> 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<AuthorityChange> 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<AuthorityChange>();
|
|
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<string, BanEntry> _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<BanEntry> 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<string, BanEntry>(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;
|
|
}
|
|
}
|