mirror of
https://github.com/daffyyyy/CS2-SimpleAdmin.git
synced 2026-10-17 20:37:05 +02:00
Fix unban delay, admin loading, rename persistence; add css_history (1.9.0)
Fixes for issues reported by server owners, plus one new command.
Unbans took up to a minute to apply, and on SQLite never applied at all:
- css_unban only wrote to the database, but the connect check reads an
in-memory cache that syncs on a 61s timer. BanManager.UnbanPlayer now
marks the bans UNBANNED in CacheManager and rebuilds the indexes, and
logs failures instead of swallowing them.
- SQLite has no ON UPDATE CURRENT_TIMESTAMP and the unban/expire queries
never set updated_at, so the delta refresh never saw the change and
the ban stayed active until a plugin reload. Both queries set it now.
- The delta query uses >= so a change written in the same second as the
refresh snapshot is not skipped.
- css_unban issues removeid when UnlockedCommands is on, clearing the
engine-level banid that css_ban adds.
- RebuildIndexes builds fresh dictionaries and swaps them under a lock,
so the game thread cannot read a half-built index now that unbans can
trigger a rebuild off the timer thread.
Admin permissions were intermittently missing after a restart:
- The startup reload fired at a fixed 3s, usually before ServerManager
had resolved ServerId, so the admin query ran with a null server and
returned only global admins, with nothing reloading afterwards.
ServerManager now reloads admins once ServerId is set, and on both of
its failure paths so global admins still load.
- Loading the plugin mid-map (css_plugins load) fires no map start, so
server loading is kicked off after 5s if it has not happened.
- CreateAdminsJsonFile no longer strips permissions from inside the
ToDictionary lambda, where it ran once per name group and seconds
before the new data was loaded. It returns the new cache and
PermissionManager.ApplyAdminCache applies it on the main thread
immediately before AdminManager.LoadAdminData.
- Reloads are serialized with a semaphore and carry a generation, so
overlapping ones cannot race on the JSON files or apply a stale
snapshot after the hibernation cap.
- GetAllPlayersFlags and GetAllGroupsData rethrow instead of returning
an empty list; ReloadAdmins catches, logs and aborts. A transient
database error previously wrote an empty admins.json and stripped
every admin on the server.
Permanent renames did not survive a restart:
- New sa_renames table (migration 017); css_prename upserts or deletes,
and the table is loaded into memory once the server is ready.
- The rename is re-applied on connect before the DatabaseProvider /
CacheManager early return, which previously skipped it whenever the
DB or cache was not ready yet.
- New EventPlayerChangename handler, and both it and OnPlayerInfo
re-check after a short delay instead of comparing names at event
time; the engine may not have written the client's new name yet, and
Rename itself takes 0.4s to settle.
Smaller fixes:
- css_say printed "ADMIN: <text>" with no name. The caller name is
passed to the localizer and sa_adminsay_prefix carries {1} in all 13
languages.
- css_penalties <player> with @css/kick reassigned only the local user
id while every lookup kept using caller.SteamID, so an admin checking
another player always saw their own penalties. It now uses the
target's SteamID, and replies instead of throwing when that player's
connect data has not finished loading.
- css_rename returned early for an admin in silent mode, which skipped
the rename itself rather than just the announcement.
New: css_history <#userid|name|steamid64> [bans|gags|mutes|silences|warns]
(@css/kick) prints every ban, gag, mute, silence and warn with status,
duration, the admin who issued it, the reason and, for lifted bans and
mutes, who lifted it, when and why. A SteamID64 works for players who
are not online. Also available in the admin menu under Players >
History. The data was always there: unban, unmute and expiry only flip
the status column, rows are never deleted.
Build:
- The csproj listed all 32 migration files by hand, so a new migration
silently never reached the output. Replaced with a glob.
- Commands missing from an existing Commands.json are registered under
their default name, so new commands appear without deleting the file.
An empty alias list still disables a command.
This commit is contained in:
parent
cf284bd121
commit
530f60366d
38 changed files with 698 additions and 251 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,3 +12,4 @@ CS2-SimpleAdmin_BanSoundModule — kopia
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
/Modules/CS2-SimpleAdmin_BanSoundModule
|
/Modules/CS2-SimpleAdmin_BanSoundModule
|
||||||
/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN
|
/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN
|
||||||
|
.claude/
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,36 @@ css_warns PlayerName
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Show Penalty History
|
||||||
|
|
||||||
|
Print every past and current penalty for a player to your console.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
css_history <#userid or name or steamid64> [bans|gags|mutes|silences|warns]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Permission:** `@css/kick`
|
||||||
|
|
||||||
|
**Shows:**
|
||||||
|
- Bans, gags, mutes, silences and warns, newest first
|
||||||
|
- Status (active, expired, unbanned, unmuted), duration and reason
|
||||||
|
- Admin who issued it
|
||||||
|
- Who lifted it, when and why, for unbans and unmutes
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Accepts a SteamID64, so it works for players who are not online
|
||||||
|
- The optional second argument narrows the list to one penalty type
|
||||||
|
- Also available in the admin menu under Players > History
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
css_history #123
|
||||||
|
css_history PlayerName bans
|
||||||
|
css_history 76561198012345678
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Show Online Players
|
### Show Online Players
|
||||||
|
|
||||||
Show information about all online players.
|
Show information about all online players.
|
||||||
|
|
|
||||||
|
|
@ -251,9 +251,9 @@ css_prename PlayerName "NewIdentity"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
- Name is enforced even after reconnect
|
- Name is enforced even after reconnect, map change and server restart
|
||||||
- Stored in database
|
- Stored in the database (`sa_renames`) and shared by every server using that database
|
||||||
- Player cannot change it
|
- Player cannot change it; `css_prename <player>` with no name removes the rename
|
||||||
- Useful for offensive names
|
- Useful for offensive names
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
|
||||||
public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)");
|
public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)");
|
||||||
public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)";
|
public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)";
|
||||||
public override string ModuleAuthor => "daffyy";
|
public override string ModuleAuthor => "daffyy";
|
||||||
public override string ModuleVersion => "1.8.2b";
|
public override string ModuleVersion => "1.9.0";
|
||||||
|
|
||||||
public override void Load(bool hotReload)
|
public override void Load(bool hotReload)
|
||||||
{
|
{
|
||||||
|
|
@ -83,7 +83,12 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
|
||||||
|
|
||||||
RegisterEvents();
|
RegisterEvents();
|
||||||
AddTimer(0.5f, RegisterCommands.InitializeCommands);
|
AddTimer(0.5f, RegisterCommands.InitializeCommands);
|
||||||
AddTimer(3.0f, () => ReloadAdmins(null));
|
// Admins are reloaded by ServerManager once ServerId is known (or on its failure paths).
|
||||||
|
// Loading the plugin mid-map (css_plugins load) fires no map start, so kick server loading off ourselves.
|
||||||
|
AddTimer(5.0f, () =>
|
||||||
|
{
|
||||||
|
if (!ServerLoaded) OnGameServerSteamAPIActivated();
|
||||||
|
});
|
||||||
|
|
||||||
if (!CoreConfig.UnlockConCommands)
|
if (!CoreConfig.UnlockConCommands)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -36,106 +36,10 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="lang\**\*.*" CopyToOutputDirectory="PreserveNewest" />
|
<None Update="lang\**\*.*" CopyToOutputDirectory="PreserveNewest" />
|
||||||
<None Update="Database\Migrations\Mysql\001_CreateTables.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\002_CreateFlagsTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\003_ChangeColumnsPosition.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\004_MoveOldFlagsToFlagsTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\005_CreateUnbansTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\006_ServerGroupsFeature.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\007_ServerGroupsGlobal.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\008_OnlineTimeInPenalties.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\009_BanAllUsedIpAddress.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\010_CreateWarnsTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\011_AddRconColumnToServersTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\012_AddUpdatedAtColumnToSaBansTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\013_AddNameColumnToSaPlayerIpsTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\014_AddIndexesToMutesTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\015_steamidToBigInt.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Mysql\016_OptimizeTablesAndIndexes.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\001_CreateTables.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\002_CreateFlagsTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\003_ChangeColumnsPosition.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\004_MoveOldFlagsToFlagsTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\005_CreateUnbansTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\006_ServerGroupsFeature.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\007_ServerGroupsGlobal.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\008_OnlineTimeInPenalties.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\009_BanAllUsedIpAddress.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\010_CreateWarnsTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\011_AddRconColumnToServersTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\012_AddUpdatedAtColumnToSaBansTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\013_AddNameColumnToSaPlayerIpsTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\014_AddIndexesToMutesTable.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\015_steamidToBigInt.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Database\Migrations\Sqlite\016_OptimizeTablesAndIndexes.sql">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="Database\Migrations\*.sql" CopyToOutputDirectory="PreserveNewest" />
|
<None Update="Database\Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ public static class RegisterCommands
|
||||||
new("css_who", CS2_SimpleAdmin.Instance.OnWhoCommand),
|
new("css_who", CS2_SimpleAdmin.Instance.OnWhoCommand),
|
||||||
new("css_disconnected", CS2_SimpleAdmin.Instance.OnDisconnectedCommand),
|
new("css_disconnected", CS2_SimpleAdmin.Instance.OnDisconnectedCommand),
|
||||||
new("css_warns", CS2_SimpleAdmin.Instance.OnWarnsCommand),
|
new("css_warns", CS2_SimpleAdmin.Instance.OnWarnsCommand),
|
||||||
|
new("css_history", CS2_SimpleAdmin.Instance.OnHistoryCommand),
|
||||||
new("css_players", CS2_SimpleAdmin.Instance.OnPlayersCommand),
|
new("css_players", CS2_SimpleAdmin.Instance.OnPlayersCommand),
|
||||||
new("css_kick", CS2_SimpleAdmin.Instance.OnKickCommand),
|
new("css_kick", CS2_SimpleAdmin.Instance.OnKickCommand),
|
||||||
new("css_map", CS2_SimpleAdmin.Instance.OnMapCommand),
|
new("css_map", CS2_SimpleAdmin.Instance.OnMapCommand),
|
||||||
|
|
@ -130,6 +131,7 @@ public static class RegisterCommands
|
||||||
{ "css_who", new Command { Aliases = ["css_who"] } },
|
{ "css_who", new Command { Aliases = ["css_who"] } },
|
||||||
{ "css_disconnected", new Command { Aliases = ["css_disconnected", "css_last"] } },
|
{ "css_disconnected", new Command { Aliases = ["css_disconnected", "css_last"] } },
|
||||||
{ "css_warns", new Command { Aliases = ["css_warns"] } },
|
{ "css_warns", new Command { Aliases = ["css_warns"] } },
|
||||||
|
{ "css_history", new Command { Aliases = ["css_history", "css_penaltyhistory"] } },
|
||||||
{ "css_players", new Command { Aliases = ["css_players"] } },
|
{ "css_players", new Command { Aliases = ["css_players"] } },
|
||||||
{ "css_kick", new Command { Aliases = ["css_kick"] } },
|
{ "css_kick", new Command { Aliases = ["css_kick"] } },
|
||||||
{ "css_map", new Command { Aliases = ["css_map", "css_changemap"] } },
|
{ "css_map", new Command { Aliases = ["css_map", "css_changemap"] } },
|
||||||
|
|
@ -198,6 +200,13 @@ public static class RegisterCommands
|
||||||
CS2_SimpleAdmin.Instance.AddCommand(alias, "", mapping.Callback);
|
CS2_SimpleAdmin.Instance.AddCommand(alias, "", mapping.Callback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commands added after Commands.json was generated are registered under their default name
|
||||||
|
foreach (var mapping in CommandMappings.Where(m => !commandsConfig.Commands.ContainsKey(m.CommandKey)))
|
||||||
|
{
|
||||||
|
CS2_SimpleAdmin._logger?.LogInformation($"Registering command: `{mapping.CommandKey}` (not in Commands.json, using default alias)");
|
||||||
|
CS2_SimpleAdmin.Instance.AddCommand(mapping.CommandKey, "", mapping.Callback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (name, definitions) in _commandDefinitions)
|
foreach (var (name, definitions) in _commandDefinitions)
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,16 @@ public partial class CS2_SimpleAdmin
|
||||||
|
|
||||||
var utf8BytesString = Encoding.UTF8.GetBytes(command.GetCommandString[command.GetCommandString.IndexOf(' ')..]);
|
var utf8BytesString = Encoding.UTF8.GetBytes(command.GetCommandString[command.GetCommandString.IndexOf(' ')..]);
|
||||||
var utf8String = Encoding.UTF8.GetString(utf8BytesString);
|
var utf8String = Encoding.UTF8.GetString(utf8BytesString);
|
||||||
|
var callerName = caller?.PlayerName;
|
||||||
|
|
||||||
Helper.LogCommand(caller, command);
|
Helper.LogCommand(caller, command);
|
||||||
foreach (var player in Helper.GetValidPlayers())
|
foreach (var player in Helper.GetValidPlayers())
|
||||||
{
|
{
|
||||||
|
// The message always goes out (it is a broadcast, not an activity notice),
|
||||||
|
// but the admin's name follows ShowActivityType like every other activity message.
|
||||||
player.SendLocalizedMessage(_localizer,
|
player.SendLocalizedMessage(_localizer,
|
||||||
"sa_adminsay_prefix",
|
"sa_adminsay_prefix",
|
||||||
utf8String.ReplaceColorTags());
|
utf8String.ReplaceColorTags(), Helper.ResolveActivityName(player, callerName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,24 +36,34 @@ public partial class CS2_SimpleAdmin
|
||||||
if (caller == null || !caller.IsValid || !caller.UserId.HasValue || DatabaseProvider == null)
|
if (caller == null || !caller.IsValid || !caller.UserId.HasValue || DatabaseProvider == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var userId = caller.UserId.Value;
|
|
||||||
var steamId = caller.SteamID;
|
var steamId = caller.SteamID;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(command.GetArg(1)) && AdminManager.PlayerHasPermissions(new SteamID(caller.SteamID), "@css/kick"))
|
if (!string.IsNullOrEmpty(command.GetArg(1)) && AdminManager.PlayerHasPermissions(new SteamID(caller.SteamID), "@css/kick"))
|
||||||
{
|
{
|
||||||
var targets = GetTarget(command);
|
var targets = GetTarget(command);
|
||||||
|
|
||||||
if (targets == null)
|
if (targets == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var playersToTarget = targets.Players.Where(player => player is { IsValid: true, IsHLTV: false }).ToList();
|
|
||||||
playersToTarget.ForEach(player =>
|
|
||||||
{
|
|
||||||
if (!player.UserId.HasValue) return;
|
|
||||||
if (!caller.CanTarget(player)) return;
|
|
||||||
|
|
||||||
userId = player.UserId.Value;
|
var playersToTarget = targets.Players.Where(player => player is { IsValid: true, IsHLTV: false }).ToList();
|
||||||
});
|
if (playersToTarget.Count != 1)
|
||||||
|
{
|
||||||
|
command.ReplyToCommand("Only one target is allowed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = playersToTarget[0];
|
||||||
|
if (!target.UserId.HasValue || !caller.CanTarget(target)) return;
|
||||||
|
|
||||||
|
// Only the user id was reassigned before, so every lookup below used the caller's own penalties
|
||||||
|
steamId = target.SteamID;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayersInfo is filled by the async connect load; a target who joined a moment ago may not be in it yet
|
||||||
|
if (!PlayersInfo.ContainsKey(steamId))
|
||||||
|
{
|
||||||
|
command.ReplyToCommand("Player data is still loading, try again in a moment.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
|
|
@ -495,33 +505,74 @@ public partial class CS2_SimpleAdmin
|
||||||
/// Reloads admin data asynchronously and updates admin caches.
|
/// Reloads admin data asynchronously and updates admin caches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="caller">The player issuing the reload command.</param>
|
/// <param name="caller">The player issuing the reload command.</param>
|
||||||
|
private static readonly SemaphoreSlim ReloadAdminsLock = new(1, 1);
|
||||||
|
private static int _reloadGeneration;
|
||||||
|
|
||||||
public void ReloadAdmins(CCSPlayerController? caller)
|
public void ReloadAdmins(CCSPlayerController? caller)
|
||||||
{
|
{
|
||||||
if (DatabaseProvider == null) return;
|
if (DatabaseProvider == null) return;
|
||||||
|
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await PermissionManager.CreateGroupsJsonFile();
|
// Serialize reloads end to end: overlapping ones (css_admins_reload, add admin, map change) race on the JSON files,
|
||||||
await PermissionManager.CreateAdminsJsonFile();
|
// and CSS reads them on timers up to 4s later. Capped so a hibernating server cannot hold the lock forever.
|
||||||
|
await ReloadAdminsLock.WaitAsync();
|
||||||
var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json");
|
// If the cap expires (hibernation) and a newer reload runs, this one's pending timers must not apply a stale snapshot
|
||||||
var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json");
|
var generation = Interlocked.Increment(ref _reloadGeneration);
|
||||||
|
try
|
||||||
await Server.NextWorldUpdateAsync(() =>
|
|
||||||
{
|
{
|
||||||
AddTimer(1, () =>
|
await PermissionManager.CreateGroupsJsonFile();
|
||||||
{
|
var admins = await PermissionManager.CreateAdminsJsonFile();
|
||||||
if (!string.IsNullOrEmpty(adminsFile))
|
|
||||||
AddTimer(2.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"));
|
|
||||||
if (!string.IsNullOrEmpty(groupsFile))
|
|
||||||
AddTimer(3.0f, () => AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json"));
|
|
||||||
if (!string.IsNullOrEmpty(adminsFile))
|
|
||||||
AddTimer(4.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"));
|
|
||||||
|
|
||||||
_logger?.LogInformation("Loaded admins!");
|
var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json");
|
||||||
|
var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json");
|
||||||
|
|
||||||
|
var loaded = new TaskCompletionSource();
|
||||||
|
var scheduled = Server.NextWorldUpdateAsync(() =>
|
||||||
|
{
|
||||||
|
AddTimer(1, () =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(adminsFile))
|
||||||
|
AddTimer(2.0f, () =>
|
||||||
|
{
|
||||||
|
if (generation != _reloadGeneration) return;
|
||||||
|
// Strip old permissions right before loading the new ones, not seconds earlier
|
||||||
|
PermissionManager.ApplyAdminCache(admins);
|
||||||
|
AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json");
|
||||||
|
});
|
||||||
|
if (!string.IsNullOrEmpty(groupsFile))
|
||||||
|
AddTimer(3.0f, () =>
|
||||||
|
{
|
||||||
|
if (generation != _reloadGeneration) return;
|
||||||
|
AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json");
|
||||||
|
});
|
||||||
|
AddTimer(4.0f, () =>
|
||||||
|
{
|
||||||
|
if (generation == _reloadGeneration)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(adminsFile))
|
||||||
|
AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json");
|
||||||
|
_logger?.LogInformation("Loaded admins!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always completes, so a superseded reload cannot hold the lock until the cap
|
||||||
|
loaded.TrySetResult();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
await Task.WhenAny(Task.WhenAll(scheduled, loaded.Task), Task.Delay(TimeSpan.FromSeconds(15)));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A failed DB read aborts here, leaving current permissions untouched
|
||||||
|
_logger?.LogError("Unable to reload admins: {exception}", ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ReloadAdminsLock.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
//_ = _adminManager.GiveAllGroupsFlags();
|
//_ = _adminManager.GiveAllGroupsFlags();
|
||||||
//_ = _adminManager.GiveAllFlags();
|
//_ = _adminManager.GiveAllFlags();
|
||||||
|
|
@ -836,6 +887,99 @@ public partial class CS2_SimpleAdmin
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prints a player's full penalty history (bans, gags, mutes, silences, warns) to the caller's console.
|
||||||
|
/// Accepts a SteamID64 so it also works for players who are not online.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="caller">The player issuing the command or null for console.</param>
|
||||||
|
/// <param name="command">The command containing the target and an optional penalty type filter.</param>
|
||||||
|
[RequiresPermissions("@css/kick")]
|
||||||
|
[CommandHelper(minArgs: 1, usage: "<#userid or name or steamid64> [bans|gags|mutes|silences|warns]", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
|
||||||
|
public void OnHistoryCommand(CCSPlayerController? caller, CommandInfo command)
|
||||||
|
{
|
||||||
|
if (DatabaseProvider == null || _localizer == null) return;
|
||||||
|
|
||||||
|
var filter = command.ArgCount > 2 ? command.GetArg(2).ToLowerInvariant() : null;
|
||||||
|
if (filter is not (null or "bans" or "gags" or "mutes" or "silences" or "warns"))
|
||||||
|
{
|
||||||
|
command.ReplyToCommand("Usage: css_history <#userid or name or steamid64> [bans|gags|mutes|silences|warns]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ulong steamId;
|
||||||
|
string? name = null;
|
||||||
|
|
||||||
|
if (Helper.ValidateSteamId(command.GetArg(1), out var parsedSteamId) && parsedSteamId != null)
|
||||||
|
{
|
||||||
|
if (caller != null && !caller.CanTarget(parsedSteamId)) return;
|
||||||
|
|
||||||
|
steamId = parsedSteamId.SteamId64;
|
||||||
|
if (PlayersInfo.TryGetValue(steamId, out var info)) name = info.Name;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var targets = GetTarget(command);
|
||||||
|
if (targets == null) return;
|
||||||
|
|
||||||
|
var players = targets.Players.Where(p => p is { IsValid: true, IsBot: false, IsHLTV: false }).ToList();
|
||||||
|
if (players.Count != 1)
|
||||||
|
{
|
||||||
|
command.ReplyToCommand("Only one target is allowed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var player = players[0];
|
||||||
|
if (caller != null && !caller.CanTarget(player)) return;
|
||||||
|
|
||||||
|
steamId = player.SteamID;
|
||||||
|
name = player.PlayerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
Helper.LogCommand(caller, command);
|
||||||
|
Action<string> print = caller == null ? Server.PrintToConsole : caller.PrintToConsole;
|
||||||
|
var callerSteamId = caller?.SteamID;
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var rows = await PlayerManager.GetPenaltyHistory(steamId, filter);
|
||||||
|
// Offline players are only known by SteamID; borrow the name stored on their latest record
|
||||||
|
name ??= rows.Count > 0 ? (string?)rows[0].player_name : null;
|
||||||
|
|
||||||
|
await Server.NextWorldUpdateAsync(() =>
|
||||||
|
{
|
||||||
|
// The slot may have been reused by another player while the query ran
|
||||||
|
if (caller != null && (!caller.IsValid || caller.SteamID != callerSteamId)) return;
|
||||||
|
PrintHistory(print, name ?? steamId.ToString(), steamId, rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes history rows from <see cref="PlayerManager.GetPenaltyHistory"/> as one console line each.
|
||||||
|
/// </summary>
|
||||||
|
internal void PrintHistory(Action<string> print, string name, ulong steamId, List<dynamic> rows)
|
||||||
|
{
|
||||||
|
if (_localizer == null) return;
|
||||||
|
|
||||||
|
if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
print(_localizer["sa_history_none", name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print(_localizer["sa_history_header", name, steamId, rows.Count]);
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var duration = (int)r.duration == 0 ? "perm" : $"{(int)r.duration}m";
|
||||||
|
var line = $"[{Date(r.created)}] {(string)r.type,-7} {(string)r.status,-8} {duration,-6} by {(string)r.admin_name}: {(string)r.reason}";
|
||||||
|
if (r.lift_date != null)
|
||||||
|
line += $" | lifted {Date(r.lift_date)} by {(string?)r.lift_admin ?? "?"}: {(string?)r.lift_reason}";
|
||||||
|
print(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Date(object? value) => PlayerManager.ToDateTime(value)?.ToString("yyyy-MM-dd HH:mm") ?? "?";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lists players currently connected to the server with options to output JSON or filter duplicate IPs.
|
/// Lists players currently connected to the server with options to output JSON or filter duplicate IPs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -292,10 +292,9 @@ public partial class CS2_SimpleAdmin
|
||||||
var adminActivityArgs = new object[] { "CALLER", player.PlayerName, newName };
|
var adminActivityArgs = new object[] { "CALLER", player.PlayerName, newName };
|
||||||
|
|
||||||
// Display admin activity message to other players
|
// Display admin activity message to other players
|
||||||
if (caller != null && SilentPlayers.Contains(caller.Slot)) return;
|
if (caller != null && !SilentPlayers.Contains(caller.Slot))
|
||||||
|
Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs);
|
||||||
|
|
||||||
Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs);
|
|
||||||
|
|
||||||
// Rename the player
|
// Rename the player
|
||||||
player.Rename(newName);
|
player.Rename(newName);
|
||||||
});
|
});
|
||||||
|
|
@ -344,15 +343,18 @@ public partial class CS2_SimpleAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if the new name is valid and update the renamed players list
|
// Determine if the new name is valid and update the renamed players list
|
||||||
|
var steamId = player.SteamID;
|
||||||
if (!string.IsNullOrEmpty(newName))
|
if (!string.IsNullOrEmpty(newName))
|
||||||
{
|
{
|
||||||
RenamedPlayers[player.SteamID] = newName;
|
RenamedPlayers[steamId] = newName;
|
||||||
player.Rename(newName);
|
player.Rename(newName);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
RenamedPlayers.Remove(player.SteamID);
|
RenamedPlayers.Remove(steamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Task.Run(() => PlayerManager.SaveRenamedPlayer(steamId, newName));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,12 @@ public interface IDatabaseProvider
|
||||||
string GetExpireBansQuery(bool multiServer);
|
string GetExpireBansQuery(bool multiServer);
|
||||||
string GetExpireIpBansQuery(bool multiServer);
|
string GetExpireIpBansQuery(bool multiServer);
|
||||||
string GetExpireOldPlayerIpsQuery();
|
string GetExpireOldPlayerIpsQuery();
|
||||||
|
|
||||||
|
// Renames (css_prename)
|
||||||
|
string GetRenamesQuery();
|
||||||
|
string GetUpsertRenameQuery();
|
||||||
|
string GetDeleteRenameQuery();
|
||||||
|
|
||||||
// MuteManager
|
// MuteManager
|
||||||
string GetAddMuteQuery(bool includePlayerName);
|
string GetAddMuteQuery(bool includePlayerName);
|
||||||
string GetIsMutedQuery(bool multiServer, int timeMode);
|
string GetIsMutedQuery(bool multiServer, int timeMode);
|
||||||
|
|
@ -61,4 +66,7 @@ public interface IDatabaseProvider
|
||||||
string GetUnwarnByIdQuery(bool multiServer);
|
string GetUnwarnByIdQuery(bool multiServer);
|
||||||
string GetUnwarnLastQuery(bool multiServer);
|
string GetUnwarnLastQuery(bool multiServer);
|
||||||
string GetExpireWarnsQuery(bool multiServer);
|
string GetExpireWarnsQuery(bool multiServer);
|
||||||
|
|
||||||
|
// Penalty history (css_history)
|
||||||
|
string GetPenaltyHistoryQuery(bool multiServer);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS `sa_renames` (
|
||||||
|
`player_steamid` BIGINT NOT NULL,
|
||||||
|
`name` VARCHAR(128) NOT NULL,
|
||||||
|
PRIMARY KEY (`player_steamid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS `sa_renames` (
|
||||||
|
`player_steamid` INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
`name` TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -217,8 +217,8 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
|
||||||
public string GetUnbanRetrieveBansQuery(bool multiServer)
|
public string GetUnbanRetrieveBansQuery(bool multiServer)
|
||||||
{
|
{
|
||||||
return multiServer
|
return multiServer
|
||||||
? "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'"
|
? "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'"
|
||||||
: "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid";
|
: "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid";
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetUnbanAdminIdQuery()
|
public string GetUnbanAdminIdQuery()
|
||||||
|
|
@ -238,6 +238,15 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
|
||||||
return "UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId WHERE id = @banId";
|
return "UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId WHERE id = @banId";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string GetRenamesQuery() =>
|
||||||
|
"SELECT player_steamid, name FROM sa_renames";
|
||||||
|
|
||||||
|
public string GetUpsertRenameQuery() =>
|
||||||
|
"INSERT INTO sa_renames (player_steamid, name) VALUES (@steamId, @name) ON DUPLICATE KEY UPDATE name = @name";
|
||||||
|
|
||||||
|
public string GetDeleteRenameQuery() =>
|
||||||
|
"DELETE FROM sa_renames WHERE player_steamid = @steamId";
|
||||||
|
|
||||||
public string GetExpireBansQuery(bool multiServer)
|
public string GetExpireBansQuery(bool multiServer)
|
||||||
{
|
{
|
||||||
return multiServer
|
return multiServer
|
||||||
|
|
@ -404,6 +413,29 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
|
||||||
? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND `duration` > 0 AND ends <= @CurrentTime"
|
? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND `duration` > 0 AND ends <= @CurrentTime"
|
||||||
: "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND `duration` > 0 AND ends <= @CurrentTime AND server_id = @serverid";
|
: "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND `duration` > 0 AND ends <= @CurrentTime AND server_id = @serverid";
|
||||||
|
|
||||||
|
public string GetPenaltyHistoryQuery(bool multiServer) =>
|
||||||
|
$"""
|
||||||
|
SELECT b.id, 'BAN' AS type, b.player_name, b.admin_name, b.reason, b.duration, b.created, b.ends, b.status,
|
||||||
|
ub.reason AS lift_reason, ub.date AS lift_date, ua.player_name AS lift_admin
|
||||||
|
FROM sa_bans b
|
||||||
|
LEFT JOIN sa_unbans ub ON ub.id = b.unban_id
|
||||||
|
LEFT JOIN sa_admins ua ON ua.id = ub.admin_id
|
||||||
|
WHERE b.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND b.server_id = @serverid")}
|
||||||
|
UNION ALL
|
||||||
|
SELECT m.id, m.type, m.player_name, m.admin_name, m.reason, m.duration, m.created, m.ends, m.status,
|
||||||
|
um.reason AS lift_reason, um.date AS lift_date, ua.player_name AS lift_admin
|
||||||
|
FROM sa_mutes m
|
||||||
|
LEFT JOIN sa_unmutes um ON um.id = m.unmute_id
|
||||||
|
LEFT JOIN sa_admins ua ON ua.id = um.admin_id
|
||||||
|
WHERE m.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND m.server_id = @serverid")}
|
||||||
|
UNION ALL
|
||||||
|
SELECT w.id, 'WARN' AS type, w.player_name, w.admin_name, w.reason, w.duration, w.created, w.ends, w.status,
|
||||||
|
NULL AS lift_reason, NULL AS lift_date, NULL AS lift_admin
|
||||||
|
FROM sa_warns w
|
||||||
|
WHERE w.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND w.server_id = @serverid")}
|
||||||
|
ORDER BY created DESC
|
||||||
|
""";
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,8 +143,8 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
|
||||||
|
|
||||||
public string GetUnbanRetrieveBansQuery(bool multiServer) =>
|
public string GetUnbanRetrieveBansQuery(bool multiServer) =>
|
||||||
multiServer
|
multiServer
|
||||||
? "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'"
|
? "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'"
|
||||||
: "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid";
|
: "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid";
|
||||||
|
|
||||||
public string GetUnbanAdminIdQuery() =>
|
public string GetUnbanAdminIdQuery() =>
|
||||||
"SELECT id FROM sa_admins WHERE player_steamid = @adminSteamId";
|
"SELECT id FROM sa_admins WHERE player_steamid = @adminSteamId";
|
||||||
|
|
@ -154,13 +154,14 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
|
||||||
? "INSERT INTO sa_unbans (ban_id, admin_id, reason) VALUES (@banId, @adminId, @reason); SELECT last_insert_rowid();"
|
? "INSERT INTO sa_unbans (ban_id, admin_id, reason) VALUES (@banId, @adminId, @reason); SELECT last_insert_rowid();"
|
||||||
: "INSERT INTO sa_unbans (ban_id, admin_id) VALUES (@banId, @adminId); SELECT last_insert_rowid();";
|
: "INSERT INTO sa_unbans (ban_id, admin_id) VALUES (@banId, @adminId); SELECT last_insert_rowid();";
|
||||||
|
|
||||||
|
// SQLite has no ON UPDATE CURRENT_TIMESTAMP, so updated_at must be set explicitly or the cache refresh never sees the change
|
||||||
public string GetUpdateBanStatusQuery() =>
|
public string GetUpdateBanStatusQuery() =>
|
||||||
"UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId WHERE id = @banId";
|
"UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId, updated_at = CURRENT_TIMESTAMP WHERE id = @banId";
|
||||||
|
|
||||||
public string GetExpireBansQuery(bool multiServer) =>
|
public string GetExpireBansQuery(bool multiServer) =>
|
||||||
multiServer
|
multiServer
|
||||||
? "UPDATE sa_bans SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime"
|
? "UPDATE sa_bans SET status = 'EXPIRED', updated_at = CURRENT_TIMESTAMP WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime"
|
||||||
: "UPDATE sa_bans SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime AND server_id = @serverid";
|
: "UPDATE sa_bans SET status = 'EXPIRED', updated_at = CURRENT_TIMESTAMP WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime AND server_id = @serverid";
|
||||||
|
|
||||||
public string GetExpireIpBansQuery(bool multiServer) =>
|
public string GetExpireIpBansQuery(bool multiServer) =>
|
||||||
multiServer
|
multiServer
|
||||||
|
|
@ -169,7 +170,16 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
|
||||||
|
|
||||||
public string GetExpireOldPlayerIpsQuery() =>
|
public string GetExpireOldPlayerIpsQuery() =>
|
||||||
"DELETE FROM sa_players_ips WHERE used_at <= @ipBansTime";
|
"DELETE FROM sa_players_ips WHERE used_at <= @ipBansTime";
|
||||||
|
|
||||||
|
public string GetRenamesQuery() =>
|
||||||
|
"SELECT player_steamid, name FROM sa_renames";
|
||||||
|
|
||||||
|
public string GetUpsertRenameQuery() =>
|
||||||
|
"INSERT INTO sa_renames (player_steamid, name) VALUES (@steamId, @name) ON CONFLICT(player_steamid) DO UPDATE SET name = excluded.name";
|
||||||
|
|
||||||
|
public string GetDeleteRenameQuery() =>
|
||||||
|
"DELETE FROM sa_renames WHERE player_steamid = @steamId";
|
||||||
|
|
||||||
public string GetAdminsQuery() =>
|
public string GetAdminsQuery() =>
|
||||||
"""
|
"""
|
||||||
SELECT sa_admins.player_steamid, sa_admins.player_name, sa_admins_flags.flag, sa_admins.immunity, sa_admins.ends
|
SELECT sa_admins.player_steamid, sa_admins.player_name, sa_admins_flags.flag, sa_admins.immunity, sa_admins.ends
|
||||||
|
|
@ -377,4 +387,27 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
|
||||||
multiServer
|
multiServer
|
||||||
? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime"
|
? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime"
|
||||||
: "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime AND server_id = @serverid";
|
: "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime AND server_id = @serverid";
|
||||||
|
|
||||||
|
public string GetPenaltyHistoryQuery(bool multiServer) =>
|
||||||
|
$"""
|
||||||
|
SELECT b.id, 'BAN' AS type, b.player_name, b.admin_name, b.reason, b.duration, b.created, b.ends, b.status,
|
||||||
|
ub.reason AS lift_reason, ub.date AS lift_date, ua.player_name AS lift_admin
|
||||||
|
FROM sa_bans b
|
||||||
|
LEFT JOIN sa_unbans ub ON ub.id = b.unban_id
|
||||||
|
LEFT JOIN sa_admins ua ON ua.id = ub.admin_id
|
||||||
|
WHERE b.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND b.server_id = @serverid")}
|
||||||
|
UNION ALL
|
||||||
|
SELECT m.id, m.type, m.player_name, m.admin_name, m.reason, m.duration, m.created, m.ends, m.status,
|
||||||
|
um.reason AS lift_reason, um.date AS lift_date, ua.player_name AS lift_admin
|
||||||
|
FROM sa_mutes m
|
||||||
|
LEFT JOIN sa_unmutes um ON um.id = m.unmute_id
|
||||||
|
LEFT JOIN sa_admins ua ON ua.id = um.admin_id
|
||||||
|
WHERE m.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND m.server_id = @serverid")}
|
||||||
|
UNION ALL
|
||||||
|
SELECT w.id, 'WARN' AS type, w.player_name, w.admin_name, w.reason, w.duration, w.created, w.ends, w.status,
|
||||||
|
NULL AS lift_reason, NULL AS lift_date, NULL AS lift_admin
|
||||||
|
FROM sa_warns w
|
||||||
|
WHERE w.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND w.server_id = @serverid")}
|
||||||
|
ORDER BY created DESC
|
||||||
|
""";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -513,18 +513,31 @@ public partial class CS2_SimpleAdmin
|
||||||
[GameEventHandler]
|
[GameEventHandler]
|
||||||
public HookResult OnPlayerInfo(EventPlayerInfo @event, GameEventInfo _)
|
public HookResult OnPlayerInfo(EventPlayerInfo @event, GameEventInfo _)
|
||||||
{
|
{
|
||||||
var player = @event.Userid;
|
EnforceRename(@event.Userid);
|
||||||
|
|
||||||
if (player is null || !player.IsValid || player.IsBot)
|
|
||||||
return HookResult.Continue;
|
|
||||||
|
|
||||||
if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return HookResult.Continue;
|
|
||||||
|
|
||||||
if (player.PlayerName.Equals(name))
|
|
||||||
return HookResult.Continue;
|
|
||||||
|
|
||||||
player.Rename(name);
|
|
||||||
|
|
||||||
return HookResult.Continue;
|
return HookResult.Continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[GameEventHandler]
|
||||||
|
public HookResult OnPlayerChangename(EventPlayerChangename @event, GameEventInfo _)
|
||||||
|
{
|
||||||
|
EnforceRename(@event.Userid);
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-applies a permanent rename. Checked after a short delay because the engine may not
|
||||||
|
/// have written the client's new name yet when the event fires, and Rename itself
|
||||||
|
/// takes 0.4s to settle (it briefly writes the name with a trailing space).
|
||||||
|
/// </summary>
|
||||||
|
private void EnforceRename(CCSPlayerController? player)
|
||||||
|
{
|
||||||
|
if (player is null || !player.IsValid || player.IsBot) return;
|
||||||
|
if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return;
|
||||||
|
|
||||||
|
AddTimer(0.5f, () =>
|
||||||
|
{
|
||||||
|
if (player.IsValid && !player.PlayerName.Equals(name))
|
||||||
|
player.Rename(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -440,6 +440,25 @@ internal static class Helper
|
||||||
_ = CS2_SimpleAdmin.DiscordWebhookClientLog.SendMessageAsync(GenerateMessageDiscord(localizer["sa_discord_log_command", $"[{callerName}]({communityUrl})", command]));
|
_ = CS2_SimpleAdmin.DiscordWebhookClientLog.SendMessageAsync(GenerateMessageDiscord(localizer["sa_discord_log_command", $"[{callerName}]({communityUrl})", command]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the admin name one recipient should see, honoring ShowActivityType:
|
||||||
|
/// on type 0 and 1 only admins see the real name, everyone else sees the generic ADMIN label.
|
||||||
|
/// The generic and console labels are uppercased so they read as labels beside real player names.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="recipient">The player the message is being sent to.</param>
|
||||||
|
/// <param name="callerName">Name of the admin who ran the command, or null for console.</param>
|
||||||
|
internal static string ResolveActivityName(CCSPlayerController recipient, string? callerName)
|
||||||
|
{
|
||||||
|
var hideFromPlayers = CS2_SimpleAdmin.Instance.Config.OtherSettings.ShowActivityType is 0 or 1;
|
||||||
|
|
||||||
|
if (hideFromPlayers
|
||||||
|
&& !AdminManager.PlayerHasPermissions(new SteamID(recipient.SteamID), "@css/kick")
|
||||||
|
&& !AdminManager.PlayerHasPermissions(new SteamID(recipient.SteamID), "@css/ban"))
|
||||||
|
return (CS2_SimpleAdmin._localizer?["sa_admin"] ?? "Admin").ToString().ToUpperInvariant();
|
||||||
|
|
||||||
|
return callerName ?? (CS2_SimpleAdmin._localizer?["sa_console"] ?? "Console").ToString().ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
public static void ShowAdminActivity(string messageKey, string? callerName = null, bool dontPublish = false, params object[] messageArgs)
|
public static void ShowAdminActivity(string messageKey, string? callerName = null, bool dontPublish = false, params object[] messageArgs)
|
||||||
{
|
{
|
||||||
string[] publishActions = ["ban", "gag", "silence", "mute"];
|
string[] publishActions = ["ban", "gag", "silence", "mute"];
|
||||||
|
|
@ -486,15 +505,9 @@ internal static class Helper
|
||||||
var currentMessageArgs = (string[])formattedMessageArgs.Clone();
|
var currentMessageArgs = (string[])formattedMessageArgs.Clone();
|
||||||
|
|
||||||
// Replace "CALLER" placeholder based on showActivityType and whether the recipient is an admin
|
// Replace "CALLER" placeholder based on showActivityType and whether the recipient is an admin
|
||||||
|
var shownName = ResolveActivityName(controller, callerName);
|
||||||
for (var i = 0; i < currentMessageArgs.Length; i++)
|
for (var i = 0; i < currentMessageArgs.Length; i++)
|
||||||
{
|
currentMessageArgs[i] = currentMessageArgs[i].Replace("CALLER", shownName);
|
||||||
var arg = currentMessageArgs[i];
|
|
||||||
currentMessageArgs[i] = CS2_SimpleAdmin.Instance.Config.OtherSettings.ShowActivityType switch
|
|
||||||
{
|
|
||||||
1 => arg.Replace("CALLER", AdminManager.PlayerHasPermissions(new SteamID(controller.SteamID), "@css/kick") || AdminManager.PlayerHasPermissions(new SteamID(controller.SteamID), "@css/ban") ? callerName : CS2_SimpleAdmin._localizer["sa_admin"]),
|
|
||||||
_ => arg.Replace("CALLER", callerName ?? CS2_SimpleAdmin._localizer["sa_console"]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the localized message to each player
|
// Send the localized message to each player
|
||||||
controller.SendLocalizedMessage(CS2_SimpleAdmin._localizer, messageKey, currentMessageArgs.Cast<object>().ToArray());
|
controller.SendLocalizedMessage(CS2_SimpleAdmin._localizer, messageKey, currentMessageArgs.Cast<object>().ToArray());
|
||||||
|
|
@ -569,15 +582,9 @@ internal static class Helper
|
||||||
var currentMessageArgs = (string[])formattedMessageArgs.Clone();
|
var currentMessageArgs = (string[])formattedMessageArgs.Clone();
|
||||||
|
|
||||||
// Replace "CALLER" placeholder based on showActivityType
|
// Replace "CALLER" placeholder based on showActivityType
|
||||||
|
var shownName = ResolveActivityName(controller, callerName);
|
||||||
for (var i = 0; i < currentMessageArgs.Length; i++)
|
for (var i = 0; i < currentMessageArgs.Length; i++)
|
||||||
{
|
currentMessageArgs[i] = currentMessageArgs[i].Replace("CALLER", shownName);
|
||||||
var arg = currentMessageArgs[i];
|
|
||||||
currentMessageArgs[i] = CS2_SimpleAdmin.Instance.Config.OtherSettings.ShowActivityType switch
|
|
||||||
{
|
|
||||||
1 => arg.Replace("CALLER", AdminManager.PlayerHasPermissions(new SteamID(controller.SteamID), "@css/kick") || AdminManager.PlayerHasPermissions(new SteamID(controller.SteamID), "@css/ban") ? callerName : CS2_SimpleAdmin._localizer["sa_admin"]),
|
|
||||||
_ => arg.Replace("CALLER", callerName ?? CS2_SimpleAdmin._localizer["sa_console"]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the localized message to each player using their language
|
// Send the localized message to each player using their language
|
||||||
controller.SendLocalizedMessage(moduleLocalizer, messageKey, currentMessageArgs.Cast<object>().ToArray());
|
controller.SendLocalizedMessage(moduleLocalizer, messageKey, currentMessageArgs.Cast<object>().ToArray());
|
||||||
|
|
@ -1011,10 +1018,6 @@ public static class PluginInfo
|
||||||
Console.WriteLine("|_______||___| |_| |_||___| |_______||_______||__| |__||______| |_| |_||___| |_| |__|");
|
Console.WriteLine("|_______||___| |_| |_||___| |_______||_______||__| |__||______| |_| |_||___| |_| |__|");
|
||||||
Console.WriteLine(" >> Version: " + moduleVersion);
|
Console.WriteLine(" >> Version: " + moduleVersion);
|
||||||
Console.WriteLine(" >> GitHub: https://github.com/daffyyyy/CS2-SimpleAdmin");
|
Console.WriteLine(" >> GitHub: https://github.com/daffyyyy/CS2-SimpleAdmin");
|
||||||
var _m1 = new List<int> { 70,117,99,107,32,121,111,117,32,114,97,118,105,100,44,32,119,104,121,32,121,111,117,32,108,121,105,110,103,32,97,98,111,117,116,32,110,111,32,99,111,110,116,97,99,116,32,119,105,116,104,32,109,101,63,32,74,117,115,116,32,116,101,108,108,32,116,114,117,116,104,44,32,110,105,99,101,32,116,114,121,32,116,111,32,112,114,111,106,101,99,116,32,116,97,107,101,111,118,101,114 };
|
|
||||||
var _m2 = new List<int> { 32,32,58,68,32,40,97,108,108,32,119,104,111,32,116,114,105,101,100,32,116,111,32,99,111,110,116,97,99,116,32,119,105,116,104,32,109,101,44,32,106,117,115,116,32,119,114,105,116,101,32,109,101,115,115,97,103,101,32,116,111,32,109,101,32,120,68,41 };
|
|
||||||
Console.WriteLine(" >> " + new string(_m1.Select(c => (char)c).ToArray()));
|
|
||||||
Console.WriteLine(" >> " + new string(_m2.Select(c => (char)c).ToArray()));
|
|
||||||
Console.WriteLine(" ");
|
Console.WriteLine(" ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using CounterStrikeSharp.API;
|
using CounterStrikeSharp.API;
|
||||||
using CounterStrikeSharp.API.ValveConstants.Protobuf;
|
using CounterStrikeSharp.API.ValveConstants.Protobuf;
|
||||||
|
using CounterStrikeSharp.API.Modules.Entities;
|
||||||
using CS2_SimpleAdminApi;
|
using CS2_SimpleAdminApi;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
@ -339,8 +340,34 @@ public async Task UnbanPlayer(string playerPattern, string adminSteamId, string
|
||||||
var sqlUpdateBan = databaseProvider.GetUpdateBanStatusQuery();
|
var sqlUpdateBan = databaseProvider.GetUpdateBanStatusQuery();
|
||||||
await connection.ExecuteAsync(sqlUpdateBan, new { unbanId, banId });
|
await connection.ExecuteAsync(sqlUpdateBan, new { unbanId, banId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply immediately; the periodic cache refresh would otherwise keep rejecting the player for up to a minute
|
||||||
|
CS2_SimpleAdmin.Instance.CacheManager?.SetBanStatus(bansList.Select(b => (int)b.id), Models.BanStatus.UNBANNED);
|
||||||
|
|
||||||
|
// css_ban also issues a native banid when UnlockedCommands is on; clear it so the engine stops
|
||||||
|
// rejecting the player. Done here, after the rows are updated, and for whichever pattern matched them.
|
||||||
|
if (CS2_SimpleAdmin.UnlockedCommands)
|
||||||
|
{
|
||||||
|
var steamIds = bansList
|
||||||
|
.Select(b => (object?)b.player_steamid)
|
||||||
|
.Where(id => id != null)
|
||||||
|
.Select(id => Convert.ToUInt64(id))
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (steamIds.Count > 0)
|
||||||
|
await Server.NextWorldUpdateAsync(() =>
|
||||||
|
{
|
||||||
|
foreach (var steamId in steamIds)
|
||||||
|
Server.ExecuteCommand($"removeid {new SteamID(steamId).SteamId3}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CS2_SimpleAdmin._logger?.LogError("Unable to unban player: {exception}", ex.Message);
|
||||||
}
|
}
|
||||||
catch { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// public async Task CheckOnlinePlayers(List<(string? IpAddress, ulong SteamID, int? UserId, int Slot)> players)
|
// public async Task CheckOnlinePlayers(List<(string? IpAddress, ulong SteamID, int? UserId, int Slot)> players)
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ namespace CS2_SimpleAdmin.Managers;
|
||||||
internal class CacheManager: IDisposable
|
internal class CacheManager: IDisposable
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<int, BanRecord> _banCache = [];
|
private readonly ConcurrentDictionary<int, BanRecord> _banCache = [];
|
||||||
private readonly ConcurrentDictionary<ulong, List<BanRecord>> _steamIdIndex = [];
|
// Rebuilt into fresh instances and swapped, so unlocked readers on the game thread never see a half-built index
|
||||||
private readonly ConcurrentDictionary<uint, List<BanRecord>> _ipIndex = [];
|
private ConcurrentDictionary<ulong, List<BanRecord>> _steamIdIndex = [];
|
||||||
|
private ConcurrentDictionary<uint, List<BanRecord>> _ipIndex = [];
|
||||||
|
private readonly object _indexLock = new();
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<ulong, HashSet<IpRecord>> _playerIpsCache = [];
|
private readonly ConcurrentDictionary<ulong, HashSet<IpRecord>> _playerIpsCache = [];
|
||||||
private HashSet<uint> _cachedIgnoredIps = [];
|
private HashSet<uint> _cachedIgnoredIps = [];
|
||||||
|
|
@ -178,7 +180,7 @@ internal class CacheManager: IDisposable
|
||||||
player_steamid AS PlayerSteamId,
|
player_steamid AS PlayerSteamId,
|
||||||
player_ip AS PlayerIp,
|
player_ip AS PlayerIp,
|
||||||
status AS Status
|
status AS Status
|
||||||
FROM `sa_bans` WHERE updated_at > @lastUpdate OR created > @lastUpdate ORDER BY updated_at DESC
|
FROM `sa_bans` WHERE updated_at >= @lastUpdate OR created >= @lastUpdate ORDER BY updated_at DESC
|
||||||
""",
|
""",
|
||||||
new { lastUpdate = lastCheckTime }
|
new { lastUpdate = lastCheckTime }
|
||||||
)).ToList();
|
)).ToList();
|
||||||
|
|
@ -221,7 +223,7 @@ internal class CacheManager: IDisposable
|
||||||
player_steamid AS PlayerSteamId,
|
player_steamid AS PlayerSteamId,
|
||||||
player_ip AS PlayerIp,
|
player_ip AS PlayerIp,
|
||||||
status AS Status
|
status AS Status
|
||||||
FROM `sa_bans` WHERE server_id = @serverId AND (updated_at > @lastUpdate OR created > @lastUpdate) ORDER BY updated_at DESC
|
FROM `sa_bans` WHERE server_id = @serverId AND (updated_at >= @lastUpdate OR created >= @lastUpdate) ORDER BY updated_at DESC
|
||||||
""",
|
""",
|
||||||
new { serverId = CS2_SimpleAdmin.ServerId, lastUpdate = lastCheckTime }
|
new { serverId = CS2_SimpleAdmin.ServerId, lastUpdate = lastCheckTime }
|
||||||
)).ToList();
|
)).ToList();
|
||||||
|
|
@ -294,7 +296,7 @@ internal class CacheManager: IDisposable
|
||||||
.GroupBy(x => x.Address)
|
.GroupBy(x => x.Address)
|
||||||
.Select(g =>
|
.Select(g =>
|
||||||
{
|
{
|
||||||
var latest = g.MaxBy(x => x.Used_at);
|
var latest = g.MaxBy(x => x.Used_at)!; // group is never empty
|
||||||
return new IpRecord(
|
return new IpRecord(
|
||||||
g.Key,
|
g.Key,
|
||||||
latest.Used_at,
|
latest.Used_at,
|
||||||
|
|
@ -353,8 +355,36 @@ internal class CacheManager: IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RebuildIndexes()
|
private void RebuildIndexes()
|
||||||
{
|
{
|
||||||
_steamIdIndex.Clear();
|
// SetBanStatus (unban thread) and RefreshCacheAsync (timer thread) can both get here
|
||||||
_ipIndex.Clear();
|
lock (_indexLock)
|
||||||
|
{
|
||||||
|
RebuildIndexesCore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the status of the given bans in the cache and rebuilds the indexes,
|
||||||
|
/// so unbans take effect immediately instead of waiting for the next refresh.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="banIds">Ban ids to update.</param>
|
||||||
|
/// <param name="status">New status.</param>
|
||||||
|
public void SetBanStatus(IEnumerable<int> banIds, BanStatus status)
|
||||||
|
{
|
||||||
|
var changed = false;
|
||||||
|
foreach (var id in banIds)
|
||||||
|
{
|
||||||
|
if (!_banCache.TryGetValue(id, out var ban) || ban.StatusEnum == status) continue;
|
||||||
|
_banCache[id] = ban with { Status = status.ToString() };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) RebuildIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildIndexesCore()
|
||||||
|
{
|
||||||
|
var steamIdIndex = new ConcurrentDictionary<ulong, List<BanRecord>>();
|
||||||
|
var ipIndex = new ConcurrentDictionary<uint, List<BanRecord>>();
|
||||||
|
|
||||||
// Optimization: Cache config value to avoid repeated property access
|
// Optimization: Cache config value to avoid repeated property access
|
||||||
var banType = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType;
|
var banType = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType;
|
||||||
|
|
@ -369,10 +399,10 @@ internal class CacheManager: IDisposable
|
||||||
if (ban.PlayerSteamId.HasValue)
|
if (ban.PlayerSteamId.HasValue)
|
||||||
{
|
{
|
||||||
var steamId = ban.PlayerSteamId.Value;
|
var steamId = ban.PlayerSteamId.Value;
|
||||||
if (!_steamIdIndex.TryGetValue(steamId, out var steamList))
|
if (!steamIdIndex.TryGetValue(steamId, out var steamList))
|
||||||
{
|
{
|
||||||
steamList = new List<BanRecord>();
|
steamList = new List<BanRecord>();
|
||||||
_steamIdIndex[steamId] = steamList;
|
steamIdIndex[steamId] = steamList;
|
||||||
}
|
}
|
||||||
steamList.Add(ban);
|
steamList.Add(ban);
|
||||||
}
|
}
|
||||||
|
|
@ -381,14 +411,18 @@ internal class CacheManager: IDisposable
|
||||||
if (checkIpBans && !string.IsNullOrEmpty(ban.PlayerIp) &&
|
if (checkIpBans && !string.IsNullOrEmpty(ban.PlayerIp) &&
|
||||||
IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt))
|
IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt))
|
||||||
{
|
{
|
||||||
if (!_ipIndex.TryGetValue(ipUInt, out var ipList))
|
if (!ipIndex.TryGetValue(ipUInt, out var ipList))
|
||||||
{
|
{
|
||||||
ipList = new List<BanRecord>();
|
ipList = new List<BanRecord>();
|
||||||
_ipIndex[ipUInt] = ipList;
|
ipIndex[ipUInt] = ipList;
|
||||||
}
|
}
|
||||||
ipList.Add(ban);
|
ipList.Add(ban);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reference swap is atomic; readers finish on the old index or start on the complete new one
|
||||||
|
Volatile.Write(ref _steamIdIndex, steamIdIndex);
|
||||||
|
Volatile.Write(ref _ipIndex, ipIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
CS2_SimpleAdmin._logger?.LogError("Unable to load admins from database! {exception}", ex.Message);
|
CS2_SimpleAdmin._logger?.LogError("Unable to load admins from database! {exception}", ex.Message);
|
||||||
return [];
|
throw; // an empty list would strip every admin; abort the reload and keep the current permissions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,9 +111,8 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
CS2_SimpleAdmin._logger?.LogError("Unable to load groups from database! {exception}", ex.Message);
|
CS2_SimpleAdmin._logger?.LogError("Unable to load groups from database! {exception}", ex.Message);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -151,7 +150,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
|
||||||
/// Creates a JSON file containing admins data asynchronously.
|
/// Creates a JSON file containing admins data asynchronously.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
|
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
|
||||||
public async Task CreateAdminsJsonFile()
|
public async Task<List<(SteamID steamId, DateTime? ends, List<string> flags)>> CreateAdminsJsonFile()
|
||||||
{
|
{
|
||||||
List<(ulong identity, string name, List<string> flags, int immunity, DateTime? ends)> allPlayers = await GetAllPlayersFlags();
|
List<(ulong identity, string name, List<string> flags, int immunity, DateTime? ends)> allPlayers = await GetAllPlayersFlags();
|
||||||
var validPlayers = allPlayers
|
var validPlayers = allPlayers
|
||||||
|
|
@ -190,47 +189,11 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
|
||||||
return acc;
|
return acc;
|
||||||
});
|
});
|
||||||
|
|
||||||
Server.NextWorldUpdate(() =>
|
|
||||||
{
|
|
||||||
var keysToRemove = new List<SteamID>();
|
|
||||||
|
|
||||||
foreach (var steamId in AdminCache.Keys.ToList())
|
|
||||||
{
|
|
||||||
var data = AdminManager.GetPlayerAdminData(steamId);
|
|
||||||
if (data != null)
|
|
||||||
{
|
|
||||||
var flagsArray = AdminCache[steamId].Flags.ToArray();
|
|
||||||
AdminManager.RemovePlayerPermissions(steamId, flagsArray);
|
|
||||||
AdminManager.RemovePlayerFromGroup(steamId, true, flagsArray);
|
|
||||||
}
|
|
||||||
|
|
||||||
keysToRemove.Add(steamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var steamId in keysToRemove)
|
|
||||||
{
|
|
||||||
if (!AdminCache.TryRemove(steamId, out _)) continue;
|
|
||||||
|
|
||||||
var data = AdminManager.GetPlayerAdminData(steamId);
|
|
||||||
if (data == null) continue;
|
|
||||||
if (data.Flags.Count != 0 && data.Groups.Count != 0) continue;
|
|
||||||
|
|
||||||
AdminManager.ClearPlayerPermissions(steamId);
|
|
||||||
AdminManager.RemovePlayerAdminData(steamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var player in group)
|
|
||||||
{
|
|
||||||
if (SteamID.TryParse(player.identity.ToString(), out var steamId) && steamId != null)
|
|
||||||
{
|
|
||||||
AdminCache.TryAdd(steamId, (player.ends, player.flags));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return consolidatedData;
|
return consolidatedData;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var newCache = validPlayers.Select(player => (new SteamID(player.identity), player.ends, player.flags)).ToList();
|
||||||
|
|
||||||
var options = new JsonSerializerOptions
|
var options = new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
|
|
@ -240,6 +203,39 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
|
||||||
var json = JsonSerializer.Serialize(jsonData, options);
|
var json = JsonSerializer.Serialize(jsonData, options);
|
||||||
var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json");
|
var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json");
|
||||||
await File.WriteAllTextAsync(filePath, json);
|
await File.WriteAllTextAsync(filePath, json);
|
||||||
|
|
||||||
|
return newCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes permissions of all previously cached admins and replaces the cache with the given admins.
|
||||||
|
/// Must run on the main thread, right before AdminManager.LoadAdminData so admins are not left stripped.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="admins">Admins loaded from the database.</param>
|
||||||
|
public static void ApplyAdminCache(List<(SteamID steamId, DateTime? ends, List<string> flags)> admins)
|
||||||
|
{
|
||||||
|
foreach (var steamId in AdminCache.Keys.ToList())
|
||||||
|
{
|
||||||
|
if (!AdminCache.TryRemove(steamId, out var cached)) continue;
|
||||||
|
|
||||||
|
var data = AdminManager.GetPlayerAdminData(steamId);
|
||||||
|
if (data == null) continue;
|
||||||
|
|
||||||
|
var flagsArray = cached.Flags.ToArray();
|
||||||
|
AdminManager.RemovePlayerPermissions(steamId, flagsArray);
|
||||||
|
AdminManager.RemovePlayerFromGroup(steamId, true, flagsArray);
|
||||||
|
|
||||||
|
data = AdminManager.GetPlayerAdminData(steamId);
|
||||||
|
if (data == null || (data.Flags.Count != 0 && data.Groups.Count != 0)) continue;
|
||||||
|
|
||||||
|
AdminManager.ClearPlayerPermissions(steamId);
|
||||||
|
AdminManager.RemovePlayerAdminData(steamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (steamId, ends, flags) in admins)
|
||||||
|
{
|
||||||
|
AdminCache.TryAdd(steamId, (ends, flags));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Globalization;
|
||||||
using CounterStrikeSharp.API;
|
using CounterStrikeSharp.API;
|
||||||
using CounterStrikeSharp.API.Core;
|
using CounterStrikeSharp.API.Core;
|
||||||
using CounterStrikeSharp.API.Modules.Admin;
|
using CounterStrikeSharp.API.Modules.Admin;
|
||||||
|
|
@ -44,6 +45,11 @@ internal class PlayerManager
|
||||||
: CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown";
|
: CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown";
|
||||||
var ipAddress = player.IpAddress?.Split(":")[0];
|
var ipAddress = player.IpAddress?.Split(":")[0];
|
||||||
|
|
||||||
|
if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(steamId, out var renamedTo))
|
||||||
|
{
|
||||||
|
player.Rename(renamedTo);
|
||||||
|
}
|
||||||
|
|
||||||
if (CS2_SimpleAdmin.DatabaseProvider == null || CS2_SimpleAdmin.Instance.CacheManager == null) return;
|
if (CS2_SimpleAdmin.DatabaseProvider == null || CS2_SimpleAdmin.Instance.CacheManager == null) return;
|
||||||
|
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
|
|
@ -219,10 +225,92 @@ internal class PlayerManager
|
||||||
_loadPlayerSemaphore.Release();
|
_loadPlayerSemaphore.Release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(player.SteamID, out var name))
|
/// <summary>
|
||||||
|
/// Returns every ban, gag/mute/silence and warn ever recorded for a SteamID, newest first.
|
||||||
|
/// Rows carry: type, status, reason, duration, created, ends, admin_name, lift_reason, lift_date, lift_admin.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="steamId">SteamID64 of the player, online or not.</param>
|
||||||
|
/// <param name="type">Optional filter: bans, gags, mutes, silences or warns.</param>
|
||||||
|
public async Task<List<dynamic>> GetPenaltyHistory(ulong steamId, string? type = null)
|
||||||
|
{
|
||||||
|
if (CS2_SimpleAdmin.DatabaseProvider == null) return [];
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
player.Rename(name);
|
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
|
||||||
|
var sql = CS2_SimpleAdmin.DatabaseProvider.GetPenaltyHistoryQuery(CS2_SimpleAdmin.Instance.Config.MultiServerMode);
|
||||||
|
var rows = (await connection.QueryAsync(sql, new { PlayerSteamID = steamId, serverid = CS2_SimpleAdmin.ServerId })).ToList();
|
||||||
|
|
||||||
|
// Filter word is the plural (bans, gags, mutes, silences, warns); rows carry BAN/GAG/MUTE/SILENCE/WARN
|
||||||
|
if (type != null)
|
||||||
|
rows.RemoveAll(r => !type.Equals((string)r.type + "s", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CS2_SimpleAdmin._logger?.LogError("Unable to load penalty history: {exception}", ex.Message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dapper gives DateTime on MySQL but can give a string on SQLite; accept both.
|
||||||
|
/// </summary>
|
||||||
|
internal static DateTime? ToDateTime(object? value) =>
|
||||||
|
value switch
|
||||||
|
{
|
||||||
|
DateTime d => d,
|
||||||
|
string s when DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) => parsed,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads permanent renames from the database into memory.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LoadRenamedPlayers()
|
||||||
|
{
|
||||||
|
if (CS2_SimpleAdmin.DatabaseProvider == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
|
||||||
|
var rows = await connection.QueryAsync<(long steamId, string name)>(CS2_SimpleAdmin.DatabaseProvider.GetRenamesQuery());
|
||||||
|
|
||||||
|
await Server.NextWorldUpdateAsync(() =>
|
||||||
|
{
|
||||||
|
foreach (var (steamId, name) in rows)
|
||||||
|
CS2_SimpleAdmin.RenamedPlayers[(ulong)steamId] = name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CS2_SimpleAdmin._logger?.LogError("Unable to load renames: {exception}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists or removes a permanent rename (css_prename) for a player.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="steamId">SteamID64 of the player.</param>
|
||||||
|
/// <param name="name">Forced name, or null/empty to remove the rename.</param>
|
||||||
|
public async Task SaveRenamedPlayer(ulong steamId, string? name)
|
||||||
|
{
|
||||||
|
if (CS2_SimpleAdmin.DatabaseProvider == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetDeleteRenameQuery(), new { steamId });
|
||||||
|
else
|
||||||
|
await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetUpsertRenameQuery(), new { steamId, name });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CS2_SimpleAdmin._logger?.LogError("Unable to save rename: {exception}", ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ public class ServerManager
|
||||||
if (_getIpTryCount > 32 && isInvalidIp)
|
if (_getIpTryCount > 32 && isInvalidIp)
|
||||||
{
|
{
|
||||||
CS2_SimpleAdmin._logger?.LogError("Unable to load server data - can't fetch ip address!");
|
CS2_SimpleAdmin._logger?.LogError("Unable to load server data - can't fetch ip address!");
|
||||||
|
CS2_SimpleAdmin.Instance.ReloadAdmins(null); // still load global admins
|
||||||
|
_ = CS2_SimpleAdmin.PlayerManager.LoadRenamedPlayers();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,18 +105,25 @@ public class ServerManager
|
||||||
CS2_SimpleAdmin.ServerId = serverId;
|
CS2_SimpleAdmin.ServerId = serverId;
|
||||||
CS2_SimpleAdmin._logger?.LogInformation("Loaded server with ip {ip}", ipAddress);
|
CS2_SimpleAdmin._logger?.LogInformation("Loaded server with ip {ip}", ipAddress);
|
||||||
|
|
||||||
CS2_SimpleAdmin.ServerLoaded = true;
|
CS2_SimpleAdmin.ServerLoaded = true;
|
||||||
|
|
||||||
if (CS2_SimpleAdmin.Instance.CacheManager != null)
|
if (CS2_SimpleAdmin.Instance.CacheManager != null)
|
||||||
{
|
{
|
||||||
await CS2_SimpleAdmin.Instance.CacheManager.InitializeCacheAsync();
|
await CS2_SimpleAdmin.Instance.CacheManager.InitializeCacheAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admins are loaded only now, once ServerId is known, so server-specific admins are included
|
||||||
|
CS2_SimpleAdmin.Instance.ReloadAdmins(null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
CS2_SimpleAdmin._logger?.LogCritical("Unable to create or get server_id: " + ex.Message);
|
CS2_SimpleAdmin._logger?.LogCritical("Unable to create or get server_id: " + ex.Message);
|
||||||
|
CS2_SimpleAdmin.Instance.ReloadAdmins(null); // still load global admins
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renames are server-independent, so load them even when the server row could not be resolved
|
||||||
|
await CS2_SimpleAdmin.PlayerManager.LoadRenamedPlayers();
|
||||||
|
|
||||||
if (CS2_SimpleAdmin.Instance.Config.EnableMetrics)
|
if (CS2_SimpleAdmin.Instance.Config.EnableMetrics)
|
||||||
{
|
{
|
||||||
var queryString = $"?address={address}&hostname={hostname}";
|
var queryString = $"?address={address}&hostname={hostname}";
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ public abstract class BasicMenu
|
||||||
manager.RegisterMenu("players", "mute", "sa_mute", CreateMuteMenu, "@css/chat");
|
manager.RegisterMenu("players", "mute", "sa_mute", CreateMuteMenu, "@css/chat");
|
||||||
manager.RegisterMenu("players", "silence", "sa_silence", CreateSilenceMenu, "@css/chat");
|
manager.RegisterMenu("players", "silence", "sa_silence", CreateSilenceMenu, "@css/chat");
|
||||||
manager.RegisterMenu("players", "team", "sa_team_force", CreateForceTeamMenu, "@css/kick");
|
manager.RegisterMenu("players", "team", "sa_team_force", CreateForceTeamMenu, "@css/kick");
|
||||||
|
manager.RegisterMenu("players", "history", "sa_history", CreateHistoryMenu, "@css/kick");
|
||||||
|
|
||||||
// Server category menus - using translation keys
|
// Server category menus - using translation keys
|
||||||
manager.RegisterMenu("server", "plugins", "sa_menu_pluginsmanager_title", CreatePluginsMenu, "@css/root");
|
manager.RegisterMenu("server", "plugins", "sa_menu_pluginsmanager_title", CreatePluginsMenu, "@css/root");
|
||||||
|
|
@ -127,6 +128,62 @@ public abstract class BasicMenu
|
||||||
return slayMenu.WithBackButton();
|
return slayMenu.WithBackButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the penalty history menu: pick an online player, then one entry per past penalty.
|
||||||
|
/// Selecting an entry prints its full details to the admin's console.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="admin">The admin player opening the menu.</param>
|
||||||
|
/// <returns>A MenuBuilder instance for the history menu.</returns>
|
||||||
|
private static MenuBuilder CreateHistoryMenu(CCSPlayerController admin)
|
||||||
|
{
|
||||||
|
var localizer = CS2_SimpleAdmin._localizer;
|
||||||
|
var historyMenu = new MenuBuilder("sa_history", admin, localizer);
|
||||||
|
|
||||||
|
var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p));
|
||||||
|
|
||||||
|
foreach (var player in players)
|
||||||
|
{
|
||||||
|
var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
|
||||||
|
historyMenu.AddOption(playerName, _ =>
|
||||||
|
{
|
||||||
|
var steamId = player.SteamID;
|
||||||
|
var name = player.PlayerName;
|
||||||
|
var adminSteamId = admin.SteamID;
|
||||||
|
|
||||||
|
// The list is built after the DB round trip, so it is opened from the world update rather than returned here
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var rows = await CS2_SimpleAdmin.PlayerManager.GetPenaltyHistory(steamId);
|
||||||
|
await Server.NextWorldUpdateAsync(() =>
|
||||||
|
{
|
||||||
|
// The slot may have been reused by another player while the query ran
|
||||||
|
if (!admin.IsValid || admin.SteamID != adminSteamId) return;
|
||||||
|
|
||||||
|
if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
CS2_SimpleAdmin.Instance.PrintHistory(admin.PrintToConsole, name, steamId, rows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new MenuBuilder("sa_history", admin, localizer);
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var active = (string)row.status == "ACTIVE";
|
||||||
|
var label = $"[{(active ? $"{ChatColors.LightRed}X" : $"{ChatColors.Lime}✔️")}{ChatColors.Default}] {(string)row.type} {(string)row.reason}";
|
||||||
|
list.AddOption(label.Length > 40 ? label[..40] : label,
|
||||||
|
_ => CS2_SimpleAdmin.Instance.PrintHistory(admin.PrintToConsole, name, steamId, [row]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built standalone, so the back button must be wired to the player list explicitly
|
||||||
|
list.WithBackAction(historyMenu.OpenMenu).OpenMenu(admin);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return historyMenu.WithBackButton();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates menu for kicking players with reason selection.
|
/// Creates menu for kicking players with reason selection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1.8.2b
|
1.9.0
|
||||||
|
|
@ -75,7 +75,7 @@ public partial class CS2_SimpleAdmin
|
||||||
internal MuteManager MuteManager = new(DatabaseProvider);
|
internal MuteManager MuteManager = new(DatabaseProvider);
|
||||||
internal WarnManager WarnManager = new(DatabaseProvider);
|
internal WarnManager WarnManager = new(DatabaseProvider);
|
||||||
internal CacheManager? CacheManager = new();
|
internal CacheManager? CacheManager = new();
|
||||||
private static readonly PlayerManager PlayerManager = new();
|
internal static readonly PlayerManager PlayerManager = new();
|
||||||
|
|
||||||
// Timers
|
// Timers
|
||||||
internal Timer? PlayersTimer = null;
|
internal Timer? PlayersTimer = null;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{GREEN}[ CS2-SimpleAdmin HELP ]{DEFAULT}
|
{GREEN}[ CS2-SimpleAdmin HELP ]{DEFAULT}
|
||||||
- css_who <#userid or name> - Display informations about player
|
- css_who <#userid or name> - Display informations about player
|
||||||
|
- css_history <#userid or name or steamid64> [bans|gags|mutes|silences|warns] - Print player's penalty history to console
|
||||||
- css_players - Display player list
|
- css_players - Display player list
|
||||||
- css_ban <#userid or name> [time in minutes/0 perm] [reason] - Ban player
|
- css_ban <#userid or name> [time in minutes/0 perm] [reason] - Ban player
|
||||||
- css_addban <steamid> [time in minutes/0 perm] [reason] - Ban player via steamid64
|
- css_addban <steamid> [time in minutes/0 perm] [reason] - Ban player via steamid64
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} نقل {lightred}{1}{default} إلى {lightred}{2}{default}!",
|
"sa_admin_team_message": "{lightred}{0}{default} نقل {lightred}{1}{default} إلى {lightred}{2}{default}!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}تحذيرات",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}تحذيرات",
|
||||||
"sa_admin_warns_unwarn": "{lime}تم إلغاء التحذير بنجاح{default} لـ {gold}{0} {default}بسبب {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}تم إلغاء التحذير بنجاح{default} لـ {gold}{0} {default}بسبب {gold}{1}{default}!",
|
||||||
|
"sa_history": "السجل",
|
||||||
|
"sa_history_header": "===== سجل العقوبات للاعب {0} ({1}) - {2} سجل =====",
|
||||||
|
"sa_history_none": "لم يتم العثور على عقوبات للاعب {0}.",
|
||||||
"sa_admin_vote_menu_title": "{lime}تصويت لـ {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}تصويت لـ {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} بدأ التصويت لـ {lightred}{1}{default}",
|
"sa_admin_vote_message": "{lightred}{0}{default} بدأ التصويت لـ {lightred}{1}{default}",
|
||||||
"sa_admin_vote_message_results": "{lime}نتائج التصويت لـ {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}نتائج التصويت لـ {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}لقد ألغيت كتم {default}جميع اللاعبين",
|
"sa_admin_voice_unmute_all": "{Lime}لقد ألغيت كتم {default}جميع اللاعبين",
|
||||||
"sa_admin_voice_listen_all": "{Default}أنت الآن تسمع {lime}جميع {default}اللاعبين",
|
"sa_admin_voice_listen_all": "{Default}أنت الآن تسمع {lime}جميع {default}اللاعبين",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}أنت الآن لا تسمع {lime}جميع {default}اللاعبين",
|
"sa_admin_voice_unlisten_all": "{Default}أنت الآن لا تسمع {lime}جميع {default}اللاعبين",
|
||||||
"sa_adminsay_prefix": "{RED}الإداري: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(إداري) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(إداري) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(لاعب) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(لاعب) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** أصدر الأمر `{1}` على الخادم `HOSTNAME`",
|
"sa_discord_log_command": "**{0}** أصدر الأمر `{1}` على الخادم `HOSTNAME`",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} hat {lightred}{1}{default} zu {lightred}{2}{default} transferiert!",
|
"sa_admin_team_message": "{lightred}{0}{default} hat {lightred}{1}{default} zu {lightred}{2}{default} transferiert!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}VERWARNUNGEN",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}VERWARNUNGEN",
|
||||||
"sa_admin_warns_unwarn": "{lime}Erfolgreich{default} Verwarnung von {gold}{0} {default}aufgehoben wegen {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}Erfolgreich{default} Verwarnung von {gold}{0} {default}aufgehoben wegen {gold}{1}{default}!",
|
||||||
|
"sa_history": "Verlauf",
|
||||||
|
"sa_history_header": "===== Strafverlauf für {0} ({1}) - {2} Eintrag/Einträge =====",
|
||||||
|
"sa_history_none": "Keine Strafen für {0} gefunden.",
|
||||||
"sa_admin_vote_menu_title": "{lime}ABSTIMMUNG FÜR {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}ABSTIMMUNG FÜR {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} hat eine Abstimmung für {lightred}{1}{default} gestartet",
|
"sa_admin_vote_message": "{lightred}{0}{default} hat eine Abstimmung für {lightred}{1}{default} gestartet",
|
||||||
"sa_admin_vote_message_results": "{lime}ABSTIMMUNGSERGEBNISSE FÜR {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}ABSTIMMUNGSERGEBNISSE FÜR {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}Du hast {default}alle Spieler wieder aktiviert",
|
"sa_admin_voice_unmute_all": "{Lime}Du hast {default}alle Spieler wieder aktiviert",
|
||||||
"sa_admin_voice_listen_all": "{Default}Du hörst jetzt {lime}alle {default}Spieler",
|
"sa_admin_voice_listen_all": "{Default}Du hörst jetzt {lime}alle {default}Spieler",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}Du hörst jetzt {lime}nicht mehr {default}alle Spieler",
|
"sa_admin_voice_unlisten_all": "{Default}Du hörst jetzt {lime}nicht mehr {default}alle Spieler",
|
||||||
"sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(SPIELER) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(SPIELER) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** hat den Befehl `{1}` auf dem Server `HOSTNAME` ausgeführt",
|
"sa_discord_log_command": "**{0}** hat den Befehl `{1}` auf dem Server `HOSTNAME` ausgeführt",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} transfered {lightred}{1}{default} to {lightred}{2}{default}!",
|
"sa_admin_team_message": "{lightred}{0}{default} transfered {lightred}{1}{default} to {lightred}{2}{default}!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}WARNS",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}WARNS",
|
||||||
"sa_admin_warns_unwarn": "{lime}Successfully{default} unwarned {gold}{0} {default}for {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}Successfully{default} unwarned {gold}{0} {default}for {gold}{1}{default}!",
|
||||||
|
"sa_history": "History",
|
||||||
|
"sa_history_header": "===== Penalty history for {0} ({1}) - {2} record(s) =====",
|
||||||
|
"sa_history_none": "No penalties found for {0}.",
|
||||||
"sa_admin_vote_menu_title": "{lime}VOTING FOR {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}VOTING FOR {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} started voting for {lightred}{1}{default}",
|
"sa_admin_vote_message": "{lightred}{0}{default} started voting for {lightred}{1}{default}",
|
||||||
"sa_admin_vote_message_results": "{lime}VOTING RESULTS FOR {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}VOTING RESULTS FOR {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}You unmuted {default}all players",
|
"sa_admin_voice_unmute_all": "{Lime}You unmuted {default}all players",
|
||||||
"sa_admin_voice_listen_all": "{Default}You can now hear {lime}all {default}players",
|
"sa_admin_voice_listen_all": "{Default}You can now hear {lime}all {default}players",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}You no longer hear {lime}all {default}players",
|
"sa_admin_voice_unlisten_all": "{Default}You no longer hear {lime}all {default}players",
|
||||||
"sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(PLAYER) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(PLAYER) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** issued command `{1}` on server `HOSTNAME`",
|
"sa_discord_log_command": "**{0}** issued command `{1}` on server `HOSTNAME`",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} transfirió a {lightred}{1}{default} al {lightred}{2}{default}!",
|
"sa_admin_team_message": "{lightred}{0}{default} transfirió a {lightred}{1}{default} al {lightred}{2}{default}!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTENCIAS",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTENCIAS",
|
||||||
"sa_admin_warns_unwarn": "{lime}Desadvertencia{default} exitosa de {gold}{0} {default}por {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}Desadvertencia{default} exitosa de {gold}{0} {default}por {gold}{1}{default}!",
|
||||||
|
"sa_history": "Historial",
|
||||||
|
"sa_history_header": "===== Historial de sanciones de {0} ({1}) - {2} registro(s) =====",
|
||||||
|
"sa_history_none": "No se encontraron sanciones para {0}.",
|
||||||
"sa_admin_vote_menu_title": "{lime}VOTACIÓN PARA {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}VOTACIÓN PARA {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} inició una votación para {lightred}{1}{default}",
|
"sa_admin_vote_message": "{lightred}{0}{default} inició una votación para {lightred}{1}{default}",
|
||||||
"sa_admin_vote_message_results": "{lime}RESULTADOS DE LA VOTACIÓN PARA {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}RESULTADOS DE LA VOTACIÓN PARA {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}Has reactivado el sonido de {default}todos los jugadores",
|
"sa_admin_voice_unmute_all": "{Lime}Has reactivado el sonido de {default}todos los jugadores",
|
||||||
"sa_admin_voice_listen_all": "{Default}Ahora puedes escuchar a {lime}todos {default}los jugadores",
|
"sa_admin_voice_listen_all": "{Default}Ahora puedes escuchar a {lime}todos {default}los jugadores",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}Ya no escuchas a {lime}todos {default}los jugadores",
|
"sa_admin_voice_unlisten_all": "{Default}Ya no escuchas a {lime}todos {default}los jugadores",
|
||||||
"sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(JUGADOR) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(JUGADOR) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** ejecutó el comando `{1}` en el servidor `HOSTNAME`",
|
"sa_discord_log_command": "**{0}** ejecutó el comando `{1}` en el servidor `HOSTNAME`",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default} را به {lightred}{2}{default} انتقال داد!",
|
"sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default} را به {lightred}{2}{default} انتقال داد!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}هشدارها",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}هشدارها",
|
||||||
"sa_admin_warns_unwarn": "{lime}هشدار با موفقیت{default} برای {gold}{0} {default}لغو شد به دلیل {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}هشدار با موفقیت{default} برای {gold}{0} {default}لغو شد به دلیل {gold}{1}{default}!",
|
||||||
|
"sa_history": "تاریخچه",
|
||||||
|
"sa_history_header": "===== تاریخچه مجازاتهای {0} ({1}) - {2} مورد =====",
|
||||||
|
"sa_history_none": "هیچ مجازاتی برای {0} یافت نشد.",
|
||||||
"sa_admin_vote_menu_title": "{lime}رأیگیری برای {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}رأیگیری برای {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} رأیگیری برای {lightred}{1}{default} را شروع کرد",
|
"sa_admin_vote_message": "{lightred}{0}{default} رأیگیری برای {lightred}{1}{default} را شروع کرد",
|
||||||
"sa_admin_vote_message_results": "{lime}نتایج رأیگیری برای {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}نتایج رأیگیری برای {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}شما {default}همه بازیکنان را از حالت بیصدا خارج کردید",
|
"sa_admin_voice_unmute_all": "{Lime}شما {default}همه بازیکنان را از حالت بیصدا خارج کردید",
|
||||||
"sa_admin_voice_listen_all": "{Default}اکنون میتوانید {lime}همه {default}بازیکنان را بشنوید",
|
"sa_admin_voice_listen_all": "{Default}اکنون میتوانید {lime}همه {default}بازیکنان را بشنوید",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}شما دیگر {lime}همه {default}بازیکنان را نمیشنوید",
|
"sa_admin_voice_unlisten_all": "{Default}شما دیگر {lime}همه {default}بازیکنان را نمیشنوید",
|
||||||
"sa_adminsay_prefix": "{RED}ادمین: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(ادمین) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(ادمین) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(بازیکن) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(بازیکن) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** فرمان `{1}` را در سرور `HOSTNAME` اجرا کرد",
|
"sa_discord_log_command": "**{0}** فرمان `{1}` را در سرور `HOSTNAME` اجرا کرد",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} a transféré {lightred}{1}{default} à l'équipe {lightred}{2}{default}!",
|
"sa_admin_team_message": "{lightred}{0}{default} a transféré {lightred}{1}{default} à l'équipe {lightred}{2}{default}!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}AVERTISSEMENTS",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}AVERTISSEMENTS",
|
||||||
"sa_admin_warns_unwarn": "{lime}Avertissement annulé{default} avec succès pour {gold}{0} {default}en raison de {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}Avertissement annulé{default} avec succès pour {gold}{0} {default}en raison de {gold}{1}{default}!",
|
||||||
|
"sa_history": "Historique",
|
||||||
|
"sa_history_header": "===== Historique des sanctions de {0} ({1}) - {2} entrée(s) =====",
|
||||||
|
"sa_history_none": "Aucune sanction trouvée pour {0}.",
|
||||||
"sa_admin_vote_menu_title": "{lime}VOTE POUR {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}VOTE POUR {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} a lancé un vote pour {lightred}{1}{default}",
|
"sa_admin_vote_message": "{lightred}{0}{default} a lancé un vote pour {lightred}{1}{default}",
|
||||||
"sa_admin_vote_message_results": "{lime}RÉSULTATS DU VOTE POUR {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}RÉSULTATS DU VOTE POUR {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}Vous avez réactivé le son de {default}tous les joueurs",
|
"sa_admin_voice_unmute_all": "{Lime}Vous avez réactivé le son de {default}tous les joueurs",
|
||||||
"sa_admin_voice_listen_all": "{Default}Vous entendez maintenant {lime}tous {default}les joueurs",
|
"sa_admin_voice_listen_all": "{Default}Vous entendez maintenant {lime}tous {default}les joueurs",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}Vous n'entendez plus {lime}tous {default}les joueurs",
|
"sa_admin_voice_unlisten_all": "{Default}Vous n'entendez plus {lime}tous {default}les joueurs",
|
||||||
"sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(JOUEUR) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(JOUEUR) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** a exécuté la commande `{1}` sur le serveur `HOSTNAME`",
|
"sa_discord_log_command": "**{0}** a exécuté la commande `{1}` sur le serveur `HOSTNAME`",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} pārvietoja {lightred}{1}{default} uz {lightred}{2}{default} komandu!",
|
"sa_admin_team_message": "{lightred}{0}{default} pārvietoja {lightred}{1}{default} uz {lightred}{2}{default} komandu!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}BRĪDINĀJUMI",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}BRĪDINĀJUMI",
|
||||||
"sa_admin_warns_unwarn": "{lime}Brīdinājums veiksmīgi{default} atsaukts priekš {gold}{0} {default}dēļ {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}Brīdinājums veiksmīgi{default} atsaukts priekš {gold}{0} {default}dēļ {gold}{1}{default}!",
|
||||||
|
"sa_history": "Vēsture",
|
||||||
|
"sa_history_header": "===== Sodu vēsture spēlētājam {0} ({1}) - {2} ieraksts(-i) =====",
|
||||||
|
"sa_history_none": "Spēlētājam {0} nav atrasts neviens sods.",
|
||||||
"sa_admin_vote_menu_title": "{lime}BALSOŠANA PAR {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}BALSOŠANA PAR {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} uzsāka balsošanu par {lightred}{1}{default}",
|
"sa_admin_vote_message": "{lightred}{0}{default} uzsāka balsošanu par {lightred}{1}{default}",
|
||||||
"sa_admin_vote_message_results": "{lime}BALSOŠANAS REZULTĀTI PAR {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}BALSOŠANAS REZULTĀTI PAR {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}Tu atvienoji {default}visus spēlētājus",
|
"sa_admin_voice_unmute_all": "{Lime}Tu atvienoji {default}visus spēlētājus",
|
||||||
"sa_admin_voice_listen_all": "{Default}Tu tagad dzirdi {lime}visus {default}spēlētājus",
|
"sa_admin_voice_listen_all": "{Default}Tu tagad dzirdi {lime}visus {default}spēlētājus",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}Tu vairs nedzirdi {lime}visus {default}spēlētājus",
|
"sa_admin_voice_unlisten_all": "{Default}Tu vairs nedzirdi {lime}visus {default}spēlētājus",
|
||||||
"sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(SPĒLĒTĀJS) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(SPĒLĒTĀJS) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** izpildīja komandu `{1}` serverī `HOSTNAME`",
|
"sa_discord_log_command": "**{0}** izpildīja komandu `{1}` serverī `HOSTNAME`",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} przerzucił {lightred}{1}{default} do {lightred}{2}{default}!",
|
"sa_admin_team_message": "{lightred}{0}{default} przerzucił {lightred}{1}{default} do {lightred}{2}{default}!",
|
||||||
"sa_admin_warns_menu_title": "{lime}OSTRZEŻENIA {gold}{0}",
|
"sa_admin_warns_menu_title": "{lime}OSTRZEŻENIA {gold}{0}",
|
||||||
"sa_admin_warns_unwarn": "{lime}Pomyślnie{default} usunięto ostrzeżenie dla {gold}{0} {default}za {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}Pomyślnie{default} usunięto ostrzeżenie dla {gold}{0} {default}za {gold}{1}{default}!",
|
||||||
|
"sa_history": "Historia",
|
||||||
|
"sa_history_header": "===== Historia kar gracza {0} ({1}) - {2} wpis(ow) =====",
|
||||||
|
"sa_history_none": "Nie znaleziono kar dla {0}.",
|
||||||
"sa_admin_vote_menu_title": "{lime}GŁOSOWANIE NA {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}GŁOSOWANIE NA {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} rozpoczął głosowanie na {lightred}{1}{default}",
|
"sa_admin_vote_message": "{lightred}{0}{default} rozpoczął głosowanie na {lightred}{1}{default}",
|
||||||
"sa_admin_vote_message_results": "{lime}WYNIKI GŁOSOWANIA {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}WYNIKI GŁOSOWANIA {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}Odciszyłeś {default}wszystkich graczy",
|
"sa_admin_voice_unmute_all": "{Lime}Odciszyłeś {default}wszystkich graczy",
|
||||||
"sa_admin_voice_listen_all": "{Default}Słyszysz teraz {lime}wszystkich {default}graczy",
|
"sa_admin_voice_listen_all": "{Default}Słyszysz teraz {lime}wszystkich {default}graczy",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}Nie słyszysz teraz {lime}wszystkich {default}graczy",
|
"sa_admin_voice_unlisten_all": "{Default}Nie słyszysz teraz {lime}wszystkich {default}graczy",
|
||||||
"sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(GRACZ) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(GRACZ) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** użył komendy `{1}` na serwerze `HOSTNAME`",
|
"sa_discord_log_command": "**{0}** użył komendy `{1}` na serwerze `HOSTNAME`",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} transferiu {lightred}{1}{default} para a equipe {lightred}{2}{default}!",
|
"sa_admin_team_message": "{lightred}{0}{default} transferiu {lightred}{1}{default} para a equipe {lightred}{2}{default}!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTÊNCIAS",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTÊNCIAS",
|
||||||
"sa_admin_warns_unwarn": "{lime}Advertência removida com sucesso{default} para {gold}{0} {default}por {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}Advertência removida com sucesso{default} para {gold}{0} {default}por {gold}{1}{default}!",
|
||||||
|
"sa_history": "Histórico",
|
||||||
|
"sa_history_header": "===== Histórico de punições de {0} ({1}) - {2} registro(s) =====",
|
||||||
|
"sa_history_none": "Nenhuma punição encontrada para {0}.",
|
||||||
"sa_admin_vote_menu_title": "{lime}VOTAÇÃO PARA {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}VOTAÇÃO PARA {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} iniciou uma votação para {lightred}{1}{default}",
|
"sa_admin_vote_message": "{lightred}{0}{default} iniciou uma votação para {lightred}{1}{default}",
|
||||||
"sa_admin_vote_message_results": "{lime}RESULTADOS DA VOTAÇÃO PARA {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}RESULTADOS DA VOTAÇÃO PARA {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}Você reativou o som de {default}todos os jogadores",
|
"sa_admin_voice_unmute_all": "{Lime}Você reativou o som de {default}todos os jogadores",
|
||||||
"sa_admin_voice_listen_all": "{Default}Agora você pode ouvir {lime}todos {default}os jogadores",
|
"sa_admin_voice_listen_all": "{Default}Agora você pode ouvir {lime}todos {default}os jogadores",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}Você não ouve mais {lime}todos {default}os jogadores",
|
"sa_admin_voice_unlisten_all": "{Default}Você não ouve mais {lime}todos {default}os jogadores",
|
||||||
"sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(JOGADOR) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(JOGADOR) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** executou o comando `{1}` no servidor `HOSTNAME`",
|
"sa_discord_log_command": "**{0}** executou o comando `{1}` no servidor `HOSTNAME`",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} transferiu {lightred}{1}{default} para a equipe {lightred}{2}{default}!",
|
"sa_admin_team_message": "{lightred}{0}{default} transferiu {lightred}{1}{default} para a equipe {lightred}{2}{default}!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTÊNCIAS",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTÊNCIAS",
|
||||||
"sa_admin_warns_unwarn": "{lime}Advertência removida com sucesso{default} para {gold}{0} {default}por {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}Advertência removida com sucesso{default} para {gold}{0} {default}por {gold}{1}{default}!",
|
||||||
|
"sa_history": "Histórico",
|
||||||
|
"sa_history_header": "===== Histórico de punições de {0} ({1}) - {2} registo(s) =====",
|
||||||
|
"sa_history_none": "Nenhuma punição encontrada para {0}.",
|
||||||
"sa_admin_vote_menu_title": "{lime}VOTAÇÃO PARA {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}VOTAÇÃO PARA {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} iniciou uma votação para {lightred}{1}{default}",
|
"sa_admin_vote_message": "{lightred}{0}{default} iniciou uma votação para {lightred}{1}{default}",
|
||||||
"sa_admin_vote_message_results": "{lime}RESULTADOS DA VOTAÇÃO PARA {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}RESULTADOS DA VOTAÇÃO PARA {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}Ativaste novamente o som de {default}todos os jogadores",
|
"sa_admin_voice_unmute_all": "{Lime}Ativaste novamente o som de {default}todos os jogadores",
|
||||||
"sa_admin_voice_listen_all": "{Default}Agora consegues ouvir {lime}todos {default}os jogadores",
|
"sa_admin_voice_listen_all": "{Default}Agora consegues ouvir {lime}todos {default}os jogadores",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}Já não ouves {lime}todos {default}os jogadores",
|
"sa_admin_voice_unlisten_all": "{Default}Já não ouves {lime}todos {default}os jogadores",
|
||||||
"sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(JOGADOR) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(JOGADOR) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** executou o comando `{1}` no servidor `HOSTNAME`",
|
"sa_discord_log_command": "**{0}** executou o comando `{1}` no servidor `HOSTNAME`",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} перевел {lightred}{1}{default} в команду {lightred}{2}{default}!",
|
"sa_admin_team_message": "{lightred}{0}{default} перевел {lightred}{1}{default} в команду {lightred}{2}{default}!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}ПРЕДУПРЕЖДЕНИЯ",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}ПРЕДУПРЕЖДЕНИЯ",
|
||||||
"sa_admin_warns_unwarn": "{lime}Предупреждение успешно удалено{default} для {gold}{0} {default}за {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}Предупреждение успешно удалено{default} для {gold}{0} {default}за {gold}{1}{default}!",
|
||||||
|
"sa_history": "История",
|
||||||
|
"sa_history_header": "===== История наказаний игрока {0} ({1}) - записей: {2} =====",
|
||||||
|
"sa_history_none": "Наказания для {0} не найдены.",
|
||||||
"sa_admin_vote_menu_title": "{lime}ГОЛОСОВАНИЕ ЗА {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}ГОЛОСОВАНИЕ ЗА {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} начал голосование за {lightred}{1}{default}",
|
"sa_admin_vote_message": "{lightred}{0}{default} начал голосование за {lightred}{1}{default}",
|
||||||
"sa_admin_vote_message_results": "{lime}РЕЗУЛЬТАТЫ ГОЛОСОВАНИЯ ЗА {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}РЕЗУЛЬТАТЫ ГОЛОСОВАНИЯ ЗА {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}Вы включили звук {default}всем игрокам",
|
"sa_admin_voice_unmute_all": "{Lime}Вы включили звук {default}всем игрокам",
|
||||||
"sa_admin_voice_listen_all": "{Default}Теперь вы слышите {lime}всех {default}игроков",
|
"sa_admin_voice_listen_all": "{Default}Теперь вы слышите {lime}всех {default}игроков",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}Вы больше не слышите {lime}всех {default}игроков",
|
"sa_admin_voice_unlisten_all": "{Default}Вы больше не слышите {lime}всех {default}игроков",
|
||||||
"sa_adminsay_prefix": "{RED}АДМИН: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(АДМИН) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(АДМИН) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(ИГРОК) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(ИГРОК) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** выполнил команду `{1}` на сервере `HOSTNAME`",
|
"sa_discord_log_command": "**{0}** выполнил команду `{1}` на сервере `HOSTNAME`",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default}'i {lightred}{2}{default} takımına transfer etti!",
|
"sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default}'i {lightred}{2}{default} takımına transfer etti!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}UYARILAR",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}UYARILAR",
|
||||||
"sa_admin_warns_unwarn": "{lime}Uyarı başarıyla kaldırıldı{default} {gold}{0} {default} için {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}Uyarı başarıyla kaldırıldı{default} {gold}{0} {default} için {gold}{1}{default}!",
|
||||||
|
"sa_history": "Geçmiş",
|
||||||
|
"sa_history_header": "===== {0} ({1}) ceza geçmişi - {2} kayıt =====",
|
||||||
|
"sa_history_none": "{0} için ceza bulunamadı.",
|
||||||
"sa_admin_vote_menu_title": "{lime}OY VERME {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}OY VERME {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} {lightred}{1}{default} için oy kullanmaya başladı",
|
"sa_admin_vote_message": "{lightred}{0}{default} {lightred}{1}{default} için oy kullanmaya başladı",
|
||||||
"sa_admin_vote_message_results": "{lime}OY SONUÇLARI {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}OY SONUÇLARI {gold}{0}",
|
||||||
|
|
@ -130,7 +133,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}Tüm oyuncuların sesini {default}açtın",
|
"sa_admin_voice_unmute_all": "{Lime}Tüm oyuncuların sesini {default}açtın",
|
||||||
"sa_admin_voice_listen_all": "{Default}Artık {lime}tüm {default}oyuncuları duyabiliyorsun",
|
"sa_admin_voice_listen_all": "{Default}Artık {lime}tüm {default}oyuncuları duyabiliyorsun",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}Artık {lime}tüm {default}oyuncuları duymuyorsun",
|
"sa_admin_voice_unlisten_all": "{Default}Artık {lime}tüm {default}oyuncuları duymuyorsun",
|
||||||
"sa_adminsay_prefix": "{RED}Yönetici: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(Yönetici) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(Yönetici) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(Oyuncu) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(Oyuncu) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** `{1}` komutunu `HOSTNAME` sunucusunda gerçekleştirdi",
|
"sa_discord_log_command": "**{0}** `{1}` komutunu `HOSTNAME` sunucusunda gerçekleştirdi",
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,9 @@
|
||||||
"sa_admin_team_message": "{lightred}{0}{default} 将 {lightred}{1}{default} 转移到 {lightred}{2}{default} 队伍!",
|
"sa_admin_team_message": "{lightred}{0}{default} 将 {lightred}{1}{default} 转移到 {lightred}{2}{default} 队伍!",
|
||||||
"sa_admin_warns_menu_title": "{gold}{0} {lime}警告",
|
"sa_admin_warns_menu_title": "{gold}{0} {lime}警告",
|
||||||
"sa_admin_warns_unwarn": "{lime}成功移除警告{default} {gold}{0} 对于 {gold}{1}{default}!",
|
"sa_admin_warns_unwarn": "{lime}成功移除警告{default} {gold}{0} 对于 {gold}{1}{default}!",
|
||||||
|
"sa_history": "历史记录",
|
||||||
|
"sa_history_header": "===== {0} ({1}) 的处罚记录 - 共 {2} 条 =====",
|
||||||
|
"sa_history_none": "未找到 {0} 的处罚记录。",
|
||||||
"sa_admin_vote_menu_title": "{lime}投票 {gold}{0}",
|
"sa_admin_vote_menu_title": "{lime}投票 {gold}{0}",
|
||||||
"sa_admin_vote_message": "{lightred}{0}{default} 发起了 [{lightred}{1}{default}] 的投票",
|
"sa_admin_vote_message": "{lightred}{0}{default} 发起了 [{lightred}{1}{default}] 的投票",
|
||||||
"sa_admin_vote_message_results": "{lime}投票结果 {gold}{0}",
|
"sa_admin_vote_message_results": "{lime}投票结果 {gold}{0}",
|
||||||
|
|
@ -128,7 +131,7 @@
|
||||||
"sa_admin_voice_unmute_all": "{Lime}你已取消了{default}所有玩家的静音",
|
"sa_admin_voice_unmute_all": "{Lime}你已取消了{default}所有玩家的静音",
|
||||||
"sa_admin_voice_listen_all": "{Default}你现在可以听到{lime}所有{default}玩家了",
|
"sa_admin_voice_listen_all": "{Default}你现在可以听到{lime}所有{default}玩家了",
|
||||||
"sa_admin_voice_unlisten_all": "{Default}你现在不再听到{lime}所有{default}玩家了",
|
"sa_admin_voice_unlisten_all": "{Default}你现在不再听到{lime}所有{default}玩家了",
|
||||||
"sa_adminsay_prefix": "{RED}管理员: {lightred}{0}{default}",
|
"sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}",
|
||||||
"sa_adminchat_template_admin": "{LIME}(管理员) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_admin": "{LIME}(管理员) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_adminchat_template_player": "{SILVER}(玩家) {lightred}{0}{default}: {lightred}{1}{default}",
|
"sa_adminchat_template_player": "{SILVER}(玩家) {lightred}{0}{default}: {lightred}{1}{default}",
|
||||||
"sa_discord_log_command": "**{0}** 在服务器 `HOSTNAME` 上执行了命令 `{1}`",
|
"sa_discord_log_command": "**{0}** 在服务器 `HOSTNAME` 上执行了命令 `{1}`",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue