diff --git a/README.md b/README.md new file mode 100644 index 0000000..239df82 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +## Description: +This SourceMod plugin adds fake clients (bots) to your server with a flexible tier system that adjusts the number of bots based on the number of real players currently connected. It helps simulate player activity and maintain server population dynamically. + +--- + +## Features + +- Dynamic bot count adjustment based on real player count using configurable tiers. +- Configurable delay before bots join after a map change. +- Supports fallback mode without tiers. +- Loads bot display names from an external file for varied and realistic bot names. +- Prevents kicking real players or SourceTV clients. +- Staggered bot join timers with random delays to avoid scripted behavior. + +--- + +## Installation + +1. Place the compiled plugin (`FakeClients.smx`) into your `addons/sourcemod/plugins/` directory. +2. Place the configuration files in the `configs/` directory: + - `fakeclients_tiers.cfg` — defines the tier thresholds and max bots per tier. + - `fakeclients.txt` — list of bot names, one per line. + +3. Restart or change the map on your server to load the plugin. + +--- + +## Configuration + +### 1. `fakeclients_tiers.cfg` + +This file defines the tier system controlling how many bots are allowed based on the number of real players connected. + +Format (KeyValues): + +```plaintext +"FakeClientsTiers" +{ + "0" "23" + "3" "22" + "9" "20" + "12" "19" + "18" "17" + "45" "8" + "48" "7" + "62" "0" +} +``` + +- The key is the minimum number of real players required to trigger that tier. +- The value is the maximum number of bots allowed at that tier. +- Entries are automatically sorted by threshold, so order in the file does not matter. +- You can define up to 32 tiers. + +--- + +### 2. `fakeclients.txt` + +This file contains the list of bot names to be used when creating fake clients. Each name should be on its own line. + +Example names: + +``` +SHITSHITSHIT +-Impulse- +SLAVA_UKRAINE +4ik-pyk +BigBoss +muhahahahaha +nide.gg +HeadShot +CRAY_ZEE +mlpro +Deadmemories +MaGothic +Hawk +``` + +- Duplicate names are allowed but the plugin tries to avoid assigning the same name to multiple bots simultaneously. +- You can customize this list freely to fit your server's theme. + +--- + +## ConVars + +- `sm_fakeclients_players` (default: 8) + Number of bots to spawn when tier system is disabled. + +- `sm_fakeclients_delay` (default: 120) + Delay in seconds after map change before bots start joining. + +- `sm_fakeclients_tiers` (default: 0) + Enable (1) or disable (0) the tier system. When enabled, bot counts follow the tiers defined in `fakeclients_tiers.cfg`. + +--- + +## How It Works + +- On map start, the plugin loads the bot names and tier configuration. +- After the configured delay, it adjusts the number of bots based on the current number of real players. +- If there are too many bots, it kicks the excess ones. +- If there are too few, it schedules staggered timers to add bots gradually. +- When real players join or leave, the bot count is adjusted accordingly. +- The plugin reserves slots to ensure real players can always connect, including extra slots if SourceTV is active. + +--- + +## Troubleshooting + +- Make sure `fakeclients_tiers.cfg` and `fakeclients.txt` are correctly placed in the `configs/` folder. +- Check your server logs for any errors related to missing or malformed config files. +- If the tier system is not working as expected, verify that `sm_fakeclients_tiers` ConVar is set to `1`. +- Ensure your bot names file has enough unique names to avoid duplicates. + +--- + +## Support & Contribution + +For issues, feature requests, or contributions, please visit the GitHub repository: +[https://github.com/srcdslab/sm-plugin-FakeClients](https://github.com/srcdslab/sm-plugin-FakeClients) diff --git a/addons/sourcemod/configs/fakeclients_tiers.cfg b/addons/sourcemod/configs/fakeclients_tiers.cfg new file mode 100644 index 0000000..290c1a5 --- /dev/null +++ b/addons/sourcemod/configs/fakeclients_tiers.cfg @@ -0,0 +1,27 @@ +"FakeClientsTiers" +{ + "0" "23" + "3" "22" + "6" "21" + "9" "20" + "12" "19" + "15" "18" + "18" "17" + "21" "16" + "24" "15" + "27" "14" + "30" "13" + "33" "12" + "36" "11" + "39" "10" + "42" "9" + "45" "8" + "48" "7" + "51" "6" + "54" "5" + "55" "4" + "57" "3" + "60" "2" + "61" "1" + "62" "0" +} diff --git a/addons/sourcemod/scripting/FakeClients.sp b/addons/sourcemod/scripting/FakeClients.sp index ab5e42c..5f3aaa0 100644 --- a/addons/sourcemod/scripting/FakeClients.sp +++ b/addons/sourcemod/scripting/FakeClients.sp @@ -1,155 +1,418 @@ #pragma semicolon 1 #pragma newdecls required - #include #include -Handle g_hCount; -Handle g_hDelay; -Handle g_hNames; +#define MAX_TIERS 32 + +ConVar g_hCount; +ConVar g_hDelay; +ConVar g_hUseTiers; +ArrayList g_hNames; + +int g_iTierThreshold[MAX_TIERS]; // real player count that triggers this tier +int g_iTierMaxBots[MAX_TIERS]; // max bots allowed for this tier +int g_iTierCount = 0; +int g_iPendingBots = 0; + +bool g_bUseTiers = false; public Plugin myinfo = { name = "FakeClients", - author = "Tsunami", - description = "Put fake clients in server", - version = "2.0.1", - url = "http://tsunami-productions.nl" + author = "Tsunami, .Rushaway", + description = "Put fake clients in server with tier system", + version = "3.0.0", + url = "https://github.com/srcdslab/sm-plugin-FakeClients" } -public void OnPluginStart() +public void OnPluginStart() { - g_hCount = CreateConVar("sm_fakeclients_players", "8", "Number of players to simulate", _, true, 0.0, true, 64.0); - g_hDelay = CreateConVar("sm_fakeclients_delay", "120", "Delay after map change before fake clients join (seconds)", _, true, 0.0, true, 10000.0); - g_hNames = CreateArray(64); + g_hCount = CreateConVar("sm_fakeclients_players", "8", "Fallback: number of bots when tier system is disabled", _, true, 0.0, true, 64.0); + g_hDelay = CreateConVar("sm_fakeclients_delay", "120", "Delay after map change before fake clients join (seconds)", _, true, 0.0, true, 10000.0); + g_hUseTiers = CreateConVar("sm_fakeclients_tiers", "0", "Use tier system from fakeclients_tiers.cfg (1 = enabled, 0 = disabled)", _, true, 0.0, true, 1.0); + + g_bUseTiers = g_hUseTiers.BoolValue; + g_hUseTiers.AddChangeHook(OnConVarChanged); + AutoExecConfig(true); } public void OnMapStart() { ParseNames(); - - CreateTimer(GetConVarInt(g_hDelay) * 1.0, Timer_CreateFakeClients); + ParseTiers(); + CreateTimer(g_hDelay.FloatValue, Timer_CreateFakeClients, _, TIMER_FLAG_NO_MAPCHANGE); } -public void OnClientPutInServer(int client) +public void OnConVarChanged(ConVar hConVar, const char[] sOldValue, const char[] sNewValue) { - if(!client) - return; - - if (!IsFakeClient(client)) + if (hConVar == g_hUseTiers) { - /*int iBots = 0, iClients = GetClientCount(true), iMaxBots = GetConVarInt(g_hCount); + g_bUseTiers = g_hUseTiers.BoolValue; + LogMessage("Tier system %s", g_bUseTiers ? "enabled" : "disabled"); - for (int i = 1; i <= MaxClients; i++) + if (g_bUseTiers) { - if (IsClientConnected(i) && IsFakeClient(i)) + g_iPendingBots = 0; + + for (int i = 1; i <= MaxClients; i++) { - iBots++; + if (!IsClientConnected(i) || !IsClientInGame(i)) + continue; + + if (!IsFakeClient(i) || IsClientSourceTV(i)) + continue; + + KickClient(i, "Client Disconnect"); } - }*/ - for (int i = 1; i <= MaxClients; i++) + // Wait a moment for all bots to be kicked before trying to add new ones according to tiers. + CreateTimer(1.0, Timer_CreateFakeClients, _, TIMER_FLAG_NO_MAPCHANGE); + } + else { - if (IsClientConnected(i) && IsFakeClient(i)) - { - Handle hTVName = FindConVar("tv_name"); - char sName[MAX_NAME_LENGTH], sTVName[MAX_NAME_LENGTH]; - - GetClientName(i, sName, sizeof(sName)); - - if (hTVName != INVALID_HANDLE) - { - GetConVarString(hTVName, sTVName, sizeof(sTVName)); - CloseHandle(hTVName); - } - - if (!StrEqual(sName, sTVName)) - { - KickClient(i, "Slot reserved"); - break; - } - } + AdjustFakeClientsToTier(); } } } -public void OnClientDisconnect(int client) +/** + * Returns the target bot count based on the current number of real players. + * Falls back to sm_fakeclients_players if tiers are disabled or not loaded. + */ +int GetTargetBotCount(int iRealPlayers) { - CreateTimer(1.0, Timer_CreateFakeClient); + if (!g_bUseTiers || g_iTierCount == 0) + return g_hCount.IntValue; + + // Walk tiers from highest threshold down, pick the first one that applies + for (int t = g_iTierCount - 1; t >= 0; t--) + { + if (iRealPlayers >= g_iTierThreshold[t]) + return g_iTierMaxBots[t]; + } + + // No tier matched (e.g. config missing threshold "0") + LogMessage("Warning: no tier matched for %d real players, defaulting to 0 bots", iRealPlayers); + return 0; } -public Action Timer_CreateFakeClient(Handle timer) +/** + * Computes the clamped target bot count, accounting for available slots and + * server over-capacity. Shared by AdjustFakeClientsToTier and OnClientPutInServer. + */ +int ComputeTarget(int iBots, int iRealPlayers, int iReservedSlots) { - int iBots = 0, iClients = GetClientCount(true), iMaxBots = GetConVarInt(g_hCount); - - if (iClients < MaxClients) + int iTarget = GetTargetBotCount(iRealPlayers); + if (iTarget < 0) + iTarget = 0; + + // Bots currently being added already count as occupied slots + int iEffectiveBots = iBots + g_iPendingBots; + int iFreeSlots = MaxClients - (iRealPlayers + iEffectiveBots + iReservedSlots); + + if (iFreeSlots < 0) { - for (int i = 1; i <= MaxClients; i++) - { - if (IsClientConnected(i) && IsFakeClient(i)) - { - iBots++; - } - } - - if (iBots < iMaxBots && iClients < iMaxBots) + iTarget = iEffectiveBots + iFreeSlots; + if (iTarget < 0) + iTarget = 0; + } + else + { + int iMaxBotsBySlots = iEffectiveBots + iFreeSlots; + if (iTarget > iMaxBotsBySlots) + iTarget = iMaxBotsBySlots; + } + + return iTarget; +} + +/** + * Kicks excess bots until the bot count reaches iTarget. + * Never kicks real players or SourceTV. + */ +void KickExcessBots(int iBots, int iRealPlayers, int iTarget) +{ + int iToKick = iBots - iTarget; + int iKicked = 0; + + for (int i = 1; i <= MaxClients && iKicked < iToKick; i++) + { + if (!IsClientConnected(i) || !IsClientInGame(i)) + continue; + + // Safety: never kick real players or SourceTV + if (!IsFakeClient(i) || IsClientSourceTV(i)) + continue; + + LogMessage("Kicking fake client '%N' (slot %d) — bots:%d target:%d", i, i, iBots, iTarget); + + KickClient(i, "Client Disconnect"); + iKicked++; + } + + if (iKicked > 0) + LogMessage("Kicked %d bot(s) — real:%d bots:%d target:%d slots:%d", iKicked, iRealPlayers, iBots, iTarget, MaxClients); +} + +/** + * Schedules staggered timers to add bots up to iToAdd, respecting free slots. + */ +void ScheduleBotsToAdd(int iToAdd, int iFreeSlots) +{ + if (iFreeSlots <= 0 || iToAdd <= 0) + return; + + if (iToAdd > iFreeSlots) + iToAdd = iFreeSlots; + + // Keep a staggered cadence but add jitter so joins look less scripted. + float fNextDelay = GetRandomFloat(0.4, 1.2); + + for (int j = 0; j < iToAdd; j++) + { + g_iPendingBots++; + CreateTimer(fNextDelay, Timer_CreateFakeClient, _, TIMER_FLAG_NO_MAPCHANGE); + fNextDelay += GetRandomFloat(0.7, 1.9); + } +} + +/** + * Compares current bot count against the target and either kicks excess bots + * or schedules new ones to fill the gap. + */ +void AdjustFakeClientsToTier() +{ + int iBots, iRealPlayers, iReservedSlots; + CollectClientCounts(iBots, iRealPlayers, iReservedSlots); + + int iTarget = ComputeTarget(iBots, iRealPlayers, iReservedSlots); + int iFreeSlots = MaxClients - (iRealPlayers + iBots + iReservedSlots); + + // Too many active bots -> kick extras + if (iBots > iTarget) + { + g_iPendingBots = 0; + KickExcessBots(iBots, iRealPlayers, iTarget); + } + // Active + pending bots are below target -> add missing bots + else if ((iBots + g_iPendingBots) < iTarget) + ScheduleBotsToAdd(iTarget - iBots - g_iPendingBots, iFreeSlots); +} + +/** + * Collects fake bots, real players and reserved slots in a single client loop. + */ +void CollectClientCounts(int &iBots, int &iRealPlayers, int &iReservedSlots) +{ + iBots = 0; + iRealPlayers = 0; + bool bHasSourceTV = false; + + for (int i = 1; i <= MaxClients; i++) + { + if (!IsClientConnected(i)) + continue; + + if (IsClientSourceTV(i)) { - char sTarget[MAX_TARGET_LENGTH]; - char sName[MAX_NAME_LENGTH]; - int iTargets[MAXPLAYERS]; - bool bTN_Is_ML; - GetArrayString(g_hNames, GetRandomInt(0, GetArraySize(g_hNames) - 1), sName, sizeof(sName)); - - while (ProcessTargetString(sName, - 0, - iTargets, - MAXPLAYERS, - COMMAND_FILTER_NO_MULTI, - sTarget, - MAX_TARGET_LENGTH, - bTN_Is_ML) == 1 && IsFakeClient(iTargets[0])) - { - GetArrayString(g_hNames, GetRandomInt(0, GetArraySize(g_hNames) - 1), sName, sizeof(sName)); - } - - CreateFakeClient(sName); + bHasSourceTV = true; + continue; // SourceTV occupies its own slot in MaxClients — do not count it as a bot } + + if (IsFakeClient(i)) + iBots++; + else + iRealPlayers++; } - - return Plugin_Handled; + + // Reserve 1 free slot so a real player can always connect. + // When SourceTV is active, reserve 1 extra (SourceTV's slot is already in MaxClients). + iReservedSlots = bHasSourceTV ? 2 : 1; } -public Action Timer_CreateFakeClients(Handle timer) +public void OnClientPutInServer(int client) { - for (int i = 1, c = GetConVarInt(g_hCount); i <= c; i++) + // Skip fake clients: bot additions are managed via timers. + // Calling AdjustFakeClientsToTier on every bot join would cause cascading timers. + if (!client || IsFakeClient(client)) + return; + + // A real player joined: only kick excess bots, never schedule additions. + // Scheduling here would race with the staggered timers already in flight. + int iBots, iRealPlayers, iReservedSlots; + CollectClientCounts(iBots, iRealPlayers, iReservedSlots); + + int iTarget = ComputeTarget(iBots, iRealPlayers, iReservedSlots); + if (iBots > iTarget) + KickExcessBots(iBots, iRealPlayers, iTarget); +} + +public void OnClientDisconnect(int client) +{ + // Ignore bot disconnects (caused by our own kicks) to avoid cascading timers. + if (IsFakeClient(client)) + return; + + CreateTimer(0.5, Timer_CreateFakeClients, _, TIMER_FLAG_NO_MAPCHANGE); +} + +public Action Timer_CreateFakeClient(Handle timer) +{ + g_iPendingBots--; + if (g_iPendingBots < 0) + g_iPendingBots = 0; + + int iBots, iRealPlayers, iReservedSlots; + CollectClientCounts(iBots, iRealPlayers, iReservedSlots); + + int iTarget = ComputeTarget(iBots, iRealPlayers, iReservedSlots); + int iFreeSlots = MaxClients - (iRealPlayers + iBots + iReservedSlots); + + // Recheck: tier may have changed since this timer was scheduled + if (iFreeSlots <= 0 || iBots >= iTarget) + return Plugin_Handled; + + char sName[MAX_NAME_LENGTH]; + char sTarget[MAX_TARGET_LENGTH]; + int iTargets[MAXPLAYERS]; + bool bTN_Is_ML; + + // Pick a random name, re-roll if it's already taken by a fake client. + // Stop after trying all available names to prevent an infinite loop when + // the name list is smaller than the number of bots (duplicates are allowed). + // If no names are configured, CreateFakeClient will assign a default name based on the engine. + int iNameCount = g_hNames.Length; + if (iNameCount > 0) { - CreateTimer(i * 1.0, Timer_CreateFakeClient); + g_hNames.GetString(GetRandomInt(0, iNameCount - 1), sName, sizeof(sName)); + + for (int iAttempt = 1; iAttempt < iNameCount; iAttempt++) + { + if (ProcessTargetString(sName, 0, iTargets, MAXPLAYERS, COMMAND_FILTER_NO_MULTI, sTarget, MAX_TARGET_LENGTH, bTN_Is_ML) != 1 || !IsFakeClient(iTargets[0])) + break; + + g_hNames.GetString(GetRandomInt(0, iNameCount - 1), sName, sizeof(sName)); + } } + CreateFakeClient(sName); + return Plugin_Handled; +} + +public Action Timer_CreateFakeClients(Handle timer) +{ + AdjustFakeClientsToTier(); return Plugin_Continue; } -stock void ParseNames() +/** + * Loads tier thresholds from configs/fakeclients_tiers.cfg. Max 32 tiers. + * Format (KeyValues): + * + * "FakeClientsTiers" + * { + * "0" "12" // 0 real players → up to 12 bots + * "5" "10" // 5+ real players → up to 10 bots + * "15" "8" // And so on... + * } + * + * Entries are sorted by threshold automatically, so order in the file + * does not matter. + */ +stock void ParseTiers() { - char sBuffer[256]; - BuildPath(Path_SM, sBuffer, sizeof(sBuffer), "configs/fakeclients.txt"); - - Handle hConfig = OpenFile(sBuffer, "r"); - - if (hConfig != INVALID_HANDLE) + if (!g_bUseTiers) + return; + + g_iTierCount = 0; + + char sPath[PLATFORM_MAX_PATH]; + BuildPath(Path_SM, sPath, sizeof(sPath), "configs/fakeclients_tiers.cfg"); + + KeyValues kv = new KeyValues("FakeClientsTiers"); + + if (!kv.ImportFromFile(sPath)) + { + LogError("configs/fakeclients_tiers.cfg not found — falling back to sm_fakeclients_players"); + g_hUseTiers.BoolValue = false; + delete kv; + return; + } + + if (!kv.GotoFirstSubKey(false)) + { + LogError("configs/fakeclients_tiers.cfg is empty or malformed"); + g_hUseTiers.BoolValue = false; + delete kv; + return; + } + + do + { + if (g_iTierCount >= MAX_TIERS) + break; + + char sKey[16], sVal[16]; + kv.GetSectionName(sKey, sizeof(sKey)); + kv.GetString(NULL_STRING, sVal, sizeof(sVal), "-1"); + + int iMaxBots = StringToInt(sVal); + if (iMaxBots < 0) + continue; // skip malformed lines + + g_iTierThreshold[g_iTierCount] = StringToInt(sKey); + g_iTierMaxBots[g_iTierCount] = iMaxBots; + g_iTierCount++; + + } while (kv.GotoNextKey(false)); + + delete kv; + + // Bubble sort tiers by threshold (ascending) so GetTargetBotCount() works correctly + for (int i = 0; i < g_iTierCount - 1; i++) { - ClearArray(g_hNames); - - while (ReadFileLine(hConfig, sBuffer, sizeof(sBuffer))) + for (int j = 0; j < g_iTierCount - 1 - i; j++) { - TrimString(sBuffer); - - if (strlen(sBuffer) > 0) + if (g_iTierThreshold[j] > g_iTierThreshold[j + 1]) { - PushArrayString(g_hNames, sBuffer); + int tmp; + tmp = g_iTierThreshold[j]; g_iTierThreshold[j] = g_iTierThreshold[j + 1]; g_iTierThreshold[j + 1] = tmp; + tmp = g_iTierMaxBots[j]; g_iTierMaxBots[j] = g_iTierMaxBots[j + 1]; g_iTierMaxBots[j + 1] = tmp; } } - - CloseHandle(hConfig); } + + LogMessage("Loaded %d tier(s) from fakeclients_tiers.cfg", g_iTierCount); +} + +/** + * Loads bot display names from configs/fakeclients.txt (one name per line). + */ +stock void ParseNames() +{ + delete g_hNames; + g_hNames = new ArrayList(MAX_NAME_LENGTH); + + char sBuffer[256]; + BuildPath(Path_SM, sBuffer, sizeof(sBuffer), "configs/fakeclients.txt"); + + File hConfig = OpenFile(sBuffer, "r"); + if (!hConfig) + { + LogError("configs/fakeclients.txt not found — using default engine name"); + return; + } + + while (hConfig.ReadLine(sBuffer, sizeof(sBuffer))) + { + TrimString(sBuffer); + if (strlen(sBuffer) > 0) + g_hNames.PushString(sBuffer); + } + + delete hConfig; + + if (!g_hNames.Length) + LogError("configs/fakeclients.txt is empty — using default engine name"); }