diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 260214d..822f2dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,8 +43,16 @@ jobs: - name: Build Ptr run: dotnet build src/Ptr/Ptr.csproj --configuration ${{ env.CONFIGURATION }} --no-restore /p:VersionSuffix=${{ steps.get_version.outputs.commit_count }} + - name: Build modules + run: | + dotnet build src/Ptr.Modules.MapManager/Ptr.Modules.MapManager.csproj --configuration ${{ env.CONFIGURATION }} --no-restore /p:VersionSuffix=${{ steps.get_version.outputs.commit_count }} + dotnet build src/Sharp.Modules.AdminManager/Sharp.Modules.AdminManager.csproj --configuration ${{ env.CONFIGURATION }} --no-restore /p:VersionSuffix=${{ steps.get_version.outputs.commit_count }} + dotnet build src/Sharp.Modules.CommandManager/Sharp.Modules.CommandManager.csproj --configuration ${{ env.CONFIGURATION }} --no-restore /p:VersionSuffix=${{ steps.get_version.outputs.commit_count }} + - name: Build Ptr.Shared run: dotnet build src/Ptr.Shared/Ptr.Shared.csproj --configuration ${{ env.CONFIGURATION }} --no-restore /p:VersionSuffix=${{ steps.get_version.outputs.commit_count }} + run: dotnet build src/Sharp.Modules.AdminManager.Shared/Sharp.Modules.AdminManager.Shared.csproj --configuration ${{ env.CONFIGURATION }} --no-restore /p:VersionSuffix=${{ steps.get_version.outputs.commit_count }} + run: dotnet build src/Sharp.Modules.CommandManager.Shared/Sharp.Modules.CommandManager.Shared.csproj --configuration ${{ env.CONFIGURATION }} --no-restore /p:VersionSuffix=${{ steps.get_version.outputs.commit_count }} # Prepare release artifacts - name: Prepare Ptr module artifact @@ -55,6 +63,24 @@ jobs: if [ -f src/Ptr/bin/${{ env.CONFIGURATION }}/net10.0/Ptr.pdb ]; then cp src/Ptr/bin/${{ env.CONFIGURATION }}/net10.0/Ptr.pdb release/sharp/modules/Ptr/ fi + + mkdir -p release/sharp/modules/Ptr.Modules.MapManager + cp src/Ptr.Modules.MapManager/bin/${{ env.CONFIGURATION }}/net10.0/Ptr.Modules.MapManager.dll release/sharp/modules/Ptr.Modules.MapManager/ + if [ -f src/Ptr.Modules.MapManager/bin/${{ env.CONFIGURATION }}/net10.0/Ptr.Modules.MapManager.pdb ]; then + cp src/Ptr.Modules.MapManager/bin/${{ env.CONFIGURATION }}/net10.0/Ptr.Modules.MapManager.pdb release/sharp/modules/Ptr.Modules.MapManager/ + fi + + mkdir -p release/sharp/modules/Sharp.Modules.AdminManager + cp src/Sharp.Modules.AdminManager/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.AdminManager.dll release/sharp/modules/Sharp.Modules.AdminManager/ + if [ -f src/Sharp.Modules.AdminManager/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.AdminManager.pdb ]; then + cp src/Sharp.Modules.AdminManager/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.AdminManager.pdb release/sharp/modules/Sharp.Modules.AdminManager/ + fi + + mkdir -p release/sharp/modules/Sharp.Modules.CommandManager + cp src/Sharp.Modules.CommandManager/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.CommandManager.dll release/sharp/modules/Sharp.Modules.CommandManager/ + if [ -f src/Sharp.Modules.CommandManager/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.CommandManager.pdb ]; then + cp src/Sharp.Modules.CommandManager/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.CommandManager.pdb release/sharp/modules/Sharp.Modules.CommandManager/ + fi - name: Prepare Ptr.Shared artifact run: | @@ -64,6 +90,25 @@ jobs: if [ -f src/Ptr.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Ptr.Shared.pdb ]; then cp src/Ptr.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Ptr.Shared.pdb release/sharp/shared/Ptr.Shared/ fi + + mkdir -p release/sharp/shared/Ptr.Modules.MapManager.Shared + cp src/Ptr.Modules.MapManager.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Ptr.Modules.MapManager.Shared.dll release/sharp/shared/Ptr.Modules.MapManager.Shared/ + if [ -f src/Ptr.Modules.MapManager.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Ptr.Modules.MapManager.Shared.pdb ]; then + cp src/Ptr.Modules.MapManager.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Ptr.Modules.MapManager.Shared.pdb release/sharp/shared/Ptr.Modules.MapManager.Shared/ + fi + + mkdir -p release/sharp/shared/Sharp.Modules.AdminManager.Shared + cp src/Sharp.Modules.AdminManager.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.AdminManager.Shared.dll release/sharp/shared/Sharp.Modules.AdminManager.Shared/ + if [ -f src/Sharp.Modules.AdminManager.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.AdminManager.Shared.pdb ]; then + cp src/Sharp.Modules.AdminManager.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.AdminManager.Shared.pdb release/sharp/shared/Sharp.Modules.AdminManager.Shared/ + fi + + mkdir -p release/sharp/shared/Sharp.Modules.CommandManager.Shared + cp src/Sharp.Modules.CommandManager.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.CommandManager.Shared.dll release/sharp/shared/Sharp.Modules.CommandManager.Shared/ + if [ -f src/Sharp.Modules.CommandManager.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.CommandManager.Shared.pdb ]; then + cp src/Sharp.Modules.CommandManager.Shared/bin/${{ env.CONFIGURATION }}/net10.0/Sharp.Modules.CommandManager.Shared.pdb release/sharp/shared/Sharp.Modules.CommandManager.Shared/ + fi + # Copy assets (gamedata, configs, etc.) - name: Copy assets diff --git a/Ptr.slnx b/Ptr.slnx index 7ed7c21..32200f7 100644 --- a/Ptr.slnx +++ b/Ptr.slnx @@ -1,7 +1,13 @@ + + + + + + diff --git a/src/Ptr.Modules.MapManager.Shared/IMapManager.cs b/src/Ptr.Modules.MapManager.Shared/IMapManager.cs new file mode 100644 index 0000000..a66949f --- /dev/null +++ b/src/Ptr.Modules.MapManager.Shared/IMapManager.cs @@ -0,0 +1,78 @@ +namespace Ptr.Modules.MapManager.Shared; + +/// +/// Map voting style +/// +public enum EMapVoteStyle +{ + /// + /// Native CS2 Endgame panorama vote + /// + Native, + + /// + /// Menu vote + /// + Menu, + + /// + /// Custom voting style + /// + Custom +} + +/// +/// Game Map +/// +public interface IGameMap +{ + /// + /// Map name, for example: de_dust2.
+ /// For workshop map, you have to inspect the actual map name by yourself. + ///
+ string MapName { get; } + + /// + /// Countdown. + /// + int Countdown { get; } + + /// + /// Minimal players that you can vote. + /// + int MinPlayers { get; } + + /// + /// Maximum players that you can vote. + /// + int MaxPlayers { get; } + + /// + /// Is this map workshop map? + /// + bool IsWorkshopMap { get; } + + /// + /// Map workshop id. + /// + ulong? WorkshopId { get; } +} + +public delegate void DelegateOnMapConfigLoaded(); + +public interface IMapManager +{ + const string Identity = nameof(IMapManager); + + event DelegateOnMapConfigLoaded? MapConfigLoaded; + + IEnumerable GetMaps(); + + void SetMapVoteStyle(EMapVoteStyle style); + + bool IsWorkshopMap(string mapName); + + void ChangeLevel(string mapName); + + void ChangeLevel(IGameMap map); +} \ No newline at end of file diff --git a/src/Ptr.Modules.MapManager.Shared/Ptr.Modules.MapManager.Shared.csproj b/src/Ptr.Modules.MapManager.Shared/Ptr.Modules.MapManager.Shared.csproj new file mode 100644 index 0000000..f281ffd --- /dev/null +++ b/src/Ptr.Modules.MapManager.Shared/Ptr.Modules.MapManager.Shared.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + + + + + + diff --git a/src/Ptr.Modules.MapManager/Hooks/ApplyGameSettingsHook.cs b/src/Ptr.Modules.MapManager/Hooks/ApplyGameSettingsHook.cs new file mode 100644 index 0000000..edc9e12 --- /dev/null +++ b/src/Ptr.Modules.MapManager/Hooks/ApplyGameSettingsHook.cs @@ -0,0 +1,64 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Ptr.Shared.Hooks.Abstractions; +using Sharp.Shared; +using Sharp.Shared.Hooks; +using Sharp.Shared.Objects; + +namespace Ptr.Modules.MapManager.Hooks; + +public unsafe class ApplyGameSettingsHook : AbstractVirtualHook +{ + private static ApplyGameSettingsHook _sInstance = null!; + private static nint _sTrampoline = nint.Zero; + private readonly ISharedSystem _sharedSystem; + + public ApplyGameSettingsHook(IModSharpModule module, string name, ISharedSystem sharedSystem, + ILogger logger) : base( + module, name, sharedSystem, logger) + { + _sharedSystem = sharedSystem; + _sInstance = this; + } + + protected override void Prepare(IVirtualHook hook) + { + hook.Prepare(Dll, Class, Function, (nint)(delegate* unmanaged)&Hook); + } + + protected override void InternalShutdown() + { + _sTrampoline = nint.Zero; + } + + protected override void InternalPostInstall(IntPtr trampoline) + { + _sTrampoline = trampoline; + } + + private void ReadConfig(IKeyValues kv) + { + var mapGroup = kv.FindKey("launchoptions")?.GetString("mapgroup") ?? string.Empty; + InterfaceBridge.Instance.CurrentMapGroup = mapGroup; + } + + [UnmanagedCallersOnly] + public static void Hook(nint pService, nint pKeyValues) + { + var trampoline = (delegate* unmanaged)_sTrampoline; + + if (_sInstance.CreateKeyValues(pKeyValues) is { } kv) + { + _sInstance.ReadConfig(kv); + } + + trampoline(pService, pKeyValues); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private IKeyValues? CreateKeyValues(nint ptr) + { + return ptr == nint.Zero ? null : _sharedSystem.GetModSharp().CreateNativeObject(ptr); + } +} \ No newline at end of file diff --git a/src/Ptr.Modules.MapManager/InterfaceBridge.cs b/src/Ptr.Modules.MapManager/InterfaceBridge.cs new file mode 100644 index 0000000..2c63d95 --- /dev/null +++ b/src/Ptr.Modules.MapManager/InterfaceBridge.cs @@ -0,0 +1,145 @@ +// ReSharper disable UnusedParameter.Local + +#pragma warning disable IDE0290 + +using Microsoft.Extensions.Configuration; +using Ptr.Modules.MapManager.Shared; +using Ptr.Shared.Misc; +using Sharp.Modules.CommandManager.Shared; +using Sharp.Shared; +using Sharp.Shared.Managers; +using Sharp.Shared.Objects; + +namespace Ptr.Modules.MapManager; + +internal record GameMapManifest(string MapName, int? Countdown, int? MinPlayers, int? MaxPlayers, ulong? WorkshopId); + +internal class GameMap : IGameMap +{ + public GameMap(string name, int countdown, int minPlayers, int maxPlayers) + { + MapName = name; + Countdown = countdown; + MinPlayers = minPlayers; + MaxPlayers = maxPlayers; + } + + public string MapName { get; init; } + public int Countdown { get; init; } + public int MinPlayers { get; init; } + public int MaxPlayers { get; init; } + + public bool IsWorkshopMap => WorkshopId is not null; + + public ulong? WorkshopId { get; set; } +} + +internal class PreviousGameMap +{ + public PreviousGameMap(string mapName) + { + MapName = mapName; + } + + public string MapName { get; } + public int RemainingTimes { get; set; } +} + +/// +/// Global context. +/// +internal class InterfaceBridge +{ + private readonly ISharedSystem _sharedSystem; + + + public InterfaceBridge(ISharedSystem sharedSystem, + string dllPath, + string sharpPath, + Version version, + IConfiguration coreConfiguration, + bool hotReload, + ChatMessageFormatter formatter, MapManager mapManager) + { + _sharedSystem = sharedSystem; + MapManager = mapManager; + DllPath = dllPath; + SharpPath = sharpPath; + Version = version; + CoreConfiguration = coreConfiguration; + IsHotReload = hotReload; + ChatFormatter = formatter; + ModuleIdentity = Path.GetFileName(dllPath); + Instance = this; + } + + public MapManager MapManager { get; init; } + public string ModuleIdentity { get; init; } + + /// + /// 开洞,一般情况下别用! + /// + internal static InterfaceBridge Instance { get; private set; } = null!; + + public ISteamApi SteamApi => ModSharp.GetSteamGameServer(); + + public IEntityManager EntityManager => _sharedSystem.GetEntityManager(); + public IClientManager ClientManager => _sharedSystem.GetClientManager(); + public IConVarManager ConVarManager => _sharedSystem.GetConVarManager(); + public ITransmitManager TransmitManager => _sharedSystem.GetTransmitManager(); + public IHookManager HookManager => _sharedSystem.GetHookManager(); + public IEventManager EventManager => _sharedSystem.GetEventManager(); + public IFileManager FileManager => _sharedSystem.GetFileManager(); + public ISchemaManager SchemaManager => _sharedSystem.GetSchemaManager(); + public IEconItemManager EconItemManager => _sharedSystem.GetEconItemManager(); + public ILibraryModuleManager LibraryModuleManager => _sharedSystem.GetLibraryModuleManager(); + public ISoundManager SoundManager => _sharedSystem.GetSoundManager(); + public IPhysicsQueryManager PhysicsQueryManager => _sharedSystem.GetPhysicsQueryManager(); + + public IModSharp ModSharp => _sharedSystem.GetModSharp(); + + /// + /// CGlobalVars* gpGlobals,没什么好说的。
+ /// 注意,一定要在地图加载之后调用!不然服务器第一次加载的时候是拿不到的! + ///
+ public IGlobalVars GlobalVars => ModSharp.GetGlobals(); + + /// + /// CGameRules* g_pGameRules
+ /// 注意,一定要在地图加载之后调用!不然服务器第一次加载的时候是拿不到的! + ///
+ public IGameRules GameRules => ModSharp.GetGameRules(); + + public INetworkServer Server => ModSharp.GetIServer(); + public IGameData GameData => ModSharp.GetGameData(); + public ISharpModuleManager SharpModuleManager => _sharedSystem.GetSharpModuleManager(); + + public ChatMessageFormatter ChatFormatter { get; init; } + + public string DllPath { get; init; } + + public string SharpPath { get; init; } + + public Version? Version { get; init; } + + public IConfiguration? CoreConfiguration { get; init; } + + public bool IsHotReload { get; init; } + + public List PreviousGameMaps { get; init; } = []; + + public List Maps { get; init; } = []; + + public List NominatedMaps { get; init; } = []; + + public EMapVoteStyle MapVoteStyle { get; set; } = EMapVoteStyle.Native; + + public DateTime AllowVoteTime { get; set; } + + /// + /// 开洞,一般情况下别用! + /// + public string CurrentMapGroup { get; set; } = string.Empty; + + //public ICommandRegistry CommandRegistry { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Ptr.Modules.MapManager/MapManager.cs b/src/Ptr.Modules.MapManager/MapManager.cs new file mode 100644 index 0000000..5fada73 --- /dev/null +++ b/src/Ptr.Modules.MapManager/MapManager.cs @@ -0,0 +1,236 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Ptr.Modules.MapManager.Hooks; +using Ptr.Modules.MapManager.Services; +using Ptr.Modules.MapManager.Shared; +using Ptr.Shared.Extensions; +using Ptr.Shared.Hooks.Hosting; +using Ptr.Shared.Hooks.Managers; +using Ptr.Shared.Hosting; +using Ptr.Shared.Misc; +using Sharp.Shared; +using Sharp.Shared.Abstractions; +using Sharp.Shared.Listeners; +using Sharp.Shared.Objects; + +namespace Ptr.Modules.MapManager; + +internal class MapManager : IModSharpModule, IMapManager, IGameListener +{ + private readonly InterfaceBridge _bridge; + private readonly IConVar? _countdownAfterChangeLevel; + private readonly ILogger _logger; + private readonly IServiceProvider _provider; + private readonly IConVar? _voteSuccessRatio; + + public MapManager( + ISharedSystem sharedSystem, + string dllPath, + string sharpPath, + Version version, + IConfiguration configuration, + bool hotReload) + { + var formatter = new ChatMessageFormatter(); + formatter.SetPrefix("{green}[地图]{white}"); + var bridge = new InterfaceBridge(sharedSystem, dllPath, sharpPath, version, configuration, hotReload, formatter, + this); + var services = new ServiceCollection(); + services.AddSingleton(sharedSystem); + services.AddSingleton(bridge); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHook("server@CSource2Server::ApplyGameSettings"); + services.AddSingleton(this); + services.AddSingleton(sharedSystem.GetLoggerFactory()); + services.AddLogging(x => x.ClearProviders()); + + var provider = services.BuildServiceProvider(); + provider.UseHook(); + _bridge = provider.GetRequiredService(); + _logger = sharedSystem.GetLoggerFactory().CreateLogger(); + + _provider = provider; + + _countdownAfterChangeLevel = _bridge.ConVarManager.CreateConVar("mapmanager_countdown_after_changelevel", 180, + "Countdown after map change (unit is sec.)"); + + _voteSuccessRatio = _bridge.ConVarManager.CreateConVar("mapmanager_vote_success_ratio", 0.6f, + "Ratio request for a success vote."); + } + + internal int GetVoteSuccessNumberRequested() + { + var ratio = _voteSuccessRatio!.GetFloat(); + var clients = _bridge.Server.GetGameClients(true, true).Count(x => !x.IsFakeClient); + var result = (int)MathF.Floor(ratio * clients); + + return result <= 0 ? 1 : result; + } + + private void InitConfig() + { + _bridge.Maps.Clear(); + + var cfgPath = Path.Combine(_bridge.SharpPath, "configs", "mapmanager", "maplist.jsonc"); + if (!Path.Exists(cfgPath)) + { + _logger.LogWarning("{CfgPath} is missing, failed to load game maps, map cycle may not work!", cfgPath); + return; + } + + var cfgContent = File.ReadAllText(cfgPath); + var results = JsonSerializer.Deserialize>(cfgContent, new JsonSerializerOptions + { + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip + }) ?? []; + + foreach (var manifest in results) + { + var mapName = manifest.MapName; + var countdown = manifest.Countdown ?? 3; + var minPlayers = manifest.MinPlayers ?? 0; + var maxPlayers = manifest.MaxPlayers ?? 0; + var workshopId = manifest.WorkshopId; + var obj = new GameMap(mapName, countdown, minPlayers, maxPlayers) + { + WorkshopId = workshopId + }; + _bridge.Maps.Add(obj); + } + + //CallMapConfigLoaded(); + } + + private void CallMapConfigLoaded() + { + if (MapConfigLoaded is null) + { + return; + } + + foreach (var @delegate in MapConfigLoaded.GetInvocationList()) + { + try + { + ((DelegateOnMapConfigLoaded)@delegate).Invoke(); + } + catch (Exception e) + { + _logger.LogError(e, "An error occurred when calling delegate {Delegate}", nameof(MapConfigLoaded)); + } + } + } + + #region IModSharpModule + + public bool Init() + { + InitConfig(); + + _provider.LoadAllSharpExtensions(); + _provider.InitNativeHooks(); + _provider.CallInit(e => { _logger.LogError(e, "An error occurred when initializing modules"); }); + + return true; + } + + public void PostInit() + { + _bridge.SharpModuleManager.RegisterSharpModuleInterface(this, IMapManager.Identity, this); + } + + public void OnLibraryConnected(string name) + { + _provider.CallLibraryConnected(name, e => + { + _logger.LogError(e, "An error occurred when calling OnLibraryConnected."); + }); + } + + public void OnAllModulesLoaded() + { + CallMapConfigLoaded(); + } + + public void Shutdown() + { + _provider.CallShutdown(e => { _logger.LogError(e, "An error occurred when shutting down modules"); }); + _provider.ShutdownNativeHooks(); + _provider.ShutdownAllSharpExtensions(); + _bridge.Maps.Clear(); + MapConfigLoaded = null; + } + + string IModSharpModule.DisplayName => "Ptr.Modules.MapManager"; + string IModSharpModule.DisplayAuthor => "laper32"; + + #endregion + + #region IMapManager + + public event DelegateOnMapConfigLoaded? MapConfigLoaded; + + public IEnumerable GetMaps() + { + return _bridge.Maps; + } + + public void SetMapVoteStyle(EMapVoteStyle style) + { + _bridge.MapVoteStyle = style; + } + + public bool IsWorkshopMap(string mapName) + { + return _bridge.Maps.FirstOrDefault(x => x.MapName.Equals(mapName, StringComparison.OrdinalIgnoreCase)) is + { + IsWorkshopMap: true + }; + } + + public void ChangeLevel(string mapName) + { + if (_bridge.Maps.FirstOrDefault(x => x.MapName.Equals(mapName, StringComparison.OrdinalIgnoreCase)) is not + { } map) + { + _logger.LogError("Map {MapName} does not found.", mapName); + return; + } + + ChangeLevel(map); + } + + public void ChangeLevel(IGameMap map) + { + // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression + if (map.IsWorkshopMap) + { + _bridge.ModSharp.ServerCommand($"ds_workshop_changelevel {map.MapName}"); + } + else + { + _bridge.ModSharp.ServerCommand($"changelevel {map.MapName}"); + } + } + + #endregion + + #region IGameListener + + public void OnServerActivate() + { + _bridge.AllowVoteTime = DateTime.Now.AddSeconds(_countdownAfterChangeLevel?.GetInt32() ?? 180); + } + + int IGameListener.ListenerPriority => 0; + + int IGameListener.ListenerVersion => IGameListener.ApiVersion; + + #endregion +} \ No newline at end of file diff --git a/src/Ptr.Modules.MapManager/Ptr.Modules.MapManager.csproj b/src/Ptr.Modules.MapManager/Ptr.Modules.MapManager.csproj new file mode 100644 index 0000000..23526c3 --- /dev/null +++ b/src/Ptr.Modules.MapManager/Ptr.Modules.MapManager.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + diff --git a/src/Ptr.Modules.MapManager/Services/ExtendService.cs b/src/Ptr.Modules.MapManager/Services/ExtendService.cs new file mode 100644 index 0000000..b3a58d2 --- /dev/null +++ b/src/Ptr.Modules.MapManager/Services/ExtendService.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.Logging; +using Ptr.Shared.Extensions; +using Ptr.Shared.Hosting; +using Sharp.Modules.CommandManager.Shared; +using Sharp.Shared.Definition; +using Sharp.Shared.Enums; +using Sharp.Shared.Listeners; +using Sharp.Shared.Objects; +using Sharp.Shared.Types; + +namespace Ptr.Modules.MapManager.Services; + +internal interface IExtendService : IModule; + +internal class ExtendService : IExtendService, IClientListener, IGameListener +{ + private readonly InterfaceBridge _bridge; + private readonly IConVar? _enableExtend; + + private readonly bool[] _extClients = new bool[64]; + private readonly IConVar? _extendTime; + private readonly ILogger _logger; + private readonly IConVar? _maxExtCount; + private int _extCount; + private ICommandRegistry _commandRegistry = null!; + + public ExtendService(InterfaceBridge bridge, ILogger logger) + { + _bridge = bridge; + _logger = logger; + _enableExtend = _bridge.ConVarManager.CreateConVar("mapmanager_enable_extend", true, "Enable ext"); + _maxExtCount = _bridge.ConVarManager.CreateConVar("mapmanager_max_extend_count", 3, + "Maximum allowed extend map time limit count."); + _extendTime = + _bridge.ConVarManager.CreateConVar("mapmanager_ext_time", 15, "The extend applies for time limit."); + } + + private void OnCommandExt(IGameClient client, StringCommand command) + { + var remaining = (int)(_bridge.AllowVoteTime - DateTime.Now).TotalSeconds; + if (remaining > 0) + { + client.PrintToChat( + _bridge.ChatFormatter.Format($"{ChatColor.Green}{remaining}{ChatColor.White} 秒后才能发起延长地图时间投票。")); + return; + } + + var maxAllowed = _maxExtCount!.GetInt32(); + if (_extCount >= maxAllowed) + { + client.PrintToChat(_bridge.ChatFormatter.Format("已到达最大可延长时间次数。")); + return; + } + + if (_extClients[client.Slot]) + { + client.PrintToChat("你已经投票过延长地图时间了。"); + return; + } + + _extClients[client.Slot] = true; + var current = _extClients.Count(t => t); + var request = _bridge.MapManager.GetVoteSuccessNumberRequested(); + var clientGaps = request - current; + client.PrintToChat( + $"已有 {ChatColor.Green}{current}{ChatColor.White} 人投票延长地图时间,还需 {ChatColor.Green}{clientGaps}{ChatColor.White} 票。"); + if (clientGaps > 0) + { + return; + } + + _bridge.ModSharp.PrintToChatAll("投票通过,即将延长地图持续时间。"); + var timeLimit = _bridge.ConVarManager.FindConVar("mp_timelimit")!; + var currentTimeLeft = timeLimit.GetFloat(); + var pendingExtendTime = _extendTime?.GetFloat() ?? 15.0f; + var nextTimeLeft = currentTimeLeft + pendingExtendTime; + timeLimit.Set($"{nextTimeLeft}"); + _extCount++; + } + + + private void ResetClientExtState(IGameClient client) + { + _extClients[client.Slot] = false; + } + + private void ResetClientsExt() + { + Array.Fill(_extClients, false); + } + + private void ResetExtCount() + { + _extCount = 0; + } + + #region IModule + + public void OnInit() + { + if (_enableExtend?.GetBool() is true) + { + return; + } + + _logger.LogInformation("Ext is disabled."); + } + + public void OnAllModulesLoaded() + { + if (_enableExtend?.GetBool() is not true) + { + return; + } + + _commandRegistry = _bridge.SharpModuleManager + .GetRequiredSharpModuleInterface(ICommandManager.Identity).Instance! + .GetRegistry(_bridge.ModuleIdentity); + + _commandRegistry.RegisterClientCommand("ext", OnCommandExt); + } + + public void OnShutdown() + { + if (_enableExtend?.GetBool() is not true) + { + return; + } + + ResetExtCount(); + ResetClientsExt(); + } + + #endregion + + #region IClientListener + + public void OnClientPutInServer(IGameClient client) + { + ResetClientExtState(client); + } + + public void OnClientDisconnected(IGameClient client, NetworkDisconnectionReason reason) + { + ResetClientExtState(client); + } + + int IClientListener.ListenerPriority => 0; + int IClientListener.ListenerVersion => IClientListener.ApiVersion; + + #endregion + + #region IGameListener + + public void OnGameDeactivate() + { + ResetClientsExt(); + } + + int IGameListener.ListenerPriority => 0; + + int IGameListener.ListenerVersion => IGameListener.ApiVersion; + + #endregion +} \ No newline at end of file diff --git a/src/Ptr.Modules.MapManager/Services/MapVoteService.cs b/src/Ptr.Modules.MapManager/Services/MapVoteService.cs new file mode 100644 index 0000000..0cacee5 --- /dev/null +++ b/src/Ptr.Modules.MapManager/Services/MapVoteService.cs @@ -0,0 +1,272 @@ +using Microsoft.Extensions.Logging; +using Ptr.Modules.MapManager.Shared; +using Ptr.Shared.Extensions; +using Ptr.Shared.Hosting; +using Sharp.Shared.Enums; +using Sharp.Shared.HookParams; +using Sharp.Shared.Listeners; +using Sharp.Shared.Objects; +using Sharp.Shared.Types; + +namespace Ptr.Modules.MapManager.Services; + +internal interface IMapVoteService : IModule; + +internal class MapVoteService : IMapVoteService, IGameListener +{ + private const int MapVoteSize = 10; + private readonly InterfaceBridge _bridge; + private readonly int[] _clientVoteOptions = new int[64]; + private readonly ILogger _logger; + + + public MapVoteService(InterfaceBridge bridge, ILogger logger) + { + _bridge = bridge; + _logger = logger; + } + + public void OnInit() + { + _bridge.ModSharp.InstallGameListener(this); + _bridge.HookManager.MapVoteCreated.InstallForward(OnMapVoteCreated); + _bridge.ClientManager.InstallCommandListener("endmatch_votenextmap", OnEndMatchVoteNextMap); + } + + public void OnShutdown() + { + _bridge.ModSharp.RemoveGameListener(this); + _bridge.HookManager.MapVoteCreated.RemoveForward(OnMapVoteCreated); + _bridge.ClientManager.RemoveCommandListener("endmatch_votenextmap", OnEndMatchVoteNextMap); + } + + private ECommandAction OnEndMatchVoteNextMap(IGameClient client, StringCommand command) + { + var option = command.Get(1); + UpdateVoteOption(client, option); + return ECommandAction.Skipped; + } + + + private void OnMapVoteCreated(IMapVoteCreatedForwardParams @params) + { + // GetMapGroupMapList的索引是可以直接和EndMatchMapGroupVoteOptions对应的 + if (_bridge.ModSharp.GetMapGroupMapList(_bridge.CurrentMapGroup) is not { } mapGroupElements) + { + _logger.LogInformation("Current map group {CurrentMapGroup} cannot retrive map list, check your map group configuration!", _bridge.CurrentMapGroup); + return; + } + + var voteMaps = SelectMapsForVote(); + _logger.LogInformation("Selected maps:\n{Maps}", + string.Join("\n", voteMaps.Select(map => $" - {map.MapName}"))); + + for (var i = 0; i < MapVoteSize; i++) + { + @params.GameRules.GetEndMatchMapGroupVoteTypes()[i] = 0; // casual + @params.GameRules.GetEndMatchMapGroupVoteOptions()[i] = 0; // casual + } + + var voteOptions = @params.GameRules.GetEndMatchMapGroupVoteOptions(); + + // Find indices in MapGroupElements for each selected map + for (var i = 0; i < Math.Min(voteMaps.Count, MapVoteSize); i++) + { + // List's index + var selectedMap = voteMaps[i]; + + // Find the index of this map in GetMapGroupMapList() + var mapIndex = -1; + for (var j = 0; j < mapGroupElements.Count; j++) + { + if (!mapGroupElements[j].Equals(selectedMap.MapName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + mapIndex = j; + break; + } + + // Set the vote option to the GetMapGroupMapList() index + if (mapIndex >= 0) + { + voteOptions[i] = mapIndex; + } + else + { + voteOptions[i] = i; // Fallback to sequential index + } + } + + // delay call to get summary. + var endMatchVoteNextLevelTime = _bridge.ConVarManager.FindConVar("mp_endmatch_votenextleveltime")!.GetFloat(); + _bridge.ModSharp.DelayCall(endMatchVoteNextLevelTime + 2, SummaryVote); + } + + private List GetAvailableMaps() + { + // Get maps that are not in countdown + var mapsNotInCountdown = _bridge.Maps.Where(map => + { + var mapInCountdown = _bridge.PreviousGameMaps.Any(prevMap => + prevMap.MapName.Equals(map.MapName, StringComparison.OrdinalIgnoreCase)); + return !mapInCountdown; + }) + .ToList(); + + + return mapsNotInCountdown; + } + + private List GetNominatedGameMaps() + { + // Convert nominated map names to GameMap objects + return _bridge.NominatedMaps + .Select(nominatedMapName => _bridge.Maps.FirstOrDefault(map => + map.MapName.Equals(nominatedMapName, StringComparison.OrdinalIgnoreCase))) + .OfType() + .ToList(); + } + + private List SelectMapsForVote(int maxMaps = 10) + { + var selectedMaps = new List(); + var availableMaps = GetAvailableMaps(); + var nominatedMaps = GetNominatedGameMaps(); + var currentMapName = _bridge.GlobalVars.MapName; + + // Filter out the current map from nominated maps + var validNominatedMaps = nominatedMaps + .Where(map => !map.MapName.Equals(currentMapName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // First, add all valid nominated maps (they must be included, but not current map) + selectedMaps.AddRange(validNominatedMaps); + + // Remove nominated maps and current map from available maps to avoid duplicates + var remainingAvailableMaps = availableMaps + .Where(map => !validNominatedMaps.Any(nominated => + nominated.MapName.Equals(map.MapName, StringComparison.OrdinalIgnoreCase))) + .Where(map => !map.MapName.Equals(currentMapName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // Calculate how many more maps we need + var remainingSlots = maxMaps - selectedMaps.Count; + + if (remainingSlots > 0 && remainingAvailableMaps.Count > 0) + { + // Randomly select from remaining available maps + var additionalMaps = remainingAvailableMaps + .Shuffle() + .Take(remainingSlots) + .ToList(); + + selectedMaps.AddRange(additionalMaps); + } + + // Shuffle the final list to randomize order + var shuffledMaps = selectedMaps.Shuffle(); + + return shuffledMaps.ToList(); + } + + + private void SummaryVote() + { + // GetMapGroupMapList的索引是可以直接和EndMatchMapGroupVoteOptions对应的 + if (_bridge.ModSharp.GetMapGroupMapList(_bridge.CurrentMapGroup) is not { } mapGroupElements) + { + return; + } + + var voteOptions = _bridge.GameRules.GetEndMatchMapGroupVoteOptions(); + var mapVotes = new int[10]; + var clients = _bridge.Server.GetGameClients(true, true); + foreach (var client in clients) + { + var voteSelectionIndex = _clientVoteOptions[client.Slot]; + if (voteSelectionIndex < 0) + { + continue; + } + + mapVotes[voteSelectionIndex]++; + } + + var hasAnyVotes = false; + var maxVotes = 0; + var winningIndex = -1; + + for (var i = 0; i < MapVoteSize; i++) + { + var opt = voteOptions[i]; + if (opt < 0) + { + continue; + } + + var votes = mapVotes[i]; + + if (votes > 0) + { + hasAnyVotes = true; + } + + Console.WriteLine($" - {mapGroupElements[opt]} got {votes} votes."); + if (votes <= maxVotes) + { + continue; + } + + maxVotes = votes; + winningIndex = i; + } + + if (!hasAnyVotes) + { + _bridge.ModSharp.DelayCall(2.0, ForceChangeMap); + return; + } + + var winningMapIndex = voteOptions[winningIndex]; + var winningMapName = mapGroupElements[winningMapIndex]; + var winningMap = _bridge.Maps.First(map => + map.MapName.Equals(winningMapName, StringComparison.OrdinalIgnoreCase)); + + _logger.LogInformation("Map {MapName} won with {Votes} votes!", winningMapName, maxVotes); + + _bridge.ModSharp.DelayCall(2.0, () => { _bridge.MapManager.ChangeLevel(winningMap); }); + } + + private void ForceChangeMap() + { + var maps = SelectMapsForVote(); + var randomOne = maps.Shuffle().First(); + Console.WriteLine($"No any selection. Randomly choose a map, that is: {randomOne.MapName}"); + _bridge.MapManager.ChangeLevel(randomOne); + } + + private void UpdateVoteOption(IGameClient client, int voteOption) + { + Console.WriteLine($"player {client.Name} vote opt: {voteOption}"); + _clientVoteOptions[client.Slot] = voteOption; + } + + private void ResetAllClientVoteOptions() + { + Array.Fill(_clientVoteOptions, -1); + } + + #region IGameListener + + public void OnGameDeactivate() + { + ResetAllClientVoteOptions(); + } + + int IGameListener.ListenerVersion => IGameListener.ApiVersion; + int IGameListener.ListenerPriority => 0; + + #endregion +} \ No newline at end of file diff --git a/src/Ptr.Modules.MapManager/Services/NominateService.cs b/src/Ptr.Modules.MapManager/Services/NominateService.cs new file mode 100644 index 0000000..6ab8dba --- /dev/null +++ b/src/Ptr.Modules.MapManager/Services/NominateService.cs @@ -0,0 +1,116 @@ +using Microsoft.Extensions.Logging; +using Ptr.Shared.Extensions; +using Ptr.Shared.Hosting; +using Sharp.Modules.CommandManager.Shared; +using Sharp.Shared.Definition; +using Sharp.Shared.Objects; +using Sharp.Shared.Types; + +namespace Ptr.Modules.MapManager.Services; + +internal interface INominateService : IModule; + +internal class NominateService : INominateService +{ + private readonly IConVar? _activateNominateMinPlayers; + private readonly InterfaceBridge _bridge; + + private readonly IConVar? _enableNominate; + private readonly ILogger _logger; + + public NominateService(InterfaceBridge bridge, ILogger logger) + { + _bridge = bridge; + _logger = logger; + + _enableNominate = _bridge.ConVarManager.CreateConVar("mapmanager_enable_nominate", true, "Enable nominate"); + _activateNominateMinPlayers = _bridge.ConVarManager.CreateConVar("mapmanager_activate_nominate_min_players", 5, + "minimal players count to activate nominate."); + } + + public void OnInit() + { + if (_enableNominate?.GetBool() is true) + { + return; + } + + _logger.LogInformation("Nomination is disabled."); + } + + public void OnAllModulesLoaded() + { + if (_enableNominate?.GetBool() is not true) + { + return; + } + + _bridge + .SharpModuleManager + .GetRequiredSharpModuleInterface(ICommandManager.Identity) + .Instance! + .GetRegistry(_bridge.ModuleIdentity) + .RegisterClientCommand("nominate", OnCommandNominate); + } + + public void OnShutdown() + { + if (_enableNominate?.GetBool() is not true) + { + return; + } + + _bridge.NominatedMaps.Clear(); + } + + private void OnCommandNominate(IGameClient client, StringCommand command) + { + var clientsCount = _bridge.Server.GetGameClients(true, true).Count; + var leastActivateNominateCount = _activateNominateMinPlayers?.GetInt32() ?? 5; + + if (clientsCount < leastActivateNominateCount) + { + client.PrintToChat(_bridge.ChatFormatter.Format( + $"当前在线人数不足 {ChatColor.Green}{leastActivateNominateCount}{ChatColor.White} 人,无法提名地图。")); + return; + } + + if (command.ArgCount < 1) + { + client.PrintToChat(_bridge.ChatFormatter.Format("用法:.nominate <地图名>")); + return; + } + + var map = command[1]; + if (_bridge.NominatedMaps.Contains(map, StringComparer.OrdinalIgnoreCase)) + { + client.PrintToChat("该地图已经被提名过了。"); + return; + } + + if (_bridge.PreviousGameMaps.Exists(x => x.MapName.Equals(map, StringComparison.OrdinalIgnoreCase))) + { + client.PrintToChat("该地图最近已经玩过了,目前无法提名。"); + return; + } + + if (_bridge.Maps.Exists(x => x.MapName.Equals(map, StringComparison.OrdinalIgnoreCase))) + { + client.PrintToChat("无法从图池中找到该地图。"); + return; + } + + var currentMap = _bridge.GlobalVars.MapName; + if (currentMap.Equals(map, StringComparison.OrdinalIgnoreCase)) + { + client.PrintToChat("不能投票正在游玩的地图。"); + return; + } + + _bridge.NominatedMaps.Add(map); + _bridge.ModSharp.PrintToChatAll(_bridge.ChatFormatter.Format( + $"{ChatColor.Green}{client.Name}{ChatColor.White} 提名了地图 {ChatColor.Green}{map}{ChatColor.White}。")); + + _logger.LogInformation("{ClientName} nominated {Map}", client.Name, map); + } +} \ No newline at end of file diff --git a/src/Ptr.Modules.MapManager/Services/RtvService.cs b/src/Ptr.Modules.MapManager/Services/RtvService.cs new file mode 100644 index 0000000..294345a --- /dev/null +++ b/src/Ptr.Modules.MapManager/Services/RtvService.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.Logging; +using Ptr.Shared.Extensions; +using Ptr.Shared.Hosting; +using Sharp.Shared.Definition; +using Sharp.Shared.Enums; +using Sharp.Shared.Listeners; +using Sharp.Shared.Objects; + +namespace Ptr.Modules.MapManager.Services; + +internal interface IRtvService : IModule; + +internal class RtvService : IRtvService, IGameListener, IClientListener +{ + private readonly InterfaceBridge _bridge; + + private readonly IConVar? _enableRtv; + private readonly ILogger _logger; + private readonly bool[] _rtvPlayers = new bool[64]; + + public RtvService(InterfaceBridge bridge, ILogger logger) + { + _bridge = bridge; + _logger = logger; + + + _enableRtv = _bridge.ConVarManager.CreateConVar("mapmanager_enable_rtv", true, "Enable RTV"); + } + + private void AttemptRtv(IGameClient client) + { + var remaining = (int)(_bridge.AllowVoteTime - DateTime.Now).TotalSeconds; + if (remaining > 0) + { + client.PrintToChat( + _bridge.ChatFormatter.Format($"{ChatColor.Green} {remaining} {ChatColor.White}秒后才能发起换图投票。")); + return; + } + + if (_rtvPlayers[client.Slot]) + { + client.PrintToChat(_bridge.ChatFormatter.Format("你已经投票过换图了!")); + return; + } + + _rtvPlayers[client.Slot] = true; + var current = _rtvPlayers.Count(rtvPlayer => rtvPlayer); + var requested = _bridge.MapManager.GetVoteSuccessNumberRequested(); + var clientGaps = requested - current; + _bridge.ModSharp.PrintToChatAll(_bridge.ChatFormatter.Format( + $"已有 {ChatColor.Green}{current}{ChatColor.White} 人投票换图,还需 {ChatColor.Green}{clientGaps}{ChatColor.White} 票。")); + + if (clientGaps > 0) + { + return; + } + + _bridge.ModSharp.PrintToChatAll(_bridge.ChatFormatter.Format("投票换图通过!将在回合结束后开始投票。")); + _bridge.ModSharp.ServerCommand("mp_timelimit 0.00000001"); + } + + private void ResetRtvState() + { + Array.Fill(_rtvPlayers, false); + } + + private void ResetClientRtvState(IGameClient client) + { + _rtvPlayers[client.Slot] = false; + } + + #region IModule + + public void OnInit() + { + if (_enableRtv?.GetBool() is not true) + { + _logger.LogInformation("RTV is disabled, skip initialization."); + return; + } + + _bridge.ClientManager.InstallClientListener(this); + _bridge.ModSharp.InstallGameListener(this); + } + + public void OnShutdown() + { + if (_enableRtv?.GetBool() is not true) + { + _logger.LogInformation("RTV is disabled, skip shutdown."); + return; + } + + _bridge.ModSharp.RemoveGameListener(this); + _bridge.ClientManager.RemoveClientListener(this); + ResetRtvState(); + } + + #endregion + + #region IGameListener + + public void OnGameDeactivate() + { + ResetRtvState(); + } + + int IGameListener.ListenerVersion => IGameListener.ApiVersion; + int IGameListener.ListenerPriority => 0; + + #endregion + + #region IClientListener + + public void OnClientPutInServer(IGameClient client) + { + ResetClientRtvState(client); + } + + public void OnClientDisconnected(IGameClient client, NetworkDisconnectionReason reason) + { + ResetClientRtvState(client); + } + + public ECommandAction OnClientSayCommand(IGameClient client, bool teamOnly, bool isCommand, string commandName, + string message) + { + if (message.Split().ElementAtOrDefault(0)?.Equals("rtv", StringComparison.OrdinalIgnoreCase) is true) + { + AttemptRtv(client); + } + + return ECommandAction.Skipped; + } + + int IClientListener.ListenerPriority => 0; + + int IClientListener.ListenerVersion => IClientListener.ApiVersion; + + #endregion +} \ No newline at end of file diff --git a/src/Sharp.Modules.AdminManager.Shared/AdminTableManifest.cs b/src/Sharp.Modules.AdminManager.Shared/AdminTableManifest.cs new file mode 100644 index 0000000..918d2c0 --- /dev/null +++ b/src/Sharp.Modules.AdminManager.Shared/AdminTableManifest.cs @@ -0,0 +1,57 @@ +namespace Sharp.Modules.AdminManager.Shared; + +/*{ + "permissionSets": { + "system": ["system:role:create", "system:role:delete"], + "pluginA": ["pluginA:getWeapon", "pluginA:fetchItems"], + "pluginB": ["pluginB:kick", "pluginB:slay"] + }, + + "roles": [ + { "name": "global_root", "permissions": ["*"] }, + { "name": "pluginA_admin", "permissions": ["pluginA:*"] }, + { "name": "pluginB_kicker", "permissions": ["pluginB:kick"] } + ], + + "users": [ + { + "user_id": "u100", + // 权限数组:@开头表示继承角色,其他表示直接权限 + "permissions": ["@global_root", "!pluginB:slay"] + // 解析后:global_root的所有权限(*) + 直接拒绝pluginB:slay + }, + { + "user_id": "u104", + "permissions": ["@pluginA_admin", "@pluginB_kicker"] + // 解析后:pluginA:*(插件A所有) + pluginB:kick(插件B踢人) + }, + { + "user_id": "u105", + "permissions": ["pluginA:getWeapon", "!pluginA:fetchItems"] + // 解析后:仅直接允许getWeapon + 直接拒绝fetchItems(不继承任何角色) + }, + { + "user_id": "u106", + "permissions": ["@pluginA_admin", "pluginB:slay"] + // 解析后:pluginA:*(来自角色) + 直接允许pluginB:slay + } + ] + }*/ + +public record AdminTableManifest( + Dictionary> PermissionCollection, + List Roles, + List Admins +); + +public record RoleManifest( + string Name, + HashSet Permissions +); + +public record AdminManifest( + string Name, + ulong Identity, + byte Immunity, + HashSet Permissions +); \ No newline at end of file diff --git a/src/Sharp.Modules.AdminManager.Shared/IAdmin.cs b/src/Sharp.Modules.AdminManager.Shared/IAdmin.cs new file mode 100644 index 0000000..86c6594 --- /dev/null +++ b/src/Sharp.Modules.AdminManager.Shared/IAdmin.cs @@ -0,0 +1,66 @@ +/* + * ModSharp + * Copyright (C) 2023-2025 Kxnrl. All Rights Reserved. + * + * This file is part of ModSharp. + * ModSharp is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * ModSharp is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with ModSharp. If not, see . + */ + +using Sharp.Shared.Units; + +namespace Sharp.Modules.AdminManager.Shared; + +public interface IAdmin +{ + /// + /// 管理员名字 + /// + string Name { get; } + + /// + /// SteamID + /// + SteamID Identity { get; } + + /// + /// 权限级别 + /// + byte Immunity { get; } + + /// + /// 权限 + /// + IReadOnlySet Permissions { get; } + + /// + /// 是否拥有权限 + /// + /// 权限字段 + /// + bool HasPermission(string permission); + + /// + /// 添加权限 + /// + /// 权限字段 + /// + bool AddPermission(string permission); + + /// + /// 删除权限 + /// + /// 权限字段 + /// + bool RemovePermission(string permission); +} \ No newline at end of file diff --git a/src/Sharp.Modules.AdminManager.Shared/IAdminManager.cs b/src/Sharp.Modules.AdminManager.Shared/IAdminManager.cs new file mode 100644 index 0000000..44b2b04 --- /dev/null +++ b/src/Sharp.Modules.AdminManager.Shared/IAdminManager.cs @@ -0,0 +1,29 @@ +using System.Collections.Immutable; +using Sharp.Shared.Objects; +using Sharp.Shared.Types; +using Sharp.Shared.Units; + +namespace Sharp.Modules.AdminManager.Shared; + +public interface IAdminManager +{ + public const char RolesOperator = '@'; + public const char DenyOperator = '!'; + public const char WildCardOperator = '*'; + public const char SeparatorOperator = ':'; + + public const string Identity = nameof(IAdminManager); + + public IAdmin? GetAdmin(SteamID identity); + + void MountAdminManifest(string moduleIdentity, Func call); + + public IAdminCommandRegistry GetCommandRegistry(string moduleIdentity); +} + + +public interface IAdminCommandRegistry +{ + public void RegisterAdminCommand(string command, Action call, + ImmutableArray permissions); +} \ No newline at end of file diff --git a/src/Sharp.Modules.AdminManager.Shared/Sharp.Modules.AdminManager.Shared.csproj b/src/Sharp.Modules.AdminManager.Shared/Sharp.Modules.AdminManager.Shared.csproj new file mode 100644 index 0000000..f281ffd --- /dev/null +++ b/src/Sharp.Modules.AdminManager.Shared/Sharp.Modules.AdminManager.Shared.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + + + + + + diff --git a/src/Sharp.Modules.AdminManager/Admin.cs b/src/Sharp.Modules.AdminManager/Admin.cs new file mode 100644 index 0000000..b939c0e --- /dev/null +++ b/src/Sharp.Modules.AdminManager/Admin.cs @@ -0,0 +1,33 @@ +using Sharp.Modules.AdminManager.Shared; +using Sharp.Shared.Units; + +namespace Sharp.Modules.AdminManager; + +internal class Admin : IAdmin +{ + public string Name { get; } + public SteamID Identity { get; } + public byte Immunity { get; } + + private readonly HashSet _permissions; + + public Admin(string name, SteamID identity, byte immunity) + { + Name = name; + Identity = identity; + Immunity = immunity; + + _permissions = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlySet Permissions => _permissions; + + public bool HasPermission(string permission) + => _permissions.Contains(permission); + + public bool AddPermission(string permission) + => _permissions.Add(permission); + + public bool RemovePermission(string permission) + => _permissions.Remove(permission); +} diff --git a/src/Sharp.Modules.AdminManager/AdminCommandRegistry.cs b/src/Sharp.Modules.AdminManager/AdminCommandRegistry.cs new file mode 100644 index 0000000..3cd83ba --- /dev/null +++ b/src/Sharp.Modules.AdminManager/AdminCommandRegistry.cs @@ -0,0 +1,70 @@ +using System.Collections.Immutable; +using Sharp.Modules.AdminManager.Shared; +using Sharp.Modules.CommandManager.Shared; +using Sharp.Shared; +using Sharp.Shared.Enums; +using Sharp.Shared.Objects; +using Sharp.Shared.Types; +using IAdmin = Sharp.Modules.AdminManager.Shared.IAdmin; + +namespace Sharp.Modules.AdminManager; + +internal class AdminCommandRegistry : IAdminCommandRegistry +{ + private readonly ICommandRegistry _commandRegistry; + private readonly AdminManager _self; + private readonly ISharedSystem _shared; + + public AdminCommandRegistry(ICommandRegistry commandRegistry, AdminManager self, ISharedSystem shared) + { + _commandRegistry = commandRegistry; + _self = self; + _shared = shared; + } + + public void RegisterAdminCommand(string command, Action call, ImmutableArray permissions) + { + _commandRegistry.RegisterGenericCommand(command, (client, stringCommand) => + { + OnExecutingAdminCommand(client, stringCommand, call, permissions); + }); + } + + private void OnExecutingAdminCommand(IGameClient? client, StringCommand command, Action call, ImmutableArray permissions) + { + if (client is null) + { + call(null, command); + return; + } + + var admin = _self.GetAdmin(client.SteamId); + if (admin is null) + { + return; + } + + if (HasPermission(admin, permissions)) + { + call(client, command); + return; + } + + if (_shared.GetEntityManager().FindPlayerControllerBySlot(client.Slot) is not { } controller) + { + return; + } + + if (!command.ChatTrigger) + { + client.ConsolePrint("[MS] You do not have access to do this command."); + } + + controller.Print(HudPrintChannel.Chat, "[MS] You do not have access to do this command."); + } + + private bool HasPermission(IAdmin admin, ImmutableArray permissions) + { + return Enumerable.Any(permissions, admin.HasPermission); + } +} \ No newline at end of file diff --git a/src/Sharp.Modules.AdminManager/AdminManager.cs b/src/Sharp.Modules.AdminManager/AdminManager.cs new file mode 100644 index 0000000..dd00e05 --- /dev/null +++ b/src/Sharp.Modules.AdminManager/AdminManager.cs @@ -0,0 +1,393 @@ +// ReSharper disable UnusedParameter.Local + +using Microsoft.Extensions.Configuration; +using Sharp.Modules.AdminManager.Shared; +using Sharp.Shared; +using Sharp.Shared.Units; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Sharp.Modules.CommandManager.Shared; + +namespace Sharp.Modules.AdminManager; + +// https://www.doubao.com/thread/wc0f1c5cae120c2bb + +// 核心目的是集中管理员注册机制,让所有管理员的注册逻辑都走同一个。 +// 由此,复杂是不可避免的:因为这里涉及到二级key。 + +using PermissionCollectionDictionary = Dictionary< + string, // Collection key + HashSet // Actual permission +>; +using RolesDictionary = Dictionary< + string, // Roles key + HashSet // Roles permissions +>; + +internal class AdminManager : IAdminManager, IModSharpModule +{ + private ICommandManager _commandManager = null!; + + private readonly ISharedSystem _shared; + + private readonly Dictionary< + string, // Module Identity + IAdminCommandRegistry> _commandRegistries = new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary< + string, // Module identity + PermissionCollectionDictionary> _permissionCollections = new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary< + string, // module identity + RolesDictionary> _roles = new(StringComparer.OrdinalIgnoreCase); + + // 这个无视所有插件的,这个是统一的,这个只是用来方便内部调用的,跟外部无关。 + private readonly HashSet _allConcretePermissions = new(StringComparer.OrdinalIgnoreCase); + + // Centralized admin storage - all admins from all modules + private readonly Dictionary> _admins = new(StringComparer.OrdinalIgnoreCase); + + public AdminManager( + ISharedSystem sharedSystem, + string dllPath, + string sharpPath, + Version version, + IConfiguration coreConfiguration, + bool hotReload) + { + var moduleIdentity = Path.GetFileName(dllPath); + _shared = sharedSystem; + var logger = sharedSystem.GetLoggerFactory().CreateLogger(); + var adminConfigPath = Path.Combine(sharpPath, "configs", "admin.jsonc"); + + if (!Path.Exists(adminConfigPath)) + { + logger.LogWarning("{DefaultConfigPath} does not found, default config will not work!", adminConfigPath); + return; + } + + if (JsonSerializer.Deserialize(File.ReadAllText(adminConfigPath), + new JsonSerializerOptions + { + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }) is { } manifest) + { + MountAdminManifest(moduleIdentity, () => manifest); + } + else + { + logger.LogWarning("{DefaultConfigPath} is not a valid json or empty, default config may not work!", adminConfigPath); + } + } + + #region IModSharpModule + + public bool Init() + { + return true; + } + + public void PostInit() + { + _shared.GetSharpModuleManager().RegisterSharpModuleInterface(this, IAdminManager.Identity, this); + } + + public void OnLibraryConnected(string name) + { + if (!name.Equals("Sharp.Modules.CommandManager", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _commandManager = _shared + .GetSharpModuleManager() + .GetRequiredSharpModuleInterface(ICommandManager.Identity) + .Instance!; + } + + public void OnLibraryDisconnect(string name) + { + // Remove command registry for this module + _commandRegistries.Remove(name); + + // Remove permissions from the disconnecting module before removing its collections + if (_permissionCollections.TryGetValue(name, out var modulePermissionCollections)) + { + foreach (var permission in modulePermissionCollections.Values.SelectMany(permissionSet => permissionSet)) + { + _allConcretePermissions.Remove(permission); + } + } + + // Remove permission collections for this module + _permissionCollections.Remove(name); + + // Remove roles for this module + _roles.Remove(name); + + // Remove admins from this module + _admins.Remove(name); + } + + public void Shutdown() + { + } + + string IModSharpModule.DisplayName => "Sharp.Modules.AdminManager"; + string IModSharpModule.DisplayAuthor => "laper32"; + + #endregion + + #region IAdminManager + + public IAdmin? GetAdmin(SteamID identity) + { + // Search across all modules for the admin + foreach (var moduleAdmins in _admins.Values) + { + var admin = moduleAdmins.FirstOrDefault(x => x.Identity == identity); + if (admin != null) + { + return admin; + } + } + return null; + } + + public IAdminCommandRegistry GetCommandRegistry(string moduleIdentity) + { + if (_commandRegistries.TryGetValue(moduleIdentity, out var value)) + { + return value; + } + + // Get a separate CommandRegistry for each module identity + var commandRegistry = _commandManager.GetRegistry(moduleIdentity); + var registry = new AdminCommandRegistry(commandRegistry, this, _shared); + _commandRegistries[moduleIdentity] = registry; + return registry; + } + + + #endregion + + public void MountAdminManifest(string moduleIdentity, Func call) + { + var manifest = call(); + + // Mount permission collections for this module + if (!_permissionCollections.ContainsKey(moduleIdentity)) + { + _permissionCollections[moduleIdentity] = new PermissionCollectionDictionary(StringComparer.OrdinalIgnoreCase); + } + + var modulePermissionCollection = _permissionCollections[moduleIdentity]; + foreach (var kv in manifest.PermissionCollection) + { + modulePermissionCollection[kv.Key] = kv.Value; + + // Add all concrete permissions from this collection to the global set + foreach (var permission in kv.Value) + { + _allConcretePermissions.Add(permission); + } + } + + // Mount roles for this module + if (!_roles.ContainsKey(moduleIdentity)) + { + _roles[moduleIdentity] = new RolesDictionary(StringComparer.OrdinalIgnoreCase); + } + + var moduleRoles = _roles[moduleIdentity]; + foreach (var role in manifest.Roles) + { + moduleRoles[role.Name] = role.Permissions; + } + + // Process admins from this module + ProcessAdmins(moduleIdentity, manifest.Admins); + + Console.WriteLine("MountAdminManifest: Result:"); + Console.WriteLine($"Admins: {JsonSerializer.Serialize(_admins, new JsonSerializerOptions{WriteIndented = true})}"); + Console.WriteLine($"Roles: {JsonSerializer.Serialize(_roles, new JsonSerializerOptions{WriteIndented = true})}"); + Console.WriteLine($"PermissionCollections: {JsonSerializer.Serialize(_permissionCollections, new JsonSerializerOptions{WriteIndented = true})}"); + } + + /// + /// Processes and adds admins from a module manifest + /// + private void ProcessAdmins(string moduleIdentity, List adminManifests) + { + // Ensure module has an admin list + if (!_admins.ContainsKey(moduleIdentity)) + { + _admins[moduleIdentity] = []; + } + + var moduleAdmins = _admins[moduleIdentity]; + + foreach (var adminManifest in adminManifests) + { + // Resolve permissions for this admin from this module + var resolvedPermissions = ResolvePermissions(moduleIdentity, adminManifest.Permissions); + + // Check if admin already exists in this module (by SteamID) + var existingAdmin = moduleAdmins.FirstOrDefault(x => x.Identity == adminManifest.Identity); + + if (existingAdmin != null) + { + // Update existing admin with permissions from this module + foreach (var permission in resolvedPermissions) + { + existingAdmin.AddPermission(permission); + } + + // Update immunity if this manifest specifies a higher level + if (adminManifest.Immunity > existingAdmin.Immunity) + { + // Note: Admin class doesn't expose Immunity setter + // This would require refactoring Admin class or recreating the admin + // For now, we'll keep the first immunity value + } + } + else + { + // Create new admin for this module + var admin = new Admin(adminManifest.Name, adminManifest.Identity, adminManifest.Immunity); + + foreach (var permission in resolvedPermissions) + { + admin.AddPermission(permission); + } + + moduleAdmins.Add(admin); + } + } + } + + /// + /// Resolves a list of permission rules into concrete permissions + /// + /// The module identity to resolve permissions within + /// Permission rules to resolve + private HashSet ResolvePermissions(string moduleIdentity, HashSet permissionRules) + { + var allowedPermissions = new HashSet(StringComparer.OrdinalIgnoreCase); + var deniedPermissions = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var rule in permissionRules.Where(rule => !string.IsNullOrWhiteSpace(rule))) + { + // Handle denial rules (!) + if (rule.StartsWith(IAdminManager.DenyOperator)) + { + var deniedRule = rule[1..]; + + // Expand wildcards in denied rules + var matchedPermissions = MatchWildcard(moduleIdentity, deniedRule); + foreach (var permission in matchedPermissions) + { + deniedPermissions.Add(permission); + } + } + // Handle role inheritance (@) + else if (rule.StartsWith(IAdminManager.RolesOperator)) + { + var roleName = rule[1..]; + + // Try to find the role in the module's roles + if (_roles.TryGetValue(moduleIdentity, out var moduleRoles) && + moduleRoles.TryGetValue(roleName, out var rolePermissions)) + { + var roleResolved = ResolvePermissions(moduleIdentity, rolePermissions); + foreach (var permission in roleResolved) + { + allowedPermissions.Add(permission); + } + } + } + // Handle direct permissions and wildcards + else + { + var matchedPermissions = MatchWildcard(moduleIdentity, rule); + foreach (var permission in matchedPermissions) + { + allowedPermissions.Add(permission); + } + } + } + + // Remove denied permissions (denial has the highest priority) + allowedPermissions.ExceptWith(deniedPermissions); + + return allowedPermissions; + } + + /// + /// Matches a permission pattern (with wildcards) against all concrete permissions + /// + /// The module identity to match within, or empty to match globally + /// The permission pattern to match + private HashSet MatchWildcard(string moduleIdentity, string pattern) + { + var matches = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Determine which permission collection to search in + + // If a specific module is provided, we could optionally restrict to that module's permissions + // For now, we'll search globally but this can be modified if needed + + // If it's a concrete permission (no wildcard), check if it exists + if (!pattern.Contains(IAdminManager.WildCardOperator)) + { + if (_allConcretePermissions.Contains(pattern)) + { + matches.Add(pattern); + } + return matches; + } + + // Handle wildcard matching + var patternSegments = pattern.Split(IAdminManager.SeparatorOperator); + + // Global wildcard: match all permissions + if (pattern == IAdminManager.WildCardOperator.ToString()) + { + foreach (var permission in _allConcretePermissions) + { + matches.Add(permission); + } + return matches; + } + + // Match against all concrete permissions + foreach (var permission in _allConcretePermissions.Where(permission => IsWildcardMatch(permission, patternSegments))) + { + matches.Add(permission); + } + + return matches; + } + + /// + /// Checks if a concrete permission matches a wildcard pattern + /// Rule: pattern segments must match permission segments (segment count must be equal) + /// + private static bool IsWildcardMatch(string permission, string[] patternSegments) + { + var permissionSegments = permission.Split(IAdminManager.SeparatorOperator); + + // Segment count must match + if (patternSegments.Length != permissionSegments.Length) + { + return false; + } + + // Check each segment + return !patternSegments + .Where((t, i) => t != IAdminManager.WildCardOperator.ToString() && !string.Equals(t, permissionSegments[i], StringComparison.OrdinalIgnoreCase)) + .Any(); + } +} \ No newline at end of file diff --git a/src/Sharp.Modules.AdminManager/Sharp.Modules.AdminManager.csproj b/src/Sharp.Modules.AdminManager/Sharp.Modules.AdminManager.csproj new file mode 100644 index 0000000..18a3856 --- /dev/null +++ b/src/Sharp.Modules.AdminManager/Sharp.Modules.AdminManager.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/Sharp.Modules.CommandManager.Shared/ICommandManager.cs b/src/Sharp.Modules.CommandManager.Shared/ICommandManager.cs new file mode 100644 index 0000000..e85aa84 --- /dev/null +++ b/src/Sharp.Modules.CommandManager.Shared/ICommandManager.cs @@ -0,0 +1,80 @@ +using Sharp.Shared.Objects; +using Sharp.Shared.Types; +using DelegateClientCommand = Sharp.Shared.Managers.IClientManager.DelegateClientCommand; + +namespace Sharp.Modules.CommandManager.Shared; + +public interface ICommandManager +{ + const string Identity = nameof(ICommandManager); + + /// + /// Add Command registry.
+ ///
+ /// + public ICommandRegistry GetRegistry(string moduleIdentity); + +} + +public interface ICommandRegistry +{ + /// + /// 注册用户指令。
+ /// 该指令可被如下途径调用:
+ /// 1. 聊天栏中输入 .{该指令内容},例:.ztele。
+ /// (注:你可以将.换成!或/,它们都是一样的。)
+ /// 2. 客户端控制台,你必须添加`ms_`前缀,例:ms_ztele。
+ /// 此函数注册的指令无法被服务端控制台调用。 + ///
+ /// 指令,你可以不添加ms_前缀,注册的时候会给你自动加上。你也可以加上,这个不影响。但是如果你是ms_ms_这种,那我们爱莫能助。 + /// + void RegisterClientCommand(string command, Action call); + + /// + /// 创建一个控制台指令。
+ /// 该指令只会在服务端控制台生效。 + ///
+ /// + /// 是否要添加ms_前缀?默认添加。 + /// + /// + void RegisterServerCommand(string command, Action call, string description = "", bool addPrefix = true); + + /// + /// 创建一个控制台指令。
+ /// 该指令只会在服务端控制台生效。 + ///
+ /// + /// 是否要添加ms_前缀?默认添加。 + /// + /// + void RegisterServerCommand(string command, Action call, string description = "", bool addPrefix = true); + + /// + /// 注册一个「通用」指令:客户端聊天栏,客户端控制台,服务端控制台均可使用。
+ /// 一定会添加ms_标签,请注意 + ///
+ /// + /// + /// + void RegisterGenericCommand(string command, Action call, string description = ""); + + /// + /// 创建一个控制台指令。
+ /// 该指令只会在客户端控制台和服务端控制台生效。
+ ///
+ /// + /// + /// 是否添加ms_前缀?默认添加。 + void RegisterConsoleCommand(string command, Action callback, bool addPrefix = true); + + /// + /// 监听指令。
+ /// 一般来说,该函数只用于监听客户端控制台内输入的指令,如player_ping。
+ /// 可自行参阅函数调用。 + ///
+ /// + /// + void AddCommandListener(string commandName, DelegateClientCommand callback); + +} \ No newline at end of file diff --git a/src/Sharp.Modules.CommandManager.Shared/Sharp.Modules.CommandManager.Shared.csproj b/src/Sharp.Modules.CommandManager.Shared/Sharp.Modules.CommandManager.Shared.csproj new file mode 100644 index 0000000..f281ffd --- /dev/null +++ b/src/Sharp.Modules.CommandManager.Shared/Sharp.Modules.CommandManager.Shared.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + + + + + + diff --git a/src/Sharp.Modules.CommandManager/CommandManager.cs b/src/Sharp.Modules.CommandManager/CommandManager.cs new file mode 100644 index 0000000..351c48c --- /dev/null +++ b/src/Sharp.Modules.CommandManager/CommandManager.cs @@ -0,0 +1,376 @@ +// ReSharper disable UnusedParameter.Local + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Sharp.Modules.CommandManager.Shared; +using Sharp.Shared; +using Sharp.Shared.Enums; +using Sharp.Shared.Managers; +using Sharp.Shared.Objects; +using Sharp.Shared.Types; +using static Sharp.Shared.Managers.IClientManager; + +namespace Sharp.Modules.CommandManager; + +internal class CommandManager : IModSharpModule, ICommandManager +{ + private readonly Dictionary _registries = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _registerCommands = new(StringComparer.OrdinalIgnoreCase); + private readonly ISharedSystem _shared; + private readonly ILogger _logger; + + public CommandManager( + ISharedSystem sharedSystem, + string dllPath, + string sharpPath, + Version version, + IConfiguration coreConfiguration, + bool hotReload) + { + _shared = sharedSystem; + Path.GetFileName(dllPath); + _logger = sharedSystem.GetLoggerFactory().CreateLogger(); + } + + #region IModSharpModule + + public bool Init() + { + return true; + } + + public void PostInit() + { + _shared.GetSharpModuleManager() + .RegisterSharpModuleInterface(this, ICommandManager.Identity, this); + } + + public void OnLibraryDisconnect(string name) + { + RemoveRegisteredCommands(name); + RemoveRegistry(name); + } + + public void Shutdown() + { + } + + string IModSharpModule.DisplayName => "Sharp.Modules.CommandManager"; + + string IModSharpModule.DisplayAuthor => "laper32"; + + #endregion + + #region ICommandManager + + public ICommandRegistry GetRegistry(string moduleIdentity) + { + if (_registries.TryGetValue(moduleIdentity, out var registry)) + { + return registry; + } + + _registerCommands[moduleIdentity] = new HashSet(StringComparer.OrdinalIgnoreCase); + registry = new CommandRegistry(moduleIdentity, this, _shared, _logger); + _registries[moduleIdentity] = registry; + return registry; + } + #endregion + + public bool IsCommandExists(string command) + { + foreach (var (_, value) in _registerCommands) + { + if (value.Contains(command)) + { + return true; + } + } + + return false; + } + + /// + /// 获取经过ms_装饰后的指令,这个一般只有服务端控制台指令需要 + /// + /// + /// + /// + public string GetAddPrefixCommand(string originalCommand, bool addPrefix = true) + { + string actualRegisterCommand; + if (addPrefix) + { + actualRegisterCommand = !originalCommand.StartsWith("ms_") ? $"ms_{originalCommand}" : originalCommand; + } + else + { + actualRegisterCommand = originalCommand; + } + + return actualRegisterCommand; + } + + /// + /// 判断是否有ms_前缀 + /// + /// + /// + public bool HasPrefix(string command) + { + return command.StartsWith("ms_", StringComparison.OrdinalIgnoreCase); + } + + /// + /// 获取移除ms_装饰后的指令,这个一般只有游戏内指令需要 + /// + /// + /// + public string GetStripPrefixCommand(string command) + { + return HasPrefix(command) + ? command[3..] + : // ms_ => 3 char + command; + } + + public void AddRegisteredCommand(string identity, string command) + { + if (_registerCommands.TryGetValue(identity, out var set)) + { + set.Add(command); + } + + set = new HashSet(StringComparer.OrdinalIgnoreCase) { command }; + _registerCommands[identity] = set; + } + + private void RemoveRegistry(string identity) + { + if (!_registries.TryGetValue(identity, out var value)) + { + return; + } + ((CommandRegistry)value).Clear(); + _registries.Remove(identity); + } + + private void RemoveRegisteredCommands(string identity) + { + if (!_registerCommands.TryGetValue(identity, out var set)) + { + return; + } + set.Clear(); + + _registerCommands.Remove(identity); + } +} + +internal class CommandRegistry : ICommandRegistry +{ + private readonly string _identity; + private readonly CommandManager _self; + private readonly IClientManager _clientManager; + private readonly ILogger _logger; + + private readonly List _hookCommands = []; + private readonly List _clientCommands = []; + private readonly List _consoleCommands = []; + private readonly List _genericCommands = []; + private readonly IConVarManager _conVarManager; + + public CommandRegistry(string identity, CommandManager self, ISharedSystem sharedSystem, ILogger logger) + { + _identity = identity; + _self = self; + _logger = logger; + _clientManager = sharedSystem.GetClientManager(); + sharedSystem.GetModSharp(); + _conVarManager = sharedSystem.GetConVarManager(); + } + + public void RegisterClientCommand(string command, Action call) + { + RegisterClientCommand(command, (client, stringCommand) => + { + call(client, stringCommand); + return ECommandAction.Handled; + }); + } + + private void RegisterClientCommand(string command, DelegateClientCommand call) + { + if (_self.IsCommandExists(command)) + { + _logger.LogWarning("Command `{Name}` has already registered.", command); + return; + } + + var info = new ClientCommandInfo(command, _self.GetStripPrefixCommand(command), call); + _clientManager.InstallCommandCallback(info.StripPrefixCommand, info.Function); + _clientCommands.Add(info); + _self.AddRegisteredCommand(_identity, info.Command); + + } + + public void RegisterServerCommand(string command, Action call, string description = "", bool addPrefix = true) + { + RegisterServerCommand(command, stringCommand => + { + call(stringCommand); + return ECommandAction.Handled; + }, description, addPrefix); + } + + public void RegisterServerCommand(string command, Action call, string description = "", bool addPrefix = true) + { + RegisterServerCommand(command, _ => + { + call(); + }, description, addPrefix); + } + + private void RegisterServerCommand(string command, Func call, + string description = "", bool addPrefix = true) + { + if (_self.IsCommandExists(command)) + { + _logger.LogWarning("Command `{Name}` has already registered.", command); + return; + } + + var info = new ConsoleCommandInfo( + command, + _self.GetAddPrefixCommand(command), + addPrefix, + (_, stringCommand) => call(stringCommand) + ); + _conVarManager.CreateServerCommand(info.AddPrefix ? info.AddPrefixCommand : info.Command, info.OnServerCommand); + _consoleCommands.Add(info); + _self.AddRegisteredCommand(_identity, info.Command); + } + + + public void RegisterGenericCommand(string command, Action call, string description = "") + { + RegisterGenericCommand(command, (client, stringCommand) => + { + call(client, stringCommand); + return ECommandAction.Handled; + }, description); + } + + private void RegisterGenericCommand(string command, Func call, + string description = "") + { + if (_self.IsCommandExists(command)) + { + _logger.LogWarning("Command `{Name}` has already registered.", command); + return; + } + + var info = new GenericCommandInfo(command, _self.GetAddPrefixCommand(command), + _self.GetStripPrefixCommand(command), call); + _clientManager.InstallCommandCallback(info.StripPrefixCommand, (client, stringCommand) => info.OnClientCommand(client, stringCommand)); + _conVarManager.CreateServerCommand(info.AddPrefixCommand, stringCommand => info.OnServerCommand(stringCommand), description); + _genericCommands.Add(info); + _self.AddRegisteredCommand(_identity, info.Command); + } + + public void RegisterConsoleCommand(string command, Action callback, + bool addPrefix = true) + { + RegisterConsoleCommand(command, (client, stringCommand) => + { + callback(client, stringCommand); + return ECommandAction.Handled; + }, addPrefix); + } + + private void RegisterConsoleCommand(string command, Func callback, bool addPrefix = true) + { + if (_self.IsCommandExists(command)) + { + _logger.LogWarning("Command `{Name}` has already registered.", command); + return; + } + + var info = new ConsoleCommandInfo(command, _self.GetAddPrefixCommand(command), addPrefix, callback); + _conVarManager.CreateConsoleCommand(info.AddPrefix ? info.AddPrefixCommand : info.Command, + info.OnConsoleCommand); + _consoleCommands.Add(info); + _self.AddRegisteredCommand(_identity, info.Command); + + } + + public void AddCommandListener(string commandName, DelegateClientCommand callback) + { + var info = new CommandListenerInfo(commandName, callback); + + _clientManager.InstallCommandListener(info.Command, info.Function); + _hookCommands.Add(info); + } + + public void Clear() + { + foreach (var info in _clientCommands) + { + _clientManager.RemoveCommandCallback(info.StripPrefixCommand, info.Function); + } + + foreach (var info in _genericCommands) + { + _conVarManager.ReleaseCommand(info.AddPrefixCommand); + _clientManager.RemoveCommandCallback(info.StripPrefixCommand, info.OnClientCommand); + } + + foreach (var info in _consoleCommands) + { + _conVarManager.ReleaseCommand(info.AddPrefix ? info.AddPrefixCommand : info.Command); + } + + foreach (var info in _hookCommands) + { + _clientManager.RemoveCommandListener(info.Command, info.Function); + } + } + + private record CommandListenerInfo(string Command, DelegateClientCommand Function); + + private record ClientCommandInfo(string Command, string StripPrefixCommand, DelegateClientCommand Function); + + private record GenericCommandInfo( + string Command, + string AddPrefixCommand, + string StripPrefixCommand, + Func Function) + { + public ECommandAction OnClientCommand(IGameClient client, StringCommand command) + { + return Function(client, command); + } + + public ECommandAction OnServerCommand(StringCommand command) + { + return Function(null, command); + } + } + + private record ConsoleCommandInfo( + string Command, + string AddPrefixCommand, + bool AddPrefix, + Func Function) + { + public ECommandAction OnConsoleCommand(IGameClient? client, StringCommand command) + { + return Function(client, command); + } + + public ECommandAction OnServerCommand(StringCommand command) + { + return Function(null, command); + } + } +} \ No newline at end of file diff --git a/src/Sharp.Modules.CommandManager/Sharp.Modules.CommandManager.csproj b/src/Sharp.Modules.CommandManager/Sharp.Modules.CommandManager.csproj new file mode 100644 index 0000000..d79be18 --- /dev/null +++ b/src/Sharp.Modules.CommandManager/Sharp.Modules.CommandManager.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + + + + + + + + + +