diff --git a/docfx/examples/WithFakeConvars.md b/docfx/examples/WithFakeConvars.md new file mode 100644 index 000000000..d32370e7e --- /dev/null +++ b/docfx/examples/WithFakeConvars.md @@ -0,0 +1,5 @@ +[!INCLUDE [WithFakeConvars](../../examples/WithFakeConvars/README.md)] + +View project on Github + +[!code-csharp[](../../examples/WithFakeConvars/WithFakeConvarsPlugin.cs)] \ No newline at end of file diff --git a/docfx/examples/toc.yml b/docfx/examples/toc.yml index 465bb9897..4fe532dd1 100644 --- a/docfx/examples/toc.yml +++ b/docfx/examples/toc.yml @@ -3,6 +3,8 @@ items: href: HelloWorld.md - name: Commands href: WithCommands.md + - name: Fake ConVars + href: WithFakeConvars.md - name: Config href: WithConfig.md - name: Dependency Injection diff --git a/examples/WithFakeConvars/ConVars.cs b/examples/WithFakeConvars/ConVars.cs new file mode 100644 index 000000000..63cf1d0c3 --- /dev/null +++ b/examples/WithFakeConvars/ConVars.cs @@ -0,0 +1,9 @@ +using CounterStrikeSharp.API.Modules.Cvars; + +namespace WithFakeConvars; + +public static class ConVars +{ + // This convar is registered from the plugin instance but can be used anywhere. + public static FakeConVar ExampleStaticCvar = new("example_static", "An example static cvar"); +} \ No newline at end of file diff --git a/examples/WithFakeConvars/EvenNumberValidator.cs b/examples/WithFakeConvars/EvenNumberValidator.cs new file mode 100644 index 000000000..d13b216d5 --- /dev/null +++ b/examples/WithFakeConvars/EvenNumberValidator.cs @@ -0,0 +1,21 @@ +using CounterStrikeSharp.API.Modules.Cvars.Validators; + +namespace WithFakeConvars; + +// This is an example of a custom validator that checks if a number is even. +public class EvenNumberValidator : IValidator +{ + public bool Validate(int value, out string? errorMessage) + { + if (value % 2 == 0) + { + errorMessage = null; + return true; + } + else + { + errorMessage = "Value must be an even number"; + return false; + } + } +} \ No newline at end of file diff --git a/examples/WithFakeConvars/README.md b/examples/WithFakeConvars/README.md new file mode 100644 index 000000000..55f6fc066 --- /dev/null +++ b/examples/WithFakeConvars/README.md @@ -0,0 +1,2 @@ +# With Fake Convars +This is an example that shows how to register "fake" convars, which are actually console commands that track their internal state. \ No newline at end of file diff --git a/examples/WithFakeConvars/WithFakeConvars.csproj b/examples/WithFakeConvars/WithFakeConvars.csproj new file mode 100644 index 000000000..080fe0c26 --- /dev/null +++ b/examples/WithFakeConvars/WithFakeConvars.csproj @@ -0,0 +1,12 @@ + + + net7.0 + enable + enable + false + false + + + + + diff --git a/examples/WithFakeConvars/WithFakeConvarsPlugin.cs b/examples/WithFakeConvars/WithFakeConvarsPlugin.cs new file mode 100644 index 000000000..d6f36d4c1 --- /dev/null +++ b/examples/WithFakeConvars/WithFakeConvarsPlugin.cs @@ -0,0 +1,58 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes; +using CounterStrikeSharp.API.Modules.Cvars; +using CounterStrikeSharp.API.Modules.Cvars.Validators; + +namespace WithFakeConvars; + +[MinimumApiVersion(168)] +public class WithFakeConvarsPlugin : BasePlugin +{ + public override string ModuleName => "Example: With Fake Convars"; + public override string ModuleVersion => "1.0.0"; + public override string ModuleAuthor => "CounterStrikeSharp & Contributors"; + public override string ModuleDescription => "A simple plugin that registers some console variables"; + + // FakeConVar is a class that can be used to create custom console variables. + // You can specify a name, description, default value, and custom validators. + public FakeConVar BoolCvar = new("example_bool", "An example boolean cvar", true); + + // Range validator is an inbuilt validator that can be used to ensure that a value is within a certain range. + public FakeConVar ExampleIntCvar = new("example_int", "An example integer cvar", 10, flags: ConVarFlags.FCVAR_NONE, new RangeValidator(0, 100)); + + public FakeConVar ExampleFloatCvar = new("example_float", "An example float cvar", 10, flags: ConVarFlags.FCVAR_NONE, new RangeValidator(5, 20)); + public FakeConVar ExampleStringCvar = new("example_string", "An example string cvar", "default"); + + // Replicated, Cheat & Protected flags are supported. + public FakeConVar ExamplePublicCvar = new("example_public_float", "An example public float cvar", 5, + ConVarFlags.FCVAR_REPLICATED); + + // Can only be changed if sv_cheats is enabled. + public FakeConVar ExampleCheatCvar = new("example_cheat_float", "An example cheat float cvar", 5, + ConVarFlags.FCVAR_CHEAT); + + // Protected cvars do not output their value when queried. + public FakeConVar ExampleProtectedCvar = new("example_protected_float", "An example cheat float cvar", 5, + ConVarFlags.FCVAR_PROTECTED); + + // You can create your own custom validators by implementing the IValidator interface. + public FakeConVar ExampleEvenNumberCvar = new("example_even_number", "An example even number cvar", 0, flags: ConVarFlags.FCVAR_NONE, new EvenNumberValidator()); + + public FakeConVar RequiresRestartCvar = new("example_requires_restart", "A cvar that requires a restart when changed"); + + public override void Load(bool hotReload) + { + // You can subscribe to the ValueChanged event to execute code when the value of a cvar changes. + // In this example, we restart the game when the value of RequiresRestartCvar is greater than 5. + RequiresRestartCvar.ValueChanged += (sender, value) => + { + if (value > 5) + { + Server.ExecuteCommand("mp_restartgame 1"); + } + }; + + RegisterFakeConVars(typeof(ConVars)); + } +} \ No newline at end of file diff --git a/managed/CounterStrikeSharp.API/CompatibilitySuppressions.xml b/managed/CounterStrikeSharp.API/CompatibilitySuppressions.xml index a95db9354..aed5411ea 100644 --- a/managed/CounterStrikeSharp.API/CompatibilitySuppressions.xml +++ b/managed/CounterStrikeSharp.API/CompatibilitySuppressions.xml @@ -1051,4 +1051,16 @@ .\ApiCompat\v151.dll obj\Debug\net7.0\CounterStrikeSharp.API.dll + + CP1002 + Microsoft.Extensions.Localization.Abstractions, Version=7.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60 + .\ApiCompat\v151.dll + obj\Debug\net7.0\CounterStrikeSharp.API.dll + + + CP1002 + Serilog, Version=2.0.0.0, Culture=neutral, PublicKeyToken=24c2f752a8e58a10 + .\ApiCompat\v151.dll + obj\Debug\net7.0\CounterStrikeSharp.API.dll + \ No newline at end of file diff --git a/managed/CounterStrikeSharp.API/ConVarFlags.cs b/managed/CounterStrikeSharp.API/ConVarFlags.cs index c8df80cd5..af4f8bda0 100644 --- a/managed/CounterStrikeSharp.API/ConVarFlags.cs +++ b/managed/CounterStrikeSharp.API/ConVarFlags.cs @@ -23,6 +23,7 @@ namespace CounterStrikeSharp.API [Flags] public enum ConVarFlags : Int64 { + FCVAR_NONE = 0, FCVAR_LINKED_CONCOMMAND = (1 << 0), FCVAR_DEVELOPMENTONLY = diff --git a/managed/CounterStrikeSharp.API/Core/BasePlugin.cs b/managed/CounterStrikeSharp.API/Core/BasePlugin.cs index 813e90fed..60ff15abe 100644 --- a/managed/CounterStrikeSharp.API/Core/BasePlugin.cs +++ b/managed/CounterStrikeSharp.API/Core/BasePlugin.cs @@ -28,6 +28,7 @@ using CounterStrikeSharp.API.Modules.Events; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.Modules.Config; +using CounterStrikeSharp.API.Modules.Cvars; using CounterStrikeSharp.API.Modules.Entities; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; @@ -308,6 +309,7 @@ public void RegisterAllAttributes(object instance) this.RegisterAttributeHandlers(instance); this.RegisterConsoleCommandAttributeHandlers(instance); this.RegisterEntityOutputAttributeHandlers(instance); + this.RegisterFakeConVars(instance); } public void InitializeConfig(object instance, Type pluginType) @@ -410,6 +412,43 @@ public void RegisterEntityOutputAttributeHandlers(object instance) } } + public void RegisterFakeConVars(Type type, object instance = null) + { + var convars = type + .GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static) + .Where(prop => prop.FieldType.IsGenericType && + prop.FieldType.GetGenericTypeDefinition() == typeof(FakeConVar<>)); + + foreach (var prop in convars) + { + object propValue = prop.GetValue(instance); // FakeConvar instance + var propValueType = prop.FieldType.GenericTypeArguments[0]; + var name = prop.FieldType.GetProperty("Name", BindingFlags.Public | BindingFlags.Instance) + .GetValue(propValue); + + var description = prop.FieldType.GetProperty("Description", BindingFlags.Public | BindingFlags.Instance) + .GetValue(propValue); + + MethodInfo executeCommandMethod = prop.FieldType + .GetMethod("ExecuteCommand", BindingFlags.Instance | BindingFlags.NonPublic); + + this.AddCommand((string)name, (string) description, (caller, command) => + { + executeCommandMethod.Invoke(propValue, new object[] {caller, command}); + }); + } + } + + /// + /// Used to bind a fake ConVar to a plugin command. Only required for ConVars that are not public properties of the plugin class. + /// + /// + /// + public void RegisterFakeConVars(object instance) + { + RegisterFakeConVars(instance.GetType(), instance); + } + public void HookEntityOutput(string classname, string outputName, EntityIO.EntityOutputHandler handler, HookMode mode = HookMode.Pre) { var subscriber = new CallbackSubscriber(handler, handler, diff --git a/managed/CounterStrikeSharp.API/Modules/Cvars/FakeConVar.cs b/managed/CounterStrikeSharp.API/Modules/Cvars/FakeConVar.cs new file mode 100644 index 000000000..c867f476a --- /dev/null +++ b/managed/CounterStrikeSharp.API/Modules/Cvars/FakeConVar.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.ComponentModel; +using CounterStrikeSharp.API.Modules.Commands; +using CounterStrikeSharp.API.Modules.Cvars.Validators; + +namespace CounterStrikeSharp.API.Modules.Cvars; + +public class FakeConVar where T : IComparable +{ + private readonly IEnumerable>? _customValidators; + + public FakeConVar(string name, string description, T defaultValue = default(T), ConVarFlags flags = ConVarFlags.FCVAR_NONE, + params IValidator[] customValidators) + { + _customValidators = customValidators; + Name = name; + Description = description; + Value = defaultValue; + Flags = flags; + } + + public ConVarFlags Flags { get; set; } + + public string Name { get; } + public string Description { get; } + + public event EventHandler ValueChanged; + + private T _value; + + public T Value + { + get => _value; + set => SetValue(value); + } + + internal void ExecuteCommand(CCSPlayerController? player, CommandInfo args) + { + if (player != null && !Flags.HasFlag(ConVarFlags.FCVAR_REPLICATED)) + { + return; + } + + if (args.ArgCount < 2) + { + if (Flags.HasFlag(ConVarFlags.FCVAR_PROTECTED) && player != null) + { + args.ReplyToCommand($"{args.GetArg(0)} = "); + } + else + { + args.ReplyToCommand($"{args.GetArg(0)} = {Value.ToString()}"); + } + + return; + } + + if (Flags.HasFlag(ConVarFlags.FCVAR_CHEAT)) + { + var cheats = ConVar.Find("sv_cheats")!.GetPrimitiveValue(); + if (!cheats) + { + args.ReplyToCommand($"SV: Convar '{Name}' is cheat protected, change ignored"); + return; + } + } + + if (player != null) + { + return; + } + + try + { + // TODO(dotnet8): Replace with IParsable + bool success = true; + T parsedValue = default(T); + TypeConverter converter = TypeDescriptor.GetConverter(typeof(T)); + if (converter.CanConvertFrom(typeof(string))) + { + try + { + parsedValue = (T)converter.ConvertFromString(args.ArgString); + } + catch + { + success = typeof(T) == typeof(bool) && TryConvertCustomBoolean(args.ArgString, out parsedValue); + } + } + + if (!success) + { + args.ReplyToCommand($"Error: String '{args.GetArg(1)}' can't be converted to {typeof(T).Name}"); + args.ReplyToCommand($"Failed to parse input ConVar '{Name}' from string '{args.GetArg(1)}'"); + return; + } + + SetValue(parsedValue); + } + catch (Exception ex) + { + args.ReplyToCommand($"Error: {ex.Message}"); + } + } + + private bool TryConvertCustomBoolean(string input, out T result) + { + input = input.Trim().ToLowerInvariant(); + if (input == "1" || input == "true") + { + result = (T)(object)true; + return true; + } + else if (input == "0" || input == "false") + { + result = (T)(object)false; + return true; + } + + result = default(T); + return false; + } + + private void SetValue(T value) + { + if (_customValidators != null) + { + foreach (var validator in _customValidators) + { + if (!validator.Validate(value, out var error)) + { + throw new ArgumentException($"{error ?? "Invalid value provided"}"); + } + } + } + + _value = value; + ValueChanged?.Invoke(this, _value); + } +} \ No newline at end of file diff --git a/managed/CounterStrikeSharp.API/Modules/Cvars/Validators/IValidator.cs b/managed/CounterStrikeSharp.API/Modules/Cvars/Validators/IValidator.cs new file mode 100644 index 000000000..e3f4dbc09 --- /dev/null +++ b/managed/CounterStrikeSharp.API/Modules/Cvars/Validators/IValidator.cs @@ -0,0 +1,6 @@ +namespace CounterStrikeSharp.API.Modules.Cvars.Validators; + +public interface IValidator +{ + bool Validate(T value, out string? errorMessage); +} \ No newline at end of file diff --git a/managed/CounterStrikeSharp.API/Modules/Cvars/Validators/RangeValidator.cs b/managed/CounterStrikeSharp.API/Modules/Cvars/Validators/RangeValidator.cs new file mode 100644 index 000000000..fcb2666dc --- /dev/null +++ b/managed/CounterStrikeSharp.API/Modules/Cvars/Validators/RangeValidator.cs @@ -0,0 +1,27 @@ +namespace CounterStrikeSharp.API.Modules.Cvars.Validators; + +public class RangeValidator : IValidator where T : IComparable +{ + private readonly T _min; + private readonly T _max; + + public RangeValidator(T min, T max) + { + _min = min; + _max = max; + } + + public bool Validate(T value, out string? errorMessage) + { + if (value.CompareTo(_min) >= 0 && value.CompareTo(_max) <= 0) + { + errorMessage = null; + return true; + } + else + { + errorMessage = $"Value must be between {_min} and {_max}"; + return false; + } + } +} \ No newline at end of file diff --git a/managed/CounterStrikeSharp.sln b/managed/CounterStrikeSharp.sln index 1fd97fcfd..25ce62def 100644 --- a/managed/CounterStrikeSharp.sln +++ b/managed/CounterStrikeSharp.sln @@ -32,6 +32,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WithTranslations", "..\exam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WithVoiceOverrides", "..\examples\WithVoiceOverrides\WithVoiceOverrides.csproj", "{6FA3107D-42AF-42A0-BF51-2230D13268B5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WithFakeConvars", "..\examples\WithFakeConvars\WithFakeConvars.csproj", "{1309954E-FAF7-47A5-9FF9-C7263B33E4E3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -98,6 +100,10 @@ Global {6FA3107D-42AF-42A0-BF51-2230D13268B5}.Debug|Any CPU.Build.0 = Debug|Any CPU {6FA3107D-42AF-42A0-BF51-2230D13268B5}.Release|Any CPU.ActiveCfg = Release|Any CPU {6FA3107D-42AF-42A0-BF51-2230D13268B5}.Release|Any CPU.Build.0 = Release|Any CPU + {1309954E-FAF7-47A5-9FF9-C7263B33E4E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1309954E-FAF7-47A5-9FF9-C7263B33E4E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1309954E-FAF7-47A5-9FF9-C7263B33E4E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1309954E-FAF7-47A5-9FF9-C7263B33E4E3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {57E64289-5D69-4AA1-BEF0-D0D96A55EE8F} = {7DF99C35-881D-4FF2-B1C9-246BD3DECB9A} @@ -111,5 +117,6 @@ Global {31EABE0B-871F-497B-BF36-37FFC6FAD15F} = {7DF99C35-881D-4FF2-B1C9-246BD3DECB9A} {BB44E08E-CCA8-4E22-A132-11B2F69D1890} = {7DF99C35-881D-4FF2-B1C9-246BD3DECB9A} {6FA3107D-42AF-42A0-BF51-2230D13268B5} = {7DF99C35-881D-4FF2-B1C9-246BD3DECB9A} + {1309954E-FAF7-47A5-9FF9-C7263B33E4E3} = {7DF99C35-881D-4FF2-B1C9-246BD3DECB9A} EndGlobalSection EndGlobal