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
+
+
+
+
+
+
+
+
+
+