/* * 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 }