From b327a80291e538b330b2b8f5cb5b9ffb391f6ecc Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 6 Mar 2026 23:40:51 +0100 Subject: [PATCH 1/2] feat: add CS:GO SourceMod plugin (SourcePawn port of CS2 plugin) Full rewrite of the 5Stack CS2 CounterStrikeSharp plugin as a SourceMod plugin for CS:GO (Source 1). Implements the same backend protocol using HTTP POST instead of WebSocket. Core systems: match state machine, ready system, knife round, captain management, tactical/tech timeouts, vote framework, demo recording/upload, player auth, and all game event handlers. --- .../sourcemod/configs/fivestack/fivestack.cfg | 16 + csgo/addons/sourcemod/scripting/fivestack.sp | 299 ++++++++++++ .../scripting/fivestack/captain_system.inc | 166 +++++++ .../fivestack/commands/captain_cmd.inc | 29 ++ .../scripting/fivestack/commands/demo_cmd.inc | 30 ++ .../scripting/fivestack/commands/help_cmd.inc | 41 ++ .../fivestack/commands/knife_cmd.inc | 43 ++ .../fivestack/commands/match_cmd.inc | 37 ++ .../fivestack/commands/ready_cmd.inc | 22 + .../fivestack/commands/timeout_cmd.inc | 30 ++ .../scripting/fivestack/commands/vote_cmd.inc | 23 + .../sourcemod/scripting/fivestack/config.inc | 54 +++ .../sourcemod/scripting/fivestack/enums.inc | 140 ++++++ .../scripting/fivestack/events/bomb.inc | 66 +++ .../scripting/fivestack/events/game_end.inc | 67 +++ .../fivestack/events/player_chat.inc | 67 +++ .../fivestack/events/player_connected.inc | 54 +++ .../fivestack/events/player_damage.inc | 57 +++ .../fivestack/events/player_disconnected.inc | 39 ++ .../fivestack/events/player_kills.inc | 100 +++++ .../fivestack/events/player_spawn.inc | 35 ++ .../fivestack/events/player_utility.inc | 97 ++++ .../scripting/fivestack/events/round_end.inc | 68 +++ .../fivestack/events/round_start.inc | 33 ++ .../scripting/fivestack/game_demos.inc | 156 +++++++ .../scripting/fivestack/game_server.inc | 99 ++++ .../sourcemod/scripting/fivestack/globals.inc | 207 +++++++++ .../scripting/fivestack/http_client.inc | 33 ++ .../scripting/fivestack/json_helpers.inc | 191 ++++++++ .../scripting/fivestack/knife_system.inc | 197 ++++++++ .../scripting/fivestack/match_data.inc | 100 +++++ .../scripting/fivestack/match_events.inc | 157 +++++++ .../scripting/fivestack/match_manager.inc | 425 ++++++++++++++++++ .../scripting/fivestack/match_service.inc | 55 +++ .../scripting/fivestack/match_utility.inc | 192 ++++++++ .../scripting/fivestack/player_auth.inc | 77 ++++ .../scripting/fivestack/ready_system.inc | 190 ++++++++ .../scripting/fivestack/team_utility.inc | 208 +++++++++ .../scripting/fivestack/timeout_system.inc | 193 ++++++++ .../scripting/fivestack/vote_system.inc | 236 ++++++++++ .../translations/fivestack.phrases.txt | 115 +++++ 41 files changed, 4444 insertions(+) create mode 100644 csgo/addons/sourcemod/configs/fivestack/fivestack.cfg create mode 100644 csgo/addons/sourcemod/scripting/fivestack.sp create mode 100644 csgo/addons/sourcemod/scripting/fivestack/captain_system.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/commands/captain_cmd.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/commands/demo_cmd.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/commands/help_cmd.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/commands/knife_cmd.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/commands/match_cmd.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/commands/ready_cmd.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/commands/timeout_cmd.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/commands/vote_cmd.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/config.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/enums.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/bomb.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/game_end.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/player_chat.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/player_connected.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/player_damage.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/player_disconnected.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/player_kills.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/player_spawn.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/player_utility.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/round_end.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/events/round_start.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/game_demos.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/game_server.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/globals.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/http_client.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/json_helpers.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/knife_system.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/match_data.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/match_events.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/match_manager.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/match_service.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/match_utility.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/player_auth.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/ready_system.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/team_utility.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/timeout_system.inc create mode 100644 csgo/addons/sourcemod/scripting/fivestack/vote_system.inc create mode 100644 csgo/addons/sourcemod/translations/fivestack.phrases.txt diff --git a/csgo/addons/sourcemod/configs/fivestack/fivestack.cfg b/csgo/addons/sourcemod/configs/fivestack/fivestack.cfg new file mode 100644 index 0000000..c5e37a3 --- /dev/null +++ b/csgo/addons/sourcemod/configs/fivestack/fivestack.cfg @@ -0,0 +1,16 @@ +// 5Stack CS:GO Plugin Configuration +// This file is auto-executed by the plugin on load. +// +// Set these values to match your 5Stack deployment: + +// Required: Your 5Stack server ID (UUID) +fivestack_server_id "" + +// Required: Your server API password +fivestack_server_api_password "" + +// API domain (default: https://api.5stack.gg) +fivestack_api_domain "https://api.5stack.gg" + +// Demos domain (default: https://demos.5stack.gg) +fivestack_demos_domain "https://demos.5stack.gg" diff --git a/csgo/addons/sourcemod/scripting/fivestack.sp b/csgo/addons/sourcemod/scripting/fivestack.sp new file mode 100644 index 0000000..29b5971 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack.sp @@ -0,0 +1,299 @@ +/** + * ============================================================================= + * 5Stack CS:GO SourceMod Plugin + * ============================================================================= + * + * 5Stack creates and manages custom competitive matches. + * This is the CS:GO (Source 1) equivalent of the CS2 CounterStrikeSharp plugin. + * + * Requires: sm-ripext (JSON + HTTP) + * + * Author: 5Stack.gg + * URL: https://5stack.gg + * ============================================================================= + */ + +#pragma semicolon 1 +#pragma newdecls required + +#include +#include +#include +#include + +// Core includes (order matters — dependencies must come first) +#include "fivestack/enums.inc" +#include "fivestack/match_data.inc" +#include "fivestack/config.inc" +#include "fivestack/json_helpers.inc" +#include "fivestack/globals.inc" +#include "fivestack/http_client.inc" +#include "fivestack/game_server.inc" +#include "fivestack/team_utility.inc" +#include "fivestack/match_utility.inc" + +// Systems +#include "fivestack/match_events.inc" +#include "fivestack/game_demos.inc" +#include "fivestack/vote_system.inc" +#include "fivestack/captain_system.inc" +#include "fivestack/ready_system.inc" +#include "fivestack/knife_system.inc" +#include "fivestack/timeout_system.inc" +#include "fivestack/match_manager.inc" +#include "fivestack/match_service.inc" +#include "fivestack/player_auth.inc" + +// Event handlers +#include "fivestack/events/player_kills.inc" +#include "fivestack/events/player_damage.inc" +#include "fivestack/events/player_utility.inc" +#include "fivestack/events/player_connected.inc" +#include "fivestack/events/player_disconnected.inc" +#include "fivestack/events/player_chat.inc" +#include "fivestack/events/player_spawn.inc" +#include "fivestack/events/round_start.inc" +#include "fivestack/events/round_end.inc" +#include "fivestack/events/bomb.inc" +#include "fivestack/events/game_end.inc" + +// Commands +#include "fivestack/commands/ready_cmd.inc" +#include "fivestack/commands/knife_cmd.inc" +#include "fivestack/commands/captain_cmd.inc" +#include "fivestack/commands/timeout_cmd.inc" +#include "fivestack/commands/vote_cmd.inc" +#include "fivestack/commands/match_cmd.inc" +#include "fivestack/commands/demo_cmd.inc" +#include "fivestack/commands/help_cmd.inc" + +public Plugin myinfo = +{ + name = "5Stack", + author = "5Stack.gg", + description = "5Stack creates and manages custom matches", + version = FIVESTACK_VERSION, + url = "https://5stack.gg" +}; + +public void OnPluginStart() +{ + LogMessage("[5Stack] Plugin v%s loading...", FIVESTACK_VERSION); + + // Initialize config ConVars + Config_Init(); + + // Initialize global state + Globals_Init(); + + // Register chat commands (. prefix via say hook) + RegConsoleCmd("sm_ready", Command_Ready, "Toggle ready status"); + RegConsoleCmd("sm_r", Command_Ready, "Toggle ready status"); + RegConsoleCmd("sm_unready", Command_Ready, "Toggle ready status"); + RegConsoleCmd("sm_ur", Command_Ready, "Toggle ready status"); + + RegConsoleCmd("sm_stay", Command_Stay, "Stay on current side after knife"); + RegConsoleCmd("sm_switch", Command_Switch, "Switch sides after knife"); + RegConsoleCmd("sm_swap", Command_Switch, "Switch sides after knife"); + RegConsoleCmd("sm_t", Command_PickT, "Pick T side after knife"); + RegConsoleCmd("sm_ct", Command_PickCT, "Pick CT side after knife"); + + RegConsoleCmd("sm_captain", Command_Captain, "Claim captain"); + RegConsoleCmd("sm_captains", Command_Captains, "Show captains"); + RegConsoleCmd("sm_release-captain", Command_ReleaseCaptain, "Release captain"); + + RegConsoleCmd("sm_pause", Command_Pause, "Request technical pause"); + RegConsoleCmd("sm_tech", Command_Pause, "Request technical pause"); + RegConsoleCmd("sm_p", Command_Pause, "Request technical pause"); + RegConsoleCmd("sm_resume", Command_Resume, "Request resume"); + RegConsoleCmd("sm_unpause", Command_Resume, "Request resume"); + RegConsoleCmd("sm_up", Command_Resume, "Request resume"); + RegConsoleCmd("sm_timeout", Command_TacTimeout, "Call tactical timeout"); + RegConsoleCmd("sm_tac", Command_TacTimeout, "Call tactical timeout"); + + RegConsoleCmd("sm_y", Command_VoteYes, "Vote yes"); + RegConsoleCmd("sm_n", Command_VoteNo, "Vote no"); + + RegConsoleCmd("sm_help", Command_Help, "Show available commands"); + + // Server-only commands + RegServerCmd("get_match", Command_GetMatch, "Fetch match data from API"); + RegServerCmd("match_state", Command_MatchState, "Show current match state"); + RegServerCmd("force_ready", Command_ForceReady, "Force start match"); + RegServerCmd("skip_knife", Command_SkipKnife, "Skip knife round"); + RegServerCmd("upload_demos", Command_UploadDemos, "Upload demos"); + RegServerCmd("test_start_demo", Command_TestStartDemo, "Test start demo recording"); + RegServerCmd("test_stop_demo", Command_TestStopDemo, "Test stop demo recording"); + RegServerCmd("sm_fivestack_allow", Command_FiveStackAllow, "Pre-authorize a player"); + + // Hook game events + HookEvent("player_death", Event_PlayerDeath); + HookEvent("player_hurt", Event_PlayerHurt); + HookEvent("player_spawn", Event_PlayerSpawn); + HookEvent("player_connect_full", Event_PlayerConnectFull); + HookEvent("player_disconnect", Event_PlayerDisconnect); + HookEvent("round_start", Event_RoundStart); + HookEvent("round_end", Event_RoundEnd); + HookEvent("round_officially_ended", Event_RoundOfficiallyEnded); + HookEvent("bomb_planted", Event_BombPlanted); + HookEvent("bomb_defused", Event_BombDefused); + HookEvent("bomb_exploded", Event_BombExploded); + HookEvent("cs_win_panel_match", Event_CSWinPanelMatch); + + // Grenade events + HookEvent("decoy_detonate", Event_DecoyDetonate); + HookEvent("hegrenade_detonate", Event_HEGrenadeDetonate); + HookEvent("flashbang_detonate", Event_FlashbangDetonate); + HookEvent("molotov_detonate", Event_MolotovDetonate); + HookEvent("smokegrenade_detonate", Event_SmokeDetonate); + HookEvent("player_blind", Event_PlayerBlind); + + // Hook say commands for chat events and dot-prefix commands + AddCommandListener(Listener_Say, "say"); + AddCommandListener(Listener_Say, "say_team"); + + // Precache models + PrecacheModel(MODEL_CT_SAS, true); + PrecacheModel(MODEL_T_PHOENIX, true); + + LogMessage("[5Stack] Plugin loaded successfully"); +} + +public void OnConfigsExecuted() +{ + // Load config values after exec + Config_Load(); + + if (!Config_IsValid()) + { + LogError("[5Stack] Config invalid: SERVER_ID or API_PASSWORD not set. Waiting for config..."); + return; + } + + LogMessage("[5Stack] Config loaded — Server ID: %s", g_szServerId); + + // Start ping timer + Ping_Start(); + + // Start event retry timer + MatchEvents_Init(); + + // Fetch match data + MatchService_FetchMatch(); +} + +public void OnMapStart() +{ + PrecacheModel(MODEL_CT_SAS, true); + PrecacheModel(MODEL_T_PHOENIX, true); +} + +public void OnMapEnd() +{ + // Reset match state on map change + MatchManager_Reset(); +} + +// Handle say commands with . prefix +public Action Listener_Say(int client, const char[] command, int argc) +{ + if (!IsValidClient(client)) + return Plugin_Continue; + + char text[256]; + GetCmdArgString(text, sizeof(text)); + + // Remove surrounding quotes + StripQuotes(text); + TrimString(text); + + if (text[0] == '\0') + return Plugin_Continue; + + // Handle dot-prefix commands + if (text[0] == '.') + { + char cmdText[64]; + strcopy(cmdText, sizeof(cmdText), text[1]); // skip the dot + + // Trim and lowercase + TrimString(cmdText); + + if (StrEqual(cmdText, "ready", false) || StrEqual(cmdText, "r", false) || + StrEqual(cmdText, "unready", false) || StrEqual(cmdText, "ur", false)) + { + Ready_Toggle(client); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "stay", false)) + { + Knife_Stay(client); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "switch", false) || StrEqual(cmdText, "swap", false)) + { + Knife_Switch(client); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "t", false)) + { + Knife_PickSide(client, CS_TEAM_T); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "ct", false)) + { + Knife_PickSide(client, CS_TEAM_CT); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "captain", false)) + { + Captain_Claim(client); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "captains", false)) + { + Captain_ShowCaptains(); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "release-captain", false)) + { + Captain_Release(client); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "pause", false) || StrEqual(cmdText, "tech", false) || + StrEqual(cmdText, "p", false)) + { + Timeout_RequestPause(client); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "resume", false) || StrEqual(cmdText, "unpause", false) || + StrEqual(cmdText, "up", false)) + { + Timeout_RequestResume(client); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "timeout", false) || StrEqual(cmdText, "tac", false)) + { + Timeout_CallTactical(client); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "y", false) || StrEqual(cmdText, "yes", false)) + { + Vote_Cast(client, true); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "n", false) || StrEqual(cmdText, "no", false)) + { + Vote_Cast(client, false); + return Plugin_Handled; + } + else if (StrEqual(cmdText, "help", false) || StrEqual(cmdText, "rules", false)) + { + Command_Help(client, 0); + return Plugin_Handled; + } + } + + // Process chat event (gag check + publish) + return HandleClientChat(client, command, text); +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/captain_system.inc b/csgo/addons/sourcemod/scripting/fivestack/captain_system.inc new file mode 100644 index 0000000..93d1de7 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/captain_system.inc @@ -0,0 +1,166 @@ +/** + * 5Stack CS:GO Plugin - Captain System + * Captain claim, auto-select, release. + */ + +#if defined _fivestack_captain_system_included + #endinput +#endif +#define _fivestack_captain_system_included + +stock void Captain_Reset() +{ + g_iCaptainCT = -1; + g_iCaptainT = -1; +} + +stock void Captain_AutoSelect() +{ + if (!g_bMatchLoaded) return; + + // Try to find marked captains from lineup data + for (int client = 1; client <= MaxClients; client++) + { + if (!IsValidClient(client)) continue; + + int team = GetClientTeam(client); + if (team != CS_TEAM_CT && team != CS_TEAM_T) continue; + + MatchMember member; + if (GetClientMember(client, member) && member.captain) + { + if (team == CS_TEAM_CT && g_iCaptainCT < 0) + { + g_iCaptainCT = client; + LogMessage("[5Stack] Auto-selected CT captain: %N", client); + } + else if (team == CS_TEAM_T && g_iCaptainT < 0) + { + g_iCaptainT = client; + LogMessage("[5Stack] Auto-selected T captain: %N", client); + } + } + } + + // If no captain was found for a team, pick the first player + if (g_iCaptainCT < 0) + { + for (int client = 1; client <= MaxClients; client++) + { + if (IsValidClient(client) && GetClientTeam(client) == CS_TEAM_CT) + { + g_iCaptainCT = client; + LogMessage("[5Stack] Auto-assigned CT captain: %N", client); + break; + } + } + } + + if (g_iCaptainT < 0) + { + for (int client = 1; client <= MaxClients; client++) + { + if (IsValidClient(client) && GetClientTeam(client) == CS_TEAM_T) + { + g_iCaptainT = client; + LogMessage("[5Stack] Auto-assigned T captain: %N", client); + break; + } + } + } +} + +stock void Captain_Claim(int client) +{ + if (!IsValidClient(client)) return; + + int team = GetClientTeam(client); + if (team != CS_TEAM_CT && team != CS_TEAM_T) + { + MessageClient(client, "You must be on a team to claim captain."); + return; + } + + if (team == CS_TEAM_CT) + { + g_iCaptainCT = client; + } + else + { + g_iCaptainT = client; + } + + MessageAll("%N is now captain of %s.", client, team == CS_TEAM_CT ? "CT" : "T"); + + // Publish captain event + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + char data[256]; + FormatEx(data, sizeof(data), + "{\"steam_id\":\"%s\",\"claim\":true}", steamId); + PublishGameEvent("captain", data); +} + +stock void Captain_Release(int client) +{ + if (!IsValidClient(client)) return; + + int team = GetClientTeam(client); + bool wasCaptain = false; + + if (team == CS_TEAM_CT && g_iCaptainCT == client) + { + g_iCaptainCT = -1; + wasCaptain = true; + } + else if (team == CS_TEAM_T && g_iCaptainT == client) + { + g_iCaptainT = -1; + wasCaptain = true; + } + + if (wasCaptain) + { + MessageAll("%N has released captain.", client); + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + char data[256]; + FormatEx(data, sizeof(data), + "{\"steam_id\":\"%s\",\"claim\":false}", steamId); + PublishGameEvent("captain", data); + } + else + { + MessageClient(client, "You are not a captain."); + } +} + +stock void Captain_ShowCaptains() +{ + char ctName[MAX_NAME_LENGTH] = "None"; + char tName[MAX_NAME_LENGTH] = "None"; + + if (g_iCaptainCT > 0 && IsValidClient(g_iCaptainCT)) + GetClientName(g_iCaptainCT, ctName, sizeof(ctName)); + + if (g_iCaptainT > 0 && IsValidClient(g_iCaptainT)) + GetClientName(g_iCaptainT, tName, sizeof(tName)); + + MessageAll("CT Captain: %s | T Captain: %s", ctName, tName); +} + +stock bool Captain_IsCaptain(int client, int team) +{ + if (team == CS_TEAM_CT) return (client == g_iCaptainCT); + if (team == CS_TEAM_T) return (client == g_iCaptainT); + return false; +} + +stock void Captain_RemoveDisconnected(int client) +{ + if (g_iCaptainCT == client) g_iCaptainCT = -1; + if (g_iCaptainT == client) g_iCaptainT = -1; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/commands/captain_cmd.inc b/csgo/addons/sourcemod/scripting/fivestack/commands/captain_cmd.inc new file mode 100644 index 0000000..25e78b2 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/commands/captain_cmd.inc @@ -0,0 +1,29 @@ +/** + * 5Stack CS:GO Plugin - Captain Commands + * .captain, .captains, .release-captain + */ + +#if defined _fivestack_cmd_captain_included + #endinput +#endif +#define _fivestack_cmd_captain_included + +public Action Command_Captain(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Captain_Claim(client); + return Plugin_Handled; +} + +public Action Command_Captains(int client, int args) +{ + Captain_ShowCaptains(); + return Plugin_Handled; +} + +public Action Command_ReleaseCaptain(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Captain_Release(client); + return Plugin_Handled; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/commands/demo_cmd.inc b/csgo/addons/sourcemod/scripting/fivestack/commands/demo_cmd.inc new file mode 100644 index 0000000..afd745b --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/commands/demo_cmd.inc @@ -0,0 +1,30 @@ +/** + * 5Stack CS:GO Plugin - Demo Commands + * upload_demos, test_start_demo, test_stop_demo (server commands) + */ + +#if defined _fivestack_cmd_demo_included + #endinput +#endif +#define _fivestack_cmd_demo_included + +public Action Command_UploadDemos(int args) +{ + LogMessage("[5Stack] Manual demo upload triggered"); + Demo_Upload(); + return Plugin_Handled; +} + +public Action Command_TestStartDemo(int args) +{ + LogMessage("[5Stack] Test demo recording started"); + Demo_Start(); + return Plugin_Handled; +} + +public Action Command_TestStopDemo(int args) +{ + LogMessage("[5Stack] Test demo recording stopped"); + Demo_Stop(); + return Plugin_Handled; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/commands/help_cmd.inc b/csgo/addons/sourcemod/scripting/fivestack/commands/help_cmd.inc new file mode 100644 index 0000000..c6feb98 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/commands/help_cmd.inc @@ -0,0 +1,41 @@ +/** + * 5Stack CS:GO Plugin - Help Command + * .help, .rules + */ + +#if defined _fivestack_cmd_help_included + #endinput +#endif +#define _fivestack_cmd_help_included + +public Action Command_Help(int client, int args) +{ + if (client == 0) + { + PrintToServer("[5Stack] Available commands:"); + PrintToServer(" .ready / .r - Toggle ready status"); + PrintToServer(" .stay / .switch - Knife round side selection"); + PrintToServer(" .t / .ct - Pick side after knife"); + PrintToServer(" .captain - Claim captain"); + PrintToServer(" .pause / .tech - Request tech pause"); + PrintToServer(" .resume - Request resume"); + PrintToServer(" .timeout / .tac - Call tactical timeout"); + PrintToServer(" .y / .n - Vote yes/no"); + PrintToServer(" .help - Show this help"); + return Plugin_Handled; + } + + MessageClient(client, "--- 5Stack Commands ---"); + MessageClient(client, ".ready (.r) - Toggle ready status"); + MessageClient(client, ".stay / .switch - Knife round side selection"); + MessageClient(client, ".t / .ct - Pick side after knife"); + MessageClient(client, ".captain - Claim captain for your team"); + MessageClient(client, ".captains - Show current captains"); + MessageClient(client, ".pause (.tech) - Request technical pause"); + MessageClient(client, ".resume - Request match resume"); + MessageClient(client, ".timeout (.tac) - Call tactical timeout"); + MessageClient(client, ".y / .n - Vote yes/no"); + MessageClient(client, ".help - Show this help"); + + return Plugin_Handled; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/commands/knife_cmd.inc b/csgo/addons/sourcemod/scripting/fivestack/commands/knife_cmd.inc new file mode 100644 index 0000000..670f543 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/commands/knife_cmd.inc @@ -0,0 +1,43 @@ +/** + * 5Stack CS:GO Plugin - Knife Commands + * .stay, .switch, .t, .ct, skip_knife + */ + +#if defined _fivestack_cmd_knife_included + #endinput +#endif +#define _fivestack_cmd_knife_included + +public Action Command_Stay(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Knife_Stay(client); + return Plugin_Handled; +} + +public Action Command_Switch(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Knife_Switch(client); + return Plugin_Handled; +} + +public Action Command_PickT(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Knife_PickSide(client, CS_TEAM_T); + return Plugin_Handled; +} + +public Action Command_PickCT(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Knife_PickSide(client, CS_TEAM_CT); + return Plugin_Handled; +} + +public Action Command_SkipKnife(int args) +{ + Knife_Skip(); + return Plugin_Handled; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/commands/match_cmd.inc b/csgo/addons/sourcemod/scripting/fivestack/commands/match_cmd.inc new file mode 100644 index 0000000..95826ee --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/commands/match_cmd.inc @@ -0,0 +1,37 @@ +/** + * 5Stack CS:GO Plugin - Match Commands + * get_match, match_state (server commands) + */ + +#if defined _fivestack_cmd_match_included + #endinput +#endif +#define _fivestack_cmd_match_included + +public Action Command_GetMatch(int args) +{ + LogMessage("[5Stack] Fetching match data from API..."); + MatchService_FetchMatch(); + return Plugin_Handled; +} + +public Action Command_MatchState(int args) +{ + char statusStr[MAX_STATUS_LENGTH]; + MapStatusToString(g_CurrentMapStatus, statusStr, sizeof(statusStr)); + + PrintToServer("[5Stack] Match loaded: %s", g_bMatchLoaded ? "yes" : "no"); + + if (g_bMatchLoaded) + { + PrintToServer("[5Stack] Match ID: %s", g_MatchData.id); + PrintToServer("[5Stack] Map status: %s", statusStr); + PrintToServer("[5Stack] Map index: %d", g_iCurrentMapIndex); + PrintToServer("[5Stack] Lineup 1: %s", g_MatchData.lineup_1.name); + PrintToServer("[5Stack] Lineup 2: %s", g_MatchData.lineup_2.name); + PrintToServer("[5Stack] Type: %s, MR: %d, BO: %d", + g_MatchData.options.type, g_MatchData.options.mr, g_MatchData.options.best_of); + } + + return Plugin_Handled; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/commands/ready_cmd.inc b/csgo/addons/sourcemod/scripting/fivestack/commands/ready_cmd.inc new file mode 100644 index 0000000..d45e343 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/commands/ready_cmd.inc @@ -0,0 +1,22 @@ +/** + * 5Stack CS:GO Plugin - Ready Commands + * .ready, .unready, force_ready + */ + +#if defined _fivestack_cmd_ready_included + #endinput +#endif +#define _fivestack_cmd_ready_included + +public Action Command_Ready(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Ready_Toggle(client); + return Plugin_Handled; +} + +public Action Command_ForceReady(int args) +{ + Ready_Skip(); + return Plugin_Handled; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/commands/timeout_cmd.inc b/csgo/addons/sourcemod/scripting/fivestack/commands/timeout_cmd.inc new file mode 100644 index 0000000..bb89fde --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/commands/timeout_cmd.inc @@ -0,0 +1,30 @@ +/** + * 5Stack CS:GO Plugin - Timeout Commands + * .pause, .resume, .timeout + */ + +#if defined _fivestack_cmd_timeout_included + #endinput +#endif +#define _fivestack_cmd_timeout_included + +public Action Command_Pause(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Timeout_RequestPause(client); + return Plugin_Handled; +} + +public Action Command_Resume(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Timeout_RequestResume(client); + return Plugin_Handled; +} + +public Action Command_TacTimeout(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Timeout_CallTactical(client); + return Plugin_Handled; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/commands/vote_cmd.inc b/csgo/addons/sourcemod/scripting/fivestack/commands/vote_cmd.inc new file mode 100644 index 0000000..5fe1c8b --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/commands/vote_cmd.inc @@ -0,0 +1,23 @@ +/** + * 5Stack CS:GO Plugin - Vote Commands + * .y, .n + */ + +#if defined _fivestack_cmd_vote_included + #endinput +#endif +#define _fivestack_cmd_vote_included + +public Action Command_VoteYes(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Vote_Cast(client, true); + return Plugin_Handled; +} + +public Action Command_VoteNo(int client, int args) +{ + if (client == 0) return Plugin_Handled; + Vote_Cast(client, false); + return Plugin_Handled; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/config.inc b/csgo/addons/sourcemod/scripting/fivestack/config.inc new file mode 100644 index 0000000..72d2b17 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/config.inc @@ -0,0 +1,54 @@ +/** + * 5Stack CS:GO Plugin - Configuration + * Loads SERVER_ID, API_PASSWORD, domain URLs from cfg file. + */ + +#if defined _fivestack_config_included + #endinput +#endif +#define _fivestack_config_included + +#define MAX_URL_LENGTH 256 + +// Config globals +char g_szServerId[GUID_LENGTH]; +char g_szServerApiPassword[MAX_PASSWORD_LENGTH]; +char g_szApiDomain[MAX_URL_LENGTH]; +char g_szDemosDomain[MAX_URL_LENGTH]; + +// ConVars +ConVar g_cvServerId; +ConVar g_cvServerApiPassword; +ConVar g_cvApiDomain; +ConVar g_cvDemosDomain; + +stock void Config_Init() +{ + g_cvServerId = CreateConVar("fivestack_server_id", "", "5Stack Server ID", FCVAR_PROTECTED); + g_cvServerApiPassword = CreateConVar("fivestack_server_api_password", "", "5Stack Server API Password", FCVAR_PROTECTED); + g_cvApiDomain = CreateConVar("fivestack_api_domain", "https://api.5stack.gg", "5Stack API Domain"); + g_cvDemosDomain = CreateConVar("fivestack_demos_domain", "https://demos.5stack.gg", "5Stack Demos Domain"); + + AutoExecConfig(true, "fivestack", "fivestack"); +} + +stock void Config_Load() +{ + g_cvServerId.GetString(g_szServerId, sizeof(g_szServerId)); + g_cvServerApiPassword.GetString(g_szServerApiPassword, sizeof(g_szServerApiPassword)); + g_cvApiDomain.GetString(g_szApiDomain, sizeof(g_szApiDomain)); + g_cvDemosDomain.GetString(g_szDemosDomain, sizeof(g_szDemosDomain)); + + // Strip trailing slashes + int len; + len = strlen(g_szApiDomain); + if (len > 0 && g_szApiDomain[len - 1] == '/') g_szApiDomain[len - 1] = '\0'; + + len = strlen(g_szDemosDomain); + if (len > 0 && g_szDemosDomain[len - 1] == '/') g_szDemosDomain[len - 1] = '\0'; +} + +stock bool Config_IsValid() +{ + return (g_szServerId[0] != '\0' && g_szServerApiPassword[0] != '\0'); +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/enums.inc b/csgo/addons/sourcemod/scripting/fivestack/enums.inc new file mode 100644 index 0000000..ac208d4 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/enums.inc @@ -0,0 +1,140 @@ +/** + * 5Stack CS:GO Plugin - Enums + * Mirrors FiveStack.Enums from the CS2 plugin. + */ + +#if defined _fivestack_enums_included + #endinput +#endif +#define _fivestack_enums_included + +enum eMapStatus +{ + MapStatus_Knife = 0, + MapStatus_Live, + MapStatus_Warmup, + MapStatus_Paused, + MapStatus_Scheduled, + MapStatus_Overtime, + MapStatus_UploadingDemo, + MapStatus_Finished, + MapStatus_Surrendered, + MapStatus_Unknown +}; + +enum eReadySettings +{ + ReadySetting_Captains = 0, + ReadySetting_Coach, + ReadySetting_Admin, + ReadySetting_Players +}; + +enum eTimeoutSettings +{ + TimeoutSetting_Coach = 0, + TimeoutSetting_CoachAndCaptains, + TimeoutSetting_CoachAndPlayers, + TimeoutSetting_Admin +}; + +enum ePlayerRoles +{ + PlayerRole_User = 0, + PlayerRole_VerifiedUser, + PlayerRole_Streamer, + PlayerRole_MatchOrganizer, + PlayerRole_TournamentOrganizer, + PlayerRole_Administrator +}; + +enum eWinReason +{ + WinReason_TerroristsWin = 0, + WinReason_CTsWin, + WinReason_BombExploded, + WinReason_TimeRanOut, + WinReason_BombDefused, + WinReason_Unknown +}; + +stock eMapStatus MapStatusStringToEnum(const char[] state) +{ + if (StrEqual(state, "Scheduled")) return MapStatus_Scheduled; + if (StrEqual(state, "Finished")) return MapStatus_Finished; + if (StrEqual(state, "Knife")) return MapStatus_Knife; + if (StrEqual(state, "Live")) return MapStatus_Live; + if (StrEqual(state, "Overtime")) return MapStatus_Overtime; + if (StrEqual(state, "Paused")) return MapStatus_Paused; + if (StrEqual(state, "Warmup")) return MapStatus_Warmup; + if (StrEqual(state, "UploadingDemo")) return MapStatus_UploadingDemo; + if (StrEqual(state, "Surrendered")) return MapStatus_Surrendered; + return MapStatus_Unknown; +} + +stock void MapStatusToString(eMapStatus status, char[] buffer, int maxlen) +{ + switch (status) + { + case MapStatus_Knife: strcopy(buffer, maxlen, "Knife"); + case MapStatus_Live: strcopy(buffer, maxlen, "Live"); + case MapStatus_Warmup: strcopy(buffer, maxlen, "Warmup"); + case MapStatus_Paused: strcopy(buffer, maxlen, "Paused"); + case MapStatus_Scheduled: strcopy(buffer, maxlen, "Scheduled"); + case MapStatus_Overtime: strcopy(buffer, maxlen, "Overtime"); + case MapStatus_UploadingDemo: strcopy(buffer, maxlen, "UploadingDemo"); + case MapStatus_Finished: strcopy(buffer, maxlen, "Finished"); + case MapStatus_Surrendered: strcopy(buffer, maxlen, "Surrendered"); + default: strcopy(buffer, maxlen, "Unknown"); + } +} + +stock eReadySettings ReadySettingStringToEnum(const char[] setting) +{ + if (StrEqual(setting, "Captains")) return ReadySetting_Captains; + if (StrEqual(setting, "Coach")) return ReadySetting_Coach; + if (StrEqual(setting, "Admin")) return ReadySetting_Admin; + return ReadySetting_Players; +} + +stock eTimeoutSettings TimeoutSettingStringToEnum(const char[] setting) +{ + if (StrEqual(setting, "Coach")) return TimeoutSetting_Coach; + if (StrEqual(setting, "CoachAndCaptains")) return TimeoutSetting_CoachAndCaptains; + if (StrEqual(setting, "CoachAndPlayers")) return TimeoutSetting_CoachAndPlayers; + if (StrEqual(setting, "Admin")) return TimeoutSetting_Admin; + return TimeoutSetting_CoachAndPlayers; +} + +stock ePlayerRoles PlayerRoleStringToEnum(const char[] role) +{ + if (StrEqual(role, "administrator")) return PlayerRole_Administrator; + if (StrEqual(role, "match_organizer")) return PlayerRole_MatchOrganizer; + if (StrEqual(role, "tournament_organizer")) return PlayerRole_TournamentOrganizer; + if (StrEqual(role, "streamer")) return PlayerRole_Streamer; + if (StrEqual(role, "verified_user")) return PlayerRole_VerifiedUser; + return PlayerRole_User; +} + +stock eWinReason WinReasonFromMessage(const char[] message) +{ + if (StrEqual(message, "#SFUI_Notice_Terrorists_Win")) return WinReason_TerroristsWin; + if (StrEqual(message, "#SFUI_Notice_CTs_Win")) return WinReason_CTsWin; + if (StrEqual(message, "#SFUI_Notice_Target_Bombed")) return WinReason_BombExploded; + if (StrEqual(message, "#SFUI_Notice_Target_Saved")) return WinReason_TimeRanOut; + if (StrEqual(message, "#SFUI_Notice_Bomb_Defused")) return WinReason_BombDefused; + return WinReason_Unknown; +} + +stock void WinReasonToString(eWinReason reason, char[] buffer, int maxlen) +{ + switch (reason) + { + case WinReason_TerroristsWin: strcopy(buffer, maxlen, "TerroristsWin"); + case WinReason_CTsWin: strcopy(buffer, maxlen, "CTsWin"); + case WinReason_BombExploded: strcopy(buffer, maxlen, "BombExploded"); + case WinReason_TimeRanOut: strcopy(buffer, maxlen, "TimeRanOut"); + case WinReason_BombDefused: strcopy(buffer, maxlen, "BombDefused"); + default: strcopy(buffer, maxlen, "Unknown"); + } +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/bomb.inc b/csgo/addons/sourcemod/scripting/fivestack/events/bomb.inc new file mode 100644 index 0000000..28b12c0 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/bomb.inc @@ -0,0 +1,66 @@ +/** + * 5Stack CS:GO Plugin - Bomb Events + * bomb_planted/defused/exploded -> "objective" + */ + +#if defined _fivestack_event_bomb_included + #endinput +#endif +#define _fivestack_event_bomb_included + +public Action Event_BombPlanted(Event event, const char[] name, bool dontBroadcast) +{ + if (!IsLive()) return Plugin_Continue; + + int client = GetClientOfUserId(event.GetInt("userid")); + if (!IsValidClient(client)) return Plugin_Continue; + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + int site = event.GetInt("site"); + + char data[256]; + FormatEx(data, sizeof(data), + "{\"type\":\"Planted\",\"player_steam_id\":\"%s\",\"site\":%d,\"round\":%d}", + steamId, site, GetCurrentRound()); + + PublishGameEvent("objective", data); + return Plugin_Continue; +} + +public Action Event_BombDefused(Event event, const char[] name, bool dontBroadcast) +{ + if (!IsLive()) return Plugin_Continue; + + int client = GetClientOfUserId(event.GetInt("userid")); + if (!IsValidClient(client)) return Plugin_Continue; + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + int site = event.GetInt("site"); + + char data[256]; + FormatEx(data, sizeof(data), + "{\"type\":\"Defused\",\"player_steam_id\":\"%s\",\"site\":%d,\"round\":%d}", + steamId, site, GetCurrentRound()); + + PublishGameEvent("objective", data); + return Plugin_Continue; +} + +public Action Event_BombExploded(Event event, const char[] name, bool dontBroadcast) +{ + if (!IsLive()) return Plugin_Continue; + + int site = event.GetInt("site"); + + char data[256]; + FormatEx(data, sizeof(data), + "{\"type\":\"Exploded\",\"player_steam_id\":\"\",\"site\":%d,\"round\":%d}", + site, GetCurrentRound()); + + PublishGameEvent("objective", data); + return Plugin_Continue; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/game_end.inc b/csgo/addons/sourcemod/scripting/fivestack/events/game_end.inc new file mode 100644 index 0000000..23f883e --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/game_end.inc @@ -0,0 +1,67 @@ +/** + * 5Stack CS:GO Plugin - Game End Events + * cs_win_panel_match -> demo upload + map change + */ + +#if defined _fivestack_event_game_end_included + #endinput +#endif +#define _fivestack_event_game_end_included + +// Determine which lineup won based on scores +stock void GetWinningLineupId(char[] buffer, int maxlen) +{ + buffer[0] = '\0'; + + if (!g_bMatchLoaded || g_iCurrentMapIndex < 0) + return; + + int lineup1Score = GetLineupScore(g_MatchData.lineup_1_id); + int lineup2Score = GetLineupScore(g_MatchData.lineup_2_id); + + if (lineup1Score > lineup2Score) + strcopy(buffer, maxlen, g_MatchData.lineup_1_id); + else + strcopy(buffer, maxlen, g_MatchData.lineup_2_id); +} + +public Action Event_CSWinPanelMatch(Event event, const char[] name, bool dontBroadcast) +{ + if (!g_bMatchLoaded) return Plugin_Continue; + + // Publish final scores + PublishRoundInformation(); + + // Stop demo and transition status + char winningId[GUID_LENGTH]; + GetWinningLineupId(winningId, sizeof(winningId)); + MatchManager_UpdateMapStatus(MapStatus_UploadingDemo, winningId); + + // Upload demo after a delay (wait for tv_delay) + CreateTimer(15.0, Timer_UploadDemo); + + return Plugin_Continue; +} + +public Action Timer_UploadDemo(Handle timer) +{ + Demo_Upload(); + + // After upload, transition to finished and schedule map change + CreateTimer(5.0, Timer_FinishAndChangeMap); + return Plugin_Stop; +} + +public Action Timer_FinishAndChangeMap(Handle timer) +{ + if (!g_bMatchLoaded) return Plugin_Stop; + + char winningId[GUID_LENGTH]; + GetWinningLineupId(winningId, sizeof(winningId)); + MatchManager_UpdateMapStatus(MapStatus_Finished, winningId); + + // Delay map change by tv_delay + MatchManager_DelayChangeMap(g_MatchData.options.tv_delay); + + return Plugin_Stop; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_chat.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_chat.inc new file mode 100644 index 0000000..457fdab --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_chat.inc @@ -0,0 +1,67 @@ +/** + * 5Stack CS:GO Plugin - Player Chat Events + * say/say_team hooks -> "chat" event + gag enforcement + */ + +#if defined _fivestack_event_chat_included + #endinput +#endif +#define _fivestack_event_chat_included + +stock Action HandleClientChat(int client, const char[] command, const char[] args) +{ + if (!IsValidClient(client) || !g_bMatchLoaded) + return Plugin_Continue; + + // Check if gagged + MatchMember member; + if (GetClientMember(client, member) && member.is_gagged) + { + MessageClient(client, "You are gagged in this match."); + return Plugin_Stop; + } + + // Don't publish empty messages or command triggers + if (args[0] == '\0' || args[0] == '.' || args[0] == '!') + return Plugin_Continue; + + // Spectators can't talk to players + if (GetClientTeam(client) <= CS_TEAM_SPECTATOR) + { + // Still publish the event but block the chat + PublishChatEvent(client, args); + return Plugin_Stop; + } + + PublishChatEvent(client, args); + return Plugin_Continue; +} + +stock void PublishChatEvent(int client, const char[] message) +{ + if (!IsLive() && !IsWarmup() && !IsKnife()) + return; + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + // Escape quotes in message for JSON + char escapedMsg[512]; + int j = 0; + for (int i = 0; message[i] != '\0' && j < sizeof(escapedMsg) - 2; i++) + { + if (message[i] == '"' || message[i] == '\\') + { + escapedMsg[j++] = '\\'; + } + escapedMsg[j++] = message[i]; + } + escapedMsg[j] = '\0'; + + char data[768]; + FormatEx(data, sizeof(data), + "{\"player\":\"%s\",\"message\":\"%s\"}", + steamId, escapedMsg); + + PublishGameEvent("chat", data); +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_connected.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_connected.inc new file mode 100644 index 0000000..5821295 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_connected.inc @@ -0,0 +1,54 @@ +/** + * 5Stack CS:GO Plugin - Player Connected Events + * player_connect_full -> "player-connected", team join handling + */ + +#if defined _fivestack_event_connected_included + #endinput +#endif +#define _fivestack_event_connected_included + +public Action Event_PlayerConnectFull(Event event, const char[] name, bool dontBroadcast) +{ + int client = GetClientOfUserId(event.GetInt("userid")); + if (!IsValidClient(client)) return Plugin_Continue; + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + char playerName[MAX_NAME_LENGTH]; + GetClientName(client, playerName, sizeof(playerName)); + + // Publish player connected event + char data[256]; + FormatEx(data, sizeof(data), + "{\"player_name\":\"%s\",\"steam_id\":\"%s\"}", + playerName, steamId); + PublishGameEvent("player-connected", data); + + // Enforce team assignment + if (g_bMatchLoaded) + { + CreateTimer(0.5, Timer_EnforceMemberTeam, GetClientUserId(client)); + } + + // Show hints + if (g_bReadySystemActive) + { + MessageClient(client, "Type .ready in chat when you are ready to play."); + } + + MessageClient(client, "Type .help for available commands."); + + return Plugin_Continue; +} + +public Action Timer_EnforceMemberTeam(Handle timer, any userId) +{ + int client = GetClientOfUserId(userId); + if (IsValidClient(client)) + { + EnforceMemberTeam(client); + } + return Plugin_Stop; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_damage.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_damage.inc new file mode 100644 index 0000000..c4fbe44 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_damage.inc @@ -0,0 +1,57 @@ +/** + * 5Stack CS:GO Plugin - Player Damage Events + * player_hurt -> "damage" event + */ + +#if defined _fivestack_event_damage_included + #endinput +#endif +#define _fivestack_event_damage_included + +public Action Event_PlayerHurt(Event event, const char[] name, bool dontBroadcast) +{ + if (!IsLive()) return Plugin_Continue; + + int victim = GetClientOfUserId(event.GetInt("userid")); + int attacker = GetClientOfUserId(event.GetInt("attacker")); + + if (!IsValidClient(victim)) return Plugin_Continue; + + char victimSteamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(victim, AuthId_SteamID64, victimSteamId, sizeof(victimSteamId)); + + char attackerSteamId[MAX_STEAMID_LENGTH] = ""; + int attackerTeam = CS_TEAM_NONE; + if (IsValidClient(attacker)) + { + GetClientAuthId(attacker, AuthId_SteamID64, attackerSteamId, sizeof(attackerSteamId)); + attackerTeam = GetClientTeam(attacker); + } + + int damage = event.GetInt("dmg_health"); + int damageArmor = event.GetInt("dmg_armor"); + int hitgroup = event.GetInt("hitgroup"); + + char weapon[64]; + event.GetString("weapon", weapon, sizeof(weapon)); + + char hitgroupStr[32]; + HitGroupToString(hitgroup, hitgroupStr, sizeof(hitgroupStr)); + + char data[1024]; + FormatEx(data, sizeof(data), + "{\"attacker_steam_id\":\"%s\",\"attacker_team\":%d," + "\"attacked_steam_id\":\"%s\",\"attacked_team\":%d," + "\"damage\":%d,\"damage_armor\":%d," + "\"weapon\":\"%s\",\"hitgroup\":\"%s\"," + "\"round\":%d}", + attackerSteamId, attackerTeam, + victimSteamId, GetClientTeam(victim), + damage, damageArmor, + weapon, hitgroupStr, + GetCurrentRound()); + + PublishGameEvent("damage", data); + + return Plugin_Continue; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_disconnected.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_disconnected.inc new file mode 100644 index 0000000..075fa32 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_disconnected.inc @@ -0,0 +1,39 @@ +/** + * 5Stack CS:GO Plugin - Player Disconnected Events + * player_disconnect -> "player-disconnected" + */ + +#if defined _fivestack_event_disconnected_included + #endinput +#endif +#define _fivestack_event_disconnected_included + +public Action Event_PlayerDisconnect(Event event, const char[] name, bool dontBroadcast) +{ + int client = GetClientOfUserId(event.GetInt("userid")); + if (client <= 0 || client > MaxClients) return Plugin_Continue; + if (IsFakeClient(client)) return Plugin_Continue; + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + // Publish disconnect event + char data[128]; + FormatEx(data, sizeof(data), "{\"steam_id\":\"%s\"}", steamId); + PublishGameEvent("player-disconnected", data); + + // During warmup/knife: unready and remove captain + if (IsWarmup() || IsKnife()) + { + Ready_UnreadyPlayer(client); + Captain_RemoveDisconnected(client); + } + + // During live: pause if in freeze period + if (IsLive() && IsFreezePeriod()) + { + MatchManager_PauseMatch("Player disconnected — waiting for reconnect"); + } + + return Plugin_Continue; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_kills.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_kills.inc new file mode 100644 index 0000000..cdaae2f --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_kills.inc @@ -0,0 +1,100 @@ +/** + * 5Stack CS:GO Plugin - Player Kill Events + * player_death -> "kill" + "assist" events + */ + +#if defined _fivestack_event_kills_included + #endinput +#endif +#define _fivestack_event_kills_included + +public Action Event_PlayerDeath(Event event, const char[] name, bool dontBroadcast) +{ + if (!IsLive()) return Plugin_Continue; + + int victim = GetClientOfUserId(event.GetInt("userid")); + int attacker = GetClientOfUserId(event.GetInt("attacker")); + int assister = GetClientOfUserId(event.GetInt("assister")); + + if (!IsValidClient(victim)) return Plugin_Continue; + + // Get victim info + char victimSteamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(victim, AuthId_SteamID64, victimSteamId, sizeof(victimSteamId)); + + float victimPos[3]; + GetClientAbsOrigin(victim, victimPos); + + char victimPlace[64]; + GetEntPropString(victim, Prop_Send, "m_szLastPlaceName", victimPlace, sizeof(victimPlace)); + + // Get attacker info + char attackerSteamId[MAX_STEAMID_LENGTH] = ""; + float attackerPos[3] = {0.0, 0.0, 0.0}; + char attackerPlace[64] = ""; + int attackerTeam = CS_TEAM_NONE; + + if (IsValidClient(attacker)) + { + GetClientAuthId(attacker, AuthId_SteamID64, attackerSteamId, sizeof(attackerSteamId)); + GetClientAbsOrigin(attacker, attackerPos); + GetEntPropString(attacker, Prop_Send, "m_szLastPlaceName", attackerPlace, sizeof(attackerPlace)); + attackerTeam = GetClientTeam(attacker); + } + + // Get weapon and kill details + char weapon[64]; + event.GetString("weapon", weapon, sizeof(weapon)); + + bool headshot = event.GetBool("headshot"); + bool penetrated = event.GetInt("penetrated") > 0; + + int hitgroup = 1; // default head for kills + char hitgroupStr[32]; + HitGroupToString(hitgroup, hitgroupStr, sizeof(hitgroupStr)); + + // Build kill event + char data[2048]; + FormatEx(data, sizeof(data), + "{\"attacker_steam_id\":\"%s\",\"attacker_team\":%d," + "\"attacker_location_coordinates\":{\"x\":%.2f,\"y\":%.2f,\"z\":%.2f}," + "\"attacker_location\":\"%s\"," + "\"attacked_steam_id\":\"%s\",\"attacked_team\":%d," + "\"attacked_location_coordinates\":{\"x\":%.2f,\"y\":%.2f,\"z\":%.2f}," + "\"attacked_location\":\"%s\"," + "\"weapon\":\"%s\",\"hitgroup\":\"%s\"," + "\"headshot\":%s,\"penetrated\":%s," + "\"round\":%d}", + attackerSteamId, attackerTeam, + attackerPos[0], attackerPos[1], attackerPos[2], + attackerPlace, + victimSteamId, GetClientTeam(victim), + victimPos[0], victimPos[1], victimPos[2], + victimPlace, + weapon, hitgroupStr, + headshot ? "true" : "false", + penetrated ? "true" : "false", + GetCurrentRound()); + + PublishGameEvent("kill", data); + + // Handle assist + if (IsValidClient(assister)) + { + char assisterSteamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(assister, AuthId_SteamID64, assisterSteamId, sizeof(assisterSteamId)); + + char assistData[512]; + FormatEx(assistData, sizeof(assistData), + "{\"attacker_steam_id\":\"%s\",\"attacker_team\":%d," + "\"attacked_steam_id\":\"%s\",\"attacked_team\":%d," + "\"round\":%d}", + assisterSteamId, GetClientTeam(assister), + victimSteamId, GetClientTeam(victim), + GetCurrentRound()); + + PublishGameEvent("assist", assistData); + } + + return Plugin_Continue; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_spawn.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_spawn.inc new file mode 100644 index 0000000..34eecff --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_spawn.inc @@ -0,0 +1,35 @@ +/** + * 5Stack CS:GO Plugin - Player Spawn Events + * player_spawn -> default model application + */ + +#if defined _fivestack_event_spawn_included + #endinput +#endif +#define _fivestack_event_spawn_included + +// CS:GO Source 1 model paths (not .vmdl like CS2) +#define MODEL_CT_SAS "models/player/ctm_sas.mdl" +#define MODEL_T_PHOENIX "models/player/tm_phoenix.mdl" + +public Action Event_PlayerSpawn(Event event, const char[] name, bool dontBroadcast) +{ + if (!g_bMatchLoaded) return Plugin_Continue; + if (!g_MatchData.options.default_models) return Plugin_Continue; + + int client = GetClientOfUserId(event.GetInt("userid")); + if (!IsValidClient(client)) return Plugin_Continue; + + int team = GetClientTeam(client); + + if (team == CS_TEAM_CT) + { + SetEntityModel(client, MODEL_CT_SAS); + } + else if (team == CS_TEAM_T) + { + SetEntityModel(client, MODEL_T_PHOENIX); + } + + return Plugin_Continue; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_utility.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_utility.inc new file mode 100644 index 0000000..31e70d7 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_utility.inc @@ -0,0 +1,97 @@ +/** + * 5Stack CS:GO Plugin - Player Utility Events + * *_detonate -> "utility" events, player_blind -> "flash" event + */ + +#if defined _fivestack_event_utility_included + #endinput +#endif +#define _fivestack_event_utility_included + +public Action Event_DecoyDetonate(Event event, const char[] name, bool dontBroadcast) +{ + return PublishUtilityEvent(event, "Decoy"); +} + +public Action Event_HEGrenadeDetonate(Event event, const char[] name, bool dontBroadcast) +{ + return PublishUtilityEvent(event, "HighExplosive"); +} + +public Action Event_FlashbangDetonate(Event event, const char[] name, bool dontBroadcast) +{ + return PublishUtilityEvent(event, "Flash"); +} + +public Action Event_MolotovDetonate(Event event, const char[] name, bool dontBroadcast) +{ + return PublishUtilityEvent(event, "Molotov"); +} + +public Action Event_SmokeDetonate(Event event, const char[] name, bool dontBroadcast) +{ + return PublishUtilityEvent(event, "Smoke"); +} + +stock Action PublishUtilityEvent(Event event, const char[] utilityType) +{ + if (!IsLive()) return Plugin_Continue; + + int client = GetClientOfUserId(event.GetInt("userid")); + if (!IsValidClient(client)) return Plugin_Continue; + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + float x = event.GetFloat("x"); + float y = event.GetFloat("y"); + float z = event.GetFloat("z"); + + char data[512]; + FormatEx(data, sizeof(data), + "{\"attacker_steam_id\":\"%s\",\"attacker_team\":%d," + "\"type\":\"%s\"," + "\"attacker_location_coordinates\":{\"x\":%.2f,\"y\":%.2f,\"z\":%.2f}," + "\"round\":%d}", + steamId, GetClientTeam(client), + utilityType, + x, y, z, + GetCurrentRound()); + + PublishGameEvent("utility", data); + return Plugin_Continue; +} + +public Action Event_PlayerBlind(Event event, const char[] name, bool dontBroadcast) +{ + if (!IsLive()) return Plugin_Continue; + + int victim = GetClientOfUserId(event.GetInt("userid")); + int attacker = GetClientOfUserId(event.GetInt("attacker")); + + if (!IsValidClient(victim) || !IsValidClient(attacker)) + return Plugin_Continue; + + char victimSteamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(victim, AuthId_SteamID64, victimSteamId, sizeof(victimSteamId)); + + char attackerSteamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(attacker, AuthId_SteamID64, attackerSteamId, sizeof(attackerSteamId)); + + float duration = event.GetFloat("blind_duration"); + bool teamFlash = (GetClientTeam(victim) == GetClientTeam(attacker)); + + char data[512]; + FormatEx(data, sizeof(data), + "{\"attacker_steam_id\":\"%s\",\"attacker_team\":%d," + "\"attacked_steam_id\":\"%s\",\"attacked_team\":%d," + "\"duration\":%.2f,\"team_flash\":%s," + "\"round\":%d}", + attackerSteamId, GetClientTeam(attacker), + victimSteamId, GetClientTeam(victim), + duration, teamFlash ? "true" : "false", + GetCurrentRound()); + + PublishGameEvent("flash", data); + return Plugin_Continue; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/round_end.inc b/csgo/addons/sourcemod/scripting/fivestack/events/round_end.inc new file mode 100644 index 0000000..63ba60b --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/round_end.inc @@ -0,0 +1,68 @@ +/** + * 5Stack CS:GO Plugin - Round End Events + * round_end -> score + win reason + * round_officially_ended -> OT detection, timeout grants + */ + +#if defined _fivestack_event_round_end_included + #endinput +#endif +#define _fivestack_event_round_end_included + +public Action Event_RoundEnd(Event event, const char[] name, bool dontBroadcast) +{ + // During knife round: detect winner + if (IsKnife()) + { + int winner = event.GetInt("winner"); + if (winner == CS_TEAM_T || winner == CS_TEAM_CT) + { + Knife_SetWinner(winner); + } + return Plugin_Continue; + } + + if (!IsLive()) return Plugin_Continue; + + char message[128]; + event.GetString("message", message, sizeof(message)); + + eWinReason reason = WinReasonFromMessage(message); + PublishRoundInformation(reason); + + return Plugin_Continue; +} + +public Action Event_RoundOfficiallyEnded(Event event, const char[] name, bool dontBroadcast) +{ + if (!IsLive()) return Plugin_Continue; + + // Check for overtime transition + if (IsOvertime() && g_CurrentMapStatus != MapStatus_Overtime) + { + MatchManager_UpdateMapStatus(MapStatus_Overtime); + } + + // Check if new OT half started — reset tactical timeouts to 1 each + if (IsOvertime() && g_bMatchLoaded && g_iCurrentMapIndex >= 0) + { + int totalRounds = GetTotalRoundsPlayed(); + int mr = g_MatchData.options.mr; + + if (totalRounds > mr * 2) + { + ConVar cvOtMaxRounds = FindConVar("mp_overtime_maxrounds"); + int otMr = (cvOtMaxRounds != null) ? cvOtMaxRounds.IntValue : 6; + + int otRound = totalRounds - (mr * 2); + // At the start of each OT half, reset timeouts + if (otRound % (otMr / 2) == 0) + { + g_MatchData.match_maps[g_iCurrentMapIndex].lineup_1_timeouts_available = 1; + g_MatchData.match_maps[g_iCurrentMapIndex].lineup_2_timeouts_available = 1; + } + } + } + + return Plugin_Continue; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/round_start.inc b/csgo/addons/sourcemod/scripting/fivestack/events/round_start.inc new file mode 100644 index 0000000..bb3691a --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/events/round_start.inc @@ -0,0 +1,33 @@ +/** + * 5Stack CS:GO Plugin - Round Start Events + * round_start -> "score" + round info + */ + +#if defined _fivestack_event_round_start_included + #endinput +#endif +#define _fivestack_event_round_start_included + +public Action Event_RoundStart(Event event, const char[] name, bool dontBroadcast) +{ + if (!IsLive()) return Plugin_Continue; + + // Publish round information (score update) + PublishRoundInformation(); + + // Check player count — pause if players missing + int expectedCount = GetExpectedPlayerCount(); + int actualCount = 0; + for (int i = 1; i <= MaxClients; i++) + { + if (IsValidClient(i) && GetClientTeam(i) > CS_TEAM_SPECTATOR) + actualCount++; + } + + if (actualCount < expectedCount) + { + MatchManager_PauseMatch("Waiting for players to reconnect"); + } + + return Plugin_Continue; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/game_demos.inc b/csgo/addons/sourcemod/scripting/fivestack/game_demos.inc new file mode 100644 index 0000000..b1eaa76 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/game_demos.inc @@ -0,0 +1,156 @@ +/** + * 5Stack CS:GO Plugin - Game Demos + * tv_record, presigned URL upload, completion notify. + */ + +#if defined _fivestack_game_demos_included + #endinput +#endif +#define _fivestack_game_demos_included + +stock void Demo_Start() +{ + if (g_bRecordingDemo || !g_bMatchLoaded || g_iCurrentMapIndex < 0) + return; + + char mapName[MAX_MAP_NAME_LENGTH]; + GetCurrentMap(mapName, sizeof(mapName)); + + int timestamp = GetTime(); + + FormatEx(g_szCurrentDemoPath, sizeof(g_szCurrentDemoPath), + "%s_%s_%d-%s", + g_MatchData.id, + g_MatchData.match_maps[g_iCurrentMapIndex].id, + timestamp, mapName); + + char cmd[PLATFORM_MAX_PATH]; + FormatEx(cmd, sizeof(cmd), "tv_record \"%s\"", g_szCurrentDemoPath); + SendCommands(cmd); + + g_bRecordingDemo = true; + LogMessage("[5Stack] Demo recording started: %s", g_szCurrentDemoPath); +} + +stock void Demo_Stop() +{ + if (!g_bRecordingDemo) return; + + SendCommands("tv_stoprecord"); + g_bRecordingDemo = false; + LogMessage("[5Stack] Demo recording stopped"); +} + +stock void Demo_Upload() +{ + if (!g_bMatchLoaded || !Config_IsValid()) + return; + + if (g_szCurrentDemoPath[0] == '\0') + { + LogMessage("[5Stack] No demo to upload"); + return; + } + + LogMessage("[5Stack] Requesting presigned URL for demo upload"); + + // Request presigned URL + char endpoint[512]; + FormatEx(endpoint, sizeof(endpoint), + "demos/%s/pre-signed?demo=%s.dem&mapId=%s", + g_MatchData.id, g_szCurrentDemoPath, + g_MatchData.match_maps[g_iCurrentMapIndex].id); + + HTTPClient client = CreateDemosClient(); + client.Get(endpoint, OnDemoPresignedUrl); + // Note: client handle leaks, but ripext internally manages the lifecycle + // for active requests. The handle is cleaned up after the callback fires. +} + +public void OnDemoPresignedUrl(HTTPResponse response, any value) +{ + int statusCode = view_as(response.Status); + + if (statusCode == 409) + { + LogMessage("[5Stack] Demo upload: map not finished yet (409)"); + return; + } + + if (statusCode == 406) + { + LogMessage("[5Stack] Demo upload: already uploaded (406)"); + return; + } + + if (statusCode != 200) + { + LogError("[5Stack] Demo presigned URL failed: HTTP %d", statusCode); + return; + } + + JSONObject json = view_as(response.Data); + if (json == null) return; + + char presignedUrl[1024]; + json.GetString("presignedUrl", presignedUrl, sizeof(presignedUrl)); + + if (presignedUrl[0] == '\0') + { + LogError("[5Stack] Empty presigned URL"); + return; + } + + // Upload the demo file via PUT to the presigned URL using HTTPRequest + char demoFile[PLATFORM_MAX_PATH]; + FormatEx(demoFile, sizeof(demoFile), "%s.dem", g_szCurrentDemoPath); + + LogMessage("[5Stack] Uploading demo: %s to presigned URL", demoFile); + + // Use ripext HTTPRequest for PUT + HTTPRequest request = new HTTPRequest(presignedUrl); + request.UploadFile("", demoFile, OnDemoUploaded); +} + +public void OnDemoUploaded(HTTPResponse response, any value) +{ + int statusCode = view_as(response.Status); + + if (statusCode != 200 && statusCode != 201 && statusCode != 204) + { + LogError("[5Stack] Demo upload failed: HTTP %d", statusCode); + return; + } + + LogMessage("[5Stack] Demo uploaded successfully, notifying backend"); + + // Notify backend that upload is complete + if (!g_bMatchLoaded || g_iCurrentMapIndex < 0) return; + + char endpoint[256]; + FormatEx(endpoint, sizeof(endpoint), "demos/%s/uploaded", g_MatchData.id); + + char demoFile[PLATFORM_MAX_PATH]; + FormatEx(demoFile, sizeof(demoFile), "%s.dem", g_szCurrentDemoPath); + + JSONObject body = new JSONObject(); + body.SetString("demo", demoFile); + body.SetString("mapId", g_MatchData.match_maps[g_iCurrentMapIndex].id); + + HTTPClient client = CreateDemosClient(); + client.Post(endpoint, body, OnDemoNotified); + delete body; +} + +public void OnDemoNotified(HTTPResponse response, any value) +{ + int statusCode = view_as(response.Status); + + if (statusCode != 200) + { + LogError("[5Stack] Demo upload notification failed: HTTP %d", statusCode); + return; + } + + LogMessage("[5Stack] Backend notified of demo upload"); +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/game_server.inc b/csgo/addons/sourcemod/scripting/fivestack/game_server.inc new file mode 100644 index 0000000..e984a4e --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/game_server.inc @@ -0,0 +1,99 @@ +/** + * 5Stack CS:GO Plugin - Game Server + * Server commands, messaging, and ping. + */ + +#if defined _fivestack_game_server_included + #endinput +#endif +#define _fivestack_game_server_included + +stock void SendCommands(const char[] commands) +{ + ServerCommand(commands); +} + +stock void MessageAll(const char[] format, any ...) +{ + char buffer[512]; + VFormat(buffer, sizeof(buffer), format, 2); + PrintToChatAll("[5Stack] %s", buffer); +} + +stock void MessageClient(int client, const char[] format, any ...) +{ + char buffer[512]; + VFormat(buffer, sizeof(buffer), format, 3); + + if (IsValidClient(client)) + { + PrintToChat(client, "[5Stack] %s", buffer); + } +} + +stock void AlertAll(const char[] format, any ...) +{ + char buffer[256]; + VFormat(buffer, sizeof(buffer), format, 2); + PrintCenterTextAll(buffer); +} + +stock void AlertClient(int client, const char[] format, any ...) +{ + char buffer[256]; + VFormat(buffer, sizeof(buffer), format, 3); + + if (IsValidClient(client)) + { + PrintCenterText(client, buffer); + } +} + +stock void HudAlertAll(const char[] format, any ...) +{ + char buffer[256]; + VFormat(buffer, sizeof(buffer), format, 2); + PrintHintTextToAll(buffer); +} + +stock bool IsValidClient(int client) +{ + return (client > 0 && client <= MaxClients && IsClientInGame(client) && !IsFakeClient(client)); +} + +// Ping the backend every 15 seconds +stock void Ping_Start() +{ + if (g_hPingTimer != INVALID_HANDLE) + { + KillTimer(g_hPingTimer); + } + g_hPingTimer = CreateTimer(15.0, Timer_Ping, _, TIMER_REPEAT); +} + +public Action Timer_Ping(Handle timer) +{ + if (!Config_IsValid()) + return Plugin_Continue; + + char mapName[MAX_MAP_NAME_LENGTH]; + GetCurrentMap(mapName, sizeof(mapName)); + + char endpoint[512]; + FormatEx(endpoint, sizeof(endpoint), + "game-server-node/ping/%s?map=%s&pluginVersion=%s", + g_szServerId, mapName, FIVESTACK_VERSION); + + HTTPClient client = CreateApiClient(); + client.Get(endpoint, OnPingResponse); + + return Plugin_Continue; +} + +public void OnPingResponse(HTTPResponse response, any value) +{ + if (response.Status != HTTPStatus_OK) + { + LogError("[5Stack] Ping failed: HTTP %d", response.Status); + } +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/globals.inc b/csgo/addons/sourcemod/scripting/fivestack/globals.inc new file mode 100644 index 0000000..2988e2c --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/globals.inc @@ -0,0 +1,207 @@ +/** + * 5Stack CS:GO Plugin - Global State + * Replaces DI singletons from the CS2 plugin. + */ + +#if defined _fivestack_globals_included + #endinput +#endif +#define _fivestack_globals_included + +// Plugin version (replaced at build time) +#define FIVESTACK_VERSION "0.1.0" + +// Match state +MatchData g_MatchData; +bool g_bMatchLoaded = false; +eMapStatus g_CurrentMapStatus = MapStatus_Unknown; + +// Current map cache (index into g_MatchData.match_maps) +int g_iCurrentMapIndex = -1; + +// Ready system +bool g_bReadyPlayers[MAXPLAYERS + 1]; +Handle g_hReadyTimer = INVALID_HANDLE; +bool g_bReadySystemActive = false; + +// Knife system +int g_iKnifeWinnerTeam = CS_TEAM_NONE; +Handle g_hKnifeTimer = INVALID_HANDLE; +bool g_bKnifeRoundActive = false; + +// Captain system +int g_iCaptainCT = -1; // client index or -1 +int g_iCaptainT = -1; // client index or -1 + +// Timeout system +Handle g_hResumeMessageTimer = INVALID_HANDLE; + +// Vote system +StringMap g_smVotes; // steamid -> bool +char g_szVoteMessage[256]; +int g_iVoteAllowedTeam = CS_TEAM_NONE; // 0 = both teams +bool g_bVoteCaptainOnly = false; +float g_fVoteStartTime = 0.0; +float g_fVoteTimeout = 0.0; +Handle g_hVoteTimer = INVALID_HANDLE; +bool g_bVoteActive = false; +// Demo system +bool g_bRecordingDemo = false; +char g_szCurrentDemoPath[PLATFORM_MAX_PATH]; + +// Event queue +ArrayList g_alPendingEvents; // ArrayList of JSON strings +Handle g_hEventRetryTimer = INVALID_HANDLE; + +// Ping timer +Handle g_hPingTimer = INVALID_HANDLE; + +// Map change +Handle g_hMapChangeTimer = INVALID_HANDLE; +Handle g_hMapChangeCountdownTimer = INVALID_HANDLE; +int g_iMapChangeDelay = 0; + +// Auth +StringMap g_smAllowedPlayers; // steamid -> role string (for RCON pre-auth) + +stock void Globals_Init() +{ + g_alPendingEvents = new ArrayList(ByteCountToCells(16384)); + g_smAllowedPlayers = new StringMap(); + g_smVotes = new StringMap(); +} + +stock void Globals_Reset() +{ + g_bMatchLoaded = false; + g_CurrentMapStatus = MapStatus_Unknown; + g_iCurrentMapIndex = -1; + + // Ready + for (int i = 0; i <= MAXPLAYERS; i++) + g_bReadyPlayers[i] = false; + if (g_hReadyTimer != INVALID_HANDLE) + { + KillTimer(g_hReadyTimer); + g_hReadyTimer = INVALID_HANDLE; + } + g_bReadySystemActive = false; + + // Knife + g_iKnifeWinnerTeam = CS_TEAM_NONE; + if (g_hKnifeTimer != INVALID_HANDLE) + { + KillTimer(g_hKnifeTimer); + g_hKnifeTimer = INVALID_HANDLE; + } + g_bKnifeRoundActive = false; + + // Captain + g_iCaptainCT = -1; + g_iCaptainT = -1; + + // Timeout + if (g_hResumeMessageTimer != INVALID_HANDLE) + { + KillTimer(g_hResumeMessageTimer); + g_hResumeMessageTimer = INVALID_HANDLE; + } + + // Vote + Vote_Cancel(); + + // Demo + g_bRecordingDemo = false; + g_szCurrentDemoPath[0] = '\0'; + + // Map change + if (g_hMapChangeTimer != INVALID_HANDLE) + { + KillTimer(g_hMapChangeTimer); + g_hMapChangeTimer = INVALID_HANDLE; + } + if (g_hMapChangeCountdownTimer != INVALID_HANDLE) + { + KillTimer(g_hMapChangeCountdownTimer); + g_hMapChangeCountdownTimer = INVALID_HANDLE; + } + g_iMapChangeDelay = 0; +} + +stock int GetCurrentMapIndex() +{ + if (!g_bMatchLoaded) return -1; + + for (int i = 0; i < g_MatchData.match_map_count; i++) + { + if (StrEqual(g_MatchData.match_maps[i].id, g_MatchData.current_match_map_id)) + { + return i; + } + } + return -1; +} + +stock bool IsMapFinished() +{ + return (g_CurrentMapStatus == MapStatus_Finished || + g_CurrentMapStatus == MapStatus_UploadingDemo || + g_CurrentMapStatus == MapStatus_Surrendered); +} + +stock bool IsWarmup() +{ + if (g_CurrentMapStatus == MapStatus_Warmup || g_CurrentMapStatus == MapStatus_Scheduled) + return true; + + return GameRules_GetProp("m_bWarmupPeriod") != 0; +} + +stock bool IsLive() +{ + if (IsWarmup() || IsKnife()) + return false; + + return (g_CurrentMapStatus == MapStatus_Live || + g_CurrentMapStatus == MapStatus_Overtime || + g_CurrentMapStatus == MapStatus_Paused); +} + +stock bool IsPaused() +{ + return (g_CurrentMapStatus == MapStatus_Paused); +} + +stock bool IsKnife() +{ + return (g_CurrentMapStatus == MapStatus_Knife); +} + +stock bool IsOvertime() +{ + return GameRules_GetProp("m_nOvertimePlaying") > 0; +} + +stock bool IsFreezePeriod() +{ + return GameRules_GetProp("m_bFreezePeriod") != 0; +} + +stock int GetTotalRoundsPlayed() +{ + return GameRules_GetProp("m_totalRoundsPlayed"); +} + +stock int GetCurrentRound() +{ + return GetTotalRoundsPlayed() + 1; +} + +stock int GetExpectedPlayerCount() +{ + if (!g_bMatchLoaded) return 10; + + if (StrEqual(g_MatchData.options.type, "Wingman")) return 4; + if (StrEqual(g_MatchData.options.type, "Duel")) return 2; + return 10; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/http_client.inc b/csgo/addons/sourcemod/scripting/fivestack/http_client.inc new file mode 100644 index 0000000..f29db2f --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/http_client.inc @@ -0,0 +1,33 @@ +/** + * 5Stack CS:GO Plugin - HTTP Client + * HTTP wrappers using sm-ripext for API communication. + */ + +#if defined _fivestack_http_client_included + #endinput +#endif +#define _fivestack_http_client_included + +#include + +// Create an HTTPClient with auth headers pointing to the API domain +stock HTTPClient CreateApiClient() +{ + HTTPClient client = new HTTPClient(g_szApiDomain); + char authHeader[256]; + FormatEx(authHeader, sizeof(authHeader), "Bearer %s", g_szServerApiPassword); + client.SetHeader("Authorization", authHeader); + client.SetHeader("Content-Type", "application/json"); + return client; +} + +// Create an HTTPClient for the demos domain +stock HTTPClient CreateDemosClient() +{ + HTTPClient client = new HTTPClient(g_szDemosDomain); + char authHeader[256]; + FormatEx(authHeader, sizeof(authHeader), "Bearer %s", g_szServerApiPassword); + client.SetHeader("Authorization", authHeader); + client.SetHeader("Content-Type", "application/json"); + return client; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/json_helpers.inc b/csgo/addons/sourcemod/scripting/fivestack/json_helpers.inc new file mode 100644 index 0000000..cff674f --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/json_helpers.inc @@ -0,0 +1,191 @@ +/** + * 5Stack CS:GO Plugin - JSON Helpers + * Utilities for building/parsing JSON using sm-ripext. + */ + +#if defined _fivestack_json_helpers_included + #endinput +#endif +#define _fivestack_json_helpers_included + +#include + +// Generate a pseudo-UUID v4 (not cryptographically secure, but unique enough for message IDs) +stock void GenerateUUID(char[] buffer, int maxlen) +{ + int r1 = GetRandomInt(0, 0x7FFFFFFF); + int r2 = GetRandomInt(0, 0x7FFFFFFF); + int r3 = GetRandomInt(0, 0x7FFFFFFF); + int r4 = GetRandomInt(0, 0x7FFFFFFF); + + FormatEx(buffer, maxlen, "%08x-%04x-4%03x-%04x-%04x%08x", + r1, + (r2 >> 16) & 0xFFFF, + r2 & 0x0FFF, + (r3 >> 16 & 0x3FFF) | 0x8000, + r3 & 0xFFFF, + r4); +} + +// Safe JSON string getter that handles missing keys +stock bool JSON_GetStringOrDefault(JSONObject json, const char[] key, char[] buffer, int maxlen, const char[] defaultVal = "") +{ + if (json.HasKey(key) && !json.IsNull(key)) + { + json.GetString(key, buffer, maxlen); + return true; + } + strcopy(buffer, maxlen, defaultVal); + return false; +} + +// Safe JSON int getter +stock int JSON_GetIntOrDefault(JSONObject json, const char[] key, int defaultVal = 0) +{ + if (json.HasKey(key) && !json.IsNull(key)) + { + return json.GetInt(key); + } + return defaultVal; +} + +// Safe JSON bool getter +stock bool JSON_GetBoolOrDefault(JSONObject json, const char[] key, bool defaultVal = false) +{ + if (json.HasKey(key) && !json.IsNull(key)) + { + return json.GetBool(key); + } + return defaultVal; +} + +// Parse a MatchMember from JSON +stock void JSON_ParseMatchMember(JSONObject json, MatchMember member) +{ + JSON_GetStringOrDefault(json, "name", member.name, sizeof(MatchMember::name)); + JSON_GetStringOrDefault(json, "role", member.role, sizeof(MatchMember::role)); + JSON_GetStringOrDefault(json, "placeholder_name", member.placeholder_name, sizeof(MatchMember::placeholder_name)); + JSON_GetStringOrDefault(json, "steam_id", member.steam_id, sizeof(MatchMember::steam_id)); + JSON_GetStringOrDefault(json, "match_lineup_id", member.match_lineup_id, sizeof(MatchMember::match_lineup_id)); + member.captain = JSON_GetBoolOrDefault(json, "captain"); + member.is_banned = JSON_GetBoolOrDefault(json, "is_banned"); + member.is_gagged = JSON_GetBoolOrDefault(json, "is_gagged"); + member.is_muted = JSON_GetBoolOrDefault(json, "is_muted"); +} + +// Parse a MatchLineUp from JSON +stock void JSON_ParseMatchLineUp(JSONObject json, MatchLineUp lineup) +{ + JSON_GetStringOrDefault(json, "id", lineup.id, sizeof(MatchLineUp::id)); + JSON_GetStringOrDefault(json, "name", lineup.name, sizeof(MatchLineUp::name)); + JSON_GetStringOrDefault(json, "tag", lineup.tag, sizeof(MatchLineUp::tag)); + JSON_GetStringOrDefault(json, "coach_steam_id", lineup.coach_steam_id, sizeof(MatchLineUp::coach_steam_id)); + + lineup.player_count = 0; + if (json.HasKey("lineup_players") && !json.IsNull("lineup_players")) + { + JSONArray players = view_as(json.Get("lineup_players")); + int count = players.Length; + if (count > MAX_LINEUP_PLAYERS) count = MAX_LINEUP_PLAYERS; + + for (int i = 0; i < count; i++) + { + JSONObject playerJson = view_as(players.Get(i)); + JSON_ParseMatchMember(playerJson, lineup.players[lineup.player_count]); + lineup.player_count++; + delete playerJson; + } + delete players; + } +} + +// Parse MatchOptions from JSON +stock void JSON_ParseMatchOptions(JSONObject json, MatchOptions options) +{ + options.mr = JSON_GetIntOrDefault(json, "mr", 12); + JSON_GetStringOrDefault(json, "type", options.type, sizeof(MatchOptions::type), "Competitive"); + options.overtime = JSON_GetBoolOrDefault(json, "overtime", true); + options.best_of = JSON_GetIntOrDefault(json, "best_of", 1); + options.tv_delay = JSON_GetIntOrDefault(json, "tv_delay", 115); + options.coaches = JSON_GetBoolOrDefault(json, "coaches", true); + options.number_of_substitutes = JSON_GetIntOrDefault(json, "number_of_substitutes", 0); + options.knife_round = JSON_GetBoolOrDefault(json, "knife_round", true); + options.default_models = JSON_GetBoolOrDefault(json, "default_models", false); + JSON_GetStringOrDefault(json, "ready_setting", options.ready_setting, sizeof(MatchOptions::ready_setting), "Players"); + JSON_GetStringOrDefault(json, "timeout_setting", options.timeout_setting, sizeof(MatchOptions::timeout_setting), "CoachAndPlayers"); + JSON_GetStringOrDefault(json, "tech_timeout_setting", options.tech_timeout_setting, sizeof(MatchOptions::tech_timeout_setting), "CoachAndPlayers"); +} + +// Parse a MatchMap from JSON +stock void JSON_ParseMatchMap(JSONObject json, MatchMap matchMap) +{ + JSON_GetStringOrDefault(json, "id", matchMap.id, sizeof(MatchMap::id)); + matchMap.order = JSON_GetIntOrDefault(json, "order", 1); + JSON_GetStringOrDefault(json, "status", matchMap.status, sizeof(MatchMap::status), "Scheduled"); + JSON_GetStringOrDefault(json, "lineup_1_side", matchMap.lineup_1_side, sizeof(MatchMap::lineup_1_side), "CT"); + JSON_GetStringOrDefault(json, "lineup_2_side", matchMap.lineup_2_side, sizeof(MatchMap::lineup_2_side), "TERRORIST"); + matchMap.lineup_1_timeouts_available = JSON_GetIntOrDefault(json, "lineup_1_timeouts_available", 3); + matchMap.lineup_2_timeouts_available = JSON_GetIntOrDefault(json, "lineup_2_timeouts_available", 3); + + if (json.HasKey("map") && !json.IsNull("map")) + { + JSONObject mapJson = view_as(json.Get("map")); + JSON_GetStringOrDefault(mapJson, "name", matchMap.map.name, sizeof(MapDef::name)); + JSON_GetStringOrDefault(mapJson, "workshop_map_id", matchMap.map.workshop_map_id, sizeof(MapDef::workshop_map_id)); + delete mapJson; + } +} + +// Parse full MatchData from JSON response +stock bool JSON_ParseMatchData(JSONObject json, MatchData matchData) +{ + JSON_GetStringOrDefault(json, "id", matchData.id, sizeof(MatchData::id)); + matchData.is_lan = JSON_GetBoolOrDefault(json, "is_lan"); + JSON_GetStringOrDefault(json, "password", matchData.password, sizeof(MatchData::password), "connectme"); + JSON_GetStringOrDefault(json, "current_match_map_id", matchData.current_match_map_id, sizeof(MatchData::current_match_map_id)); + JSON_GetStringOrDefault(json, "lineup_1_id", matchData.lineup_1_id, sizeof(MatchData::lineup_1_id)); + JSON_GetStringOrDefault(json, "lineup_2_id", matchData.lineup_2_id, sizeof(MatchData::lineup_2_id)); + + // Parse options + if (json.HasKey("options") && !json.IsNull("options")) + { + JSONObject optionsJson = view_as(json.Get("options")); + JSON_ParseMatchOptions(optionsJson, matchData.options); + delete optionsJson; + } + + // Parse lineups + if (json.HasKey("lineup_1") && !json.IsNull("lineup_1")) + { + JSONObject lineup1Json = view_as(json.Get("lineup_1")); + JSON_ParseMatchLineUp(lineup1Json, matchData.lineup_1); + delete lineup1Json; + } + + if (json.HasKey("lineup_2") && !json.IsNull("lineup_2")) + { + JSONObject lineup2Json = view_as(json.Get("lineup_2")); + JSON_ParseMatchLineUp(lineup2Json, matchData.lineup_2); + delete lineup2Json; + } + + // Parse match maps + matchData.match_map_count = 0; + if (json.HasKey("match_maps") && !json.IsNull("match_maps")) + { + JSONArray mapsArray = view_as(json.Get("match_maps")); + int count = mapsArray.Length; + if (count > MAX_MATCH_MAPS) count = MAX_MATCH_MAPS; + + for (int i = 0; i < count; i++) + { + JSONObject mapJson = view_as(mapsArray.Get(i)); + JSON_ParseMatchMap(mapJson, matchData.match_maps[matchData.match_map_count]); + matchData.match_map_count++; + delete mapJson; + } + delete mapsArray; + } + + return true; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/knife_system.inc b/csgo/addons/sourcemod/scripting/fivestack/knife_system.inc new file mode 100644 index 0000000..544ab50 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/knife_system.inc @@ -0,0 +1,197 @@ +/** + * 5Stack CS:GO Plugin - Knife System + * Knife round, winner tracking, stay/switch. + */ + +#if defined _fivestack_knife_system_included + #endinput +#endif +#define _fivestack_knife_system_included + +stock void Knife_Start() +{ + if (IsKnife()) return; + + LogMessage("[5Stack] Starting knife round"); + + g_bKnifeRoundActive = true; + g_iKnifeWinnerTeam = CS_TEAM_NONE; + + SendCommands("exec 5stack.knife.cfg"); + SendCommands("mp_warmup_end; mp_restartgame 1"); + + MessageAll("Knife round! Winner picks side."); +} + +stock void Knife_SetWinner(int winnerTeam) +{ + g_iKnifeWinnerTeam = winnerTeam; + + char teamStr[16]; + CSTeamToString(winnerTeam, teamStr, sizeof(teamStr)); + LogMessage("[5Stack] Knife round won by %s", teamStr); + + // Start the captain prompt + if (g_hKnifeTimer != INVALID_HANDLE) + { + KillTimer(g_hKnifeTimer); + } + g_hKnifeTimer = CreateTimer(3.0, Timer_KnifePrompt, _, TIMER_REPEAT); + + // Enter warmup for decision + SendCommands("mp_warmup_start"); +} + +public Action Timer_KnifePrompt(Handle timer) +{ + if (g_iKnifeWinnerTeam == CS_TEAM_NONE) + { + g_hKnifeTimer = INVALID_HANDLE; + return Plugin_Stop; + } + + // Find the captain of the winning team + int captain = (g_iKnifeWinnerTeam == CS_TEAM_CT) ? g_iCaptainCT : g_iCaptainT; + + if (captain > 0 && IsValidClient(captain)) + { + PrintCenterText(captain, "Your team won the knife round!\nType .stay or .switch to pick a side\nOr type .ct / .t"); + } + + // Show waiting message to everyone else + for (int i = 1; i <= MaxClients; i++) + { + if (!IsValidClient(i) || i == captain) continue; + PrintCenterText(i, "Waiting for captain to pick a side..."); + } + + return Plugin_Continue; +} + +stock void Knife_Stay(int client) +{ + if (g_iKnifeWinnerTeam == CS_TEAM_NONE) + { + MessageClient(client, "No knife round decision pending."); + return; + } + + if (!Knife_IsCaptainOfWinner(client)) + { + MessageClient(client, "Only the captain of the winning team can decide."); + return; + } + + LogMessage("[5Stack] Captain chose to STAY"); + MessageAll("Captain has chosen to stay on the current side."); + + Knife_Finalize(false); +} + +stock void Knife_Switch(int client) +{ + if (g_iKnifeWinnerTeam == CS_TEAM_NONE) + { + MessageClient(client, "No knife round decision pending."); + return; + } + + if (!Knife_IsCaptainOfWinner(client)) + { + MessageClient(client, "Only the captain of the winning team can decide."); + return; + } + + LogMessage("[5Stack] Captain chose to SWITCH"); + MessageAll("Captain has chosen to switch sides."); + + Knife_Finalize(true); +} + +stock void Knife_PickSide(int client, int side) +{ + if (g_iKnifeWinnerTeam == CS_TEAM_NONE) + { + MessageClient(client, "No knife round decision pending."); + return; + } + + if (!Knife_IsCaptainOfWinner(client)) + { + MessageClient(client, "Only the captain of the winning team can decide."); + return; + } + + int currentSide = GetClientTeam(client); + bool shouldSwitch = (currentSide != side); + + if (shouldSwitch) + { + LogMessage("[5Stack] Captain picked %s (switch)", side == CS_TEAM_CT ? "CT" : "T"); + MessageAll("Captain has chosen to switch sides."); + } + else + { + LogMessage("[5Stack] Captain picked %s (stay)", side == CS_TEAM_CT ? "CT" : "T"); + MessageAll("Captain has chosen to stay on the current side."); + } + + Knife_Finalize(shouldSwitch); +} + +stock void Knife_Skip() +{ + LogMessage("[5Stack] Knife round skipped"); + MessageAll("Knife round has been skipped."); + + g_iKnifeWinnerTeam = CS_TEAM_NONE; + g_bKnifeRoundActive = false; + + if (g_hKnifeTimer != INVALID_HANDLE) + { + KillTimer(g_hKnifeTimer); + g_hKnifeTimer = INVALID_HANDLE; + } + + MatchManager_UpdateMapStatus(MapStatus_Live); +} + +stock void Knife_Finalize(bool doSwitch) +{ + if (g_hKnifeTimer != INVALID_HANDLE) + { + KillTimer(g_hKnifeTimer); + g_hKnifeTimer = INVALID_HANDLE; + } + + if (doSwitch) + { + SendCommands("mp_swapteams"); + PublishGameEvent("switch", "{}"); + } + + g_iKnifeWinnerTeam = CS_TEAM_NONE; + g_bKnifeRoundActive = false; + + MatchManager_UpdateMapStatus(MapStatus_Live); +} + +stock bool Knife_IsCaptainOfWinner(int client) +{ + if (g_iKnifeWinnerTeam == CS_TEAM_NONE) return false; + + int captain = (g_iKnifeWinnerTeam == CS_TEAM_CT) ? g_iCaptainCT : g_iCaptainT; + return (client == captain); +} + +stock void Knife_Reset() +{ + g_iKnifeWinnerTeam = CS_TEAM_NONE; + g_bKnifeRoundActive = false; + + if (g_hKnifeTimer != INVALID_HANDLE) + { + KillTimer(g_hKnifeTimer); + g_hKnifeTimer = INVALID_HANDLE; + } +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/match_data.inc b/csgo/addons/sourcemod/scripting/fivestack/match_data.inc new file mode 100644 index 0000000..56ee701 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/match_data.inc @@ -0,0 +1,100 @@ +/** + * 5Stack CS:GO Plugin - Match Data Structures + * Mirrors FiveStack.Entities from CS2 plugin. + * Uses enum structs (SourcePawn 1.10+). + */ + +#if defined _fivestack_match_data_included + #endinput +#endif +#define _fivestack_match_data_included + +#define MAX_PLAYERS_PER_TEAM 5 +#define MAX_LINEUP_PLAYERS (MAX_PLAYERS_PER_TEAM + 5) // players + substitutes +#define MAX_MATCH_MAPS 5 +#define GUID_LENGTH 37 // 36 chars + null +#define MAX_NAME_LENGTH 128 +#define MAX_STEAMID_LENGTH 32 +#define MAX_PASSWORD_LENGTH 64 +#define MAX_MAP_NAME_LENGTH 64 +#define MAX_SIDE_LENGTH 16 +#define MAX_TAG_LENGTH 32 +#define MAX_ROLE_LENGTH 32 +#define MAX_STATUS_LENGTH 32 +#define MAX_TYPE_LENGTH 32 +#define MAX_WORKSHOP_ID_LEN 32 + +enum struct MatchMember +{ + char name[MAX_NAME_LENGTH]; + char role[MAX_ROLE_LENGTH]; + char placeholder_name[MAX_NAME_LENGTH]; + char steam_id[MAX_STEAMID_LENGTH]; + bool captain; + char match_lineup_id[GUID_LENGTH]; + bool is_banned; + bool is_gagged; + bool is_muted; +} + +enum struct MapDef +{ + char name[MAX_MAP_NAME_LENGTH]; + char workshop_map_id[MAX_WORKSHOP_ID_LEN]; +} + +enum struct MatchMap +{ + char id[GUID_LENGTH]; + MapDef map; + int order; + char status[MAX_STATUS_LENGTH]; + char lineup_1_side[MAX_SIDE_LENGTH]; + char lineup_2_side[MAX_SIDE_LENGTH]; + int lineup_1_timeouts_available; + int lineup_2_timeouts_available; +} + +enum struct MatchLineUp +{ + char id[GUID_LENGTH]; + char name[MAX_NAME_LENGTH]; + char tag[MAX_TAG_LENGTH]; + char coach_steam_id[MAX_STEAMID_LENGTH]; + MatchMember players[MAX_LINEUP_PLAYERS]; + int player_count; +} + +enum struct MatchOptions +{ + int mr; + char type[MAX_TYPE_LENGTH]; // "Competitive", "Wingman", "Duel" + bool overtime; + int best_of; + int tv_delay; + bool coaches; + int number_of_substitutes; + bool knife_round; + bool default_models; + char ready_setting[MAX_TYPE_LENGTH]; + char timeout_setting[MAX_TYPE_LENGTH]; + char tech_timeout_setting[MAX_TYPE_LENGTH]; +} + +enum struct MatchData +{ + char id[GUID_LENGTH]; + bool is_lan; + char password[MAX_PASSWORD_LENGTH]; + char current_match_map_id[GUID_LENGTH]; + + MatchMap match_maps[MAX_MATCH_MAPS]; + int match_map_count; + + MatchOptions options; + + char lineup_1_id[GUID_LENGTH]; + char lineup_2_id[GUID_LENGTH]; + MatchLineUp lineup_1; + MatchLineUp lineup_2; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/match_events.inc b/csgo/addons/sourcemod/scripting/fivestack/match_events.inc new file mode 100644 index 0000000..8c4dbe6 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/match_events.inc @@ -0,0 +1,157 @@ +/** + * 5Stack CS:GO Plugin - Match Events + * HTTP POST event publishing with retry queue. + */ + +#if defined _fivestack_match_events_included + #endinput +#endif +#define _fivestack_match_events_included + +#define EVENT_RETRY_INTERVAL 5.0 + +stock void MatchEvents_Init() +{ + if (g_hEventRetryTimer != INVALID_HANDLE) + { + KillTimer(g_hEventRetryTimer); + } + g_hEventRetryTimer = CreateTimer(EVENT_RETRY_INTERVAL, Timer_RetryEvents, _, TIMER_REPEAT); +} + +stock void PublishMapStatus(eMapStatus status, const char[] winningLineupId = "") +{ + if (!Config_IsValid()) return; + + char statusStr[MAX_STATUS_LENGTH]; + MapStatusToString(status, statusStr, sizeof(statusStr)); + + char data[512]; + FormatEx(data, sizeof(data), + "{\"status\":\"%s\",\"winning_lineup_id\":\"%s\"}", + statusStr, winningLineupId); + + PublishGameEvent("mapStatus", data); +} + +// Publish a game event to the backend via HTTP POST. +// dataJson should be a JSON object string like {"key":"value",...} +stock void PublishGameEvent(const char[] eventName, const char[] dataJson) +{ + if (!Config_IsValid() || !g_bMatchLoaded) + return; + + if (g_iCurrentMapIndex < 0) + return; + + char messageId[GUID_LENGTH]; + GenerateUUID(messageId, sizeof(messageId)); + + // Build the full event payload + char payload[8192]; + FormatEx(payload, sizeof(payload), + "{\"event\":\"events\",\"data\":{\"matchId\":\"%s\",\"mapId\":\"%s\",\"messageId\":\"%s\",\"event\":\"%s\",\"data\":%s}}", + g_MatchData.id, + g_MatchData.match_maps[g_iCurrentMapIndex].id, + messageId, + eventName, + dataJson); + + // Send immediately + SendEventHTTP(payload); + + // Also queue for retry in case of failure + g_alPendingEvents.PushString(payload); +} + +stock void SendEventHTTP(const char[] payload) +{ + HTTPClient client = CreateApiClient(); + + JSONObject json = JSONObject.FromString(payload); + if (json == null) + { + LogError("[5Stack] Failed to parse event payload as JSON"); + return; + } + + client.Post("game-server-node/events", json, OnEventSent); + delete json; +} + +public void OnEventSent(HTTPResponse response, any value) +{ + if (response.Status == HTTPStatus_OK) + { + // Success — remove from pending queue (next retry tick cleans stale entries) + return; + } + + LogError("[5Stack] Event POST failed: HTTP %d", response.Status); +} + +public Action Timer_RetryEvents(Handle timer) +{ + if (g_alPendingEvents == null || g_alPendingEvents.Length == 0) + return Plugin_Continue; + + // Try to send the first pending event + char payload[8192]; + g_alPendingEvents.GetString(0, payload, sizeof(payload)); + g_alPendingEvents.Erase(0); + + SendEventHTTP(payload); + + return Plugin_Continue; +} + +// Publish round score information +stock void PublishRoundInformation(eWinReason winReason = WinReason_Unknown, const char[] backupFile = "") +{ + if (!g_bMatchLoaded || g_iCurrentMapIndex < 0 || !IsLive()) + return; + + int totalRounds = GetTotalRoundsPlayed(); + int lineup1Side = GetLineupSide(g_MatchData.lineup_1_id, totalRounds); + int lineup2Side = GetLineupSide(g_MatchData.lineup_2_id, totalRounds); + + int lineup1Score = GetLineupScore(g_MatchData.lineup_1_id); + int lineup2Score = GetLineupScore(g_MatchData.lineup_2_id); + + int lineup1Money = GetTeamMoney(lineup1Side); + int lineup2Money = GetTeamMoney(lineup2Side); + + char winReasonStr[32]; + WinReasonToString(winReason, winReasonStr, sizeof(winReasonStr)); + + char lineup1SideStr[16], lineup2SideStr[16]; + CSTeamToString(lineup1Side, lineup1SideStr, sizeof(lineup1SideStr)); + CSTeamToString(lineup2Side, lineup2SideStr, sizeof(lineup2SideStr)); + + char winningSide[16] = ""; + if (winReason == WinReason_TerroristsWin || winReason == WinReason_BombExploded) + strcopy(winningSide, sizeof(winningSide), "TERRORIST"); + else if (winReason != WinReason_Unknown) + strcopy(winningSide, sizeof(winningSide), "CT"); + + char data[2048]; + FormatEx(data, sizeof(data), + "{\"match_map_id\":\"%s\",\"round\":%d," + "\"lineup_1_score\":%d,\"lineup_2_score\":%d," + "\"lineup_1_money\":%d,\"lineup_2_money\":%d," + "\"lineup_1_timeouts_available\":%d,\"lineup_2_timeouts_available\":%d," + "\"lineup_1_side\":\"%s\",\"lineup_2_side\":\"%s\"," + "\"winning_side\":\"%s\",\"winning_reason\":\"%s\"," + "\"backup_file\":\"%s\"}", + g_MatchData.match_maps[g_iCurrentMapIndex].id, + GetCurrentRound(), + lineup1Score, lineup2Score, + lineup1Money, lineup2Money, + g_MatchData.match_maps[g_iCurrentMapIndex].lineup_1_timeouts_available, + g_MatchData.match_maps[g_iCurrentMapIndex].lineup_2_timeouts_available, + lineup1SideStr, lineup2SideStr, + winningSide, winReasonStr, + backupFile); + + PublishGameEvent("score", data); +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/match_manager.inc b/csgo/addons/sourcemod/scripting/fivestack/match_manager.inc new file mode 100644 index 0000000..ea22c55 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/match_manager.inc @@ -0,0 +1,425 @@ +/** + * 5Stack CS:GO Plugin - Match Manager + * State machine: Scheduled->Warmup->Knife->Live->Overtime->Finished + */ + +#if defined _fivestack_match_manager_included + #endinput +#endif +#define _fivestack_match_manager_included + +stock void MatchManager_SetupMatch() +{ + if (!g_bMatchLoaded) + return; + + LogMessage("[5Stack] Setup Match %s", g_MatchData.id); + + g_iCurrentMapIndex = GetCurrentMapIndex(); + if (g_iCurrentMapIndex < 0) + { + LogError("[5Stack] Match does not have a current map"); + return; + } + + char currentMapName[MAX_MAP_NAME_LENGTH]; + GetCurrentMap(currentMapName, sizeof(currentMapName)); + + char expectedMapName[MAX_MAP_NAME_LENGTH]; + strcopy(expectedMapName, sizeof(expectedMapName), g_MatchData.match_maps[g_iCurrentMapIndex].map.name); + + LogMessage("[5Stack] Game State: %s, expected map: %s, current map: %s", + g_MatchData.match_maps[g_iCurrentMapIndex].status, + expectedMapName, currentMapName); + + // Check if we need to change map + if (g_MatchData.match_maps[g_iCurrentMapIndex].map.workshop_map_id[0] != '\0') + { + // Workshop map - would need special handling for CS:GO + // For now fall through to name check + } + + if (StrContains(currentMapName, expectedMapName, false) == -1) + { + MatchManager_ChangeMap(); + return; + } + + if (IsMapFinished()) + { + LogMessage("[5Stack] Map already finished"); + return; + } + + // Execute match type config + char execCmd[128]; + FormatEx(execCmd, sizeof(execCmd), "exec 5stack.%s.cfg", g_MatchData.options.type); + StringToLower(execCmd); + SendCommands(execCmd); + + if (g_MatchData.is_lan) + { + SendCommands("exec 5stack.lan.cfg"); + } + + // Set password + char passwordCmd[128]; + FormatEx(passwordCmd, sizeof(passwordCmd), "sv_password \"%s\"", g_MatchData.password); + SendCommands(passwordCmd); + + // Set match restart delay + char delayCmd[64]; + FormatEx(delayCmd, sizeof(delayCmd), "mp_match_restart_delay %d", g_MatchData.options.tv_delay); + SendCommands(delayCmd); + + // Setup game mode + MatchManager_SetupGameMode(); + + // Transition to the map's current status + eMapStatus targetStatus = MapStatusStringToEnum(g_MatchData.match_maps[g_iCurrentMapIndex].status); + if (targetStatus != g_CurrentMapStatus) + { + MatchManager_UpdateMapStatus(targetStatus); + } + + if (IsWarmup()) + { + AlertAll("Match data received"); + } + + // Setup TV + char tvCmd[64]; + FormatEx(tvCmd, sizeof(tvCmd), "tv_delay %d", g_MatchData.options.tv_delay); + SendCommands(tvCmd); + + // Kick bots + SendCommands("bot_kick"); +} + +stock void MatchManager_SetupGameMode() +{ + if (!g_bMatchLoaded) return; + + if (StrEqual(g_MatchData.options.type, "Wingman") || StrEqual(g_MatchData.options.type, "Duel")) + { + SendCommands("game_type 0; game_mode 2"); + } + else + { + SendCommands("game_type 0; game_mode 1"); + } +} + +stock void MatchManager_SetupTeams() +{ + if (!g_bMatchLoaded || g_iCurrentMapIndex < 0) return; + + int lineup1StartingSide = TeamStringToCSTeam(g_MatchData.match_maps[g_iCurrentMapIndex].lineup_1_side); + + // mp_teamname_1 = CT team name, mp_teamname_2 = T team name + char cmd[256]; + if (lineup1StartingSide == CS_TEAM_CT) + { + FormatEx(cmd, sizeof(cmd), "mp_teamname_1 \"%s\"; mp_teamname_2 \"%s\"", + g_MatchData.lineup_1.name, g_MatchData.lineup_2.name); + } + else + { + FormatEx(cmd, sizeof(cmd), "mp_teamname_1 \"%s\"; mp_teamname_2 \"%s\"", + g_MatchData.lineup_2.name, g_MatchData.lineup_1.name); + } + SendCommands(cmd); + + // Enforce all player teams after a short delay + CreateTimer(1.0, Timer_EnforceTeams); +} + +public Action Timer_EnforceTeams(Handle timer) +{ + for (int i = 1; i <= MaxClients; i++) + { + if (IsValidClient(i)) + { + EnforceMemberTeam(i); + } + } + return Plugin_Stop; +} + +stock void MatchManager_UpdateMapStatus(eMapStatus newStatus, const char[] winningLineupId = "") +{ + if (!g_bMatchLoaded) return; + if (g_CurrentMapStatus == newStatus) return; + + char oldStr[MAX_STATUS_LENGTH], newStr[MAX_STATUS_LENGTH]; + MapStatusToString(g_CurrentMapStatus, oldStr, sizeof(oldStr)); + MapStatusToString(newStatus, newStr, sizeof(newStr)); + LogMessage("[5Stack] Map status: %s -> %s", oldStr, newStr); + + switch (newStatus) + { + case MapStatus_Scheduled: + { + MatchManager_UpdateMapStatus(MapStatus_Warmup); + return; + } + case MapStatus_Warmup: + { + MatchManager_SetupTeams(); + MatchManager_StartWarmup(); + } + case MapStatus_Knife: + { + if (!g_MatchData.options.knife_round) + { + MatchManager_UpdateMapStatus(MapStatus_Live); + return; + } + if (g_iCurrentMapIndex >= 0 && + g_MatchData.match_maps[g_iCurrentMapIndex].order == g_MatchData.options.best_of) + { + MatchManager_StartKnife(); + } + else + { + MatchManager_UpdateMapStatus(MapStatus_Live); + return; + } + } + case MapStatus_Paused: + { + if (IsWarmup()) + { + SendCommands("mp_warmup_end"); + } + MatchManager_PauseMatch("", true); + } + case MapStatus_Live: + { + MatchManager_StartLive(); + } + case MapStatus_Finished, MapStatus_Surrendered: + { + Demo_Stop(); + } + case MapStatus_UploadingDemo: + { + Demo_Stop(); + if (g_CurrentMapStatus == MapStatus_Unknown || IsMapFinished()) + return; + } + } + + // Publish status change to backend + PublishMapStatus(newStatus, winningLineupId); + g_CurrentMapStatus = newStatus; +} + +stock void MatchManager_StartWarmup() +{ + SendCommands("exec 5stack.warmup.cfg"); + g_bKnifeRoundActive = false; + + if (!g_bMatchLoaded) return; + + // Ensure warmup mode is active + if (GameRules_GetProp("m_bWarmupPeriod") == 0) + { + SendCommands("mp_warmup_start"); + } + + Ready_Setup(); +} + +stock void MatchManager_StartKnife() +{ + if (!g_bMatchLoaded || IsKnife()) return; + + Captain_AutoSelect(); + Knife_Start(); +} + +stock void MatchManager_StartLive() +{ + g_bKnifeRoundActive = false; + + if (!g_bMatchLoaded) return; + + LogMessage("[5Stack] Starting Live Match"); + + // Configure live match settings + char cmds[512]; + FormatEx(cmds, sizeof(cmds), + "mp_backup_round_auto 1; mp_maxrounds %d; mp_overtime_enable %d", + g_MatchData.options.mr * 2, + g_MatchData.options.overtime ? 1 : 0); + SendCommands(cmds); + + // Execute match type config + char execCmd[128]; + FormatEx(execCmd, sizeof(execCmd), "exec 5stack.%s.cfg", g_MatchData.options.type); + StringToLower(execCmd); + SendCommands(execCmd); + + // Start demo recording + Demo_Start(); + + // End warmup and start live + CreateTimer(0.5, Timer_StartLiveGame); +} + +public Action Timer_StartLiveGame(Handle timer) +{ + MatchManager_ResumeMatch("", true); + + if (IsWarmup() || IsKnife()) + { + SendCommands("mp_restartgame 1; mp_warmup_end"); + } + return Plugin_Stop; +} + +stock void MatchManager_PauseMatch(const char[] message = "", bool skipUpdate = false) +{ + SendCommands("mp_pause_match"); + + if (IsPaused()) return; + + if (message[0] != '\0') + { + AlertAll(message); + } + + // Resume hint timer + if (g_hResumeMessageTimer != INVALID_HANDLE) + { + KillTimer(g_hResumeMessageTimer); + } + g_hResumeMessageTimer = CreateTimer(3.0, Timer_ResumeHint, _, TIMER_REPEAT); + + if (!skipUpdate) + { + MatchManager_UpdateMapStatus(MapStatus_Paused); + } +} + +public Action Timer_ResumeHint(Handle timer) +{ + if (!IsFreezePeriod() || !IsPaused()) + return Plugin_Continue; + + AlertAll("Type .resume in chat to unpause"); + return Plugin_Continue; +} + +stock void MatchManager_ResumeMatch(const char[] message = "", bool skipUpdate = false) +{ + SendCommands("mp_unpause_match"); + + if (g_hResumeMessageTimer != INVALID_HANDLE) + { + KillTimer(g_hResumeMessageTimer); + g_hResumeMessageTimer = INVALID_HANDLE; + } + + // Cancel any active resume votes + if (g_bVoteActive) + { + Vote_Cancel(); + } + + if (!IsPaused()) return; + + if (message[0] != '\0') + { + AlertAll(message); + } + + if (!skipUpdate) + { + MatchManager_UpdateMapStatus(IsOvertime() ? MapStatus_Overtime : MapStatus_Live); + } +} + +stock void MatchManager_ChangeMap() +{ + if (g_hMapChangeCountdownTimer != INVALID_HANDLE) return; + if (!g_bMatchLoaded || g_iCurrentMapIndex < 0) return; + + Globals_Reset(); + + char mapName[MAX_MAP_NAME_LENGTH]; + strcopy(mapName, sizeof(mapName), g_MatchData.match_maps[g_iCurrentMapIndex].map.name); + + if (g_MatchData.match_maps[g_iCurrentMapIndex].map.workshop_map_id[0] != '\0') + { + char cmd[128]; + FormatEx(cmd, sizeof(cmd), "host_workshop_map %s", g_MatchData.match_maps[g_iCurrentMapIndex].map.workshop_map_id); + LogMessage("[5Stack] Changing to workshop map: %s", g_MatchData.match_maps[g_iCurrentMapIndex].map.workshop_map_id); + SendCommands(cmd); + } + else + { + char cmd[128]; + FormatEx(cmd, sizeof(cmd), "changelevel \"%s\"", mapName); + LogMessage("[5Stack] Changing map to: %s", mapName); + SendCommands(cmd); + } +} + +stock void MatchManager_DelayChangeMap(int delay) +{ + g_iMapChangeDelay = delay; + LogMessage("[5Stack] Delaying map change by %d seconds", delay); + + if (g_hMapChangeTimer != INVALID_HANDLE) + { + KillTimer(g_hMapChangeTimer); + } + + g_hMapChangeTimer = CreateTimer(float(delay), Timer_MapChangeComplete); + g_hMapChangeCountdownTimer = CreateTimer(1.0, Timer_MapChangeCountdown, _, TIMER_REPEAT); +} + +public Action Timer_MapChangeComplete(Handle timer) +{ + g_hMapChangeTimer = INVALID_HANDLE; + if (g_hMapChangeCountdownTimer != INVALID_HANDLE) + { + KillTimer(g_hMapChangeCountdownTimer); + g_hMapChangeCountdownTimer = INVALID_HANDLE; + } + LogMessage("[5Stack] Map change delay complete"); + MatchService_FetchMatch(); + return Plugin_Stop; +} + +public Action Timer_MapChangeCountdown(Handle timer) +{ + if (g_iMapChangeDelay > 0) + { + AlertAll("Next map in %d seconds...", g_iMapChangeDelay); + g_iMapChangeDelay--; + return Plugin_Continue; + } + g_hMapChangeCountdownTimer = INVALID_HANDLE; + return Plugin_Stop; +} + +stock void MatchManager_Reset() +{ + LogMessage("[5Stack] Resetting match state"); + Globals_Reset(); +} + +// Helper to lowercase a string in-place +stock void StringToLower(char[] str) +{ + int i = 0; + while (str[i] != '\0') + { + if (str[i] >= 'A' && str[i] <= 'Z') + str[i] += 32; + i++; + } +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/match_service.inc b/csgo/addons/sourcemod/scripting/fivestack/match_service.inc new file mode 100644 index 0000000..4e25eef --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/match_service.inc @@ -0,0 +1,55 @@ +/** + * 5Stack CS:GO Plugin - Match Service + * Fetches match data from the API and parses it into global state. + */ + +#if defined _fivestack_match_service_included + #endinput +#endif +#define _fivestack_match_service_included + +stock void MatchService_FetchMatch() +{ + if (!Config_IsValid()) + { + LogError("[5Stack] Cannot fetch match: Server ID or API password not configured"); + return; + } + + char endpoint[256]; + FormatEx(endpoint, sizeof(endpoint), "matches/current-match/%s", g_szServerId); + + HTTPClient client = CreateApiClient(); + client.Get(endpoint, OnMatchFetched); +} + +public void OnMatchFetched(HTTPResponse response, any value) +{ + if (response.Status != HTTPStatus_OK) + { + LogError("[5Stack] Failed to fetch match data: HTTP %d", response.Status); + return; + } + + JSONObject json = view_as(response.Data); + if (json == null) + { + LogError("[5Stack] Match response was not valid JSON"); + return; + } + + // Parse match data into globals + if (!JSON_ParseMatchData(json, g_MatchData)) + { + LogError("[5Stack] Failed to parse match data"); + return; + } + + g_bMatchLoaded = true; + g_iCurrentMapIndex = GetCurrentMapIndex(); + + LogMessage("[5Stack] Match loaded: %s (map index: %d)", g_MatchData.id, g_iCurrentMapIndex); + + // Trigger match setup + MatchManager_SetupMatch(); +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/match_utility.inc b/csgo/addons/sourcemod/scripting/fivestack/match_utility.inc new file mode 100644 index 0000000..9f35872 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/match_utility.inc @@ -0,0 +1,192 @@ +/** + * 5Stack CS:GO Plugin - Match Utility + * Player lookup, member access, damage helpers. + */ + +#if defined _fivestack_match_utility_included + #endinput +#endif +#define _fivestack_match_utility_included + +// Find a MatchMember by steam ID or placeholder name. +// Returns true if found, fills out member struct. +stock bool GetMemberFromLineup(const char[] steamId, const char[] playerName, MatchMember member) +{ + if (!g_bMatchLoaded) + return false; + + // Search lineup 1 + for (int i = 0; i < g_MatchData.lineup_1.player_count; i++) + { + if (g_MatchData.lineup_1.players[i].steam_id[0] != '\0') + { + if (StrEqual(g_MatchData.lineup_1.players[i].steam_id, steamId)) + { + member = g_MatchData.lineup_1.players[i]; + return true; + } + } + else if (g_MatchData.lineup_1.players[i].placeholder_name[0] != '\0') + { + if (strncmp(g_MatchData.lineup_1.players[i].placeholder_name, playerName, + strlen(g_MatchData.lineup_1.players[i].placeholder_name)) == 0) + { + member = g_MatchData.lineup_1.players[i]; + return true; + } + } + } + + // Search lineup 2 + for (int i = 0; i < g_MatchData.lineup_2.player_count; i++) + { + if (g_MatchData.lineup_2.players[i].steam_id[0] != '\0') + { + if (StrEqual(g_MatchData.lineup_2.players[i].steam_id, steamId)) + { + member = g_MatchData.lineup_2.players[i]; + return true; + } + } + else if (g_MatchData.lineup_2.players[i].placeholder_name[0] != '\0') + { + if (strncmp(g_MatchData.lineup_2.players[i].placeholder_name, playerName, + strlen(g_MatchData.lineup_2.players[i].placeholder_name)) == 0) + { + member = g_MatchData.lineup_2.players[i]; + return true; + } + } + } + + return false; +} + +// Get the MatchMember for a connected client +stock bool GetClientMember(int client, MatchMember member) +{ + if (!IsValidClient(client)) + return false; + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + char playerName[MAX_NAME_LENGTH]; + GetClientName(client, playerName, sizeof(playerName)); + + return GetMemberFromLineup(steamId, playerName, member); +} + +// Check if player is an admin/organizer role +stock bool IsPlayerPrivileged(int client) +{ + MatchMember member; + if (!GetClientMember(client, member)) + { + // Check RCON-allowed players + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + char role[MAX_ROLE_LENGTH]; + if (g_smAllowedPlayers.GetString(steamId, role, sizeof(role))) + { + ePlayerRoles playerRole = PlayerRoleStringToEnum(role); + return (playerRole == PlayerRole_Administrator || + playerRole == PlayerRole_MatchOrganizer || + playerRole == PlayerRole_TournamentOrganizer); + } + return false; + } + + ePlayerRoles playerRole = PlayerRoleStringToEnum(member.role); + return (playerRole == PlayerRole_Administrator || + playerRole == PlayerRole_MatchOrganizer || + playerRole == PlayerRole_TournamentOrganizer); +} + +// Get expected team for a player based on lineup assignment +stock int GetExpectedTeam(int client) +{ + if (!g_bMatchLoaded || g_iCurrentMapIndex < 0) + return CS_TEAM_NONE; + + char lineupId[GUID_LENGTH]; + if (!GetPlayerLineupId(client, lineupId, sizeof(lineupId))) + return CS_TEAM_SPECTATOR; + + return GetLineupSide(lineupId, GetTotalRoundsPlayed()); +} + +// Enforce that a player is on their correct team +stock void EnforceMemberTeam(int client) +{ + if (!IsValidClient(client)) return; + + int expectedTeam = GetExpectedTeam(client); + if (expectedTeam == CS_TEAM_NONE) return; + + int currentTeam = GetClientTeam(client); + if (currentTeam != expectedTeam) + { + char teamStr[16]; + CSTeamToString(expectedTeam, teamStr, sizeof(teamStr)); + LogMessage("[5Stack] Switching %N to %s", client, teamStr); + + CS_SwitchTeam(client, expectedTeam); + MessageClient(client, "You've been assigned to %s.", teamStr); + } + + // Apply member properties + MatchMember member; + if (GetClientMember(client, member)) + { + if (member.is_banned) + { + KickClient(client, "You are banned from this match"); + return; + } + + if (member.is_muted) + { + SetClientListeningFlags(client, VOICE_MUTED); + } + else + { + SetClientListeningFlags(client, VOICE_NORMAL); + } + } + + // Respawn during warmup or freeze period + if (IsWarmup() || IsFreezePeriod()) + { + if (expectedTeam != CS_TEAM_SPECTATOR) + { + CS_RespawnPlayer(client); + } + } + + // Set clan tag + char tag[MAX_TAG_LENGTH]; + if (GetPlayerLineupTag(client, tag, sizeof(tag))) + { + CS_SetClientClanTag(client, tag); + } +} + +// HitGroup int to string mapping +stock void HitGroupToString(int hitgroup, char[] buffer, int maxlen) +{ + switch (hitgroup) + { + case 0: strcopy(buffer, maxlen, "generic"); + case 1: strcopy(buffer, maxlen, "head"); + case 2: strcopy(buffer, maxlen, "chest"); + case 3: strcopy(buffer, maxlen, "stomach"); + case 4: strcopy(buffer, maxlen, "left arm"); + case 5: strcopy(buffer, maxlen, "right arm"); + case 6: strcopy(buffer, maxlen, "left leg"); + case 7: strcopy(buffer, maxlen, "right leg"); + case 8: strcopy(buffer, maxlen, "neck"); + default: strcopy(buffer, maxlen, "unknown"); + } +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/player_auth.inc b/csgo/addons/sourcemod/scripting/fivestack/player_auth.inc new file mode 100644 index 0000000..4900b8f --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/player_auth.inc @@ -0,0 +1,77 @@ +/** + * 5Stack CS:GO Plugin - Player Authentication + * OnClientConnect lineup check, RCON sm_fivestack_allow pre-auth. + */ + +#if defined _fivestack_player_auth_included + #endinput +#endif +#define _fivestack_player_auth_included + +public bool OnClientConnect(int client, char[] rejectmsg, int maxlen) +{ + if (IsFakeClient(client)) + return true; + + if (!g_bMatchLoaded) + return true; // Allow connection if no match loaded yet + + // Get steam ID — may not be available yet at this point + // We'll do the full check in OnClientAuthorized instead + return true; +} + +public void OnClientAuthorized(int client, const char[] auth) +{ + if (IsFakeClient(client)) + return; + + if (!g_bMatchLoaded) + return; + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + // Check if pre-authorized via RCON + char role[MAX_ROLE_LENGTH]; + if (g_smAllowedPlayers.GetString(steamId, role, sizeof(role))) + { + LogMessage("[5Stack] Pre-authorized player connected: %s (role: %s)", steamId, role); + return; + } + + // Check if player is in either lineup + char playerName[MAX_NAME_LENGTH]; + GetClientName(client, playerName, sizeof(playerName)); + + MatchMember member; + if (GetMemberFromLineup(steamId, playerName, member)) + { + LogMessage("[5Stack] Lineup player connected: %s (%s)", steamId, member.name); + return; + } + + // Player not in lineup and not pre-authorized + LogMessage("[5Stack] Rejecting player %s — not in match lineup", steamId); + KickClient(client, "You are not part of this match. Visit 5stack.gg to join."); +} + +// RCON command to pre-authorize a player +public Action Command_FiveStackAllow(int args) +{ + if (args < 2) + { + PrintToServer("Usage: sm_fivestack_allow "); + return Plugin_Handled; + } + + char steamId[MAX_STEAMID_LENGTH]; + char role[MAX_ROLE_LENGTH]; + + GetCmdArg(1, steamId, sizeof(steamId)); + GetCmdArg(2, role, sizeof(role)); + + g_smAllowedPlayers.SetString(steamId, role); + PrintToServer("[5Stack] Pre-authorized player %s with role %s", steamId, role); + return Plugin_Handled; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/ready_system.inc b/csgo/addons/sourcemod/scripting/fivestack/ready_system.inc new file mode 100644 index 0000000..39a6d0b --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/ready_system.inc @@ -0,0 +1,190 @@ +/** + * 5Stack CS:GO Plugin - Ready System + * Toggle ready, clan tag display, threshold check. + */ + +#if defined _fivestack_ready_system_included + #endinput +#endif +#define _fivestack_ready_system_included + +stock void Ready_Setup() +{ + Ready_Reset(); + g_bReadySystemActive = true; + g_hReadyTimer = CreateTimer(3.0, Timer_ReadyStatus, _, TIMER_REPEAT); +} + +stock void Ready_Reset() +{ + for (int i = 0; i <= MAXPLAYERS; i++) + g_bReadyPlayers[i] = false; + + if (g_hReadyTimer != INVALID_HANDLE) + { + KillTimer(g_hReadyTimer); + g_hReadyTimer = INVALID_HANDLE; + } + g_bReadySystemActive = false; +} + +stock void Ready_Toggle(int client) +{ + if (!g_bReadySystemActive || !IsValidClient(client)) + return; + + if (!g_bMatchLoaded) + return; + + // Check if player is allowed to ready based on ready_setting + eReadySettings setting = ReadySettingStringToEnum(g_MatchData.options.ready_setting); + if (setting == ReadySetting_Admin && !IsPlayerPrivileged(client)) + { + MessageClient(client, "Only an admin can start the match."); + return; + } + + g_bReadyPlayers[client] = !g_bReadyPlayers[client]; + + // Update clan tag + if (g_bReadyPlayers[client]) + { + CS_SetClientClanTag(client, "[ready]"); + MessageClient(client, "You are now ready."); + } + else + { + CS_SetClientClanTag(client, "[not ready]"); + MessageClient(client, "You are now not ready."); + } + + // Check if all required players are ready + Ready_CheckThreshold(); +} + +stock void Ready_UnreadyPlayer(int client) +{ + if (client > 0 && client <= MAXPLAYERS) + { + g_bReadyPlayers[client] = false; + if (IsValidClient(client)) + { + CS_SetClientClanTag(client, "[not ready]"); + } + } +} + +stock void Ready_Skip() +{ + if (!g_bReadySystemActive) return; + + LogMessage("[5Stack] Force starting match (ready skipped)"); + MessageAll("Admin has force-started the match."); + Ready_TransitionFromWarmup(); +} + +stock void Ready_CheckThreshold() +{ + if (!g_bReadySystemActive || !g_bMatchLoaded) + return; + + eReadySettings setting = ReadySettingStringToEnum(g_MatchData.options.ready_setting); + int required = 0; + int readyCount = 0; + + switch (setting) + { + case ReadySetting_Admin: + { + required = 1; + for (int i = 1; i <= MaxClients; i++) + { + if (IsValidClient(i) && g_bReadyPlayers[i] && IsPlayerPrivileged(i)) + readyCount++; + } + } + case ReadySetting_Captains: + { + required = 2; + if (g_iCaptainCT > 0 && g_bReadyPlayers[g_iCaptainCT]) readyCount++; + if (g_iCaptainT > 0 && g_bReadyPlayers[g_iCaptainT]) readyCount++; + } + case ReadySetting_Players: + { + required = GetExpectedPlayerCount(); + for (int i = 1; i <= MaxClients; i++) + { + if (IsValidClient(i) && g_bReadyPlayers[i]) + readyCount++; + } + } + } + + if (readyCount >= required && required > 0) + { + Ready_TransitionFromWarmup(); + } +} + +stock void Ready_TransitionFromWarmup() +{ + Ready_Reset(); + MatchManager_UpdateMapStatus(MapStatus_Knife); +} + +public Action Timer_ReadyStatus(Handle timer) +{ + if (!g_bReadySystemActive) + { + g_hReadyTimer = INVALID_HANDLE; + return Plugin_Stop; + } + + if (!g_bMatchLoaded) return Plugin_Continue; + + eReadySettings setting = ReadySettingStringToEnum(g_MatchData.options.ready_setting); + + // Show status to each player + for (int i = 1; i <= MaxClients; i++) + { + if (!IsValidClient(i)) continue; + + if (setting == ReadySetting_Admin) + { + PrintCenterText(i, "Waiting for admin to start the match"); + } + else if (setting == ReadySetting_Captains) + { + PrintCenterText(i, "Waiting for captains to ready up\nType .ready in chat"); + } + else + { + int readyCount = 0; + int required = GetExpectedPlayerCount(); + for (int j = 1; j <= MaxClients; j++) + { + if (IsValidClient(j) && g_bReadyPlayers[j]) + readyCount++; + } + PrintCenterText(i, "Players ready: %d/%d\nType .ready in chat", readyCount, required); + } + } + + // Set clan tags for players who haven't readied + for (int i = 1; i <= MaxClients; i++) + { + if (!IsValidClient(i)) continue; + if (GetClientTeam(i) <= CS_TEAM_SPECTATOR) continue; + + if (g_bReadyPlayers[i]) + { + CS_SetClientClanTag(i, "[ready]"); + } + else + { + CS_SetClientClanTag(i, "[not ready]"); + } + } + + return Plugin_Continue; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/team_utility.inc b/csgo/addons/sourcemod/scripting/fivestack/team_utility.inc new file mode 100644 index 0000000..023f54a --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/team_utility.inc @@ -0,0 +1,208 @@ +/** + * 5Stack CS:GO Plugin - Team Utility + * Side calculation with overtime math, score, money helpers. + */ + +#if defined _fivestack_team_utility_included + #endinput +#endif +#define _fivestack_team_utility_included + +stock int GetOppositeSide(int side) +{ + if (side == CS_TEAM_T) return CS_TEAM_CT; + if (side == CS_TEAM_CT) return CS_TEAM_T; + return CS_TEAM_NONE; +} + +stock int TeamStringToCSTeam(const char[] team) +{ + if (StrEqual(team, "TERRORIST")) return CS_TEAM_T; + if (StrEqual(team, "CT")) return CS_TEAM_CT; + if (StrEqual(team, "Spectator")) return CS_TEAM_SPECTATOR; + return CS_TEAM_NONE; +} + +stock void CSTeamToString(int team, char[] buffer, int maxlen) +{ + switch (team) + { + case CS_TEAM_T: strcopy(buffer, maxlen, "TERRORIST"); + case CS_TEAM_CT: strcopy(buffer, maxlen, "CT"); + case CS_TEAM_SPECTATOR: strcopy(buffer, maxlen, "Spectator"); + default: strcopy(buffer, maxlen, "None"); + } +} + +// Determine which CS team a lineup should be on for a given round. +// Handles regular time halves and overtime rotation. +stock int GetLineupSide(const char[] lineupId, int round) +{ + if (!g_bMatchLoaded || g_iCurrentMapIndex < 0) + return CS_TEAM_NONE; + + int mr = g_MatchData.options.mr; + if (mr <= 0) + return CS_TEAM_NONE; + + bool isLineup1 = StrEqual(g_MatchData.lineup_1_id, lineupId); + char sideStr[MAX_SIDE_LENGTH]; + if (isLineup1) + strcopy(sideStr, sizeof(sideStr), g_MatchData.match_maps[g_iCurrentMapIndex].lineup_1_side); + else + strcopy(sideStr, sizeof(sideStr), g_MatchData.match_maps[g_iCurrentMapIndex].lineup_2_side); + + int startingSide = TeamStringToCSTeam(sideStr); + if (startingSide == CS_TEAM_NONE || startingSide == CS_TEAM_SPECTATOR) + return CS_TEAM_NONE; + + // Regular time + if (round < mr * 2) + { + if (round < mr) + return startingSide; // First half + return GetOppositeSide(startingSide); // Second half + } + + // Overtime + int overtimeRound = round - (mr * 2); + ConVar cvOtMaxRounds = FindConVar("mp_overtime_maxrounds"); + int overtimeMr = (cvOtMaxRounds != null) ? cvOtMaxRounds.IntValue : 6; + + int overTimeNumber = (overtimeRound / overtimeMr) + 1; + int block = overtimeRound % overtimeMr; + + bool condition = (overTimeNumber % 2 == 1) ? (block < (overtimeMr / 2)) : (block >= (overtimeMr / 2)); + return condition ? GetOppositeSide(startingSide) : startingSide; +} + +// Get team score for a lineup at the current round +stock int GetLineupScore(const char[] lineupId) +{ + int round = GetTotalRoundsPlayed(); + int expectedSide = GetLineupSide(lineupId, round); + if (expectedSide == CS_TEAM_NONE) + return 0; + + return GetTeamScore(expectedSide); +} + +// Get team money sum for a given CS team +stock int GetTeamMoney(int csTeam) +{ + int totalCash = 0; + for (int i = 1; i <= MaxClients; i++) + { + if (!IsValidClient(i)) continue; + if (GetClientTeam(i) != csTeam) continue; + + totalCash += GetEntProp(i, Prop_Send, "m_iAccount"); + } + return totalCash; +} + +// Get the lineup ID for a given player +stock bool GetPlayerLineupId(int client, char[] lineupId, int maxlen) +{ + if (!g_bMatchLoaded || !IsValidClient(client)) + { + lineupId[0] = '\0'; + return false; + } + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + char playerName[MAX_NAME_LENGTH]; + GetClientName(client, playerName, sizeof(playerName)); + + // Search lineup 1 + for (int i = 0; i < g_MatchData.lineup_1.player_count; i++) + { + if (g_MatchData.lineup_1.players[i].steam_id[0] != '\0') + { + if (StrEqual(g_MatchData.lineup_1.players[i].steam_id, steamId)) + { + strcopy(lineupId, maxlen, g_MatchData.lineup_1.players[i].match_lineup_id); + return true; + } + } + else if (g_MatchData.lineup_1.players[i].placeholder_name[0] != '\0') + { + if (strncmp(g_MatchData.lineup_1.players[i].placeholder_name, playerName, + strlen(g_MatchData.lineup_1.players[i].placeholder_name)) == 0) + { + strcopy(lineupId, maxlen, g_MatchData.lineup_1.players[i].match_lineup_id); + return true; + } + } + } + + // Search lineup 2 + for (int i = 0; i < g_MatchData.lineup_2.player_count; i++) + { + if (g_MatchData.lineup_2.players[i].steam_id[0] != '\0') + { + if (StrEqual(g_MatchData.lineup_2.players[i].steam_id, steamId)) + { + strcopy(lineupId, maxlen, g_MatchData.lineup_2.players[i].match_lineup_id); + return true; + } + } + else if (g_MatchData.lineup_2.players[i].placeholder_name[0] != '\0') + { + if (strncmp(g_MatchData.lineup_2.players[i].placeholder_name, playerName, + strlen(g_MatchData.lineup_2.players[i].placeholder_name)) == 0) + { + strcopy(lineupId, maxlen, g_MatchData.lineup_2.players[i].match_lineup_id); + return true; + } + } + } + + lineupId[0] = '\0'; + return false; +} + +// Get lineup tag string (e.g. "[TAG]") for a player +stock bool GetPlayerLineupTag(int client, char[] tag, int maxlen) +{ + char lineupId[GUID_LENGTH]; + if (!GetPlayerLineupId(client, lineupId, sizeof(lineupId))) + { + tag[0] = '\0'; + return false; + } + + if (StrEqual(g_MatchData.lineup_1_id, lineupId) || StrEqual(g_MatchData.lineup_1.id, lineupId)) + { + if (g_MatchData.lineup_1.tag[0] != '\0') + { + FormatEx(tag, maxlen, "[%s]", g_MatchData.lineup_1.tag); + return true; + } + } + else + { + if (g_MatchData.lineup_2.tag[0] != '\0') + { + FormatEx(tag, maxlen, "[%s]", g_MatchData.lineup_2.tag); + return true; + } + } + + tag[0] = '\0'; + return false; +} + +// Count human players on a team +stock int GetTeamPlayerCount(int csTeam) +{ + int count = 0; + for (int i = 1; i <= MaxClients; i++) + { + if (IsValidClient(i) && GetClientTeam(i) == csTeam) + count++; + } + return count; +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/timeout_system.inc b/csgo/addons/sourcemod/scripting/fivestack/timeout_system.inc new file mode 100644 index 0000000..8289d88 --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/timeout_system.inc @@ -0,0 +1,193 @@ +/** + * 5Stack CS:GO Plugin - Timeout System + * Tech pause, tactical timeout, resume logic. + */ + +#if defined _fivestack_timeout_system_included + #endinput +#endif +#define _fivestack_timeout_system_included + +stock void Timeout_RequestPause(int client) +{ + if (!IsLive()) + { + MessageClient(client, "Cannot pause: match is not live."); + return; + } + + if (IsPaused()) + { + MessageClient(client, "Match is already paused."); + return; + } + + if (!g_bMatchLoaded) return; + + // Check permission + bool canPause = false; + eTimeoutSettings setting = TimeoutSettingStringToEnum(g_MatchData.options.tech_timeout_setting); + + if (IsPlayerPrivileged(client)) + { + canPause = true; + } + else + { + switch (setting) + { + case TimeoutSetting_Coach: + canPause = false; // Coach-only — non-coaches go to vote + case TimeoutSetting_CoachAndPlayers: + canPause = true; + case TimeoutSetting_CoachAndCaptains: + canPause = Captain_IsCaptain(client, GetClientTeam(client)); + case TimeoutSetting_Admin: + canPause = false; + } + } + + if (canPause) + { + MessageAll("%N has paused the match.", client); + MatchManager_PauseMatch("Technical pause"); + } + else + { + // Start a vote + Vote_Start("Technical Pause", CS_TEAM_NONE, Vote_PauseSuccess, Vote_PauseFail, true, 30.0); + } +} + +public void Vote_PauseSuccess() +{ + MessageAll("Technical pause vote passed."); + MatchManager_PauseMatch("Technical pause (voted)"); +} + +public void Vote_PauseFail() +{ + MessageAll("Technical pause vote failed."); +} + +stock void Timeout_RequestResume(int client) +{ + if (!IsPaused()) + { + MessageClient(client, "Match is not paused."); + return; + } + + if (IsPlayerPrivileged(client)) + { + MessageAll("%N has resumed the match.", client); + MatchManager_ResumeMatch("Match resumed"); + } + else + { + // Start a vote to resume + Vote_Start("Resume match", CS_TEAM_NONE, Vote_ResumeSuccess, Vote_ResumeFail, true, 30.0); + } +} + +public void Vote_ResumeSuccess() +{ + MessageAll("Resume vote passed."); + MatchManager_ResumeMatch("Match resumed (voted)"); +} + +public void Vote_ResumeFail() +{ + MessageAll("Resume vote failed."); +} + +stock void Timeout_CallTactical(int client) +{ + if (!IsLive()) + { + MessageClient(client, "Cannot call timeout: match is not live."); + return; + } + + if (!g_bMatchLoaded || g_iCurrentMapIndex < 0) return; + + int team = GetClientTeam(client); + + // Check permission + eTimeoutSettings setting = TimeoutSettingStringToEnum(g_MatchData.options.timeout_setting); + bool canTimeout = false; + + if (IsPlayerPrivileged(client)) + { + canTimeout = true; + } + else + { + switch (setting) + { + case TimeoutSetting_Coach: + canTimeout = false; + case TimeoutSetting_CoachAndPlayers: + canTimeout = true; + case TimeoutSetting_CoachAndCaptains: + canTimeout = Captain_IsCaptain(client, team); + case TimeoutSetting_Admin: + canTimeout = false; + } + } + + if (!canTimeout) + { + MessageClient(client, "You don't have permission to call a timeout."); + return; + } + + // Check timeouts available + bool isLineup1 = false; + char lineupId[GUID_LENGTH]; + if (GetPlayerLineupId(client, lineupId, sizeof(lineupId))) + { + isLineup1 = StrEqual(lineupId, g_MatchData.lineup_1_id) || + StrEqual(lineupId, g_MatchData.lineup_1.id); + } + + int available; + if (isLineup1) + available = g_MatchData.match_maps[g_iCurrentMapIndex].lineup_1_timeouts_available; + else + available = g_MatchData.match_maps[g_iCurrentMapIndex].lineup_2_timeouts_available; + + if (available <= 0) + { + MessageClient(client, "Your team has no timeouts remaining."); + return; + } + + // Decrement timeout counter + if (isLineup1) + g_MatchData.match_maps[g_iCurrentMapIndex].lineup_1_timeouts_available--; + else + g_MatchData.match_maps[g_iCurrentMapIndex].lineup_2_timeouts_available--; + + // Call the tactical timeout + if (team == CS_TEAM_T) + SendCommands("timeout_terrorist_start"); + else + SendCommands("timeout_ct_start"); + + MessageAll("%N called a tactical timeout (%d remaining).", client, available - 1); + + // Publish event + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + char data[256]; + FormatEx(data, sizeof(data), "{\"steam_id\":\"%s\"}", steamId); + PublishGameEvent("techTimeout", data); +} + +stock bool Timeout_IsActive() +{ + return (GameRules_GetProp("m_bTerroristTimeOutActive") != 0 || + GameRules_GetProp("m_bCTTimeOutActive") != 0); +} diff --git a/csgo/addons/sourcemod/scripting/fivestack/vote_system.inc b/csgo/addons/sourcemod/scripting/fivestack/vote_system.inc new file mode 100644 index 0000000..b1561ef --- /dev/null +++ b/csgo/addons/sourcemod/scripting/fivestack/vote_system.inc @@ -0,0 +1,236 @@ +/** + * 5Stack CS:GO Plugin - Vote System + * Generic vote framework: team-scoped, timed, callback on pass/fail. + */ + +#if defined _fivestack_vote_system_included + #endinput +#endif +#define _fivestack_vote_system_included + +Function g_fnVoteSuccess = INVALID_FUNCTION; +Function g_fnVoteFail = INVALID_FUNCTION; + +stock void Vote_Start(const char[] message, int allowedTeam, Function successCb, Function failCb, + bool captainOnly = false, float timeout = 30.0) +{ + Vote_Cancel(); // Cancel any existing vote + + strcopy(g_szVoteMessage, sizeof(g_szVoteMessage), message); + g_iVoteAllowedTeam = allowedTeam; + g_bVoteCaptainOnly = captainOnly; + g_fVoteStartTime = GetGameTime(); + g_fVoteTimeout = timeout; + g_bVoteActive = true; + g_fnVoteSuccess = successCb; + g_fnVoteFail = failCb; + + if (g_smVotes != null) + g_smVotes.Clear(); + else + g_smVotes = new StringMap(); + + g_hVoteTimer = CreateTimer(1.0, Timer_VoteDisplay, _, TIMER_REPEAT); + + // Announce + for (int i = 1; i <= MaxClients; i++) + { + if (!IsValidClient(i)) continue; + int team = GetClientTeam(i); + if (g_iVoteAllowedTeam != CS_TEAM_NONE && team != g_iVoteAllowedTeam) continue; + + MessageClient(i, "Vote: %s — Type .y or .n", message); + } +} + +stock void Vote_Cast(int client, bool vote) +{ + if (!g_bVoteActive || !IsValidClient(client)) + return; + + int team = GetClientTeam(client); + if (g_iVoteAllowedTeam != CS_TEAM_NONE && team != g_iVoteAllowedTeam) + { + MessageClient(client, "You are not eligible to vote."); + return; + } + + if (g_bVoteCaptainOnly && !Captain_IsCaptain(client, team)) + { + MessageClient(client, "Only captains can vote."); + return; + } + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + + g_smVotes.SetValue(steamId, vote); + + MessageClient(client, "You voted %s.", vote ? "yes" : "no"); + + Vote_CheckResult(); +} + +stock void Vote_Cancel() +{ + g_bVoteActive = false; + g_fnVoteSuccess = INVALID_FUNCTION; + g_fnVoteFail = INVALID_FUNCTION; + + if (g_hVoteTimer != INVALID_HANDLE) + { + KillTimer(g_hVoteTimer); + g_hVoteTimer = INVALID_HANDLE; + } + + if (g_smVotes != null) + g_smVotes.Clear(); +} + +stock void Vote_CheckResult() +{ + if (!g_bVoteActive) return; + + int yesVotes = 0; + int noVotes = 0; + int totalVotes = 0; + int eligibleCount = 0; + + // Count eligible voters + for (int i = 1; i <= MaxClients; i++) + { + if (!IsValidClient(i)) continue; + int team = GetClientTeam(i); + if (g_iVoteAllowedTeam != CS_TEAM_NONE && team != g_iVoteAllowedTeam) continue; + if (g_bVoteCaptainOnly && !Captain_IsCaptain(i, team)) continue; + eligibleCount++; + } + + // Count votes + StringMapSnapshot snap = g_smVotes.Snapshot(); + totalVotes = snap.Length; + + for (int i = 0; i < snap.Length; i++) + { + char key[MAX_STEAMID_LENGTH]; + snap.GetKey(i, key, sizeof(key)); + + bool vote; + g_smVotes.GetValue(key, vote); + if (vote) yesVotes++; + else noVotes++; + } + delete snap; + + // Check if captain-only vote + if (g_bVoteCaptainOnly) + { + if (totalVotes >= 2) + { + if (yesVotes >= 2) + Vote_Pass(); + else + Vote_Fail(); + } + return; + } + + // Standard vote: need majority + int required = (eligibleCount / 2) + 1; + if (yesVotes >= required) + { + Vote_Pass(); + } + else if (noVotes > eligibleCount - required) + { + Vote_Fail(); + } + else if (totalVotes >= eligibleCount) + { + // Everyone voted + if (yesVotes >= required) + Vote_Pass(); + else + Vote_Fail(); + } +} + +stock void Vote_Pass() +{ + if (!g_bVoteActive) return; + + MessageAll("Vote passed: %s", g_szVoteMessage); + + Function cb = g_fnVoteSuccess; + Vote_Cancel(); + + if (cb != INVALID_FUNCTION) + { + Call_StartFunction(null, cb); + Call_Finish(); + } +} + +stock void Vote_Fail() +{ + if (!g_bVoteActive) return; + + MessageAll("Vote failed: %s", g_szVoteMessage); + + Function cb = g_fnVoteFail; + Vote_Cancel(); + + if (cb != INVALID_FUNCTION) + { + Call_StartFunction(null, cb); + Call_Finish(); + } +} + +public Action Timer_VoteDisplay(Handle timer) +{ + if (!g_bVoteActive) + { + g_hVoteTimer = INVALID_HANDLE; + return Plugin_Stop; + } + + // Check timeout + float elapsed = GetGameTime() - g_fVoteStartTime; + if (elapsed >= g_fVoteTimeout) + { + Vote_Fail(); + return Plugin_Stop; + } + + int remaining = RoundToCeil(g_fVoteTimeout - elapsed); + + for (int i = 1; i <= MaxClients; i++) + { + if (!IsValidClient(i)) continue; + + int team = GetClientTeam(i); + bool eligible = (g_iVoteAllowedTeam == CS_TEAM_NONE || team == g_iVoteAllowedTeam); + + if (!eligible) + { + PrintCenterText(i, "Other team voting: %s (%ds remaining)", g_szVoteMessage, remaining); + continue; + } + + char steamId[MAX_STEAMID_LENGTH]; + GetClientAuthId(i, AuthId_SteamID64, steamId, sizeof(steamId)); + + bool hasVoted; + if (g_smVotes.GetValue(steamId, hasVoted)) + { + PrintCenterText(i, "You voted on: %s (%ds remaining)", g_szVoteMessage, remaining); + } + else + { + PrintCenterText(i, "Vote: %s\nType .y or .n (%ds remaining)", g_szVoteMessage, remaining); + } + } + + return Plugin_Continue; +} diff --git a/csgo/addons/sourcemod/translations/fivestack.phrases.txt b/csgo/addons/sourcemod/translations/fivestack.phrases.txt new file mode 100644 index 0000000..69075d6 --- /dev/null +++ b/csgo/addons/sourcemod/translations/fivestack.phrases.txt @@ -0,0 +1,115 @@ +"Phrases" +{ + "match.received_data" + { + "en" "Match data received" + } + "match.resume_hint" + { + "#format" "{1:s}" + "en" "Type {1}resume in chat to unpause" + } + "match.tv_delay" + { + "#format" "{1:d}" + "en" "Next map in {1} seconds..." + } + "ready.ready" + { + "en" "[ready]" + } + "ready.not_ready" + { + "en" "[not ready]" + } + "ready.marked" + { + "en" "You are now ready." + } + "ready.type_to_ready" + { + "en" "Type .ready in chat when you are ready to play." + } + "ready.waiting_for_players" + { + "#format" "{1:d},{2:d}" + "en" "Players ready: {1}/{2}" + } + "ready.forced_start" + { + "en" "Admin has force-started the match." + } + "knife.start" + { + "en" "Knife round! Winner picks side." + } + "knife.captain_prompt" + { + "en" "Your team won the knife round!\nType .stay or .switch to pick a side\nOr type .ct / .t" + } + "knife.captain_picked_stay" + { + "en" "Captain has chosen to stay on the current side." + } + "knife.captain_picked_swap" + { + "en" "Captain has chosen to switch sides." + } + "knife.not_captain" + { + "en" "Only the captain of the winning team can decide." + } + "knife.skipping" + { + "en" "Knife round has been skipped." + } + "captain.assigned" + { + "#format" "{1:N},{2:s}" + "en" "{1} is now captain of {2}." + } + "timeout.cannot_pause_not_live" + { + "en" "Cannot pause: match is not live." + } + "timeout.not_allowed" + { + "en" "You don't have permission to call a timeout." + } + "timeout.no_timeouts_left" + { + "en" "Your team has no timeouts remaining." + } + "timeout.called_tactical" + { + "#format" "{1:N},{2:d}" + "en" "{1} called a tactical timeout ({2} remaining)." + } + "vote.prompt_options" + { + "#format" "{1:s}" + "en" "Vote: {1} — Type .y or .n" + } + "vote.success" + { + "#format" "{1:s}" + "en" "Vote passed: {1}" + } + "vote.failed" + { + "#format" "{1:s}" + "en" "Vote failed: {1}" + } + "player.join.ready_hint" + { + "en" "Type .ready in chat when you are ready to play." + } + "player.join.help_hint" + { + "en" "Type .help for available commands." + } + "gag.you_are_gagged" + { + "en" "You are gagged in this match." + } +} From 31419ec44a79813fc84f89d8a10d4651888e27fb Mon Sep 17 00:00:00 2001 From: Flegma Date: Sat, 7 Mar 2026 00:03:53 +0100 Subject: [PATCH 2/2] fix: address code review issues in CS:GO SourceMod plugin - Fix GetPlayerLineupId returning member ID instead of lineup ID - JSON-escape player names in player-connected event payload - Add Demo_Stop() before upload in game end flow - Use correct CS:GO event name inferno_startburn instead of molotov_detonate - Infer kill hitgroup from headshot flag instead of hardcoding to Head - Cache HTTPClient for ping timer to prevent handle leak - Guard GetClientAuthId in disconnect handler with IsClientConnected fallback --- csgo/addons/sourcemod/scripting/fivestack.sp | 2 +- .../scripting/fivestack/events/game_end.inc | 7 +++++-- .../fivestack/events/player_connected.inc | 17 +++++++++++++++-- .../fivestack/events/player_disconnected.inc | 7 ++++++- .../scripting/fivestack/events/player_kills.inc | 7 +++++-- .../fivestack/events/player_utility.inc | 2 +- .../scripting/fivestack/game_server.inc | 6 ++++-- .../sourcemod/scripting/fivestack/globals.inc | 1 + .../scripting/fivestack/team_utility.inc | 8 ++++---- 9 files changed, 42 insertions(+), 15 deletions(-) diff --git a/csgo/addons/sourcemod/scripting/fivestack.sp b/csgo/addons/sourcemod/scripting/fivestack.sp index 29b5971..88bd994 100644 --- a/csgo/addons/sourcemod/scripting/fivestack.sp +++ b/csgo/addons/sourcemod/scripting/fivestack.sp @@ -144,7 +144,7 @@ public void OnPluginStart() HookEvent("decoy_detonate", Event_DecoyDetonate); HookEvent("hegrenade_detonate", Event_HEGrenadeDetonate); HookEvent("flashbang_detonate", Event_FlashbangDetonate); - HookEvent("molotov_detonate", Event_MolotovDetonate); + HookEvent("inferno_startburn", Event_InfernoStartBurn); HookEvent("smokegrenade_detonate", Event_SmokeDetonate); HookEvent("player_blind", Event_PlayerBlind); diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/game_end.inc b/csgo/addons/sourcemod/scripting/fivestack/events/game_end.inc index 23f883e..8601436 100644 --- a/csgo/addons/sourcemod/scripting/fivestack/events/game_end.inc +++ b/csgo/addons/sourcemod/scripting/fivestack/events/game_end.inc @@ -32,12 +32,15 @@ public Action Event_CSWinPanelMatch(Event event, const char[] name, bool dontBro // Publish final scores PublishRoundInformation(); - // Stop demo and transition status + // Stop demo recording before upload + Demo_Stop(); + + // Transition status char winningId[GUID_LENGTH]; GetWinningLineupId(winningId, sizeof(winningId)); MatchManager_UpdateMapStatus(MapStatus_UploadingDemo, winningId); - // Upload demo after a delay (wait for tv_delay) + // Upload demo after a delay (wait for tv_delay to flush) CreateTimer(15.0, Timer_UploadDemo); return Plugin_Continue; diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_connected.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_connected.inc index 5821295..56ae29a 100644 --- a/csgo/addons/sourcemod/scripting/fivestack/events/player_connected.inc +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_connected.inc @@ -19,11 +19,24 @@ public Action Event_PlayerConnectFull(Event event, const char[] name, bool dontB char playerName[MAX_NAME_LENGTH]; GetClientName(client, playerName, sizeof(playerName)); + // Escape quotes in player name for JSON + char escapedName[MAX_NAME_LENGTH * 2]; + int j = 0; + for (int i = 0; playerName[i] != '\0' && j < sizeof(escapedName) - 2; i++) + { + if (playerName[i] == '"' || playerName[i] == '\\') + { + escapedName[j++] = '\\'; + } + escapedName[j++] = playerName[i]; + } + escapedName[j] = '\0'; + // Publish player connected event - char data[256]; + char data[512]; FormatEx(data, sizeof(data), "{\"player_name\":\"%s\",\"steam_id\":\"%s\"}", - playerName, steamId); + escapedName, steamId); PublishGameEvent("player-connected", data); // Enforce team assignment diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_disconnected.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_disconnected.inc index 075fa32..066a770 100644 --- a/csgo/addons/sourcemod/scripting/fivestack/events/player_disconnected.inc +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_disconnected.inc @@ -14,8 +14,13 @@ public Action Event_PlayerDisconnect(Event event, const char[] name, bool dontBr if (client <= 0 || client > MaxClients) return Plugin_Continue; if (IsFakeClient(client)) return Plugin_Continue; + // Use GetClientAuthId with IsClientConnected check — client may be partially disconnected char steamId[MAX_STEAMID_LENGTH]; - GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId)); + if (!IsClientConnected(client) || !GetClientAuthId(client, AuthId_SteamID64, steamId, sizeof(steamId))) + { + // Fallback: get networkid from event (SteamID2 format) + event.GetString("networkid", steamId, sizeof(steamId)); + } // Publish disconnect event char data[128]; diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_kills.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_kills.inc index cdaae2f..da45410 100644 --- a/csgo/addons/sourcemod/scripting/fivestack/events/player_kills.inc +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_kills.inc @@ -49,9 +49,12 @@ public Action Event_PlayerDeath(Event event, const char[] name, bool dontBroadca bool headshot = event.GetBool("headshot"); bool penetrated = event.GetInt("penetrated") > 0; - int hitgroup = 1; // default head for kills + // player_death doesn't carry hitgroup — infer from headshot flag char hitgroupStr[32]; - HitGroupToString(hitgroup, hitgroupStr, sizeof(hitgroupStr)); + if (headshot) + strcopy(hitgroupStr, sizeof(hitgroupStr), "Head"); + else + strcopy(hitgroupStr, sizeof(hitgroupStr), "Generic"); // Build kill event char data[2048]; diff --git a/csgo/addons/sourcemod/scripting/fivestack/events/player_utility.inc b/csgo/addons/sourcemod/scripting/fivestack/events/player_utility.inc index 31e70d7..7aca563 100644 --- a/csgo/addons/sourcemod/scripting/fivestack/events/player_utility.inc +++ b/csgo/addons/sourcemod/scripting/fivestack/events/player_utility.inc @@ -23,7 +23,7 @@ public Action Event_FlashbangDetonate(Event event, const char[] name, bool dontB return PublishUtilityEvent(event, "Flash"); } -public Action Event_MolotovDetonate(Event event, const char[] name, bool dontBroadcast) +public Action Event_InfernoStartBurn(Event event, const char[] name, bool dontBroadcast) { return PublishUtilityEvent(event, "Molotov"); } diff --git a/csgo/addons/sourcemod/scripting/fivestack/game_server.inc b/csgo/addons/sourcemod/scripting/fivestack/game_server.inc index e984a4e..eda6cff 100644 --- a/csgo/addons/sourcemod/scripting/fivestack/game_server.inc +++ b/csgo/addons/sourcemod/scripting/fivestack/game_server.inc @@ -84,8 +84,10 @@ public Action Timer_Ping(Handle timer) "game-server-node/ping/%s?map=%s&pluginVersion=%s", g_szServerId, mapName, FIVESTACK_VERSION); - HTTPClient client = CreateApiClient(); - client.Get(endpoint, OnPingResponse); + if (g_hPingClient == null) + g_hPingClient = CreateApiClient(); + + g_hPingClient.Get(endpoint, OnPingResponse); return Plugin_Continue; } diff --git a/csgo/addons/sourcemod/scripting/fivestack/globals.inc b/csgo/addons/sourcemod/scripting/fivestack/globals.inc index 2988e2c..63edc73 100644 --- a/csgo/addons/sourcemod/scripting/fivestack/globals.inc +++ b/csgo/addons/sourcemod/scripting/fivestack/globals.inc @@ -55,6 +55,7 @@ Handle g_hEventRetryTimer = INVALID_HANDLE; // Ping timer Handle g_hPingTimer = INVALID_HANDLE; +HTTPClient g_hPingClient = null; // Map change Handle g_hMapChangeTimer = INVALID_HANDLE; diff --git a/csgo/addons/sourcemod/scripting/fivestack/team_utility.inc b/csgo/addons/sourcemod/scripting/fivestack/team_utility.inc index 023f54a..3ada029 100644 --- a/csgo/addons/sourcemod/scripting/fivestack/team_utility.inc +++ b/csgo/addons/sourcemod/scripting/fivestack/team_utility.inc @@ -123,7 +123,7 @@ stock bool GetPlayerLineupId(int client, char[] lineupId, int maxlen) { if (StrEqual(g_MatchData.lineup_1.players[i].steam_id, steamId)) { - strcopy(lineupId, maxlen, g_MatchData.lineup_1.players[i].match_lineup_id); + strcopy(lineupId, maxlen, g_MatchData.lineup_1_id); return true; } } @@ -132,7 +132,7 @@ stock bool GetPlayerLineupId(int client, char[] lineupId, int maxlen) if (strncmp(g_MatchData.lineup_1.players[i].placeholder_name, playerName, strlen(g_MatchData.lineup_1.players[i].placeholder_name)) == 0) { - strcopy(lineupId, maxlen, g_MatchData.lineup_1.players[i].match_lineup_id); + strcopy(lineupId, maxlen, g_MatchData.lineup_1_id); return true; } } @@ -145,7 +145,7 @@ stock bool GetPlayerLineupId(int client, char[] lineupId, int maxlen) { if (StrEqual(g_MatchData.lineup_2.players[i].steam_id, steamId)) { - strcopy(lineupId, maxlen, g_MatchData.lineup_2.players[i].match_lineup_id); + strcopy(lineupId, maxlen, g_MatchData.lineup_2_id); return true; } } @@ -154,7 +154,7 @@ stock bool GetPlayerLineupId(int client, char[] lineupId, int maxlen) if (strncmp(g_MatchData.lineup_2.players[i].placeholder_name, playerName, strlen(g_MatchData.lineup_2.players[i].placeholder_name)) == 0) { - strcopy(lineupId, maxlen, g_MatchData.lineup_2.players[i].match_lineup_id); + strcopy(lineupId, maxlen, g_MatchData.lineup_2_id); return true; } }