Fix unban delay, admin loading, rename persistence; add css_history (1.9.0)

Fixes for issues reported by server owners, plus one new command.

Unbans took up to a minute to apply, and on SQLite never applied at all:
- css_unban only wrote to the database, but the connect check reads an
  in-memory cache that syncs on a 61s timer. BanManager.UnbanPlayer now
  marks the bans UNBANNED in CacheManager and rebuilds the indexes, and
  logs failures instead of swallowing them.
- SQLite has no ON UPDATE CURRENT_TIMESTAMP and the unban/expire queries
  never set updated_at, so the delta refresh never saw the change and
  the ban stayed active until a plugin reload. Both queries set it now.
- The delta query uses >= so a change written in the same second as the
  refresh snapshot is not skipped.
- css_unban issues removeid when UnlockedCommands is on, clearing the
  engine-level banid that css_ban adds.
- RebuildIndexes builds fresh dictionaries and swaps them under a lock,
  so the game thread cannot read a half-built index now that unbans can
  trigger a rebuild off the timer thread.

Admin permissions were intermittently missing after a restart:
- The startup reload fired at a fixed 3s, usually before ServerManager
  had resolved ServerId, so the admin query ran with a null server and
  returned only global admins, with nothing reloading afterwards.
  ServerManager now reloads admins once ServerId is set, and on both of
  its failure paths so global admins still load.
- Loading the plugin mid-map (css_plugins load) fires no map start, so
  server loading is kicked off after 5s if it has not happened.
- CreateAdminsJsonFile no longer strips permissions from inside the
  ToDictionary lambda, where it ran once per name group and seconds
  before the new data was loaded. It returns the new cache and
  PermissionManager.ApplyAdminCache applies it on the main thread
  immediately before AdminManager.LoadAdminData.
- Reloads are serialized with a semaphore and carry a generation, so
  overlapping ones cannot race on the JSON files or apply a stale
  snapshot after the hibernation cap.
- GetAllPlayersFlags and GetAllGroupsData rethrow instead of returning
  an empty list; ReloadAdmins catches, logs and aborts. A transient
  database error previously wrote an empty admins.json and stripped
  every admin on the server.

Permanent renames did not survive a restart:
- New sa_renames table (migration 017); css_prename upserts or deletes,
  and the table is loaded into memory once the server is ready.
- The rename is re-applied on connect before the DatabaseProvider /
  CacheManager early return, which previously skipped it whenever the
  DB or cache was not ready yet.
- New EventPlayerChangename handler, and both it and OnPlayerInfo
  re-check after a short delay instead of comparing names at event
  time; the engine may not have written the client's new name yet, and
  Rename itself takes 0.4s to settle.

Smaller fixes:
- css_say printed "ADMIN: <text>" with no name. The caller name is
  passed to the localizer and sa_adminsay_prefix carries {1} in all 13
  languages.
- css_penalties <player> with @css/kick reassigned only the local user
  id while every lookup kept using caller.SteamID, so an admin checking
  another player always saw their own penalties. It now uses the
  target's SteamID, and replies instead of throwing when that player's
  connect data has not finished loading.
- css_rename returned early for an admin in silent mode, which skipped
  the rename itself rather than just the announcement.

New: css_history <#userid|name|steamid64> [bans|gags|mutes|silences|warns]
(@css/kick) prints every ban, gag, mute, silence and warn with status,
duration, the admin who issued it, the reason and, for lifted bans and
mutes, who lifted it, when and why. A SteamID64 works for players who
are not online. Also available in the admin menu under Players >
History. The data was always there: unban, unmute and expiry only flip
the status column, rows are never deleted.

Build:
- The csproj listed all 32 migration files by hand, so a new migration
  silently never reached the output. Replaced with a glob.
- Commands missing from an existing Commands.json are registered under
  their default name, so new commands appear without deleting the file.
  An empty alias list still disables a command.
