/*
* CSB Admin Map Menu
* Copyright (C) 2026 counter-strike-boost.com
*
* Reads mapcycle.txt (or addons/amxmodx/configs/maps.ini) into a paginated menu.
* Picking a map asks for confirmation, then changes level immediately or waits
* for the end of the round. amx_mapsearch <name> finds a map by substring, and
* every candidate is checked with is_map_valid before it is offered.
*
* Inspired by the AMX Mod X maps menu (AMX Mod X Development Team). Independent
* GPL re-implementation.
*
* 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 <https://www.gnu.org/licenses/> for details.
*/
#include <amxmodx>
#include <amxmisc>
new const PLUGIN[] = "CSB Admin Map Menu"
new const VERSION[] = "1.0.0"
new const AUTHOR[] = "counter-strike-boost.com"
#define MAX_MAPS 128
new g_szMaps[MAX_MAPS][32]
new g_iMapCount = 0
new g_szPending[33][32] /* map chosen, awaiting confirm */
new bool:g_bChangePending = false
new g_szNextMap[32]
public plugin_init()
{
register_plugin(PLUGIN, VERSION, AUTHOR)
register_concmd("amx_mapmenu", "cmdMapMenu", ADMIN_MAP, "- open the map change menu")
register_concmd("amx_mapsearch", "cmdSearch", ADMIN_MAP, "<part of name> - search maps and open a menu")
register_logevent("eventRoundEnd", 2, "1=Round_End")
}
public plugin_cfg()
{
loadMaps()
}
loadMaps()
{
g_iMapCount = 0
if (readMapFile("mapcycle.txt"))
return
readMapFile("addons/amxmodx/configs/maps.ini")
}
bool:readMapFile(const path[])
{
new line[64], name[32], len, i = 0
while (g_iMapCount < MAX_MAPS && read_file(path, i, line, charsmax(line), len))
{
i++
trim(line)
if (!line[0] || line[0] == ';' || line[0] == '/' || line[0] == '#')
continue
/* mapcycle lines can be "de_dust2" or "de_dust2 { ... }" */
strtok(line, name, charsmax(name), line, charsmax(line), ' ')
trim(name)
if (!name[0] || !is_map_valid(name))
continue
copy(g_szMaps[g_iMapCount], charsmax(g_szMaps[]), name)
g_iMapCount++
}
return g_iMapCount > 0
}
public cmdMapMenu(id, level, cid)
{
if (!cmd_access(id, level, cid, 1))
return PLUGIN_HANDLED
if (!g_iMapCount)
{
console_print(id, "[CSB] No maps loaded (mapcycle.txt / maps.ini empty).")
return PLUGIN_HANDLED
}
buildMenu(id, "")
return PLUGIN_HANDLED
}
public cmdSearch(id, level, cid)
{
if (!cmd_access(id, level, cid, 1))
return PLUGIN_HANDLED
new needle[32]
read_argv(1, needle, charsmax(needle))
if (!needle[0])
{
console_print(id, "[CSB] Usage: amx_mapsearch <part of name>")
return PLUGIN_HANDLED
}
buildMenu(id, needle)
return PLUGIN_HANDLED
}
buildMenu(id, const needle[])
{
new title[64]
formatex(title, charsmax(title), "\yCSB Map Menu\w^nPick a map to change to")
new menu = menu_create(title, "menuPick")
new added = 0
for (new i = 0; i < g_iMapCount; i++)
{
if (needle[0] && containi(g_szMaps[i], needle) == -1)
continue
menu_additem(menu, g_szMaps[i], g_szMaps[i], 0)
added++
}
if (!added)
{
menu_destroy(menu)
console_print(id, "[CSB] No maps matched '%s'.", needle)
return
}
menu_display(id, menu, 0)
}
public menuPick(id, menu, item)
{
if (item == MENU_EXIT || item < 0)
{
menu_destroy(menu)
return PLUGIN_HANDLED
}
new info[32], name[32], access, callback
menu_item_getinfo(menu, item, access, info, charsmax(info), name, charsmax(name), callback)
menu_destroy(menu)
copy(g_szPending[id], charsmax(g_szPending[]), info)
confirmMenu(id)
return PLUGIN_HANDLED
}
confirmMenu(id)
{
new title[96]
formatex(title, charsmax(title), "\yChange to %s?\w", g_szPending[id])
new menu = menu_create(title, "menuConfirm")
menu_additem(menu, "\rChange now", "now", 0)
menu_additem(menu, "\yChange at round end", "end", 0)
menu_additem(menu, "\wCancel", "cancel", 0)
menu_setprop(menu, MPROP_EXIT, MEXIT_ALL)
menu_display(id, menu, 0)
}
public menuConfirm(id, menu, item)
{
if (item == MENU_EXIT || item < 0)
{
menu_destroy(menu)
return PLUGIN_HANDLED
}
new info[8], name[32], access, callback
menu_item_getinfo(menu, item, access, info, charsmax(info), name, charsmax(name), callback)
menu_destroy(menu)
if (equal(info, "cancel") || !g_szPending[id][0])
return PLUGIN_HANDLED
if (!is_map_valid(g_szPending[id]))
{
client_print(id, print_chat, "[CSB] %s is no longer valid.", g_szPending[id])
return PLUGIN_HANDLED
}
new aname[32]
getName(id, aname, charsmax(aname))
if (equal(info, "now"))
{
client_print(0, print_chat, "[CSB] %s is changing the map to %s...", aname, g_szPending[id])
log_amx("[CSB Maps] %s changed the map to %s", aname, g_szPending[id])
new mp[32]
copy(mp, charsmax(mp), g_szPending[id])
set_task(2.0, "taskChange", 0, mp, charsmax(mp))
}
else
{
copy(g_szNextMap, charsmax(g_szNextMap), g_szPending[id])
g_bChangePending = true
client_print(0, print_chat, "[CSB] %s scheduled %s for the end of this round.", aname, g_szNextMap)
log_amx("[CSB Maps] %s scheduled %s at round end", aname, g_szNextMap)
}
return PLUGIN_HANDLED
}
public taskChange(const map[])
{
server_cmd("changelevel %s", map)
}
public eventRoundEnd()
{
if (!g_bChangePending)
return
g_bChangePending = false
server_cmd("changelevel %s", g_szNextMap)
}
getName(id, name[], len)
{
if (id)
get_user_name(id, name, len)
else
copy(name, len, "CONSOLE")
}