Add C# dedicated server replacement
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user