diff --git a/Couch/scripting/Couch.sp b/Couch/scripting/Couch.sp index 9848fed..ce322d9 100644 --- a/Couch/scripting/Couch.sp +++ b/Couch/scripting/Couch.sp @@ -880,10 +880,15 @@ void BreakCouch(int couch, int attacker) EmitSoundToAll(g_sExplodeSounds[GetRandomInt(0, sizeof(g_sExplodeSounds) - 1)], SOUND_FROM_PLAYER, SNDCHAN_STATIC, SNDLEVEL_NONE); EmitSoundToAll(g_sBreakSounds[GetRandomInt(0, sizeof(g_sBreakSounds) - 1)], couch, SNDCHAN_STATIC, SNDLEVEL_SCREAMING); - if (attacker >= 1 && attacker <= MaxClients && IsClientInGame(attacker)) - PrintToChatAll(PREFIX ... "%N\x01 broke the couch! Game over!", attacker); - else - PrintToChatAll(PREFIX ... "The couch\x01 broke! Game over!"); + // Printed three times so it stands out in a busy chat. + bool bNamed = attacker >= 1 && attacker <= MaxClients && IsClientInGame(attacker); + for (int i = 0; i < 3; i++) + { + if (bNamed) + PrintToChatAll(PREFIX ... "%N\x01 broke the couch! Game over!", attacker); + else + PrintToChatAll(PREFIX ... "The couch\x01 broke! Game over!"); + } // While the entity still exists, so listeners can read its position. Call_StartForward(g_fwdOnCouchBroken); diff --git a/EventNotifier/scripting/EventNotifier.sp b/EventNotifier/scripting/EventNotifier.sp index 197d210..3884092 100644 --- a/EventNotifier/scripting/EventNotifier.sp +++ b/EventNotifier/scripting/EventNotifier.sp @@ -1,6 +1,7 @@ #include #include #include +#include #define EVENTNOTIFIER_CORE #include @@ -8,7 +9,7 @@ #pragma semicolon 1 #pragma newdecls required -#define PLUGIN_VERSION "1.1" +#define PLUGIN_VERSION "2.0" #define TIMEZONE_FILE "configs/eventnotifier_timezones.cfg" #define TIMEZONE_NAME_LEN 64 @@ -40,7 +41,7 @@ public Plugin myinfo = { name = "EventNotifier", author = "marisakirisame.net", - description = "Fetches upcoming Unloze events and exposes them to other plugins", + description = "Fetches upcoming Unloze events, shows them via sm_event and announces them in chat", version = PLUGIN_VERSION, url = "https://marisakirisame.net" }; @@ -75,6 +76,9 @@ public void OnPluginStart() g_cvServer.AddChangeHook(OnSourceChanged); g_cvInterval.AddChangeHook(OnIntervalChanged); + Menu_OnPluginStart(); + Announce_OnPluginStart(); + AutoExecConfig(true, "EventNotifier"); RegAdminCmd("sm_event_refresh", Command_EventRefresh, ADMFLAG_GENERIC, "Re-fetches the event list now"); @@ -108,6 +112,7 @@ public void OnClientDisconnect(int client) public void OnConfigsExecuted() { RestartRefreshTimer(); + RestartAnnounceTimer(); FetchEvents(); } @@ -134,37 +139,58 @@ public Action Timer_Refresh(Handle timer) } // --------------------------------------------------------------------------- -// Natives +// Public API +// +// The natives stay registered for other plugins. The menu and announcement +// modules compiled into this plugin call these functions instead: with +// EVENTNOTIFIER_CORE defined the include declares no natives, so the API +// names resolve to the plain functions below. // --------------------------------------------------------------------------- -public any Native_GetEventCount(Handle plugin, int numParams) +int EventNotifier_GetEventCount() { return g_Events.Length; } -public any Native_HasFetched(Handle plugin, int numParams) +bool EventNotifier_HasFetched() { return g_bEverFetched; } +bool EventNotifier_GetEvent(int index, EventInfo ev) +{ + if (index < 0 || index >= g_Events.Length) + return false; + + g_Events.GetArray(index, ev); + return true; +} + +public any Native_GetEventCount(Handle plugin, int numParams) +{ + return EventNotifier_GetEventCount(); +} + +public any Native_HasFetched(Handle plugin, int numParams) +{ + return EventNotifier_HasFetched(); +} + public any Native_FindEventByNumber(Handle plugin, int numParams) { - return FindEventByNumber(GetNativeCell(1)); + return EventNotifier_FindEventByNumber(GetNativeCell(1)); } public any Native_GetEvent(Handle plugin, int numParams) { - int index = GetNativeCell(1); int size = GetNativeCell(3); - if (size < sizeof(EventInfo)) return ThrowNativeError(SP_ERROR_NATIVE, "EventInfo buffer too small (%d < %d), recompile against the current EventNotifier.inc", size, sizeof(EventInfo)); - if (index < 0 || index >= g_Events.Length) + EventInfo ev; + if (!EventNotifier_GetEvent(GetNativeCell(1), ev)) return false; - EventInfo ev; - g_Events.GetArray(index, ev); SetNativeArray(2, ev, sizeof(EventInfo)); return true; } @@ -183,7 +209,7 @@ public any Native_FormatClientTime(Handle plugin, int numParams) int maxlen = GetNativeCell(4); char[] buffer = new char[maxlen]; - FormatClientTime(client, GetNativeCell(2), buffer, maxlen, GetNativeCell(5)); + EventNotifier_FormatClientTime(client, GetNativeCell(2), buffer, maxlen, GetNativeCell(5)); SetNativeString(3, buffer, maxlen); return 0; } @@ -307,6 +333,7 @@ void ParseEvent(JSONObject obj, EventInfo ev) if (!ev.name[0]) GetJsonString(obj, "title", ev.name, sizeof(ev.name)); + GetJsonString(obj, "url", ev.url, sizeof(ev.url)); GetJsonString(obj, "server", ev.server, sizeof(ev.server)); GetJsonString(obj, "rewards", ev.rewards, sizeof(ev.rewards)); GetJsonString(obj, "leader", ev.leader, sizeof(ev.leader)); @@ -364,7 +391,7 @@ public Action Command_EventRefresh(int client, int args) return Plugin_Handled; } -int FindEventByNumber(int number) +int EventNotifier_FindEventByNumber(int number) { int count = g_Events.Length; for (int i = 0; i < count; i++) @@ -475,7 +502,7 @@ void GetClientZonePeriod(int client, int stamp, TzPeriod out) // Formats stamp in the client's timezone as "Thu 01 Oct 2026, 20:00 BST" // (or without the year). FormatTime can't be used, it only knows the server's zone. -void FormatClientTime(int client, int stamp, char[] buffer, int maxlen, bool withYear) +void EventNotifier_FormatClientTime(int client, int stamp, char[] buffer, int maxlen, bool withYear = true) { static const char weekdays[][] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; static const char months[][] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; @@ -585,3 +612,10 @@ void CivilFromDays(int z, int &y, int &m, int &d) m = (mp < 10) ? mp + 3 : mp - 9; y = yoe + era * 400 + ((m <= 2) ? 1 : 0); } + +// --------------------------------------------------------------------------- +// Modules +// --------------------------------------------------------------------------- + +#include "EventNotifier/menu.sp" +#include "EventNotifier/announcements.sp" diff --git a/EventNotifier/scripting/EventNotifierAnnouncements.sp b/EventNotifier/scripting/EventNotifier/announcements.sp similarity index 85% rename from EventNotifier/scripting/EventNotifierAnnouncements.sp rename to EventNotifier/scripting/EventNotifier/announcements.sp index f6f809d..ce84bdc 100644 --- a/EventNotifier/scripting/EventNotifierAnnouncements.sp +++ b/EventNotifier/scripting/EventNotifier/announcements.sp @@ -1,12 +1,3 @@ -#include -#include -#include - -#pragma semicolon 1 -#pragma newdecls required - -#define PLUGIN_VERSION "1.1" - // Shortest automatic announcement interval, in seconds. Values below this are // raised to it; 0 or below disables the timer entirely. #define MIN_ANNOUNCE_INTERVAL 60.0 @@ -16,41 +7,25 @@ // Visible characters allowed for the map list before " +N more" is appended. #define ANNOUNCE_MAPS_BUDGET 120 -ConVar g_cvInterval; -ConVar g_cvCount; +ConVar g_cvAnnounceInterval; +ConVar g_cvAnnounceCount; Handle g_hAnnounceTimer; int g_iRotation; -public Plugin myinfo = +void Announce_OnPluginStart() { - name = "EventNotifier Announcements", - author = "marisakirisame.net", - description = "Periodically announces upcoming Unloze events in chat", - version = PLUGIN_VERSION, - url = "https://marisakirisame.net" -}; - -public void OnPluginStart() -{ - g_cvInterval = CreateConVar("sm_event_announce_interval", "120", + g_cvAnnounceInterval = CreateConVar("sm_event_announce_interval", "120", "Seconds between event announcements in chat, -1 to disable automatic announcements (sm_event_announce still works)", _, true, -1.0); - g_cvCount = CreateConVar("sm_event_announce_count", "3", + g_cvAnnounceCount = CreateConVar("sm_event_announce_count", "3", "Rotate announcements through the next N upcoming events, one per announcement", _, true, 1.0, true, 20.0); - g_cvInterval.AddChangeHook(OnIntervalChanged); - - AutoExecConfig(true, "EventNotifierAnnouncements"); + g_cvAnnounceInterval.AddChangeHook(OnAnnounceIntervalChanged); RegAdminCmd("sm_event_announce", Command_Announce, ADMFLAG_GENERIC, "sm_event_announce [event number] - Posts an event announcement in chat now (next in rotation if no number given)"); } -public void OnConfigsExecuted() -{ - RestartAnnounceTimer(); -} - -public void OnIntervalChanged(ConVar convar, const char[] oldValue, const char[] newValue) +public void OnAnnounceIntervalChanged(ConVar convar, const char[] oldValue, const char[] newValue) { RestartAnnounceTimer(); } @@ -59,7 +34,7 @@ void RestartAnnounceTimer() { delete g_hAnnounceTimer; - float interval = g_cvInterval.FloatValue; + float interval = g_cvAnnounceInterval.FloatValue; if (interval <= 0.0) return; @@ -124,7 +99,7 @@ bool PostAnnouncement() if (count <= 0) return false; - int window = g_cvCount.IntValue; + int window = g_cvAnnounceCount.IntValue; if (window > count) window = count; diff --git a/EventNotifier/scripting/EventNotifierMenu.sp b/EventNotifier/scripting/EventNotifier/menu.sp similarity index 85% rename from EventNotifier/scripting/EventNotifierMenu.sp rename to EventNotifier/scripting/EventNotifier/menu.sp index de601a9..c1e86dc 100644 --- a/EventNotifier/scripting/EventNotifierMenu.sp +++ b/EventNotifier/scripting/EventNotifier/menu.sp @@ -1,27 +1,15 @@ -#include -#include - -#pragma semicolon 1 -#pragma newdecls required - -#define PLUGIN_VERSION "1.2" #define CHAT_PREFIX "\x04[Events]\x01" #define PANEL_LINE_WIDTH 60 +#define KEY_FORUM 1 #define KEY_BACK 8 #define KEY_EXIT 10 -public Plugin myinfo = -{ - name = "EventNotifier Menu", - author = "marisakirisame.net", - description = "Shows upcoming Unloze events in-game via sm_event", - version = PLUGIN_VERSION, - url = "https://marisakirisame.net" -}; +// Event number shown on each client's details panel, for EventDetails_Handler. +static int g_iDetailsNumber[MAXPLAYERS + 1]; -public void OnPluginStart() +void Menu_OnPluginStart() { RegConsoleCmd("sm_event", Command_EventMenu, "Shows upcoming Unloze events. Usage: sm_event [event number]"); RegConsoleCmd("sm_events", Command_EventMenu, "Shows upcoming Unloze events. Usage: sm_events [event number]"); @@ -174,21 +162,46 @@ void ShowEventDetails(int client, int index) panel.DrawText(line); panel.DrawText(" "); + if (ev.url[0]) + { + panel.CurrentKey = KEY_FORUM; + panel.DrawItem("View forum post"); + } panel.CurrentKey = KEY_BACK; panel.DrawItem("Back"); panel.CurrentKey = KEY_EXIT; panel.DrawItem("Exit"); + g_iDetailsNumber[client] = ev.number; panel.Send(client, EventDetails_Handler, MENU_TIME_FOREVER); delete panel; } public int EventDetails_Handler(Menu menu, MenuAction action, int param1, int param2) { - if (action == MenuAction_Select && param2 == KEY_BACK) + if (action != MenuAction_Select || !IsClientInGame(param1)) + return 0; + + if (param2 == KEY_BACK) { - if (IsClientInGame(param1)) - Command_EventMenu(param1, 0); + Command_EventMenu(param1, 0); + } + else if (param2 == KEY_FORUM) + { + int index = EventNotifier_FindEventByNumber(g_iDetailsNumber[param1]); + + EventInfo ev; + if (index == -1 || !EventNotifier_GetEvent(index, ev) || !ev.url[0]) + { + PrintToChat(param1, "%s That event is no longer in the upcoming list.", CHAT_PREFIX); + return 0; + } + + ShowMOTDPanel(param1, ev.name, ev.url, MOTDPANEL_TYPE_URL); + + // Keep the details panel underneath so Back/Exit still work after + // the MOTD window is closed. + ShowEventDetails(param1, index); } return 0; } diff --git a/EventNotifier/scripting/include/EventNotifier.inc b/EventNotifier/scripting/include/EventNotifier.inc index 4f04929..36ac238 100644 --- a/EventNotifier/scripting/include/EventNotifier.inc +++ b/EventNotifier/scripting/include/EventNotifier.inc @@ -5,6 +5,7 @@ #define _EventNotifier_included #define EVENT_STR_LEN 128 +#define EVENT_URL_LEN 192 #define EVENT_MAPS_LEN 1024 enum struct EventInfo @@ -12,6 +13,7 @@ enum struct EventInfo int number; int start; // unix timestamp (UTC), 0 if unknown int end; // unix timestamp (UTC), 0 if unknown + char url[EVENT_URL_LEN]; // forum thread URL, empty if unknown char name[EVENT_STR_LEN]; char server[EVENT_STR_LEN]; char rewards[EVENT_STR_LEN]; diff --git a/LagCompensation/scripting/LagCompensation_nide_edit-old.sp b/LagCompensation/scripting/LagCompensation_nide_edit-old.sp new file mode 100644 index 0000000..43973b5 --- /dev/null +++ b/LagCompensation/scripting/LagCompensation_nide_edit-old.sp @@ -0,0 +1,1592 @@ +#include +#include +#include +#include +#include +#include +#include + +#define SetBit(%1,%2) ((%1)[(%2) >> 5] |= (1 << ((%2) & 31))) +#define ClearBit(%1,%2) ((%1)[(%2) >> 5] &= ~(1 << ((%2) & 31))) +#define CheckBit(%1,%2) !!((%1)[(%2) >> 5] & (1 << ((%2) & 31))) + +#pragma semicolon 1 +#pragma newdecls required + +public Plugin myinfo = +{ + name = "LagCompensation", + author = "BotoX, Vauff, .Rushaway, maxime1907", + description = "", + version = "1.1.0", + url = "" +}; + +bool g_bLateLoad = false; +bool g_bHasPhysHooks = true; +bool g_bHasOnEntitySpawned = false; +bool g_bCheckPing = false; +int g_iCompenseEntityClass; + +// Don't change this. +#define MAX_EDICTS 2048 +#define FSOLID_FORCE_WORLD_ALIGNED 0x0040 +#define FSOLID_ROOT_PARENT_ALIGNED 0x0100 +#define EFL_DIRTY_ABSTRANSFORM (1<<11) +#define EFL_DIRTY_SURROUNDING_COLLISION_BOUNDS (1<<14) +#define EFL_CHECK_UNTOUCH (1<<24) +#define COORDINATE_FRAME_SIZE 14 + +enum +{ + USE_OBB_COLLISION_BOUNDS = 0, + USE_BEST_COLLISION_BOUNDS, + USE_HITBOXES, + USE_SPECIFIED_BOUNDS, + USE_GAME_CODE, + USE_ROTATION_EXPANDED_BOUNDS, + USE_COLLISION_BOUNDS_NEVER_VPHYSICS, +} + +enum +{ + SOLID_NONE = 0, // no solid model + SOLID_BSP = 1, // a BSP tree + SOLID_BBOX = 2, // an AABB + SOLID_OBB = 3, // an OBB (not implemented yet) + SOLID_OBB_YAW = 4, // an OBB, constrained so that it can only yaw + SOLID_CUSTOM = 5, // Always call into the entity for tests + SOLID_VPHYSICS = 6, // solid vphysics object, get vcollide from the model and collide with that + SOLID_LAST, +}; + +enum +{ + SF_TRIGGER_ALLOW_CLIENTS = 0x01, // Players can fire this trigger + SF_TRIGGER_ALLOW_NPCS = 0x02, // NPCS can fire this trigger + SF_TRIGGER_ALLOW_PUSHABLES = 0x04, // Pushables can fire this trigger + SF_TRIGGER_ALLOW_PHYSICS = 0x08, // Physics objects can fire this trigger + SF_TRIGGER_ONLY_PLAYER_ALLY_NPCS = 0x10, // *if* NPCs can fire this trigger, this flag means only player allies do so + SF_TRIGGER_ONLY_CLIENTS_IN_VEHICLES = 0x20, // *if* Players can fire this trigger, this flag means only players inside vehicles can + SF_TRIGGER_ALLOW_ALL = 0x40, // Everything can fire this trigger EXCEPT DEBRIS! + SF_TRIGGER_ONLY_CLIENTS_OUT_OF_VEHICLES = 0x200, // *if* Players can fire this trigger, this flag means only players outside vehicles can + SF_TRIG_PUSH_ONCE = 0x80, // trigger_push removes itself after firing once + SF_TRIG_PUSH_AFFECT_PLAYER_ON_LADDER = 0x100, // if pushed object is player on a ladder, then this disengages them from the ladder (HL2only) + SF_TRIG_TOUCH_DEBRIS = 0x400, // Will touch physics debris objects + SF_TRIGGER_ONLY_NPCS_IN_VEHICLES = 0x800, // *if* NPCs can fire this trigger, only NPCs in vehicles do so (respects player ally flag too) +}; + +#define SF_LAGCOMP_DISABLE (1 << 30) + +#define MAX_RECORDS 32 +#define MAX_ENTITIES 256 + +enum struct LagRecord +{ + float vecOrigin[3]; + float vecAbsOrigin[3]; + float angRotation[3]; + float angAbsRotation[3]; + float vecMins[3]; + float vecMaxs[3]; + float flSimulationTime; + float rgflCoordinateFrame[COORDINATE_FRAME_SIZE]; +} + +enum struct EntityLagData +{ + int iEntity; + int iRecordIndex; + int iNumRecords; + int iRecordsValid; + int iSpawned; + int iDeleted; + int iNotMoving; + bool bRestore; + bool bLateKill; + char sClassname[64]; +} + +LagRecord g_aaLagRecords[MAX_ENTITIES][MAX_RECORDS]; +EntityLagData g_aEntityLagData[MAX_ENTITIES]; +int g_iNumEntities = 0; +bool g_bCleaningUp = true; + +// Cache +int g_iGameTick; +float g_fTickInterval; +int g_aLerpTicks[MAXPLAYERS + 1]; + +// SDKCall +Handle g_hCalcAbsolutePosition; +Handle g_hMarkPartitionHandleDirty; + +// DHooks Detour +Handle g_hUTIL_Remove; +Handle g_hRestartRound; +Handle g_hSetTarget; +Handle g_hSetTargetPost; +Handle g_hFrameUpdatePostEntityThink; + +// DHooks Virtual +Handle g_hActivate; +Handle g_hAcceptInput; +Handle g_hEndGameFrame; + +#define PREFIX "{green}[LagCompensation]{default}" + +int g_iNetworkableOuter; +int g_iParent; +int g_iSpawnFlags; +int g_iCollision; +int g_iSolidFlags; +int g_iSolidType; +int g_iSurroundType; +int g_iEFlags; + +int g_iVecOrigin; +int g_iVecAbsOrigin; +int g_iAngRotation; +int g_iAngAbsRotation; +int g_iVecMins; +int g_iVecMaxs; +int g_iSimulationTime; +int g_iCoordinateFrame; +int g_iMinPing; + +int g_aLagCompensated[MAX_EDICTS] = {-1, ...}; +int g_aBlockTriggerTouchPlayers[MAX_EDICTS / 32]; +int g_aaFilterClientSolidTouch[((MAXPLAYERS + 1) * MAX_EDICTS) / 32]; +int g_aBlockTriggerMoved[MAX_EDICTS / 32]; +int g_aBlacklisted[MAX_EDICTS / 32]; + +Handle g_hCookie_LagCompSettings; +bool g_bDisableLagComp[MAXPLAYERS + 1]; +bool g_bPlayerLaserCompensated[MAXPLAYERS + 1]; +bool g_bLagCompMessages[MAXPLAYERS + 1]; +int g_iDisableLagComp[MAXPLAYERS + 1]; +ConVar g_cvCompenseEntityClass; +ConVar g_cvCheckUserPing; +ConVar g_cvMinimumPing; + +public void OnPluginStart() +{ + LoadTranslations("common.phrases"); + + g_cvCompenseEntityClass = CreateConVar("sm_lagcomp_entityclass", "0", "Only lagcompensate: [0 = All | 1 = func_physbox only]", _, true, 0.0, true, 1.0); + g_cvCheckUserPing = CreateConVar("sm_lagcomp_checkuserping", "0", "Check user's ping before lagcompensating them [0 = Disabled | 1 = Enabled]", _, true, 0.0, true, 1.0); + g_cvMinimumPing = CreateConVar("sm_lagcomp_ping", "180", "Minimum ping threshold to apply lagcomp on a client", _, true, 0.0); + + AutoExecConfig(true); + + Handle hGameData = LoadGameConfigFile("LagCompensation.games"); + if(!hGameData) + SetFailState("Failed to load LagCompensation gamedata."); + + // CBaseEntity::CalcAbsolutePosition + StartPrepSDKCall(SDKCall_Entity); + if(!PrepSDKCall_SetFromConf(hGameData, SDKConf_Signature, "CalcAbsolutePosition")) + { + delete hGameData; + SetFailState("PrepSDKCall_SetFromConf(hGameData, SDKConf_Signature, \"CalcAbsolutePosition\") failed!"); + } + g_hCalcAbsolutePosition = EndPrepSDKCall(); + + // CCollisionProperty::MarkPartitionHandleDirty + StartPrepSDKCall(SDKCall_Raw); + if(!PrepSDKCall_SetFromConf(hGameData, SDKConf_Signature, "MarkPartitionHandleDirty")) + { + delete hGameData; + SetFailState("PrepSDKCall_SetFromConf(hGameData, SDKConf_Signature, \"MarkPartitionHandleDirty\") failed!"); + } + g_hMarkPartitionHandleDirty = EndPrepSDKCall(); + + + // ::UTIL_Remove + g_hUTIL_Remove = DHookCreateFromConf(hGameData, "UTIL_Remove"); + if(!g_hUTIL_Remove) + { + delete hGameData; + SetFailState("Failed to setup detour for UTIL_Remove"); + } + + if(!DHookEnableDetour(g_hUTIL_Remove, false, Detour_OnUTIL_Remove)) + { + delete hGameData; + SetFailState("Failed to detour UTIL_Remove."); + } + + // CCSGameRules::RestartRound + g_hRestartRound = DHookCreateFromConf(hGameData, "CCSGameRules__RestartRound"); + if(!g_hRestartRound) + { + delete hGameData; + SetFailState("Failed to setup detour for CCSGameRules__RestartRound"); + } + + if(!DHookEnableDetour(g_hRestartRound, false, Detour_OnRestartRound)) + { + delete hGameData; + SetFailState("Failed to detour CCSGameRules__RestartRound."); + } + + // CLogicMeasureMovement::SetTarget + g_hSetTarget = DHookCreateFromConf(hGameData, "CLogicMeasureMovement__SetTarget"); + if(!g_hSetTarget) + { + delete hGameData; + SetFailState("Failed to setup detour for CLogicMeasureMovement__SetTarget"); + } + + if(!DHookEnableDetour(g_hSetTarget, false, Detour_OnSetTargetPre)) + { + delete hGameData; + SetFailState("Failed to detour CLogicMeasureMovement__SetTarget."); + } + + // CLogicMeasureMovement::SetTarget (fix post hook crashing due to this pointer being overwritten) + g_hSetTargetPost = DHookCreateFromConf(hGameData, "CLogicMeasureMovement__SetTarget_post"); + if(!g_hSetTargetPost) + { + delete hGameData; + SetFailState("Failed to setup detour for CLogicMeasureMovement__SetTarget_post"); + } + + if(!DHookEnableDetour(g_hSetTargetPost, true, Detour_OnSetTargetPost)) + { + delete hGameData; + SetFailState("Failed to detour CLogicMeasureMovement__SetTarget_post."); + } + + // CEntityTouchManager::FrameUpdatePostEntityThink + g_hFrameUpdatePostEntityThink = DHookCreateFromConf(hGameData, "CEntityTouchManager__FrameUpdatePostEntityThink"); + if(!g_hFrameUpdatePostEntityThink) + { + delete hGameData; + SetFailState("Failed to setup detour for CEntityTouchManager__FrameUpdatePostEntityThink"); + } + + if(!DHookEnableDetour(g_hFrameUpdatePostEntityThink, false, Detour_OnFrameUpdatePostEntityThink)) + { + delete hGameData; + SetFailState("Failed to detour CEntityTouchManager__FrameUpdatePostEntityThink."); + } + + + g_iNetworkableOuter = GameConfGetOffset(hGameData, "CServerNetworkableProperty::m_pOuter"); + if(g_iNetworkableOuter == -1) + { + delete hGameData; + SetFailState("GameConfGetOffset(hGameData, \"CServerNetworkableProperty::m_pOuter\") failed!"); + } + + + int offset = GameConfGetOffset(hGameData, "CGameRules::EndGameFrame"); + if(offset == -1) + { + delete hGameData; + SetFailState("Failed to find CGameRules::EndGameFrame offset."); + } + + // CGameRules::EndGameFrame + g_hEndGameFrame = DHookCreate(offset, HookType_GameRules, ReturnType_Void, ThisPointer_Ignore, Hook_EndGameFrame); + if(g_hEndGameFrame == INVALID_HANDLE) + { + delete hGameData; + SetFailState("Failed to DHook CGameRules::EndGameFrame."); + } + delete hGameData; + + + hGameData = LoadGameConfigFile("sdktools.games"); + if(!hGameData) + SetFailState("Failed to load sdktools gamedata."); + + offset = GameConfGetOffset(hGameData, "Activate"); + if(offset == -1) + { + delete hGameData; + SetFailState("Failed to find Activate offset"); + } + + // CPhysForce::Activate + g_hActivate = DHookCreate(offset, HookType_Entity, ReturnType_Void, ThisPointer_CBaseEntity, Hook_CPhysForce_Activate); + if(g_hActivate == INVALID_HANDLE) + { + delete hGameData; + SetFailState("Failed to DHookCreate Activate"); + } + + offset = GameConfGetOffset(hGameData, "AcceptInput"); + if(offset == -1) + { + delete hGameData; + SetFailState("Failed to find AcceptInput offset."); + } + + // CBaseEntity::AcceptInput( const char *szInputName, CBaseEntity *pActivator, CBaseEntity *pCaller, variant_t Value, int outputID ) + g_hAcceptInput = DHookCreate(offset, HookType_Entity, ReturnType_Bool, ThisPointer_CBaseEntity, Hook_AcceptInput); + if(g_hAcceptInput == INVALID_HANDLE) + { + delete hGameData; + SetFailState("Failed to DHook AcceptInput."); + } + + DHookAddParam(g_hAcceptInput, HookParamType_CharPtr); + DHookAddParam(g_hAcceptInput, HookParamType_CBaseEntity); + DHookAddParam(g_hAcceptInput, HookParamType_CBaseEntity); + DHookAddParam(g_hAcceptInput, HookParamType_Object, 20, DHookPass_ByVal|DHookPass_ODTOR|DHookPass_OCTOR|DHookPass_OASSIGNOP); // variant_t is a union of 12 (float[3]) plus two int type params 12 + 8 = 20 + DHookAddParam(g_hAcceptInput, HookParamType_Int); + + delete hGameData; + + // Capability provider from https://github.com/alliedmodders/sourcemod/pull/1078 + g_bHasOnEntitySpawned = GetFeatureStatus(FeatureType_Capability, "SDKHook_OnEntitySpawned") == FeatureStatus_Available; + + HookConVarChange(g_cvCompenseEntityClass, OnConVarChanged); + HookConVarChange(g_cvCheckUserPing, OnConVarChanged); + HookConVarChange(g_cvMinimumPing, OnConVarChanged); + + + g_hCookie_LagCompSettings = RegClientCookie("lagcomp_settings", "LagCompensation Settings", CookieAccess_Private); + RegConsoleCmd("sm_lagcomp", OnToggleLagCompSettings); + RegConsoleCmd("sm_0ping", OnToggleLagCompSettings); + RegConsoleCmd("sm_0pinglasers", OnToggleLagCompLasersSettings); + RegConsoleCmd("sm_checklag", CheckLagComp); + SetCookieMenuItem(MenuHandler_CookieMenu, 0, "LagCompensation"); + + CreateTimer(0.1, DisableLagCompTimer, _, TIMER_REPEAT); + + RegAdminCmd("sm_unlag", Command_AddLagCompensation, ADMFLAG_ROOT, "sm_unlag "); + RegAdminCmd("sm_lagged", Command_CheckLagCompensated, ADMFLAG_ROOT, "sm_lagged"); + + FilterClientSolidTouch(g_aaFilterClientSolidTouch, true); + BlockTriggerMoved(g_aBlockTriggerMoved, true); +} + +public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max) +{ + RegPluginLibrary("LagCompensation"); + g_bLateLoad = late; + return APLRes_Success; +} + +public void OnLibraryRemoved(const char[] name) +{ + if(strcmp(name, "PhysHooks", false) == 0) + g_bHasPhysHooks = false; +} + +public void OnPluginEnd() +{ + g_bCleaningUp = true; + if(g_bHasPhysHooks) + { + FilterClientSolidTouch(g_aaFilterClientSolidTouch, false); + BlockTriggerMoved(g_aBlockTriggerMoved, false); + BlockTriggerTouchPlayers(g_aBlockTriggerTouchPlayers, false); + } + + DHookDisableDetour(g_hUTIL_Remove, false, Detour_OnUTIL_Remove); + + for(int i = 0; i < g_iNumEntities; i++) + { + if(!IsValidEntity(g_aEntityLagData[i].iEntity)) + continue; + + if(g_aEntityLagData[i].iDeleted) + { + RemoveEdict(g_aEntityLagData[i].iEntity); + } + } + + delete g_hCookie_LagCompSettings; +} + +public void OnConfigsExecuted() +{ + g_iCompenseEntityClass = GetConVarInt(g_cvCompenseEntityClass); + g_bCheckPing = GetConVarBool(g_cvCheckUserPing); + g_iMinPing = GetConVarInt(g_cvMinimumPing); +} + +void OnConVarChanged(ConVar convar, const char[] oldValue, const char[] newValue) +{ + if (convar == g_cvCompenseEntityClass) + g_iCompenseEntityClass = GetConVarInt(convar); + else if (convar == g_cvCheckUserPing) + g_bCheckPing = GetConVarBool(convar); + else if (convar == g_cvMinimumPing) + g_iMinPing = GetConVarInt(convar); +} + +public void OnMapStart() +{ + bool bLate = g_bLateLoad; + + DHookGamerules(g_hEndGameFrame, true); + + g_bCleaningUp = false; + + g_fTickInterval = GetTickInterval(); + + g_iParent = FindDataMapInfo(0, "m_pParent"); + g_iSpawnFlags = FindDataMapInfo(0, "m_spawnflags"); + g_iCollision = FindDataMapInfo(0, "m_Collision"); + g_iSolidFlags = FindDataMapInfo(0, "m_usSolidFlags"); + g_iSolidType = FindDataMapInfo(0, "m_nSolidType"); + g_iSurroundType = FindDataMapInfo(0, "m_nSurroundType"); + g_iEFlags = FindDataMapInfo(0, "m_iEFlags"); + + g_iVecOrigin = FindDataMapInfo(0, "m_vecOrigin"); + g_iVecAbsOrigin = FindDataMapInfo(0, "m_vecAbsOrigin"); + g_iAngRotation = FindDataMapInfo(0, "m_angRotation"); + g_iAngAbsRotation = FindDataMapInfo(0, "m_angAbsRotation"); + g_iVecMins = FindDataMapInfo(0, "m_vecMins"); + g_iVecMaxs = FindDataMapInfo(0, "m_vecMaxs"); + g_iSimulationTime = FindDataMapInfo(0, "m_flSimulationTime"); + g_iCoordinateFrame = FindDataMapInfo(0, "m_rgflCoordinateFrame"); + + /* Late Load */ + if(bLate) + { + for (int client = 1; client <= MaxClients; client++) + { + if(IsClientInGame(client)) + { + OnClientConnected(client); + if(AreClientCookiesCached(client)) + OnClientCookiesCached(client); + OnClientSettingsChanged(client); + } + } + + int entity = INVALID_ENT_REFERENCE; + while((entity = FindEntityByClassname(entity, "*")) != INVALID_ENT_REFERENCE) + { + char sClassname[64]; + if(GetEntityClassname(entity, sClassname, sizeof(sClassname))) + { + OnEntityCreated(entity, sClassname); + OnEntitySpawned(entity, sClassname); + + if(strcmp(sClassname, "phys_thruster", false) == 0) + { + Hook_CPhysForce_Activate(entity); + } + } + } + } + + g_bLateLoad = false; +} + +public void OnMapEnd() +{ + Detour_OnRestartRound(); + g_bCleaningUp = true; +} + +public void OnClientConnected(int client) +{ + g_bDisableLagComp[client] = false; + g_bPlayerLaserCompensated[client] = false; + g_iDisableLagComp[client] = 0; + g_aLerpTicks[client] = 0; +} + +public void OnClientCookiesCached(int client) +{ + // Load all settings from single cookie + ParseClientCookie(client, g_bDisableLagComp[client], g_bPlayerLaserCompensated[client], g_bLagCompMessages[client]); +} + +public void OnClientSettingsChanged(int client) +{ + if(!IsClientInGame(client) || IsFakeClient(client)) + return; + + float fLerpTime = GetEntPropFloat(client, Prop_Data, "m_fLerpTime"); + g_aLerpTicks[client] = RoundToNearest(fLerpTime / g_fTickInterval); +} + +public void OnClientDisconnect(int client) +{ + g_bDisableLagComp[client] = false; + g_bPlayerLaserCompensated[client] = false; + g_iDisableLagComp[client] = 0; +} + +public void OnEntityCreated(int entity, const char[] classname) +{ + if(g_bCleaningUp) + return; + + if (strcmp(classname, "phys_thruster", false) == 0) + { + DHookEntity(g_hActivate, true, entity); + } + else if (strcmp(classname, "game_ui", false) == 0) + { + DHookEntity(g_hAcceptInput, true, entity); + } + + if (!g_bHasOnEntitySpawned && (!strncmp(classname, "func_physbox", 12, false) || (g_iCompenseEntityClass == 0 && strcmp(classname, "trigger_hurt", false) == 0 || + strcmp(classname, "trigger_push", false) == 0 || strcmp(classname, "trigger_teleport", false) == 0))) + { + SDKHook(entity, SDKHook_SpawnPost, OnSDKHookEntitySpawnPost); + } +} + +public void OnSDKHookEntitySpawnPost(int entity) +{ + char classname[64]; + GetEntityClassname(entity, classname, sizeof(classname)); + + OnEntitySpawned(entity, classname); +} + +public void OnEntitySpawned(int entity, const char[] classname) +{ + if(g_bCleaningUp) + return; + + CheckEntityForLagComp(entity, classname); +} + +public MRESReturn Hook_AcceptInput(int entity, Handle hReturn, Handle hParams) +{ + if(!IsValidEntity(entity)) + return MRES_Ignored; + + char sCommand[128]; + DHookGetParamString(hParams, 1, sCommand, sizeof(sCommand)); + + if (strcmp(sCommand, "Activate", false) != 0) + return MRES_Ignored; + + if(DHookIsNullParam(hParams, 3)) + return MRES_Ignored; + + int iCaller = DHookGetParam(hParams, 3); + if(iCaller <= 0 || iCaller >= MAX_EDICTS) + return MRES_Ignored; + + // Don't lagcompensate anything that has a game_ui button in their hierarchy. + BlacklistFamily(iCaller); + + return MRES_Ignored; +} + +void BlacklistFamily(int entity) +{ + if(entity > 0 && entity < MAX_EDICTS) + { + SetBit(g_aBlacklisted, entity); + RemoveEntityFromLagCompensation(entity); + } + + // Blacklist children of this entity + BlacklistChildren(entity); + + // And blacklist all parents and their children + for(;;) + { + entity = GetEntDataEnt2(entity, g_iParent); + if(entity == INVALID_ENT_REFERENCE) + break; + + if(entity > 0 && entity < MAX_EDICTS) + { + SetBit(g_aBlacklisted, entity); + RemoveEntityFromLagCompensation(entity); + } + + // And their children + BlacklistChildren(entity); + } +} + +void BlacklistChildren(int parent) +{ + int entity = INVALID_ENT_REFERENCE; + while((entity = FindEntityByClassname(entity, "*")) != INVALID_ENT_REFERENCE) + { + if(GetEntDataEnt2(entity, g_iParent) != parent) + continue; + + if(entity > 0 && entity < MAX_EDICTS) + { + SetBit(g_aBlacklisted, entity); + RemoveEntityFromLagCompensation(entity); + } + } +} + +bool CheckEntityForLagComp(int entity, const char[] classname, bool bRecursive=false, bool bGoodParents=false) +{ + if(entity < 0 || entity > MAX_EDICTS) + return false; + + if(!IsValidEntity(entity)) + return false; + + if(g_aLagCompensated[entity] != -1) + return false; + + int SpawnFlags = GetEntData(entity, g_iSpawnFlags); + if(SpawnFlags & SF_LAGCOMP_DISABLE) + return false; + + bool bTrigger = false; + + if (g_iCompenseEntityClass == 0) + bTrigger = strcmp(classname, "trigger_hurt", false) == 0 || strcmp(classname, "trigger_push", false) == 0 || strcmp(classname, "trigger_teleport", false) == 0; + + bool bPhysbox = !strncmp(classname, "func_physbox", 12, false); + + bool bBlacklisted = CheckBit(g_aBlacklisted, entity); + + if(!bTrigger && !bPhysbox || bBlacklisted) + return false; + + // Don't lag compensate anything that could be parented to a player + // The player simulation would usually move the entity, + // but we would overwrite that position change by restoring the entity to its previous state. + int iParent = INVALID_ENT_REFERENCE; + char sParentClassname[64]; + for(int iTmp = entity;;) + { + iTmp = GetEntDataEnt2(iTmp, g_iParent); + if(iTmp == INVALID_ENT_REFERENCE) + break; + + iParent = iTmp; + GetEntityClassname(iParent, sParentClassname, sizeof(sParentClassname)); + + if((iParent >= 1 && iParent <= MaxClients) || + !strncmp(sParentClassname, "weapon_", 7)) + { + return false; + } + + if(CheckBit(g_aBlacklisted, entity)) + { + return false; + } + + if(g_aLagCompensated[iParent] != -1) + { + bGoodParents = true; + break; + } + + if(strncmp(sParentClassname, "func_", 5)) + continue; + + if(strcmp(sParentClassname[5], "movelinear", true) == 0 || + strcmp(sParentClassname[5], "door", true) == 0 || + strcmp(sParentClassname[5], "rotating", true) == 0 || + strcmp(sParentClassname[5], "tracktrain", true) == 0) + { + bGoodParents = true; + break; + } + } + + if(!bGoodParents) + return false; + + if(AddEntityForLagCompensation(entity, bTrigger)) + { + if(bTrigger) + { + if(!(SpawnFlags & (SF_TRIGGER_ALLOW_PUSHABLES | SF_TRIGGER_ALLOW_PHYSICS | SF_TRIGGER_ALLOW_ALL | SF_TRIG_TOUCH_DEBRIS))) + SetBit(g_aBlockTriggerMoved, entity); + } + + if(bRecursive) + { + CheckEntityChildrenForLagComp(entity); + } + + return true; + } + + return false; +} + +void CheckEntityChildrenForLagComp(int parent) +{ + int entity = INVALID_ENT_REFERENCE; + while((entity = FindEntityByClassname(entity, "*")) != INVALID_ENT_REFERENCE) + { + if(GetEntDataEnt2(entity, g_iParent) != parent) + continue; + + char sClassname[64]; + if(GetEntityClassname(entity, sClassname, sizeof(sClassname))) + { + CheckEntityForLagComp(entity, sClassname, _, true); + CheckEntityChildrenForLagComp(entity); + } + } +} + +public void OnEntityDestroyed(int entity) +{ + if(g_bCleaningUp) + return; + + if(entity < 0 || entity > MAX_EDICTS) + return; + + ClearBit(g_aBlacklisted, entity); + + int iIndex = g_aLagCompensated[entity]; + if(iIndex == -1) + return; + + RemoveRecord(iIndex); +} + + +public MRESReturn Detour_OnUTIL_Remove(Handle hParams) +{ + if(g_bCleaningUp) + return MRES_Ignored; + + if(DHookIsNullParam(hParams, 1)) + return MRES_Ignored; + + int entity = DHookGetParamObjectPtrVar(hParams, 1, g_iNetworkableOuter, ObjectValueType_CBaseEntityPtr); + if(entity < 0 || entity > MAX_EDICTS) + return MRES_Ignored; + + int iIndex = g_aLagCompensated[entity]; + if(iIndex == -1) + return MRES_Ignored; + + // let it die + if(!g_aEntityLagData[iIndex].bLateKill) + return MRES_Ignored; + + // ignore sleeping entities + if(g_aEntityLagData[iIndex].iNotMoving >= MAX_RECORDS) + return MRES_Ignored; + + if(!g_aEntityLagData[iIndex].iDeleted) + { + g_aEntityLagData[iIndex].iDeleted = GetGameTickCount(); + } + + return MRES_Supercede; +} + +public MRESReturn Detour_OnRestartRound() +{ + g_bCleaningUp = true; + + for(int i = 0; i < g_iNumEntities; i++) + { + int iEntity = g_aEntityLagData[i].iEntity; + + g_aLagCompensated[iEntity] = -1; + ClearBit(g_aBlockTriggerTouchPlayers, iEntity); + ClearBit(g_aBlockTriggerMoved, iEntity); + + for(int client = 1; client <= MaxClients; client++) + { + ClearBit(g_aaFilterClientSolidTouch, client * MAX_EDICTS + iEntity); + } + + if(g_aEntityLagData[i].iDeleted) + { + if(IsValidEntity(iEntity)) + RemoveEdict(iEntity); + } + + g_aEntityLagData[i].iEntity = INVALID_ENT_REFERENCE; + } + + for(int i = 0; i < sizeof(g_aBlacklisted); i++) + g_aBlacklisted[i] = 0; + + g_iNumEntities = 0; + + g_bCleaningUp = false; + return MRES_Ignored; +} + +// https://developer.valvesoftware.com/wiki/Logic_measure_movement +int g_OnSetTarget_pThis; +public MRESReturn Detour_OnSetTargetPre(int pThis, Handle hParams) +{ + g_OnSetTarget_pThis = pThis; + return MRES_Ignored; +} +public MRESReturn Detour_OnSetTargetPost(Handle hParams) +{ + int entity = GetEntPropEnt(g_OnSetTarget_pThis, Prop_Data, "m_hTarget"); + if(!IsValidEntity(entity)) + return MRES_Ignored; + + char sClassname[64]; + if(!GetEntityClassname(entity, sClassname, sizeof(sClassname))) + return MRES_Ignored; + + CheckEntityForLagComp(entity, sClassname, true, true); + + return MRES_Ignored; +} + +public MRESReturn Hook_CPhysForce_Activate(int entity) +{ + int attachedObject = GetEntPropEnt(entity, Prop_Data, "m_attachedObject"); + if(!IsValidEntity(attachedObject)) + return MRES_Ignored; + + char sClassname[64]; + if(!GetEntityClassname(attachedObject, sClassname, sizeof(sClassname))) + return MRES_Ignored; + + CheckEntityForLagComp(attachedObject, sClassname, true, true); + + return MRES_Ignored; +} + +public MRESReturn Detour_OnFrameUpdatePostEntityThink() +{ + for(int i = 0; i < g_iNumEntities; i++) + { + // Don't make the entity check untouch in FrameUpdatePostEntityThink. + // If the player didn't get simulated in the current frame then + // they didn't have a chance to touch this entity. + // Hence the touchlink could be broken and we only let the player check untouch. + int EFlags = GetEntData(g_aEntityLagData[i].iEntity, g_iEFlags); + EFlags &= ~EFL_CHECK_UNTOUCH; + SetEntData(g_aEntityLagData[i].iEntity, g_iEFlags, EFlags); + } + return MRES_Ignored; +} + + +public void OnRunThinkFunctions(bool simulating) +{ + g_iGameTick = GetGameTickCount(); + BlockTriggerTouchPlayers(g_aBlockTriggerTouchPlayers, false); +} + +public Action OnPlayerRunCmd(int client, int &buttons, int &impulse, float vel[3], float angles[3], int &weapon, int &subtype, int &cmdnum, int &tickcount, int &seed, int mouse[2]) +{ + if(!IsClientInGame(client) || !IsPlayerAlive(client) || IsFakeClient(client)) + return Plugin_Continue; + + int iTargetTick = tickcount - g_aLerpTicks[client]; + + // -1 because the newest record in the list is one tick old + // this is because we simulate players first + // hence no new entity record was inserted on the current tick + int iDelta = g_iGameTick - iTargetTick - 1; + + // The player is stupid and doesn't want lag compensation. + // To get the original behavior back lets assume they actually have 0 latency. + // To avoid abusing toggling lagcomp we increase/decrease this var by 1 every 100ms. + // This is so we only skip single ticks at a time. Fully ON = 0, Fully OFF = MAX_RECORDS + iDelta -= g_iDisableLagComp[client]; + + if(iDelta < 0) + iDelta = 0; + if(iDelta > MAX_RECORDS) + iDelta = MAX_RECORDS; + + int iPlayerSimTick = g_iGameTick - iDelta; + + for(int i = 0; i < g_iNumEntities; i++) + { + int iEntity = g_aEntityLagData[i].iEntity; + + // Entity too new, the client couldn't even see it yet. + if(g_aEntityLagData[i].iSpawned > iPlayerSimTick) + { + SetBit(g_aaFilterClientSolidTouch, client * MAX_EDICTS + iEntity); + continue; + } + + if(g_aEntityLagData[i].iDeleted) + { + if(g_aEntityLagData[i].iDeleted <= iPlayerSimTick) + { + SetBit(g_aaFilterClientSolidTouch, client * MAX_EDICTS + iEntity); + continue; + } + } + else + { + ClearBit(g_aaFilterClientSolidTouch, client * MAX_EDICTS + iEntity); + } + + if(g_aEntityLagData[i].iNotMoving >= MAX_RECORDS) + continue; + + // Verify if the client have compensated the lasers + if (!g_bPlayerLaserCompensated[client] || g_iCompenseEntityClass == 0 && strcmp(g_aEntityLagData[i].sClassname, "trigger_hurt", false) != 0 || strcmp(g_aEntityLagData[i].sClassname, "trigger_push", false) != 0 || strcmp(g_aEntityLagData[i].sClassname, "trigger_teleport", false) != 0) + continue; + + int iRecord = iDelta; + if(iRecord >= g_aEntityLagData[i].iNumRecords) + iRecord = g_aEntityLagData[i].iNumRecords - 1; + + int iRecordIndex = g_aEntityLagData[i].iRecordIndex - iRecord; + if(iRecordIndex < 0) + iRecordIndex += MAX_RECORDS; + + RestoreEntityFromRecord(iEntity, g_aaLagRecords[i][iRecordIndex]); + g_aEntityLagData[i].bRestore = !g_aEntityLagData[i].iDeleted; + } + + return Plugin_Continue; +} + +public void OnPostPlayerThinkFunctions() +{ + for(int i = 0; i < g_iNumEntities; i++) + { + if(!g_aEntityLagData[i].bRestore) + continue; + + RestoreEntityFromRecord(g_aEntityLagData[i].iEntity, g_aaLagRecords[i][g_aEntityLagData[i].iRecordIndex]); + g_aEntityLagData[i].bRestore = false; + } + + BlockTriggerTouchPlayers(g_aBlockTriggerTouchPlayers, true); +} + +public MRESReturn Hook_EndGameFrame() +{ + for(int i = 0; i < g_iNumEntities; i++) + { + if(g_aEntityLagData[i].iDeleted) + { + if(g_aEntityLagData[i].iDeleted + MAX_RECORDS <= g_iGameTick) + { + // calls OnEntityDestroyed right away + // which calls RemoveRecord + // which moves the next element to our current position + RemoveEdict(g_aEntityLagData[i].iEntity); + i--; continue; + } + + if(g_aEntityLagData[i].iRecordsValid) + { + g_aEntityLagData[i].iRecordIndex++; + + if(g_aEntityLagData[i].iRecordIndex >= MAX_RECORDS) + g_aEntityLagData[i].iRecordIndex = 0; + + g_aEntityLagData[i].iRecordsValid--; + } + + continue; + } + + LagRecord TmpRecord; + RecordDataIntoRecord(g_aEntityLagData[i].iEntity, TmpRecord); + + // sleep detection + { + int iOldRecord = g_aEntityLagData[i].iRecordIndex; + + if(CompareVectors(g_aaLagRecords[i][iOldRecord].vecAbsOrigin, TmpRecord.vecAbsOrigin) && + CompareVectors(g_aaLagRecords[i][iOldRecord].angAbsRotation, TmpRecord.angAbsRotation)) + { + g_aEntityLagData[i].iNotMoving++; + } + else + { + g_aEntityLagData[i].iNotMoving = 0; + } + + if(g_aEntityLagData[i].iNotMoving >= MAX_RECORDS) + continue; + } + + g_aEntityLagData[i].iRecordIndex++; + + if(g_aEntityLagData[i].iRecordIndex >= MAX_RECORDS) + g_aEntityLagData[i].iRecordIndex = 0; + + if(g_aEntityLagData[i].iNumRecords < MAX_RECORDS) + g_aEntityLagData[i].iRecordsValid = ++g_aEntityLagData[i].iNumRecords; + + LagRecord_Copy(g_aaLagRecords[i][g_aEntityLagData[i].iRecordIndex], TmpRecord); + } + + return MRES_Ignored; +} + + +void RecordDataIntoRecord(int iEntity, LagRecord Record) +{ + // Force recalculation of all values + int EFlags = GetEntData(iEntity, g_iEFlags); + EFlags |= EFL_DIRTY_ABSTRANSFORM; + SetEntData(iEntity, g_iEFlags, EFlags); + + SDKCall(g_hCalcAbsolutePosition, iEntity); + + GetEntDataVector(iEntity, g_iVecOrigin, Record.vecOrigin); + GetEntDataVector(iEntity, g_iVecAbsOrigin, Record.vecAbsOrigin); + GetEntDataVector(iEntity, g_iAngRotation, Record.angRotation); + GetEntDataVector(iEntity, g_iAngAbsRotation, Record.angAbsRotation); + GetEntDataVector(iEntity, g_iVecMins, Record.vecMins); + GetEntDataVector(iEntity, g_iVecMaxs, Record.vecMaxs); + GetEntDataArray(iEntity, g_iCoordinateFrame, view_as(Record.rgflCoordinateFrame), COORDINATE_FRAME_SIZE); + Record.flSimulationTime = GetEntDataFloat(iEntity, g_iSimulationTime); +} + +bool DoesRotationInvalidateSurroundingBox(int iEntity) +{ + int SolidFlags = GetEntData(iEntity, g_iSolidFlags); + if(SolidFlags & FSOLID_ROOT_PARENT_ALIGNED) + return true; + + int SurroundType = GetEntData(iEntity, g_iSurroundType); + switch(SurroundType) + { + case USE_COLLISION_BOUNDS_NEVER_VPHYSICS, + USE_OBB_COLLISION_BOUNDS, + USE_BEST_COLLISION_BOUNDS: + { + // IsBoundsDefinedInEntitySpace() + int SolidType = GetEntData(iEntity, g_iSolidType); + return ((SolidFlags & FSOLID_FORCE_WORLD_ALIGNED) == 0) && + (SolidType != SOLID_BBOX) && (SolidType != SOLID_NONE); + } + + case USE_HITBOXES, + USE_GAME_CODE: + { + return true; + } + + case USE_ROTATION_EXPANDED_BOUNDS, + USE_SPECIFIED_BOUNDS: + { + return false; + } + + default: + { + return true; + } + } +} + +void InvalidatePhysicsRecursive(int iEntity) +{ + // CollisionProp()->MarkPartitionHandleDirty(); + Address CollisionProp = GetEntityAddress(iEntity) + view_as
(g_iCollision); + SDKCall(g_hMarkPartitionHandleDirty, CollisionProp); + + if(DoesRotationInvalidateSurroundingBox(iEntity)) + { + // CollisionProp()->MarkSurroundingBoundsDirty(); + int EFlags = GetEntData(iEntity, g_iEFlags); + EFlags |= EFL_DIRTY_SURROUNDING_COLLISION_BOUNDS; + SetEntData(iEntity, g_iEFlags, EFlags); + } +} + +void RestoreEntityFromRecord(int iEntity, LagRecord Record) +{ + SetEntDataVector(iEntity, g_iVecOrigin, Record.vecOrigin); + SetEntDataVector(iEntity, g_iVecAbsOrigin, Record.vecAbsOrigin); + SetEntDataVector(iEntity, g_iAngRotation, Record.angRotation); + SetEntDataVector(iEntity, g_iAngAbsRotation, Record.angAbsRotation); + SetEntDataVector(iEntity, g_iVecMins, Record.vecMins); + SetEntDataVector(iEntity, g_iVecMaxs, Record.vecMaxs); + SetEntDataArray(iEntity, g_iCoordinateFrame, view_as(Record.rgflCoordinateFrame), COORDINATE_FRAME_SIZE); + SetEntDataFloat(iEntity, g_iSimulationTime, Record.flSimulationTime); + + InvalidatePhysicsRecursive(iEntity); +} + + +bool AddEntityForLagCompensation(int iEntity, bool bLateKill) +{ + if(g_bCleaningUp) + return false; + + if(g_aLagCompensated[iEntity] != -1) + return false; + + char sClassname[64]; + GetEntityClassname(iEntity, sClassname, sizeof(sClassname)); + + char sTargetname[64]; + GetEntPropString(iEntity, Prop_Data, "m_iName", sTargetname, sizeof(sTargetname)); + + int iHammerID = GetEntProp(iEntity, Prop_Data, "m_iHammerID"); + + if(g_iNumEntities >= MAX_ENTITIES) + { + PrintToBoth("[%d] OUT OF LAGCOMP SLOTS entity %d (%s)\"%s\"(#%d)", GetGameTickCount(), iEntity, sClassname, sTargetname, iHammerID); + LogError("OUT OF LAGCOMP SLOTS entity %d (%s)\"%s\"(#%d)", iEntity, sClassname, sTargetname, iHammerID); + return false; + } + + int i = g_iNumEntities; + g_iNumEntities++; + + g_aLagCompensated[iEntity] = i; + + g_aEntityLagData[i].iEntity = iEntity; + g_aEntityLagData[i].iRecordIndex = 0; + g_aEntityLagData[i].iNumRecords = 1; + g_aEntityLagData[i].iRecordsValid = 1; + g_aEntityLagData[i].iSpawned = GetGameTickCount(); + g_aEntityLagData[i].iDeleted = 0; + g_aEntityLagData[i].iNotMoving = MAX_RECORDS; + g_aEntityLagData[i].bRestore = false; + g_aEntityLagData[i].bLateKill = bLateKill; + g_aEntityLagData[i].sClassname = sClassname; + + if(bLateKill) + { + SetBit(g_aBlockTriggerTouchPlayers, iEntity); + } + + RecordDataIntoRecord(iEntity, g_aaLagRecords[i][0]); + PrintToBoth("[%d] added entity %d (%s)\"%s\"(#%d) under index %d", GetGameTickCount(), iEntity, sClassname, sTargetname, iHammerID, i); + + return true; +} + +bool RemoveEntityFromLagCompensation(int iEntity) +{ + int index = g_aLagCompensated[iEntity]; + if(index == -1) + return false; + + RemoveRecord(index); + return true; +} + +void RemoveRecord(int index) +{ + if(g_bCleaningUp) + return; + + int iEntity = g_aEntityLagData[index].iEntity; + + if(IsValidEntity(iEntity)) + { + char sClassname[64]; + GetEntityClassname(g_aEntityLagData[index].iEntity, sClassname, sizeof(sClassname)); + + char sTargetname[64]; + GetEntPropString(g_aEntityLagData[index].iEntity, Prop_Data, "m_iName", sTargetname, sizeof(sTargetname)); + + int iHammerID = GetEntProp(g_aEntityLagData[index].iEntity, Prop_Data, "m_iHammerID"); + + PrintToBoth("[%d] RemoveRecord %d / %d (%s)\"%s\"(#%d), num: %d", GetGameTickCount(), index, g_aEntityLagData[index].iEntity, sClassname, sTargetname, iHammerID, g_iNumEntities); + } + + g_aLagCompensated[iEntity] = -1; + ClearBit(g_aBlockTriggerTouchPlayers, iEntity); + ClearBit(g_aBlockTriggerMoved, iEntity); + + for(int client = 1; client <= MaxClients; client++) + { + ClearBit(g_aaFilterClientSolidTouch, client * MAX_EDICTS + iEntity); + } + + g_aEntityLagData[index].iEntity = INVALID_ENT_REFERENCE; + + for(int src = index + 1; src < g_iNumEntities; src++) + { + int dest = src - 1; + + EntityLagData_Copy(g_aEntityLagData[dest], g_aEntityLagData[src]); + g_aEntityLagData[src].iEntity = INVALID_ENT_REFERENCE; + g_aLagCompensated[g_aEntityLagData[dest].iEntity] = dest; + + int iNumRecords = g_aEntityLagData[dest].iNumRecords; + for(int i = 0; i < iNumRecords; i++) + { + LagRecord_Copy(g_aaLagRecords[dest][i], g_aaLagRecords[src][i]); + } + } + + g_iNumEntities--; +} + +void EntityLagData_Copy(EntityLagData obj, const EntityLagData other) +{ + obj.iEntity = other.iEntity; + obj.iRecordIndex = other.iRecordIndex; + obj.iNumRecords = other.iNumRecords; + obj.iRecordsValid = other.iRecordsValid; + obj.iSpawned = other.iSpawned; + obj.iDeleted = other.iDeleted; + obj.iNotMoving = other.iNotMoving; + obj.bRestore = other.bRestore; + obj.bLateKill = other.bLateKill; +} + +void LagRecord_Copy(LagRecord obj, const LagRecord other) +{ + for(int i = 0; i < 3; i++) + { + obj.vecOrigin[i] = other.vecOrigin[i]; + obj.vecAbsOrigin[i] = other.vecAbsOrigin[i]; + obj.angRotation[i] = other.angRotation[i]; + obj.angAbsRotation[i] = other.angAbsRotation[i]; + obj.vecMins[i] = other.vecMins[i]; + obj.vecMaxs[i] = other.vecMaxs[i]; + } + + obj.flSimulationTime = other.flSimulationTime; + + for(int i = 0; i < COORDINATE_FRAME_SIZE; i++) + { + obj.rgflCoordinateFrame[i] = other.rgflCoordinateFrame[i]; + } +} + +bool CompareVectors(const float vec1[3], const float vec2[3]) +{ + return vec1[0] == vec2[0] && vec1[1] == vec2[1] && vec1[2] == vec2[2]; +} + +void SaveLagCompSettings(int client) +{ + // Read current cookie values + bool currentDisableLagComp, currentPlayerLaserCompensated, currentLagCompMessages; + ParseClientCookie(client, currentDisableLagComp, currentPlayerLaserCompensated, currentLagCompMessages); + + // Check if values have changed + bool disableChanged = g_bDisableLagComp[client] != currentDisableLagComp; + bool laserChanged = g_bPlayerLaserCompensated[client] != currentPlayerLaserCompensated; + bool messagesChanged = g_bLagCompMessages[client] != currentLagCompMessages; + + // If no values have changed, no need to save + if (!disableChanged && !laserChanged && !messagesChanged) { + return; + } + + // Otherwise, save the chain format + char sBuffer[8]; + Format(sBuffer, sizeof(sBuffer), "%d%d%d", + g_bDisableLagComp[client] ? 1 : 0, + g_bPlayerLaserCompensated[client] ? 1 : 0, + g_bLagCompMessages[client] ? 1 : 0); + SetClientCookie(client, g_hCookie_LagCompSettings, sBuffer); +} + +void ParseClientCookie(int client, bool &disableLagComp, bool &playerLaserCompensated, bool &lagCompMessages) +{ + char buffer[16]; + GetClientCookie(client, g_hCookie_LagCompSettings, buffer, sizeof(buffer)); + + // Parse chain format: disable|laser|messages + if (strlen(buffer) >= 3) + { + disableLagComp = (buffer[0] == '1'); + playerLaserCompensated = (buffer[1] == '1'); + lagCompMessages = (buffer[2] == '1'); + } + else + { + // Default values + disableLagComp = false; + playerLaserCompensated = true; + lagCompMessages = false; + } +} + +public Action Command_AddLagCompensation(int client, int argc) +{ + if(argc < 1) + { + ReplyToCommand(client, "[SM] Usage: sm_unlag [late]"); + return Plugin_Handled; + } + + char sArgs[32]; + GetCmdArg(1, sArgs, sizeof(sArgs)); + + int entity = StringToInt(sArgs); + + bool late = false; + if(argc >= 2) + { + GetCmdArg(2, sArgs, sizeof(sArgs)); + late = view_as(StringToInt(sArgs)); + } + + AddEntityForLagCompensation(entity, late); + + return Plugin_Handled; +} + +public Action Command_CheckLagCompensated(int client, int argc) +{ + if(argc == 0) + { + for(int i = 0; i < g_iNumEntities; i++) + { + char sClassname[64]; + GetEntityClassname(g_aEntityLagData[i].iEntity, sClassname, sizeof(sClassname)); + + char sTargetname[64]; + GetEntPropString(g_aEntityLagData[i].iEntity, Prop_Data, "m_iName", sTargetname, sizeof(sTargetname)); + + int iHammerID = GetEntProp(g_aEntityLagData[i].iEntity, Prop_Data, "m_iHammerID"); + + PrintToConsole(client, "%2d. #%d %s \"%s\" (#%d)", i, g_aEntityLagData[i].iEntity, sClassname, sTargetname, iHammerID); + } + + return Plugin_Handled; + } + + for(int iEntity = 0; iEntity < MAX_EDICTS; iEntity++) + { + bool bDeleted = false; + for(int j = 1; j <= MaxClients; j++) + { + if(CheckBit(g_aaFilterClientSolidTouch, j * MAX_EDICTS + iEntity)) + { + bDeleted = true; + break; + } + } + + bool bBlockPhysics = CheckBit(g_aBlockTriggerTouchPlayers, iEntity); + bool bBlockTriggerMoved = CheckBit(g_aBlockTriggerMoved, iEntity); + bool bBlacklisted = CheckBit(g_aBlacklisted, iEntity); + + if(bDeleted || bBlockPhysics || bBlockTriggerMoved || bBlacklisted) + { + int index = -1; + for(int j = 0; j < g_iNumEntities; j++) + { + if(g_aEntityLagData[j].iEntity == iEntity) + { + index = j; + break; + } + } + + char sClassname[64] = "INVALID"; + char sTargetname[64] = "INVALID"; + int iHammerID = -1; + + if(IsValidEntity(iEntity)) + { + GetEntityClassname(iEntity, sClassname, sizeof(sClassname)); + GetEntPropString(iEntity, Prop_Data, "m_iName", sTargetname, sizeof(sTargetname)); + iHammerID = GetEntProp(iEntity, Prop_Data, "m_iHammerID"); + } + + PrintToConsole(client, "%2d. #%d %s \"%s\" (#%d) -> Phys: %d / TrigMov: %d / Deleted: %d / Black: %d", + index, iEntity, sClassname, sTargetname, iHammerID, bBlockPhysics, bBlockTriggerMoved, bDeleted, bBlacklisted); + } + } + + return Plugin_Handled; +} + + +stock void PrintToBoth(const char[] format, any ...) +{ + char buffer[254]; + VFormat(buffer, sizeof(buffer), format, 2); + LogMessage(buffer); + + for(int client = 1; client <= MaxClients; client++) + { + if (IsClientInGame(client) && g_bLagCompMessages[client] && CheckCommandAccess(client, "sm_lagged", ADMFLAG_ROOT)) + { + PrintToConsole(client, buffer); + } + } +} + +public Action DisableLagCompTimer(Handle timer) +{ + for(int client = 1; client <= MaxClients; client++) + { + if(!IsClientInGame(client) || !IsPlayerAlive(client) || IsFakeClient(client)) + continue; + + // https://github.com/ValveSoftware/source-sdk-2013/blob/master/sp/src/game/server/util.cpp#L747 + if (g_bCheckPing) + { + int iAvgPing = RoundFloat(GetClientAvgLatency(client, NetFlow_Outgoing) * 1000); + + if (iAvgPing > g_iMinPing) + { + g_iDisableLagComp[client]++; + return Plugin_Continue; + } + } + + if(g_bDisableLagComp[client] && g_iDisableLagComp[client] < MAX_RECORDS) + { + g_iDisableLagComp[client]++; + } + else if(!g_bDisableLagComp[client] && g_iDisableLagComp[client] > 0) + { + g_iDisableLagComp[client]--; + } + } + + return Plugin_Continue; +} + +public Action OnLagCompSettings(int client, int args) +{ + ShowSettingsMenu(client); + return Plugin_Handled; +} + +public Action OnToggleLagCompSettings(int client, int args) +{ + ToggleLagCompSettings(client); + return Plugin_Handled; +} + +public Action OnToggleLagCompLasersSettings(int client, int args) +{ + ToggleLagCompLasers(client); + return Plugin_Handled; +} + +public void ToggleLagCompSettings(int client) +{ + if(!client || !IsClientInGame(client) || IsFakeClient(client)) + return; + + g_bDisableLagComp[client] = !g_bDisableLagComp[client]; + SaveLagCompSettings(client); + + CPrintToChat(client, "%s LagCompensation has been %s.", PREFIX, g_bDisableLagComp[client] ? "disabled" : "enabled"); +} + +public void ToggleLagCompLasers(int client) +{ + if(!client || !IsClientInGame(client) || IsFakeClient(client)) + return; + + g_bPlayerLaserCompensated[client] = !g_bPlayerLaserCompensated[client]; + SaveLagCompSettings(client); + + CPrintToChat(client, "%s LagCompensation for Lasers has been %s.", PREFIX, g_bPlayerLaserCompensated[client] ? "enabled" : "disabled"); + if (g_bCheckPing && g_bPlayerLaserCompensated[client]) + CPrintToChat(client, "%s It will take effect if your real ping is higher than %dms.", PREFIX, g_iMinPing); +} + +public void ToggleLagCompMessages(int client) +{ + if(!client || !IsClientInGame(client) || IsFakeClient(client)) + return; + + g_bLagCompMessages[client] = !g_bLagCompMessages[client]; + SaveLagCompSettings(client); + + CPrintToChat(client, "%s LagCompensation messages have been %s.", PREFIX, g_bLagCompMessages[client] ? "enabled" : "disabled"); +} + +public Action CheckLagComp(int client, int args) +{ + if (args > 1) + { + CPrintToChat(client, "%s Usage sm_checklag ", PREFIX); + return Plugin_Handled; + } + + int target = -1; + char sEnable[64]; + FormatEx(sEnable, sizeof(sEnable), "enabled (%s)", g_iCompenseEntityClass == 0 ? "Triggers and func_physbox" : "func_physbox only"); + + if (args == 0) + target = client; + else + { + char arg1[65]; + GetCmdArg(1, arg1, sizeof(arg1)); + target = FindTarget(client, arg1, true, false); + } + + if (target == -1) + { + CReplyToCommand(client, "%s Invalid target.", PREFIX); + return Plugin_Handled; + } + + CPrintToChat(client, "%s LagCompensation is %s for {olive}%N", PREFIX, g_bDisableLagComp[target] ? "disabled" : sEnable, target); + + return Plugin_Handled; +} + +public void ShowSettingsMenu(int client) +{ + Menu menu = new Menu(MenuHandler_MainMenu); + menu.SetTitle("LagCompensation Settings", client); + menu.ExitBackButton = true; + + char buffer[128], buffer_lasers[128], msg_buffer[128]; + Format(buffer, sizeof(buffer), "LagCompensation: %s", g_bDisableLagComp[client] ? "Disabled" : "Enabled"); + Format(buffer_lasers, sizeof(buffer_lasers), "LagCompensation for Lasers: %s", g_bPlayerLaserCompensated[client] ? "Enabled" : "Disabled"); + Format(msg_buffer, sizeof(msg_buffer), "LagCompensation Messages: %s", g_bLagCompMessages[client] ? "Enabled" : "Disabled"); + + menu.AddItem("0", buffer); + menu.AddItem("1", buffer_lasers); + menu.AddItem("2", msg_buffer, CheckCommandAccess(client, "sm_lagged", ADMFLAG_ROOT) ? ITEMDRAW_DEFAULT : ITEMDRAW_DISABLED); + + menu.Display(client, MENU_TIME_FOREVER); +} + +public void MenuHandler_CookieMenu(int client, CookieMenuAction action, any info, char[] buffer, int maxlen) +{ + switch(action) + { + case(CookieMenuAction_DisplayOption): + { + Format(buffer, maxlen, "LagCompensation", client); + } + case(CookieMenuAction_SelectOption): + { + ShowSettingsMenu(client); + } + } +} + +public int MenuHandler_MainMenu(Menu menu, MenuAction action, int client, int selection) +{ + switch(action) + { + case(MenuAction_Select): + { + switch(selection) + { + case(0): ToggleLagCompSettings(client); + case(1): ToggleLagCompLasers(client); + case(2): ToggleLagCompMessages(client); + } + + ShowSettingsMenu(client); + } + case(MenuAction_Cancel): + { + ShowCookieMenu(client); + } + case(MenuAction_End): + { + delete menu; + } + } + return 0; +} diff --git a/Leader3/content/materials/models/leader3/glow_ct/ct_urban.vmt b/Leader3/content/materials/models/leader3/glow_ct/ct_urban.vmt new file mode 100644 index 0000000..14bc524 --- /dev/null +++ b/Leader3/content/materials/models/leader3/glow_ct/ct_urban.vmt @@ -0,0 +1,8 @@ +"UnlitGeneric" +{ + "$basetexture" "models/leader3/glow_ct/white" + "$additive" "1" + "$ignorez" "1" + "$model" "1" + "$nocull" "1" +} diff --git a/Leader3/content/materials/models/leader3/glow_ct/ct_urban_glass.vmt b/Leader3/content/materials/models/leader3/glow_ct/ct_urban_glass.vmt new file mode 100644 index 0000000..14bc524 --- /dev/null +++ b/Leader3/content/materials/models/leader3/glow_ct/ct_urban_glass.vmt @@ -0,0 +1,8 @@ +"UnlitGeneric" +{ + "$basetexture" "models/leader3/glow_ct/white" + "$additive" "1" + "$ignorez" "1" + "$model" "1" + "$nocull" "1" +} diff --git a/Leader3/content/materials/models/leader3/glow_ct/white.vtf b/Leader3/content/materials/models/leader3/glow_ct/white.vtf new file mode 100644 index 0000000..198b42c Binary files /dev/null and b/Leader3/content/materials/models/leader3/glow_ct/white.vtf differ diff --git a/Leader3/content/materials/nide/defend.vmt b/Leader3/content/materials/nide/defend.vmt new file mode 100644 index 0000000..c996b84 --- /dev/null +++ b/Leader3/content/materials/nide/defend.vmt @@ -0,0 +1,5 @@ +"UnlitGeneric" +{ + "$basetexture" "nide/defend" + "$translucent" 1 +} diff --git a/Leader3/content/materials/nide/defend.vtf b/Leader3/content/materials/nide/defend.vtf new file mode 100644 index 0000000..4670c8a Binary files /dev/null and b/Leader3/content/materials/nide/defend.vtf differ diff --git a/Leader3/content/materials/nide/follow.vmt b/Leader3/content/materials/nide/follow.vmt new file mode 100644 index 0000000..d5389d8 --- /dev/null +++ b/Leader3/content/materials/nide/follow.vmt @@ -0,0 +1,5 @@ +"UnlitGeneric" +{ + "$basetexture" "nide/follow" + "$translucent" 1 +} diff --git a/Leader3/content/materials/nide/follow.vtf b/Leader3/content/materials/nide/follow.vtf new file mode 100644 index 0000000..beb408d Binary files /dev/null and b/Leader3/content/materials/nide/follow.vtf differ diff --git a/Leader3/content/materials/unloze/ping/ping.vmt b/Leader3/content/materials/unloze/ping/ping.vmt new file mode 100644 index 0000000..4cc0184 --- /dev/null +++ b/Leader3/content/materials/unloze/ping/ping.vmt @@ -0,0 +1,10 @@ +"VertexLitGeneric" +{ + "$basetexture" "unloze/ping/ping" + "$selfillum" "1" + "$selfillumtint" "[3 3 3]" + //"$selfillummaskscale" "10000" + "$additive" "1" + "$nocull" "1" + +} \ No newline at end of file diff --git a/Leader3/content/materials/unloze/ping/ping.vtf b/Leader3/content/materials/unloze/ping/ping.vtf new file mode 100644 index 0000000..cc300b3 Binary files /dev/null and b/Leader3/content/materials/unloze/ping/ping.vtf differ diff --git a/Leader3/content/materials/unloze/ping/ping_cross.vmt b/Leader3/content/materials/unloze/ping/ping_cross.vmt new file mode 100644 index 0000000..054f56a --- /dev/null +++ b/Leader3/content/materials/unloze/ping/ping_cross.vmt @@ -0,0 +1,10 @@ +"VertexLitGeneric" +{ + "$basetexture" "unloze/ping/ping_cross" + "$selfillum" "1" + "$selfillumtint" "[3 3 3]" + //"$selfillummaskscale" "10000" + "$additive" "1" + "$nocull" "1" + +} \ No newline at end of file diff --git a/Leader3/content/materials/unloze/ping/ping_cross.vtf b/Leader3/content/materials/unloze/ping/ping_cross.vtf new file mode 100644 index 0000000..2b01e78 Binary files /dev/null and b/Leader3/content/materials/unloze/ping/ping_cross.vtf differ diff --git a/Leader3/content/materials/unloze/ping/ping_moon.vmt b/Leader3/content/materials/unloze/ping/ping_moon.vmt new file mode 100644 index 0000000..0e4f5d0 --- /dev/null +++ b/Leader3/content/materials/unloze/ping/ping_moon.vmt @@ -0,0 +1,10 @@ +"VertexLitGeneric" +{ + "$basetexture" "unloze/ping/ping_moon" + "$selfillum" "1" + "$selfillumtint" "[3 3 3]" + //"$selfillummaskscale" "10000" + "$additive" "1" + "$nocull" "1" + +} \ No newline at end of file diff --git a/Leader3/content/materials/unloze/ping/ping_moon.vtf b/Leader3/content/materials/unloze/ping/ping_moon.vtf new file mode 100644 index 0000000..accfe63 Binary files /dev/null and b/Leader3/content/materials/unloze/ping/ping_moon.vtf differ diff --git a/Leader3/content/materials/unloze/ping/ping_skull.vmt b/Leader3/content/materials/unloze/ping/ping_skull.vmt new file mode 100644 index 0000000..3342f99 --- /dev/null +++ b/Leader3/content/materials/unloze/ping/ping_skull.vmt @@ -0,0 +1,10 @@ +"VertexLitGeneric" +{ + "$basetexture" "unloze/ping/ping_skull" + "$selfillum" "1" + "$selfillumtint" "[3 3 3]" + //"$selfillummaskscale" "10000" + "$additive" "1" + "$nocull" "1" + +} \ No newline at end of file diff --git a/Leader3/content/materials/unloze/ping/ping_skull.vtf b/Leader3/content/materials/unloze/ping/ping_skull.vtf new file mode 100644 index 0000000..060f8dd Binary files /dev/null and b/Leader3/content/materials/unloze/ping/ping_skull.vtf differ diff --git a/Leader3/content/models/leader3/glow_ct2.dx80.vtx b/Leader3/content/models/leader3/glow_ct2.dx80.vtx new file mode 100644 index 0000000..011c07d Binary files /dev/null and b/Leader3/content/models/leader3/glow_ct2.dx80.vtx differ diff --git a/Leader3/content/models/leader3/glow_ct2.dx90.vtx b/Leader3/content/models/leader3/glow_ct2.dx90.vtx new file mode 100644 index 0000000..f57eb62 Binary files /dev/null and b/Leader3/content/models/leader3/glow_ct2.dx90.vtx differ diff --git a/Leader3/content/models/leader3/glow_ct2.mdl b/Leader3/content/models/leader3/glow_ct2.mdl new file mode 100644 index 0000000..6022eb8 Binary files /dev/null and b/Leader3/content/models/leader3/glow_ct2.mdl differ diff --git a/Leader3/content/models/leader3/glow_ct2.sw.vtx b/Leader3/content/models/leader3/glow_ct2.sw.vtx new file mode 100644 index 0000000..19ffb4e Binary files /dev/null and b/Leader3/content/models/leader3/glow_ct2.sw.vtx differ diff --git a/Leader3/content/models/leader3/glow_ct2.vvd b/Leader3/content/models/leader3/glow_ct2.vvd new file mode 100644 index 0000000..9a01e55 Binary files /dev/null and b/Leader3/content/models/leader3/glow_ct2.vvd differ diff --git a/Leader3/content/models/unloze/unloze_ping.dx80.vtx b/Leader3/content/models/unloze/unloze_ping.dx80.vtx new file mode 100644 index 0000000..b46a1c7 Binary files /dev/null and b/Leader3/content/models/unloze/unloze_ping.dx80.vtx differ diff --git a/Leader3/content/models/unloze/unloze_ping.dx90.vtx b/Leader3/content/models/unloze/unloze_ping.dx90.vtx new file mode 100644 index 0000000..21c2722 Binary files /dev/null and b/Leader3/content/models/unloze/unloze_ping.dx90.vtx differ diff --git a/Leader3/content/models/unloze/unloze_ping.mdl b/Leader3/content/models/unloze/unloze_ping.mdl new file mode 100644 index 0000000..ffd11b3 Binary files /dev/null and b/Leader3/content/models/unloze/unloze_ping.mdl differ diff --git a/Leader3/content/models/unloze/unloze_ping.sw.vtx b/Leader3/content/models/unloze/unloze_ping.sw.vtx new file mode 100644 index 0000000..c2ba389 Binary files /dev/null and b/Leader3/content/models/unloze/unloze_ping.sw.vtx differ diff --git a/Leader3/content/models/unloze/unloze_ping.vvd b/Leader3/content/models/unloze/unloze_ping.vvd new file mode 100644 index 0000000..77ff250 Binary files /dev/null and b/Leader3/content/models/unloze/unloze_ping.vvd differ diff --git a/Leader3/scripting/Leader3.sp b/Leader3/scripting/Leader3.sp new file mode 100644 index 0000000..5333f08 --- /dev/null +++ b/Leader3/scripting/Leader3.sp @@ -0,0 +1,536 @@ +#pragma semicolon 1 + +#define LEADER3_CORE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#undef REQUIRE_PLUGIN +#include +#define REQUIRE_PLUGIN + +#pragma newdecls required + +#define PLUGIN_VERSION "3.0" + +#define PREFIX "\x07FF00E1[Leader] \x01" +#define PREFIX_PLAIN "[Leader]" + +#define MARKER_MODEL "models/unloze/unloze_ping.mdl" +#define MARKER_MODEL_HEIGHT 80.0 // hull_max.z of unloze_ping.mdl +#define MARKER_MODEL_SCALE 1.5 +#define MARKER_TEXT_CLEARANCE 24.0 // headroom kept between the title text and a ceiling above it +#define SPRITE_DEFEND "materials/nide/defend.vmt" +#define SPRITE_FOLLOW "materials/nide/follow.vmt" +// Stock ct_urban patched to pull its materials from materials/models/leader3/glow_ct/ +// (additive + $ignorez -> visible through walls). Bonemerges onto any stock CT skeleton. +// v2: IK chains/rules stripped (header-patched) -- the decompiled ragdoll sequence's IK +// yanked hand/foot bones to world origin on frames where the parent's bones were unavailable. +#define XRAY_MODEL "models/leader3/glow_ct2.mdl" + +// m_fEffects bits (not in SM includes) used by the xray shell +#define EF_BONEMERGE (1 << 0) +#define EF_NOSHADOW (1 << 4) +#define EF_NORECEIVESHADOW (1 << 6) +#define EF_BONEMERGE_FASTCULL (1 << 7) +#define EF_PARENT_ANIMATES (1 << 9) + +// unloze_ping.mdl skins +#define SKIN_STAR 0 +#define SKIN_SKULL 1 +#define SKIN_MOON 2 +#define SKIN_CROSS 3 + +// g_iEffectFlags bits +#define EFFECT_XRAY (1 << 0) +#define EFFECT_BEACON (1 << 1) +#define EFFECT_TRACER_TOGGLED (1 << 2) + +// g_iSpriteChoice +#define SPRITE_NONE -1 +#define SPRITE_CHOICE_DEFEND 0 +#define SPRITE_CHOICE_FOLLOW 1 + +// g_iTracerMode +#define TRACER_MODE_TAP 0 // hold +use OR tap to toggle +#define TRACER_MODE_HOLD 1 // hold +use only +#define TRACER_MODE_OFF 2 + +// Leader roster: userids, index 0 = primary +ArrayList g_alLeaders; +bool g_bIsLeader[MAXPLAYERS + 1]; + +// Per-leader effect state +int g_iEffectFlags[MAXPLAYERS + 1]; +int g_iSpriteChoice[MAXPLAYERS + 1] = { SPRITE_NONE, ... }; +int g_iTracerMode[MAXPLAYERS + 1]; +// Per-leader colour override for xray/beacon/tracer; persisted as a cookie ("RRGGBB" hex). +bool g_bHasCustomColour[MAXPLAYERS + 1]; +int g_iCustomColour[MAXPLAYERS + 1][3]; +Cookie g_hColourCookie; +int g_iXrayRef[MAXPLAYERS + 1] = { INVALID_ENT_REFERENCE, ... }; +// ShouldTransmit vtable hook armed on each xray shell; null if gamedata failed. +Handle g_hXrayShouldTransmit; +int g_iSpriteRef[MAXPLAYERS + 1] = { INVALID_ENT_REFERENCE, ... }; +int g_iBeaconSerial[MAXPLAYERS + 1]; +int g_iSerialGen; +int g_iLastButtons[MAXPLAYERS + 1]; +float g_fUsePressedAt[MAXPLAYERS + 1]; +float g_fNextTracerSend[MAXPLAYERS + 1]; + +// Beam sprites for beacon/tracer +int g_iBeamSprite = -1; +int g_iHaloSprite = -1; + +// Leader whitelist (persisted in the SourceMod database) +Database g_hWhitelistDB; +bool g_bLeaderWhitelisted[MAXPLAYERS + 1]; + +// Force-mute +bool g_bForceLeaderMute; +bool g_bForceMuted[MAXPLAYERS + 1]; + +bool g_bAFKManager; +bool g_bWorldTextBroken; + +GlobalForward g_fwdOnLeaderAdded; +GlobalForward g_fwdOnLeaderRemoved; +GlobalForward g_fwdOnPrimaryChanged; + +ConVar g_cvMaxLeaders; +ConVar g_cvAllowVotes; +ConVar g_cvVoteRatio; +ConVar g_cvVoteAfkTime; +ConVar g_cvMarkerLimit; +ConVar g_cvBeaconRadius; +ConVar g_cvBeaconInterval; +ConVar g_cvTracerLife; +ConVar g_cvTracerWidth; +ConVar g_cvTracerInterval; +ConVar g_cvTracerTapTime; +ConVar g_cvXrayColourPrimary; +ConVar g_cvXrayColourSub; +ConVar g_cvXrayAlpha; +ConVar g_cvDeathSound; + +int g_iXrayColourPrimary[4] = {0, 255, 0, 255}; +int g_iXrayColourSub[4] = {0, 150, 255, 255}; + +#include "leader3/core.sp" +#include "leader3/whitelist.sp" +#include "leader3/effects.sp" +#include "leader3/markers.sp" +#include "leader3/chat.sp" +#include "leader3/vote.sp" +#include "leader3/menu.sp" + +public Plugin myinfo = +{ + name = "Leader3", + author = "marisakirisame.net", + description = "ZE leader plugin: multiple leaders, effects, persistent markers with titles.", + version = PLUGIN_VERSION, + url = "" +}; + +public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max) +{ + CreateNative("Leader_CurrentLeader", Native_CurrentLeader); + CreateNative("Leader_SetLeader", Native_SetLeader); + CreateNative("Leader_Is", Native_Is); + CreateNative("Leader_GetLeaderCount", Native_GetLeaderCount); + CreateNative("Leader_GetLeader", Native_GetLeader); + RegPluginLibrary("leader"); + return APLRes_Success; +} + +public void OnPluginStart() +{ + LoadTranslations("common.phrases"); + LoadTranslations("core.phrases"); + + g_alLeaders = new ArrayList(); + Markers_OnPluginStart(); + Whitelist_OnPluginStart(); + Xray_SetupForceTransmit(); + + g_hColourCookie = new Cookie("leader3_colour", "Leader3 custom effect colour (RRGGBB hex)", CookieAccess_Protected); + for (int i = 1; i <= MaxClients; i++) + { + if (IsClientInGame(i) && !IsFakeClient(i) && AreClientCookiesCached(i)) + LeaderColour_LoadCookie(i); + } + + g_fwdOnLeaderAdded = new GlobalForward("Leader_OnLeaderAdded", ET_Ignore, Param_Cell, Param_Cell); + g_fwdOnLeaderRemoved = new GlobalForward("Leader_OnLeaderRemoved", ET_Ignore, Param_Cell); + g_fwdOnPrimaryChanged = new GlobalForward("Leader_OnPrimaryChanged", ET_Ignore, Param_Cell, Param_Cell); + + CreateConVar("sm_leader_version", PLUGIN_VERSION, "Leader3 version", FCVAR_SPONLY|FCVAR_REPLICATED|FCVAR_NOTIFY); + g_cvMaxLeaders = CreateConVar("sm_leader_max", "4", "Maximum concurrent leaders", FCVAR_NOTIFY, true, 1.0, true, 16.0); + g_cvAllowVotes = CreateConVar("sm_leader_allow_votes", "1", "Allow !voteleader", FCVAR_NOTIFY); + g_cvVoteRatio = CreateConVar("sm_leader_vote_ratio", "0.60", "Ratio of eligible voters needed for !voteleader", FCVAR_NOTIFY, true, 0.05, true, 1.0); + g_cvVoteAfkTime = CreateConVar("sm_leader_vote_afk_time", "180", "Seconds of idle time before a player no longer counts as an eligible voter", FCVAR_NOTIFY); + g_cvMarkerLimit = CreateConVar("sm_leader_marker_limit", "4", "Max markers per leader (one per type, max 4)", FCVAR_NOTIFY, true, 1.0, true, 4.0); + g_cvBeaconRadius = CreateConVar("sm_leader_beacon_radius", "375.0", "Beacon ring max radius", FCVAR_NOTIFY); + g_cvBeaconInterval = CreateConVar("sm_leader_beacon_interval", "1.0", "Beacon ring interval", FCVAR_NOTIFY, true, 0.1); + g_cvTracerLife = CreateConVar("sm_leader_tracer_life", "0.1", "Tracer beam life", FCVAR_NOTIFY); + g_cvTracerWidth = CreateConVar("sm_leader_tracer_width", "2.0", "Tracer beam width", FCVAR_NOTIFY); + g_cvTracerInterval = CreateConVar("sm_leader_tracer_interval", "0.05", "Seconds between tracer beam sends", FCVAR_NOTIFY, true, 0.05); + g_cvTracerTapTime = CreateConVar("sm_leader_tracer_tap_time", "0.3", "Max +use press duration counted as a tap (toggle)", FCVAR_NOTIFY); + g_cvXrayColourPrimary = CreateConVar("sm_leader_xray_colour_primary", "0 255 0", "Xray/tracer RGB for the primary leader", FCVAR_NOTIFY); + g_cvXrayColourSub = CreateConVar("sm_leader_xray_colour_sub", "0 150 255", "Xray/tracer RGB for sub-leaders", FCVAR_NOTIFY); + g_cvXrayAlpha = CreateConVar("sm_leader_xray_alpha", "200", "Xray shell brightness (renderamt of the additive player-model shell)", FCVAR_NOTIFY, true, 1.0, true, 255.0); + g_cvDeathSound = CreateConVar("sm_leader_death_sound", "ambient/alarms/klaxon1.wav", "Sound played to everyone when a leader dies (relative to sound/, empty = off)", FCVAR_NOTIFY); + + g_cvXrayColourPrimary.AddChangeHook(OnXrayColourChanged); + g_cvXrayColourSub.AddChangeHook(OnXrayColourChanged); + g_cvDeathSound.AddChangeHook(OnDeathSoundChanged); + + AutoExecConfig(true, "Leader3"); + + HookEvent("round_start", Event_RoundStart); + HookEvent("player_spawn", Event_PlayerSpawn); + HookEvent("player_death", Event_PlayerDeath); + HookEvent("player_team", Event_PlayerTeam); + + RegConsoleCmd("sm_leader", Cmd_Leader, "Open the leader menu / (admin) set a leader"); + RegConsoleCmd("sm_pingmenu", Cmd_Leader, "Open the leader menu / (admin) set a leader"); + RegConsoleCmd("sm_currentleader", Cmd_Leaders, "List the current leaders"); + RegConsoleCmd("sm_currentleaders", Cmd_Leaders, "List the current leaders"); + RegConsoleCmd("sm_leaders", Cmd_Leaders, "List the current leaders"); + RegConsoleCmd("sm_voteleader", Cmd_VoteLeader, "Vote for a player to become a leader"); + RegConsoleCmd("sm_vl", Cmd_VoteLeader, "Vote for a player to become a leader"); + RegConsoleCmd("sm_leadercolour", Cmd_LeaderColour, "Set your leader colour: sm_leadercolour "); + RegConsoleCmd("sm_leadercolor", Cmd_LeaderColour, "Set your leader colour: sm_leadercolor "); + RegConsoleCmd("sm_marker", Cmd_Marker, "Place a marker: sm_marker "); + RegConsoleCmd("sm_markertitle", Cmd_MarkerTitle, "Set the title of your last marker"); + RegConsoleCmd("sm_clearmarkers", Cmd_ClearMarkers, "Clear your markers"); + RegConsoleCmd("sm_resign", Cmd_LeaderResign, "Resign from being a leader"); + RegAdminCmd("sm_primaryleader", Cmd_PrimaryLeader, ADMFLAG_GENERIC, "Set the primary leader: sm_primaryleader "); + RegAdminCmd("sm_removeleader", Cmd_RemoveLeader, ADMFLAG_GENERIC, "Remove a leader: sm_removeleader "); + RegAdminCmd("sm_addleaderwhitelist", Cmd_AddLeaderWhitelist, ADMFLAG_GENERIC, "Add a player to the leader whitelist: sm_addleaderwhitelist [name]"); + RegAdminCmd("sm_removeleaderwhitelist", Cmd_RemoveLeaderWhitelist, ADMFLAG_GENERIC, "Remove a player from the leader whitelist: sm_removeleaderwhitelist "); + RegAdminCmd("sm_leaderwhitelist", Cmd_LeaderWhitelist, ADMFLAG_GENERIC, "Show the leader whitelist"); + RegAdminCmd("sm_forceleader", Cmd_ForceLeader, ADMFLAG_GENERIC, "Toggle force mute while a leader is talking"); + + CreateTimer(0.5, Chat_CheckLeaderVoice, _, TIMER_REPEAT); + + AddMultiTargetFilter("@leaders", Filter_Leaders, "all Leaders", false); + AddMultiTargetFilter("@!leaders", Filter_NotLeaders, "everyone but the Leaders", false); + AddMultiTargetFilter("@leader", Filter_Leader, "the Primary Leader", false); + AddMultiTargetFilter("@!leader", Filter_NotLeader, "everyone but the Primary Leader", false); +} + +public void OnPluginEnd() +{ + RemoveMultiTargetFilter("@leaders", Filter_Leaders); + RemoveMultiTargetFilter("@!leaders", Filter_NotLeaders); + RemoveMultiTargetFilter("@leader", Filter_Leader); + RemoveMultiTargetFilter("@!leader", Filter_NotLeader); +} + +public void OnAllPluginsLoaded() +{ + g_bAFKManager = LibraryExists("AFKManager"); +} + +public void OnLibraryAdded(const char[] name) +{ + if (StrEqual(name, "AFKManager")) + g_bAFKManager = true; +} + +public void OnLibraryRemoved(const char[] name) +{ + if (StrEqual(name, "AFKManager")) + g_bAFKManager = false; +} + +public void OnMapStart() +{ + g_iBeamSprite = PrecacheModel("sprites/laser.vmt"); + g_iHaloSprite = PrecacheModel("sprites/halo01.vmt"); + g_bWorldTextBroken = false; +} + +public void OnMapEnd() +{ + Core_RemoveAllLeaders(); + Markers_ClearAll(false); +} + +public void OnConfigsExecuted() +{ + PrecacheModel(MARKER_MODEL); + AddFileToDownloadsTable("models/unloze/unloze_ping.mdl"); + AddFileToDownloadsTable("models/unloze/unloze_ping.vvd"); + AddFileToDownloadsTable("models/unloze/unloze_ping.dx80.vtx"); + AddFileToDownloadsTable("models/unloze/unloze_ping.dx90.vtx"); + AddFileToDownloadsTable("models/unloze/unloze_ping.sw.vtx"); + AddFileToDownloadsTable("materials/unloze/ping/ping.vmt"); + AddFileToDownloadsTable("materials/unloze/ping/ping.vtf"); + AddFileToDownloadsTable("materials/unloze/ping/ping_cross.vmt"); + AddFileToDownloadsTable("materials/unloze/ping/ping_cross.vtf"); + AddFileToDownloadsTable("materials/unloze/ping/ping_moon.vmt"); + AddFileToDownloadsTable("materials/unloze/ping/ping_moon.vtf"); + AddFileToDownloadsTable("materials/unloze/ping/ping_skull.vmt"); + AddFileToDownloadsTable("materials/unloze/ping/ping_skull.vtf"); + + AddFileToDownloadsTable(SPRITE_DEFEND); + AddFileToDownloadsTable("materials/nide/defend.vtf"); + AddFileToDownloadsTable(SPRITE_FOLLOW); + AddFileToDownloadsTable("materials/nide/follow.vtf"); + + PrecacheModel(XRAY_MODEL); + AddFileToDownloadsTable(XRAY_MODEL); + AddFileToDownloadsTable("models/leader3/glow_ct2.vvd"); + AddFileToDownloadsTable("models/leader3/glow_ct2.dx80.vtx"); + AddFileToDownloadsTable("models/leader3/glow_ct2.dx90.vtx"); + AddFileToDownloadsTable("models/leader3/glow_ct2.sw.vtx"); + AddFileToDownloadsTable("materials/models/leader3/glow_ct/ct_urban.vmt"); + AddFileToDownloadsTable("materials/models/leader3/glow_ct/ct_urban_glass.vmt"); + AddFileToDownloadsTable("materials/models/leader3/glow_ct/white.vtf"); + + PrecacheGeneric(SPRITE_DEFEND, true); + PrecacheGeneric("materials/nide/defend.vtf", true); + PrecacheGeneric(SPRITE_FOLLOW, true); + PrecacheGeneric("materials/nide/follow.vtf", true); + + char sound[PLATFORM_MAX_PATH]; + g_cvDeathSound.GetString(sound, sizeof(sound)); + if (sound[0]) + PrecacheSound(sound, true); + + ParseXrayColours(); +} + +public void OnDeathSoundChanged(ConVar cvar, const char[] oldVal, const char[] newVal) +{ + if (newVal[0]) + PrecacheSound(newVal, true); +} + +public void OnXrayColourChanged(ConVar cvar, const char[] oldVal, const char[] newVal) +{ + ParseXrayColours(); +} + +void ParseXrayColours() +{ + char buf[32]; + g_cvXrayColourPrimary.GetString(buf, sizeof(buf)); + ParseColourString(buf, g_iXrayColourPrimary); + g_cvXrayColourSub.GetString(buf, sizeof(buf)); + ParseColourString(buf, g_iXrayColourSub); +} + +void ParseColourString(const char[] str, int colour[4]) +{ + char parts[3][8]; + int found = ExplodeString(str, " ", parts, sizeof(parts), sizeof(parts[])); + for (int i = 0; i < 3; i++) + colour[i] = (i < found) ? StringToInt(parts[i]) : 255; + colour[3] = 255; +} + +public void OnClientAuthorized(int client, const char[] auth) +{ + Whitelist_CheckClient(client); +} + +public void OnClientDisconnect(int client) +{ + Vote_OnClientDisconnect(client); + Core_OnClientDisconnect(client); + g_bLeaderWhitelisted[client] = false; + + if (g_bForceMuted[client]) + { + BaseComm_SetClientMute(client, false); + g_bForceMuted[client] = false; + } +} + +public void Event_RoundStart(Event event, const char[] name, bool dontBroadcast) +{ + Markers_OnRoundStart(); + Core_AnnounceLeaders(); +} + +public void Event_PlayerSpawn(Event event, const char[] name, bool dontBroadcast) +{ + int userid = event.GetInt("userid"); + // Attach effects slightly late: at spawn time ZR state and position may not be final. + CreateTimer(0.3, Timer_ResumeEffects, userid, TIMER_FLAG_NO_MAPCHANGE); +} + +public Action Timer_ResumeEffects(Handle timer, any userid) +{ + int client = GetClientOfUserId(userid); + if (client > 0 && IsEffectsActive(client)) + Effects_Resume(client); + return Plugin_Stop; +} + +public void Event_PlayerDeath(Event event, const char[] name, bool dontBroadcast) +{ + int client = GetClientOfUserId(event.GetInt("userid")); + if (client <= 0) + return; + + // Toll and announce only for an active (human) leader falling; an infected leader dying later as a zombie stays quiet. + // ZR_IsClientHuman throws on dead clients, so go by team: zombified leaders sit on T. + bool diedAsHuman = GetClientTeam(client) == CS_TEAM_CT; + + if (IsLeader(client) && diedAsHuman) + { + char sound[PLATFORM_MAX_PATH]; + g_cvDeathSound.GetString(sound, sizeof(sound)); + if (sound[0]) + EmitSoundToAll(sound); + } + + // ZR infection fires a fake player_death (kill feed) while the client is still alive; + // ZR_OnClientInfected announces that one, so only a real death tolls "died" here. + if (IsPlayerAlive(client)) + return; + + int attacker = GetClientOfUserId(event.GetInt("attacker")); + + char verb[80]; + if (attacker > 0 && attacker != client) + Format(verb, sizeof(verb), "was killed by \x04%N\x01", attacker); + else + strcopy(verb, sizeof(verb), "died"); + + Core_OnLeaderIncapacitated(client, verb, diedAsHuman); +} + +public void Event_PlayerTeam(Event event, const char[] name, bool dontBroadcast) +{ + Vote_RecheckAll(); +} + +public void ZR_OnClientInfected(int client, int attacker, bool motherInfect, bool respawnOverride, bool respawn) +{ + char verb[80]; + if (attacker > 0 && attacker <= MaxClients && attacker != client) + Format(verb, sizeof(verb), "has been infected by \x04%N\x01", attacker); + else + strcopy(verb, sizeof(verb), "has been infected"); + + Core_OnLeaderIncapacitated(client, verb); +} + +public void ZR_OnClientHumanPost(int client, bool respawn, bool protect) +{ + if (IsEffectsActive(client)) + Effects_Resume(client); +} + +public Action OnPlayerRunCmd(int client, int &buttons, int &impulse, float vel[3], float angles[3], int &weapon, int &subtype, int &cmdnum, int &tickcount, int &seed, int mouse[2]) +{ + if (!g_bIsLeader[client]) + return Plugin_Continue; + + if (impulse == 100) + { + ShowLeaderMenu(client); + } + else if (impulse == 201 || impulse == -201) // player spray; inverse value comes from SprayManager + { + Marker_PlaceBySpray(client); + } + + Effects_OnPlayerRunCmd(client, buttons); + + return Plugin_Continue; +} + +//---------------------------------------------------------------------------------------------------- +// Natives +//---------------------------------------------------------------------------------------------------- +public int Native_CurrentLeader(Handle plugin, int numParams) +{ + return GetPrimaryLeader(); +} + +public int Native_SetLeader(Handle plugin, int numParams) +{ + int client = GetNativeCell(1); + if (client >= 1 && client <= MaxClients && IsClientInGame(client)) + Core_MakePrimary(client); + return 0; +} + +public int Native_Is(Handle plugin, int numParams) +{ + int client = GetNativeCell(1); + if (client < 1 || client > MaxClients) + return 0; + return g_bIsLeader[client] ? 1 : 0; +} + +public int Native_GetLeaderCount(Handle plugin, int numParams) +{ + return g_alLeaders.Length; +} + +public int Native_GetLeader(Handle plugin, int numParams) +{ + int index = GetNativeCell(1); + if (index < 0 || index >= g_alLeaders.Length) + return -1; + int client = GetClientOfUserId(g_alLeaders.Get(index)); + return client > 0 ? client : -1; +} + +//---------------------------------------------------------------------------------------------------- +// Target filters +//---------------------------------------------------------------------------------------------------- +public bool Filter_Leaders(const char[] pattern, Handle clients) +{ + for (int i = 1; i <= MaxClients; i++) + { + if (g_bIsLeader[i] && IsClientInGame(i)) + PushArrayCell(clients, i); + } + return true; +} + +public bool Filter_NotLeaders(const char[] pattern, Handle clients) +{ + for (int i = 1; i <= MaxClients; i++) + { + if (!g_bIsLeader[i] && IsClientInGame(i) && !IsFakeClient(i)) + PushArrayCell(clients, i); + } + return true; +} + +public bool Filter_Leader(const char[] pattern, Handle clients) +{ + int primary = GetPrimaryLeader(); + if (primary != -1) + PushArrayCell(clients, primary); + return true; +} + +public bool Filter_NotLeader(const char[] pattern, Handle clients) +{ + int primary = GetPrimaryLeader(); + for (int i = 1; i <= MaxClients; i++) + { + if (i != primary && IsClientInGame(i) && !IsFakeClient(i)) + PushArrayCell(clients, i); + } + return true; +} diff --git a/Leader3/scripting/include/leader.inc b/Leader3/scripting/include/leader.inc new file mode 100644 index 0000000..df9f9a3 --- /dev/null +++ b/Leader3/scripting/include/leader.inc @@ -0,0 +1,90 @@ +#if defined _leader_included_ + #endinput +#endif +#define _leader_included_ + +/** + * Returns the current PRIMARY leader. + * + * @return Client index of the primary leader (-1 = no leader) + */ +native int Leader_CurrentLeader(); + +/** + * Makes a client the PRIMARY leader. If another primary exists, they are + * demoted to sub-leader. If the client is already a sub-leader, they are + * promoted. + * + * @param client Client index to be set as primary leader + */ +native void Leader_SetLeader(int client); + +/** + * Checks if a client is currently a leader (primary or sub-leader). + * + * @param client Client index to check + * @return True if the client is a leader + */ +native bool Leader_Is(int client); + +/** + * Returns the number of current leaders (primary + sub-leaders). + * + * @return Leader count + */ +native int Leader_GetLeaderCount(); + +/** + * Returns the leader at the given roster index. + * + * @param index Roster index (0 = primary) + * @return Client index, or -1 if no leader at that index + */ +native int Leader_GetLeader(int index); + +/** + * Called after a client becomes a leader. + * + * @param client The new leader + * @param primary True if they became the primary leader + */ +forward void Leader_OnLeaderAdded(int client, bool primary); + +/** + * Called after a client stops being a leader. + * + * @param client The removed leader + */ +forward void Leader_OnLeaderRemoved(int client); + +/** + * Called when the primary leader changes. + * + * @param oldLeader Previous primary (-1 = none) + * @param newLeader New primary (-1 = none) + */ +forward void Leader_OnPrimaryChanged(int oldLeader, int newLeader); + +#if !defined LEADER3_CORE +public SharedPlugin __pl_leader = +{ + name = "leader", + file = "Leader3.smx", +#if defined REQUIRE_PLUGIN + required = 1, +#else + required = 0, +#endif +}; + +#if !defined REQUIRE_PLUGIN +public void __pl_leader_SetNTVOptional() +{ + MarkNativeAsOptional("Leader_CurrentLeader"); + MarkNativeAsOptional("Leader_SetLeader"); + MarkNativeAsOptional("Leader_Is"); + MarkNativeAsOptional("Leader_GetLeaderCount"); + MarkNativeAsOptional("Leader_GetLeader"); +} +#endif +#endif // LEADER3_CORE diff --git a/Leader3/scripting/leader3/chat.sp b/Leader3/scripting/leader3/chat.sp new file mode 100644 index 0000000..b817ad7 --- /dev/null +++ b/Leader3/scripting/leader3/chat.sp @@ -0,0 +1,105 @@ +//---------------------------------------------------------------------------------------------------- +// Leader3 chat: `title listener, force-mute while a leader talks. +//---------------------------------------------------------------------------------------------------- + +//---------------------------------------------------------------------------------------------------- +// ` in chat sets the title of the leader's last marker. '`' alone clears it. +//---------------------------------------------------------------------------------------------------- +public Action OnClientSayCommand(int client, const char[] command, const char[] sArgs) +{ + if (client == 0 || sArgs[0] != '`' || !IsLeader(client)) + return Plugin_Continue; + + char text[128]; + strcopy(text, sizeof(text), sArgs[1]); + TrimString(text); + + Marker_SetTitle(client, text); + return Plugin_Handled; +} + +//---------------------------------------------------------------------------------------------------- +// Force-mute: while any leader is talking, mute everyone without admin access. +//---------------------------------------------------------------------------------------------------- +public Action Cmd_ForceLeader(int client, int args) +{ + if (g_alLeaders.Length == 0) + { + ReplyToCommand(client, "%s Cannot use this when there is no leader at the moment.", PREFIX_PLAIN); + return Plugin_Handled; + } + + // Enabling is reserved for whitelisted leaders (server console excepted); disabling is not. + if (!g_bForceLeaderMute && client != 0 && !Whitelist_IsWhitelisted(client)) + { + ReplyToCommand(client, "%s Only players on the leader whitelist can enable force mute.", PREFIX_PLAIN); + return Plugin_Handled; + } + + g_bForceLeaderMute = !g_bForceLeaderMute; + + if (g_bForceLeaderMute) + PrintToChatAll("%s\x04%N\x01 activated force mute while a leader is speaking!", PREFIX, client); + else + PrintToChatAll("%s\x04%N\x01 deactivated force mute while a leader is speaking!", PREFIX, client); + + return Plugin_Handled; +} + +bool IsAnyLeaderTalking() +{ + for (int i = 0; i < g_alLeaders.Length; i++) + { + int leader = GetClientOfUserId(g_alLeaders.Get(i)); + if (leader > 0 && IsClientTalking(leader)) + return true; + } + return false; +} + +public Action Chat_CheckLeaderVoice(Handle timer) +{ + if (!g_bForceLeaderMute || g_alLeaders.Length == 0) + { + if (!g_bForceLeaderMute) + Chat_UnmuteForced(); + return Plugin_Continue; + } + + if (IsAnyLeaderTalking()) + { + for (int i = 1; i <= MaxClients; i++) + { + if (IsClientInGame(i) && !g_bIsLeader[i] && !BaseComm_IsClientMuted(i) + && !CheckCommandAccess(i, "sm_admin", ADMFLAG_GENERIC, false)) + { + BaseComm_SetClientMute(i, true); + g_bForceMuted[i] = true; + } + } + + for (int i = 1; i <= MaxClients; i++) + { + if (g_bForceMuted[i] && IsClientInGame(i) && IsClientTalking(i)) + PrintHintText(i, "You are muted right now because a leader is talking!"); + } + } + else + { + Chat_UnmuteForced(); + } + + return Plugin_Continue; +} + +void Chat_UnmuteForced() +{ + for (int i = 1; i <= MaxClients; i++) + { + if (g_bForceMuted[i] && IsClientInGame(i)) + { + BaseComm_SetClientMute(i, false); + g_bForceMuted[i] = false; + } + } +} diff --git a/Leader3/scripting/leader3/core.sp b/Leader3/scripting/leader3/core.sp new file mode 100644 index 0000000..f5ad4e6 --- /dev/null +++ b/Leader3/scripting/leader3/core.sp @@ -0,0 +1,544 @@ +//---------------------------------------------------------------------------------------------------- +// Leader3 core: leader roster (index 0 = primary), add/remove/promote, lifecycle handling. +//---------------------------------------------------------------------------------------------------- + +bool IsValidClient(int client, bool nobots = true) +{ + if (client <= 0 || client > MaxClients || !IsClientConnected(client) || (nobots && IsFakeClient(client))) + return false; + return IsClientInGame(client); +} + +bool IsLeader(int client) +{ + return client >= 1 && client <= MaxClients && g_bIsLeader[client]; +} + +int GetPrimaryLeader() +{ + if (g_alLeaders.Length == 0) + return -1; + int client = GetClientOfUserId(g_alLeaders.Get(0)); + return client > 0 ? client : -1; +} + +bool IsPrimaryLeader(int client) +{ + return client != -1 && client == GetPrimaryLeader(); +} + +// ZR_IsClientZombie/ZR_IsClientHuman throw on dead clients, so the alive check +// has to come first in both helpers. +bool IsLivingZombie(int client) +{ + return IsPlayerAlive(client) && ZR_IsClientZombie(client); +} + +bool IsLivingHuman(int client) +{ + return IsPlayerAlive(client) && ZR_IsClientHuman(client); +} + +// The single gate every visual effect checks. +bool IsEffectsActive(int client) +{ + return IsLeader(client) && IsClientInGame(client) && IsLivingHuman(client); +} + +int GetLeaderRosterIndex(int client) +{ + if (!IsLeader(client)) + return -1; + return g_alLeaders.FindValue(GetClientUserId(client)); +} + +// 'actor' is who performed the promotion (0 = console); -1 or self = no credit shown. +bool AddLeader(int client, bool asPrimary = false, int actor = -1) +{ + if (!IsValidClient(client)) + return false; + + if (g_bIsLeader[client]) + { + if (asPrimary) + Core_MakePrimary(client, actor); + return true; + } + + if (g_alLeaders.Length >= g_cvMaxLeaders.IntValue) + { + PrintToChatAll("%sCannot add \x04%N\x01 as leader: the maximum of %d leaders is reached.", PREFIX, client, g_cvMaxLeaders.IntValue); + return false; + } + + int oldPrimary = GetPrimaryLeader(); + bool primary = asPrimary || g_alLeaders.Length == 0; + + if (primary && g_alLeaders.Length > 0) + { + g_alLeaders.ShiftUp(0); + g_alLeaders.Set(0, GetClientUserId(client)); + } + else + { + g_alLeaders.Push(GetClientUserId(client)); + } + + g_bIsLeader[client] = true; + + // Fresh defaults: xray off, beacon off, no sprite, hold-to-show tracer. + g_iEffectFlags[client] = 0; + g_iSpriteChoice[client] = SPRITE_NONE; + g_iTracerMode[client] = TRACER_MODE_HOLD; + g_iLastButtons[client] = 0; + g_fNextTracerSend[client] = 0.0; + + bool credit = (actor >= 0 && actor != client); + if (primary) + { + if (credit) + PrintToChatAll("%s\x04%N\x01: \x04%N\x01 is now the primary leader! Follow their markers and orders.", PREFIX, actor, client); + else + PrintToChatAll("%s\x04%N\x01 is now the leader! Follow their markers and orders.", PREFIX, client); + PrintToChat(client, "%sYou are now the \x04primary leader\x01! Type \x04!leader\x01 or press your \x04flashlight key\x01 for the menu.", PREFIX); + } + else + { + if (credit) + PrintToChatAll("%s\x04%N\x01: \x04%N\x01 is now a sub-leader!", PREFIX, actor, client); + else + PrintToChatAll("%s\x04%N\x01 is now a sub-leader!", PREFIX, client); + PrintToChat(client, "%sYou are now a \x04sub-leader\x01! Type \x04!leader\x01 or press your \x04flashlight key\x01 for the menu.", PREFIX); + } + + Call_StartForward(g_fwdOnLeaderAdded); + Call_PushCell(client); + Call_PushCell(primary); + Call_Finish(); + + if (primary && oldPrimary != client) + FirePrimaryChanged(oldPrimary, client); + + if (IsEffectsActive(client)) + Effects_Resume(client); + + return true; +} + +// Make a client the primary leader (adding them as leader if needed). +// 'actor' is who performed the promotion (0 = console); -1 or self = no credit shown. +void Core_MakePrimary(int client, int actor = -1) +{ + if (!IsValidClient(client)) + return; + + if (!g_bIsLeader[client]) + { + AddLeader(client, true, actor); + return; + } + + int oldPrimary = GetPrimaryLeader(); + if (oldPrimary == client) + return; + + int idx = GetLeaderRosterIndex(client); + if (idx <= 0) + return; + + g_alLeaders.Erase(idx); + g_alLeaders.ShiftUp(0); + g_alLeaders.Set(0, GetClientUserId(client)); + + if (actor >= 0 && actor != client) + PrintToChatAll("%s\x04%N\x01: \x04%N\x01 is now the primary leader!", PREFIX, actor, client); + else + PrintToChatAll("%s\x04%N\x01 is now the primary leader!", PREFIX, client); + FirePrimaryChanged(oldPrimary, client); + + // Primary/sub xray colours differ: refresh both affected leaders. + Effects_RefreshXray(client); + if (oldPrimary != -1) + Effects_RefreshXray(oldPrimary); +} + +void RemoveLeaderClient(int client, const char[] reason, bool announce = true) +{ + int idx = GetLeaderRosterIndex(client); + if (idx == -1) + return; + + bool wasPrimary = (idx == 0); + + Effects_Suspend(client); + Marker_ClearByOwner(client, false); + g_alLeaders.Erase(idx); + g_bIsLeader[client] = false; + g_iEffectFlags[client] = 0; + g_iSpriteChoice[client] = SPRITE_NONE; + + if (announce) + PrintToChatAll("%s\x04%N\x01 is no longer a leader (%s).", PREFIX, client, reason); + + Call_StartForward(g_fwdOnLeaderRemoved); + Call_PushCell(client); + Call_Finish(); + + if (wasPrimary) + { + int newPrimary = GetPrimaryLeader(); + if (newPrimary != -1) + { + PrintToChatAll("%s\x04%N\x01 has been promoted to primary leader!", PREFIX, newPrimary); + Effects_RefreshXray(newPrimary); + } + FirePrimaryChanged(client, newPrimary); + } +} + +void Core_RemoveAllLeaders(bool announce = false, int actor = -1) +{ + for (int i = 1; i <= MaxClients; i++) + { + if (g_bIsLeader[i]) + { + Effects_Suspend(i); + Marker_ClearByOwner(i, false); + g_bIsLeader[i] = false; + g_iEffectFlags[i] = 0; + g_iSpriteChoice[i] = SPRITE_NONE; + + Call_StartForward(g_fwdOnLeaderRemoved); + Call_PushCell(i); + Call_Finish(); + } + } + int oldPrimary = GetPrimaryLeader(); + g_alLeaders.Clear(); + if (oldPrimary != -1) + FirePrimaryChanged(oldPrimary, -1); + + if (announce) + { + if (actor >= 0) + PrintToChatAll("%sAll leaders have been removed by \x04%N\x01.", PREFIX, actor); + else + PrintToChatAll("%sAll leaders have been removed.", PREFIX); + } +} + +void FirePrimaryChanged(int oldLeader, int newLeader) +{ + Call_StartForward(g_fwdOnPrimaryChanged); + Call_PushCell(oldLeader); + Call_PushCell(newLeader); + Call_Finish(); +} + +// A leader died or got infected: effects off, markers and leadership stay. +void Core_OnLeaderIncapacitated(int client, const char[] verb, bool announce = true) +{ + if (!IsLeader(client)) + return; + + Effects_Suspend(client); + + if (!announce) + return; + + char remaining[256]; + int count = Core_GetActiveLeaderNames(client, remaining, sizeof(remaining)); + + if (count == 0) + PrintToChatAll("%s\x04%N\x01 %s! \x07FF4040No leaders remain!", PREFIX, client, verb); + else if (count == 1) + PrintToChatAll("%s\x04%N\x01 %s! Remaining leader: \x04%s\x01.", PREFIX, client, verb, remaining); + else + PrintToChatAll("%s\x04%N\x01 %s! Remaining leaders: \x04%s\x01.", PREFIX, client, verb, remaining); +} + +// List the leaders still standing (alive humans), excluding 'exclude'. Roster order, primary first. +int Core_GetActiveLeaderNames(int exclude, char[] buf, int maxlen) +{ + int count = 0; + char name[MAX_NAME_LENGTH]; + + for (int i = 0; i < g_alLeaders.Length; i++) + { + int leader = GetClientOfUserId(g_alLeaders.Get(i)); + if (leader <= 0 || leader == exclude) + continue; + + if (!IsLivingHuman(leader)) + continue; + + GetClientName(leader, name, sizeof(name)); + if (buf[0]) + StrCat(buf, maxlen, "\x01, \x04"); + StrCat(buf, maxlen, name); + count++; + } + + return count; +} + +void Core_OnClientDisconnect(int client) +{ + if (g_bIsLeader[client]) + RemoveLeaderClient(client, "disconnected"); + + g_iEffectFlags[client] = 0; + g_iSpriteChoice[client] = SPRITE_NONE; + g_iTracerMode[client] = TRACER_MODE_HOLD; + g_bHasCustomColour[client] = false; + g_iXrayRef[client] = INVALID_ENT_REFERENCE; + g_iSpriteRef[client] = INVALID_ENT_REFERENCE; + g_iBeaconSerial[client] = 0; + g_iLastButtons[client] = 0; +} + +//---------------------------------------------------------------------------------------------------- +// Commands +//---------------------------------------------------------------------------------------------------- +public Action Cmd_Leader(int client, int args) +{ + bool admin = CheckCommandAccess(client, "sm_admin", ADMFLAG_GENERIC, false); + + if (args >= 1) + { + if (!admin) + { + ReplyToCommand(client, "%s Only admins can assign leaders. Use !voteleader instead.", PREFIX_PLAIN); + return Plugin_Handled; + } + + char arg[65], targetName[MAX_TARGET_LENGTH]; + int targets[MAXPLAYERS]; + bool tnIsMl; + GetCmdArg(1, arg, sizeof(arg)); + + int count = ProcessTargetString(arg, client, targets, sizeof(targets), + COMMAND_FILTER_ALIVE | COMMAND_FILTER_NO_BOTS, targetName, sizeof(targetName), tnIsMl); + if (count <= 0) + { + ReplyToTargetError(client, count); + return Plugin_Handled; + } + + for (int i = 0; i < count; i++) + { + int target = targets[i]; + if (g_bIsLeader[target]) + { + ReplyToCommand(client, "%s %N is already a leader.", PREFIX_PLAIN, target); + continue; + } + if (ZR_IsClientZombie(target)) + { + ReplyToCommand(client, "%s %N has to be a human!", PREFIX_PLAIN, target); + continue; + } + if (AddLeader(target, false, client) && count == 1) + ShowLeaderMenu(target); + } + return Plugin_Handled; + } + + if (client == 0) + { + ReplyToCommand(client, "%s Usage: sm_leader ", PREFIX_PLAIN); + return Plugin_Handled; + } + + if (g_bIsLeader[client]) + { + ShowLeaderMenu(client); + return Plugin_Handled; + } + + // Admins may always self-assign as an override; whitelisted players may too. + if (admin || Whitelist_IsWhitelisted(client)) + { + if (!IsPlayerAlive(client)) + { + ReplyToCommand(client, "%s You have to be alive!", PREFIX_PLAIN); + return Plugin_Handled; + } + if (ZR_IsClientZombie(client)) + { + ReplyToCommand(client, "%s You have to be a human!", PREFIX_PLAIN); + return Plugin_Handled; + } + if (AddLeader(client)) + ShowLeaderMenu(client); + return Plugin_Handled; + } + + ReplyToCommand(client, "%s You are not a leader. Ask players to vote for you with !voteleader , or ask an admin to whitelist you.", PREFIX_PLAIN); + return Plugin_Handled; +} + +void Core_AnnounceLeaders() +{ + char buf[512], name[MAX_NAME_LENGTH]; + int count = 0; + + // Index 0 is the primary leader, so the roster order already puts them first. + for (int i = 0; i < g_alLeaders.Length; i++) + { + int leader = GetClientOfUserId(g_alLeaders.Get(i)); + if (leader <= 0) + continue; + + GetClientName(leader, name, sizeof(name)); + if (buf[0]) + StrCat(buf, sizeof(buf), "\x01, \x04"); + StrCat(buf, sizeof(buf), name); + count++; + } + + if (count == 1) + PrintToChatAll("%sYour leader for this round is \x04%s\x01.", PREFIX, buf); + else if (count > 1) + PrintToChatAll("%sYour leaders for this round are \x04%s\x01.", PREFIX, buf); +} + +public Action Cmd_Leaders(int client, int args) +{ + if (g_alLeaders.Length == 0) + { + ReplyToCommand(client, "%s There are no leaders right now.", PREFIX_PLAIN); + return Plugin_Handled; + } + + char buf[512], name[MAX_NAME_LENGTH]; + for (int i = 0; i < g_alLeaders.Length; i++) + { + int leader = GetClientOfUserId(g_alLeaders.Get(i)); + if (leader <= 0) + continue; + + GetClientName(leader, name, sizeof(name)); + if (buf[0]) + StrCat(buf, sizeof(buf), ", "); + StrCat(buf, sizeof(buf), name); + if (i == 0) + StrCat(buf, sizeof(buf), " [PRIMARY]"); + if (!IsLivingHuman(leader)) + StrCat(buf, sizeof(buf), " (dead)"); + } + ReplyToCommand(client, "%s Current leaders: %s", PREFIX_PLAIN, buf); + return Plugin_Handled; +} + +public Action Cmd_LeaderResign(int client, int args) +{ + if (client == 0) + { + ReplyToCommand(client, "%s This command can only be used in-game.", PREFIX_PLAIN); + return Plugin_Handled; + } + + if (!g_bIsLeader[client]) + { + ReplyToCommand(client, "%s You are not a leader.", PREFIX_PLAIN); + return Plugin_Handled; + } + + RemoveLeaderClient(client, "resigned"); + return Plugin_Handled; +} + +public Action Cmd_PrimaryLeader(int client, int args) +{ + if (args < 1) + { + ReplyToCommand(client, "%s Usage: sm_primaryleader ", PREFIX_PLAIN); + return Plugin_Handled; + } + + char arg[65], targetName[MAX_TARGET_LENGTH]; + int targets[MAXPLAYERS]; + bool tnIsMl; + GetCmdArg(1, arg, sizeof(arg)); + + int count = ProcessTargetString(arg, client, targets, sizeof(targets), + COMMAND_FILTER_ALIVE | COMMAND_FILTER_NO_BOTS, targetName, sizeof(targetName), tnIsMl); + if (count <= 0) + { + ReplyToTargetError(client, count); + return Plugin_Handled; + } + if (count > 1) + { + ReplyToCommand(client, "%s There can only be one primary leader, pick a single player.", PREFIX_PLAIN); + return Plugin_Handled; + } + + int target = targets[0]; + if (ZR_IsClientZombie(target)) + { + ReplyToCommand(client, "%s %N has to be a human!", PREFIX_PLAIN, target); + return Plugin_Handled; + } + if (IsPrimaryLeader(target)) + { + ReplyToCommand(client, "%s %N is already the primary leader.", PREFIX_PLAIN, target); + return Plugin_Handled; + } + + Core_MakePrimary(target, client); + LogAction(client, target, "\"%L\" made \"%L\" the primary leader", client, target); + return Plugin_Handled; +} + +public Action Cmd_RemoveLeader(int client, int args) +{ + if (args == 0) + { + if (g_alLeaders.Length == 0) + { + ReplyToCommand(client, "%s There is no leader to remove.", PREFIX_PLAIN); + return Plugin_Handled; + } + Core_RemoveAllLeaders(true, client); + LogAction(client, -1, "\"%L\" removed all leaders", client); + return Plugin_Handled; + } + + char arg[65], targetName[MAX_TARGET_LENGTH]; + int targets[MAXPLAYERS]; + bool tnIsMl; + GetCmdArg(1, arg, sizeof(arg)); + + int count = ProcessTargetString(arg, client, targets, sizeof(targets), + COMMAND_FILTER_CONNECTED | COMMAND_FILTER_NO_BOTS, targetName, sizeof(targetName), tnIsMl); + if (count <= 0) + { + ReplyToTargetError(client, count); + return Plugin_Handled; + } + + int removed = 0; + for (int i = 0; i < count; i++) + { + int target = targets[i]; + + if (!g_bIsLeader[target]) + { + if (count == 1) + ReplyToCommand(client, "%s %N is not a leader.", PREFIX_PLAIN, target); + continue; + } + + char reason[80]; + Format(reason, sizeof(reason), "removed by \x04%N\x01", client); + RemoveLeaderClient(target, reason); + LogAction(client, target, "\"%L\" removed leader \"%L\"", client, target); + removed++; + } + + if (count > 1) + ReplyToCommand(client, "%s Removed %d leader(s) in %s.", PREFIX_PLAIN, removed, targetName); + return Plugin_Handled; +} diff --git a/Leader3/scripting/leader3/effects.sp b/Leader3/scripting/leader3/effects.sp new file mode 100644 index 0000000..cd0f84b --- /dev/null +++ b/Leader3/scripting/leader3/effects.sp @@ -0,0 +1,599 @@ +//---------------------------------------------------------------------------------------------------- +// Leader3 effects: xray shell, overhead sprite, beacon rings, +use view tracer. +//---------------------------------------------------------------------------------------------------- + +void Effects_Resume(int client) +{ + if (!IsEffectsActive(client)) + return; + + if (g_iEffectFlags[client] & EFFECT_XRAY) + Xray_Create(client); + + if (g_iSpriteChoice[client] != SPRITE_NONE) + Sprite_Create(client); + + if ((g_iEffectFlags[client] & EFFECT_BEACON) && g_iBeaconSerial[client] == 0) + Beacon_Start(client); +} + +void Effects_Suspend(int client) +{ + Xray_Kill(client); + Sprite_Kill(client); + Beacon_Stop(client); + g_iEffectFlags[client] &= ~EFFECT_TRACER_TOGGLED; +} + +//---------------------------------------------------------------------------------------------------- +// Xray shell: prop_dynamic_override with the shipped xray model ($additive + $ignorez material, +// so it shows through walls), bonemerged onto the player and tinted with the tier colour. +// SetTransmit hides it from zombies, from the leader themselves, and from first-person spectators +// of the leader. +// +// $ignorez only helps once the client has the entity; CheckTransmit culls edicts against the +// viewer's PVS first, so the shell (and the leader) vanished whenever the viewer was in another +// vis area - thick walls, other side of the map. Fix: strip the shell's edict state down to +// FL_EDICT_FULLCHECK so the engine calls ShouldTransmit() every frame, and DHook that to return +// FL_EDICT_ALWAYS. The engine then calls SetTransmit(pInfo, true), which marks the shell AND its +// move parent (the leader) for sending regardless of PVS, and still runs Hook_XrayShellTransmit, +// so zombies keep seeing nothing. +//---------------------------------------------------------------------------------------------------- +void Xray_SetupForceTransmit() +{ + // SDKHooks' gamedata keeps the SetTransmit vtable index current; ShouldTransmit sits two + // slots before it (ShouldTransmit, UpdateTransmitState, SetTransmit in baseentity.h). + Handle gc = LoadGameConfigFile("sdkhooks.games/engine.ep2v"); + if (gc == null) + { + LogError("Could not load sdkhooks.games/engine.ep2v; leader xray stays PVS-limited."); + return; + } + + int offset = GameConfGetOffset(gc, "SetTransmit"); + delete gc; + if (offset == -1) + { + LogError("No SetTransmit offset in sdkhooks.games/engine.ep2v; leader xray stays PVS-limited."); + return; + } + + g_hXrayShouldTransmit = DHookCreate(offset - 2, HookType_Entity, ReturnType_Int, ThisPointer_Ignore, Hook_XrayShouldTransmit); + DHookAddParam(g_hXrayShouldTransmit, HookParamType_ObjectPtr); // const CCheckTransmitInfo * +} + +public MRESReturn Hook_XrayShouldTransmit(Handle hReturn, Handle hParams) +{ + DHookSetReturn(hReturn, FL_EDICT_ALWAYS); + return MRES_Supercede; +} + +void Xray_Create(int client) +{ + Xray_Kill(client); + + if (!IsEffectsActive(client)) + return; + + int ent = CreateEntityByName("prop_dynamic_override"); + if (ent == -1) + return; + + DispatchKeyValue(ent, "model", XRAY_MODEL); + DispatchKeyValue(ent, "solid", "0"); + DispatchKeyValue(ent, "spawnflags", "256"); + DispatchKeyValue(ent, "disableshadows", "1"); + DispatchKeyValue(ent, "disablereceiveshadows", "1"); + if (!DispatchSpawn(ent)) + { + AcceptEntityInput(ent, "Kill"); + return; + } + SetEntProp(ent, Prop_Send, "m_CollisionGroup", 11); // COLLISION_GROUP_WEAPON: never collides with players + + int colour[4]; + GetLeaderColour(client, colour); + SetEntityRenderMode(ent, RENDER_TRANSADD); + SetEntityRenderColor(ent, colour[0], colour[1], colour[2], g_cvXrayAlpha.IntValue); + + // CBaseEntity::FollowEntity() recipe. The client only bone-merges when the entity has + // EF_BONEMERGE, a move parent AND MOVETYPE_NONE (C_BaseEntity::IsFollowingEntity) -- + // prop_dynamic is MOVETYPE_PUSH and SetParent doesn't change that, hence t-posing shells. + float origin[3], angles[3]; + GetClientAbsOrigin(client, origin); + GetClientAbsAngles(client, angles); + TeleportEntity(ent, origin, angles, NULL_VECTOR); + + SetVariantString("!activator"); + AcceptEntityInput(ent, "SetParent", client, ent); + SetEntityMoveType(ent, MOVETYPE_NONE); + SetEntProp(ent, Prop_Send, "m_fEffects", + EF_BONEMERGE|EF_BONEMERGE_FASTCULL|EF_NOSHADOW|EF_NORECEIVESHADOW|EF_PARENT_ANIMATES); + + SetEntPropEnt(ent, Prop_Send, "m_hOwnerEntity", client); + SDKHook(ent, SDKHook_SetTransmit, Hook_XrayShellTransmit); + + if (g_hXrayShouldTransmit != null) + { + DHookEntity(g_hXrayShouldTransmit, false, ent); + // FULLCHECK is the absence of the other transmit-state flags. + SetEdictFlags(ent, GetEdictFlags(ent) & ~(FL_EDICT_ALWAYS|FL_EDICT_PVSCHECK|FL_EDICT_DONTSEND)); + } + + g_iXrayRef[client] = EntIndexToEntRef(ent); +} + +public Action Hook_XrayShellTransmit(int ent, int client) +{ + // Anything that recomputes transmit state (DispatchUpdateTransmitState) puts PVSCHECK + // back on the edict; strip it so Hook_XrayShouldTransmit resumes overriding PVS culling. + if (g_hXrayShouldTransmit != null) + { + int flags = GetEdictFlags(ent); + if (flags & (FL_EDICT_ALWAYS|FL_EDICT_PVSCHECK)) + SetEdictFlags(ent, flags & ~(FL_EDICT_ALWAYS|FL_EDICT_PVSCHECK)); + } + + int owner = GetEntPropEnt(ent, Prop_Send, "m_hOwnerEntity"); + if (owner == client) + return Plugin_Handled; + + // Humans only: never reveal the leader's through-wall xray to zombies. + if (IsLivingZombie(client)) + return Plugin_Handled; + + // First-person spectators of the owner would get the shell in their face too. + if (!IsPlayerAlive(client) && owner != -1 + && GetEntProp(client, Prop_Send, "m_iObserverMode") == 4 + && GetEntPropEnt(client, Prop_Send, "m_hObserverTarget") == owner) + return Plugin_Handled; + + return Plugin_Continue; +} + +void Xray_Kill(int client) +{ + int ent = EntRefToEntIndex(g_iXrayRef[client]); + g_iXrayRef[client] = INVALID_ENT_REFERENCE; + + if (ent == INVALID_ENT_REFERENCE || !IsValidEdict(ent)) + return; + + char classname[64]; + GetEdictClassname(ent, classname, sizeof(classname)); + if (StrContains(classname, "prop_dynamic") == 0) + AcceptEntityInput(ent, "Kill"); +} + +// Re-create the xray with the right tier colour (used when primary changes). +void Effects_RefreshXray(int client) +{ + if ((g_iEffectFlags[client] & EFFECT_XRAY) && IsEffectsActive(client)) + Xray_Create(client); +} + +void Xray_Toggle(int client) +{ + g_iEffectFlags[client] ^= EFFECT_XRAY; + if (g_iEffectFlags[client] & EFFECT_XRAY) + { + Xray_Create(client); + PrintToChat(client, "%sXray enabled.", PREFIX); + } + else + { + Xray_Kill(client); + PrintToChat(client, "%sXray disabled.", PREFIX); + } +} + +//---------------------------------------------------------------------------------------------------- +// Overhead sprite: 'Defend here' / 'Follow me' +//---------------------------------------------------------------------------------------------------- +void Sprite_Set(int client, int choice) +{ + g_iSpriteChoice[client] = choice; + Sprite_Kill(client); + + if (choice == SPRITE_NONE) + { + PrintToChat(client, "%sSprite removed.", PREFIX); + return; + } + + Sprite_Create(client); + PrintToChat(client, "%sSprite changed to '%s'.", PREFIX, choice == SPRITE_CHOICE_DEFEND ? "Defend Here" : "Follow Me"); +} + +void Sprite_Create(int client) +{ + Sprite_Kill(client); + + if (!IsEffectsActive(client) || g_iSpriteChoice[client] == SPRITE_NONE) + return; + + char mat[PLATFORM_MAX_PATH]; + strcopy(mat, sizeof(mat), g_iSpriteChoice[client] == SPRITE_CHOICE_DEFEND ? SPRITE_DEFEND : SPRITE_FOLLOW); + + g_iSpriteRef[client] = AttachSpriteEnt(client, mat, "0.1", "1", "255 255 255", 45.0); +} + +void Sprite_Kill(int client) +{ + KillSpriteRef(g_iSpriteRef[client]); + g_iSpriteRef[client] = INVALID_ENT_REFERENCE; +} + +//---------------------------------------------------------------------------------------------------- +// env_sprite attach for the overhead sprite (Leader2's targetname-rename parenting trick). +//---------------------------------------------------------------------------------------------------- +int AttachSpriteEnt(int client, const char[] material, const char[] scale, const char[] rendermode, const char[] colour, float zOffset) +{ + if (!IsPlayerAlive(client)) + return INVALID_ENT_REFERENCE; + + char targetname[16], oldTargetname[64]; + GetEntPropString(client, Prop_Data, "m_iName", oldTargetname, sizeof(oldTargetname)); + Format(targetname, sizeof(targetname), "Client%d", client); + DispatchKeyValue(client, "targetname", targetname); + + float origin[3]; + GetClientEyePosition(client, origin); + origin[2] += zOffset; + + int ent = CreateEntityByName("env_sprite"); + if (ent == -1) + { + DispatchKeyValue(client, "targetname", oldTargetname); + return INVALID_ENT_REFERENCE; + } + + DispatchKeyValue(ent, "model", material); + DispatchKeyValue(ent, "classname", "env_sprite"); + DispatchKeyValue(ent, "spawnflags", "1"); + DispatchKeyValue(ent, "scale", scale); + DispatchKeyValue(ent, "rendermode", rendermode); + DispatchKeyValue(ent, "rendercolor", colour); + DispatchSpawn(ent); + TeleportEntity(ent, origin, NULL_VECTOR, NULL_VECTOR); + SetVariantString(targetname); + AcceptEntityInput(ent, "SetParent", ent, ent, 0); + + DispatchKeyValue(client, "targetname", oldTargetname); + + return EntIndexToEntRef(ent); +} + +void KillSpriteRef(int ref) +{ + if (ref == INVALID_ENT_REFERENCE) + return; + + int ent = EntRefToEntIndex(ref); + if (ent == INVALID_ENT_REFERENCE || !IsValidEdict(ent)) + return; + + char classname[64]; + GetEdictClassname(ent, classname, sizeof(classname)); + if (strcmp(classname, "env_sprite") == 0) + AcceptEntityInput(ent, "Kill"); +} + +//---------------------------------------------------------------------------------------------------- +// Beacon (serial-guarded repeating timer, Leader2/Couch ring pattern) +//---------------------------------------------------------------------------------------------------- +void Beacon_Toggle(int client) +{ + g_iEffectFlags[client] ^= EFFECT_BEACON; + if (g_iEffectFlags[client] & EFFECT_BEACON) + { + if (IsEffectsActive(client)) + Beacon_Start(client); + PrintToChat(client, "%sBeacon enabled.", PREFIX); + LogAction(client, client, "\"%L\" set a leader beacon on himself", client); + } + else + { + Beacon_Stop(client); + PrintToChat(client, "%sBeacon disabled.", PREFIX); + } +} + +void Beacon_Start(int client) +{ + g_iBeaconSerial[client] = ++g_iSerialGen; + CreateTimer(g_cvBeaconInterval.FloatValue, Timer_Beacon, client | (g_iSerialGen << 7), TIMER_REPEAT|TIMER_FLAG_NO_MAPCHANGE); +} + +void Beacon_Stop(int client) +{ + g_iBeaconSerial[client] = 0; +} + +public Action Timer_Beacon(Handle timer, any value) +{ + int client = value & 0x7f; + int serial = value >> 7; + + if (g_iBeaconSerial[client] != serial || !IsClientInGame(client) || !IsPlayerAlive(client)) + { + if (g_iBeaconSerial[client] == serial) + g_iBeaconSerial[client] = 0; + return Plugin_Stop; + } + + float vec[3]; + GetClientAbsOrigin(client, vec); + vec[2] += 10.0; + + float radius = g_cvBeaconRadius.FloatValue; + + int tierColour[4]; + GetLeaderColour(client, tierColour); + TE_SetupBeamRingPoint(vec, 10.0, radius, g_iBeamSprite, g_iHaloSprite, 0, 15, 0.5, 5.0, 0.0, tierColour, 10, 0); + TE_SendToAll(); + + int rainbowColour[4]; + float gameTime = GetGameTime(); + rainbowColour[0] = RoundFloat(Sine(2.5 * gameTime + 0.0) * 127.0 + 128.0); + rainbowColour[1] = RoundFloat(Sine(2.5 * gameTime + 2.0943951) * 127.0 + 128.0); + rainbowColour[2] = RoundFloat(Sine(2.5 * gameTime + 4.1887902) * 127.0 + 128.0); + rainbowColour[3] = 255; + + TE_SetupBeamRingPoint(vec, 10.0, radius, g_iBeamSprite, g_iHaloSprite, 0, 10, 0.6, 10.0, 0.5, rainbowColour, 10, 0); + TE_SendToAll(); + + return Plugin_Continue; +} + +void GetLeaderColour(int client, int colour[4]) +{ + if (g_bHasCustomColour[client]) + { + colour[0] = g_iCustomColour[client][0]; + colour[1] = g_iCustomColour[client][1]; + colour[2] = g_iCustomColour[client][2]; + } + else if (IsPrimaryLeader(client)) + { + colour[0] = g_iXrayColourPrimary[0]; + colour[1] = g_iXrayColourPrimary[1]; + colour[2] = g_iXrayColourPrimary[2]; + } + else + { + colour[0] = g_iXrayColourSub[0]; + colour[1] = g_iXrayColourSub[1]; + colour[2] = g_iXrayColourSub[2]; + } + colour[3] = 255; +} + +//---------------------------------------------------------------------------------------------------- +// Custom leader colour: !leadercolour . Applies to xray, beacon and tracer. +//---------------------------------------------------------------------------------------------------- +static const char g_sColourNames[][] = { + "red", "orange", "yellow", "lime", "green", "cyan", + "blue", "purple", "magenta", "pink", "white" +}; +static const int g_iColourValues[][3] = { + {255, 0, 0}, {255, 128, 0}, {255, 255, 0}, {128, 255, 0}, {0, 255, 0}, {0, 255, 255}, + {0, 100, 255}, {160, 0, 255}, {255, 0, 255}, {255, 105, 180}, {255, 255, 255} +}; + +public Action Cmd_LeaderColour(int client, int args) +{ + if (client == 0) + { + ReplyToCommand(client, "%s This command can only be used in-game.", PREFIX_PLAIN); + return Plugin_Handled; + } + + if (!IsLeader(client)) + { + PrintToChat(client, "%sOnly leaders can set a leader colour.", PREFIX); + return Plugin_Handled; + } + + if (args == 0) + { + LeaderColour_PrintUsage(client); + return Plugin_Handled; + } + + char arg[32]; + GetCmdArg(1, arg, sizeof(arg)); + TrimString(arg); + + if (StrEqual(arg, "default", false) || StrEqual(arg, "reset", false)) + { + g_bHasCustomColour[client] = false; + LeaderColour_SaveCookie(client); + Effects_RefreshXray(client); + PrintToChat(client, "%sColour reset to the default %s colour.", PREFIX, + IsPrimaryLeader(client) ? "primary leader" : "sub-leader"); + return Plugin_Handled; + } + + int rgb[3]; + if (!ParseCustomColour(arg, rgb)) + { + LeaderColour_PrintUsage(client); + return Plugin_Handled; + } + + // Refuse colours too dark to see as a xray tint or beam. + if (rgb[0] < 40 && rgb[1] < 40 && rgb[2] < 40) + { + PrintToChat(client, "%sThat colour is too dark to be visible. Pick a brighter one.", PREFIX); + return Plugin_Handled; + } + + g_iCustomColour[client][0] = rgb[0]; + g_iCustomColour[client][1] = rgb[1]; + g_iCustomColour[client][2] = rgb[2]; + g_bHasCustomColour[client] = true; + LeaderColour_SaveCookie(client); + Effects_RefreshXray(client); + + PrintToChat(client, "%sColour set to \x07%02X%02X%02X#%02X%02X%02X\x01.", PREFIX, + rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]); + return Plugin_Handled; +} + +public void OnClientCookiesCached(int client) +{ + if (!IsFakeClient(client)) + LeaderColour_LoadCookie(client); +} + +void LeaderColour_LoadCookie(int client) +{ + char buf[16]; + g_hColourCookie.Get(client, buf, sizeof(buf)); + + int rgb[3]; + if (buf[0] && ParseCustomColour(buf, rgb)) + { + g_iCustomColour[client][0] = rgb[0]; + g_iCustomColour[client][1] = rgb[1]; + g_iCustomColour[client][2] = rgb[2]; + g_bHasCustomColour[client] = true; + } +} + +void LeaderColour_SaveCookie(int client) +{ + if (g_bHasCustomColour[client]) + { + char buf[8]; + Format(buf, sizeof(buf), "%02X%02X%02X", + g_iCustomColour[client][0], g_iCustomColour[client][1], g_iCustomColour[client][2]); + g_hColourCookie.Set(client, buf); + } + else + { + g_hColourCookie.Set(client, ""); + } +} + +void LeaderColour_PrintUsage(int client) +{ + char names[256]; + for (int i = 0; i < sizeof(g_sColourNames); i++) + { + if (names[0]) + StrCat(names, sizeof(names), ", "); + StrCat(names, sizeof(names), g_sColourNames[i]); + } + PrintToChat(client, "%sUsage: \x04!leadercolour \x01", PREFIX); + PrintToChat(client, "%sColours: \x04%s\x01, or a hex code like \x04FF66CC\x01.", PREFIX, names); +} + +bool ParseCustomColour(const char[] str, int rgb[3]) +{ + // Colour name + for (int i = 0; i < sizeof(g_sColourNames); i++) + { + if (StrEqual(str, g_sColourNames[i], false)) + { + rgb[0] = g_iColourValues[i][0]; + rgb[1] = g_iColourValues[i][1]; + rgb[2] = g_iColourValues[i][2]; + return true; + } + } + + // Hex code: RRGGBB, optionally prefixed with # or 0x + int offset = 0; + if (str[0] == '#') + offset = 1; + else if (str[0] == '0' && (str[1] == 'x' || str[1] == 'X')) + offset = 2; + + if (strlen(str[offset]) != 6) + return false; + + int value; + if (StringToIntEx(str[offset], value, 16) != 6) + return false; + + rgb[0] = (value >> 16) & 0xFF; + rgb[1] = (value >> 8) & 0xFF; + rgb[2] = value & 0xFF; + return true; +} + +//---------------------------------------------------------------------------------------------------- +// View tracer: hold +use to show, tap +use to toggle. Visual only; buttons are never modified. +//---------------------------------------------------------------------------------------------------- +void Effects_OnPlayerRunCmd(int client, int buttons) +{ + bool pressed = (buttons & IN_USE) && !(g_iLastButtons[client] & IN_USE); + bool released = !(buttons & IN_USE) && (g_iLastButtons[client] & IN_USE); + g_iLastButtons[client] = buttons; + + if (!IsEffectsActive(client) || g_iTracerMode[client] == TRACER_MODE_OFF) + return; + + if (pressed) + { + g_fUsePressedAt[client] = GetGameTime(); + } + else if (released && g_iTracerMode[client] == TRACER_MODE_TAP + && GetGameTime() - g_fUsePressedAt[client] < g_cvTracerTapTime.FloatValue) + { + g_iEffectFlags[client] ^= EFFECT_TRACER_TOGGLED; + } + + bool active = (buttons & IN_USE) != 0 || (g_iEffectFlags[client] & EFFECT_TRACER_TOGGLED) != 0; + if (!active) + return; + + float now = GetGameTime(); + if (now < g_fNextTracerSend[client]) + return; + g_fNextTracerSend[client] = now + g_cvTracerInterval.FloatValue; + + Tracer_Draw(client); +} + +void Tracer_Draw(int client) +{ + float endPos[3]; + if (!TracePlayerAngles(client, endPos)) + return; + + float startPos[3]; + GetClientEyePosition(client, startPos); + startPos[2] -= 5.0; // keep the beam out of the leader's own crosshair + + int colour[4]; + GetLeaderColour(client, colour); + + TE_SetupBeamPoints(startPos, endPos, g_iBeamSprite, g_iHaloSprite, 0, 0, + g_cvTracerLife.FloatValue, g_cvTracerWidth.FloatValue, g_cvTracerWidth.FloatValue, + 0, 0.0, colour, 0); + TE_SendToAll(); +} + +void Tracer_CycleMode(int client) +{ + // Rotate Hold + Tap-toggle -> Hold only -> Off -> Hold + Tap-toggle. + switch (g_iTracerMode[client]) + { + case TRACER_MODE_TAP: g_iTracerMode[client] = TRACER_MODE_HOLD; + case TRACER_MODE_HOLD: g_iTracerMode[client] = TRACER_MODE_OFF; + default: g_iTracerMode[client] = TRACER_MODE_TAP; + } + g_iEffectFlags[client] &= ~EFFECT_TRACER_TOGGLED; + + char mode[40]; + switch (g_iTracerMode[client]) + { + case TRACER_MODE_TAP: mode = "hold +USE OR tap to toggle"; + case TRACER_MODE_HOLD: mode = "hold +USE only"; + default: mode = "off"; + } + PrintToChat(client, "%sTracer mode: \x04%s\x01.", PREFIX, mode); +} diff --git a/Leader3/scripting/leader3/markers.sp b/Leader3/scripting/leader3/markers.sp new file mode 100644 index 0000000..733cbf6 --- /dev/null +++ b/Leader3/scripting/leader3/markers.sp @@ -0,0 +1,626 @@ +//---------------------------------------------------------------------------------------------------- +// Leader3 markers: unloze_ping prop_dynamic markers with point_worldtext titles. +// The ArrayList is the authoritative store; entities are respawned from it every round. +//---------------------------------------------------------------------------------------------------- + +enum struct Marker +{ + int ownerUserId; + char ownerName[MAX_NAME_LENGTH]; + int skin; + float pos[3]; + char title[128]; + float placedAt; + int propRef; + int textRef; +} + +ArrayList g_alMarkers; + +void Markers_OnPluginStart() +{ + g_alMarkers = new ArrayList(sizeof(Marker)); +} + +void SkinName(int skin, char[] buf, int maxlen) +{ + switch (skin) + { + case SKIN_SKULL: strcopy(buf, maxlen, "Skull"); + case SKIN_MOON: strcopy(buf, maxlen, "Moon"); + case SKIN_CROSS: strcopy(buf, maxlen, "Cross"); + default: strcopy(buf, maxlen, "Star"); + } +} + +int SkinFromName(const char[] name) +{ + if (StrEqual(name, "star", false)) return SKIN_STAR; + if (StrEqual(name, "skull", false)) return SKIN_SKULL; + if (StrEqual(name, "moon", false)) return SKIN_MOON; + if (StrEqual(name, "cross", false)) return SKIN_CROSS; + return -1; +} + +//---------------------------------------------------------------------------------------------------- +// Placement +//---------------------------------------------------------------------------------------------------- +void Marker_Place(int client, int skin) +{ + if (!IsLeader(client)) + return; + + // Dead and spectating leaders may keep placing markers (leading from spec); + // only living zombies are blocked. + if (IsLivingZombie(client)) + { + PrintToChat(client, "%sYou cannot place markers as a zombie.", PREFIX); + return; + } + + float pos[3]; + if (!TracePlayerAngles(client, pos)) + { + PrintToChat(client, "%sCould not find a spot to place the marker on.", PREFIX); + return; + } + + char skinName[16]; + SkinName(skin, skinName, sizeof(skinName)); + + int userid = GetClientUserId(client); + int existing = FindMarkerByOwnerSkin(userid, skin); + + Marker marker; + if (existing != -1) + { + // Moving keeps the title and stays out of chat. The prop and text entities are + // teleported rather than respawned so repeated moves don't churn edicts. + g_alMarkers.GetArray(existing, marker); + marker.pos = pos; + marker.placedAt = GetGameTime(); + GetClientName(client, marker.ownerName, sizeof(marker.ownerName)); + g_alMarkers.SetArray(existing, marker); + Marker_MoveProp(existing); + Marker_UpdateText(existing); + } + else + { + if (CountMarkersByOwner(userid) >= g_cvMarkerLimit.IntValue) + { + PrintToChat(client, "%sMarker limit reached (%d). Replace one of your markers or use \x04!clearmarkers\x01.", PREFIX, g_cvMarkerLimit.IntValue); + return; + } + + marker.ownerUserId = userid; + GetClientName(client, marker.ownerName, sizeof(marker.ownerName)); + marker.skin = skin; + marker.pos = pos; + marker.title[0] = '\0'; + marker.placedAt = GetGameTime(); + marker.propRef = INVALID_ENT_REFERENCE; + marker.textRef = INVALID_ENT_REFERENCE; + + int idx = g_alMarkers.PushArray(marker); + SpawnMarkerProp(idx); + PrintToChatAll("%s\x04%N\x01 placed a \x04%s\x01 marker.", PREFIX, client, skinName); + PrintToChat(client, "%sType \x04`your text\x01 in chat to put a title on it.", PREFIX); + } +} + +void Marker_PlaceBySpray(int client) +{ + Marker_Place(client, SKIN_STAR); +} + +int FindMarkerByOwnerSkin(int userid, int skin) +{ + Marker marker; + for (int i = 0; i < g_alMarkers.Length; i++) + { + g_alMarkers.GetArray(i, marker); + if (marker.ownerUserId == userid && marker.skin == skin) + return i; + } + return -1; +} + +bool Marker_GetTitle(int client, int skin, char[] buf, int maxlen) +{ + int idx = FindMarkerByOwnerSkin(GetClientUserId(client), skin); + if (idx == -1) + return false; + + Marker marker; + g_alMarkers.GetArray(idx, marker); + if (marker.title[0] == '\0') + return false; + + strcopy(buf, maxlen, marker.title); + return true; +} + +int CountMarkersByOwner(int userid) +{ + int count = 0; + Marker marker; + for (int i = 0; i < g_alMarkers.Length; i++) + { + g_alMarkers.GetArray(i, marker); + if (marker.ownerUserId == userid) + count++; + } + return count; +} + +//---------------------------------------------------------------------------------------------------- +// Titles +//---------------------------------------------------------------------------------------------------- + +// Titles typed entirely in lowercase are uppercased; mixed-case titles are kept as typed. +void UppercaseAllLowerTitle(char[] text) +{ + bool hasLower = false; + for (int i = 0; text[i] != '\0'; i++) + { + int bytes = IsCharMB(text[i]); + if (bytes > 1) + { + i += bytes - 1; + continue; + } + if (IsCharUpper(text[i])) + return; + if (IsCharLower(text[i])) + hasLower = true; + } + + if (!hasLower) + return; + + for (int i = 0; text[i] != '\0'; i++) + { + int bytes = IsCharMB(text[i]); + if (bytes > 1) + { + i += bytes - 1; + continue; + } + text[i] = CharToUpper(text[i]); + } +} + +void Marker_SetTitle(int client, const char[] text) +{ + int userid = GetClientUserId(client); + + // Most recently placed marker owned by this leader. + int lastIdx = -1; + float lastTime = -1.0; + Marker marker; + for (int i = 0; i < g_alMarkers.Length; i++) + { + g_alMarkers.GetArray(i, marker); + if (marker.ownerUserId == userid && marker.placedAt > lastTime) + { + lastTime = marker.placedAt; + lastIdx = i; + } + } + + if (lastIdx == -1) + { + PrintToChat(client, "%sYou have no marker to title. Place one first.", PREFIX); + return; + } + + g_alMarkers.GetArray(lastIdx, marker); + strcopy(marker.title, sizeof(marker.title), text); + UppercaseAllLowerTitle(marker.title); + g_alMarkers.SetArray(lastIdx, marker); + Marker_UpdateText(lastIdx); + + char skinName[16]; + SkinName(marker.skin, skinName, sizeof(skinName)); + + if (text[0] == '\0') + { + PrintToChat(client, "%sTitle removed from your \x04%s\x01 marker.", PREFIX, skinName); + return; + } + PrintToChatAll("%s\x04%N\x01 marked \x04%s\x01: \x04%s", PREFIX, client, skinName, marker.title); +} + +//---------------------------------------------------------------------------------------------------- +// Entity lifecycle +//---------------------------------------------------------------------------------------------------- +void SpawnMarkerProp(int idx) +{ + Marker marker; + g_alMarkers.GetArray(idx, marker); + + int ent = CreateEntityByName("prop_dynamic"); + if (ent == -1) + return; + + SetEntityModel(ent, MARKER_MODEL); + DispatchKeyValueFloat(ent, "modelscale", MARKER_MODEL_SCALE); + + char skin[4]; + IntToString(marker.skin, skin, sizeof(skin)); + SetVariantString(skin); + AcceptEntityInput(ent, "Skin"); + + DispatchSpawn(ent); + TeleportEntity(ent, marker.pos, NULL_VECTOR, NULL_VECTOR); + + marker.propRef = EntIndexToEntRef(ent); + g_alMarkers.SetArray(idx, marker); +} + +void SpawnMarkerText(int idx) +{ + Marker marker; + g_alMarkers.GetArray(idx, marker); + + if (marker.title[0] == '\0' || g_bWorldTextBroken) + return; + + int ent = CreateEntityByName("point_worldtext"); + if (ent == -1) + { + // Engine build without point_worldtext: titles stay chat-only for this map. + g_bWorldTextBroken = true; + LogMessage("point_worldtext could not be created; marker titles will not render in the world."); + return; + } + + char text[192]; + Format(text, sizeof(text), "%s", marker.title); + + DispatchKeyValue(ent, "message", text); + DispatchKeyValue(ent, "font", "10"); + DispatchKeyValue(ent, "textsize", "12"); + DispatchKeyValue(ent, "color", "255 255 255"); + DispatchKeyValue(ent, "orientation", "1"); + DispatchKeyValue(ent, "rainbow", "1"); + DispatchSpawn(ent); + + float pos[3]; + ComputeMarkerTextPos(marker, pos); + TeleportEntity(ent, pos, NULL_VECTOR, NULL_VECTOR); + + marker.textRef = EntIndexToEntRef(ent); + g_alMarkers.SetArray(idx, marker); +} + +void ComputeMarkerTextPos(const Marker marker, float pos[3]) +{ + pos = marker.pos; + // unloze_ping.mdl is 80 units tall and spawned at modelscale 1.5 (120 units). + pos[2] += MARKER_MODEL_HEIGHT * MARKER_MODEL_SCALE + 8.0; + + // Markers placed in low rooms would put the text inside the ceiling: trace up + // from the marker base and keep the text MARKER_TEXT_CLEARANCE below any hit. + float up[3]; + up = marker.pos; + up[2] = pos[2] + MARKER_TEXT_CLEARANCE; + Handle trace = TR_TraceRayFilterEx(marker.pos, up, MASK_SOLID_BRUSHONLY, RayType_EndPoint, TraceFilter_NoPlayers); + if (TR_DidHit(trace)) + { + float hit[3]; + TR_GetEndPosition(hit, trace); + pos[2] = hit[2] - MARKER_TEXT_CLEARANCE; + } + delete trace; +} + +// Teleports the existing marker prop to the stored position, spawning one only if the +// reference went stale. +void Marker_MoveProp(int idx) +{ + Marker marker; + g_alMarkers.GetArray(idx, marker); + + int ent = LiveEntRef(marker.propRef, "prop_dynamic"); + if (ent == -1) + { + SpawnMarkerProp(idx); + return; + } + + TeleportEntity(ent, marker.pos, NULL_VECTOR, NULL_VECTOR); +} + +// Retitles/moves the existing point_worldtext in place (SetText is networked via +// SendPropString) instead of respawning it, so retitles and moves don't churn edicts. +void Marker_UpdateText(int idx) +{ + Marker marker; + g_alMarkers.GetArray(idx, marker); + + if (marker.title[0] == '\0') + { + KillEntRef(marker.textRef, "point_worldtext"); + marker.textRef = INVALID_ENT_REFERENCE; + g_alMarkers.SetArray(idx, marker); + return; + } + + int ent = LiveEntRef(marker.textRef, "point_worldtext"); + if (ent == -1) + { + SpawnMarkerText(idx); + return; + } + + SetVariantString(marker.title); + AcceptEntityInput(ent, "SetText"); + + float pos[3]; + ComputeMarkerTextPos(marker, pos); + TeleportEntity(ent, pos, NULL_VECTOR, NULL_VECTOR); +} + +void Marker_KillEnts(Marker marker) +{ + KillEntRef(marker.propRef, "prop_dynamic"); + KillEntRef(marker.textRef, "point_worldtext"); +} + +void KillEntRef(int ref, const char[] expectedClass) +{ + int ent = LiveEntRef(ref, expectedClass); + if (ent != -1) + AcceptEntityInput(ent, "Kill"); +} + +// Resolves an entity reference, returning -1 unless it is still a live edict of the +// expected class. +int LiveEntRef(int ref, const char[] expectedClass) +{ + if (ref == INVALID_ENT_REFERENCE) + return -1; + + int ent = EntRefToEntIndex(ref); + if (ent == INVALID_ENT_REFERENCE || !IsValidEdict(ent)) + return -1; + + char classname[64]; + GetEdictClassname(ent, classname, sizeof(classname)); + return strcmp(classname, expectedClass) == 0 ? ent : -1; +} + +// Round transitions delete runtime entities: rebuild every marker from the stored data. +void Markers_OnRoundStart() +{ + Marker marker; + for (int i = 0; i < g_alMarkers.Length; i++) + { + g_alMarkers.GetArray(i, marker); + marker.propRef = INVALID_ENT_REFERENCE; + marker.textRef = INVALID_ENT_REFERENCE; + g_alMarkers.SetArray(i, marker); + + SpawnMarkerProp(i); + SpawnMarkerText(i); + } +} + +void Markers_ClearAll(bool killEnts = true) +{ + if (killEnts) + { + Marker marker; + for (int i = 0; i < g_alMarkers.Length; i++) + { + g_alMarkers.GetArray(i, marker); + Marker_KillEnts(marker); + } + } + g_alMarkers.Clear(); +} + +void Marker_ClearByOwner(int client, bool announce = true) +{ + int userid = GetClientUserId(client); + int removed = 0; + + Marker marker; + for (int i = g_alMarkers.Length - 1; i >= 0; i--) + { + g_alMarkers.GetArray(i, marker); + if (marker.ownerUserId != userid) + continue; + + Marker_KillEnts(marker); + g_alMarkers.Erase(i); + removed++; + } + + if (!announce) + return; + + if (removed > 0) + PrintToChat(client, "%sRemoved %d of your markers.", PREFIX, removed); + else + PrintToChat(client, "%sYou have no markers.", PREFIX); +} + +//---------------------------------------------------------------------------------------------------- +// Commands +//---------------------------------------------------------------------------------------------------- +public Action Cmd_Marker(int client, int args) +{ + if (client == 0) + { + ReplyToCommand(client, "%s Cannot use this from the console.", PREFIX_PLAIN); + return Plugin_Handled; + } + + if (!IsLeader(client)) + { + ReplyToCommand(client, "%s Only leaders can place markers.", PREFIX_PLAIN); + return Plugin_Handled; + } + + if (args < 1) + { + ReplyToCommand(client, "%s Usage: sm_marker ", PREFIX_PLAIN); + return Plugin_Handled; + } + + char arg[16]; + GetCmdArg(1, arg, sizeof(arg)); + int skin = SkinFromName(arg); + if (skin == -1) + { + ReplyToCommand(client, "%s Unknown marker type '%s'. Use star, skull, moon or cross.", PREFIX_PLAIN, arg); + return Plugin_Handled; + } + + Marker_Place(client, skin); + return Plugin_Handled; +} + +public Action Cmd_MarkerTitle(int client, int args) +{ + if (client == 0) + { + ReplyToCommand(client, "%s Cannot use this from the console.", PREFIX_PLAIN); + return Plugin_Handled; + } + + if (!IsLeader(client)) + { + ReplyToCommand(client, "%s Only leaders can title markers.", PREFIX_PLAIN); + return Plugin_Handled; + } + + char text[128]; + GetCmdArgString(text, sizeof(text)); + StripQuotes(text); + TrimString(text); + + Marker_SetTitle(client, text); + return Plugin_Handled; +} + +public Action Cmd_ClearMarkers(int client, int args) +{ + if (client == 0) + { + Markers_ClearAll(); + ReplyToCommand(client, "%s All markers cleared.", PREFIX_PLAIN); + return Plugin_Handled; + } + + if (args >= 1 && CheckCommandAccess(client, "sm_admin", ADMFLAG_GENERIC, false)) + { + char arg[65]; + GetCmdArg(1, arg, sizeof(arg)); + if (StrEqual(arg, "all", false)) + { + Markers_ClearAll(); + PrintToChatAll("%sAll markers have been cleared by \x04%N\x01.", PREFIX, client); + return Plugin_Handled; + } + + char targetName[MAX_TARGET_LENGTH]; + int targets[MAXPLAYERS]; + bool tnIsMl; + int count = ProcessTargetString(arg, client, targets, sizeof(targets), + COMMAND_FILTER_CONNECTED | COMMAND_FILTER_NO_BOTS, targetName, sizeof(targetName), tnIsMl); + if (count <= 0) + { + ReplyToTargetError(client, count); + return Plugin_Handled; + } + + for (int i = 0; i < count; i++) + Marker_ClearByOwner(targets[i]); + ReplyToCommand(client, "%s Cleared markers of %s.", PREFIX_PLAIN, targetName); + return Plugin_Handled; + } + + if (!IsLeader(client)) + { + ReplyToCommand(client, "%s Only leaders can clear markers.", PREFIX_PLAIN); + return Plugin_Handled; + } + + Marker_ClearByOwner(client); + return Plugin_Handled; +} + +//---------------------------------------------------------------------------------------------------- +// Aim trace (port of Leader2's TracePlayerAngles) +//---------------------------------------------------------------------------------------------------- +bool TracePlayerAngles(int client, float vecResult[3]) +{ + if (!IsClientInGame(client)) + return false; + + float vecEyeAngles[3], vecEyeOrigin[3]; + GetClientEyeAngles(client, vecEyeAngles); + GetClientEyePosition(client, vecEyeOrigin); + + Handle trace = TR_TraceRayFilterEx(vecEyeOrigin, vecEyeAngles, MASK_SHOT_HULL, RayType_Infinite, TraceFilter_NoPlayers); + + // AllSolid: eyes clipped inside a solid (crowd pushes); endpos degenerates to the eye. + bool hit = TR_DidHit(trace) && !TR_AllSolid(trace); + int hitEnt = hit ? TR_GetEntityIndex(trace) : -1; + if (hit) + TR_GetEndPosition(vecResult, trace); + delete trace; + + if (!hit) + return false; + + // NaN or out-of-grid components reach the client as 0 (WriteBitCoord encodes them + // as zero), which draws the beam to the world origin. Catch them before the TE. + if (vecResult[0] != vecResult[0] || vecResult[1] != vecResult[1] || vecResult[2] != vecResult[2] + || FloatAbs(vecResult[0]) > 16384.0 || FloatAbs(vecResult[1]) > 16384.0 || FloatAbs(vecResult[2]) > 16384.0) + { + LogSuspectTrace(client, hitEnt, vecResult, vecEyeOrigin, vecEyeAngles, "insane endpos"); + return false; + } + + // ZE maps park "removed" but still solid props at the world origin; their hulls + // catch the ray there. Hits on the world itself near origin stay legitimate. + if (hitEnt > 0 && GetVectorLength(vecResult) < 64.0) + { + LogSuspectTrace(client, hitEnt, vecResult, vecEyeOrigin, vecEyeAngles, "entity hull at world origin"); + return false; + } + + return true; +} + +// One line per 5s so a leader holding the tracer on a culprit doesn't flood the log. +static float g_fNextSuspectTraceLog; + +static void LogSuspectTrace(int client, int ent, const float end[3], const float eye[3], const float ang[3], const char[] why) +{ + float now = GetGameTime(); + if (now < g_fNextSuspectTraceLog) + return; + g_fNextSuspectTraceLog = now + 5.0; + + char classname[64] = "worldspawn"; + char model[PLATFORM_MAX_PATH]; + if (ent > 0 && IsValidEdict(ent)) + { + GetEdictClassname(ent, classname, sizeof(classname)); + GetEntPropString(ent, Prop_Data, "m_ModelName", model, sizeof(model)); + } + + LogMessage("Aim trace rejected (%s) for %L: end (%.1f %.1f %.1f), hit ent %d (%s, model '%s'), eye (%.1f %.1f %.1f), ang (%.1f %.1f %.1f)", + why, client, end[0], end[1], end[2], ent, classname, model, + eye[0], eye[1], eye[2], ang[0], ang[1], ang[2]); +} + +public bool TraceFilter_NoPlayers(int entity, int contentsMask) +{ + return entity > MaxClients; +} diff --git a/Leader3/scripting/leader3/menu.sp b/Leader3/scripting/leader3/menu.sp new file mode 100644 index 0000000..c7cc3cb --- /dev/null +++ b/Leader3/scripting/leader3/menu.sp @@ -0,0 +1,365 @@ +//---------------------------------------------------------------------------------------------------- +// Leader3 menus. Opened with !leader or the flashlight key (impulse 100). +//---------------------------------------------------------------------------------------------------- + +bool CanManageLeaders(int client) +{ + return IsPrimaryLeader(client) || CheckCommandAccess(client, "sm_admin", ADMFLAG_GENERIC, false); +} + +void AddMarkerMenuItem(Menu menu, int client, const char[] info, const char[] name, int skin) +{ + char title[128], display[160]; + if (Marker_GetTitle(client, skin, title, sizeof(title))) + { + Format(display, sizeof(display), "%s: %s", name, title); + menu.AddItem(info, display); + } + else + { + menu.AddItem(info, name); + } +} + +void ShowLeaderMenu(int client) +{ + if (!IsLeader(client)) + return; + + Menu menu = new Menu(MenuHandler_Main); + + menu.SetTitle("Leader Menu (%s)\nBeacon: %s | Xray: %s", + IsPrimaryLeader(client) ? "PRIMARY" : "SUB-LEADER", + (g_iEffectFlags[client] & EFFECT_BEACON) ? "On" : "Off", + (g_iEffectFlags[client] & EFFECT_XRAY) ? "On" : "Off"); + + AddMarkerMenuItem(menu, client, "skull", "Skull", SKIN_SKULL); + AddMarkerMenuItem(menu, client, "cross", "Cross", SKIN_CROSS); + AddMarkerMenuItem(menu, client, "moon", "Moon", SKIN_MOON); + AddMarkerMenuItem(menu, client, "star", "Star", SKIN_STAR); + + menu.AddItem("clear", "Clear markers"); + + char buf[32]; + switch (g_iSpriteChoice[client]) + { + case SPRITE_CHOICE_FOLLOW: buf = "Sprite: Follow me"; + case SPRITE_CHOICE_DEFEND: buf = "Sprite: Defend here"; + default: buf = "Sprite: Off"; + } + menu.AddItem("sprite", buf); + + menu.AddItem("settings", "Settings"); + + menu.ExitButton = true; + menu.Display(client, MENU_TIME_FOREVER); +} + +public int MenuHandler_Main(Menu menu, MenuAction action, int client, int position) +{ + if (action == MenuAction_End) + { + delete menu; + return 0; + } + + if (action != MenuAction_Select || !IsLeader(client)) + return 0; + + char info[32]; + menu.GetItem(position, info, sizeof(info)); + + // Zombie leaders may still use the settings menu and clear their own markers; + // the other main-menu items act in the world, so don't act and just let the menu close. + if (IsLivingZombie(client) && !StrEqual(info, "settings") && !StrEqual(info, "clear")) + { + PrintToChat(client, "%sYou cannot use this as a zombie.", PREFIX); + return 0; + } + + int skin = SkinFromName(info); + if (skin != -1) + { + Marker_Place(client, skin); + ShowLeaderMenu(client); + } + else if (StrEqual(info, "clear")) + { + Marker_ClearByOwner(client); + ShowLeaderMenu(client); + } + else if (StrEqual(info, "sprite")) + { + // Rotate Off -> Follow me -> Defend here -> Off. + switch (g_iSpriteChoice[client]) + { + case SPRITE_NONE: Sprite_Set(client, SPRITE_CHOICE_FOLLOW); + case SPRITE_CHOICE_FOLLOW: Sprite_Set(client, SPRITE_CHOICE_DEFEND); + default: Sprite_Set(client, SPRITE_NONE); + } + ShowLeaderMenu(client); + } + else if (StrEqual(info, "settings")) + { + ShowSettingsMenu(client); + } + + return 0; +} + +//---------------------------------------------------------------------------------------------------- +// Settings / Extra +//---------------------------------------------------------------------------------------------------- +void ShowSettingsMenu(int client) +{ + Menu menu = new Menu(MenuHandler_Settings); + menu.SetTitle("Leader Settings"); + + char buf[64]; + Format(buf, sizeof(buf), "Beacon: %s", (g_iEffectFlags[client] & EFFECT_BEACON) ? "On" : "Off"); + menu.AddItem("beacon", buf); + Format(buf, sizeof(buf), "Xray: %s", (g_iEffectFlags[client] & EFFECT_XRAY) ? "On" : "Off"); + menu.AddItem("xray", buf); + switch (g_iTracerMode[client]) + { + case TRACER_MODE_TAP: buf = "Tracer: Hold + Tap-toggle"; + case TRACER_MODE_HOLD: buf = "Tracer: Hold only"; + default: buf = "Tracer: Off"; + } + menu.AddItem("tracer", buf); + menu.AddItem("resign", "Resign from Leader"); + + if (CanManageLeaders(client)) + { + menu.AddItem("manage", "Manage Leaders"); + menu.AddItem("clearall", "Clear ALL Markers"); + Format(buf, sizeof(buf), "Force-Mute: %s", g_bForceLeaderMute ? "On" : "Off"); + menu.AddItem("forcemute", buf); + } + + menu.ExitBackButton = true; + menu.ExitButton = true; + menu.Display(client, MENU_TIME_FOREVER); +} + +public int MenuHandler_Settings(Menu menu, MenuAction action, int client, int position) +{ + if (action == MenuAction_End) + { + delete menu; + return 0; + } + + if (action == MenuAction_Cancel && position == MenuCancel_ExitBack) + { + ShowLeaderMenu(client); + return 0; + } + + if (action != MenuAction_Select || !IsLeader(client)) + return 0; + + char info[32]; + menu.GetItem(position, info, sizeof(info)); + + if (StrEqual(info, "beacon")) + { + Beacon_Toggle(client); + ShowSettingsMenu(client); + } + else if (StrEqual(info, "xray")) + { + Xray_Toggle(client); + ShowSettingsMenu(client); + } + else if (StrEqual(info, "tracer")) + { + Tracer_CycleMode(client); + ShowSettingsMenu(client); + } + else if (StrEqual(info, "resign")) + { + RemoveLeaderClient(client, "resigned"); + } + else if (StrEqual(info, "manage") && CanManageLeaders(client)) + { + ShowManageMenu(client); + } + else if (StrEqual(info, "clearall") && CanManageLeaders(client)) + { + Markers_ClearAll(); + PrintToChatAll("%sAll markers have been cleared by \x04%N\x01.", PREFIX, client); + ShowSettingsMenu(client); + } + else if (StrEqual(info, "forcemute") && CanManageLeaders(client)) + { + // Enabling is reserved for whitelisted leaders; disabling is not. + if (!g_bForceLeaderMute && !Whitelist_IsWhitelisted(client)) + { + PrintToChat(client, "%sOnly players on the leader whitelist can enable force mute.", PREFIX); + } + else + { + g_bForceLeaderMute = !g_bForceLeaderMute; + PrintToChatAll("%s\x04%N\x01 %s force mute while a leader is speaking!", PREFIX, client, + g_bForceLeaderMute ? "activated" : "deactivated"); + } + ShowSettingsMenu(client); + } + + return 0; +} + +//---------------------------------------------------------------------------------------------------- +// Manage leaders (primary leader or admin) +//---------------------------------------------------------------------------------------------------- +void ShowManageMenu(int client) +{ + Menu menu = new Menu(MenuHandler_Manage); + menu.SetTitle("Manage Leaders\n[P] primary, [L] sub-leader"); + + char info[12], display[MAX_NAME_LENGTH + 8]; + bool any = false; + + // Active leaders first, in roster order (index 0 = primary). + for (int i = 0; i < g_alLeaders.Length; i++) + { + int leader = GetClientOfUserId(g_alLeaders.Get(i)); + if (leader <= 0) + continue; + + any = true; + IntToString(GetClientUserId(leader), info, sizeof(info)); + Format(display, sizeof(display), "[%s] %N", i == 0 ? "P" : "L", leader); + menu.AddItem(info, display); + } + + // Then everyone else who could become one. + for (int i = 1; i <= MaxClients; i++) + { + if (g_bIsLeader[i] || !IsClientInGame(i) || IsFakeClient(i)) + continue; + + if (!IsLivingHuman(i)) + continue; + + any = true; + IntToString(GetClientUserId(i), info, sizeof(info)); + Format(display, sizeof(display), "%N", i); + menu.AddItem(info, display); + } + + if (!any) + menu.AddItem("", "No players available", ITEMDRAW_DISABLED); + + menu.ExitBackButton = true; + menu.ExitButton = true; + menu.Display(client, MENU_TIME_FOREVER); +} + +public int MenuHandler_Manage(Menu menu, MenuAction action, int client, int position) +{ + if (action == MenuAction_End) + { + delete menu; + return 0; + } + + if (action == MenuAction_Cancel && position == MenuCancel_ExitBack) + { + ShowSettingsMenu(client); + return 0; + } + + if (action != MenuAction_Select || !CanManageLeaders(client)) + return 0; + + char info[12]; + menu.GetItem(position, info, sizeof(info)); + + int target = GetClientOfUserId(StringToInt(info)); + if (target == 0) + { + PrintToChat(client, "%sThat player is no longer available.", PREFIX); + ShowManageMenu(client); + return 0; + } + + ShowManageActionMenu(client, target); + return 0; +} + +void ShowManageActionMenu(int client, int target) +{ + Menu menu = new Menu(MenuHandler_ManageAction); + menu.SetTitle("Manage: %N", target); + + char info[16]; + IntToString(GetClientUserId(target), info, sizeof(info)); + + if (g_bIsLeader[target]) + { + if (!IsPrimaryLeader(target)) + menu.AddItem(info, "Make Primary Leader"); + menu.AddItem(info, "Demote (remove leader)"); + } + else + { + menu.AddItem(info, "Promote to Sub-Leader"); + } + + menu.ExitBackButton = true; + menu.ExitButton = true; + menu.Display(client, MENU_TIME_FOREVER); +} + +public int MenuHandler_ManageAction(Menu menu, MenuAction action, int client, int position) +{ + if (action == MenuAction_End) + { + delete menu; + return 0; + } + + if (action == MenuAction_Cancel && position == MenuCancel_ExitBack) + { + ShowManageMenu(client); + return 0; + } + + if (action != MenuAction_Select || !CanManageLeaders(client)) + return 0; + + char info[16], display[64]; + int style; + menu.GetItem(position, info, sizeof(info), style, display, sizeof(display)); + + int target = GetClientOfUserId(StringToInt(info)); + if (target == 0) + { + PrintToChat(client, "%sThat player is no longer available.", PREFIX); + ShowManageMenu(client); + return 0; + } + + if (StrContains(display, "Primary") != -1) + { + Core_MakePrimary(target, client); + } + else if (StrContains(display, "Demote") != -1) + { + char reason[80]; + Format(reason, sizeof(reason), "demoted by \x04%N\x01", client); + RemoveLeaderClient(target, reason); + } + else if (StrContains(display, "Promote") != -1) + { + if (!IsLivingHuman(target)) + PrintToChat(client, "%sThat player has to be a living human.", PREFIX); + else + AddLeader(target, false, client); + } + + ShowManageMenu(client); + return 0; +} diff --git a/Leader3/scripting/leader3/vote.sp b/Leader3/scripting/leader3/vote.sp new file mode 100644 index 0000000..f9f3e7b --- /dev/null +++ b/Leader3/scripting/leader3/vote.sp @@ -0,0 +1,136 @@ +//---------------------------------------------------------------------------------------------------- +// Leader3 vote: !voteleader / !vl . Passes at sm_leader_vote_ratio of eligible voters. +// Eligible = in-game, not a bot, not AFK (AFKManager, when loaded). +//---------------------------------------------------------------------------------------------------- + +int g_iVoteCount[MAXPLAYERS + 1]; // votes received, per candidate +int g_iVotedForSerial[MAXPLAYERS + 1]; // per voter: client serial of their candidate + +public Action Cmd_VoteLeader(int client, int args) +{ + if (client == 0) + { + ReplyToCommand(client, "%s Cannot use this from the console.", PREFIX_PLAIN); + return Plugin_Handled; + } + + if (!g_cvAllowVotes.BoolValue) + { + ReplyToCommand(client, "%s Voting for leader is disabled.", PREFIX_PLAIN); + return Plugin_Handled; + } + + if (args < 1) + { + ReplyToCommand(client, "%s Usage: sm_voteleader ", PREFIX_PLAIN); + return Plugin_Handled; + } + + char arg[65]; + GetCmdArg(1, arg, sizeof(arg)); + int target = FindTarget(client, arg, true, false); + if (target == -1) + return Plugin_Handled; + + if (g_bIsLeader[target]) + { + ReplyToCommand(client, "%s %N is already a leader!", PREFIX_PLAIN, target); + return Plugin_Handled; + } + + if (g_alLeaders.Length >= g_cvMaxLeaders.IntValue) + { + ReplyToCommand(client, "%s The maximum of %d leaders is already reached.", PREFIX_PLAIN, g_cvMaxLeaders.IntValue); + return Plugin_Handled; + } + + int previous = GetClientFromSerial(g_iVotedForSerial[client]); + if (previous == target) + { + ReplyToCommand(client, "%s You have already voted for this player!", PREFIX_PLAIN); + return Plugin_Handled; + } + + if (previous != 0) + g_iVoteCount[previous]--; + + g_iVoteCount[target]++; + g_iVotedForSerial[client] = GetClientSerial(target); + + PrintToChatAll("%s\x04%N\x01 voted for \x04%N\x01 to become a leader (\x04%d/%d\x01 votes).", + PREFIX, client, target, g_iVoteCount[target], Vote_Needed()); + + Vote_Check(target); + return Plugin_Handled; +} + +int Vote_EligibleCount() +{ + int afkTime = g_cvVoteAfkTime.IntValue; + int count = 0; + + for (int i = 1; i <= MaxClients; i++) + { + if (!IsClientInGame(i) || IsFakeClient(i)) + continue; + if (g_bAFKManager && afkTime > 0 && GetClientIdleTime(i) >= afkTime) + continue; + count++; + } + return count; +} + +int Vote_Needed() +{ + int needed = RoundToCeil(float(Vote_EligibleCount()) * g_cvVoteRatio.FloatValue); + return needed < 2 ? 2 : needed; +} + +void Vote_Check(int candidate) +{ + if (!IsValidClient(candidate) || g_iVoteCount[candidate] <= 0) + return; + + if (g_iVoteCount[candidate] < Vote_Needed()) + return; + + Vote_ClearCandidate(candidate); + PrintToChatAll("%s\x04%N\x01 has been voted to become a leader!", PREFIX, candidate); + if (AddLeader(candidate)) + ShowLeaderMenu(candidate); +} + +void Vote_ClearCandidate(int candidate) +{ + g_iVoteCount[candidate] = 0; + + int serial = GetClientSerial(candidate); + for (int i = 1; i <= MaxClients; i++) + { + if (g_iVotedForSerial[i] == serial) + g_iVotedForSerial[i] = 0; + } +} + +// A shrinking voter pool can push a pending candidate over the threshold. +void Vote_RecheckAll() +{ + for (int i = 1; i <= MaxClients; i++) + { + if (g_iVoteCount[i] > 0) + Vote_Check(i); + } +} + +void Vote_OnClientDisconnect(int client) +{ + int previous = GetClientFromSerial(g_iVotedForSerial[client]); + if (previous != 0) + g_iVoteCount[previous]--; + g_iVotedForSerial[client] = 0; + + if (g_iVoteCount[client] > 0) + Vote_ClearCandidate(client); + + Vote_RecheckAll(); +} diff --git a/Leader3/scripting/leader3/whitelist.sp b/Leader3/scripting/leader3/whitelist.sp new file mode 100644 index 0000000..aa46ea2 --- /dev/null +++ b/Leader3/scripting/leader3/whitelist.sp @@ -0,0 +1,371 @@ +//---------------------------------------------------------------------------------------------------- +// Leader3 whitelist: players allowed to self-assign leader without a vote or admin flags. +// Stored in the SourceMod database ("sourcemod" config when defined, else storage-local). +//---------------------------------------------------------------------------------------------------- + +void Whitelist_OnPluginStart() +{ + char conf[64]; + strcopy(conf, sizeof(conf), SQL_CheckConfig("sourcemod") ? "sourcemod" : "storage-local"); + Database.Connect(Whitelist_OnDBConnected, conf); +} + +public void Whitelist_OnDBConnected(Database db, const char[] error, any data) +{ + if (db == null) + { + LogError("[Leader3] Whitelist database connection failed: %s", error); + return; + } + + g_hWhitelistDB = db; + g_hWhitelistDB.SetCharset("utf8"); + + g_hWhitelistDB.Query(Whitelist_OnTableCreated, + "CREATE TABLE IF NOT EXISTS leader_whitelist (" + ... "steam_id VARCHAR(32) NOT NULL PRIMARY KEY, " + ... "name VARCHAR(64) NOT NULL DEFAULT '', " + ... "added_by VARCHAR(96) NOT NULL DEFAULT '', " + ... "added_at INTEGER NOT NULL DEFAULT 0)"); +} + +public void Whitelist_OnTableCreated(Database db, DBResultSet results, const char[] error, any data) +{ + if (results == null) + { + LogError("[Leader3] Failed to create leader_whitelist table: %s", error); + delete g_hWhitelistDB; + return; + } + + // Late load / slow connect: check everyone already on. + for (int i = 1; i <= MaxClients; i++) + { + if (IsClientInGame(i) && IsClientAuthorized(i)) + Whitelist_CheckClient(i); + } +} + +void Whitelist_CheckClient(int client) +{ + g_bLeaderWhitelisted[client] = false; + + char auth[32]; + if (g_hWhitelistDB == null || IsFakeClient(client) || !GetClientAuthId(client, AuthId_Steam2, auth, sizeof(auth))) + return; + + char query[128]; + g_hWhitelistDB.Format(query, sizeof(query), "SELECT 1 FROM leader_whitelist WHERE steam_id = '%s'", auth); + g_hWhitelistDB.Query(Whitelist_OnCheckDone, query, GetClientUserId(client)); +} + +public void Whitelist_OnCheckDone(Database db, DBResultSet results, const char[] error, any userid) +{ + if (results == null) + { + LogError("[Leader3] Whitelist lookup failed: %s", error); + return; + } + + int client = GetClientOfUserId(userid); + if (client > 0) + g_bLeaderWhitelisted[client] = results.FetchRow(); +} + +bool Whitelist_IsWhitelisted(int client) +{ + return client >= 1 && client <= MaxClients && g_bLeaderWhitelisted[client]; +} + +void Whitelist_Add(int admin, int target) +{ + if (g_hWhitelistDB == null) + { + ReplyToCommand(admin, "%s The whitelist database is not available.", PREFIX_PLAIN); + return; + } + + char auth[32]; + if (!GetClientAuthId(target, AuthId_Steam2, auth, sizeof(auth))) + { + ReplyToCommand(admin, "%s %N is not authorized with Steam yet, try again shortly.", PREFIX_PLAIN, target); + return; + } + + if (g_bLeaderWhitelisted[target]) + { + ReplyToCommand(admin, "%s %N is already on the leader whitelist.", PREFIX_PLAIN, target); + return; + } + + char name[MAX_NAME_LENGTH], adminAuth[96]; + GetClientName(target, name, sizeof(name)); + if (admin > 0) + GetClientAuthId(admin, AuthId_Steam2, adminAuth, sizeof(adminAuth)); + else + strcopy(adminAuth, sizeof(adminAuth), "CONSOLE"); + + char query[512]; + g_hWhitelistDB.Format(query, sizeof(query), + "REPLACE INTO leader_whitelist (steam_id, name, added_by, added_at) VALUES ('%s', '%s', '%s', %d)", + auth, name, adminAuth, GetTime()); + g_hWhitelistDB.Query(Whitelist_OnWriteDone, query); + + g_bLeaderWhitelisted[target] = true; + + ReplyToCommand(admin, "%s Added %N (%s) to the leader whitelist.", PREFIX_PLAIN, target, auth); + PrintToChat(target, "%sYou are now on the \x04leader whitelist\x01! Type \x04!leader\x01 to lead at any time.", PREFIX); + LogAction(admin, target, "\"%L\" added \"%L\" to the leader whitelist", admin, target); +} + +// Normalize a typed SteamID to canonical upper-case Steam2 form (STEAM_X:Y:Z). +// The steam_id column comparison is case-sensitive, so lower-case input must not reach the DB. +static bool Whitelist_NormalizeSteam2(const char[] input, char[] auth, int maxlen) +{ + strcopy(auth, maxlen, input); + for (int i = 0; auth[i] != '\0'; i++) + auth[i] = CharToUpper(auth[i]); + + if (StrContains(auth, "STEAM_", true) != 0) + return false; + if (!IsCharNumeric(auth[6]) || auth[7] != ':' || (auth[8] != '0' && auth[8] != '1') || auth[9] != ':' || !IsCharNumeric(auth[10])) + return false; + + for (int i = 11; auth[i] != '\0'; i++) + { + if (!IsCharNumeric(auth[i])) + return false; + } + return true; +} + +// Add a possibly offline player by SteamID (sm_addleaderwhitelist STEAM_0:X:XXXX [name]). +void Whitelist_AddAuth(int admin, const char[] authArg, const char[] name) +{ + if (g_hWhitelistDB == null) + { + ReplyToCommand(admin, "%s The whitelist database is not available.", PREFIX_PLAIN); + return; + } + + char auth[32]; + if (!Whitelist_NormalizeSteam2(authArg, auth, sizeof(auth))) + { + ReplyToCommand(admin, "%s '%s' is not a valid SteamID (expected STEAM_0:X:XXXX).", PREFIX_PLAIN, authArg); + return; + } + + char adminAuth[96]; + if (admin > 0) + GetClientAuthId(admin, AuthId_Steam2, adminAuth, sizeof(adminAuth)); + else + strcopy(adminAuth, sizeof(adminAuth), "CONSOLE"); + + char query[512]; + g_hWhitelistDB.Format(query, sizeof(query), + "REPLACE INTO leader_whitelist (steam_id, name, added_by, added_at) VALUES ('%s', '%s', '%s', %d)", + auth, name, adminAuth, GetTime()); + g_hWhitelistDB.Query(Whitelist_OnWriteDone, query); + + // Update the cache should that player be connected under this ID. + char clientAuth[32]; + for (int i = 1; i <= MaxClients; i++) + { + if (!g_bLeaderWhitelisted[i] && IsClientInGame(i) && !IsFakeClient(i) + && GetClientAuthId(i, AuthId_Steam2, clientAuth, sizeof(clientAuth)) + && StrEqual(auth, clientAuth)) + { + g_bLeaderWhitelisted[i] = true; + PrintToChat(i, "%sYou are now on the \x04leader whitelist\x01! Type \x04!leader\x01 to lead at any time.", PREFIX); + } + } + + ReplyToCommand(admin, "%s Added %s to the leader whitelist.", PREFIX_PLAIN, auth); + LogAction(admin, -1, "\"%L\" added \"%s\" to the leader whitelist", admin, auth); +} + +// Remove a connected client from the whitelist. Returns true if they were on it. +bool Whitelist_Remove(int admin, int target) +{ + if (!g_bLeaderWhitelisted[target]) + return false; + + g_bLeaderWhitelisted[target] = false; + + char auth[32]; + if (GetClientAuthId(target, AuthId_Steam2, auth, sizeof(auth))) + Whitelist_DeleteAuth(auth); + + ReplyToCommand(admin, "%s Removed %N from the leader whitelist.", PREFIX_PLAIN, target); + LogAction(admin, target, "\"%L\" removed \"%L\" from the leader whitelist", admin, target); + return true; +} + +// Remove an offline player by SteamID (sm_removeleaderwhitelist STEAM_0:X:XXXX). +void Whitelist_RemoveAuth(int admin, const char[] authArg) +{ + if (g_hWhitelistDB == null) + { + ReplyToCommand(admin, "%s The whitelist database is not available.", PREFIX_PLAIN); + return; + } + + char auth[32]; + if (!Whitelist_NormalizeSteam2(authArg, auth, sizeof(auth))) + { + ReplyToCommand(admin, "%s '%s' is not a valid SteamID (expected STEAM_0:X:XXXX).", PREFIX_PLAIN, authArg); + return; + } + + Whitelist_DeleteAuth(auth); + + // Clear the cache should that player be connected under this ID. + char clientAuth[32]; + for (int i = 1; i <= MaxClients; i++) + { + if (g_bLeaderWhitelisted[i] && IsClientInGame(i) + && GetClientAuthId(i, AuthId_Steam2, clientAuth, sizeof(clientAuth)) + && StrEqual(auth, clientAuth)) + { + g_bLeaderWhitelisted[i] = false; + } + } + + ReplyToCommand(admin, "%s Removed %s from the leader whitelist (if present).", PREFIX_PLAIN, auth); + LogAction(admin, -1, "\"%L\" removed \"%s\" from the leader whitelist", admin, auth); +} + +void Whitelist_DeleteAuth(const char[] auth) +{ + if (g_hWhitelistDB == null) + return; + + char query[128]; + g_hWhitelistDB.Format(query, sizeof(query), "DELETE FROM leader_whitelist WHERE steam_id = '%s'", auth); + g_hWhitelistDB.Query(Whitelist_OnWriteDone, query); +} + +public void Whitelist_OnWriteDone(Database db, DBResultSet results, const char[] error, any data) +{ + if (results == null) + LogError("[Leader3] Whitelist write failed: %s", error); +} + +//---------------------------------------------------------------------------------------------------- +// Commands +//---------------------------------------------------------------------------------------------------- +public Action Cmd_AddLeaderWhitelist(int client, int args) +{ + if (args < 1) + { + ReplyToCommand(client, "%s Usage: sm_addleaderwhitelist [name] - adds a player to the leader whitelist.", PREFIX_PLAIN); + return Plugin_Handled; + } + + char arg[65], targetName[MAX_TARGET_LENGTH]; + int targets[MAXPLAYERS]; + bool tnIsMl; + GetCmdArg(1, arg, sizeof(arg)); + + int count = ProcessTargetString(arg, client, targets, sizeof(targets), + COMMAND_FILTER_CONNECTED | COMMAND_FILTER_NO_BOTS, targetName, sizeof(targetName), tnIsMl); + if (count <= 0) + { + // Offline whitelist add by SteamID. + if (StrContains(arg, "STEAM_", false) == 0) + { + char name[MAX_NAME_LENGTH]; + if (args >= 2) + GetCmdArg(2, name, sizeof(name)); + Whitelist_AddAuth(client, arg, name); + return Plugin_Handled; + } + ReplyToTargetError(client, count); + return Plugin_Handled; + } + + for (int i = 0; i < count; i++) + Whitelist_Add(client, targets[i]); + + return Plugin_Handled; +} + +public Action Cmd_RemoveLeaderWhitelist(int client, int args) +{ + if (args < 1) + { + ReplyToCommand(client, "%s Usage: sm_removeleaderwhitelist - removes a player from the leader whitelist.", PREFIX_PLAIN); + return Plugin_Handled; + } + + char arg[65], targetName[MAX_TARGET_LENGTH]; + int targets[MAXPLAYERS]; + bool tnIsMl; + GetCmdArg(1, arg, sizeof(arg)); + + int count = ProcessTargetString(arg, client, targets, sizeof(targets), + COMMAND_FILTER_CONNECTED | COMMAND_FILTER_NO_BOTS, targetName, sizeof(targetName), tnIsMl); + if (count <= 0) + { + // Offline whitelist removal by SteamID. + if (StrContains(arg, "STEAM_", false) == 0) + { + Whitelist_RemoveAuth(client, arg); + return Plugin_Handled; + } + ReplyToTargetError(client, count); + return Plugin_Handled; + } + + for (int i = 0; i < count; i++) + { + if (!Whitelist_Remove(client, targets[i])) + ReplyToCommand(client, "%s %N is not on the leader whitelist.", PREFIX_PLAIN, targets[i]); + } + + return Plugin_Handled; +} + +public Action Cmd_LeaderWhitelist(int client, int args) +{ + if (g_hWhitelistDB == null) + { + ReplyToCommand(client, "%s The whitelist database is not available.", PREFIX_PLAIN); + return Plugin_Handled; + } + + g_hWhitelistDB.Query(Whitelist_OnListDone, + "SELECT steam_id, name FROM leader_whitelist ORDER BY added_at DESC", + client > 0 ? GetClientUserId(client) : 0); + return Plugin_Handled; +} + +public void Whitelist_OnListDone(Database db, DBResultSet results, const char[] error, any userid) +{ + int client = (userid == 0) ? 0 : GetClientOfUserId(userid); + if (userid != 0 && client == 0) + return; + + if (results == null) + { + LogError("[Leader3] Whitelist listing failed: %s", error); + ReplyToCommand(client, "%s Failed to read the leader whitelist.", PREFIX_PLAIN); + return; + } + + if (results.RowCount == 0) + { + ReplyToCommand(client, "%s The leader whitelist is empty.", PREFIX_PLAIN); + return; + } + + ReplyToCommand(client, "%s Leader whitelist (%d):", PREFIX_PLAIN, results.RowCount); + + char auth[32], name[MAX_NAME_LENGTH]; + while (results.FetchRow()) + { + results.FetchString(0, auth, sizeof(auth)); + results.FetchString(1, name, sizeof(name)); + ReplyToCommand(client, " %s - %s", auth, name); + } +} diff --git a/NameFilter2/configs/NameFilter2.cfg b/NameFilter2/configs/NameFilter2.cfg new file mode 100644 index 0000000..e5cea36 --- /dev/null +++ b/NameFilter2/configs/NameFilter2.cfg @@ -0,0 +1,180 @@ +"NameFilter2" +{ + "settings" + { + // names shorter than this after filtering get a replacement name + "min_length" "2" + + // percentage of a name's letters that must survive folding; below + // this the name gets a replacement instead. 0 disables the check + "min_survived_letters" "50" + + // a name containing a contiguous typeable (printable ASCII) run with + // at least this many letters/digits is kept as-is, unicode decoration + // and all, unless an advert or banned filter matches. An admin can + // target such a name by its typeable part. 0 disables the exemption + "keep_typeable_run" "5" + + // non-ASCII characters that are always kept. + // accented Latin letters (À-ÿ, Latin Extended-A) are also kept, but only + // when the name starts with at least two plain Latin letters; otherwise + // they are reduced to their base letter (Å -> A, æ -> ae) + "whitelist" "äöüÄÖܧğĞšŠ" + } + + // single-character lookalikes folded to ASCII before unmapped unicode is stripped. + // key = the confusable character (raw UTF-8), value = ASCII replacement + "confusables" + { + // Cyrillic lowercase + "а" "a" + "е" "e" + "о" "o" + "р" "p" + "с" "c" + "у" "y" + "х" "x" + "і" "i" + "ѕ" "s" + "ј" "j" + // Cyrillic uppercase + "А" "A" + "В" "B" + "Е" "E" + "К" "K" + "М" "M" + "Н" "H" + "О" "O" + "Р" "P" + "С" "C" + "Т" "T" + "Х" "X" + "Ѕ" "S" + "І" "I" + "Ј" "J" + "У" "Y" + "З" "3" + // Cyrillic mirrored/aesthetic lookalikes + "Я" "R" + "я" "r" + "И" "N" + "и" "n" + "в" "b" + "к" "k" + "м" "m" + "н" "h" + "т" "t" + // Cyrillic with diacritics, same lookalikes as their base letters + "Ѐ" "E" + "Ё" "E" + "ѐ" "e" + "ё" "e" + "Ї" "I" + "ї" "i" + "Ў" "Y" + "ў" "y" + "Ѝ" "N" + "Й" "N" + "ѝ" "n" + "й" "n" + // Greek + "Α" "A" + "Β" "B" + "Ε" "E" + "Ζ" "Z" + "Η" "H" + "Ι" "I" + "Κ" "K" + "Μ" "M" + "Ν" "N" + "Ο" "O" + "Ρ" "P" + "Τ" "T" + "Υ" "Y" + "Χ" "X" + "α" "a" + "ν" "v" + "ο" "o" + // Greek with tonos/dialytika, same lookalikes as their base letters + "Ά" "A" + "Έ" "E" + "Ή" "H" + "Ί" "I" + "Ϊ" "I" + "Ό" "O" + "Ύ" "Y" + "Ϋ" "Y" + "Ώ" "O" + "ά" "a" + "ή" "n" + "ό" "o" + "ώ" "w" + // Greek aesthetic lookalikes + "Λ" "A" + "Σ" "E" + "Δ" "A" + "Ω" "O" + "Θ" "O" + "θ" "o" + "μ" "u" + "ω" "w" + "η" "n" + // math symbols used as letters + "∂" "d" + "∑" "E" + } + + // regexes (case-insensitive) whose matches are REMOVED from the name; + // the rest of the name is kept. Applied to the pure-ASCII folded name. + "adverts" + { + "1" "(astraserver|bruss\.org|marcoplay|marcoserv|css-vip|for-css)\.(ru|com)" + "2" "discord\.gg/\S+" + } + + // regexes (case-insensitive); ANY match assigns a replacement name. + // Anchor with ^...$ for full-match "too default" names. + "banned" + { + "1" "^player[0-9]*$" + "2" "^unnamed$" + "3" "^default$" + "4" "^nick(name)?$" + "5" "[a4]+d+m+[il1!]+n+" + "6" "@((!?)me|(c?)t(s?)|(!?)admins|(!?)friends|random((c?)t?)|humans|spec|alive|dead|aim|bots)" + } + + // replacement names; picked deterministically (client % count, next-free probe) + "names" + { + "1" "Reimu Hakurei" + "2" "Koishi Komeiji" + "3" "Youmu Konpaku" + "4" "Sakuya Izayoi" + "5" "Flandre Scarlet" + "6" "Remilia Scarlet" + "7" "Satori Komeiji" + "8" "Alice Margatroid" + "9" "Fujiwara no Mokou" + "10" "Aya Shameimaru" + "11" "Tenshi Hinanawi" + "12" "Yukari Yakumo" + "13" "Sanae Kochiya" + "14" "Shion Yorigami" + "15" "Reisen Udongein Inaba" + "16" "Saeko Busujima" + "17" "Rei Miyamoto" + "18" "Saya Takagi" + "19" "Eru Chitanda" + "20" "Fuyumi Irisu" + "21" "Lisara Restall" + "22" "Sen Yarizui" + "23" "Momiji Binboda" + "24" "Ranmaru Rindou" + "25" "Tatenashi Sarashiki" + "26" "Laura Bodewig" + "27" "Houki Shinonono" + "28" "Ririchiyo Shirakiin" + "29" "Karuta Roromiya" + } +} diff --git a/NameFilter2/scripting/NameFilter2.sp b/NameFilter2/scripting/NameFilter2.sp new file mode 100644 index 0000000..58b4b37 --- /dev/null +++ b/NameFilter2/scripting/NameFilter2.sp @@ -0,0 +1,1174 @@ +#include +#include +#include + +#pragma semicolon 1 +#pragma newdecls required + +#define CHAT_PREFIX "\x04[NameFilter]\x01" +#define MAX_STACKED_MARKS 2 // combining marks allowed on one letter (Vietnamese needs 2) +#define RELOAD_FILE "data/namefilter2_reload.dat" +#define RELOAD_MAX_AGE 120 // seconds; older saves are from a previous server run + +enum struct SavedName +{ + int iUserId; + char sOriginal[MAX_NAME_LENGTH]; + char sAssigned[MAX_NAME_LENGTH]; + char sCurrent[MAX_NAME_LENGTH]; +} + +char g_sOriginalName[MAXPLAYERS + 1][MAX_NAME_LENGTH]; +char g_sAssignedName[MAXPLAYERS + 1][MAX_NAME_LENGTH]; // the filtered name we gave the player +ArrayList g_SavedNames; // original names saved over a plugin reload +int g_iBlockNameChangeEvents[MAXPLAYERS + 1] = {0, ...}; +bool g_bFilteredOnConnect[MAXPLAYERS + 1]; +bool g_bReplacedOnConnect[MAXPLAYERS + 1]; +bool g_bLastFilterReplaced; // whether the last FilterName() assigned a replacement name + +ArrayList g_AdvertExprs; +ArrayList g_BannedExprs; +ArrayList g_ReplacementNames; +ArrayList g_ConfusableFrom; +ArrayList g_ConfusableTo; +ArrayList g_WhitelistCps; +int g_iMinNameLength = 2; +int g_iMinSurvivedPct = 50; +int g_iKeepTypeableRun = 5; + +public Plugin myinfo = +{ + name = "NameFilter2", + author = "marisakirisame.net", + description = "Normalises and filters player names", + version = "1.0" +} + +public void OnPluginStart() +{ + HookEvent("player_changename", Event_ChangeName, EventHookMode_Pre); + HookUserMessage(GetUserMessageId("SayText2"), UserMessage_SayText2, true); + + RegConsoleCmd("sm_originalname", Command_OriginalName, "Show a player's original name"); + RegConsoleCmd("sm_changednames", Command_ChangedNames, "List all players whose names were changed"); + RegAdminCmd("sm_namefilter2_reload", Command_Reload, ADMFLAG_CONFIG, "Reload the NameFilter2 config"); + + LoadConfig(); + LoadSavedNames(); + + for(int i = 1; i <= MaxClients; i++) + { + if(IsClientConnected(i)) + OnClientConnected(i); + } +} + +public void OnPluginEnd() +{ + char sPath[PLATFORM_MAX_PATH]; + BuildPath(Path_SM, sPath, sizeof(sPath), RELOAD_FILE); + + File hFile = OpenFile(sPath, "wb"); + if(!hFile) + return; + + // null-terminated strings, as names may contain any printable character + hFile.WriteInt32(GetTime()); + for(int i = 1; i <= MaxClients; i++) + { + if(!IsClientConnected(i) || IsFakeClient(i) || !g_sOriginalName[i][0]) + continue; + + char sCurrent[MAX_NAME_LENGTH]; + GetClientName(i, sCurrent, sizeof(sCurrent)); + + hFile.WriteInt32(GetClientUserId(i)); + hFile.WriteString(g_sOriginalName[i], true); + hFile.WriteString(g_sAssignedName[i], true); + hFile.WriteString(sCurrent, true); + } + + delete hFile; +} + +void LoadSavedNames() +{ + g_SavedNames = new ArrayList(sizeof(SavedName)); + + char sPath[PLATFORM_MAX_PATH]; + BuildPath(Path_SM, sPath, sizeof(sPath), RELOAD_FILE); + + File hFile = OpenFile(sPath, "rb"); + if(!hFile) + return; + + int iSaved; + if(hFile.ReadInt32(iSaved) && GetTime() - iSaved <= RELOAD_MAX_AGE) + { + SavedName saved; + while(hFile.ReadInt32(saved.iUserId) + && hFile.ReadString(saved.sOriginal, sizeof(saved.sOriginal)) != -1 + && hFile.ReadString(saved.sAssigned, sizeof(saved.sAssigned)) != -1 + && hFile.ReadString(saved.sCurrent, sizeof(saved.sCurrent)) != -1) + { + g_SavedNames.PushArray(saved); + } + } + + delete hFile; + DeleteFile(sPath); +} + +// restores a late-loaded client's state saved by OnPluginEnd, as long as +// they still have the same name. Entries are kept: userids are never reused +bool RestoreSavedName(int client, const char[] sName) +{ + int iUserId = GetClientUserId(client); + + for(int i = 0; i < g_SavedNames.Length; i++) + { + SavedName saved; + g_SavedNames.GetArray(i, saved); + if(saved.iUserId != iUserId) + continue; + + if(!StrEqual(saved.sCurrent, sName)) + return false; + + strcopy(g_sOriginalName[client], MAX_NAME_LENGTH, saved.sOriginal); + strcopy(g_sAssignedName[client], MAX_NAME_LENGTH, saved.sAssigned); + return true; + } + + return false; +} + +public void OnClientConnected(int client) +{ + g_iBlockNameChangeEvents[client] = 0; + g_bFilteredOnConnect[client] = false; + g_bReplacedOnConnect[client] = false; + g_sAssignedName[client][0] = '\0'; + + char sName[MAX_NAME_LENGTH]; + GetClientName(client, sName, sizeof(sName)); + strcopy(g_sOriginalName[client], MAX_NAME_LENGTH, sName); + + if(IsFakeClient(client)) + return; + + if(RestoreSavedName(client, sName)) + return; + + if(FilterName(client, sName)) + { + g_bFilteredOnConnect[client] = true; + g_bReplacedOnConnect[client] = g_bLastFilterReplaced; + strcopy(g_sAssignedName[client], MAX_NAME_LENGTH, sName); + SetClientName(client, sName); + } +} + +public void OnClientPutInServer(int client) +{ + if(IsFakeClient(client)) + return; + + char sName[MAX_NAME_LENGTH]; + GetClientName(client, sName, sizeof(sName)); + + if(FilterName(client, sName)) + { + g_bFilteredOnConnect[client] = true; + if(g_bLastFilterReplaced) + g_bReplacedOnConnect[client] = true; + g_iBlockNameChangeEvents[client] = 2; + strcopy(g_sAssignedName[client], MAX_NAME_LENGTH, sName); + SetClientName(client, sName); + } + + if(g_bFilteredOnConnect[client]) + { + NotifyAdminsFiltered(client, sName, g_bReplacedOnConnect[client]); + NotifyClientRenamed(client, sName, g_bReplacedOnConnect[client]); + } +} + +public void OnClientDisconnect(int client) +{ + g_sOriginalName[client][0] = '\0'; + g_sAssignedName[client][0] = '\0'; + g_iBlockNameChangeEvents[client] = 0; + g_bFilteredOnConnect[client] = false; + g_bReplacedOnConnect[client] = false; +} + +void NotifyAdminsFiltered(int client, const char[] sNewName, bool bReplaced) +{ + PrintToServer("[NameFilter2] \"%s\" (#%d) was %s \"%s\"", g_sOriginalName[client], GetClientUserId(client), + bReplaced ? "given replacement name" : "renamed to", sNewName); + + for(int i = 1; i <= MaxClients; i++) + { + if(!IsClientInGame(i) || IsFakeClient(i)) + continue; + + if(!CheckCommandAccess(i, "sm_namefilter2_notify", ADMFLAG_GENERIC)) + continue; + + PrintToChat(i, CHAT_PREFIX ... " \x07FF6060%s\x01 (#%d) was renamed to \x0760FF60%s\x01", g_sOriginalName[client], GetClientUserId(client), sNewName); + } +} + +void NotifyClientRenamed(int client, const char[] sNewName, bool bReplaced) +{ + if(bReplaced) + PrintToChat(client, CHAT_PREFIX ... " Your name was unable to be filtered and was replaced with \x0760FF60%s\x01.", sNewName); + else + PrintToChat(client, CHAT_PREFIX ... " Your name was filtered to \x0760FF60%s\x01.", sNewName); +} + +public Action Event_ChangeName(Event event, const char[] name, bool dontBroadcast) +{ + int client = GetClientOfUserId(event.GetInt("userid")); + if(g_iBlockNameChangeEvents[client]) + { + g_iBlockNameChangeEvents[client]--; + SetEventBroadcast(event, true); + return Plugin_Handled; + } + + return Plugin_Continue; +} + +public Action UserMessage_SayText2(UserMsg msg_id, BfRead msg, const int[] players, int playersNum, bool reliable, bool init) +{ + if(!reliable) + return Plugin_Continue; + + int client; + char sMessage[32]; + char sOldName[MAX_NAME_LENGTH]; + char sNewName[MAX_NAME_LENGTH]; + + if(GetUserMessageType() == UM_Protobuf) + { + PbReadString(msg, "msg_name", sMessage, sizeof(sMessage)); + + if(!(sMessage[0] == '#' && StrContains(sMessage, "Name_Change") != -1)) + return Plugin_Continue; + + client = PbReadInt(msg, "ent_idx"); + PbReadString(msg, "params", sOldName, sizeof(sOldName), 1); + PbReadString(msg, "params", sNewName, sizeof(sNewName), 2); + } + else + { + client = BfReadByte(msg); + BfReadByte(msg); + BfReadString(msg, sMessage, sizeof(sMessage)); + + if(!(sMessage[0] == '#' && StrContains(sMessage, "Name_Change") != -1)) + return Plugin_Continue; + + BfReadString(msg, sOldName, sizeof(sOldName)); + BfReadString(msg, sNewName, sizeof(sNewName)); + } + + if(client < 1 || client > MaxClients) + return Plugin_Continue; + + if(g_iBlockNameChangeEvents[client]) + { + g_iBlockNameChangeEvents[client]--; + return Plugin_Handled; + } + + // the name we gave them (Timer_ChangeName's re-apply or any later echo of + // it); let it broadcast but keep the original it was filtered from + if(g_sAssignedName[client][0] && StrEqual(sNewName, g_sAssignedName[client])) + return Plugin_Continue; + + strcopy(g_sOriginalName[client], MAX_NAME_LENGTH, sNewName); + g_sAssignedName[client][0] = '\0'; + + if(FilterName(client, sNewName)) + { + strcopy(g_sAssignedName[client], MAX_NAME_LENGTH, sNewName); + + // CCSPlayer::ChangeName sends this SayText2 before it fires the event + // and stores the new name, so a SetClientName from in here is undone + // as soon as we return. Re-apply later instead, even when the filtered + // name equals the old one: 3 = this change's event, then the timer's + // SayText2 and event + g_iBlockNameChangeEvents[client] = 3; + + DataPack pack = new DataPack(); + pack.WriteCell(GetClientUserId(client)); + pack.WriteString(sNewName); + pack.WriteCell(g_bLastFilterReplaced); + + CreateTimer(0.1, Timer_ChangeName, pack); + + return Plugin_Handled; + } + + return Plugin_Continue; +} + +public Action Timer_ChangeName(Handle timer, any data) +{ + DataPack pack = view_as(data); + pack.Reset(); + + int client = GetClientOfUserId(pack.ReadCell()); + char sName[MAX_NAME_LENGTH]; + pack.ReadString(sName, sizeof(sName)); + bool bReplaced = view_as(pack.ReadCell()); + + delete pack; + + if(client) + { + SetClientName(client, sName); + NotifyAdminsFiltered(client, sName, bReplaced); + NotifyClientRenamed(client, sName, bReplaced); + } + + return Plugin_Stop; +} + +public Action Command_OriginalName(int client, int args) +{ + if(args < 1) + { + ReplyToCommand(client, "[SM] Usage: sm_originalname "); + return Plugin_Handled; + } + + char sPattern[MAX_TARGET_LENGTH]; + GetCmdArg(1, sPattern, sizeof(sPattern)); + + char sTargetName[MAX_TARGET_LENGTH]; + int aTargets[MAXPLAYERS]; + bool bTnIsMl; + + int iCount = ProcessTargetString(sPattern, client, aTargets, MAXPLAYERS, + COMMAND_FILTER_CONNECTED | COMMAND_FILTER_NO_IMMUNITY, + sTargetName, sizeof(sTargetName), bTnIsMl); + + if(iCount <= 0) + { + ReplyToTargetError(client, iCount); + return Plugin_Handled; + } + + for(int i = 0; i < iCount; i++) + { + char sCurrent[MAX_NAME_LENGTH]; + GetClientName(aTargets[i], sCurrent, sizeof(sCurrent)); + + if(StrEqual(sCurrent, g_sOriginalName[aTargets[i]])) + ReplyToCommand(client, "[SM] %N's name was not changed.", aTargets[i]); + else + ReplyToCommand(client, "[SM] %N's original name: %s", aTargets[i], g_sOriginalName[aTargets[i]]); + } + + return Plugin_Handled; +} + +public Action Command_ChangedNames(int client, int args) +{ + int iFound = 0; + char sCurrent[MAX_NAME_LENGTH]; + + for(int i = 1; i <= MaxClients; i++) + { + if(!IsClientConnected(i) || IsFakeClient(i)) + continue; + + GetClientName(i, sCurrent, sizeof(sCurrent)); + if(StrEqual(sCurrent, g_sOriginalName[i])) + continue; + + PrintToConsole(client, "%s - original name: %s", sCurrent, g_sOriginalName[i]); + iFound++; + } + + if(!iFound) + PrintToConsole(client, "[SM] No changed names."); + else if(client && GetCmdReplySource() == SM_REPLY_TO_CHAT) + PrintToChat(client, "[SM] %d changed name%s listed in console.", iFound, (iFound == 1) ? "" : "s"); + + return Plugin_Handled; +} + +public Action Command_Reload(int client, int args) +{ + LoadConfig(); + ReplyToCommand(client, "[SM] NameFilter2 config reloaded."); + return Plugin_Handled; +} + +void CloseRegexList(ArrayList list) +{ + if(!list) + return; + + for(int i = 0; i < list.Length; i++) + { + Handle hRegex = list.Get(i); + CloseHandle(hRegex); + } + delete list; +} + +void LoadConfig() +{ + CloseRegexList(g_AdvertExprs); + g_AdvertExprs = null; + CloseRegexList(g_BannedExprs); + g_BannedExprs = null; + + delete g_ReplacementNames; + delete g_ConfusableFrom; + delete g_ConfusableTo; + delete g_WhitelistCps; + + static char sConfigFile[PLATFORM_MAX_PATH]; + BuildPath(Path_SM, sConfigFile, sizeof(sConfigFile), "configs/NameFilter2.cfg"); + if(!FileExists(sConfigFile)) + SetFailState("Could not find config: \"%s\"", sConfigFile); + + KeyValues Config = new KeyValues("NameFilter2"); + if(!Config.ImportFromFile(sConfigFile)) + { + delete Config; + SetFailState("ImportFromFile() failed!"); + } + + char sWhitelist[64] = "äöüÄÖܧğĞšŠ"; + if(Config.JumpToKey("settings")) + { + g_iMinNameLength = Config.GetNum("min_length", 2); + g_iMinSurvivedPct = Config.GetNum("min_survived_letters", 50); + g_iKeepTypeableRun = Config.GetNum("keep_typeable_run", 5); + Config.GetString("whitelist", sWhitelist, sizeof(sWhitelist), sWhitelist); + Config.GoBack(); + } + + g_WhitelistCps = new ArrayList(); + int iPos = 0; + while(sWhitelist[iPos] != '\0') + { + int cp = DecodeUTF8Char(sWhitelist, iPos); + if(cp > 0x7E) + g_WhitelistCps.Push(cp); + } + + static char sBuffer[256]; + char sError[256]; + RegexError iError; + + g_ConfusableFrom = new ArrayList(ByteCountToCells(16)); + g_ConfusableTo = new ArrayList(ByteCountToCells(16)); + if(Config.JumpToKey("confusables")) + { + if(Config.GotoFirstSubKey(false)) + { + do + { + char sFrom[16]; + Config.GetSectionName(sFrom, sizeof(sFrom)); + Config.GetString(NULL_STRING, sBuffer, sizeof(sBuffer)); + + if(sFrom[0]) + { + g_ConfusableFrom.PushString(sFrom); + g_ConfusableTo.PushString(sBuffer); + } + } while(Config.GotoNextKey(false)); + Config.GoBack(); + } + Config.GoBack(); + } + + g_AdvertExprs = new ArrayList(); + if(Config.JumpToKey("adverts")) + { + if(Config.GotoFirstSubKey(false)) + { + do + { + Config.GetString(NULL_STRING, sBuffer, sizeof(sBuffer)); + + Handle hRegex = CompileRegex(sBuffer, PCRE_CASELESS, sError, sizeof(sError), iError); + if(iError != REGEX_ERROR_NONE) + LogError("Error parsing advert filter \"%s\": %s", sBuffer, sError); + else + g_AdvertExprs.Push(hRegex); + } while(Config.GotoNextKey(false)); + Config.GoBack(); + } + Config.GoBack(); + } + + g_BannedExprs = new ArrayList(); + if(Config.JumpToKey("banned")) + { + if(Config.GotoFirstSubKey(false)) + { + do + { + Config.GetString(NULL_STRING, sBuffer, sizeof(sBuffer)); + + Handle hRegex = CompileRegex(sBuffer, PCRE_CASELESS, sError, sizeof(sError), iError); + if(iError != REGEX_ERROR_NONE) + LogError("Error parsing banned filter \"%s\": %s", sBuffer, sError); + else + g_BannedExprs.Push(hRegex); + } while(Config.GotoNextKey(false)); + Config.GoBack(); + } + Config.GoBack(); + } + + g_ReplacementNames = new ArrayList(ByteCountToCells(MAX_NAME_LENGTH)); + if(Config.JumpToKey("names")) + { + if(Config.GotoFirstSubKey(false)) + { + do + { + Config.GetString(NULL_STRING, sBuffer, sizeof(sBuffer)); + + g_ReplacementNames.PushString(sBuffer); + } while(Config.GotoNextKey(false)); + Config.GoBack(); + } + Config.GoBack(); + } + + if(!g_ReplacementNames.Length) + { + LogError("Warning, you didn't specify any replacement names!"); + g_ReplacementNames.PushString("BAD_NAME"); + } + + delete Config; +} + +bool FilterName(int client, char[] sName, int Length = MAX_NAME_LENGTH) +{ + g_bLastFilterReplaced = false; + + char sBefore[MAX_NAME_LENGTH]; + strcopy(sBefore, sizeof(sBefore), sName); + + // applies to kept names too, so none can hide characters or pose as another + StripInvisible(sName); + + char sClean[MAX_NAME_LENGTH]; + strcopy(sClean, sizeof(sClean), sName); + + // a name an admin can target by typing an ASCII run may keep its + // unicode decoration, as long as the checks below find nothing bad + bool bKeepOriginal = HasTypeableRun(sClean); + bool bContentRemoved = false; + + ApplyConfusables(sName, Length); + + int iLettersBefore = CountLetters(sName); + + FoldToASCII(sName, true); + NormalizeWhitespace(sName); + + // accented Latin letters are only allowed when the name starts with + // at least two plain Latin letters + if(!HasLatinPrefix(sName)) + { + FoldToASCII(sName, false); + NormalizeWhitespace(sName); + } + + // folding lost too many of the name's letters; the remnant no longer + // resembles the name the player chose. Still run the advert/banned + // checks on the remnant first if the original might be kept + bool bSurvivedFail = CountLetters(sName) * 100 < iLettersBefore * g_iMinSurvivedPct; + if(bSurvivedFail && !bKeepOriginal) + { + AssignReplacementName(client, sName, Length); + return true; + } + + RegexError iError; + + for(int i = 0; i < g_AdvertExprs.Length; i++) + { + Handle hRegex = g_AdvertExprs.Get(i); + + // SourceMod Regex bug, bounded loop + int Guard; + for(Guard = 0; Guard < 100; Guard++) + { + if(!strlen(sName)) + break; + + int Match = MatchRegex(hRegex, sName, iError); + if(iError != REGEX_ERROR_NONE) + { + LogError("Regex Error: %d", iError); + break; + } + + if(Match <= 0) + break; + + char sMatch[MAX_NAME_LENGTH]; + if(!GetRegexSubString(hRegex, 0, sMatch, sizeof(sMatch)) || !sMatch[0]) + break; + + if(ReplaceStringEx(sName, Length, sMatch, "") == -1) + break; + + bContentRemoved = true; + } + if(Guard == 100) + LogError("SourceMod Regex failed! \"%s\"", sName); + } + + NormalizeWhitespace(sName); + + for(int i = 0; i < g_BannedExprs.Length; i++) + { + if(!strlen(sName)) + break; + + Handle hRegex = g_BannedExprs.Get(i); + int Match = MatchRegex(hRegex, sName, iError); + if(iError != REGEX_ERROR_NONE) + { + LogError("Regex Error: %d", iError); + continue; + } + + if(Match <= 0) + continue; + + AssignReplacementName(client, sName, Length); + return true; + } + + TrimString(sName); + + if(strlen(sName) < g_iMinNameLength || !HasLetterOrDigit(sName)) + { + AssignReplacementName(client, sName, Length); + return true; + } + + if(bKeepOriginal && !bContentRemoved) + { + strcopy(sName, Length, sClean); + return !StrEqual(sBefore, sClean); + } + + if(bSurvivedFail) + { + AssignReplacementName(client, sName, Length); + return true; + } + + return !StrEqual(sBefore, sName); +} + +// removes characters that render as nothing or reorder the text around them +// (zero-width, bidi controls, fillers, tags) and caps stacked combining marks +void StripInvisible(char[] sName) +{ + int r = 0; + int w = 0; + int iMarks = 0; + + while(sName[r] != '\0') + { + int rStart = r; + int cp = DecodeUTF8Char(sName, r); + if(cp < 0 || IsInvisibleCp(cp)) + continue; + + if(IsCombiningMarkCp(cp)) + { + if(++iMarks > MAX_STACKED_MARKS) + continue; + } + else + iMarks = 0; + + for(int k = rStart; k < r; k++) + sName[w++] = sName[k]; + } + sName[w] = '\0'; +} + +bool IsInvisibleCp(int cp) +{ + return cp < 0x20 || (cp >= 0x7F && cp <= 0x9F) // C0/C1 controls, DEL + || cp == 0xAD // soft hyphen + || cp == 0x34F // combining grapheme joiner + || cp == 0x61C // arabic letter mark + || cp == 0x115F || cp == 0x1160 // hangul fillers + || cp == 0x3164 || cp == 0xFFA0 // hangul fillers + || cp == 0x17B4 || cp == 0x17B5 // khmer inherent vowels + || (cp >= 0x180B && cp <= 0x180F) // mongolian selectors, vowel separator + || (cp >= 0x200B && cp <= 0x200F) // zero-width space/joiners, LRM/RLM + || (cp >= 0x2028 && cp <= 0x202E) // line/paragraph separators, bidi embeds/overrides + || (cp >= 0x2060 && cp <= 0x206F) // word joiner, invisible operators, bidi isolates + || cp == 0x2800 // braille blank + || (cp >= 0xFE00 && cp <= 0xFE0F) // variation selectors + || cp == 0xFEFF // zero-width no-break space (BOM) + || (cp >= 0xFFF9 && cp <= 0xFFFB) // interlinear annotation + || (cp >= 0x1D173 && cp <= 0x1D17A) // musical formatting + || (cp >= 0xE0000 && cp <= 0xE0FFF); // tags, variation selectors supplement +} + +bool IsCombiningMarkCp(int cp) +{ + return (cp >= 0x300 && cp <= 0x36F) // combining diacritical marks + || (cp >= 0x1AB0 && cp <= 0x1AFF) // extended + || (cp >= 0x1DC0 && cp <= 0x1DFF) // supplement + || (cp >= 0x20D0 && cp <= 0x20FF) // for symbols + || (cp >= 0xFE20 && cp <= 0xFE2F); // half marks +} + +void ApplyConfusables(char[] sName, int Length) +{ + char sFrom[16]; + char sTo[16]; + + for(int i = 0; i < g_ConfusableFrom.Length; i++) + { + g_ConfusableFrom.GetString(i, sFrom, sizeof(sFrom)); + g_ConfusableTo.GetString(i, sTo, sizeof(sTo)); + + if(sFrom[0]) + ReplaceString(sName, Length, sFrom, sTo, true); + } +} + +// decodes + folds in-place; output is never longer than input. +// whitelisted codepoints are always kept; accented Latin only when +// bAllowAccents, otherwise it is reduced to its base letter +void FoldToASCII(char[] sName, bool bAllowAccents) +{ + int r = 0; + int w = 0; + + while(sName[r] != '\0') + { + int rStart = r; + int cp = DecodeUTF8Char(sName, r); + if(cp < 0) + continue; + + int c = FoldCodepoint(cp); + if(c > 0) + { + sName[w++] = c; + continue; + } + + if(IsWhitelistedCp(cp) || (bAllowAccents && IsAccentedLatin(cp))) + { + for(int k = rStart; k < r; k++) + sName[w++] = sName[k]; + continue; + } + + // every codepoint FoldLatinLetter maps is >= 2 UTF-8 bytes + char sBase[3]; + int iBase = FoldLatinLetter(cp, sBase, sizeof(sBase)); + for(int k = 0; k < iBase; k++) + sName[w++] = sBase[k]; + } + sName[w] = '\0'; +} + +int CountLetters(const char[] s) +{ + int i = 0; + int iLetters = 0; + + while(s[i] != '\0') + { + int cp = DecodeUTF8Char(s, i); + if(cp > 0 && IsLetterCp(cp)) + iLetters++; + } + + return iLetters; +} + +// letters that either survive folding (ASCII, foldable, kept accents) or +// belong to an alphabetic script that FoldToASCII strips +bool IsLetterCp(int cp) +{ + int c = FoldCodepoint(cp); + if(c) + return IsCharAlpha(c); + + char sBase[3]; + if(IsAccentedLatin(cp) || FoldLatinLetter(cp, sBase, sizeof(sBase))) + return true; + + return (cp >= 0x370 && cp <= 0x3FF) // Greek + || (cp >= 0x400 && cp <= 0x52F) // Cyrillic + || (cp >= 0x530 && cp <= 0x58F) // Armenian + || (cp >= 0x590 && cp <= 0x5FF) // Hebrew + || (cp >= 0x600 && cp <= 0x6FF) // Arabic + || (cp >= 0x900 && cp <= 0xDFF) // Indic scripts + || (cp >= 0xE00 && cp <= 0xE7F) // Thai + || (cp >= 0x1E00 && cp <= 0x1FFF) // Latin ext. additional, Greek ext. + || (cp >= 0x3040 && cp <= 0x30FF) // kana + || (cp >= 0x3400 && cp <= 0x9FFF) // CJK + || (cp >= 0xAC00 && cp <= 0xD7AF);// Hangul +} + +bool HasLetterOrDigit(const char[] s) +{ + int i = 0; + while(s[i] != '\0') + { + int cp = DecodeUTF8Char(s, i); + if(cp > 0 && (IsLetterCp(cp) || (cp >= '0' && cp <= '9'))) + return true; + } + return false; +} + +// true if the name has a contiguous printable-ASCII run containing at +// least g_iKeepTypeableRun letters/digits; an admin can target such a +// name without being able to type its unicode parts +bool HasTypeableRun(const char[] s) +{ + if(g_iKeepTypeableRun <= 0) + return false; + + int iRun = 0; + for(int i = 0; s[i] != '\0'; i++) + { + int c = s[i] & 0xFF; + if(c < 0x20 || c > 0x7E) + { + iRun = 0; + continue; + } + + if(IsCharAlpha(c) || IsCharNumeric(c)) + { + if(++iRun >= g_iKeepTypeableRun) + return true; + } + } + + return false; +} + +bool HasLatinPrefix(const char[] sName) +{ + return IsCharAlpha(sName[0]) && IsCharAlpha(sName[1]); +} + +bool IsWhitelistedCp(int cp) +{ + return g_WhitelistCps.FindValue(cp) != -1; +} + +bool IsAccentedLatin(int cp) +{ + if(cp >= 0xC0 && cp <= 0xFF) // Latin-1 letters + return cp != 0xD7 && cp != 0xF7; // × ÷ + return cp >= 0x100 && cp <= 0x17F; // Latin Extended-A +} + +// returns the codepoint and advances i, or -1 on an invalid byte (advances past it) +int DecodeUTF8Char(const char[] s, int &i) +{ + int b = s[i] & 0xFF; + if(b < 0x80) + { + i++; + return b; + } + + int len; + int cp; + if((b & 0xE0) == 0xC0) { len = 2; cp = b & 0x1F; } + else if((b & 0xF0) == 0xE0) { len = 3; cp = b & 0x0F; } + else if((b & 0xF8) == 0xF0) { len = 4; cp = b & 0x07; } + else + { + i++; + return -1; + } + + for(int k = 1; k < len; k++) + { + int cb = s[i + k] & 0xFF; + if((cb & 0xC0) != 0x80) + { + i++; + return -1; + } + cp = (cp << 6) | (cb & 0x3F); + } + + i += len; + return cp; +} + +// writes the plain ASCII form of a Latin letter with diacritics, stroke or +// ligature (Å -> A, ø -> o, æ -> ae) to sOut; returns its length, 0 if none +int FoldLatinLetter(int cp, char[] sOut, int Length) +{ + char sLig[3]; + switch(cp) + { + case 0xC6, 0x1E2, 0x1FC: strcopy(sLig, sizeof(sLig), "AE"); // Æ Ǣ Ǽ + case 0xDE: strcopy(sLig, sizeof(sLig), "Th"); // Þ + case 0xDF: strcopy(sLig, sizeof(sLig), "ss"); // ß + case 0xE6, 0x1E3, 0x1FD: strcopy(sLig, sizeof(sLig), "ae"); // æ ǣ ǽ + case 0xFE: strcopy(sLig, sizeof(sLig), "th"); // þ + case 0x132: strcopy(sLig, sizeof(sLig), "IJ"); // IJ + case 0x133: strcopy(sLig, sizeof(sLig), "ij"); // ij + case 0x152: strcopy(sLig, sizeof(sLig), "OE"); // Œ + case 0x153: strcopy(sLig, sizeof(sLig), "oe"); // œ + case 0x1C4, 0x1F1: strcopy(sLig, sizeof(sLig), "DZ"); // DŽ DZ + case 0x1C5, 0x1F2: strcopy(sLig, sizeof(sLig), "Dz"); // Dž Dz + case 0x1C6, 0x1F3: strcopy(sLig, sizeof(sLig), "dz"); // dž dz + case 0x1C7: strcopy(sLig, sizeof(sLig), "LJ"); // LJ + case 0x1C8: strcopy(sLig, sizeof(sLig), "Lj"); // Lj + case 0x1C9: strcopy(sLig, sizeof(sLig), "lj"); // lj + case 0x1CA: strcopy(sLig, sizeof(sLig), "NJ"); // NJ + case 0x1CB: strcopy(sLig, sizeof(sLig), "Nj"); // Nj + case 0x1CC: strcopy(sLig, sizeof(sLig), "nj"); // nj + case 0x1E9E: strcopy(sLig, sizeof(sLig), "SS"); // ẞ + } + if(sLig[0]) + return strcopy(sOut, Length, sLig); + + // '.' = no base letter + static const char aLatinBase[] = "AAAAAA.CEEEEIIIIDNOOOOO.OUUUUY.." // U+00C0 + ... "aaaaaa.ceeeeiiiidnooooo.ouuuuy.y" // U+00E0 + ... "AaAaAaCcCcCcCcDdDdEeEeEeEeEeGgGg" // U+0100 + ... "GgGgHhHhIiIiIiIiIi..JjKkkLlLlLlL" // U+0120 + ... "lLlNnNnNnnNnOoOoOo..RrRrRrSsSsSs" // U+0140 + ... "SsTtTtTtUuUuUuUuUuUuWwYyYZzZzZzs" // U+0160 + ... "bBBb...CcDDDd.E..FfG...IKkl..NnO" // U+0180 + ... "Oo..Pp.....tTtTUu.VYyZz........." // U+01A0 + ... ".............AaIiOoUuUuUuUuUu.Aa" // U+01C0 + ... "Aa..GgGgKkOoOo..j...Gg..NnAa..Oo" // U+01E0 + ... "AaAaEeEeIiIiOoOoRrRrUuUuSsTt..Hh" // U+0200 + ... "Nd..ZzAaEeOoOoOoOoYylntj..ACcLTs" // U+0220 + ... "z..BUAEeJj.qRrYy" // U+0240 + ; + static const char aLatinExtBase[] = "AaBbBbBbCcDdDdDdDdDdEeEeEeEeEeFf" // U+1E00 + ... "GgHhHhHhHhHhIiIiKkKkKkLlLlLlLlMm" // U+1E20 + ... "MmMmNnNnNnNnOoOoOoOoPpPpRrRrRrRr" // U+1E40 + ... "SsSsSsSsSsTtTtTtTtUuUuUuUuUuVvVv" // U+1E60 + ... "WwWwWwWwWwXxXxYyZzZzZzhtwyasss.." // U+1E80 + ... "AaAaAaAaAaAaAaAaAaAaAaAaEeEeEeEe" // U+1EA0 + ... "EeEeEeEeIiIiOoOoOoOoOoOoOoOoOoOo" // U+1EC0 + ... "OoOoUuUuUuUuUuUuUuYyYyYyYy....Yy" // U+1EE0 + ; + + int c = '.'; + if(cp >= 0xC0 && cp <= 0x24F) + c = aLatinBase[cp - 0xC0]; + else if(cp >= 0x1E00 && cp <= 0x1EFF) + c = aLatinExtBase[cp - 0x1E00]; + + if(c == '.') + return 0; + + sOut[0] = c; + sOut[1] = '\0'; + return 1; +} + +// maps a codepoint to printable ASCII, or 0 to strip it +int FoldCodepoint(int cp) +{ + if(cp >= 0x20 && cp <= 0x7E) + return cp; + + if(cp < 0x20 || cp == 0x7F) + return 0; + + if(cp == 0xA0 || cp == 0x3000 // nbsp / ideographic space + || (cp >= 0x2000 && cp <= 0x200A) // en/em/thin/hair spaces + || cp == 0x202F || cp == 0x205F) // narrow nbsp / math space + return ' '; + + if(cp >= 0xFF01 && cp <= 0xFF5E) // fullwidth ASCII forms + return cp - 0xFEE0; + + if(cp >= 0x1D400 && cp <= 0x1D6A3) // 13 styled Latin alphabets of 52 + { + int r = (cp - 0x1D400) % 52; + return (r < 26) ? ('A' + r) : ('a' + r - 26); + } + + if(cp == 0x1D6A4) // dotless i + return 'i'; + if(cp == 0x1D6A5) // dotless j + return 'j'; + + if(cp >= 0x1D7CE && cp <= 0x1D7FF) // styled math digits, 5 sets of 10 + return '0' + ((cp - 0x1D7CE) % 10); + + if(cp >= 0x24B6 && cp <= 0x24CF) // circled A-Z + return 'A' + (cp - 0x24B6); + if(cp >= 0x24D0 && cp <= 0x24E9) // circled a-z + return 'a' + (cp - 0x24D0); + if(cp >= 0x2460 && cp <= 0x2468) // circled 1-9 + return '1' + (cp - 0x2460); + if(cp == 0x24EA) // circled 0 + return '0'; + + if(cp >= 0x249C && cp <= 0x24B5) // parenthesized a-z + return 'a' + (cp - 0x249C); + if(cp >= 0x2474 && cp <= 0x247C) // parenthesized 1-9 + return '1' + (cp - 0x2474); + if(cp >= 0x2488 && cp <= 0x2490) // digit full stop 1.-9. + return '1' + (cp - 0x2488); + if(cp >= 0x24F5 && cp <= 0x24FD) // double circled 1-9 + return '1' + (cp - 0x24F5); + if(cp == 0x24FF) // negative circled 0 + return '0'; + if(cp >= 0x2776 && cp <= 0x277E) // dingbat negative circled 1-9 + return '1' + (cp - 0x2776); + if(cp >= 0x2780 && cp <= 0x2788) // dingbat circled sans-serif 1-9 + return '1' + (cp - 0x2780); + if(cp >= 0x278A && cp <= 0x2792) // dingbat negative circled sans-serif 1-9 + return '1' + (cp - 0x278A); + + if(cp >= 0x1F110 && cp <= 0x1F129) // parenthesized A-Z + return 'A' + (cp - 0x1F110); + if(cp >= 0x1F130 && cp <= 0x1F149) // squared A-Z + return 'A' + (cp - 0x1F130); + if(cp >= 0x1F150 && cp <= 0x1F169) // negative circled A-Z + return 'A' + (cp - 0x1F150); + if(cp >= 0x1F170 && cp <= 0x1F189) // negative squared A-Z + return 'A' + (cp - 0x1F170); + if(cp >= 0x1F1E6 && cp <= 0x1F1FF) // regional indicator (flag letter) A-Z + return 'A' + (cp - 0x1F1E6); + + if(cp == 0xB9) // superscript 1 + return '1'; + if(cp == 0xB2 || cp == 0xB3) // superscript 2-3 + return '0' + (cp - 0xB0); + if(cp == 0x2070) // superscript 0 + return '0'; + if(cp >= 0x2074 && cp <= 0x2079) // superscript 4-9 + return '4' + (cp - 0x2074); + if(cp >= 0x2080 && cp <= 0x2089) // subscript 0-9 + return '0' + (cp - 0x2080); + + // superscript (modifier) letters, small capitals and letterlike symbols + // (the BMP "holes" in the styled math alphabets), scattered codepoints + static const int aLetterCps[] = { + 0x1D2C, 0x1D2E, 0x1D30, 0x1D31, 0x1D33, 0x1D34, 0x1D35, 0x1D36, // ᴬ ᴮ ᴰ ᴱ ᴳ ᴴ ᴵ ᴶ + 0x1D37, 0x1D38, 0x1D39, 0x1D3A, 0x1D3C, 0x1D3E, 0x1D3F, 0x1D40, // ᴷ ᴸ ᴹ ᴺ ᴼ ᴾ ᴿ ᵀ + 0x1D41, 0x1D42, // ᵁ ᵂ + 0x1D43, 0x1D47, 0x1D9C, 0x1D48, 0x1D49, 0x1DA0, 0x1D4D, 0x02B0, // ᵃ ᵇ ᶜ ᵈ ᵉ ᶠ ᵍ ʰ + 0x2071, 0x02B2, 0x1D4F, 0x02E1, 0x1D50, 0x207F, 0x1D52, 0x1D56, // ⁱ ʲ ᵏ ˡ ᵐ ⁿ ᵒ ᵖ + 0x107A5, 0x02B3, 0x02E2, 0x1D57, 0x1D58, 0x1D5B, 0x02B7, 0x02E3, // 𐞥 ʳ ˢ ᵗ ᵘ ᵛ ʷ ˣ + 0x02B8, 0x1DBB, // ʸ ᶻ + 0x1D00, 0x0299, 0x1D04, 0x1D05, 0x1D07, 0xA730, 0x0262, 0x029C, // ᴀ ʙ ᴄ ᴅ ᴇ ꜰ ɢ ʜ + 0x026A, 0x1D0A, 0x1D0B, 0x029F, 0x1D0D, 0x0274, 0x1D0F, 0x1D18, // ɪ ᴊ ᴋ ʟ ᴍ ɴ ᴏ ᴘ + 0x0280, 0xA731, 0x1D1B, 0x1D1C, 0x1D20, 0x1D21, 0x028F, 0x1D22, // ʀ ꜱ ᴛ ᴜ ᴠ ᴡ ʏ ᴢ + 0x2102, 0x210A, 0x210B, 0x210C, 0x210D, 0x210E, 0x210F, 0x2110, // ℂ ℊ ℋ ℌ ℍ ℎ ℏ ℐ + 0x2111, 0x2112, 0x2113, 0x2115, 0x2119, 0x211A, 0x211B, 0x211C, // ℑ ℒ ℓ ℕ ℙ ℚ ℛ ℜ + 0x211D, 0x2124, 0x2128, 0x212A, 0x212B, 0x212C, 0x212D, 0x212F, // ℝ ℤ ℨ K(elvin) Å ℬ ℭ ℯ + 0x2130, 0x2131, 0x2133, 0x2134, 0x2139, 0x2145, 0x2146, 0x2147, // ℰ ℱ ℳ ℴ ℹ ⅅ ⅆ ⅇ + 0x2148, 0x2149 // ⅈ ⅉ + }; + static const char aLetterAscii[] = "ABDEGHIJKLMNOPRTUW" + ... "abcdefghijklmnopqrstuvwxyz" + ... "abcdefghijklmnoprstuvwyz" + ... "CgHHHhhIILlNPQRRRZZKABCeEFMoiDdeij"; + + for(int i = 0; i < sizeof(aLetterCps); i++) + { + if(aLetterCps[i] == cp) + return aLetterAscii[i]; + } + + return 0; +} + +void NormalizeWhitespace(char[] sName) +{ + int r = 0; + int w = 0; + bool bLastSpace = false; + + while(sName[r] != '\0') + { + char c = sName[r++]; + if(c == ' ' || c == '\t') + { + if(bLastSpace) + continue; + bLastSpace = true; + sName[w++] = ' '; + } + else + { + bLastSpace = false; + sName[w++] = c; + } + } + sName[w] = '\0'; + + TrimString(sName); +} + +void AssignReplacementName(int client, char[] sName, int Length) +{ + g_bLastFilterReplaced = true; + + int iCount = g_ReplacementNames.Length; + int iBase = client % iCount; + char sCandidate[MAX_NAME_LENGTH]; + + for(int i = 0; i < iCount; i++) + { + g_ReplacementNames.GetString((iBase + i) % iCount, sCandidate, sizeof(sCandidate)); + + bool bTaken = false; + for(int j = 1; j <= MaxClients; j++) + { + if(j == client || !IsClientConnected(j)) + continue; + + char sOther[MAX_NAME_LENGTH]; + GetClientName(j, sOther, sizeof(sOther)); + if(StrEqual(sOther, sCandidate)) + { + bTaken = true; + break; + } + } + + if(!bTaken) + { + strcopy(sName, Length, sCandidate); + return; + } + } + + g_ReplacementNames.GetString(iBase, sCandidate, sizeof(sCandidate)); + Format(sName, Length, "%s %d", sCandidate, GetClientUserId(client)); +} diff --git a/Radio/scripting/Radio.sp b/Radio/scripting/Radio.sp new file mode 100644 index 0000000..1337cbb --- /dev/null +++ b/Radio/scripting/Radio.sp @@ -0,0 +1,131 @@ +#pragma semicolon 1 + +#include +#include +#include +#include +#include + +#undef REQUIRE_PLUGIN +#include +#include +#include +#define REQUIRE_PLUGIN + +#pragma newdecls required + +#define PLUGIN_VERSION "1.0" + +ConVar g_cvEnabled; +ConVar g_cvCustom; +ConVar g_cvCooldown; +ConVar g_cvRoundLimit; +ConVar g_cvDead; +ConVar g_cvSound; + +bool g_bCCC; +bool g_bLeader; +bool g_bZR; + +float g_flNextRadio[MAXPLAYERS + 1]; +int g_iRadioCount[MAXPLAYERS + 1]; + +public Plugin myinfo = +{ + name = "Radio", + author = "marisakirisame.net", + description = "Stock CS:S radio reimplemented with custom-chatcolors styling", + version = PLUGIN_VERSION, + url = "" +}; + +public void OnPluginStart() +{ + if (GetUserMessageType() == UM_Protobuf) + SetFailState("This plugin only supports bitbuffer usermessages (CS:S)."); + + if (GetUserMessageId("SendAudio") == INVALID_MESSAGE_ID || GetUserMessageId("SayText2") == INVALID_MESSAGE_ID) + SetFailState("SendAudio/SayText2 usermessages unavailable."); + + g_cvEnabled = CreateConVar("sm_radio_enabled", "1", "Enable the custom radio handling (0 = fully stock behaviour)", _, true, 0.0, true, 1.0); + g_cvCustom = CreateConVar("sm_radio_custom", "1", "Enable the custom radio commands (thanks, cheer); 0 = stock radio set only", _, true, 0.0, true, 1.0); + g_cvCooldown = CreateConVar("sm_radio_cooldown", "1.5", "Seconds a player has to wait between radio messages", _, true, 0.0); + g_cvRoundLimit = CreateConVar("sm_radio_round_limit", "0", "Max radio messages per player per round (engine default = 60, 0 = unlimited)", _, true, 0.0); + g_cvDead = CreateConVar("sm_radio_dead", "0", "Allow dead players to use the radio", _, true, 0.0, true, 1.0); + g_cvSound = CreateConVar("sm_radio_sound", "1", "Play the radio voice line", _, true, 0.0, true, 1.0); + AutoExecConfig(true); + + HookEvent("round_start", Event_RoundStart, EventHookMode_PostNoCopy); + + Commands_Init(); + Grenade_Init(); + Bans_Init(); + Settings_Init(); +} + +bool Radio_IsLeader(int client) +{ + // A zombified leader gets no [Leader] tag and no cooldown bypass. + if (g_bZR + && GetFeatureStatus(FeatureType_Native, "ZR_IsClientZombie") == FeatureStatus_Available + && ZR_IsClientZombie(client)) + { + return false; + } + + return g_bLeader + && GetFeatureStatus(FeatureType_Native, "Leader_Is") == FeatureStatus_Available + && Leader_Is(client); +} + +public void OnAllPluginsLoaded() +{ + g_bCCC = LibraryExists("ccc"); + g_bLeader = LibraryExists("leader"); + g_bZR = LibraryExists("zombiereloaded"); +} + +public void OnLibraryAdded(const char[] name) +{ + if (StrEqual(name, "ccc")) + g_bCCC = true; + else if (StrEqual(name, "leader")) + g_bLeader = true; + else if (StrEqual(name, "zombiereloaded")) + g_bZR = true; +} + +public void OnLibraryRemoved(const char[] name) +{ + if (StrEqual(name, "ccc")) + g_bCCC = false; + else if (StrEqual(name, "leader")) + g_bLeader = false; + else if (StrEqual(name, "zombiereloaded")) + g_bZR = false; +} + +public void OnMapStart() +{ + Bans_Clear(); + Commands_PrecacheSounds(); +} + +public void OnClientConnected(int client) +{ + g_flNextRadio[client] = 0.0; + g_iRadioCount[client] = 0; + Settings_OnClientConnected(client); +} + +public void Event_RoundStart(Event event, const char[] name, bool dontBroadcast) +{ + for (int i = 1; i <= MaxClients; i++) + g_iRadioCount[i] = 0; +} + +#include "radio/commands.sp" +#include "radio/grenade.sp" +#include "radio/format.sp" +#include "radio/bans.sp" +#include "radio/settings.sp" diff --git a/Radio/scripting/radio/bans.sp b/Radio/scripting/radio/bans.sp new file mode 100644 index 0000000..e96328e --- /dev/null +++ b/Radio/scripting/radio/bans.sp @@ -0,0 +1,90 @@ +//---------------------------------------------------------------------------------------------------- +// Radio bans: per-map bans keyed by SteamID, so they survive reconnects but die with the map. +//---------------------------------------------------------------------------------------------------- + +static StringMap g_BannedAuths; + +void Bans_Init() +{ + g_BannedAuths = new StringMap(); + + RegAdminCmd("sm_radiomute", Command_RadioBan, ADMFLAG_CHAT, "sm_radiomute - mute target(s)' radio for the rest of the map"); + RegAdminCmd("sm_radiounmute", Command_RadioUnban, ADMFLAG_CHAT, "sm_radiounmute - allow target(s) to use the radio again"); +} + +void Bans_Clear() +{ + g_BannedAuths.Clear(); +} + +bool Bans_IsBanned(int client) +{ + char auth[32]; + if (!GetClientAuthId(client, AuthId_Steam2, auth, sizeof(auth))) + return false; + + int dummy; + return g_BannedAuths.GetValue(auth, dummy); +} + +static void Bans_Set(int client, bool banned) +{ + char auth[32]; + if (!GetClientAuthId(client, AuthId_Steam2, auth, sizeof(auth))) + return; + + if (banned) + g_BannedAuths.SetValue(auth, 1); + else + g_BannedAuths.Remove(auth); +} + +public Action Command_RadioBan(int client, int argc) +{ + return Bans_Command(client, argc, true, "sm_radiomute"); +} + +public Action Command_RadioUnban(int client, int argc) +{ + return Bans_Command(client, argc, false, "sm_radiounmute"); +} + +static Action Bans_Command(int client, int argc, bool ban, const char[] command) +{ + if (argc < 1) + { + ReplyToCommand(client, "[SM] Usage: %s ", command); + return Plugin_Handled; + } + + char pattern[MAX_TARGET_LENGTH]; + GetCmdArg(1, pattern, sizeof(pattern)); + + char targetName[MAX_TARGET_LENGTH]; + int targets[MAXPLAYERS]; + bool tnIsML; + + int count = ProcessTargetString(pattern, client, targets, sizeof(targets), + COMMAND_FILTER_CONNECTED | COMMAND_FILTER_NO_BOTS, targetName, sizeof(targetName), tnIsML); + if (count <= 0) + { + ReplyToTargetError(client, count); + return Plugin_Handled; + } + + for (int i = 0; i < count; i++) + Bans_Set(targets[i], ban); + + if (ban) + { + ShowActivity2(client, "[SM] ", "Radio-muted %s for the rest of the map.", targetName); + LogAction(client, count == 1 ? targets[0] : -1, "\"%L\" radio-muted \"%s\"", client, targetName); + } + else + { + ShowActivity2(client, "[SM] ", "Radio-unmuted %s.", targetName); + LogAction(client, count == 1 ? targets[0] : -1, "\"%L\" radio-unmuted \"%s\"", client, targetName); + } + + return Plugin_Handled; +} diff --git a/Radio/scripting/radio/commands.sp b/Radio/scripting/radio/commands.sp new file mode 100644 index 0000000..8654b29 --- /dev/null +++ b/Radio/scripting/radio/commands.sp @@ -0,0 +1,234 @@ +//---------------------------------------------------------------------------------------------------- +// Radio commands: takes over the stock CS:S radio commands (the listener blocks the game's +// handling) and re-emits the voice line plus a custom-chatcolors styled chat line to the team. +//---------------------------------------------------------------------------------------------------- + +static const char g_sRadioCommands[][] = { + "coverme", "takepoint", "holdpos", "regroup", "followme", "takingfire", "go", + "fallback", "sticktog", "getinpos", "stormfront", "report", "roger", "enemyspot", + "needbackup", "sectorclear", "inposition", "reportingin", "getout", "negative", "enemydown", + "thanks", "cheer" +}; + +// Everything from this index on is a custom addition, not a stock radio command +// (gated behind sm_radio_custom). +#define RADIO_FIRST_CUSTOM (sizeof(g_sRadioCommands) - 2) + +static const char g_sRadioSounds[][] = { + "radio/ct_coverme.wav", "radio/takepoint.wav", "radio/position.wav", "radio/regroup.wav", + "radio/followme.wav", "radio/fireassis.wav", "radio/com_go.wav", "radio/fallback.wav", + "radio/sticktog.wav", "radio/com_getinpos.wav", "radio/stormfront.wav", "radio/com_reportin.wav", + "radio/roger.wav", "radio/ct_enemys.wav", "radio/ct_backup.wav", "radio/clear.wav", + "radio/ct_inpos.wav", "radio/ct_reportingin.wav", "radio/blow.wav", "radio/negative.wav", + "radio/enemydown.wav", + "", "" // thanks/cheer have no stock radio sound; a random bot-chatter wav is picked below +}; + +#define SOUND_AFFIRMATIVE "radio/ct_affirm.wav" +#define SOUND_FIREINTHEHOLE "radio/ct_fireinhole.wav" + +static const char g_sThanksSounds[][] = { + "bot/nice.wav", "bot/nice2.wav", "bot/good_one.wav", "bot/good_one2.wav", + "bot/very_nice.wav", "bot/well_done.wav" +}; + +static const char g_sCheerSounds[][] = { + "bot/whoo.wav", "bot/whoo2.wav", "bot/yesss.wav", "bot/yesss2.wav", + "bot/yea_baby.wav", "bot/oh_yea.wav", "bot/oh_yea2.wav" +}; + +// The bot chatter wavs are mastered 6-10 dB hotter than the radio wavs (radio averages +// -16.6 LUFS), so each one carries a gain that brings it down to the radio loudness. +static const float g_flThanksScales[] = { + 0.61, 0.44, 0.45, 0.63, + 0.43, 0.42 +}; + +static const float g_flCheerScales[] = { + 0.33, 0.27, 0.25, 0.32, + 0.31, 0.32, 0.39 +}; + +static const char g_sRadioPhrases[][] = { + "Cover Me!", "You take the point.", "Hold This position.", "Regroup Team.", + "Follow Me.", "Taking fire...need assistance!", "Go go go!", "Team, fall back!", + "Stick together, team.", "Get in position and wait for my go.", "Storm the Front!", + "Report in, team.", "Roger that.", "Enemy spotted.", "Need backup.", "Sector clear.", + "I'm in position.", "Reporting in.", "Get out of there, it's gonna blow!", + "Negative.", "Enemy down.", + "Thanks!", "Cheered!!!" +}; + +static const char g_sCheerColors[][] = { + "FF0000", "FF4500", "FF8C00", "FFD700", "ADFF2F", + "00FF00", "00BFFF", "1E90FF", "0000CD", "9400D3" +}; +static const char g_sCheerText[] = "Cheered!!!"; +static int g_iCheerOffset; + +static void Commands_BuildCheerPhrase(char[] phrase, int maxlen) +{ + phrase[0] = '\0'; + for (int i = 0; i < sizeof(g_sCheerText) - 1; i++) + { + Format(phrase, maxlen, "%s\x07%s%c", phrase, + g_sCheerColors[(i + g_iCheerOffset) % sizeof(g_sCheerColors)], g_sCheerText[i]); + } + g_iCheerOffset = (g_iCheerOffset + 1) % sizeof(g_sCheerColors); +} + +static StringMap g_RadioIndex; + +void Commands_PrecacheSounds() +{ + for (int i = 0; i < sizeof(g_sRadioSounds); i++) + { + if (g_sRadioSounds[i][0]) + PrecacheSound(g_sRadioSounds[i]); + } + PrecacheSound(SOUND_AFFIRMATIVE); + PrecacheSound(SOUND_FIREINTHEHOLE); // grenade.sp + for (int i = 0; i < sizeof(g_sThanksSounds); i++) + PrecacheSound(g_sThanksSounds[i]); + for (int i = 0; i < sizeof(g_sCheerSounds); i++) + PrecacheSound(g_sCheerSounds[i]); +} + +void Radio_EmitToPlayers(const int[] players, int playersNum, const char[] sound, float scale = 1.0) +{ + for (int i = 0; i < playersNum; i++) + { + float volume = Settings_GetVolume(players[i]); + if (volume <= 0.0) + continue; + + EmitSoundToClient(players[i], sound, SOUND_FROM_PLAYER, SNDCHAN_VOICE, + SNDLEVEL_NORMAL, SND_NOFLAGS, volume * scale); + } +} + +void Commands_Init() +{ + g_RadioIndex = new StringMap(); + for (int i = 0; i < sizeof(g_sRadioCommands); i++) + { + g_RadioIndex.SetValue(g_sRadioCommands[i], i); + AddCommandListener(Command_Radio, g_sRadioCommands[i]); + } +} + +public Action Command_Radio(int client, const char[] command, int argc) +{ + if (!g_cvEnabled.BoolValue) + return Plugin_Continue; + + if (client < 1 || client > MaxClients || !IsClientInGame(client)) + return Plugin_Continue; + + // Console commands are case-insensitive, StringMap keys are not. + char cmd[16]; + strcopy(cmd, sizeof(cmd), command); + for (int i = 0; cmd[i]; i++) + cmd[i] = CharToLower(cmd[i]); + + int index; + if (!g_RadioIndex.GetValue(cmd, index)) + return Plugin_Continue; + + // With the custom commands disabled they fall through as unknown commands. + if (index >= RADIO_FIRST_CUSTOM && !g_cvCustom.BoolValue) + return Plugin_Continue; + + if (Bans_IsBanned(client)) + return Plugin_Handled; + + int team = GetClientTeam(client); + if (team != CS_TEAM_T && team != CS_TEAM_CT) + return Plugin_Handled; + + if (!IsPlayerAlive(client) && !g_cvDead.BoolValue) + return Plugin_Handled; + + bool bLeader = Radio_IsLeader(client); + + float flNow = GetGameTime(); + bool bPlaySound = g_cvSound.BoolValue; + + if (!bLeader) + { + if (flNow < g_flNextRadio[client]) + return Plugin_Handled; + + int limit = g_cvRoundLimit.IntValue; + if (limit > 0 && g_iRadioCount[client] >= limit) + return Plugin_Handled; + + g_flNextRadio[client] = flNow + g_cvCooldown.FloatValue; + } + else if (flNow < g_flNextRadio[client]) + { + // Leaders may spam the chat line, but the voice line still honours the cooldown. + bPlaySound = false; + } + else + { + g_flNextRadio[client] = flNow + g_cvCooldown.FloatValue; + } + + g_iRadioCount[client]++; + + bool bCustom = (index >= RADIO_FIRST_CUSTOM); + + int[] players = new int[MaxClients]; + int playersNum = 0; + for (int i = 1; i <= MaxClients; i++) + { + if (!IsClientInGame(i) || IsFakeClient(i) || GetClientTeam(i) != team) + continue; + + // Players may opt out of the custom commands in !settings. + if (bCustom && !Settings_GetCustom(i)) + continue; + + players[playersNum++] = i; + } + + if (!playersNum) + return Plugin_Handled; + + // phrase must hold the coloured cheer line (10 glyphs each prefixed by a 7-byte \x07RRGGBB code) + char sound[32], phrase[128]; + float soundScale = 1.0; + strcopy(sound, sizeof(sound), g_sRadioSounds[index]); + strcopy(phrase, sizeof(phrase), g_sRadioPhrases[index]); + + // Stock "roger" picks one of two acknowledgements at random. + if (StrEqual(cmd, "roger") && GetRandomInt(0, 1)) + { + strcopy(sound, sizeof(sound), SOUND_AFFIRMATIVE); + strcopy(phrase, sizeof(phrase), "Affirmative."); + } + else if (StrEqual(cmd, "thanks")) + { + int pick = GetRandomInt(0, sizeof(g_sThanksSounds) - 1); + strcopy(sound, sizeof(sound), g_sThanksSounds[pick]); + soundScale = g_flThanksScales[pick]; + } + else if (StrEqual(cmd, "cheer")) + { + int pick = GetRandomInt(0, sizeof(g_sCheerSounds) - 1); + strcopy(sound, sizeof(sound), g_sCheerSounds[pick]); + soundScale = g_flCheerScales[pick]; + Commands_BuildCheerPhrase(phrase, sizeof(phrase)); + } + + if (bPlaySound && sound[0]) + Radio_EmitToPlayers(players, playersNum, sound, soundScale); + + char text[256]; + Format_RadioLine(client, bLeader, phrase, text, sizeof(text)); + + Radio_SendChat(client, players, playersNum, text); + + return Plugin_Handled; +} diff --git a/Radio/scripting/radio/format.sp b/Radio/scripting/radio/format.sp new file mode 100644 index 0000000..0b3cd0c --- /dev/null +++ b/Radio/scripting/radio/format.sp @@ -0,0 +1,97 @@ +//---------------------------------------------------------------------------------------------------- +// Radio format: assembles the chat line, colouring the sender's tag/name/text through +// custom-chatcolors when it is loaded. Colour mapping mirrors custom-chatcolors.sp. +//---------------------------------------------------------------------------------------------------- + +// Same prefix Leader3 puts on its own chat lines (Leader3.sp PREFIX). +#define LEADER_PREFIX "\x07FF00E1[Leader] \x01" + +// ent_idx = author makes \x03 render as the author's team colour; the chat flag must be +// true (the client's non-chat path mishandles raw coloured text); BLOCKHOOKS keeps chat +// processors (CCC included) from re-processing our synthetic line. +void Radio_SendChat(int author, const int[] players, int playersNum, const char[] text) +{ + Handle say = StartMessage("SayText2", players, playersNum, USERMSG_RELIABLE | USERMSG_BLOCKHOOKS); + BfWriteByte(say, author); + BfWriteByte(say, 1); + BfWriteString(say, text); + EndMessage(); +} + +static void ColorCode(int color, bool alpha, char[] out, int maxlen) +{ + switch (color) + { + case COLOR_TEAM: strcopy(out, maxlen, "\x03"); + case COLOR_CGREEN: strcopy(out, maxlen, "\x04"); + case COLOR_OLIVE: strcopy(out, maxlen, "\x05"); + case COLOR_NULL: out[0] = '\0'; + case COLOR_NONE: strcopy(out, maxlen, "\x01"); + default: + { + if (alpha) + FormatEx(out, maxlen, "\x08%08X", color); + else + FormatEx(out, maxlen, "\x07%06X", color); + } + } +} + +void Format_RadioLine(int client, bool leader, const char[] phrase, char[] out, int maxlen) +{ + char sender[MAX_NAME_LENGTH + 96]; + + bool useCCC = g_bCCC && GetFeatureStatus(FeatureType_Native, "CCC_GetColor") == FeatureStatus_Available; + + if (useCCC) + { + // CCC stores tags in 64-byte buffers (custom-chatcolors.sp g_sTag) and a processed + // tag can carry an inline \x07RRGGBB code per character, so match CCC's size. + char tag[64], tagCode[16], nameCode[16]; + bool alpha; + + CCC_GetTag(client, tag, sizeof(tag)); + + alpha = false; + ColorCode(CCC_GetColor(client, CCC_TagColor, alpha), alpha, tagCode, sizeof(tagCode)); + + alpha = false; + int nameColor = CCC_GetColor(client, CCC_NameColor, alpha); + + if (nameColor == COLOR_NULL && tag[0]) + { + // CCC name colour "X" (COLOR_NULL) erases the name so only the tag shows + FormatEx(sender, sizeof(sender), "%s%s", tagCode, tag); + } + else + { + ColorCode(nameColor, alpha, nameCode, sizeof(nameCode)); + + if (tag[0]) + FormatEx(sender, sizeof(sender), "%s%s%s%N", tagCode, tag, nameCode, client); + else + FormatEx(sender, sizeof(sender), "%s%N", nameCode, client); + } + } + else + { + FormatEx(sender, sizeof(sender), "\x03%N", client); + } + + char location[64]; + GetEntPropString(client, Prop_Send, "m_szLastPlaceName", location, sizeof(location)); + + // Leading \x01 guards the CS:S quirk where a colour code as the very first byte may not + // render; the \x01 before (RADIO): resets the line to the default orange, so the phrase + // renders like a stock radio message regardless of the player's CCC chat colour. + if (location[0]) + { + FormatEx(out, maxlen, "\x01%s%s \x01@ %s (RADIO): %s", + leader ? LEADER_PREFIX : "", sender, location, phrase); + } + else + { + FormatEx(out, maxlen, "\x01%s%s \x01(RADIO): %s", + leader ? LEADER_PREFIX : "", sender, phrase); + } +} diff --git a/Radio/scripting/radio/grenade.sp b/Radio/scripting/radio/grenade.sp new file mode 100644 index 0000000..e6ada93 --- /dev/null +++ b/Radio/scripting/radio/grenade.sp @@ -0,0 +1,132 @@ +//---------------------------------------------------------------------------------------------------- +// Grenade announce: throwing a grenade makes the game itself emit "Fire in the hole!" via the +// RadioText usermessage (it is not a client command), so intercept it and re-send the line +// styled a frame later. The game's SendAudio (Radio.FireInTheHole) is likewise intercepted and +// re-emitted through EmitSoundToClient so each listener's radio volume preference applies. +//---------------------------------------------------------------------------------------------------- + +#define FIRE_IN_THE_HOLE_TOKEN "#Cstrike_TitlesTXT_Fire_in_the_hole" + +void Grenade_Init() +{ + UserMsg RadioText = GetUserMessageId("RadioText"); + if (RadioText == INVALID_MESSAGE_ID) + SetFailState("RadioText usermessage unavailable."); + + HookUserMessage(RadioText, Hook_RadioText, true); + HookUserMessage(GetUserMessageId("SendAudio"), Hook_SendAudio, true); +} + +public Action Hook_SendAudio(UserMsg msg_id, Handle bf, const int[] players, int playersNum, bool reliable, bool init) +{ + if (!g_cvEnabled.BoolValue) + return Plugin_Continue; + + char sound[32]; + BfReadString(bf, sound, sizeof(sound), false); + + // Grenade announces are the only radio SendAudio the engine still emits (the command + // listeners block the rest); round events (bomb/win jingles) pass through untouched. + if (!StrEqual(sound, "Radio.FireInTheHole")) + return Plugin_Continue; + + // Sounds cannot be emitted from inside a usermessage hook either, so re-emit next frame. + DataPack pack = new DataPack(); + pack.WriteCell(playersNum); + for (int i = 0; i < playersNum; i++) + { + int target = players[i]; + pack.WriteCell((target >= 1 && target <= MaxClients && IsClientInGame(target)) ? GetClientUserId(target) : 0); + } + RequestFrame(OnFireInTheHoleResend, pack); + + return Plugin_Handled; +} + +public void OnFireInTheHoleResend(DataPack pack) +{ + pack.Reset(); + int num = pack.ReadCell(); + + int[] players = new int[MaxClients]; + int playersNum = 0; + for (int i = 0; i < num; i++) + { + int target = GetClientOfUserId(pack.ReadCell()); + if (target && !IsFakeClient(target)) + players[playersNum++] = target; + } + delete pack; + + Radio_EmitToPlayers(players, playersNum, SOUND_FIREINTHEHOLE); +} + +public Action Hook_RadioText(UserMsg msg_id, Handle bf, const int[] players, int playersNum, bool reliable, bool init) +{ + if (!g_cvEnabled.BoolValue) + return Plugin_Continue; + + BfReadByte(bf); // msg_dest + int client = BfReadByte(bf); + + if (client < 1 || client > MaxClients || !IsClientInGame(client)) + return Plugin_Continue; + + // Layout: msg_name, then up to four params. "#Game_radio" carries the phrase token in + // param2, "#Game_radio_location" in param3 (param2 is the nav place name). + char msgName[64], param[64], token[64]; + BfReadString(bf, msgName, sizeof(msgName), false); // msg_name + BfReadString(bf, param, sizeof(param), false); // param1 = player name + BfReadString(bf, token, sizeof(token), false); // param2 + BfReadString(bf, param, sizeof(param), false); // param3 + + if (StrEqual(msgName, "#Game_radio_location")) + strcopy(token, sizeof(token), param); + else if (!StrEqual(msgName, "#Game_radio")) + return Plugin_Continue; + + if (!StrEqual(token, FIRE_IN_THE_HOLE_TOKEN)) + return Plugin_Continue; + + if (Bans_IsBanned(client)) + return Plugin_Handled; + + // Usermessages cannot be sent from inside a usermessage hook, so stash the data and + // recipients (as userids) and re-send next frame. + DataPack pack = new DataPack(); + pack.WriteCell(GetClientUserId(client)); + pack.WriteCell(playersNum); + for (int i = 0; i < playersNum; i++) + { + int target = players[i]; + pack.WriteCell((target >= 1 && target <= MaxClients && IsClientInGame(target)) ? GetClientUserId(target) : 0); + } + RequestFrame(OnGrenadeResend, pack); + + return Plugin_Handled; +} + +public void OnGrenadeResend(DataPack pack) +{ + pack.Reset(); + int client = GetClientOfUserId(pack.ReadCell()); + int num = pack.ReadCell(); + + int[] players = new int[MaxClients]; + int playersNum = 0; + for (int i = 0; i < num; i++) + { + int target = GetClientOfUserId(pack.ReadCell()); + if (target && !IsFakeClient(target)) + players[playersNum++] = target; + } + delete pack; + + if (!client || !IsClientInGame(client) || !playersNum) + return; + + char text[256]; + Format_RadioLine(client, Radio_IsLeader(client), "Fire in the hole!", text, sizeof(text)); + + Radio_SendChat(client, players, playersNum, text); +} diff --git a/Radio/scripting/radio/settings.sp b/Radio/scripting/radio/settings.sp new file mode 100644 index 0000000..7ae6383 --- /dev/null +++ b/Radio/scripting/radio/settings.sp @@ -0,0 +1,191 @@ +//---------------------------------------------------------------------------------------------------- +// Radio settings: per-player preferences stored as clientprefs cookies, exposed as a "Radio" entry +// in the stock Client Preferences menu (sm_settings / !settings) that opens a sub-menu with the +// voice-line volume and a toggle for receiving the custom radio commands (thanks, cheer). +//---------------------------------------------------------------------------------------------------- + +static Cookie g_hVolumeCookie; +static Cookie g_hCustomCookie; +static int g_iVolume[MAXPLAYERS + 1]; +static bool g_bCustom[MAXPLAYERS + 1]; + +static const int g_iVolumeSteps[] = {100, 75, 50, 25, 0}; + +#define RADIO_VOLUME_DEFAULT 100 + +void Settings_Init() +{ + g_hVolumeCookie = new Cookie("radio_volume", "Radio voice line volume in percent (0 = off)", CookieAccess_Protected); + g_hCustomCookie = new Cookie("radio_custom", "Receive the custom radio commands (thanks, cheer)", CookieAccess_Protected); + SetCookieMenuItem(CookieMenu_Radio, 0, "Radio"); + + // Late load: clients may already be in game with cached cookies. + for (int i = 1; i <= MaxClients; i++) + { + g_iVolume[i] = RADIO_VOLUME_DEFAULT; + g_bCustom[i] = true; + if (IsClientConnected(i) && AreClientCookiesCached(i)) + OnClientCookiesCached(i); + } +} + +void Settings_OnClientConnected(int client) +{ + // until the cookies arrive + g_iVolume[client] = RADIO_VOLUME_DEFAULT; + g_bCustom[client] = true; +} + +public void OnClientCookiesCached(int client) +{ + char val[8]; + g_hVolumeCookie.Get(client, val, sizeof(val)); + + if (val[0]) // never set = keep the default + { + int volume = StringToInt(val); + if (volume < 0) + volume = 0; + else if (volume > 100) + volume = 100; + + g_iVolume[client] = volume; + } + + g_hCustomCookie.Get(client, val, sizeof(val)); + if (val[0]) + g_bCustom[client] = StringToInt(val) != 0; +} + +float Settings_GetVolume(int client) +{ + return g_iVolume[client] / 100.0; +} + +bool Settings_GetCustom(int client) +{ + return g_bCustom[client]; +} + +public void CookieMenu_Radio(int client, CookieMenuAction action, any info, char[] buffer, int maxlen) +{ + switch (action) + { + case CookieMenuAction_DisplayOption: + { + FormatEx(buffer, maxlen, "Radio"); + } + case CookieMenuAction_SelectOption: + { + Settings_ShowMainMenu(client); + } + } +} + +static void Settings_ShowMainMenu(int client) +{ + Menu menu = new Menu(Menu_RadioMain); + menu.SetTitle("Radio Settings"); + + char display[64]; + + FormatEx(display, sizeof(display), "Sound volume: %d%%", g_iVolume[client]); + menu.AddItem("volume", display); + + FormatEx(display, sizeof(display), "Custom Radio (thanks, cheer): %s", + g_bCustom[client] ? "Enabled" : "Disabled"); + menu.AddItem("custom", display); + + menu.ExitBackButton = true; // back to sm_settings + menu.Display(client, MENU_TIME_FOREVER); +} + +public int Menu_RadioMain(Menu menu, MenuAction action, int client, int param) +{ + switch (action) + { + case MenuAction_Select: + { + char item[8]; + menu.GetItem(param, item, sizeof(item)); + + if (StrEqual(item, "volume")) + { + Settings_ShowVolumeMenu(client); + } + else if (StrEqual(item, "custom")) + { + g_bCustom[client] = !g_bCustom[client]; + g_hCustomCookie.Set(client, g_bCustom[client] ? "1" : "0"); + Settings_ShowMainMenu(client); // re-show with the new state + } + } + case MenuAction_Cancel: + { + if (param == MenuCancel_ExitBack) + ShowCookieMenu(client); + } + case MenuAction_End: + { + delete menu; + } + } + return 0; +} + +static void Settings_ShowVolumeMenu(int client) +{ + Menu menu = new Menu(Menu_RadioVolume); + menu.SetTitle("Radio Sound Volume"); + + char item[8], display[16]; + for (int i = 0; i < sizeof(g_iVolumeSteps); i++) + { + int volume = g_iVolumeSteps[i]; + IntToString(volume, item, sizeof(item)); + + if (volume > 0) + FormatEx(display, sizeof(display), "%d%%", volume); + else + strcopy(display, sizeof(display), "Off"); + + if (volume == g_iVolume[client]) + StrCat(display, sizeof(display), " <<"); + + menu.AddItem(item, display); + } + + menu.ExitBackButton = true; // back to the Radio sub-menu + menu.Display(client, MENU_TIME_FOREVER); +} + +public int Menu_RadioVolume(Menu menu, MenuAction action, int client, int param) +{ + switch (action) + { + case MenuAction_Select: + { + char item[8]; + menu.GetItem(param, item, sizeof(item)); + + int volume = StringToInt(item); + g_iVolume[client] = volume; + + char val[8]; + IntToString(volume, val, sizeof(val)); + g_hVolumeCookie.Set(client, val); + + Settings_ShowVolumeMenu(client); // re-show with the new pick marked + } + case MenuAction_Cancel: + { + if (param == MenuCancel_ExitBack) + Settings_ShowMainMenu(client); + } + case MenuAction_End: + { + delete menu; + } + } + return 0; +} diff --git a/TeamManager/scripting/TeamManager.sp b/TeamManager/scripting/TeamManager.sp index 79c4706..91f78a5 100644 --- a/TeamManager/scripting/TeamManager.sp +++ b/TeamManager/scripting/TeamManager.sp @@ -2,6 +2,7 @@ #include #include #include +#include #pragma semicolon 1 #pragma newdecls required @@ -14,7 +15,19 @@ bool g_bWarmup = false; ConVar g_CVar_sm_warmuptime; ConVar g_CVar_sm_warmupratio; ConVar g_CVar_sm_warmupmaxtime; +ConVar g_CVar_sm_warmupmintime; +ConVar g_CVar_sm_warmupbase; +ConVar g_CVar_sm_warmupperplayer; +ConVar g_CVar_sm_warmuppermib; +ConVar g_CVar_sm_warmupdefaultmib; +ConVar g_CVar_sm_warmupfastdl; bool g_bLateLoad = false; + +// Max warmup inputs: compressed map size on fastdl and the most players seen still connecting +float g_fMapMiB = 0.0; +int g_iPeakConnecting = 0; +int g_iMapSerial = 0; +bool g_bTriedLowercase = false; Handle g_hWarmupEndFwd; bool g_bRoundEnded = false; @@ -40,8 +53,14 @@ public void OnPluginStart() HookEvent("round_end", OnRoundEnd, EventHookMode_PostNoCopy); g_CVar_sm_warmuptime = CreateConVar("sm_warmuptime", "10", "Warmup timer.", 0, true, 0.0, true, 60.0); - g_CVar_sm_warmupratio = CreateConVar("sm_warmupratio", "0.60", "Ratio of connected players that need to be in game to start warmup timer.", 0, true, 0.0, true, 1.0); - g_CVar_sm_warmupmaxtime = CreateConVar("sm_warmupmaxtime", "45", "Max Warmup timer.", 0, true, 0.0, true, 120.0); + g_CVar_sm_warmupratio = CreateConVar("sm_warmupratio", "0.90", "Ratio of connected players that need to be in game to start warmup timer.", 0, true, 0.0, true, 1.0); + g_CVar_sm_warmupmaxtime = CreateConVar("sm_warmupmaxtime", "150", "Upper limit for the computed max warmup time.", 0, true, 0.0, true, 600.0); + g_CVar_sm_warmupmintime = CreateConVar("sm_warmupmintime", "30", "Lower limit for the computed max warmup time.", 0, true, 0.0, true, 600.0); + g_CVar_sm_warmupbase = CreateConVar("sm_warmupbase", "20", "Base seconds of the computed max warmup time.", 0, true, 0.0); + g_CVar_sm_warmupperplayer = CreateConVar("sm_warmupperplayer", "0.0", "Max warmup seconds added per player still connecting.", 0, true, 0.0); + g_CVar_sm_warmuppermib = CreateConVar("sm_warmuppermib", "0.5", "Max warmup seconds added per MiB of the compressed map on fastdl.", 0, true, 0.0); + g_CVar_sm_warmupdefaultmib = CreateConVar("sm_warmupdefaultmib", "60", "Map size in MiB to assume until, or if, the fastdl lookup fails.", 0, true, 0.0); + g_CVar_sm_warmupfastdl = CreateConVar("sm_warmupfastdl", "https://fastdl.unloze.com/css_ze/maps/", "FastDL maps URL, .bsp.bz2 is appended."); g_hWarmupEndFwd = CreateGlobalForward("TeamManager_WarmupEnd", ET_Ignore); @@ -81,17 +100,151 @@ public void OnConfigsExecuted() g_bRoundEnded = false; g_bZombieSpawned = false; - if(g_CVar_sm_warmuptime.IntValue > 0 || g_CVar_sm_warmupratio.FloatValue > 0.0) + g_iMapSerial++; + g_iPeakConnecting = 0; + g_fMapMiB = g_CVar_sm_warmupdefaultmib.FloatValue; + g_bTriedLowercase = false; + + // sm_warmuptime 0 disables warmup entirely, some maps break if the round is restarted + if(g_CVar_sm_warmuptime.IntValue > 0) { + char sMap[PLATFORM_MAX_PATH]; + GetCurrentMap(sMap, sizeof(sMap)); + RequestMapSize(sMap); + g_bWarmup = true; CreateTimer(1.0, OnWarmupTimer, 0, TIMER_REPEAT|TIMER_FLAG_NO_MAPCHANGE); } } +void RequestMapSize(const char[] sMap) +{ + char sBaseURL[256]; + g_CVar_sm_warmupfastdl.GetString(sBaseURL, sizeof(sBaseURL)); + + char sURL[512]; + Format(sURL, sizeof(sURL), "%s%s.bsp.bz2", sBaseURL, sMap); + + // rip.ext has no HEAD, a one byte range request returns the full size in Content-Range instead + HTTPRequest request = new HTTPRequest(sURL); + request.SetHeader("Range", "bytes=0-0"); + request.Timeout = 10; + request.Get(OnMapSizeResponse, g_iMapSerial); +} + +public void OnMapSizeResponse(HTTPResponse response, any serial, const char[] error) +{ + if(serial != g_iMapSerial) + return; + + char sMap[PLATFORM_MAX_PATH]; + GetCurrentMap(sMap, sizeof(sMap)); + + if(error[0]) + { + LogMessage("Map size lookup for %s failed (%s), assuming %.1f MiB.", sMap, error, g_fMapMiB); + return; + } + + char sSize[64]; + bool bFound = false; + + if(response.Status == HTTPStatus_PartialContent && response.GetHeader("Content-Range", sSize, sizeof(sSize))) + { + int iSlash = FindCharInString(sSize, '/', true); + if(iSlash != -1) + { + strcopy(sSize, sizeof(sSize), sSize[iSlash + 1]); + bFound = true; + } + } + else if(response.Status == HTTPStatus_OK && response.GetHeader("Content-Length", sSize, sizeof(sSize))) + bFound = true; + else if(response.Status == HTTPStatus_NotFound && !g_bTriedLowercase) + { + // fastdl is case sensitive and mostly stores lowercase names + g_bTriedLowercase = true; + + char sLower[PLATFORM_MAX_PATH]; + strcopy(sLower, sizeof(sLower), sMap); + for(int i = 0; sLower[i]; i++) + sLower[i] = CharToLower(sLower[i]); + + if(!StrEqual(sLower, sMap)) + { + RequestMapSize(sLower); + return; + } + } + + // float, a large file would overflow an int byte count + float fBytes = bFound ? StringToFloat(sSize) : 0.0; + if(fBytes <= 0.0) + { + LogMessage("Map size lookup for %s got no size (HTTP %d), assuming %.1f MiB.", sMap, response.Status, g_fMapMiB); + return; + } + + g_fMapMiB = fBytes / 1048576.0; + LogMessage("Map %s is %.1f MiB on fastdl.", sMap, g_fMapMiB); +} + +int GetMaxWarmupTime() +{ + float fTime = g_CVar_sm_warmupbase.FloatValue + + g_CVar_sm_warmupperplayer.FloatValue * g_iPeakConnecting + + g_CVar_sm_warmuppermib.FloatValue * g_fMapMiB; + + int iTime = RoundToCeil(fTime); + int iMin = g_CVar_sm_warmupmintime.IntValue; + int iMax = g_CVar_sm_warmupmaxtime.IntValue; + + if(iTime < iMin) + iTime = iMin; + if(iTime > iMax) + iTime = iMax; + + return iTime; +} + public Action OnWarmupTimer(Handle timer) { - if(g_iMaxWarmup >= g_CVar_sm_warmupmaxtime.IntValue || g_iWarmup >= g_CVar_sm_warmuptime.IntValue) + // Disabled after warmup started (e.g. a map config ran late): stop without restarting the round + if(g_CVar_sm_warmuptime.IntValue <= 0) { + g_iMaxWarmup = 0; + g_bWarmup = false; + Call_StartForward(g_hWarmupEndFwd); + Call_Finish(); + return Plugin_Stop; + } + + // Only players still loading in count, anyone who left during the map change is already gone + int iConnecting = 0; + int iReady = 0; + for(int client = 1; client <= MaxClients; client++) + { + if(!IsClientConnected(client) || IsFakeClient(client)) + continue; + + if(IsClientInGame(client)) + iReady++; + else + iConnecting++; + } + + if(iConnecting > g_iPeakConnecting) + g_iPeakConnecting = iConnecting; + + int iMaxWarmupTime = GetMaxWarmupTime(); + + if(g_iMaxWarmup >= iMaxWarmupTime || g_iWarmup >= g_CVar_sm_warmuptime.IntValue) + { + char sMap[PLATFORM_MAX_PATH]; + GetCurrentMap(sMap, sizeof(sMap)); + LogMessage("Warmup on %s ended after %ds with %d ready players: %.1f MiB, %d peak connecting, max warmup %ds.", + sMap, g_iMaxWarmup + g_iWarmup, iReady, g_fMapMiB, g_iPeakConnecting, iMaxWarmupTime); + g_iMaxWarmup = 0; g_bWarmup = false; float fDelay = 3.0; @@ -110,7 +263,7 @@ public Action OnWarmupTimer(Handle timer) if(ClientsInGame < ClientsNeeded) { g_iMaxWarmup++; - PrintCenterTextAll("Warmup: Waiting for %d more players to join or %d seconds.", ClientsNeeded - ClientsInGame, g_CVar_sm_warmupmaxtime.IntValue - g_iMaxWarmup); + PrintCenterTextAll("Warmup: Waiting for %d more players to join or %d seconds.", ClientsNeeded - ClientsInGame, iMaxWarmupTime - g_iMaxWarmup); return Plugin_Continue; } else diff --git a/ZombieManager/scripting/ZombieManager.sp b/ZombieManager/scripting/ZombieManager.sp index 04622bf..37a3b04 100755 --- a/ZombieManager/scripting/ZombieManager.sp +++ b/ZombieManager/scripting/ZombieManager.sp @@ -256,6 +256,17 @@ public Action ZR_OnClientMotherZombieEligible(int client) return Plugin_Continue; } +stock bool IsNemesisMap() +{ + ConVar cvar = FindConVar("sm_info_message_file"); // not every server runs the info message plugin + if (cvar == null) + return false; + + char sNemesis[32]; + cvar.GetString(sNemesis, sizeof(sNemesis)); + return StrEqual(sNemesis, "nemesis"); +} + stock bool IsValidClient(int client) { if (client > 0 && client <= MaxClients && IsClientConnected(client) && IsClientInGame(client)) @@ -420,9 +431,7 @@ public Action Command_Testround(int client, int args) public Action Command_ZShield(int client, int args) { - char sNemesis[32]; - GetConVarString(FindConVar("sm_info_message_file"), sNemesis, sizeof(sNemesis)); - bool bNemesis = StrEqual(sNemesis, "nemesis"); + bool bNemesis = IsNemesisMap(); if(bNemesis) { @@ -520,9 +529,7 @@ public Action Timer_LockMotherZMNames(Handle timer) public Action Timer_HandleHPShield(Handle timer) { - char sNemesis[32]; - GetConVarString(FindConVar("sm_info_message_file"), sNemesis, sizeof(sNemesis)); - bool bNemesis = StrEqual(sNemesis, "nemesis"); + bool bNemesis = IsNemesisMap(); for(int i = 1; i <= MaxClients; i++) { diff --git a/_Hide/scripting/Hide.sp b/_Hide/scripting/Hide.sp index eb3e575..4a059b6 100644 --- a/_Hide/scripting/Hide.sp +++ b/_Hide/scripting/Hide.sp @@ -12,7 +12,6 @@ bool g_bHasSomebodyToHide[MAXPLAYERS + 1]; /* INTEGERS */ int g_iHideRange[MAXPLAYERS+1]; -int g_iLeader = 0; int g_iHideCapacity = 8; /* HANDLES */ @@ -203,7 +202,7 @@ public Action UpdateHide(Handle timer) { GetClientAbsOrigin(target, fOriginTarget); //PrintToChatAll("%N--%N::::::%f", client, target, GetVectorDistance(fOriginTarget, fOriginClient, false)); - if((GetVectorDistance(fOriginTarget, fOriginClient, true) <= float(g_iHideRange[client])) && (g_iLeader != target)) + if((GetVectorDistance(fOriginTarget, fOriginClient, true) <= float(g_iHideRange[client])) && !Leader_Is(target)) { g_bHidePlayers[client][target] = true; g_bHasSomebodyToHide[client] = true; @@ -219,7 +218,6 @@ public Action UpdateHide(Handle timer) } } } - g_iLeader = Leader_CurrentLeader() return Plugin_Continue; } diff --git a/_Hide/scripting/Hide_TransmitManager.sp b/_Hide/scripting/Hide_TransmitManager.sp index 08e4e68..555a08f 100644 --- a/_Hide/scripting/Hide_TransmitManager.sp +++ b/_Hide/scripting/Hide_TransmitManager.sp @@ -13,10 +13,22 @@ bool g_bHasSomebodyToHide[MAXPLAYERS + 1]; bool g_bHasTobeVisible[MAXPLAYERS + 1]; bool g_bHidePlayers[MAXPLAYERS + 1][MAXPLAYERS + 1]; +/* PER-TICK CACHED PLAYER STATE, refreshed at the top of UpdateHide so the pair loops are plain array reads */ +bool g_bInGame[MAXPLAYERS + 1]; +bool g_bFake[MAXPLAYERS + 1]; +bool g_bAlive[MAXPLAYERS + 1]; +bool g_bHuman[MAXPLAYERS + 1]; +bool g_bZombie[MAXPLAYERS + 1]; +bool g_bLeader[MAXPLAYERS + 1]; +bool g_bHasItem[MAXPLAYERS + 1]; +bool g_bHooked[MAXPLAYERS + 1]; +bool g_bTargetEligible[MAXPLAYERS + 1]; +float g_fOrigin[MAXPLAYERS + 1][3]; + /* INTEGERS */ int g_iHideRange[MAXPLAYERS+1]; -int g_iLeader = 0; int g_iTierHide = 0; +int g_iNextClient = 1; /* HANDLES */ Handle g_hTimer; @@ -48,6 +60,12 @@ public Plugin myinfo = url = "www.unloze.com" }; +public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max) +{ + MarkNativeAsOptional("GetPlayerTier_native"); + return APLRes_Success; +} + //---------------------------------------------------------------------------------------------------- // Purpose: //---------------------------------------------------------------------------------------------------- @@ -73,7 +91,9 @@ public void OnPluginStart() } SetCookieMenuItem(MenuHandler_CookieMenu, 0, "Hide"); - g_hTimer = CreateTimer(1.0, UpdateHide, INVALID_HANDLE, TIMER_REPEAT); + //0.1s ticks each processing a tenth of the slots: every client still refreshes about once a second, + //but the cost is spread over ten frames instead of one spike + g_hTimer = CreateTimer(0.1, UpdateHide, INVALID_HANDLE, TIMER_REPEAT); g_iTierHide = FindTierForCommandOverride("sm_hide"); } @@ -108,129 +128,166 @@ public Action UpdateHide(Handle timer) if (!g_hCVar_HideEnabled.BoolValue) return Plugin_Continue; - g_iLeader = Leader_CurrentLeader(); - - for (int client = 1; client <= MaxClients; client++) + //early-out when nobody has hide enabled and no hide state is left to clean up + bool bAnyWork = false; + for (int i = 1; i <= MaxClients; i++) { - if (!IsValidClient(client) || IsClientSourceTV(client) || IsFakeClient(client)) + if ((g_iHideRange[i] != DISABLED || g_bHasSomebodyToHide[i]) && IsClientInGame(i)) { + bAnyWork = true; + break; + } + } + if (!bAnyWork) + return Plugin_Continue; + + //cache the per-player state once per tick. everything the pair loops need depends on a single + //player, so this costs natives once per player instead of once per pair. + for (int i = 1; i <= MaxClients; i++) + { + g_bInGame[i] = IsClientInGame(i) && !IsClientSourceTV(i); + if (!g_bInGame[i]) + { + g_bTargetEligible[i] = false; continue; } + g_bFake[i] = IsFakeClient(i); + g_bHooked[i] = TransmitManager_IsEntityHooked(i); + g_bAlive[i] = IsPlayerAlive(i); + g_bHuman[i] = g_bAlive[i] && ZR_IsClientHuman(i); + g_bZombie[i] = g_bAlive[i] && ZR_IsClientZombie(i); + g_bLeader[i] = Leader_Is(i); + g_bHasItem[i] = EW_ClientHasItem(i); + if (g_bAlive[i]) + GetClientAbsOrigin(i, g_fOrigin[i]); - if (g_iHideRange[client] == DISABLED) //client just disabled hide right now. the bool will pass once until they enabled hide again. - { - if (g_bHasSomebodyToHide[client] && TransmitManager_IsEntityHooked(client)) - { - for (int target = 1; target <= MaxClients; target++) - { - if (IsValidClient(target) && client != target && TransmitManager_IsEntityHooked(target)) - { - TransmitManager_SetEntityState(target, client, true); //client can see all players again - g_bHidePlayers[client][target] = false; - } - } - g_bHasSomebodyToHide[client] = false; - } - continue; - } + //the target is not the leader, has no item, is alive and is a human. so consider him for the distance check. + g_bTargetEligible[i] = g_bHuman[i] && !g_bLeader[i] && !g_bHasItem[i]; + } - //lazy fix for invisible zm - if (TransmitManager_IsEntityHooked(client)) - { - for (int target = 1; target <= MaxClients; target++) - { - if (IsValidClient(target) && client != target && g_bHidePlayers[client][target] - && IsPlayerAlive(target) && ZR_IsClientZombie(target) && TransmitManager_IsEntityHooked(target)) - { - g_bHidePlayers[client][target] = false; - TransmitManager_SetEntityState(target, client, true); //client can see player again because the player is a zm - } - } - } - - bool ClientIsAlive = IsPlayerAlive(client); - bool ClientIsHuman = false; - if (ClientIsAlive) - { - ClientIsHuman = ZR_IsClientHuman(client); - } - bool ClientHasItem = EW_ClientHasItem(client); - - //player is leader, has an item, is not alive or is not human, set the transmit state once via the bool so all can see the player. - if (g_iLeader == client || ClientHasItem || !ClientIsAlive || !ClientIsHuman) - { - if (!g_bHasTobeVisible[client] && TransmitManager_IsEntityHooked(client)) - { - for (int target = 1; target <= MaxClients; target++) - { - if (IsValidClient(target) && client != target && TransmitManager_IsEntityHooked(target)) - { - //all must see the leader, the zombie and the item user. - TransmitManager_SetEntityState(client, target, true); //all can see the player again - - //if the player is dead or not human they must see all players again - if (!ClientIsAlive || !ClientIsHuman) - { - TransmitManager_SetEntityState(target, client, true); //client can see all players again - } - } - } - g_bHasTobeVisible[client] = true; - } - //but the human leader and human item users may still want to hide the remaining human players. - if (ClientIsAlive && ClientIsHuman && (g_iLeader == client || ClientHasItem)) - { - DoTheTargetCheck(client); - } - continue; - } - g_bHasTobeVisible[client] = false; - - //all the remaining checks for clients with hide enabled that are alive humans and not leaders or item users. - DoTheTargetCheck(client); + //process a tenth of the slots per tick; combined with the 0.1s interval every client is + //refreshed about once a second, like the old 1.0s full scan, without the frame spike. + int iSlice = (MaxClients + 9) / 10; + for (int n = 0; n < iSlice; n++) + { + int client = g_iNextClient; + g_iNextClient = (g_iNextClient % MaxClients) + 1; + ProcessHideClient(client); } return Plugin_Continue; } +void ProcessHideClient(int client) +{ + if (!g_bInGame[client] || g_bFake[client]) + return; + + if (g_iHideRange[client] == DISABLED) //client just disabled hide right now. the bool will pass once until they enabled hide again. + { + if (g_bHasSomebodyToHide[client] && g_bHooked[client]) + { + for (int target = 1; target <= MaxClients; target++) + { + if (client != target && g_bInGame[target] && g_bHooked[target]) + { + TransmitManager_SetEntityState(target, client, true); //client can see all players again + g_bHidePlayers[client][target] = false; + } + } + g_bHasSomebodyToHide[client] = false; + } + return; + } + + //lazy fix for invisible zm + if (g_bHooked[client]) + { + for (int target = 1; target <= MaxClients; target++) + { + if (g_bHidePlayers[client][target] && client != target && g_bInGame[target] + && g_bZombie[target] && g_bHooked[target]) + { + g_bHidePlayers[client][target] = false; + TransmitManager_SetEntityState(target, client, true); //client can see player again because the player is a zm + } + } + } + + bool ClientIsAlive = g_bAlive[client]; + bool ClientIsHuman = g_bHuman[client]; + bool ClientIsLeader = g_bLeader[client]; + bool ClientHasItem = g_bHasItem[client]; + + //player is leader, has an item, is not alive or is not human, set the transmit state once via the bool so all can see the player. + if (ClientIsLeader || ClientHasItem || !ClientIsAlive || !ClientIsHuman) + { + if (!g_bHasTobeVisible[client] && g_bHooked[client]) + { + for (int target = 1; target <= MaxClients; target++) + { + if (client != target && g_bInGame[target] && g_bHooked[target]) + { + //all must see the leader, the zombie and the item user. + TransmitManager_SetEntityState(client, target, true); //all can see the player again + + //if the player is dead or not human they must see all players again + if (!ClientIsAlive || !ClientIsHuman) + { + TransmitManager_SetEntityState(target, client, true); //client can see all players again + } + } + } + g_bHasTobeVisible[client] = true; + } + //but the human leader and human item users may still want to hide the remaining human players. + if (ClientIsAlive && ClientIsHuman && (ClientIsLeader || ClientHasItem)) + { + DoTheTargetCheck(client); + } + return; + } + g_bHasTobeVisible[client] = false; + + //all the remaining checks for clients with hide enabled that are alive humans and not leaders or item users. + DoTheTargetCheck(client); +} + public void DoTheTargetCheck(int client) { - float fOriginClient[3]; - float fOriginTarget[3]; - GetClientAbsOrigin(client, fOriginClient); g_bHasSomebodyToHide[client] = false; + float fRangeSquared = float(g_iHideRange[client]); + float fClientX = g_fOrigin[client][0]; + float fClientY = g_fOrigin[client][1]; + float fClientZ = g_fOrigin[client][2]; + bool bClientHooked = g_bHooked[client]; + for (int target = 1; target <= MaxClients; target++) { - if (!IsValidClient(target) || IsClientSourceTV(target)) + if (target == client || !g_bTargetEligible[target]) { continue; } - //the target is not the leader, has no item, is alive and is a human. so consider him for the distance check. - if (g_iLeader != target && !EW_ClientHasItem(target) && IsPlayerAlive(target) && ZR_IsClientHuman(target) && target != client) + float fDeltaX = g_fOrigin[target][0] - fClientX; + float fDeltaY = g_fOrigin[target][1] - fClientY; + float fDeltaZ = g_fOrigin[target][2] - fClientZ; + + if (fDeltaX * fDeltaX + fDeltaY * fDeltaY + fDeltaZ * fDeltaZ <= fRangeSquared) { - GetClientAbsOrigin(target, fOriginTarget); - if (GetVectorDistance(fOriginTarget, fOriginClient, true) <= float(g_iHideRange[client])) + //we only have to check the hook when the state of the hidePlayers boolean changed + if (!g_bHidePlayers[client][target] && bClientHooked && g_bHooked[target]) { - //we only have to check the hook when the state of the hidePlayers boolean changed - if (!g_bHidePlayers[client][target] && TransmitManager_IsEntityHooked(client) && TransmitManager_IsEntityHooked(target)) - { - //the client here cannot be a fakeclient. thats already checked. - TransmitManager_SetEntityState(target, client, false); //the player is hiding the teammate due to distance - g_bHidePlayers[client][target] = true; - //LogMessage("client: %N is %s. target: %N is %s", client, ZR_IsClientHuman(client) ? "Human" : "Zombie", target, ZR_IsClientHuman(target) ? "Human" : "Zombie"); - } - g_bHasSomebodyToHide[client] = true; //theres at least one target to hide for the player, the teammate - } - else - { - //we only have to check the hook when the state of the hidePlayers boolean changed - if (g_bHidePlayers[client][target] && TransmitManager_IsEntityHooked(client) && TransmitManager_IsEntityHooked(target)) - { - TransmitManager_SetEntityState(target, client, true); //the player is seeing the teammate due to distance - g_bHidePlayers[client][target] = false; - } + //the client here cannot be a fakeclient. thats already checked. + TransmitManager_SetEntityState(target, client, false); //the player is hiding the teammate due to distance + g_bHidePlayers[client][target] = true; } + g_bHasSomebodyToHide[client] = true; //theres at least one target to hide for the player, the teammate + } + else if (g_bHidePlayers[client][target] && bClientHooked && g_bHooked[target]) + { + TransmitManager_SetEntityState(target, client, true); //the player is seeing the teammate due to distance + g_bHidePlayers[client][target] = false; } } } @@ -393,10 +450,13 @@ stock int FindTierForCommandOverride(const char[] command) //---------------------------------------------------------------------------------------------------- public void MenuHandler_CookieMenu(int client, CookieMenuAction action, any info, char[] buffer, int maxlen) { - int client_tier = GetPlayerTier_native(client); - if (g_iTierHide > client_tier) + if (GetFeatureStatus(FeatureType_Native, "GetPlayerTier_native") == FeatureStatus_Available) { - return; + int client_tier = GetPlayerTier_native(client); + if (g_iTierHide > client_tier) + { + return; + } } switch(action) { @@ -452,7 +512,6 @@ public void OnClientCookiesCached(int client) stock bool IsValidClient(int client) { - if (client > 0 && client <= MaxClients && IsClientConnected(client) && IsClientInGame(client)) - return true; - return false; + //IsClientInGame implies IsClientConnected + return (client > 0 && client <= MaxClients && IsClientInGame(client)); } diff --git a/mapchooser_extended/scripting/mapchooser_extended_unloze.sp b/mapchooser_extended/scripting/mapchooser_extended_unloze.sp index 6805555..9c6b3ae 100755 --- a/mapchooser_extended/scripting/mapchooser_extended_unloze.sp +++ b/mapchooser_extended/scripting/mapchooser_extended_unloze.sp @@ -342,6 +342,7 @@ public void OnPluginStart() RegAdminCmd("sm_mapvote", Command_Mapvote, ADMFLAG_CHANGEMAP, "sm_mapvote - Forces MapChooser to attempt to run a map vote now."); RegAdminCmd("sm_setnextmap", Command_SetNextmap, ADMFLAG_CHANGEMAP, "sm_setnextmap "); + RegAdminCmd("sm_unsetnextmap", Command_UnsetNextmap, ADMFLAG_CHANGEMAP, "sm_unsetnextmap - Unsets an admin-set next map and re-enables the end of map vote."); // Mapchooser Extended Commands RegAdminCmd("mce_reload_maplist", Command_ReloadMaps, ADMFLAG_CHANGEMAP, "mce_reload_maplist - Reload the Official Maplist file."); @@ -885,6 +886,23 @@ public Action Command_SetNextmap(int client, int args) return Plugin_Handled; } +public Action Command_UnsetNextmap(int client, int args) +{ + if(g_iNextmapState != 2) + { + CReplyToCommand(client, "[MCE] No admin-set next map to unset."); + return Plugin_Handled; + } + + g_iNextmapState = 0; + g_MapVoteCompleted = false; + FindConVar("sm_nextmap").SetString(""); + + ShowActivity(client, "unset the next map"); + LogAction(client, -1, "\"%L\" unset the nextmap", client); + return Plugin_Handled; +} + public Action Command_ReloadMaps(int client, int args) { InitializeOfficialMapList();