Merge pull request #293 from daffyyyy/fix/stability-and-penalty-history

1.9.0: unban delay, admin loading, rename persistence, css_history
This commit is contained in:
Ravid Atia 2026-10-03 02:34:25 +03:00 • committed by GitHub
commit 2813e91b0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 698 additions and 251 deletions

1
.gitignore vendored
View file

@ -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/

View file

@ -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.

View file

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

View file

@ -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)
{ {

View file

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

View file

@ -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)

View file

@ -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));
} }
} }

View file

@ -36,7 +36,6 @@ 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"))
@ -47,13 +46,24 @@ public partial class CS2_SimpleAdmin
return; return;
var playersToTarget = targets.Players.Where(player => player is { IsValid: true, IsHLTV: false }).ToList(); var playersToTarget = targets.Players.Where(player => player is { IsValid: true, IsHLTV: false }).ToList();
playersToTarget.ForEach(player => if (playersToTarget.Count != 1)
{ {
if (!player.UserId.HasValue) return; command.ReplyToCommand("Only one target is allowed.");
if (!caller.CanTarget(player)) return; return;
}
userId = player.UserId.Value; 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,34 +505,75 @@ 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 () =>
{
// Serialize reloads end to end: overlapping ones (css_admins_reload, add admin, map change) race on the JSON files,
// and CSS reads them on timers up to 4s later. Capped so a hibernating server cannot hold the lock forever.
await ReloadAdminsLock.WaitAsync();
// If the cap expires (hibernation) and a newer reload runs, this one's pending timers must not apply a stale snapshot
var generation = Interlocked.Increment(ref _reloadGeneration);
try
{ {
await PermissionManager.CreateGroupsJsonFile(); await PermissionManager.CreateGroupsJsonFile();
await PermissionManager.CreateAdminsJsonFile(); var admins = await PermissionManager.CreateAdminsJsonFile();
var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json"); var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json");
var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json"); var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json");
await Server.NextWorldUpdateAsync(() => var loaded = new TaskCompletionSource();
var scheduled = Server.NextWorldUpdateAsync(() =>
{ {
AddTimer(1, () => AddTimer(1, () =>
{ {
if (!string.IsNullOrEmpty(adminsFile)) if (!string.IsNullOrEmpty(adminsFile))
AddTimer(2.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json")); 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)) if (!string.IsNullOrEmpty(groupsFile))
AddTimer(3.0f, () => AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json")); AddTimer(3.0f, () =>
{
if (generation != _reloadGeneration) return;
AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json");
});
AddTimer(4.0f, () =>
{
if (generation == _reloadGeneration)
{
if (!string.IsNullOrEmpty(adminsFile)) if (!string.IsNullOrEmpty(adminsFile))
AddTimer(4.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json")); AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json");
_logger?.LogInformation("Loaded admins!"); _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>

View file

@ -292,8 +292,7 @@ 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
@ -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));
}); });
} }

View file

@ -42,6 +42,11 @@ public interface IDatabaseProvider
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);
} }

View file

@ -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;

View file

@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS `sa_renames` (
`player_steamid` INTEGER NOT NULL PRIMARY KEY,
`name` TEXT NOT NULL
);

View file

@ -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;
} }

View file

@ -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
@ -170,6 +171,15 @@ 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
""";
} }

View file

@ -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; return HookResult.Continue;
}
if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return HookResult.Continue; [GameEventHandler]
public HookResult OnPlayerChangename(EventPlayerChangename @event, GameEventInfo _)
if (player.PlayerName.Equals(name)) {
EnforceRename(@event.Userid);
return HookResult.Continue; 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); player.Rename(name);
});
return HookResult.Continue;
} }
} }

View file

@ -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(" ");
} }
} }

View file

@ -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)

View file

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

View file

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

View file

@ -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)
{ {
player.Rename(name); if (CS2_SimpleAdmin.DatabaseProvider == null) return [];
try
{
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);
} }
} }

View file

@ -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;
} }
@ -109,12 +111,19 @@ CS2_SimpleAdmin.ServerLoaded = true;
{ {
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}";

View file

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

View file

@ -1 +1 @@
1.8.2b 1.9.0

View file

@ -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;

View file

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

View file

@ -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`",

View file

@ -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",

View file

@ -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`",

View file

@ -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`",

View file

@ -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` اجرا کرد",

View file

@ -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`",

View file

@ -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`",

View file

@ -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`",

View file

@ -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`",

View file

@ -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`",

View file

@ -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`",

View file

@ -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",

View file

@ -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}`",