using System; using System.Collections.Generic; using System.Reflection; namespace SystemTests; /// /// A cheat ConVar can only be changed while cheats are enabled, so nothing it was set to is /// allowed to outlive that authority - otherwise you could enable cheats in your own game, turn /// something on, and still have it running in somebody else's game. /// [TestClass] [DoNotParallelize] public class CheatConVarTest { // Deliberately plain properties - ManagedCommand takes the attribute as a constructor // argument, so we don't need [ConVar] here and don't drag the codegen into these tests internal static float InitialisedCheat { get; set; } = 15000f; internal static bool InitialisedCheatBool { get; set; } = true; internal static float InitialisedReplicatedCheat { get; set; } = 15000f; internal static float ThrowingGetterCheat { get => throw new InvalidOperationException( "This getter always throws" ); set { } } readonly List registered = new(); bool previousCheatsEnabled; [TestInitialize] public void Setup() { ThreadSafe.MarkMainThread(); previousCheatsEnabled = Game.CheatsEnabled; } [TestCleanup] public void Cleanup() { foreach ( var name in registered ) { ConVarSystem.Members.Remove( name ); } registered.Clear(); Game.CheatsEnabled = previousCheatsEnabled; } T Register( T command ) where T : Command { ConVarSystem.Members[command.Name] = command; registered.Add( command.Name ); return command; } T Add( T command ) where T : Command { registered.Add( command.Name ); ConVarSystem.AddConVar( command ); return command; } /// /// Resetting a cheat ConVar is only safe if we know what its default actually was. These are /// declared as properties with initialisers, so the CLR type default is the wrong answer - /// resetting r_terrain_displacement to default(bool) would turn terrain displacement off. /// [TestMethod] public void DefaultValue_ComesFromPropertyInitialiser() { var command = BuildManagedCommand( nameof( InitialisedCheat ), "test_cheat_initialised" ); Assert.AreEqual( "15000", command.DefaultValue, "Default should be the property's initialised value, not the CLR type default" ); } [TestMethod] public void DefaultValue_ComesFromPropertyInitialiser_Bool() { var command = BuildManagedCommand( nameof( InitialisedCheatBool ), "test_cheat_initialised_bool" ); Assert.AreEqual( "True", command.DefaultValue, "Default should be the property's initialised value, not the CLR type default" ); } /// /// Reading a replicated ConVar goes through the network string table rather than the property's /// own value, so we can't use it to work out a default - a client hotloading addon code while /// connected to a server with cheats on would otherwise capture the host's value as the default, /// and resetting to it would leave the cheat on. Fall back to the type default instead. /// [TestMethod] public void DefaultValue_IgnoresLiveValueForReplicatedConVar() { var command = BuildManagedCommand( nameof( InitialisedReplicatedCheat ), "test_cheat_replicated", ConVarFlags.Cheat | ConVarFlags.Replicated ); Assert.AreEqual( "0", command.DefaultValue, "Replicated convars shouldn't take their default from a value that could have come off the network" ); } /// /// Addon code owns these getters, so one throwing can't stop the ConVar registering at all. /// [TestMethod] public void DefaultValue_SurvivesAThrowingGetter() { var command = BuildManagedCommand( nameof( ThrowingGetterCheat ), "test_cheat_throwing_getter" ); Assert.AreEqual( "0", command.DefaultValue, "A throwing getter should fall back to the type default" ); } static ManagedCommand BuildManagedCommand( string propertyName, string convarName, ConVarFlags flags = ConVarFlags.Cheat ) { var member = typeof( CheatConVarTest ).GetProperty( propertyName, BindingFlags.Static | BindingFlags.NonPublic ); Assert.IsNotNull( member, $"Couldn't find test property {propertyName}" ); var attribute = new ConVarAttribute( convarName, flags ) { Help = "Test convar" }; return new ManagedCommand( typeof( CheatConVarTest ).Assembly, member, attribute ); } [TestMethod] public void ResetCheatConVars_ResetsCheatVariables() { var cheat = Register( new TestConVar( "test_cheat_reset", "1", "0" ) ); ConVarSystem.ResetCheatConVars(); Assert.AreEqual( "0", cheat.Value ); } [TestMethod] public void ResetCheatConVars_LeavesNonCheatVariablesAlone() { var normal = Register( new TestConVar( "test_normal_reset", "1", "0", isCheat: false ) ); ConVarSystem.ResetCheatConVars(); Assert.AreEqual( "1", normal.Value, "A ConVar that isn't a cheat should never be touched" ); } /// /// Addon code owns these setters, so one of them throwing can't be allowed to abandon the /// reset and leave every other cheat switched on. /// [TestMethod] public void ResetCheatConVars_KeepsGoingWhenASetterThrows() { Register( new ThrowingConVar( "test_cheat_throws" ) ); var cheat = Register( new TestConVar( "test_cheat_after_throw", "1", "0" ) ); ConVarSystem.ResetCheatConVars(); Assert.AreEqual( "0", cheat.Value, "A throwing setter must not leave other cheats enabled" ); } /// /// Game assemblies register their ConVars long after a game was torn down, and cookies, the /// command line and hotloads can all bring a value back with them. /// [TestMethod] public void AddConVar_DropsRestoredCheatValueWhenCheatsAreOff() { Game.CheatsEnabled = false; var cheat = Add( new TestConVar( "test_cheat_restored", "0", "0" ) { SavedValue = "1" } ); Assert.AreEqual( "0", cheat.Value, "A saved cheat value must not come back while cheats are off" ); } [TestMethod] public void AddConVar_KeepsRestoredCheatValueWhenCheatsAreOn() { Game.CheatsEnabled = true; var cheat = Add( new TestConVar( "test_cheat_restored_allowed", "0", "0" ) { SavedValue = "1" } ); Assert.AreEqual( "1", cheat.Value, "Cheats are on, so the saved value is allowed" ); } [TestMethod] public void AddConVar_KeepsRestoredValueForNonCheatConVar() { Game.CheatsEnabled = false; var normal = Add( new TestConVar( "test_normal_restored", "0", "0", isCheat: false ) { SavedValue = "1" } ); Assert.AreEqual( "1", normal.Value, "Non-cheat ConVars should still load their saved value" ); } [TestMethod] public void SvCheatsTurningOff_ResetsCheatVariables() { var cheat = Register( new TestConVar( "test_cheat_sv_off", "1", "0" ) ); Game.CheatsEnabled = false; ConVarSystem.InvokeConVarChanged( new TestConVar( "sv_cheats", "0", "False", isCheat: false ), "1" ); Assert.AreEqual( "0", cheat.Value ); } [TestMethod] public void SvCheatsTurningOn_LeavesCheatVariablesAlone() { var cheat = Register( new TestConVar( "test_cheat_sv_on", "1", "0" ) ); Game.CheatsEnabled = true; ConVarSystem.InvokeConVarChanged( new TestConVar( "sv_cheats", "1", "False", isCheat: false ), "0" ); Assert.AreEqual( "1", cheat.Value, "Cheats are on, nothing should be reset" ); } /// /// Tearing a game down gives up cheat authority entirely - see Application.ClearGame. /// [TestMethod] public void ResetCheats_TurnsCheatsOffAndResetsCheatVariables() { var cheat = Register( new TestConVar( "test_cheat_teardown", "1", "0" ) ); var svCheats = Register( new TestConVar( "sv_cheats", "1", "False", isCheat: false ) ); ConVarSystem.ResetCheats(); Assert.AreEqual( "False", svCheats.Value, "Cheats should go back off when a game is torn down" ); Assert.AreEqual( "0", cheat.Value ); } class TestConVar : Command { readonly string defaultValue; /// /// Pretends to have a value saved to a cookie, like a ConVar flagged Saved would. /// public string SavedValue { get; init; } public TestConVar( string name, string value, string defaultValue, bool isCheat = true ) { Name = name; IsConCommand = false; IsCheat = isCheat; Value = value; this.defaultValue = defaultValue; } public override string DefaultValue => defaultValue; public override bool TryLoad( out string loaded ) { loaded = SavedValue; return SavedValue is not null; } } class ThrowingConVar : Command { public ThrowingConVar( string name ) { Name = name; IsConCommand = false; IsCheat = true; } public override string DefaultValue => "0"; public override string Value { get => "1"; set => throw new InvalidOperationException( "This setter always throws" ); } } }