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
/Modules/CS2-SimpleAdmin_BanSoundModule
/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 information about all online players.

View file

@ -251,9 +251,9 @@ css_prename PlayerName "NewIdentity"
```
**Notes:**
- Name is enforced even after reconnect
- Stored in database
- Player cannot change it
- Name is enforced even after reconnect, map change and server restart
- Stored in the database (`sa_renames`) and shared by every server using that database
- Player cannot change it; `css_prename <player>` with no name removes the rename
- 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 ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)";
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)
{
@ -83,7 +83,12 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
RegisterEvents();
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)
{

View file

@ -36,106 +36,10 @@
</ItemGroup>
<ItemGroup>
<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>
<None Update="Database\Migrations\*.sql" CopyToOutputDirectory="PreserveNewest" />
<None Update="Database\Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View file

@ -45,6 +45,7 @@ public static class RegisterCommands
new("css_who", CS2_SimpleAdmin.Instance.OnWhoCommand),
new("css_disconnected", CS2_SimpleAdmin.Instance.OnDisconnectedCommand),
new("css_warns", CS2_SimpleAdmin.Instance.OnWarnsCommand),
new("css_history", CS2_SimpleAdmin.Instance.OnHistoryCommand),
new("css_players", CS2_SimpleAdmin.Instance.OnPlayersCommand),
new("css_kick", CS2_SimpleAdmin.Instance.OnKickCommand),
new("css_map", CS2_SimpleAdmin.Instance.OnMapCommand),
@ -130,6 +131,7 @@ public static class RegisterCommands
{ "css_who", new Command { Aliases = ["css_who"] } },
{ "css_disconnected", new Command { Aliases = ["css_disconnected", "css_last"] } },
{ "css_warns", new Command { Aliases = ["css_warns"] } },
{ "css_history", new Command { Aliases = ["css_history", "css_penaltyhistory"] } },
{ "css_players", new Command { Aliases = ["css_players"] } },
{ "css_kick", new Command { Aliases = ["css_kick"] } },
{ "css_map", new Command { Aliases = ["css_map", "css_changemap"] } },
@ -198,6 +200,13 @@ public static class RegisterCommands
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)

View file

@ -71,13 +71,16 @@ public partial class CS2_SimpleAdmin
var utf8BytesString = Encoding.UTF8.GetBytes(command.GetCommandString[command.GetCommandString.IndexOf(' ')..]);
var utf8String = Encoding.UTF8.GetString(utf8BytesString);
var callerName = caller?.PlayerName;
Helper.LogCommand(caller, command);
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,
"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)
return;
var userId = caller.UserId.Value;
var steamId = caller.SteamID;
if (!string.IsNullOrEmpty(command.GetArg(1)) && AdminManager.PlayerHasPermissions(new SteamID(caller.SteamID), "@css/kick"))
@ -47,13 +46,24 @@ public partial class CS2_SimpleAdmin
return;
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;
if (!caller.CanTarget(player)) return;
command.ReplyToCommand("Only one target is allowed.");
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 () =>
@ -495,34 +505,75 @@ public partial class CS2_SimpleAdmin
/// Reloads admin data asynchronously and updates admin caches.
/// </summary>
/// <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)
{
if (DatabaseProvider == null) return;
Task.Run(async () =>
{
// Serialize reloads end to end: overlapping ones (css_admins_reload, add admin, map change) race on the JSON files,
// and CSS reads them on timers up to 4s later. Capped so a hibernating server cannot hold the lock forever.
await ReloadAdminsLock.WaitAsync();
// If the cap expires (hibernation) and a newer reload runs, this one's pending timers must not apply a stale snapshot
var generation = Interlocked.Increment(ref _reloadGeneration);
try
{
await PermissionManager.CreateGroupsJsonFile();
await PermissionManager.CreateAdminsJsonFile();
var admins = await PermissionManager.CreateAdminsJsonFile();
var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json");
var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json");
await Server.NextWorldUpdateAsync(() =>
var loaded = new TaskCompletionSource();
var scheduled = Server.NextWorldUpdateAsync(() =>
{
AddTimer(1, () =>
{
if (!string.IsNullOrEmpty(adminsFile))
AddTimer(2.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"));
AddTimer(2.0f, () =>
{
if (generation != _reloadGeneration) return;
// Strip old permissions right before loading the new ones, not seconds earlier
PermissionManager.ApplyAdminCache(admins);
AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json");
});
if (!string.IsNullOrEmpty(groupsFile))
AddTimer(3.0f, () => AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json"));
AddTimer(3.0f, () =>
{
if (generation != _reloadGeneration) return;
AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json");
});
AddTimer(4.0f, () =>
{
if (generation == _reloadGeneration)
{
if (!string.IsNullOrEmpty(adminsFile))
AddTimer(4.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"));
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.GiveAllFlags();
}
@ -836,6 +887,99 @@ public partial class CS2_SimpleAdmin
});
}
/// <summary>
/// Prints a player's full penalty history (bans, gags, mutes, silences, warns) to the caller's console.
/// Accepts a SteamID64 so it also works for players who are not online.
/// </summary>
/// <param name="caller">The player issuing the command or null for console.</param>
/// <param name="command">The command containing the target and an optional penalty type filter.</param>
[RequiresPermissions("@css/kick")]
[CommandHelper(minArgs: 1, usage: "<#userid or name or steamid64> [bans|gags|mutes|silences|warns]", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
public void OnHistoryCommand(CCSPlayerController? caller, CommandInfo command)
{
if (DatabaseProvider == null || _localizer == null) return;
var filter = command.ArgCount > 2 ? command.GetArg(2).ToLowerInvariant() : null;
if (filter is not (null or "bans" or "gags" or "mutes" or "silences" or "warns"))
{
command.ReplyToCommand("Usage: css_history <#userid or name or steamid64> [bans|gags|mutes|silences|warns]");
return;
}
ulong steamId;
string? name = null;
if (Helper.ValidateSteamId(command.GetArg(1), out var parsedSteamId) && parsedSteamId != null)
{
if (caller != null && !caller.CanTarget(parsedSteamId)) return;
steamId = parsedSteamId.SteamId64;
if (PlayersInfo.TryGetValue(steamId, out var info)) name = info.Name;
}
else
{
var targets = GetTarget(command);
if (targets == null) return;
var players = targets.Players.Where(p => p is { IsValid: true, IsBot: false, IsHLTV: false }).ToList();
if (players.Count != 1)
{
command.ReplyToCommand("Only one target is allowed.");
return;
}
var player = players[0];
if (caller != null && !caller.CanTarget(player)) return;
steamId = player.SteamID;
name = player.PlayerName;
}
Helper.LogCommand(caller, command);
Action<string> print = caller == null ? Server.PrintToConsole : caller.PrintToConsole;
var callerSteamId = caller?.SteamID;
Task.Run(async () =>
{
var rows = await PlayerManager.GetPenaltyHistory(steamId, filter);
// Offline players are only known by SteamID; borrow the name stored on their latest record
name ??= rows.Count > 0 ? (string?)rows[0].player_name : null;
await Server.NextWorldUpdateAsync(() =>
{
// The slot may have been reused by another player while the query ran
if (caller != null && (!caller.IsValid || caller.SteamID != callerSteamId)) return;
PrintHistory(print, name ?? steamId.ToString(), steamId, rows);
});
});
}
/// <summary>
/// Writes history rows from <see cref="PlayerManager.GetPenaltyHistory"/> as one console line each.
/// </summary>
internal void PrintHistory(Action<string> print, string name, ulong steamId, List<dynamic> rows)
{
if (_localizer == null) return;
if (rows.Count == 0)
{
print(_localizer["sa_history_none", name]);
return;
}
print(_localizer["sa_history_header", name, steamId, rows.Count]);
foreach (var r in rows)
{
var duration = (int)r.duration == 0 ? "perm" : $"{(int)r.duration}m";
var line = $"[{Date(r.created)}] {(string)r.type,-7} {(string)r.status,-8} {duration,-6} by {(string)r.admin_name}: {(string)r.reason}";
if (r.lift_date != null)
line += $" | lifted {Date(r.lift_date)} by {(string?)r.lift_admin ?? "?"}: {(string?)r.lift_reason}";
print(line);
}
static string Date(object? value) => PlayerManager.ToDateTime(value)?.ToString("yyyy-MM-dd HH:mm") ?? "?";
}
/// <summary>
/// Lists players currently connected to the server with options to output JSON or filter duplicate IPs.
/// </summary>

View file

@ -292,8 +292,7 @@ public partial class CS2_SimpleAdmin
var adminActivityArgs = new object[] { "CALLER", player.PlayerName, newName };
// 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);
// Rename the player
@ -344,15 +343,18 @@ public partial class CS2_SimpleAdmin
}
// Determine if the new name is valid and update the renamed players list
var steamId = player.SteamID;
if (!string.IsNullOrEmpty(newName))
{
RenamedPlayers[player.SteamID] = newName;
RenamedPlayers[steamId] = newName;
player.Rename(newName);
}
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 GetExpireOldPlayerIpsQuery();
// Renames (css_prename)
string GetRenamesQuery();
string GetUpsertRenameQuery();
string GetDeleteRenameQuery();
// MuteManager
string GetAddMuteQuery(bool includePlayerName);
string GetIsMutedQuery(bool multiServer, int timeMode);
@ -61,4 +66,7 @@ public interface IDatabaseProvider
string GetUnwarnByIdQuery(bool multiServer);
string GetUnwarnLastQuery(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)
{
return multiServer
? "SELECT id 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'"
: "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()
@ -238,6 +238,15 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
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)
{
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 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;
}

View file

@ -143,8 +143,8 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
public string GetUnbanRetrieveBansQuery(bool multiServer) =>
multiServer
? "SELECT id 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'"
: "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() =>
"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) 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() =>
"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) =>
multiServer
? "UPDATE sa_bans SET status = 'EXPIRED' 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"
: "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) =>
multiServer
@ -170,6 +171,15 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
public string GetExpireOldPlayerIpsQuery() =>
"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() =>
"""
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
? "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";
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]
public HookResult OnPlayerInfo(EventPlayerInfo @event, GameEventInfo _)
{
var player = @event.Userid;
if (player is null || !player.IsValid || player.IsBot)
EnforceRename(@event.Userid);
return HookResult.Continue;
}
if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return HookResult.Continue;
if (player.PlayerName.Equals(name))
[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);
return HookResult.Continue;
});
}
}

View file

@ -440,6 +440,25 @@ internal static class Helper
_ = CS2_SimpleAdmin.DiscordWebhookClientLog.SendMessageAsync(GenerateMessageDiscord(localizer["sa_discord_log_command", $"[{callerName}]({communityUrl})", command]));
}
/// <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)
{
string[] publishActions = ["ban", "gag", "silence", "mute"];
@ -486,15 +505,9 @@ internal static class Helper
var currentMessageArgs = (string[])formattedMessageArgs.Clone();
// 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++)
{
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"]),
};
}
currentMessageArgs[i] = currentMessageArgs[i].Replace("CALLER", shownName);
// Send the localized message to each player
controller.SendLocalizedMessage(CS2_SimpleAdmin._localizer, messageKey, currentMessageArgs.Cast<object>().ToArray());
@ -569,15 +582,9 @@ internal static class Helper
var currentMessageArgs = (string[])formattedMessageArgs.Clone();
// Replace "CALLER" placeholder based on showActivityType
var shownName = ResolveActivityName(controller, callerName);
for (var i = 0; i < currentMessageArgs.Length; i++)
{
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"]),
};
}
currentMessageArgs[i] = currentMessageArgs[i].Replace("CALLER", shownName);
// Send the localized message to each player using their language
controller.SendLocalizedMessage(moduleLocalizer, messageKey, currentMessageArgs.Cast<object>().ToArray());
@ -1011,10 +1018,6 @@ public static class PluginInfo
Console.WriteLine("|_______||___| |_| |_||___| |_______||_______||__| |__||______| |_| |_||___| |_| |__|");
Console.WriteLine(" >> Version: " + moduleVersion);
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(" ");
}
}

View file

@ -1,5 +1,6 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.ValveConstants.Protobuf;
using CounterStrikeSharp.API.Modules.Entities;
using CS2_SimpleAdminApi;
using Dapper;
using Microsoft.Extensions.Logging;
@ -339,8 +340,34 @@ public async Task UnbanPlayer(string playerPattern, string adminSteamId, string
var sqlUpdateBan = databaseProvider.GetUpdateBanStatusQuery();
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)

View file

@ -10,8 +10,10 @@ namespace CS2_SimpleAdmin.Managers;
internal class CacheManager: IDisposable
{
private readonly ConcurrentDictionary<int, BanRecord> _banCache = [];
private readonly ConcurrentDictionary<ulong, List<BanRecord>> _steamIdIndex = [];
private readonly ConcurrentDictionary<uint, List<BanRecord>> _ipIndex = [];
// Rebuilt into fresh instances and swapped, so unlocked readers on the game thread never see a half-built index
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 HashSet<uint> _cachedIgnoredIps = [];
@ -178,7 +180,7 @@ internal class CacheManager: IDisposable
player_steamid AS PlayerSteamId,
player_ip AS PlayerIp,
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 }
)).ToList();
@ -221,7 +223,7 @@ internal class CacheManager: IDisposable
player_steamid AS PlayerSteamId,
player_ip AS PlayerIp,
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 }
)).ToList();
@ -294,7 +296,7 @@ internal class CacheManager: IDisposable
.GroupBy(x => x.Address)
.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(
g.Key,
latest.Used_at,
@ -353,8 +355,36 @@ internal class CacheManager: IDisposable
/// </summary>
private void RebuildIndexes()
{
_steamIdIndex.Clear();
_ipIndex.Clear();
// SetBanStatus (unban thread) and RefreshCacheAsync (timer thread) can both get here
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
var banType = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType;
@ -369,10 +399,10 @@ internal class CacheManager: IDisposable
if (ban.PlayerSteamId.HasValue)
{
var steamId = ban.PlayerSteamId.Value;
if (!_steamIdIndex.TryGetValue(steamId, out var steamList))
if (!steamIdIndex.TryGetValue(steamId, out var steamList))
{
steamList = new List<BanRecord>();
_steamIdIndex[steamId] = steamList;
steamIdIndex[steamId] = steamList;
}
steamList.Add(ban);
}
@ -381,14 +411,18 @@ internal class CacheManager: IDisposable
if (checkIpBans && !string.IsNullOrEmpty(ban.PlayerIp) &&
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>();
_ipIndex[ipUInt] = ipList;
ipIndex[ipUInt] = ipList;
}
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>

View file

@ -68,7 +68,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
catch (Exception ex)
{
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)
{
CS2_SimpleAdmin._logger?.LogError("Unable to load groups from database! {exception}", ex.Message);
throw;
}
return [];
}
/// <summary>
@ -151,7 +150,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
/// Creates a JSON file containing admins data asynchronously.
/// </summary>
[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();
var validPlayers = allPlayers
@ -190,47 +189,11 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
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;
});
var newCache = validPlayers.Select(player => (new SteamID(player.identity), player.ends, player.flags)).ToList();
var options = new JsonSerializerOptions
{
WriteIndented = true,
@ -240,6 +203,39 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
var json = JsonSerializer.Serialize(jsonData, options);
var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.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>

View file

@ -1,3 +1,4 @@
using System.Globalization;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Admin;
@ -44,6 +45,11 @@ internal class PlayerManager
: CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown";
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;
Task.Run(async () =>
@ -219,10 +225,92 @@ internal class PlayerManager
_loadPlayerSemaphore.Release();
}
});
}
if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(player.SteamID, out var name))
/// <summary>
/// Returns every ban, gag/mute/silence and warn ever recorded for a SteamID, newest first.
/// Rows carry: type, status, reason, duration, created, ends, admin_name, lift_reason, lift_date, lift_admin.
/// </summary>
/// <param name="steamId">SteamID64 of the player, online or not.</param>
/// <param name="type">Optional filter: bans, gags, mutes, silences or warns.</param>
public async Task<List<dynamic>> GetPenaltyHistory(ulong steamId, string? type = null)
{
player.Rename(name);
if (CS2_SimpleAdmin.DatabaseProvider == null) return [];
try
{
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
var sql = CS2_SimpleAdmin.DatabaseProvider.GetPenaltyHistoryQuery(CS2_SimpleAdmin.Instance.Config.MultiServerMode);
var rows = (await connection.QueryAsync(sql, new { PlayerSteamID = steamId, serverid = CS2_SimpleAdmin.ServerId })).ToList();
// Filter word is the plural (bans, gags, mutes, silences, warns); rows carry BAN/GAG/MUTE/SILENCE/WARN
if (type != null)
rows.RemoveAll(r => !type.Equals((string)r.type + "s", StringComparison.OrdinalIgnoreCase));
return rows;
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError("Unable to load penalty history: {exception}", ex.Message);
return [];
}
}
/// <summary>
/// Dapper gives DateTime on MySQL but can give a string on SQLite; accept both.
/// </summary>
internal static DateTime? ToDateTime(object? value) =>
value switch
{
DateTime d => d,
string s when DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) => parsed,
_ => null
};
/// <summary>
/// Loads permanent renames from the database into memory.
/// </summary>
public async Task LoadRenamedPlayers()
{
if (CS2_SimpleAdmin.DatabaseProvider == null) return;
try
{
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
var rows = await connection.QueryAsync<(long steamId, string name)>(CS2_SimpleAdmin.DatabaseProvider.GetRenamesQuery());
await Server.NextWorldUpdateAsync(() =>
{
foreach (var (steamId, name) in rows)
CS2_SimpleAdmin.RenamedPlayers[(ulong)steamId] = name;
});
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError("Unable to load renames: {exception}", ex.Message);
}
}
/// <summary>
/// Persists or removes a permanent rename (css_prename) for a player.
/// </summary>
/// <param name="steamId">SteamID64 of the player.</param>
/// <param name="name">Forced name, or null/empty to remove the rename.</param>
public async Task SaveRenamedPlayer(ulong steamId, string? name)
{
if (CS2_SimpleAdmin.DatabaseProvider == null) return;
try
{
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
if (string.IsNullOrEmpty(name))
await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetDeleteRenameQuery(), new { steamId });
else
await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetUpsertRenameQuery(), new { steamId, name });
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError("Unable to save rename: {exception}", ex.Message);
}
}

View file

@ -43,6 +43,8 @@ public class ServerManager
if (_getIpTryCount > 32 && isInvalidIp)
{
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;
}
@ -109,12 +111,19 @@ CS2_SimpleAdmin.ServerLoaded = true;
{
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)
{
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)
{
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", "silence", "sa_silence", CreateSilenceMenu, "@css/chat");
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
manager.RegisterMenu("server", "plugins", "sa_menu_pluginsmanager_title", CreatePluginsMenu, "@css/root");
@ -127,6 +128,62 @@ public abstract class BasicMenu
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>
/// Creates menu for kicking players with reason selection.
/// </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 WarnManager WarnManager = new(DatabaseProvider);
internal CacheManager? CacheManager = new();
private static readonly PlayerManager PlayerManager = new();
internal static readonly PlayerManager PlayerManager = new();
// Timers
internal Timer? PlayersTimer = null;

View file

@ -1,5 +1,6 @@
{GREEN}[ CS2-SimpleAdmin HELP ]{DEFAULT}
- 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_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

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} نقل {lightred}{1}{default} إلى {lightred}{2}{default}!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}تحذيرات",
"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_message": "{lightred}{0}{default} بدأ التصويت لـ {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}نتائج التصويت لـ {gold}{0}",
@ -130,7 +133,7 @@
"sa_admin_voice_unmute_all": "{Lime}لقد ألغيت كتم {default}جميع اللاعبين",
"sa_admin_voice_listen_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_player": "{SILVER}(لاعب) {lightred}{0}{default}: {lightred}{1}{default}",
"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_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_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_message": "{lightred}{0}{default} hat eine Abstimmung für {lightred}{1}{default} gestartet",
"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_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_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_player": "{SILVER}(SPIELER) {lightred}{0}{default}: {lightred}{1}{default}",
"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_warns_menu_title": "{gold}{0} {lime}WARNS",
"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_message": "{lightred}{0}{default} started voting for {lightred}{1}{default}",
"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_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_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_player": "{SILVER}(PLAYER) {lightred}{0}{default}: {lightred}{1}{default}",
"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_warns_menu_title": "{gold}{0} {lime}ADVERTENCIAS",
"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_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}",
@ -130,7 +133,7 @@
"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_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_player": "{SILVER}(JUGADOR) {lightred}{0}{default}: {lightred}{1}{default}",
"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_warns_menu_title": "{gold}{0} {lime}هشدارها",
"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_message": "{lightred}{0}{default} رأی‌گیری برای {lightred}{1}{default} را شروع کرد",
"sa_admin_vote_message_results": "{lime}نتایج رأی‌گیری برای {gold}{0}",
@ -130,7 +133,7 @@
"sa_admin_voice_unmute_all": "{Lime}شما {default}همه بازیکنان را از حالت بی‌صدا خارج کردید",
"sa_admin_voice_listen_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_player": "{SILVER}(بازیکن) {lightred}{0}{default}: {lightred}{1}{default}",
"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_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_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_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}",
@ -130,7 +133,7 @@
"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_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_player": "{SILVER}(JOUEUR) {lightred}{0}{default}: {lightred}{1}{default}",
"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_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_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_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}",
@ -130,7 +133,7 @@
"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_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_player": "{SILVER}(SPĒLĒTĀJS) {lightred}{0}{default}: {lightred}{1}{default}",
"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_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_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_message": "{lightred}{0}{default} rozpoczął głosowanie na {lightred}{1}{default}",
"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_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_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_player": "{SILVER}(GRACZ) {lightred}{0}{default}: {lightred}{1}{default}",
"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_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_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_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}",
@ -130,7 +133,7 @@
"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_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_player": "{SILVER}(JOGADOR) {lightred}{0}{default}: {lightred}{1}{default}",
"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_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_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_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}",
@ -130,7 +133,7 @@
"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_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_player": "{SILVER}(JOGADOR) {lightred}{0}{default}: {lightred}{1}{default}",
"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_warns_menu_title": "{gold}{0} {lime}ПРЕДУПРЕЖДЕНИЯ",
"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_message": "{lightred}{0}{default} начал голосование за {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}РЕЗУЛЬТАТЫ ГОЛОСОВАНИЯ ЗА {gold}{0}",
@ -130,7 +133,7 @@
"sa_admin_voice_unmute_all": "{Lime}Вы включили звук {default}всем игрокам",
"sa_admin_voice_listen_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_player": "{SILVER}(ИГРОК) {lightred}{0}{default}: {lightred}{1}{default}",
"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_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_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_message": "{lightred}{0}{default} {lightred}{1}{default} için oy kullanmaya başladı",
"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_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_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_player": "{SILVER}(Oyuncu) {lightred}{0}{default}: {lightred}{1}{default}",
"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_warns_menu_title": "{gold}{0} {lime}警告",
"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_message": "{lightred}{0}{default} 发起了 [{lightred}{1}{default}] 的投票",
"sa_admin_vote_message_results": "{lime}投票结果 {gold}{0}",
@ -128,7 +131,7 @@
"sa_admin_voice_unmute_all": "{Lime}你已取消了{default}所有玩家的静音",
"sa_admin_voice_listen_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_player": "{SILVER}(玩家) {lightred}{0}{default}: {lightred}{1}{default}",
"sa_discord_log_command": "**{0}** 在服务器 `HOSTNAME` 上执行了命令 `{1}`",