From 21297257449730ad12010129c66a81e4661be103 Mon Sep 17 00:00:00 2001 From: miniduikboot Date: Fri, 6 Oct 2023 14:52:16 +0200 Subject: [PATCH] Improve cross-compatibility (#534) Co-authored-by: js6pak --- src/Directory.Build.props | 2 +- src/Impostor.Api/Innersloth/GameVersion.cs | 103 +++++++- src/Impostor.Api/Net/IClient.cs | 2 +- .../Net/Manager/ICompatibilityManager.cs | 102 ++++++++ .../Net/MessageReaderExtensions.cs | 6 + .../Net/MessageWriterExtensions.cs | 6 + .../Net/Messages/C2S/HandshakeC2S.cs | 12 +- .../Messages/S2C/Message07JoinedGameS2C.cs | 11 +- src/Impostor.Server/Net/Client.cs | 2 +- src/Impostor.Server/Net/ClientBase.cs | 4 +- .../Net/Factories/ClientFactory.cs | 2 +- .../Net/Factories/IClientFactory.cs | 2 +- .../Net/Manager/ClientManager.cs | 70 +----- .../Net/Manager/CompatibilityManager.cs | 222 ++++++++++++++++++ .../Net/Manager/GameManager.cs | 25 +- .../Net/State/Game.Incoming.cs | 9 +- .../Net/State/Game.Outgoing.cs | 3 +- src/Impostor.Server/Net/State/Game.cs | 4 + src/Impostor.Server/Program.cs | 1 + .../Recorder/ClientRecorder.cs | 2 +- .../Recorder/PacketRecorder.cs | 2 +- .../CompatibilityManagerTests.cs | 117 +++++++++ src/Impostor.Tests/GameVersionTests.cs | 28 +++ src/Impostor.Tools.ServerReplay/Program.cs | 2 +- src/ProjectRules.ruleset | 1 + 25 files changed, 638 insertions(+), 102 deletions(-) create mode 100644 src/Impostor.Api/Net/Manager/ICompatibilityManager.cs create mode 100644 src/Impostor.Server/Net/Manager/CompatibilityManager.cs create mode 100644 src/Impostor.Tests/CompatibilityManagerTests.cs create mode 100644 src/Impostor.Tests/GameVersionTests.cs diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8f454b3..e348a77 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ net7.0 11 - 1.8.4 + 1.9.0 dev true true diff --git a/src/Impostor.Api/Innersloth/GameVersion.cs b/src/Impostor.Api/Innersloth/GameVersion.cs index 5df8dea..3c10804 100644 --- a/src/Impostor.Api/Innersloth/GameVersion.cs +++ b/src/Impostor.Api/Innersloth/GameVersion.cs @@ -1,20 +1,103 @@ +using System; +using System.Numerics; + namespace Impostor.Api.Innersloth { - public static class GameVersion + public readonly struct GameVersion : IEquatable, IComparable, IComparisonOperators { - public static int GetVersion(int year, int month, int day, int revision = 0) + private const int YearMask = 25000; + private const int MonthMask = 1800; + private const int DayMask = 50; + + private const int DisableServerAuthorityFlag = 25; + + public GameVersion(int value) + { + Value = value; + } + + public GameVersion(int year, int month, int day, int revision = 0) + { + Value = (year * YearMask) + (month * MonthMask) + (day * DayMask) + revision; + } + + public int Value { get; } + + public int Year => Value / YearMask; + + public int Month => (Value % YearMask) / MonthMask; + + public int Day => ((Value % YearMask) % MonthMask) / DayMask; + + public int Revision => Value % DayMask; + + /// + /// Gets a value indicating whether the DisableServerAuthority flag is present. + /// + public bool HasDisableServerAuthorityFlag + { + get + { + return Revision >= DisableServerAuthorityFlag; + } + } + + public static bool operator ==(GameVersion left, GameVersion right) => left.Value == right.Value; + + public static bool operator !=(GameVersion left, GameVersion right) => left.Value != right.Value; + + public static bool operator >(GameVersion left, GameVersion right) => left.Value > right.Value; + + public static bool operator >=(GameVersion left, GameVersion right) => left.Value >= right.Value; + + public static bool operator <(GameVersion left, GameVersion right) => left.Value < right.Value; + + public static bool operator <=(GameVersion left, GameVersion right) => left.Value <= right.Value; + + public void GetComponents(out int year, out int month, out int day, out int revision) + { + var value = Value; + year = value / YearMask; + value %= YearMask; + month = value / MonthMask; + value %= MonthMask; + day = value / DayMask; + revision = value % DayMask; + } + + /// + /// Normalizes this game version by removing all the special flags. + /// + /// This GameVersion but stripped of special flags. + public GameVersion Normalize() + { + return HasDisableServerAuthorityFlag ? new GameVersion(Value - DisableServerAuthorityFlag) : this; + } + + public override string ToString() + { + GetComponents(out var year, out var month, out var day, out var revision); + return $"{year}.{month}.{day}{(revision == 0 ? string.Empty : "." + revision)}"; + } + + public int CompareTo(GameVersion other) + { + return Value.CompareTo(other.Value); + } + + public bool Equals(GameVersion other) + { + return Value == other.Value; + } + + public override bool Equals(object? obj) { - return (year * 25000) + (month * 1800) + (day * 50) + revision; + return obj is GameVersion other && Equals(other); } - public static void ParseVersion(int version, out int year, out int month, out int day, out int revision) + public override int GetHashCode() { - year = version / 25000; - version %= 25000; - month = version / 1800; - version %= 1800; - day = version / 50; - revision = version % 50; + return Value; } } } diff --git a/src/Impostor.Api/Net/IClient.cs b/src/Impostor.Api/Net/IClient.cs index 09b20be..74309b2 100644 --- a/src/Impostor.Api/Net/IClient.cs +++ b/src/Impostor.Api/Net/IClient.cs @@ -67,7 +67,7 @@ namespace Impostor.Api.Net /// /// Gets the version of the game the client is using. /// - int GameVersion { get; } + GameVersion GameVersion { get; } /// /// Gets platform specific data of the . diff --git a/src/Impostor.Api/Net/Manager/ICompatibilityManager.cs b/src/Impostor.Api/Net/Manager/ICompatibilityManager.cs new file mode 100644 index 0000000..27941ee --- /dev/null +++ b/src/Impostor.Api/Net/Manager/ICompatibilityManager.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Linq; +using Impostor.Api.Games; +using Impostor.Api.Innersloth; + +namespace Impostor.Api.Net.Manager +{ + /// + /// Maintains an internal compatibility list of versions that are allowed to connect to the server, and which game + /// versions they are allowed to play with. + /// + public interface ICompatibilityManager + { + public enum VersionCompareResult + { + Compatible, + ClientTooOld, + ServerTooOld, + Unknown, + } + + /// + /// Gets the compatibility groups. + /// + public IEnumerable CompatibilityGroups { get; } + + /// + /// Check if a client can join the server according to the currently accepted game versions. + /// + /// The client version to check for. + /// + /// Whether this version is supported by the server at the moment and if not, whether it is too old or too new. + /// + public VersionCompareResult CanConnectToServer(GameVersion clientVersion); + + /// Check if a player can join an existing game. + /// The client version of the host. + /// The client version of the player that is joining. + /// + /// + /// if everything is OK. + /// if the player runs a too old game version. + /// if the player runs a too new game version. + /// + /// + public GameJoinError CanJoinGame(GameVersion hostVersion, GameVersion clientVersion); + + /// + /// Add a new compatibility group. + /// + /// WARNING: this method does not magically make changes to Impostor to properly support these versions. If + /// Impostor cannot support the game versions you're trying to add or that the game versions you're making + /// compatible do not crossplay correctly, weird behaviour may occur. Here be dragons. + /// + /// The compatibility group to add. + public void AddCompatibilityGroup(CompatibilityGroup compatibilityGroup); + + /// + /// Add a supported game version to the specified compatibility group. + /// + /// WARNING: this method does not magically make changes to Impostor to properly support these versions. If + /// Impostor cannot support the game versions you're trying to add or that the game versions you're making + /// compatible do not crossplay correctly, weird behaviour may occur. Here be dragons. + /// + /// The compatibility group to add this version to. + /// The game version to add. + public void AddSupportedVersion(CompatibilityGroup compatibilityGroup, GameVersion gameVersion); + + /// + /// Remove a version from the internal version compatibility list. + /// + /// Note that this will not stop players currently connected to the server from playing, it will only stop new + /// connections. + /// The version to remove from the list. + /// True iff this version was on the current compatibility list. + public bool RemoveSupportedVersion(GameVersion removedVersion); + + public sealed class CompatibilityGroup + { + private readonly List _gameVersions; + + public CompatibilityGroup(IEnumerable gameVersions) + { + _gameVersions = gameVersions.ToList(); + } + + public IReadOnlyList GameVersions => _gameVersions; + + public static implicit operator CompatibilityGroup(GameVersion[] gameVersions) => new(gameVersions); + + internal void Add(GameVersion gameVersion) + { + _gameVersions.Add(gameVersion); + } + + internal bool Remove(GameVersion gameVersion) + { + return _gameVersions.Remove(gameVersion); + } + } + } +} diff --git a/src/Impostor.Api/Net/MessageReaderExtensions.cs b/src/Impostor.Api/Net/MessageReaderExtensions.cs index 1f77947..0234555 100644 --- a/src/Impostor.Api/Net/MessageReaderExtensions.cs +++ b/src/Impostor.Api/Net/MessageReaderExtensions.cs @@ -1,5 +1,6 @@ using System.Numerics; using Impostor.Api.Games; +using Impostor.Api.Innersloth; using Impostor.Api.Net.Inner; using Impostor.Api.Unity; @@ -7,6 +8,11 @@ namespace Impostor.Api.Net; public static class MessageReaderExtensions { + public static GameVersion ReadGameVersion(this IMessageReader reader) + { + return new GameVersion(reader.ReadInt32()); + } + public static T? ReadNetObject(this IMessageReader reader, IGame game) where T : IInnerNetObject { diff --git a/src/Impostor.Api/Net/MessageWriterExtensions.cs b/src/Impostor.Api/Net/MessageWriterExtensions.cs index ddf3ba7..f860ce5 100644 --- a/src/Impostor.Api/Net/MessageWriterExtensions.cs +++ b/src/Impostor.Api/Net/MessageWriterExtensions.cs @@ -1,5 +1,6 @@ using System.Numerics; using Impostor.Api.Games; +using Impostor.Api.Innersloth; using Impostor.Api.Net.Inner; using Impostor.Api.Unity; @@ -7,6 +8,11 @@ namespace Impostor.Api.Net; public static class MessageWriterExtensions { + public static void Write(this IMessageWriter writer, GameVersion value) + { + writer.Write(value.Value); + } + public static void Serialize(this GameCode gameCode, IMessageWriter writer) { writer.Write(gameCode.Value); diff --git a/src/Impostor.Api/Net/Messages/C2S/HandshakeC2S.cs b/src/Impostor.Api/Net/Messages/C2S/HandshakeC2S.cs index fb7283c..454fce9 100644 --- a/src/Impostor.Api/Net/Messages/C2S/HandshakeC2S.cs +++ b/src/Impostor.Api/Net/Messages/C2S/HandshakeC2S.cs @@ -4,9 +4,9 @@ namespace Impostor.Api.Net.Messages.C2S { public static class HandshakeC2S { - public static void Deserialize(IMessageReader reader, out int clientVersion, out string name, out Language language, out QuickChatModes chatMode, out PlatformSpecificData? platformSpecificData) + public static void Deserialize(IMessageReader reader, out GameVersion clientVersion, out string name, out Language language, out QuickChatModes chatMode, out PlatformSpecificData? platformSpecificData) { - clientVersion = reader.ReadInt32(); + clientVersion = reader.ReadGameVersion(); name = reader.ReadString(); if (clientVersion >= Version.V1) @@ -44,13 +44,13 @@ namespace Impostor.Api.Net.Messages.C2S private static class Version { - public static readonly int V1 = GameVersion.GetVersion(2021, 4, 25); + public static readonly GameVersion V1 = new(2021, 4, 25); - public static readonly int V2 = GameVersion.GetVersion(2021, 6, 30); + public static readonly GameVersion V2 = new(2021, 6, 30); - public static readonly int V3 = GameVersion.GetVersion(2021, 11, 9); + public static readonly GameVersion V3 = new(2021, 11, 9); - public static readonly int V4 = GameVersion.GetVersion(2021, 12, 14); + public static readonly GameVersion V4 = new(2021, 12, 14); } } } diff --git a/src/Impostor.Api/Net/Messages/S2C/Message07JoinedGameS2C.cs b/src/Impostor.Api/Net/Messages/S2C/Message07JoinedGameS2C.cs index bba0e71..d5d3718 100644 --- a/src/Impostor.Api/Net/Messages/S2C/Message07JoinedGameS2C.cs +++ b/src/Impostor.Api/Net/Messages/S2C/Message07JoinedGameS2C.cs @@ -4,7 +4,7 @@ namespace Impostor.Api.Net.Messages.S2C { public static class Message07JoinedGameS2C { - public static void Serialize(IMessageWriter writer, bool clear, int gameCode, int playerId, int hostId, IClientPlayer[] otherPlayers, bool post20220202 = true) + public static void Serialize(IMessageWriter writer, bool clear, int gameCode, int playerId, int hostId, IClientPlayer[] otherPlayers) { if (clear) { @@ -24,12 +24,9 @@ namespace Impostor.Api.Net.Messages.S2C ply.Client.PlatformSpecificData.Serialize(writer); writer.WritePacked(ply.Character?.PlayerInfo.PlayerLevel ?? 1); - if (post20220202) - { - // ProductUserId and FriendCode are not yet known, so set them to an empty string - writer.Write(string.Empty); - writer.Write(string.Empty); - } + // ProductUserId and FriendCode are not yet known, so set them to an empty string + writer.Write(string.Empty); + writer.Write(string.Empty); } writer.EndMessage(); diff --git a/src/Impostor.Server/Net/Client.cs b/src/Impostor.Server/Net/Client.cs index 44d609a..48d8ea3 100644 --- a/src/Impostor.Server/Net/Client.cs +++ b/src/Impostor.Server/Net/Client.cs @@ -26,7 +26,7 @@ namespace Impostor.Server.Net private readonly GameManager _gameManager; private readonly ICustomMessageManager _customMessageManager; - public Client(ILogger logger, IOptions antiCheatOptions, ClientManager clientManager, GameManager gameManager, ICustomMessageManager customMessageManager, string name, int gameVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData, IHazelConnection connection) + public Client(ILogger logger, IOptions antiCheatOptions, ClientManager clientManager, GameManager gameManager, ICustomMessageManager customMessageManager, string name, GameVersion gameVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData, IHazelConnection connection) : base(name, gameVersion, language, chatMode, platformSpecificData, connection) { _logger = logger; diff --git a/src/Impostor.Server/Net/ClientBase.cs b/src/Impostor.Server/Net/ClientBase.cs index eb5b14d..d967bda 100644 --- a/src/Impostor.Server/Net/ClientBase.cs +++ b/src/Impostor.Server/Net/ClientBase.cs @@ -10,7 +10,7 @@ namespace Impostor.Server.Net { internal abstract class ClientBase : IClient { - protected ClientBase(string name, int gameVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData, IHazelConnection connection) + protected ClientBase(string name, GameVersion gameVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData, IHazelConnection connection) { Name = name; GameVersion = gameVersion; @@ -31,7 +31,7 @@ namespace Impostor.Server.Net public PlatformSpecificData PlatformSpecificData { get; } - public int GameVersion { get; } + public GameVersion GameVersion { get; } public IHazelConnection Connection { get; } diff --git a/src/Impostor.Server/Net/Factories/ClientFactory.cs b/src/Impostor.Server/Net/Factories/ClientFactory.cs index 5a7e666..98e5e4a 100644 --- a/src/Impostor.Server/Net/Factories/ClientFactory.cs +++ b/src/Impostor.Server/Net/Factories/ClientFactory.cs @@ -15,7 +15,7 @@ namespace Impostor.Server.Net.Factories _serviceProvider = serviceProvider; } - public ClientBase Create(IHazelConnection connection, string name, int clientVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData) + public ClientBase Create(IHazelConnection connection, string name, GameVersion clientVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData) { var client = ActivatorUtilities.CreateInstance(_serviceProvider, name, clientVersion, language, chatMode, platformSpecificData, connection); connection.Client = client; diff --git a/src/Impostor.Server/Net/Factories/IClientFactory.cs b/src/Impostor.Server/Net/Factories/IClientFactory.cs index ccccb33..adee6dc 100644 --- a/src/Impostor.Server/Net/Factories/IClientFactory.cs +++ b/src/Impostor.Server/Net/Factories/IClientFactory.cs @@ -5,6 +5,6 @@ namespace Impostor.Server.Net.Factories { internal interface IClientFactory { - ClientBase Create(IHazelConnection connection, string name, int clientVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData); + ClientBase Create(IHazelConnection connection, string name, GameVersion clientVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData); } } diff --git a/src/Impostor.Server/Net/Manager/ClientManager.cs b/src/Impostor.Server/Net/Manager/ClientManager.cs index d3610f7..195d536 100644 --- a/src/Impostor.Server/Net/Manager/ClientManager.cs +++ b/src/Impostor.Server/Net/Manager/ClientManager.cs @@ -1,13 +1,13 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Impostor.Api.Config; using Impostor.Api.Events.Managers; using Impostor.Api.Innersloth; using Impostor.Api.Net; +using Impostor.Api.Net.Manager; using Impostor.Hazel; using Impostor.Server.Events.Client; using Impostor.Server.Net.Factories; @@ -18,42 +18,24 @@ namespace Impostor.Server.Net.Manager { internal partial class ClientManager { - // NOTE: when updating this array, keep the versions ordered from old to new, otherwise the version compare logic doesn't work properly - private static readonly int[] SupportedVersions = - { - GameVersion.GetVersion(2022, 11, 1), // 2022.12.8 - GameVersion.GetVersion(2022, 11, 9), // 2022.12.14 - GameVersion.GetVersion(2022, 12, 2), // 2023.2.28 - GameVersion.GetVersion(2023, 1, 11), // 2023.3.28s - GameVersion.GetVersion(2023, 3, 13), // 2023.3.28a - GameVersion.GetVersion(2023, 4, 21), // 2023.6.13 - GameVersion.GetVersion(2023, 5, 20), // 2023.7.11 - }; - private readonly ILogger _logger; private readonly IEventManager _eventManager; private readonly ConcurrentDictionary _clients; + private readonly ICompatibilityManager _compatibilityManager; private readonly CompatibilityConfig _compatibilityConfig; private readonly IClientFactory _clientFactory; private int _idLast; - public ClientManager(ILogger logger, IEventManager eventManager, IClientFactory clientFactory, IOptions compatibilityConfig) + public ClientManager(ILogger logger, IEventManager eventManager, IClientFactory clientFactory, ICompatibilityManager compatibilityManager, IOptions compatibilityConfig) { _logger = logger; _eventManager = eventManager; _clientFactory = clientFactory; _clients = new ConcurrentDictionary(); + _compatibilityManager = compatibilityManager; _compatibilityConfig = compatibilityConfig.Value; } - private enum VersionCompareResult - { - Compatible, - ClientTooOld, - ServerTooOld, - Unknown, - } - public IEnumerable Clients => _clients.Values; public int NextId() @@ -72,26 +54,24 @@ namespace Impostor.Server.Net.Manager return clientId; } - public async ValueTask RegisterConnectionAsync(IHazelConnection connection, string name, int clientVersion, Language language, QuickChatModes chatMode, PlatformSpecificData? platformSpecificData) + public async ValueTask RegisterConnectionAsync(IHazelConnection connection, string name, GameVersion clientVersion, Language language, QuickChatModes chatMode, PlatformSpecificData? platformSpecificData) { - var versionCompare = CompareVersion(clientVersion); - if (versionCompare == VersionCompareResult.ServerTooOld && _compatibilityConfig.AllowFutureGameVersions && platformSpecificData != null) + var versionCompare = _compatibilityManager.CanConnectToServer(clientVersion); + if (versionCompare == ICompatibilityManager.VersionCompareResult.ServerTooOld && _compatibilityConfig.AllowFutureGameVersions && platformSpecificData != null) { - GameVersion.ParseVersion(clientVersion, out var year, out var month, out var day, out var revision); - _logger.LogWarning("Client connected using future version: {clientVersion} ({version}). Unsupported, continue at your own risk.", clientVersion, $"{year}.{month}.{day}{(revision == 0 ? string.Empty : "." + revision)}"); + _logger.LogWarning("Client connected using future version: {clientVersion} ({version}). Unsupported, continue at your own risk.", clientVersion.Value, clientVersion.ToString()); } - else if (versionCompare != VersionCompareResult.Compatible || platformSpecificData == null) + else if (versionCompare != ICompatibilityManager.VersionCompareResult.Compatible || platformSpecificData == null) { - GameVersion.ParseVersion(clientVersion, out var year, out var month, out var day, out var revision); - _logger.LogTrace("Client connected using unsupported version: {clientVersion} ({version})", clientVersion, $"{year}.{month}.{day}{(revision == 0 ? string.Empty : "." + revision)}"); + _logger.LogTrace("Client connected using unsupported version: {clientVersion} ({version})", clientVersion.Value, clientVersion.ToString()); using var packet = MessageWriter.Get(MessageType.Reliable); var message = versionCompare switch { - VersionCompareResult.ClientTooOld => DisconnectMessages.VersionClientTooOld, - VersionCompareResult.ServerTooOld => DisconnectMessages.VersionServerTooOld, - VersionCompareResult.Unknown => DisconnectMessages.VersionUnsupported, + ICompatibilityManager.VersionCompareResult.ClientTooOld => DisconnectMessages.VersionClientTooOld, + ICompatibilityManager.VersionCompareResult.ServerTooOld => DisconnectMessages.VersionServerTooOld, + ICompatibilityManager.VersionCompareResult.Unknown => DisconnectMessages.VersionUnsupported, _ => throw new ArgumentOutOfRangeException(), }; @@ -133,29 +113,5 @@ namespace Impostor.Server.Net.Manager && _clients.TryGetValue(client.Id, out var registeredClient) && ReferenceEquals(client, registeredClient); } - - private VersionCompareResult CompareVersion(int clientVersion) - { - foreach (var serverVersion in SupportedVersions) - { - if (clientVersion == serverVersion) - { - return VersionCompareResult.Compatible; - } - } - - if (clientVersion < SupportedVersions[0]) - { - return VersionCompareResult.ClientTooOld; - } - - if (clientVersion > SupportedVersions.Last()) - { - return VersionCompareResult.ServerTooOld; - } - - // This may happen in the very rare case that version X is supported, X+2 is as well, but X+1 is not. - return VersionCompareResult.Unknown; - } } } diff --git a/src/Impostor.Server/Net/Manager/CompatibilityManager.cs b/src/Impostor.Server/Net/Manager/CompatibilityManager.cs new file mode 100644 index 0000000..30395d8 --- /dev/null +++ b/src/Impostor.Server/Net/Manager/CompatibilityManager.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Impostor.Api.Games; +using Impostor.Api.Innersloth; +using Impostor.Api.Net.Manager; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.Manager; + +using CompatibilityGroup = ICompatibilityManager.CompatibilityGroup; +using VersionCompareResult = ICompatibilityManager.VersionCompareResult; + +internal class CompatibilityManager : ICompatibilityManager +{ + private static readonly CompatibilityGroup[] DefaultSupportedVersions = + { + new[] + { + new GameVersion(2022, 11, 1), // 2022.12.8 + }, + + new[] + { + new GameVersion(2022, 11, 9), // 2022.12.14 + }, + + new[] + { + new GameVersion(2022, 12, 2), // 2023.2.28 + }, + + new[] + { + new GameVersion(2023, 1, 11), // 2023.3.28s + new GameVersion(2023, 3, 13), // 2023.3.28a + new GameVersion(2023, 4, 21), // 2023.6.13 + }, + + new[] + { + new GameVersion(2023, 5, 20), // 2023.7.11 + new GameVersion(2222, 0, 0), // 2023.7.11 for host-only mods + }, + }; + + private readonly List _compatibilityGroups = new(); + private readonly Dictionary _supportMap = new(); + private readonly ILogger _logger; + private GameVersion _lowestVersionSupported = new(int.MaxValue); + private GameVersion _highestVersionSupported = new(0); + + public CompatibilityManager(ILogger logger) : this(logger, DefaultSupportedVersions) + { + } + + internal CompatibilityManager(ILogger logger, IEnumerable defaultSupportedVersions) + { + _logger = logger; + + foreach (var compatibilityGroup in defaultSupportedVersions) + { + AddCompatibilityGroup(compatibilityGroup); + } + } + + public IEnumerable CompatibilityGroups => _compatibilityGroups; + + private CompatibilityGroup? TryGetCompatibilityGroup(GameVersion clientVersion) + { + // Innersloth servers allow disabling server authority by incrementing the version revision by 25. + // We should allow crossplay between client versions with this flag set and those without. + clientVersion = clientVersion.Normalize(); + + if (_supportMap.TryGetValue(clientVersion, out var compatibilityGroup)) + { + return compatibilityGroup; + } + + return null; + } + + public VersionCompareResult CanConnectToServer(GameVersion clientVersion) + { + if (this.TryGetCompatibilityGroup(clientVersion) != null) + { + return VersionCompareResult.Compatible; + } + + if (clientVersion < _lowestVersionSupported) + { + return VersionCompareResult.ClientTooOld; + } + + if (clientVersion > _highestVersionSupported) + { + return VersionCompareResult.ServerTooOld; + } + + return VersionCompareResult.Unknown; + } + + public GameJoinError CanJoinGame(GameVersion hostVersion, GameVersion clientVersion) + { + if (hostVersion == clientVersion) + { + // Optimize a common case: a player on version X should always be able to join version X + return GameJoinError.None; + } + + var hostCompatGroup = this.TryGetCompatibilityGroup(hostVersion); + var playerCompatGroup = this.TryGetCompatibilityGroup(clientVersion); + + if (hostCompatGroup == null || playerCompatGroup == null) + { + return GameJoinError.InvalidClient; + } + + if (hostCompatGroup != playerCompatGroup) + { + return clientVersion < hostVersion + ? GameJoinError.ClientOutdated + : GameJoinError.ClientTooNew; + } + + return GameJoinError.None; + } + + void ICompatibilityManager.AddCompatibilityGroup(CompatibilityGroup compatibilityGroup) + { + _logger.LogWarning($"{nameof(AddCompatibilityGroup)} was called by a plugin, this can create unexpected issues. Please proceed carefully"); + + AddCompatibilityGroup(compatibilityGroup); + } + + void ICompatibilityManager.AddSupportedVersion(CompatibilityGroup compatibilityGroup, GameVersion gameVersion) + { + _logger.LogWarning($"{nameof(AddSupportedVersion)} was called by a plugin, this can create unexpected issues. Please proceed carefully"); + + if (compatibilityGroup.GameVersions.Contains(gameVersion)) + { + return; + } + + AddSupportedVersion(compatibilityGroup, gameVersion, true); + } + + private void AddCompatibilityGroup(CompatibilityGroup compatibilityGroup) + { + foreach (var gameVersion in compatibilityGroup.GameVersions) + { + if (_supportMap.ContainsKey(gameVersion)) + { + throw new InvalidOperationException($"Can't add this compatibility group because one if its versions ({gameVersion}) is already added"); + } + } + + _compatibilityGroups.Add(compatibilityGroup); + + foreach (var gameVersion in compatibilityGroup.GameVersions) + { + AddSupportedVersion(compatibilityGroup, gameVersion, false); + } + } + + private void AddSupportedVersion(CompatibilityGroup compatibilityGroup, GameVersion gameVersion, bool addToGroup) + { + if (!_compatibilityGroups.Contains(compatibilityGroup)) + { + throw new InvalidOperationException("You have to add the compatibility group first"); + } + + if (_supportMap.ContainsKey(gameVersion)) + { + throw new InvalidOperationException("Can't add this game version because it's already in another compatibility group"); + } + + if (addToGroup) + { + compatibilityGroup.Add(gameVersion); + } + + _supportMap.Add(gameVersion, compatibilityGroup); + + // We special case the host-only 2023.7.11 here + // Ideally it should never have existed so remove once 2023.7.11 is unsupported TODO + var includeInSupportRange = gameVersion != new GameVersion(2222, 0, 0); + + if (includeInSupportRange) + { + if (gameVersion < _lowestVersionSupported) + { + _lowestVersionSupported = gameVersion; + } + + if (gameVersion > _highestVersionSupported) + { + _highestVersionSupported = gameVersion; + } + } + } + + public bool RemoveSupportedVersion(GameVersion removedVersion) + { + if (_supportMap.Remove(removedVersion, out var compatibilityGroup)) + { + if (!compatibilityGroup.Remove(removedVersion)) + { + throw new InvalidOperationException("Removed the version from the support map but it was missing from it's compatibility group"); + } + + if (!compatibilityGroup.GameVersions.Any()) + { + _compatibilityGroups.Remove(compatibilityGroup); + } + + return true; + } + + return false; + } +} diff --git a/src/Impostor.Server/Net/Manager/GameManager.cs b/src/Impostor.Server/Net/Manager/GameManager.cs index 1a95058..40c8610 100644 --- a/src/Impostor.Server/Net/Manager/GameManager.cs +++ b/src/Impostor.Server/Net/Manager/GameManager.cs @@ -12,6 +12,7 @@ using Impostor.Api.Games.Managers; using Impostor.Api.Innersloth; using Impostor.Api.Innersloth.GameOptions; using Impostor.Api.Net; +using Impostor.Api.Net.Manager; using Impostor.Server.Events; using Impostor.Server.Net.State; using Microsoft.Extensions.DependencyInjection; @@ -29,8 +30,16 @@ namespace Impostor.Server.Net.Manager private readonly IServiceProvider _serviceProvider; private readonly IEventManager _eventManager; private readonly IGameCodeFactory _gameCodeFactory; - - public GameManager(ILogger logger, IOptions config, IServiceProvider serviceProvider, IEventManager eventManager, IGameCodeFactory gameCodeFactory, IOptions compatibilityConfig) + private readonly ICompatibilityManager _compatibilityManager; + + public GameManager( + ILogger logger, + IOptions config, + IServiceProvider serviceProvider, + IEventManager eventManager, + IGameCodeFactory gameCodeFactory, + IOptions compatibilityConfig, + ICompatibilityManager compatibilityManager) { _logger = logger; _serviceProvider = serviceProvider; @@ -39,6 +48,7 @@ namespace Impostor.Server.Net.Manager _publicIp = new IPEndPoint(IPAddress.Parse(config.Value.ResolvePublicIp()), config.Value.PublicPort); _games = new ConcurrentDictionary(); _compatibilityConfig = compatibilityConfig.Value; + _compatibilityManager = compatibilityManager; } IEnumerable IGameManager.Games => _games.Select(kv => kv.Value); @@ -51,7 +61,13 @@ namespace Impostor.Server.Net.Manager return game; } - public IEnumerable FindListings(MapFlags map, int impostorCount, GameKeywords language, int gameVersion, HashSet filterTags, int count = 10) + public IEnumerable FindListings( + MapFlags map, + int impostorCount, + GameKeywords language, + GameVersion gameVersion, + HashSet filterTags, + int count = 10) { var results = 0; @@ -60,7 +76,8 @@ namespace Impostor.Server.Net.Manager x.Value.IsPublic && x.Value.GameState == GameStates.NotStarted && x.Value.PlayerCount < x.Value.Options.MaxPlayers && - (_compatibilityConfig.AllowVersionMixing || x.Value.Host == null || x.Value.Host.Client.GameVersion == gameVersion))) + (_compatibilityConfig.AllowVersionMixing || x.Value.Host == null || + this._compatibilityManager.CanJoinGame(x.Value.Host.Client.GameVersion, gameVersion) == GameJoinError.None))) { // Check for options. if (!map.HasFlag((MapFlags)(1 << (byte)game.Options.Map))) diff --git a/src/Impostor.Server/Net/State/Game.Incoming.cs b/src/Impostor.Server/Net/State/Game.Incoming.cs index 95281cf..3d637e4 100644 --- a/src/Impostor.Server/Net/State/Game.Incoming.cs +++ b/src/Impostor.Server/Net/State/Game.Incoming.cs @@ -156,13 +156,10 @@ namespace Impostor.Server.Net.State if (_compatibilityConfig.AllowVersionMixing == false && this.Host != null && client.GameVersion != this.Host.Client.GameVersion) { - if (client.GameVersion < this.Host.Client.GameVersion) + var versionCheckResult = _compatibilityManager.CanJoinGame(Host.Client.GameVersion, client.GameVersion); + if (versionCheckResult != GameJoinError.None) { - return GameJoinResult.FromError(GameJoinError.ClientOutdated); - } - else - { - return GameJoinResult.FromError(GameJoinError.ClientTooNew); + return GameJoinResult.FromError(versionCheckResult); } } diff --git a/src/Impostor.Server/Net/State/Game.Outgoing.cs b/src/Impostor.Server/Net/State/Game.Outgoing.cs index ddb794b..d2bbe06 100644 --- a/src/Impostor.Server/Net/State/Game.Outgoing.cs +++ b/src/Impostor.Server/Net/State/Game.Outgoing.cs @@ -83,8 +83,7 @@ namespace Impostor.Server.Net.State .Select(x => x.Value) .ToArray(); - // TODO: clean up post20220202 when versions before it are no longer supported. - Message07JoinedGameS2C.Serialize(message, clear, Code, player.Client.Id, HostId, players, player.Client.GameVersion >= GameVersion.GetVersion(2022, 2, 2)); + Message07JoinedGameS2C.Serialize(message, clear, Code, player.Client.Id, HostId, players); } private void WriteAlterGameMessage(IMessageWriter message, bool clear, bool isPublic) diff --git a/src/Impostor.Server/Net/State/Game.cs b/src/Impostor.Server/Net/State/Game.cs index b342b42..a83920c 100644 --- a/src/Impostor.Server/Net/State/Game.cs +++ b/src/Impostor.Server/Net/State/Game.cs @@ -12,6 +12,7 @@ using Impostor.Api.Games; using Impostor.Api.Innersloth; using Impostor.Api.Innersloth.GameOptions; using Impostor.Api.Net; +using Impostor.Api.Net.Manager; using Impostor.Api.Net.Messages.S2C; using Impostor.Server.Events; using Impostor.Server.Net.Manager; @@ -29,6 +30,7 @@ namespace Impostor.Server.Net.State private readonly ConcurrentDictionary _players; private readonly HashSet _bannedIps; private readonly IEventManager _eventManager; + private readonly ICompatibilityManager _compatibilityManager; private readonly CompatibilityConfig _compatibilityConfig; private readonly TimeoutConfig _timeoutConfig; @@ -42,6 +44,7 @@ namespace Impostor.Server.Net.State GameFilterOptions filterOptions, ClientManager clientManager, IEventManager eventManager, + ICompatibilityManager compatibilityManager, IOptions compatibilityConfig, IOptions timeoutConfig) { @@ -60,6 +63,7 @@ namespace Impostor.Server.Net.State FilterOptions = filterOptions; _clientManager = clientManager; _eventManager = eventManager; + _compatibilityManager = compatibilityManager; _compatibilityConfig = compatibilityConfig.Value; _timeoutConfig = timeoutConfig.Value; Items = new ConcurrentDictionary(); diff --git a/src/Impostor.Server/Program.cs b/src/Impostor.Server/Program.cs index abd4425..584629f 100644 --- a/src/Impostor.Server/Program.cs +++ b/src/Impostor.Server/Program.cs @@ -101,6 +101,7 @@ namespace Impostor.Server services.Configure(host.Configuration.GetSection(ServerConfig.Section)); services.Configure(host.Configuration.GetSection(TimeoutConfig.Section)); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(p => p.GetRequiredService()); diff --git a/src/Impostor.Server/Recorder/ClientRecorder.cs b/src/Impostor.Server/Recorder/ClientRecorder.cs index 3029652..4671dd3 100644 --- a/src/Impostor.Server/Recorder/ClientRecorder.cs +++ b/src/Impostor.Server/Recorder/ClientRecorder.cs @@ -18,7 +18,7 @@ namespace Impostor.Server.Recorder private bool _createdGame; private bool _recordAfter; - public ClientRecorder(ILogger logger, IOptions antiCheatOptions, ClientManager clientManager, ICustomMessageManager customMessageManager, GameManager gameManager, string name, int gameVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData, HazelConnection connection, PacketRecorder recorder) + public ClientRecorder(ILogger logger, IOptions antiCheatOptions, ClientManager clientManager, ICustomMessageManager customMessageManager, GameManager gameManager, string name, GameVersion gameVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData, HazelConnection connection, PacketRecorder recorder) : base(logger, antiCheatOptions, clientManager, gameManager, customMessageManager, name, gameVersion, language, chatMode, platformSpecificData, connection) { _recorder = recorder; diff --git a/src/Impostor.Server/Recorder/PacketRecorder.cs b/src/Impostor.Server/Recorder/PacketRecorder.cs index f49e4b0..2f8bb1a 100644 --- a/src/Impostor.Server/Recorder/PacketRecorder.cs +++ b/src/Impostor.Server/Recorder/PacketRecorder.cs @@ -194,7 +194,7 @@ namespace Impostor.Server.Recorder context.Writer.Write(addressBytes); context.Writer.Write((ushort)address.Port); context.Writer.Write(client.Name); - context.Writer.Write(client.GameVersion); + context.Writer.Write(client.GameVersion.Value); } } diff --git a/src/Impostor.Tests/CompatibilityManagerTests.cs b/src/Impostor.Tests/CompatibilityManagerTests.cs new file mode 100644 index 0000000..20cb776 --- /dev/null +++ b/src/Impostor.Tests/CompatibilityManagerTests.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.Linq; +using Impostor.Api.Games; +using Impostor.Api.Innersloth; +using Impostor.Api.Net.Manager; +using Impostor.Server.Net.Manager; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Impostor.Tests; + +using CompatibilityGroup = ICompatibilityManager.CompatibilityGroup; +using VersionCompareResult = ICompatibilityManager.VersionCompareResult; + +public sealed class CompatibilityManagerTests +{ + private static readonly CompatibilityGroup[] DefaultSupportedVersions = + { + new[] + { + new GameVersion(1, 0, 0), + }, + + new[] + { + new GameVersion(2, 0, 0), + new GameVersion(2, 1, 0), + }, + }; + + private readonly CompatibilityManager _compatibilityManager = new(NullLogger.Instance, DefaultSupportedVersions); + + public static IEnumerable CanConnectToServerData => + new List + { + new object[] { VersionCompareResult.ClientTooOld, new GameVersion(0, 0, 0) }, + new object[] { VersionCompareResult.ServerTooOld, new GameVersion(100, 0, 0) }, + new object[] { VersionCompareResult.Unknown, new GameVersion(2, 0, 1) }, + }; + + [Theory] + [MemberData(nameof(CanConnectToServerData))] + public void CanConnectToServer(VersionCompareResult versionCompareResult, GameVersion gameVersion) + { + Assert.Equal(versionCompareResult, _compatibilityManager.CanConnectToServer(gameVersion)); + } + + public static IEnumerable CanJoinGameData => + new List + { + new object[] { GameJoinError.None, new GameVersion(1, 0, 0), new GameVersion(1, 0, 0) }, + new object[] { GameJoinError.InvalidClient, new GameVersion(1, 0, 0), new GameVersion(100, 0, 0) }, + new object[] { GameJoinError.ClientOutdated, new GameVersion(2, 0, 0), new GameVersion(1, 0, 0) }, + new object[] { GameJoinError.ClientTooNew, new GameVersion(1, 0, 0), new GameVersion(2, 0, 0) }, + new object[] { GameJoinError.None, new GameVersion(2, 0, 0), new GameVersion(2, 1, 0) }, + }; + + [Theory] + [MemberData(nameof(CanJoinGameData))] + public void CanJoinGame(GameJoinError gameJoinError, GameVersion hostVersion, GameVersion clientVersion) + { + Assert.Equal(gameJoinError, _compatibilityManager.CanJoinGame(hostVersion, clientVersion)); + } + + public static IEnumerable CanConnectAndJoinData => + new List + { + new object[] { new GameVersion(1, 0, 0), new GameVersion(1, 0, 0) }, + new object[] { new GameVersion(2, 0, 0), new GameVersion(2, 0, 0) }, + new object[] { new GameVersion(2, 1, 0), new GameVersion(2, 0, 0) }, + new object[] { new GameVersion(2, 0, 0), new GameVersion(2, 1, 0) }, + new object[] { new GameVersion(2, 0, 0), new GameVersion(2, 0, 0, 25) }, // server authority flag + }; + + [Theory] + [MemberData(nameof(CanConnectAndJoinData))] + public void CanConnectAndJoin(GameVersion hostVersion, GameVersion clientVersion) + { + Assert.Equal(VersionCompareResult.Compatible, _compatibilityManager.CanConnectToServer(hostVersion)); + Assert.Equal(VersionCompareResult.Compatible, _compatibilityManager.CanConnectToServer(clientVersion)); + + Assert.Equal(GameJoinError.None, _compatibilityManager.CanJoinGame(hostVersion, clientVersion)); + } + + [Fact] + public void PublicApi() + { + ICompatibilityManager compatibilityManager = _compatibilityManager; + + { + Assert.NotEqual(VersionCompareResult.Compatible, _compatibilityManager.CanConnectToServer(new GameVersion(2, 2, 0))); + Assert.NotEqual(GameJoinError.None, _compatibilityManager.CanJoinGame(new GameVersion(2, 2, 0), new GameVersion(2, 1, 0))); + + var groupV2 = compatibilityManager.CompatibilityGroups.Single(g => g.GameVersions.Contains(new GameVersion(2, 0, 0))); + compatibilityManager.AddSupportedVersion(groupV2, new GameVersion(2, 2, 0)); + + Assert.Equal(VersionCompareResult.Compatible, _compatibilityManager.CanConnectToServer(new GameVersion(2, 2, 0))); + Assert.Equal(GameJoinError.None, _compatibilityManager.CanJoinGame(new GameVersion(2, 2, 0), new GameVersion(2, 1, 0))); + } + + { + Assert.NotEqual(VersionCompareResult.Compatible, _compatibilityManager.CanConnectToServer(new GameVersion(3, 0, 0))); + + compatibilityManager.AddCompatibilityGroup(new[] { new GameVersion(3, 0, 0), }); + + Assert.Equal(VersionCompareResult.Compatible, _compatibilityManager.CanConnectToServer(new GameVersion(3, 0, 0))); + } + + { + Assert.Equal(VersionCompareResult.Compatible, _compatibilityManager.CanConnectToServer(new GameVersion(1, 0, 0))); + + compatibilityManager.RemoveSupportedVersion(new GameVersion(1, 0, 0)); + + Assert.NotEqual(VersionCompareResult.Compatible, _compatibilityManager.CanConnectToServer(new GameVersion(1, 0, 0))); + } + } +} diff --git a/src/Impostor.Tests/GameVersionTests.cs b/src/Impostor.Tests/GameVersionTests.cs new file mode 100644 index 0000000..8c62a8a --- /dev/null +++ b/src/Impostor.Tests/GameVersionTests.cs @@ -0,0 +1,28 @@ +using Impostor.Api.Innersloth; +using Xunit; + +namespace Impostor.Tests; + +public sealed class GameVersionTests +{ + [Theory] + [InlineData(50588150, 2023, 7, 11)] + [InlineData(50588175, 2023, 7, 11, 25)] + public void Test(int value, int year, int month, int day, int revision = 0) + { + var gameVersion = new GameVersion(year, month, day, revision); + + Assert.Equal(value, gameVersion.Value); + + gameVersion.GetComponents(out var parsedYear, out var parsedMonth, out var parsedDay, out var parsedRevision); + Assert.Equal(year, parsedYear); + Assert.Equal(month, parsedMonth); + Assert.Equal(day, parsedDay); + Assert.Equal(revision, parsedRevision); + + Assert.Equal(year, gameVersion.Year); + Assert.Equal(month, gameVersion.Month); + Assert.Equal(day, gameVersion.Day); + Assert.Equal(revision, gameVersion.Revision); + } +} diff --git a/src/Impostor.Tools.ServerReplay/Program.cs b/src/Impostor.Tools.ServerReplay/Program.cs index 7adeb14..ebfdd08 100644 --- a/src/Impostor.Tools.ServerReplay/Program.cs +++ b/src/Impostor.Tools.ServerReplay/Program.cs @@ -165,7 +165,7 @@ namespace Impostor.Tools.ServerReplay var addressPort = reader.ReadUInt16(); var address = new IPEndPoint(new IPAddress(addressBytes), addressPort); var name = reader.ReadString(); - var gameVersion = reader.ReadInt32(); + var gameVersion = new GameVersion(reader.ReadInt32()); // Create and register connection. var connection = new MockHazelConnection(address); diff --git a/src/ProjectRules.ruleset b/src/ProjectRules.ruleset index a50fadd..445d19b 100644 --- a/src/ProjectRules.ruleset +++ b/src/ProjectRules.ruleset @@ -12,6 +12,7 @@ + -- 2.39.5