/*
* CSB MySQL Ranks
* Copyright (C) 2026 counter-strike-boost.com
*
* The same statistics model as the nVault rank plugin, but stored in MySQL with
* fully threaded SQLx queries so the game thread is never blocked. Stats are
* loaded with an async SELECT on authorise, written back with an async UPSERT
* on disconnect and at map end, and the player's ladder position is resolved
* with a COUNT(*) query against a stored score column. Several servers can
* therefore share one ladder and the website can read the same table.
*
* Inspired by MySQL rank systems such as PsychoStats. This is an independent
* GPL re-implementation with its own tiny schema; no original code is reused.
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version. It is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See for details.
*/
#include
#include
#include
new const PLUGIN[] = "CSB MySQL Ranks"
new const VERSION[] = "1.0.0"
new const AUTHOR[] = "counter-strike-boost.com"
#define TABLE "csb_ranks"
enum _:Stats
{
ST_KILLS,
ST_DEATHS,
ST_HS,
ST_DAMAGE,
ST_ROUNDS,
ST_TIME
}
new g_iStats[33][Stats]
new g_iConnect[33]
new g_szAuth[33][35]
new bool:g_bLoaded[33]
new Handle:g_hTuple = Empty_Handle
new g_pHost, g_pUser, g_pPass, g_pDb
public plugin_init()
{
register_plugin(PLUGIN, VERSION, AUTHOR)
g_pHost = register_cvar("csb_rank_host", "127.0.0.1")
g_pUser = register_cvar("csb_rank_user", "csb")
g_pPass = register_cvar("csb_rank_pass", "")
g_pDb = register_cvar("csb_rank_db", "csb")
register_event("DeathMsg", "onDeath", "a")
register_event("HLTV", "onNewRound", "a", "1=0", "2=0")
RegisterHam(Ham_TakeDamage, "player", "fw_TakeDamage")
register_clcmd("say /rank", "cmdRank")
register_clcmd("say_team /rank", "cmdRank")
register_clcmd("say /stats", "cmdRank")
}
public plugin_cfg()
{
new host[64], user[64], pass[64], db[64]
get_pcvar_string(g_pHost, host, charsmax(host))
get_pcvar_string(g_pUser, user, charsmax(user))
get_pcvar_string(g_pPass, pass, charsmax(pass))
get_pcvar_string(g_pDb, db, charsmax(db))
g_hTuple = SQL_MakeDbTuple(host, user, pass, db)
new query[640]
formatex(query, charsmax(query), "CREATE TABLE IF NOT EXISTS `%s` (`authid` VARCHAR(34) NOT NULL PRIMARY KEY, `name` VARCHAR(32) NOT NULL DEFAULT '', `kills` INT UNSIGNED NOT NULL DEFAULT 0, `deaths` INT UNSIGNED NOT NULL DEFAULT 0, `hs` INT UNSIGNED NOT NULL DEFAULT 0, `damage` INT UNSIGNED NOT NULL DEFAULT 0, `rounds` INT UNSIGNED NOT NULL DEFAULT 0, `playtime` INT UNSIGNED NOT NULL DEFAULT 0, `score` INT NOT NULL DEFAULT 0, `last_seen` INT UNSIGNED NOT NULL DEFAULT 0, KEY `k_score` (`score`)) ENGINE=InnoDB", TABLE)
SQL_ThreadQuery(g_hTuple, "handlerSimple", query)
}
public plugin_end()
{
for (new id = 1; id <= 32; id++)
if (g_bLoaded[id])
saveStats(id)
if (g_hTuple != Empty_Handle)
SQL_FreeHandle(g_hTuple)
}
/* ---------- load / save ---------- */
public client_authorized(id)
{
resetSlot(id)
if (is_user_bot(id) || is_user_hltv(id) || g_hTuple == Empty_Handle)
return
get_user_authid(id, g_szAuth[id], charsmax(g_szAuth[]))
g_iConnect[id] = get_systime()
g_bLoaded[id] = true
if (!g_szAuth[id][0])
return
new safe[70]
sqlEscape(g_szAuth[id], safe, charsmax(safe))
new query[256]
formatex(query, charsmax(query), "SELECT `kills`, `deaths`, `hs`, `damage`, `rounds`, `playtime` FROM `%s` WHERE `authid` = '%s'", TABLE, safe)
new data[2]
data[0] = id
data[1] = get_user_userid(id)
SQL_ThreadQuery(g_hTuple, "handlerLoad", query, data, sizeof(data))
}
public handlerLoad(failstate, Handle:query, error[], errnum, data[], size, Float:queuetime)
{
if (failstate != TQUERY_SUCCESS)
{
log_amx("[CSB Rank] load failed (%d): %s", errnum, error)
return
}
new id = data[0]
if (!is_user_connected(id) || get_user_userid(id) != data[1])
return
if (!SQL_NumResults(query))
return
g_iStats[id][ST_KILLS] = SQL_ReadResult(query, 0)
g_iStats[id][ST_DEATHS] = SQL_ReadResult(query, 1)
g_iStats[id][ST_HS] = SQL_ReadResult(query, 2)
g_iStats[id][ST_DAMAGE] = SQL_ReadResult(query, 3)
g_iStats[id][ST_ROUNDS] = SQL_ReadResult(query, 4)
g_iStats[id][ST_TIME] = SQL_ReadResult(query, 5)
}
public client_disconnected(id)
{
if (g_bLoaded[id])
saveStats(id)
resetSlot(id)
}
saveStats(id)
{
if (g_hTuple == Empty_Handle || !g_szAuth[id][0])
return
if (g_iConnect[id])
{
g_iStats[id][ST_TIME] += get_systime() - g_iConnect[id]
g_iConnect[id] = get_systime()
}
new name[32], safeName[70], safeAuth[70]
get_user_name(id, name, charsmax(name))
sqlEscape(name, safeName, charsmax(safeName))
sqlEscape(g_szAuth[id], safeAuth, charsmax(safeAuth))
new score = scoreOf(id)
new query[768]
formatex(query, charsmax(query), "INSERT INTO `%s` (`authid`, `name`, `kills`, `deaths`, `hs`, `damage`, `rounds`, `playtime`, `score`, `last_seen`) VALUES ('%s', '%s', %d, %d, %d, %d, %d, %d, %d, %d) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `kills` = VALUES(`kills`), `deaths` = VALUES(`deaths`), `hs` = VALUES(`hs`), `damage` = VALUES(`damage`), `rounds` = VALUES(`rounds`), `playtime` = VALUES(`playtime`), `score` = VALUES(`score`), `last_seen` = VALUES(`last_seen`)",
TABLE, safeAuth, safeName,
g_iStats[id][ST_KILLS], g_iStats[id][ST_DEATHS], g_iStats[id][ST_HS],
g_iStats[id][ST_DAMAGE], g_iStats[id][ST_ROUNDS], g_iStats[id][ST_TIME],
score, get_systime())
SQL_ThreadQuery(g_hTuple, "handlerSimple", query)
}
resetSlot(id)
{
for (new i = 0; i < Stats; i++)
g_iStats[id][i] = 0
g_iConnect[id] = 0
g_szAuth[id][0] = 0
g_bLoaded[id] = false
}
/* ---------- gameplay hooks ---------- */
public onDeath()
{
new killer = read_data(1)
new victim = read_data(2)
new headshot = read_data(3)
if (victim >= 1 && victim <= 32 && g_bLoaded[victim])
g_iStats[victim][ST_DEATHS]++
if (killer >= 1 && killer <= 32 && killer != victim && g_bLoaded[killer])
{
g_iStats[killer][ST_KILLS]++
if (headshot)
g_iStats[killer][ST_HS]++
}
}
public fw_TakeDamage(victim, inflictor, attacker, Float:damage, damagebits)
{
if (attacker < 1 || attacker > 32 || attacker == victim || !g_bLoaded[attacker])
return HAM_IGNORED
g_iStats[attacker][ST_DAMAGE] += floatround(damage)
return HAM_IGNORED
}
public onNewRound()
{
for (new id = 1; id <= 32; id++)
if (g_bLoaded[id])
g_iStats[id][ST_ROUNDS]++
}
/* ---------- score / rank ---------- */
scoreOf(id)
{
return g_iStats[id][ST_KILLS] * 2
+ g_iStats[id][ST_HS]
+ g_iStats[id][ST_DAMAGE] / 50
+ g_iStats[id][ST_ROUNDS]
- g_iStats[id][ST_DEATHS]
}
public cmdRank(id)
{
if (!g_bLoaded[id])
{
client_print(id, print_chat, "[CSB] Your stats are not loaded yet.")
return PLUGIN_HANDLED
}
new mins = g_iStats[id][ST_TIME] / 60
if (g_iConnect[id])
mins += (get_systime() - g_iConnect[id]) / 60
client_print(id, print_chat,
"[CSB] Kills: %d | Deaths: %d | HS: %d | Damage: %d | Rounds: %d | Time: %d min | Score: %d",
g_iStats[id][ST_KILLS], g_iStats[id][ST_DEATHS], g_iStats[id][ST_HS],
g_iStats[id][ST_DAMAGE], g_iStats[id][ST_ROUNDS], mins, scoreOf(id))
/* resolve the exact ladder position with a threaded COUNT(*) */
if (g_hTuple != Empty_Handle)
{
new query[192]
formatex(query, charsmax(query), "SELECT COUNT(*) + 1 FROM `%s` WHERE `score` > %d", TABLE, scoreOf(id))
new data[2]
data[0] = id
data[1] = get_user_userid(id)
SQL_ThreadQuery(g_hTuple, "handlerPosition", query, data, sizeof(data))
}
return PLUGIN_HANDLED
}
public handlerPosition(failstate, Handle:query, error[], errnum, data[], size, Float:queuetime)
{
if (failstate != TQUERY_SUCCESS)
{
log_amx("[CSB Rank] position query failed (%d): %s", errnum, error)
return
}
new id = data[0]
if (!is_user_connected(id) || get_user_userid(id) != data[1])
return
if (!SQL_NumResults(query))
return
new pos = SQL_ReadResult(query, 0)
client_print_color(id, print_team_default,
"^x04[CSB]^x03 Your ladder position is^x04 #%d^x01.", pos)
}
public handlerSimple(failstate, Handle:query, error[], errnum, data[], size, Float:queuetime)
{
if (failstate != TQUERY_SUCCESS)
log_amx("[CSB Rank] query failed (%d): %s", errnum, error)
}
/* ---------- helpers ---------- */
stock sqlEscape(const input[], output[], len)
{
new i = 0, j = 0, c
while (input[i] != 0 && j < len)
{
c = input[i]
if (c == 39 || c == 34 || c == 92 || c == 59 || c == 37 || c == 96 || c < 32)
{
i++
continue
}
output[j++] = c
i++
}
output[j] = 0
}