/*
* CSB Map Chooser
* Copyright (C) 2026 counter-strike-boost.com
*
* End-of-map vote. A short time before the time limit runs out the plugin opens
* a menu with a handful of random maps taken from the map cycle (excluding the
* current map and the last few that were played, which are tracked in a file),
* counts the votes with a HUD countdown and sets amx_nextmap to the winner. If
* enabled, players can instead vote to extend the current map, up to a capped
* number of extensions.
*
* Inspired by the original AMX Mod X MapChooser. This is an independent GPL
* re-implementation; 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
new const PLUGIN[] = "CSB Map Chooser"
new const VERSION[] = "1.0.0"
new const AUTHOR[] = "counter-strike-boost.com"
#define MAX_CHOICES 6
#define EXTEND_SLOT MAX_CHOICES /* votes[] index used by the extend option */
new g_pEnabled, g_pStart, g_pChoices, g_pDuration
new g_pExtend, g_pExtendStep, g_pExtendMax, g_pRecent
new g_szChoice[MAX_CHOICES][32]
new g_iVotes[MAX_CHOICES + 1]
new g_iNumChoices
new bool:g_bExtendOffered
new bool:g_bVoted[33]
new bool:g_bVoteActive
new bool:g_bDecided
new g_iExtendsDone
new g_iSecondsLeft
new g_menu = -1
public plugin_init()
{
register_plugin(PLUGIN, VERSION, AUTHOR)
g_pEnabled = register_cvar("csb_mc_enabled", "1")
g_pStart = register_cvar("csb_mc_start", "90") /* seconds before end to open the vote */
g_pChoices = register_cvar("csb_mc_choices", "5")
g_pDuration = register_cvar("csb_mc_duration", "20")
g_pExtend = register_cvar("csb_mc_extend", "1")
g_pExtendStep = register_cvar("csb_mc_extendstep", "15") /* minutes added per extend */
g_pExtendMax = register_cvar("csb_mc_extendmax", "2")
g_pRecent = register_cvar("csb_mc_recent", "3")
register_clcmd("say /nextmap", "cmdNextmap")
set_task(15.0, "checkTime", 0, _, _, "b")
}
public cmdNextmap(id)
{
new nextmap[32]
get_cvar_string("amx_nextmap", nextmap, charsmax(nextmap))
client_print(id, print_chat, "[CSB] Next map: %s", nextmap[0] ? nextmap : "not decided yet")
return PLUGIN_HANDLED
}
public checkTime()
{
if (!get_pcvar_num(g_pEnabled) || g_bVoteActive || g_bDecided)
return
new left = get_timeleft()
/* no timelimit, or too early */
if (left <= 10 || left > get_pcvar_num(g_pStart))
return
startVote()
}
startVote()
{
new pool[64][32], poolCount = 0
gatherMaps(pool, poolCount)
if (poolCount < 2)
{
log_amx("[CSB MapChooser] not enough eligible maps to hold a vote.")
g_bDecided = true
return
}
new want = get_pcvar_num(g_pChoices)
if (want < 2) want = 2
if (want > MAX_CHOICES) want = MAX_CHOICES
if (want > poolCount) want = poolCount
g_iNumChoices = 0
for (new i = 0; i < want; i++)
{
new pick = random_num(0, poolCount - 1)
copy(g_szChoice[g_iNumChoices], charsmax(g_szChoice[]), pool[pick])
g_iNumChoices++
/* remove the chosen map so it cannot be picked twice */
pool[pick] = pool[poolCount - 1]
poolCount--
}
for (new i = 0; i <= MAX_CHOICES; i++)
g_iVotes[i] = 0
for (new id = 1; id <= 32; id++)
g_bVoted[id] = false
g_bExtendOffered = (get_pcvar_num(g_pExtend) != 0 && g_iExtendsDone < get_pcvar_num(g_pExtendMax))
buildMenu()
new players[32], num
get_players(players, num, "ch")
for (new i = 0; i < num; i++)
menu_display(players[i], g_menu, 0)
g_bVoteActive = true
new dur = get_pcvar_num(g_pDuration)
if (dur < 5) dur = 5
g_iSecondsLeft = dur
set_task(1.0, "voteTick", 0, _, _, "a", dur)
set_task(float(dur), "endVote")
client_print_color(0, print_team_default, "^x04[CSB]^x03 Vote for the next map!^x01 Check the menu.")
}
buildMenu()
{
if (g_menu != -1)
{
menu_destroy(g_menu)
g_menu = -1
}
g_menu = menu_create("\yVote for the next map\w", "voteHandler")
new item[48]
for (new i = 0; i < g_iNumChoices; i++)
{
formatex(item, charsmax(item), "%s", g_szChoice[i])
menu_additem(g_menu, item)
}
if (g_bExtendOffered)
{
formatex(item, charsmax(item), "\rExtend the current map (+%d min)", get_pcvar_num(g_pExtendStep))
menu_additem(g_menu, item)
}
menu_setprop(g_menu, MPROP_EXIT, MEXIT_ALL)
}
public voteHandler(id, menu, item)
{
if (item == MENU_EXIT || !g_bVoteActive)
return PLUGIN_HANDLED
if (g_bVoted[id])
return PLUGIN_HANDLED
/* extend option sits right after the map items */
if (g_bExtendOffered && item == g_iNumChoices)
{
g_iVotes[EXTEND_SLOT]++
g_bVoted[id] = true
client_print(id, print_chat, "[CSB] You voted to extend the current map.")
return PLUGIN_HANDLED
}
if (item >= 0 && item < g_iNumChoices)
{
g_iVotes[item]++
g_bVoted[id] = true
client_print(id, print_chat, "[CSB] You voted for %s.", g_szChoice[item])
}
return PLUGIN_HANDLED
}
public voteTick()
{
if (!g_bVoteActive)
return
if (g_iSecondsLeft > 0)
g_iSecondsLeft--
set_hudmessage(255, 200, 0, -1.0, 0.20, 0, 0.0, 1.1, 0.0, 0.0, -1)
show_hudmessage(0, "Map vote: %d second(s) left", g_iSecondsLeft)
}
public endVote()
{
g_bVoteActive = false
new best = -1, bestVotes = -1
for (new i = 0; i < g_iNumChoices; i++)
{
if (g_iVotes[i] > bestVotes)
{
bestVotes = g_iVotes[i]
best = i
}
}
new bool:extendWon = false
if (g_bExtendOffered && g_iVotes[EXTEND_SLOT] > bestVotes)
{
extendWon = true
bestVotes = g_iVotes[EXTEND_SLOT]
}
if (extendWon)
{
new step = get_pcvar_num(g_pExtendStep)
new Float:cur = get_cvar_float("mp_timelimit")
set_cvar_float("mp_timelimit", cur + float(step))
g_iExtendsDone++
g_bDecided = false /* allow another vote later */
client_print_color(0, print_team_default,
"^x04[CSB]^x03 The current map was extended by^x04 %d^x01 minute(s) (%d vote(s)).",
step, bestVotes)
}
else if (best != -1)
{
set_cvar_string("amx_nextmap", g_szChoice[best])
g_bDecided = true
rememberCurrentMap()
client_print_color(0, print_team_default,
"^x04[CSB]^x03 Next map will be^x04 %s^x01 (%d vote(s)).",
g_szChoice[best], bestVotes)
}
else
{
g_bDecided = true
}
if (g_menu != -1)
{
menu_destroy(g_menu)
g_menu = -1
}
}
/* ---------- map pool ---------- */
gatherMaps(pool[][], &count)
{
count = 0
new cur[32]
get_mapname(cur, charsmax(cur))
new recent[16][32], recentNum = 0
loadRecent(recent, recentNum)
new cyclefile[64]
get_cvar_string("mapcyclefile", cyclefile, charsmax(cyclefile))
if (!cyclefile[0])
copy(cyclefile, charsmax(cyclefile), "mapcycle.txt")
new fp = fopen(cyclefile, "rt")
if (!fp)
return
new line[64], map[32]
while (!feof(fp) && count < 64)
{
fgets(fp, line, charsmax(line))
trim(line)
if (!line[0] || line[0] == ';' || (line[0] == '/' && line[1] == '/'))
continue
/* first token is the map name */
new i = 0, j = 0
while (line[i] && line[i] != ' ' && line[i] != '^t' && j < charsmax(map))
map[j++] = line[i++]
map[j] = 0
if (!map[0] || equali(map, cur) || !is_map_valid(map))
continue
if (inList(map, recent, recentNum) || inList(map, pool, count))
continue
copy(pool[count], 31, map)
count++
}
fclose(fp)
}
bool:inList(const map[], const list[][], num)
{
for (new i = 0; i < num; i++)
if (equali(list[i], map))
return true
return false
}
loadRecent(recent[][], &num)
{
num = 0
new path[160]
recentPath(path, charsmax(path))
if (!file_exists(path))
return
new fp = fopen(path, "rt")
if (!fp)
return
new line[32]
new keep = get_pcvar_num(g_pRecent)
while (!feof(fp) && num < 16)
{
fgets(fp, line, charsmax(line))
trim(line)
if (!line[0])
continue
copy(recent[num], 31, line)
num++
}
fclose(fp)
/* only the last 'keep' entries actually count */
if (keep >= 0 && num > keep)
{
new drop = num - keep
for (new i = 0; i < keep; i++)
copy(recent[i], 31, recent[i + drop])
num = keep
}
}
rememberCurrentMap()
{
new cur[32]
get_mapname(cur, charsmax(cur))
new path[160]
recentPath(path, charsmax(path))
new lines[16][32], num = 0
if (file_exists(path))
{
new fp = fopen(path, "rt")
if (fp)
{
new line[32]
while (!feof(fp) && num < 16)
{
fgets(fp, line, charsmax(line))
trim(line)
if (line[0])
{
copy(lines[num], 31, line)
num++
}
}
fclose(fp)
}
}
/* keep the most recent 15, then append the current map */
new start = 0
if (num >= 15)
start = num - 14
new fw = fopen(path, "wt")
if (!fw)
return
for (new i = start; i < num; i++)
{
fputs(fw, lines[i])
fputs(fw, "^n")
}
fputs(fw, cur)
fputs(fw, "^n")
fclose(fw)
}
recentPath(out[], len)
{
new dir[128]
get_localinfo("amxx_configsdir", dir, charsmax(dir))
formatex(out, len, "%s/csb_mc_recent.ini", dir)
}