This commit is contained in:
Ravid-A 2026-10-03 02:18:43 +03:00
parent cf284bd121
commit 530f60366d
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,32 +505,73 @@ public partial class CS2_SimpleAdmin
/// Reloads admin data asynchronously and updates admin caches. /// Reloads admin data asynchronously and updates admin caches.
/// </summary> /// </summary>
/// <param name="caller">The player issuing the reload command.</param> /// <param name="caller">The player issuing the reload command.</param>
private static readonly SemaphoreSlim ReloadAdminsLock = new(1, 1);
private static int _reloadGeneration;
public void ReloadAdmins(CCSPlayerController? caller) public void ReloadAdmins(CCSPlayerController? caller)
{ {
if (DatabaseProvider == null) return; if (DatabaseProvider == null) return;
Task.Run(async () => Task.Run(async () =>
{ {
await PermissionManager.CreateGroupsJsonFile(); // Serialize reloads end to end: overlapping ones (css_admins_reload, add admin, map change) race on the JSON files,
await PermissionManager.CreateAdminsJsonFile(); // and CSS reads them on timers up to 4s later. Capped so a hibernating server cannot hold the lock forever.
await ReloadAdminsLock.WaitAsync();
var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json"); // If the cap expires (hibernation) and a newer reload runs, this one's pending timers must not apply a stale snapshot
var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json"); var generation = Interlocked.Increment(ref _reloadGeneration);
try
await Server.NextWorldUpdateAsync(() =>
{ {
AddTimer(1, () => await PermissionManager.CreateGroupsJsonFile();
{ var admins = await PermissionManager.CreateAdminsJsonFile();
if (!string.IsNullOrEmpty(adminsFile))
AddTimer(2.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"));
if (!string.IsNullOrEmpty(groupsFile))
AddTimer(3.0f, () => AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json"));
if (!string.IsNullOrEmpty(adminsFile))
AddTimer(4.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"));
_logger?.LogInformation("Loaded admins!"); var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json");
var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json");
var loaded = new TaskCompletionSource();
var scheduled = Server.NextWorldUpdateAsync(() =>
{
AddTimer(1, () =>
{
if (!string.IsNullOrEmpty(adminsFile))
AddTimer(2.0f, () =>
{
if (generation != _reloadGeneration) return;
// Strip old permissions right before loading the new ones, not seconds earlier
PermissionManager.ApplyAdminCache(admins);
AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json");
});
if (!string.IsNullOrEmpty(groupsFile))
AddTimer(3.0f, () =>
{
if (generation != _reloadGeneration) return;
AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json");
});
AddTimer(4.0f, () =>
{
if (generation == _reloadGeneration)
{
if (!string.IsNullOrEmpty(adminsFile))
AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json");
_logger?.LogInformation("Loaded admins!");
}
// Always completes, so a superseded reload cannot hold the lock until the cap
loaded.TrySetResult();
});
});
}); });
});
await Task.WhenAny(Task.WhenAll(scheduled, loaded.Task), Task.Delay(TimeSpan.FromSeconds(15)));
}
catch (Exception ex)
{
// A failed DB read aborts here, leaving current permissions untouched
_logger?.LogError("Unable to reload admins: {exception}", ex.Message);
}
finally
{
ReloadAdminsLock.Release();
}
}); });
//_ = _adminManager.GiveAllGroupsFlags(); //_ = _adminManager.GiveAllGroupsFlags();
@ -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,9 +292,8 @@ public partial class CS2_SimpleAdmin
var adminActivityArgs = new object[] { "CALLER", player.PlayerName, newName }; var adminActivityArgs = new object[] { "CALLER", player.PlayerName, newName };
// Display admin activity message to other players // Display admin activity message to other players
if (caller != null && SilentPlayers.Contains(caller.Slot)) return; if (caller != null && !SilentPlayers.Contains(caller.Slot))
Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs);
Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs);
// Rename the player // Rename the player
player.Rename(newName); player.Rename(newName);
@ -344,15 +343,18 @@ public partial class CS2_SimpleAdmin
} }
// Determine if the new name is valid and update the renamed players list // Determine if the new name is valid and update the renamed players list
var steamId = player.SteamID;
if (!string.IsNullOrEmpty(newName)) if (!string.IsNullOrEmpty(newName))
{ {
RenamedPlayers[player.SteamID] = newName; RenamedPlayers[steamId] = newName;
player.Rename(newName); player.Rename(newName);
} }
else else
{ {
RenamedPlayers.Remove(player.SteamID); RenamedPlayers.Remove(steamId);
} }
Task.Run(() => PlayerManager.SaveRenamedPlayer(steamId, newName));
}); });
} }

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;
if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return HookResult.Continue;
if (player.PlayerName.Equals(name))
return HookResult.Continue;
player.Rename(name);
return HookResult.Continue; return HookResult.Continue;
} }
[GameEventHandler]
public HookResult OnPlayerChangename(EventPlayerChangename @event, GameEventInfo _)
{
EnforceRename(@event.Userid);
return HookResult.Continue;
}
/// <summary>
/// Re-applies a permanent rename. Checked after a short delay because the engine may not
/// have written the client's new name yet when the event fires, and Rename itself
/// takes 0.4s to settle (it briefly writes the name with a trailing space).
/// </summary>
private void EnforceRename(CCSPlayerController? player)
{
if (player is null || !player.IsValid || player.IsBot) return;
if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return;
AddTimer(0.5f, () =>
{
if (player.IsValid && !player.PlayerName.Equals(name))
player.Rename(name);
});
}
} }

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)
{
if (CS2_SimpleAdmin.DatabaseProvider == null) return [];
try
{ {
player.Rename(name); await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
var sql = CS2_SimpleAdmin.DatabaseProvider.GetPenaltyHistoryQuery(CS2_SimpleAdmin.Instance.Config.MultiServerMode);
var rows = (await connection.QueryAsync(sql, new { PlayerSteamID = steamId, serverid = CS2_SimpleAdmin.ServerId })).ToList();
// Filter word is the plural (bans, gags, mutes, silences, warns); rows carry BAN/GAG/MUTE/SILENCE/WARN
if (type != null)
rows.RemoveAll(r => !type.Equals((string)r.type + "s", StringComparison.OrdinalIgnoreCase));
return rows;
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError("Unable to load penalty history: {exception}", ex.Message);
return [];
}
}
/// <summary>
/// Dapper gives DateTime on MySQL but can give a string on SQLite; accept both.
/// </summary>
internal static DateTime? ToDateTime(object? value) =>
value switch
{
DateTime d => d,
string s when DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) => parsed,
_ => null
};
/// <summary>
/// Loads permanent renames from the database into memory.
/// </summary>
public async Task LoadRenamedPlayers()
{
if (CS2_SimpleAdmin.DatabaseProvider == null) return;
try
{
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
var rows = await connection.QueryAsync<(long steamId, string name)>(CS2_SimpleAdmin.DatabaseProvider.GetRenamesQuery());
await Server.NextWorldUpdateAsync(() =>
{
foreach (var (steamId, name) in rows)
CS2_SimpleAdmin.RenamedPlayers[(ulong)steamId] = name;
});
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError("Unable to load renames: {exception}", ex.Message);
}
}
/// <summary>
/// Persists or removes a permanent rename (css_prename) for a player.
/// </summary>
/// <param name="steamId">SteamID64 of the player.</param>
/// <param name="name">Forced name, or null/empty to remove the rename.</param>
public async Task SaveRenamedPlayer(ulong steamId, string? name)
{
if (CS2_SimpleAdmin.DatabaseProvider == null) return;
try
{
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
if (string.IsNullOrEmpty(name))
await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetDeleteRenameQuery(), new { steamId });
else
await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetUpsertRenameQuery(), new { steamId, name });
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError("Unable to save rename: {exception}", ex.Message);
} }
} }

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;
} }
@ -103,18 +105,25 @@ public class ServerManager
CS2_SimpleAdmin.ServerId = serverId; CS2_SimpleAdmin.ServerId = serverId;
CS2_SimpleAdmin._logger?.LogInformation("Loaded server with ip {ip}", ipAddress); CS2_SimpleAdmin._logger?.LogInformation("Loaded server with ip {ip}", ipAddress);
CS2_SimpleAdmin.ServerLoaded = true; CS2_SimpleAdmin.ServerLoaded = true;
if (CS2_SimpleAdmin.Instance.CacheManager != null) if (CS2_SimpleAdmin.Instance.CacheManager != null)
{ {
await CS2_SimpleAdmin.Instance.CacheManager.InitializeCacheAsync(); await CS2_SimpleAdmin.Instance.CacheManager.InitializeCacheAsync();
} }
// Admins are loaded only now, once ServerId is known, so server-specific admins are included
CS2_SimpleAdmin.Instance.ReloadAdmins(null);
} }
catch (Exception ex) catch (Exception ex)
{ {
CS2_SimpleAdmin._logger?.LogCritical("Unable to create or get server_id: " + ex.Message); CS2_SimpleAdmin._logger?.LogCritical("Unable to create or get server_id: " + ex.Message);
CS2_SimpleAdmin.Instance.ReloadAdmins(null); // still load global admins
} }
// Renames are server-independent, so load them even when the server row could not be resolved
await CS2_SimpleAdmin.PlayerManager.LoadRenamedPlayers();
if (CS2_SimpleAdmin.Instance.Config.EnableMetrics) if (CS2_SimpleAdmin.Instance.Config.EnableMetrics)
{ {
var queryString = $"?address={address}&hostname={hostname}"; var queryString = $"?address={address}&hostname={hostname}";

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