diff options
Diffstat (limited to 'sourcemod/scripting/gokz-jumpstats')
| -rw-r--r-- | sourcemod/scripting/gokz-jumpstats/api.sp | 78 | ||||
| -rw-r--r-- | sourcemod/scripting/gokz-jumpstats/commands.sp | 28 | ||||
| -rw-r--r-- | sourcemod/scripting/gokz-jumpstats/distance_tiers.sp | 118 | ||||
| -rw-r--r-- | sourcemod/scripting/gokz-jumpstats/jump_reporting.sp | 508 | ||||
| -rw-r--r-- | sourcemod/scripting/gokz-jumpstats/jump_tracking.sp | 1624 | ||||
| -rw-r--r-- | sourcemod/scripting/gokz-jumpstats/jump_validating.sp | 82 | ||||
| -rw-r--r-- | sourcemod/scripting/gokz-jumpstats/options.sp | 86 | ||||
| -rw-r--r-- | sourcemod/scripting/gokz-jumpstats/options_menu.sp | 145 |
8 files changed, 2669 insertions, 0 deletions
diff --git a/sourcemod/scripting/gokz-jumpstats/api.sp b/sourcemod/scripting/gokz-jumpstats/api.sp new file mode 100644 index 0000000..2625fda --- /dev/null +++ b/sourcemod/scripting/gokz-jumpstats/api.sp @@ -0,0 +1,78 @@ +static GlobalForward H_OnTakeoff; +static GlobalForward H_OnLanding; +static GlobalForward H_OnFailstat; +static GlobalForward H_OnJumpstatAlways; +static GlobalForward H_OnFailstatAlways; +static GlobalForward H_OnJumpInvalidated; + + + +// =====[ FORWARDS ]===== + +void CreateGlobalForwards() +{ + H_OnTakeoff = new GlobalForward("GOKZ_JS_OnTakeoff", ET_Ignore, Param_Cell, Param_Cell); + H_OnLanding = new GlobalForward("GOKZ_JS_OnLanding", ET_Ignore, Param_Array); + H_OnFailstat = new GlobalForward("GOKZ_JS_OnFailstat", ET_Ignore, Param_Array); + H_OnJumpstatAlways = new GlobalForward("GOKZ_JS_OnJumpstatAlways", ET_Ignore, Param_Array); + H_OnFailstatAlways = new GlobalForward("GOKZ_JS_OnFailstatAlways", ET_Ignore, Param_Array); + H_OnJumpInvalidated = new GlobalForward("GOKZ_JS_OnJumpInvalidated", ET_Ignore, Param_Cell); +} + +void Call_OnTakeoff(int client, int jumpType) +{ + Call_StartForward(H_OnTakeoff); + Call_PushCell(client); + Call_PushCell(jumpType); + Call_Finish(); +} + +void Call_OnLanding(Jump jump) +{ + Call_StartForward(H_OnLanding); + Call_PushArray(jump, sizeof(jump)); + Call_Finish(); +} + +void Call_OnJumpInvalidated(int client) +{ + Call_StartForward(H_OnJumpInvalidated); + Call_PushCell(client); + Call_Finish(); +} + +void Call_OnFailstat(Jump jump) +{ + Call_StartForward(H_OnFailstat); + Call_PushArray(jump, sizeof(jump)); + Call_Finish(); +} + +void Call_OnJumpstatAlways(Jump jump) +{ + Call_StartForward(H_OnJumpstatAlways); + Call_PushArray(jump, sizeof(jump)); + Call_Finish(); +} + +void Call_OnFailstatAlways(Jump jump) +{ + Call_StartForward(H_OnFailstatAlways); + Call_PushArray(jump, sizeof(jump)); + Call_Finish(); +} + + + +// =====[ NATIVES ]===== + +void CreateNatives() +{ + CreateNative("GOKZ_JS_InvalidateJump", Native_InvalidateJump); +} + +public int Native_InvalidateJump(Handle plugin, int numParams) +{ + InvalidateJumpstat(GetNativeCell(1)); + return 0; +} diff --git a/sourcemod/scripting/gokz-jumpstats/commands.sp b/sourcemod/scripting/gokz-jumpstats/commands.sp new file mode 100644 index 0000000..991f9e6 --- /dev/null +++ b/sourcemod/scripting/gokz-jumpstats/commands.sp @@ -0,0 +1,28 @@ + +void RegisterCommands() +{ + RegConsoleCmd("sm_jso", CommandJumpstatsOptions, "[KZ] Open the jumpstats options menu."); + RegConsoleCmd("sm_jsalways", CommandAlwaysJumpstats, "[KZ] Toggle the always-on jumpstats."); +} + +public Action CommandJumpstatsOptions(int client, int args) +{ + DisplayJumpstatsOptionsMenu(client); + return Plugin_Handled; +} + +public Action CommandAlwaysJumpstats(int client, int args) +{ + if (GOKZ_JS_GetOption(client, JSOption_JumpstatsAlways) == JSToggleOption_Enabled) + { + GOKZ_JS_SetOption(client, JSOption_JumpstatsAlways, JSToggleOption_Disabled); + GOKZ_PrintToChat(client, true, "%t", "Jumpstats Option - Jumpstats Always - Disable"); + } + else + { + GOKZ_JS_SetOption(client, JSOption_JumpstatsAlways, JSToggleOption_Enabled); + GOKZ_PrintToChat(client, true, "%t", "Jumpstats Option - Jumpstats Always - Enable"); + } + + return Plugin_Handled; +} diff --git a/sourcemod/scripting/gokz-jumpstats/distance_tiers.sp b/sourcemod/scripting/gokz-jumpstats/distance_tiers.sp new file mode 100644 index 0000000..3abe8e9 --- /dev/null +++ b/sourcemod/scripting/gokz-jumpstats/distance_tiers.sp @@ -0,0 +1,118 @@ +/* + Categorises jumps into tiers based on their distance. + Tier thresholds are loaded from a config. +*/ + + + +static float distanceTiers[JUMPTYPE_COUNT - 3][MODE_COUNT][DISTANCETIER_COUNT]; + + + +// =====[ PUBLIC ]===== + +int GetDistanceTier(int jumpType, int mode, float distance, float offset = 0.0) +{ + // No tiers given for 'Invalid' jumps. + if (jumpType == JumpType_Invalid || jumpType == JumpType_FullInvalid + || jumpType == JumpType_Fall || jumpType == JumpType_Other + || jumpType != JumpType_LadderJump && offset < -JS_OFFSET_EPSILON + || distance > JS_MAX_JUMP_DISTANCE) + { + // TODO Give a tier to "Other" jumps + // TODO Give a tier to offset jumps + return DistanceTier_None; + } + + // Get highest tier distance that the jump beats + int tier = DistanceTier_None; + while (tier + 1 < DISTANCETIER_COUNT && distance >= GetDistanceTierDistance(jumpType, mode, tier + 1)) + { + tier++; + } + + return tier; +} + +float GetDistanceTierDistance(int jumpType, int mode, int tier) +{ + return distanceTiers[jumpType][mode][tier]; +} + +bool LoadBroadcastTiers() +{ + char chatTier[16], soundTier[16]; + + KeyValues kv = new KeyValues("broadcast"); + if (!kv.ImportFromFile(JS_CFG_BROADCAST)) + { + return false; + } + + kv.GetString("chat", chatTier, sizeof(chatTier), "ownage"); + kv.GetString("sound", soundTier, sizeof(chatTier), ""); + + for (int tier = 0; tier < sizeof(gC_DistanceTierKeys); tier++) + { + if (StrEqual(chatTier, gC_DistanceTierKeys[tier])) + { + gI_JSOptionDefaults[JSOption_MinChatBroadcastTier] = tier; + } + if (StrEqual(soundTier, gC_DistanceTierKeys[tier])) + { + gI_JSOptionDefaults[JSOption_MinSoundBroadcastTier] = tier; + } + } + + delete kv; + return true; +} + + + +// =====[ EVENTS ]===== + +void OnMapStart_DistanceTiers() +{ + if (!LoadDistanceTiers()) + { + SetFailState("Failed to load file: \"%s\".", JS_CFG_TIERS); + } +} + + + +// =====[ PRIVATE ]===== + +static bool LoadDistanceTiers() +{ + KeyValues kv = new KeyValues("tiers"); + if (!kv.ImportFromFile(JS_CFG_TIERS)) + { + return false; + } + + // It's a bit of a hack to exclude non-tiered jumptypes + for (int jumpType = 0; jumpType < sizeof(gC_JumpTypeKeys) - 3; jumpType++) + { + if (!kv.JumpToKey(gC_JumpTypeKeys[jumpType])) + { + return false; + } + for (int mode = 0; mode < MODE_COUNT; mode++) + { + if (!kv.JumpToKey(gC_ModeKeys[mode])) + { + return false; + } + for (int tier = DistanceTier_Meh; tier < DISTANCETIER_COUNT; tier++) + { + distanceTiers[jumpType][mode][tier] = kv.GetFloat(gC_DistanceTierKeys[tier]); + } + kv.GoBack(); + } + kv.GoBack(); + } + delete kv; + return true; +} diff --git a/sourcemod/scripting/gokz-jumpstats/jump_reporting.sp b/sourcemod/scripting/gokz-jumpstats/jump_reporting.sp new file mode 100644 index 0000000..31a1bb2 --- /dev/null +++ b/sourcemod/scripting/gokz-jumpstats/jump_reporting.sp @@ -0,0 +1,508 @@ +/* + Chat and console reports for jumpstats. +*/ + +static char sounds[DISTANCETIER_COUNT][256]; + + + +// =====[ PUBLIC ]===== + +void PlayJumpstatSound(int client, int tier) +{ + int soundOption = GOKZ_JS_GetOption(client, JSOption_MinSoundTier); + if (tier <= DistanceTier_Meh || soundOption == DistanceTier_None || soundOption > tier) + { + return; + } + + GOKZ_EmitSoundToClient(client, sounds[tier], _, "Jumpstats"); +} + + + +// =====[ EVENTS ]===== + +void OnMapStart_JumpReporting() +{ + if (!LoadSounds()) + { + SetFailState("Failed to load file: \"%s\".", JS_CFG_SOUNDS); + } +} + +void OnLanding_JumpReporting(Jump jump) +{ + int minTier; + int tier = GetDistanceTier(jump.type, GOKZ_GetCoreOption(jump.jumper, Option_Mode), jump.distance, jump.offset); + if (tier == DistanceTier_None) + { + return; + } + + // Report the jumpstat to the client and their spectators + DoJumpstatsReport(jump.jumper, jump, tier); + + for (int client = 1; client <= MaxClients; client++) + { + if (IsValidClient(client) && client != jump.jumper) + { + if (GetObserverTarget(client) == jump.jumper) + { + DoJumpstatsReport(client, jump, tier); + } + else + { + minTier = GOKZ_JS_GetOption(client, JSOption_MinChatBroadcastTier); + if (minTier != 0 && tier >= minTier) + { + GOKZ_PrintToChat(client, true, "%t", "Broadcast Jumpstat Chat Report", + gC_DistanceTierChatColours[tier], + jump.jumper, + jump.distance, + gC_JumpTypes[jump.originalType]); + DoConsoleReport(client, false, jump, tier, "Console Jump Header"); + } + + minTier = GOKZ_JS_GetOption(client, JSOption_MinSoundBroadcastTier); + if (minTier != 0 && tier >= minTier) + { + PlayJumpstatSound(client, tier); + } + } + } + } +} + +void OnFailstat_FailstatReporting(Jump jump) +{ + int tier = GetDistanceTier(jump.type, GOKZ_GetCoreOption(jump.jumper, Option_Mode), jump.distance); + if (tier == DistanceTier_None) + { + return; + } + + // Report the failstat to the client and their spectators + DoFailstatReport(jump.jumper, jump, tier); + + for (int client = 1; client <= MaxClients; client++) + { + if (IsValidClient(client) && GetObserverTarget(client) == jump.jumper) + { + DoFailstatReport(client, jump, tier); + } + } +} + +void OnJumpstatAlways_JumpstatAlwaysReporting(Jump jump) +{ + DoJumpstatAlwaysReport(jump.jumper, jump); + + for (int client = 1; client <= MaxClients; client++) + { + if (IsValidClient(client) && GetObserverTarget(client) == jump.jumper) + { + DoJumpstatAlwaysReport(client, jump); + } + } +} + + +void OnFailstatAlways_FailstatAlwaysReporting(Jump jump) +{ + DoFailstatAlwaysReport(jump.jumper, jump); + + for (int client = 1; client <= MaxClients; client++) + { + if (IsValidClient(client) && GetObserverTarget(client) == jump.jumper) + { + DoFailstatAlwaysReport(client, jump); + } + } +} + + + +// =====[ PRIVATE ]===== + +static void DoJumpstatsReport(int client, Jump jump, int tier) +{ + if (GOKZ_JS_GetOption(client, JSOption_JumpstatsMaster) == JSToggleOption_Disabled) + { + return; + } + + DoChatReport(client, false, jump, tier); + DoConsoleReport(client, false, jump, tier, "Console Jump Header"); + PlayJumpstatSound(client, tier); +} + +static void DoFailstatReport(int client, Jump jump, int tier) +{ + if (GOKZ_JS_GetOption(client, JSOption_JumpstatsMaster) == JSToggleOption_Disabled) + { + return; + } + + DoChatReport(client, true, jump, tier); + DoConsoleReport(client, true, jump, tier, "Console Failstat Header"); +} + +static void DoJumpstatAlwaysReport(int client, Jump jump) +{ + if (GOKZ_JS_GetOption(client, JSOption_JumpstatsMaster) == JSToggleOption_Disabled || + GOKZ_JS_GetOption(client, JSOption_JumpstatsAlways) == JSToggleOption_Disabled) + { + return; + } + + DoChatReport(client, false, jump, 1); + DoConsoleReport(client, false, jump, 1, "Console Jump Header"); +} + +static void DoFailstatAlwaysReport(int client, Jump jump) +{ + if (GOKZ_JS_GetOption(client, JSOption_JumpstatsMaster) == JSToggleOption_Disabled || + GOKZ_JS_GetOption(client, JSOption_JumpstatsAlways) == JSToggleOption_Disabled) + { + return; + } + + DoChatReport(client, true, jump, 1); + DoConsoleReport(client, true, jump, 1, "Console Failstat Header"); +} + + + + +// CONSOLE REPORT + +static void DoConsoleReport(int client, bool isFailstat, Jump jump, int tier, char[] header) +{ + int minConsoleTier = GOKZ_JS_GetOption(client, JSOption_MinConsoleTier); + if ((minConsoleTier == 0 || minConsoleTier > tier) && GOKZ_JS_GetOption(client, JSOption_JumpstatsAlways) == JSToggleOption_Disabled + || isFailstat && GOKZ_JS_GetOption(client, JSOption_FailstatsConsole) == JSToggleOption_Disabled) + { + return; + } + + char releaseWString[32], blockString[32], edgeString[32], deviationString[32], missString[32]; + + if (jump.originalType == JumpType_LongJump || + jump.originalType == JumpType_LadderJump || + jump.originalType == JumpType_WeirdJump || + jump.originalType == JumpType_LowpreWeirdJump) + { + FormatEx(releaseWString, sizeof(releaseWString), " %s", GetIntConsoleString(client, "W Release", jump.releaseW)); + } + else if (jump.crouchRelease < 20 && jump.crouchRelease > -20) + { + FormatEx(releaseWString, sizeof(releaseWString), " %s", GetIntConsoleString(client, "Crouch Release", jump.crouchRelease)); + } + + if (jump.miss > 0.0) + { + FormatEx(missString, sizeof(missString), " %s", GetFloatConsoleString2(client, "Miss", jump.miss)); + } + + if (jump.block > 0) + { + FormatEx(blockString, sizeof(blockString), " %s", GetIntConsoleString(client, "Block", jump.block)); + FormatEx(deviationString, sizeof(deviationString), " %s", GetFloatConsoleString1(client, "Deviation", jump.deviation)); + } + + if (jump.edge > 0.0 || (jump.block > 0 && jump.edge == 0.0)) + { + FormatEx(edgeString, sizeof(edgeString), " %s", GetFloatConsoleString2(client, "Edge", jump.edge)); + } + + PrintToConsole(client, "%t", header, jump.jumper, jump.distance, gC_JumpTypes[jump.originalType]); + + PrintToConsole(client, "%s%s%s%s %s %s %s %s%s %s %s%s %s %s %s %s %s", + gC_ModeNamesShort[GOKZ_GetCoreOption(jump.jumper, Option_Mode)], + blockString, + edgeString, + missString, + GetIntConsoleString(client, jump.strafes == 1 ? "Strafe" : "Strafes", jump.strafes), + GetSyncConsoleString(client, jump.sync), + GetFloatConsoleString2(client, "Pre", jump.preSpeed), + GetFloatConsoleString2(client, "Max", jump.maxSpeed), + releaseWString, + GetIntConsoleString(client, "Overlap", jump.overlap), + GetIntConsoleString(client, "Dead Air", jump.deadair), + deviationString, + GetWidthConsoleString(client, jump.width, jump.strafes), + GetFloatConsoleString1(client, "Height", jump.height), + GetIntConsoleString(client, "Airtime", jump.duration), + GetFloatConsoleString1(client, "Offset", jump.offset), + GetIntConsoleString(client, "Crouch Ticks", jump.crouchTicks)); + + PrintToConsole(client, " #. %12t%12t%12t%12t%12t%9t%t", "Sync (Table)", "Gain (Table)", "Loss (Table)", "Airtime (Table)", "Width (Table)", "Overlap (Table)", "Dead Air (Table)"); + if (jump.strafes_ticks[0] > 0) + { + PrintToConsole(client, " 0. ---- ----- ----- %3.0f%% ----- -- --", GetStrafeAirtime(jump, 0)); + } + for (int strafe = 1; strafe <= jump.strafes && strafe < JS_MAX_TRACKED_STRAFES; strafe++) + { + PrintToConsole(client, + " %2d. %3.0f%% %5.2f %5.2f %3.0f%% %5.1f° %2d %2d", + strafe, + GetStrafeSync(jump, strafe), + jump.strafes_gain[strafe], + jump.strafes_loss[strafe], + GetStrafeAirtime(jump, strafe), + FloatAbs(jump.strafes_width[strafe]), + jump.strafes_overlap[strafe], + jump.strafes_deadair[strafe]); + } + PrintToConsole(client, ""); // New line +} + +static char[] GetSyncConsoleString(int client, float sync) +{ + char resultString[32]; + FormatEx(resultString, sizeof(resultString), "| %.0f%% %T", sync, "Sync", client); + return resultString; +} + +static char[] GetWidthConsoleString(int client, float width, int strafes) +{ + char resultString[32]; + FormatEx(resultString, sizeof(resultString), "| %.1f° %T", GetAverageStrafeWidth(strafes, width), "Width", client); + return resultString; +} + +// I couldn't really merge those together +static char[] GetFloatConsoleString1(int client, const char[] stat, float value) +{ + char resultString[32]; + FormatEx(resultString, sizeof(resultString), "| %.1f %T", value, stat, client); + return resultString; +} + +static char[] GetFloatConsoleString2(int client, const char[] stat, float value) +{ + char resultString[32]; + FormatEx(resultString, sizeof(resultString), "| %.2f %T", value, stat, client); + return resultString; +} + +static char[] GetIntConsoleString(int client, const char[] stat, int value) +{ + char resultString[32]; + FormatEx(resultString, sizeof(resultString), "| %d %T", value, stat, client); + return resultString; +} + + + +// CHAT REPORT + +static void DoChatReport(int client, bool isFailstat, Jump jump, int tier) +{ + int minChatTier = GOKZ_JS_GetOption(client, JSOption_MinChatTier); + if ((minChatTier == 0 || minChatTier > tier) // 0 means disabled + && GOKZ_JS_GetOption(client, JSOption_JumpstatsAlways) == JSToggleOption_Disabled) + { + return; + } + + char typePostfix[3], color[16], blockStats[32], extBlockStats[32]; + char releaseStats[32], edgeOffset[64], offsetEdge[32], missString[32]; + + if (isFailstat) + { + if (GOKZ_JS_GetOption(client, JSOption_FailstatsChat) == JSToggleOption_Disabled + && GOKZ_JS_GetOption(client, JSOption_JumpstatsAlways) == JSToggleOption_Disabled) + { + return; + } + strcopy(typePostfix, sizeof(typePostfix), "-F"); + strcopy(color, sizeof(color), "{grey}"); + } + else + { + strcopy(color, sizeof(color), gC_DistanceTierChatColours[tier]); + } + + if (jump.block > 0) + { + FormatEx(blockStats, sizeof(blockStats), " | %s", GetFloatChatString(client, "Edge", jump.edge)); + FormatEx(extBlockStats, sizeof(extBlockStats), " | %s", GetFloatChatString(client, "Deviation", jump.deviation)); + } + + if (jump.miss > 0.0) + { + FormatEx(missString, sizeof(missString), " | %s", GetFloatChatString(client, "Miss", jump.miss)); + } + + if (jump.edge > 0.0 || (jump.block > 0 && jump.edge == 0.0)) + { + if (jump.originalType == JumpType_LadderJump) + { + FormatEx(offsetEdge, sizeof(offsetEdge), " | %s", GetFloatChatString(client, "Edge", jump.edge)); + } + else + { + FormatEx(edgeOffset, sizeof(edgeOffset), " | %s", GetFloatChatString(client, "Edge", jump.edge)); + } + } + + if (jump.originalType == JumpType_LongJump || + jump.originalType == JumpType_LadderJump || + jump.originalType == JumpType_WeirdJump) + { + if (jump.releaseW >= 20 || jump.releaseW <= -20) + { + FormatEx(releaseStats, sizeof(releaseStats), " | {red}✗ {grey}W", GetReleaseChatString(client, "W Release", jump.releaseW)); + } + else + { + FormatEx(releaseStats, sizeof(releaseStats), " | %s", GetReleaseChatString(client, "W Release", jump.releaseW)); + } + } + else if (jump.crouchRelease < 20 && jump.crouchRelease > -20) + { + FormatEx(releaseStats, sizeof(releaseStats), " | %s", GetReleaseChatString(client, "Crouch Release", jump.crouchRelease)); + } + + if (jump.originalType == JumpType_LadderJump) + { + FormatEx(edgeOffset, sizeof(edgeOffset), " | %s", GetFloatChatString(client, "Offset Short", jump.offset)); + } + else + { + FormatEx(offsetEdge, sizeof(offsetEdge), " | %s", GetFloatChatString(client, "Offset", jump.offset)); + } + + GOKZ_PrintToChat(client, true, + "%s%s%s{grey}: %s%.1f{grey} | %s | %s%s%s", + color, + gC_JumpTypesShort[jump.originalType], + typePostfix, + color, + jump.distance, + GetStrafesSyncChatString(client, jump.strafes, jump.sync), + GetSpeedChatString(client, jump.preSpeed, jump.maxSpeed), + edgeOffset, + releaseStats); + + if (GOKZ_JS_GetOption(client, JSOption_ExtendedChatReport) == JSToggleOption_Enabled) + { + GOKZ_PrintToChat(client, false, + "%s | %s%s%s | %s | %s%s", + GetIntChatString(client, "Overlap", jump.overlap), + GetIntChatString(client, "Dead Air", jump.deadair), + offsetEdge, + extBlockStats, + GetWidthChatString(client, jump.width, jump.strafes), + GetFloatChatString(client, "Height", jump.height), + missString); + } +} + +static char[] GetStrafesSyncChatString(int client, int strafes, float sync) +{ + char resultString[64]; + FormatEx(resultString, sizeof(resultString), + "{lime}%d{grey} %T ({lime}%.0f%%%%{grey})", + strafes, "Strafes", client, sync); + return resultString; +} + +static char[] GetSpeedChatString(int client, float preSpeed, float maxSpeed) +{ + char resultString[64]; + FormatEx(resultString, sizeof(resultString), + "{lime}%.0f{grey} / {lime}%.0f{grey} %T", + preSpeed, maxSpeed, "Speed", client); + return resultString; +} + +static char[] GetReleaseChatString(int client, char[] releaseType, int release) +{ + char resultString[32]; + if (release == 0) + { + FormatEx(resultString, sizeof(resultString), + "{green}✓{grey} %T", + releaseType, client); + } + else if (release > 0) + { + FormatEx(resultString, sizeof(resultString), + "{red}+%d{grey} %T", + release, + releaseType, client); + } + else + { + FormatEx(resultString, sizeof(resultString), + "{blue}%d{grey} %T", + release, + releaseType, client); + } + return resultString; +} + +static char[] GetWidthChatString(int client, float width, int strafes) +{ + char resultString[32]; + FormatEx(resultString, sizeof(resultString), + "{lime}%.1f°{grey} %T", + GetAverageStrafeWidth(strafes, width), "Width", client); + return resultString; +} + +static float GetAverageStrafeWidth(int strafes, float totalWidth) +{ + if (strafes == 0) + { + return 0.0; + } + + return totalWidth / strafes; +} + +static char[] GetFloatChatString(int client, const char[] stat, float value) +{ + char resultString[32]; + FormatEx(resultString, sizeof(resultString), + "{lime}%.1f{grey} %T", + value, stat, client); + return resultString; +} + +static char[] GetIntChatString(int client, const char[] stat, int value) +{ + char resultString[32]; + FormatEx(resultString, sizeof(resultString), + "{lime}%d{grey} %T", + value, stat, client); + return resultString; +} + + + +// SOUNDS + +static bool LoadSounds() +{ + KeyValues kv = new KeyValues("sounds"); + if (!kv.ImportFromFile(JS_CFG_SOUNDS)) + { + return false; + } + + char downloadPath[256]; + for (int tier = DistanceTier_Impressive; tier < DISTANCETIER_COUNT; tier++) + { + kv.GetString(gC_DistanceTierKeys[tier], sounds[tier], sizeof(sounds[])); + FormatEx(downloadPath, sizeof(downloadPath), "sound/%s", sounds[tier]); + AddFileToDownloadsTable(downloadPath); + PrecacheSound(sounds[tier], true); + } + + delete kv; + return true; +} diff --git a/sourcemod/scripting/gokz-jumpstats/jump_tracking.sp b/sourcemod/scripting/gokz-jumpstats/jump_tracking.sp new file mode 100644 index 0000000..acd9442 --- /dev/null +++ b/sourcemod/scripting/gokz-jumpstats/jump_tracking.sp @@ -0,0 +1,1624 @@ +/* + Tracking of jump type, speed, strafes and more. +*/ + + + +// =====[ STRUCTS ]============================================================ + +enum struct Pose +{ + float position[3]; + float orientation[3]; + float velocity[3]; + float speed; + int duration; + int overlap; + int deadair; + int syncTicks; +} + + + +// =====[ GLOBAL VARIABLES ]=================================================== + +static ArrayList entityTouchList[MAXPLAYERS + 1]; +static int entityTouchDuration[MAXPLAYERS + 1]; +static int lastNoclipTime[MAXPLAYERS + 1]; +static int lastDuckbugTime[MAXPLAYERS + 1]; +static int lastGroundSpeedCappedTime[MAXPLAYERS + 1]; +static int lastMovementProcessedTime[MAXPLAYERS + 1]; +static float lastJumpButtonTime[MAXPLAYERS + 1]; +static bool validCmd[MAXPLAYERS + 1]; // Whether no illegal action is detected +static const float playerMins[3] = { -16.0, -16.0, 0.0 }; +static const float playerMaxs[3] = { 16.0, 16.0, 0.0 }; +static const float playerMinsEx[3] = { -20.0, -20.0, 0.0 }; +static const float playerMaxsEx[3] = { 20.0, 20.0, 0.0 }; +static bool doFailstatAlways[MAXPLAYERS + 1]; +static bool isInAir[MAXPLAYERS + 1]; +static const Jump emptyJump; +static Handle acceptInputHook; + + +// =====[ DEFINITIONS ]======================================================== + +// We cannot return enum structs and it's annoying +// The modulo operator is broken, so we can't access this using negative numbers +// (https://github.com/alliedmodders/sourcepawn/issues/456). We use the method +// described here instead: https://stackoverflow.com/a/42131603/7421666 +#define pose(%1) (poseHistory[this.jumper][((this.poseIndex + (%1)) % JS_FAILSTATS_MAX_TRACKED_TICKS + JS_FAILSTATS_MAX_TRACKED_TICKS) % JS_FAILSTATS_MAX_TRACKED_TICKS]) + + + +// =====[ TRACKING ]=========================================================== + +// We cannot put that into the tracker struct +Pose poseHistory[MAXPLAYERS + 1][JS_FAILSTATS_MAX_TRACKED_TICKS]; + +enum struct JumpTracker +{ + Jump jump; + int jumper; + int jumpoffTick; + int poseIndex; + int strafeDirection; + int lastJumpTick; + int lastTeleportTick; + int lastType; + int lastWPressedTick; + int nextCrouchRelease; + int syncTicks; + int lastCrouchPressedTick; + int tickCount; + bool failstatBlockDetected; + bool failstatFailed; + bool failstatValid; + float failstatBlockHeight; + float takeoffOrigin[3]; + float takeoffVelocity[3]; + float position[3]; + + void Init(int jumper) + { + this.jumper = jumper; + this.jump.jumper = jumper; + this.nextCrouchRelease = 100; + this.tickCount = 0; + } + + + + // =====[ ENTRYPOINTS ]======================================================= + + void Reset(bool jumped, bool ladderJump, bool jumpbug) + { + // We need to do that before we reset the jump cause we need the + // offset and type of the previous jump + this.lastType = this.DetermineType(jumped, ladderJump, jumpbug); + + // We need this for weirdjump w-release + int releaseWTemp = this.jump.releaseW; + + // Reset all stats + this.jump = emptyJump; + this.jump.type = this.lastType; + this.jump.jumper = this.jumper; + this.syncTicks = 0; + this.strafeDirection = StrafeDirection_None; + this.jump.releaseW = 100; + + // We have to show this on the jumpbug stat, not the lj stat + this.jump.crouchRelease = this.nextCrouchRelease; + this.nextCrouchRelease = 100; + + // Handle weirdjump w-release + if (this.jump.type == JumpType_WeirdJump) + { + this.jump.releaseW = releaseWTemp; + } + + // Reset pose history + this.poseIndex = 0; + // Update the first tick if it is a jumpbug. + this.UpdateOnGround(); + } + + void Begin() + { + // Initialize stats + this.CalcTakeoff(); + this.AdjustLowpreJumptypes(); + + this.failstatBlockDetected = this.jump.type != JumpType_LadderJump; + this.failstatFailed = false; + this.failstatValid = false; + this.failstatBlockHeight = this.takeoffOrigin[2]; + + // Store the original type for the always stats + this.jump.originalType = this.jump.type; + + // Notify everyone about the takeoff + Call_OnTakeoff(this.jumper, this.jump.type); + } + + void Update() + { + this.UpdatePoseHistory(); + + float speed = pose(0).speed; + + // Fix certain props that don't give you base velocity + /* + We check for speed reduction for abuse; while prop abuses increase speed, + wall collision will very likely (if not always) result in a speed reduction. + */ + float actualSpeed = GetVectorHorizontalDistance(this.position, pose(-1).position) / GetTickInterval(); + if (FloatAbs(speed - actualSpeed) > JS_SPEED_MODIFICATION_TOLERANCE && this.jump.duration != 0) + { + if (actualSpeed <= pose(-1).speed) + { + pose(0).speed = actualSpeed; + } + // This check is needed if you land via ducking instead of moving (duckbug) + else if (FloatAbs(actualSpeed) > EPSILON) + { + this.Invalidate(); + } + } + // You shouldn't gain any vertical velocity during a jump. + // This would only happen if you get boosted back up somehow, or you edgebugged. + if (!Movement_GetOnGround(this.jumper) && pose(0).velocity[2] > pose(-1).velocity[2]) + { + this.Invalidate(); + } + + this.jump.height = FloatMax(this.jump.height, this.position[2] - this.takeoffOrigin[2]); + this.jump.maxSpeed = FloatMax(this.jump.maxSpeed, speed); + this.jump.crouchTicks += Movement_GetDucking(this.jumper) ? 1 : 0; + this.syncTicks += speed > pose(-1).speed ? 1 : 0; + this.jump.duration++; + + this.UpdateStrafes(); + this.UpdateFailstat(); + this.UpdatePoseStats(); + + this.lastType = this.jump.type; + } + + void End() + { + // The jump is so invalid we don't even have to bother. + // Also check if the player just teleported. + if (this.jump.type == JumpType_FullInvalid || + this.tickCount - this.lastTeleportTick < JS_MIN_TELEPORT_DELAY) + { + return; + } + + // Measure last tick of jumpstat + this.Update(); + + // Fix the edgebug for the current position + Movement_GetNobugLandingOrigin(this.jumper, this.position); + + // There are a couple bugs and exploits we have to check for + this.EndBugfixExploits(); + + // Calculate the last stats + this.jump.distance = this.CalcDistance(); + this.jump.sync = float(this.syncTicks) / float(this.jump.duration) * 100.0; + this.jump.offset = this.position[2] - this.takeoffOrigin[2]; + + this.EndBlockDistance(); + + // Make sure the ladder has no offset for ladder jumps + if (this.jump.type == JumpType_LadderJump) + { + this.TraceLadderOffset(this.position[2]); + } + + // Calculate always-on stats + if (GOKZ_JS_GetOption(this.jumper, JSOption_JumpstatsAlways) == JSToggleOption_Enabled) + { + this.EndAlwaysJumpstats(); + } + + // Call the appropriate functions for either regular or always stats + this.Callback(); + } + + void Invalidate() + { + if (this.jump.type != JumpType_Invalid && + this.jump.type != JumpType_FullInvalid) + { + this.jump.type = JumpType_Invalid; + Call_OnJumpInvalidated(this.jumper); + } + } + + + + // =====[ BEGIN HELPERS ]===================================================== + + void CalcTakeoff() + { + // MovementAPI now correctly calculates the takeoff origin + // and velocity for jumpbugs. What is wrong though, is how + // mode plugins set bhop prespeed. + // Jumpbug takeoff origin is correct. + Movement_GetTakeoffOrigin(this.jumper, this.takeoffOrigin); + Movement_GetTakeoffVelocity(this.jumper, this.takeoffVelocity); + if (this.jump.type == JumpType_Jumpbug || this.jump.type == JumpType_MultiBhop + || this.jump.type == JumpType_Bhop || this.jump.type == JumpType_LowpreBhop + || this.jump.type == JumpType_LowpreWeirdJump || this.jump.type == JumpType_WeirdJump) + { + // Move the origin to the ground. + // The difference can only be 2 units maximum. + float bhopOrigin[3]; + CopyVector(this.takeoffOrigin, bhopOrigin); + bhopOrigin[2] -= 2.0; + TraceHullPosition(this.takeoffOrigin, bhopOrigin, playerMins, playerMaxs, this.takeoffOrigin); + } + + this.jump.preSpeed = Movement_GetTakeoffSpeed(this.jumper); + poseHistory[this.jumper][0].speed = this.jump.preSpeed; + } + + void AdjustLowpreJumptypes() + { + // Exclude SKZ and VNL stats. + if (GOKZ_GetCoreOption(this.jumper, Option_Mode) == Mode_KZTimer) + { + if (this.jump.type == JumpType_Bhop && + this.jump.preSpeed < 360.0) + { + this.jump.type = JumpType_LowpreBhop; + } + else if (this.jump.type == JumpType_WeirdJump && + this.jump.preSpeed < 300.0) + { + this.jump.type = JumpType_LowpreWeirdJump; + } + } + } + + int DetermineType(bool jumped, bool ladderJump, bool jumpbug) + { + if (gB_SpeedJustModifiedExternally[this.jumper] || this.tickCount - this.lastTeleportTick < JS_MIN_TELEPORT_DELAY) + { + return JumpType_Invalid; + } + else if (ladderJump) + { + // Check for ladder gliding. + float curtime = GetGameTime(); + float ignoreLadderJumpTime = GetEntPropFloat(this.jumper, Prop_Data, "m_ignoreLadderJumpTime"); + // Check if the ladder glide period is still active and if the player held jump in that period. + if (ignoreLadderJumpTime > curtime && + ignoreLadderJumpTime - IGNORE_JUMP_TIME < lastJumpButtonTime[this.jumper] && lastJumpButtonTime[this.jumper] < ignoreLadderJumpTime) + { + return JumpType_Invalid; + } + if (jumped) + { + return JumpType_Ladderhop; + } + else + { + return JumpType_LadderJump; + } + } + else if (!jumped) + { + return JumpType_Fall; + } + else if (jumpbug) + { + // Check for no offset + // The origin and offset is now correct, no workaround needed + if (FloatAbs(this.jump.offset) < JS_OFFSET_EPSILON && this.lastType == JumpType_LongJump) + { + return JumpType_Jumpbug; + } + else + { + return JumpType_Invalid; + } + } + else if (this.HitBhop() && !this.HitDuckbugRecently()) + { + // Check for no offset + if (FloatAbs(this.jump.offset) < JS_OFFSET_EPSILON) + { + switch (this.lastType) + { + case JumpType_LongJump:return JumpType_Bhop; + case JumpType_Bhop:return JumpType_MultiBhop; + case JumpType_LowpreBhop:return JumpType_MultiBhop; + case JumpType_MultiBhop:return JumpType_MultiBhop; + default:return JumpType_Other; + } + } + // Check for weird jump + else if (this.lastType == JumpType_Fall && + this.ValidWeirdJumpDropDistance()) + { + return JumpType_WeirdJump; + } + else + { + return JumpType_Other; + } + } + if (this.HitDuckbugRecently() || !this.GroundSpeedCappedRecently()) + { + return JumpType_Invalid; + } + return JumpType_LongJump; + } + + bool HitBhop() + { + return Movement_GetTakeoffCmdNum(this.jumper) - Movement_GetLandingCmdNum(this.jumper) <= JS_MAX_BHOP_GROUND_TICKS; + } + + bool ValidWeirdJumpDropDistance() + { + if (this.jump.offset < -1 * JS_MAX_WEIRDJUMP_FALL_OFFSET) + { + // Don't bother telling them if they fell a very far distance + if (!GetJumpstatsDisabled(this.jumper) && this.jump.offset >= -2 * JS_MAX_WEIRDJUMP_FALL_OFFSET) + { + GOKZ_PrintToChat(this.jumper, true, "%t", "Dropped Too Far (Weird Jump)", -1 * this.jump.offset, JS_MAX_WEIRDJUMP_FALL_OFFSET); + } + return false; + } + return true; + } + + bool HitDuckbugRecently() + { + return this.tickCount - lastDuckbugTime[this.jumper] <= JS_MAX_DUCKBUG_RESET_TICKS; + } + + bool GroundSpeedCappedRecently() + { + // A valid longjump needs to have their ground speed capped the tick right before. + return lastGroundSpeedCappedTime[this.jumper] == lastMovementProcessedTime[this.jumper]; + } + + // =====[ UPDATE HELPERS ]==================================================== + + // We split that up in two functions to get a reference to the pose so we + // don't have to recalculate the pose index all the time. + void UpdatePoseHistory() + { + this.poseIndex++; + this.UpdatePose(pose(0)); + } + + void UpdatePose(Pose p) + { + Movement_GetProcessingOrigin(this.jumper, p.position); + Movement_GetProcessingVelocity(this.jumper, p.velocity); + Movement_GetEyeAngles(this.jumper, p.orientation); + p.speed = GetVectorHorizontalLength(p.velocity); + + // We use the current position in a lot of places, so we store it + // separately to avoid calling 'pose' all the time. + CopyVector(p.position, this.position); + } + + // We split that up in two functions to get a reference to the pose so we + // don't have to recalculate the pose index all the time. We seperate that + // from UpdatePose() cause those stats are not calculated yet when we call that. + void UpdatePoseStats() + { + this.UpdatePoseStats_P(pose(0)); + } + + void UpdatePoseStats_P(Pose p) + { + p.duration = this.jump.duration; + p.syncTicks = this.syncTicks; + p.overlap = this.jump.overlap; + p.deadair = this.jump.deadair; + } + + void UpdateOnGround() + { + // We want accurate values to measure the first tick + this.UpdatePose(poseHistory[this.jumper][0]); + } + + void UpdateRelease() + { + // Using UpdateOnGround doesn't work because + // takeoff tick is calculated after leaving the ground. + this.jumpoffTick = Movement_GetTakeoffTick(this.jumper); + + // We also check IN_BACK cause that happens for backwards ladderjumps + if (Movement_GetButtons(this.jumper) & IN_FORWARD || + Movement_GetButtons(this.jumper) & IN_BACK) + { + this.lastWPressedTick = this.tickCount; + } + else if (this.jump.releaseW > 99) + { + this.jump.releaseW = this.lastWPressedTick - this.jumpoffTick + 1; + } + + if (Movement_GetButtons(this.jumper) & IN_DUCK) + { + this.lastCrouchPressedTick = this.tickCount; + this.nextCrouchRelease = 100; + } + else if (this.nextCrouchRelease > 99) + { + this.nextCrouchRelease = this.lastCrouchPressedTick - this.jumpoffTick - 95; + } + } + + void UpdateStrafes() + { + // Strafe direction + if (Movement_GetTurningLeft(this.jumper) && + this.strafeDirection != StrafeDirection_Left) + { + this.strafeDirection = StrafeDirection_Left; + this.jump.strafes++; + } + else if (Movement_GetTurningRight(this.jumper) && + this.strafeDirection != StrafeDirection_Right) + { + this.strafeDirection = StrafeDirection_Right; + this.jump.strafes++; + } + + // Overlap / Deadair + int buttons = Movement_GetButtons(this.jumper); + int overlap = buttons & IN_MOVERIGHT && buttons & IN_MOVELEFT ? 1 : 0; + int deadair = !(buttons & IN_MOVERIGHT) && !(buttons & IN_MOVELEFT) ? 1 : 0; + + // Sync / Gain / Loss + float deltaSpeed = pose(0).speed - pose(-1).speed; + bool gained = deltaSpeed > EPSILON; + bool lost = deltaSpeed < -EPSILON; + + // Width + float width = FloatAbs(CalcDeltaAngle(pose(0).orientation[1], pose(-1).orientation[1])); + + // Overall stats + this.jump.overlap += overlap; + this.jump.deadair += deadair; + this.jump.width += width; + + // Individual stats + if (this.jump.strafes >= JS_MAX_TRACKED_STRAFES) + { + return; + } + + int i = this.jump.strafes; + this.jump.strafes_ticks[i]++; + + this.jump.strafes_overlap[i] += overlap; + this.jump.strafes_deadair[i] += deadair; + this.jump.strafes_loss[i] += lost ? -1 * deltaSpeed : 0.0; + this.jump.strafes_width[i] += width; + + if (gained) + { + this.jump.strafes_gainTicks[i]++; + this.jump.strafes_gain[i] += deltaSpeed; + } + } + + void UpdateFailstat() + { + int coordDist, distSign; + float failstatPosition[3], block[3], traceStart[3]; + + // There's no point in going further if we're already done + if (this.failstatValid || this.failstatFailed) + { + return; + } + + // Get the coordinate system orientation. + GetCoordOrientation(this.position, this.takeoffOrigin, coordDist, distSign); + + // For ladderjumps we have to find the landing block early so we know at which point the jump failed. + // For this, we search for the block 10 units above the takeoff origin, assuming the player already + // traveled a significant enough distance in the direction of the block at this time. + if (!this.failstatBlockDetected && + this.position[2] - this.takeoffOrigin[2] < 10.0 && + this.jump.height > 10.0) + { + this.failstatBlockDetected = true; + + // Setup a trace to search for the block + CopyVector(this.takeoffOrigin, traceStart); + traceStart[2] -= 5.0; + CopyVector(traceStart, block); + traceStart[coordDist] += JS_MIN_LAJ_BLOCK_DISTANCE * distSign; + block[coordDist] += JS_MAX_LAJ_FAILSTAT_DISTANCE * distSign; + + // Search for the block + if (!TraceHullPosition(traceStart, block, playerMins, playerMaxs, block)) + { + // Mark the calculation as failed + this.failstatFailed = true; + return; + } + + // Find the block height + block[2] += 5.0; + this.failstatBlockHeight = this.FindBlockHeight(block, float(distSign) * 17.0, coordDist, 10.0) - 0.031250; + } + + // Only do the calculation once we're below the block level + if (this.position[2] >= this.failstatBlockHeight) + { + // We need that cause we can duck after getting lower than the failstat + // height and still make the block. + this.failstatValid = false; + return; + } + + // Calculate the true origin where the player would have hit the ground. + this.GetFailOrigin(this.failstatBlockHeight, failstatPosition, -1); + + // Calculate the jump distance. + this.jump.distance = FloatAbs(GetVectorHorizontalDistance(failstatPosition, this.takeoffOrigin)); + + // Construct the maximum landing origin, assuming the player reached + // at least the middle of the gap. + CopyVector(this.takeoffOrigin, block); + block[coordDist] = 2 * failstatPosition[coordDist] - this.takeoffOrigin[coordDist]; + block[view_as<int>(!coordDist)] = failstatPosition[view_as<int>(!coordDist)]; + block[2] = this.failstatBlockHeight; + + // Calculate block stats + if ((this.lastType == JumpType_LongJump || + this.lastType == JumpType_Bhop || + this.lastType == JumpType_MultiBhop || + this.lastType == JumpType_Ladderhop || + this.lastType == JumpType_WeirdJump || + this.lastType == JumpType_Jumpbug || + this.lastType == JumpType_LowpreBhop || + this.lastType == JumpType_LowpreWeirdJump) + && this.jump.distance >= JS_MIN_BLOCK_DISTANCE) + { + // Add the player model to the distance. + this.jump.distance += 32.0; + + this.CalcBlockStats(block, true); + } + else if (this.lastType == JumpType_LadderJump && + this.jump.distance >= JS_MIN_LAJ_BLOCK_DISTANCE) + { + this.CalcLadderBlockStats(block, true); + } + else + { + this.failstatFailed = true; + return; + } + + if (this.jump.block > 0) + { + // Calculate the last stats + this.jump.sync = float(this.syncTicks) / float(this.jump.duration) * 100.0; + this.jump.offset = failstatPosition[2] - this.takeoffOrigin[2]; + + // Call the callback for the reporting. + Call_OnFailstat(this.jump); + + // Mark the calculation as successful + this.failstatValid = true; + } + else + { + this.failstatFailed = true; + } + } + + + + // =====[ END HELPERS ]===================================================== + + float CalcDistance() + { + float distance = GetVectorHorizontalDistance(this.takeoffOrigin, this.position); + + // Check whether the distance is NaN + if (distance != distance) + { + this.Invalidate(); + + // We need that for the always stats + float pos[3]; + + // For the always stats it's ok to ignore the bug + Movement_GetOrigin(this.jumper, pos); + + distance = GetVectorHorizontalDistance(this.takeoffOrigin, pos); + } + + if (this.jump.originalType != JumpType_LadderJump) + { + distance += 32.0; + } + return distance; + } + + void EndBlockDistance() + { + if ((this.jump.type == JumpType_LongJump || + this.jump.type == JumpType_Bhop || + this.jump.type == JumpType_MultiBhop || + this.jump.type == JumpType_Ladderhop || + this.jump.type == JumpType_WeirdJump || + this.jump.type == JumpType_Jumpbug || + this.jump.type == JumpType_LowpreBhop || + this.jump.type == JumpType_LowpreWeirdJump) + && this.jump.distance >= JS_MIN_BLOCK_DISTANCE) + { + this.CalcBlockStats(this.position); + } + else if (this.jump.type == JumpType_LadderJump && + this.jump.distance >= JS_MIN_LAJ_BLOCK_DISTANCE) + { + this.CalcLadderBlockStats(this.position); + } + } + + void EndAlwaysJumpstats() + { + // Only calculate that form of edge if the regular block calculations failed + if (this.jump.block == 0 && this.jump.type != JumpType_LadderJump) + { + this.CalcAlwaysEdge(); + } + + // It's possible that the offset calculation failed with the nobug origin + // functions, so we have to fix it when that happens. The offset shouldn't + // be affected by the bug anyway. + if (this.jump.offset != this.jump.offset) + { + Movement_GetOrigin(this.jumper, this.position); + this.jump.offset = this.position[2] - this.takeoffOrigin[2]; + } + } + + void EndBugfixExploits() + { + // Try to prevent a form of booster abuse + if (!this.IsValidAirtime()) + { + this.Invalidate(); + } + } + + bool IsValidAirtime() + { + // Ladderjumps can have pretty much any airtime. + if (this.jump.type == JumpType_LadderJump) + { + return true; + } + + // Ladderhops can have a maximum airtime of 102. + if (this.jump.type == JumpType_Ladderhop + && this.jump.duration <= 102) + { + return true; + } + + // Crouchjumped or perfed longjumps/bhops can have a maximum of 101 airtime + // when the lj bug occurs. Since we've fixed that the airtime is valid. + if (this.jump.duration <= 101) + { + return true; + } + + return false; + } + + void Callback() + { + if (GOKZ_JS_GetOption(this.jumper, JSOption_JumpstatsAlways) == JSToggleOption_Enabled) + { + Call_OnJumpstatAlways(this.jump); + } + else + { + Call_OnLanding(this.jump); + } + } + + + + // =====[ ALWAYS FAILSTATS ]================================================== + + void AlwaysFailstat() + { + bool foundBlock; + int coordDist, distSign; + float traceStart[3], traceEnd[3], tracePos[3], landingPos[3], orientation[3], failOrigin[3]; + + // Check whether the jump was already handled + if (this.jump.type == JumpType_FullInvalid || this.failstatValid) + { + return; + } + + // Initialize the trace boxes + float traceMins[3] = { 0.0, 0.0, 0.0 }; + float traceLongMaxs[3] = { 0.0, 0.0, 200.0 }; + float traceShortMaxs[3] = { 0.0, 0.0, 54.0 }; + + // Clear the stats + this.jump.miss = 0.0; + this.jump.distance = 0.0; + + // Calculate the edge + this.CalcAlwaysEdge(); + + // We will search for the block based on the direction the player was looking + CopyVector(pose(0).orientation, orientation); + + // Get the landing orientation + coordDist = FloatAbs(orientation[0]) < FloatAbs(orientation[1]); + distSign = orientation[coordDist] > 0 ? 1 : -1; + + // Initialize the traces + CopyVector(this.position, traceStart); + CopyVector(this.position, traceEnd); + + // Assume the miss is less than 100 units + traceEnd[coordDist] += 100.0 * distSign; + + // Search for the end block with the long trace + foundBlock = TraceHullPosition(traceStart, traceEnd, traceMins, traceLongMaxs, tracePos); + + // If not even the long trace finds the block, we're out of luck + if (foundBlock) + { + // Search for the block height + tracePos[2] = this.position[2]; + foundBlock = this.TryFindBlockHeight(tracePos, landingPos, coordDist, distSign); + + // Maybe there was a headbanger, try with the short trace instead + if (!foundBlock) + { + if (TraceHullPosition(traceStart, traceEnd, traceMins, traceShortMaxs, tracePos)) + { + // Search for the height again + tracePos[2] = this.position[2]; + foundBlock = this.TryFindBlockHeight(tracePos, landingPos, coordDist, distSign); + } + } + + if (foundBlock) + { + // Search for the last tick the player was above the landing block elevation. + for (int i = 0; i < JS_FAILSTATS_MAX_TRACKED_TICKS; i++) + { + Pose p; + + // This copies it, but it shouldn't be that much of a problem + p = pose(-i); + + if(p.position[2] >= landingPos[2]) + { + // Calculate the correct fail position + this.GetFailOrigin(landingPos[2], failOrigin, -i); + + // Calculate all missing stats + this.jump.miss = FloatAbs(failOrigin[coordDist] - landingPos[coordDist]) - 16.0; + this.jump.distance = GetVectorHorizontalDistance(failOrigin, this.takeoffOrigin); + this.jump.offset = failOrigin[2] - this.takeoffOrigin[2]; + this.jump.duration = p.duration; + this.jump.overlap = p.overlap; + this.jump.deadair = p.deadair; + this.jump.sync = float(p.syncTicks) / float(this.jump.duration) * 100.0; + break; + } + } + } + } + + // Notify everyone about the jump + Call_OnFailstatAlways(this.jump); + + // Fully invalidate the jump cause we failstatted it already + this.jump.type = JumpType_FullInvalid; + } + + void CalcAlwaysEdge() + { + int coordDist, distSign; + float traceStart[3], traceEnd[3], velocity[3]; + float ladderNormal[3], ladderMins[3], ladderMaxs[3]; + + // Ladder jumps have a different definition of edge + if (this.jump.originalType == JumpType_LadderJump) + { + // Get a vector that points outwards from the lader towards the player + GetEntPropVector(this.jumper, Prop_Send, "m_vecLadderNormal", ladderNormal); + + // Initialize box to search for the ladder + if (ladderNormal[0] > ladderNormal[1]) + { + ladderMins = view_as<float>({ 0.0, -20.0, 0.0 }); + ladderMaxs = view_as<float>({ 0.0, 20.0, 0.0 }); + coordDist = 0; + } + else + { + ladderMins = view_as<float>({ -20.0, 0.0, 0.0 }); + ladderMaxs = view_as<float>({ 20.0, 0.0, 0.0 }); + coordDist = 1; + } + + // The max the ladder will be away is the player model (16) + danvari tech (10) + a safety unit + CopyVector(this.takeoffOrigin, traceEnd); + traceEnd[coordDist] += 27.0; + + // Search for the ladder + if (TraceHullPosition(this.takeoffOrigin, traceEnd, ladderMins, ladderMaxs, traceEnd)) + { + this.jump.edge = FloatAbs(traceEnd[coordDist] - this.takeoffOrigin[coordDist]) - 16.0; + } + } + else + { + // We calculate the orientation of the takeoff block based on what + // direction the player was moving + CopyVector(this.takeoffVelocity, velocity); + this.jump.edge = -1.0; + + // Calculate the takeoff orientation + coordDist = FloatAbs(velocity[0]) < FloatAbs(velocity[1]); + distSign = velocity[coordDist] > 0 ? 1 : -1; + + // Make sure we hit the jumpoff block + CopyVector(this.takeoffOrigin, traceEnd); + traceEnd[coordDist] -= 16.0 * distSign; + traceEnd[2] -= 1.0; + + // Assume a max edge of 20 + CopyVector(traceEnd, traceStart); + traceStart[coordDist] += 20.0 * distSign; + + // Trace the takeoff block + if (TraceRayPosition(traceStart, traceEnd, traceEnd)) + { + // Check whether the trace was stuck in the block from the beginning + if (FloatAbs(traceEnd[coordDist] - traceStart[coordDist]) > EPSILON) + { + // Block trace ends 0.03125 in front of the actual block. Adjust the edge correctly. + this.jump.edge = FloatAbs(traceEnd[coordDist] - this.takeoffOrigin[coordDist] + (16.0 - 0.03125) * distSign); + } + } + } + } + + bool TryFindBlockHeight(const float position[3], float result[3], int coordDist, int distSign) + { + float traceStart[3], traceEnd[3]; + + // Setup the trace points + CopyVector(position, traceStart); + traceStart[coordDist] += distSign; + CopyVector(traceStart, traceEnd); + + // We search in 54 unit steps + traceStart[2] += 54.0; + + // We search with multiple trace starts in case the landing block has a roof + for (int i = 0; i < 3; i += 1) + { + if (TraceRayPosition(traceStart, traceEnd, result)) + { + // Make sure the trace didn't get stuck right away + if (FloatAbs(result[2] - traceStart[2]) > EPSILON) + { + result[coordDist] -= distSign; + return true; + } + } + + // Try the next are to find the block. We use two different values to have + // some overlap in case the block perfectly aligns with the trace. + traceStart[2] += 54.0; + traceEnd[2] += 53.0; + } + + return false; + } + + + + // =====[ BLOCK STATS HELPERS ]=============================================== + + void CalcBlockStats(float landingOrigin[3], bool checkOffset = false) + { + int coordDist, coordDev, distSign; + float middle[3], startBlock[3], endBlock[3], sweepBoxMin[3], sweepBoxMax[3]; + + // Get the orientation of the block. + GetCoordOrientation(landingOrigin, this.takeoffOrigin, coordDist, distSign); + coordDev = !coordDist; + + // We can't make measurements from within an entity, so we assume the + // player had a remotely reasonable edge and that the middle of the jump + // is not over a block and then start measuring things out from there. + middle[coordDist] = (this.takeoffOrigin[coordDist] + landingOrigin[coordDist]) / 2; + middle[coordDev] = (this.takeoffOrigin[coordDev] + landingOrigin[coordDev]) / 2; + middle[2] = this.takeoffOrigin[2] - 1.0; + + // Get the deviation. + this.jump.deviation = FloatAbs(landingOrigin[coordDev] - this.takeoffOrigin[coordDev]); + + // Setup a sweeping line that starts in the middle and tries to search for the smallest + // block within the deviation of the player. + sweepBoxMin[coordDist] = 0.0; + sweepBoxMin[coordDev] = -this.jump.deviation - 16.0; + sweepBoxMin[2] = 0.0; + sweepBoxMax[coordDist] = 0.0; + sweepBoxMax[coordDev] = this.jump.deviation + 16.0; + sweepBoxMax[2] = 0.0; + + // Modify the takeoff and landing origins to line up with the middle and respect + // the bounding box of the player. + startBlock[coordDist] = this.takeoffOrigin[coordDist] - distSign * 16.0; + // Sometimes you can land 0.03125 units in front of a block, so the trace needs to be extended. + endBlock[coordDist] = landingOrigin[coordDist] + distSign * (16.0 + 0.03125); + startBlock[coordDev] = middle[coordDev]; + endBlock[coordDev] = middle[coordDev]; + startBlock[2] = middle[2]; + endBlock[2] = middle[2]; + + // Search for the blocks + if (!TraceHullPosition(middle, startBlock, sweepBoxMin, sweepBoxMax, startBlock) + || !TraceHullPosition(middle, endBlock, sweepBoxMin, sweepBoxMax, endBlock)) + { + return; + } + + // Make sure the edges of the blocks are parallel. + if (!this.BlockAreEdgesParallel(startBlock, endBlock, this.jump.deviation + 32.0, coordDist, coordDev)) + { + this.jump.block = 0; + this.jump.edge = -1.0; + return; + } + + // Needed for failstats, but you need the endBlock position for that, so we do it here. + if (checkOffset) + { + endBlock[2] += 1.0; + if (FloatAbs(this.FindBlockHeight(endBlock, float(distSign) * 17.0, coordDist, 1.0) - landingOrigin[2]) > JS_OFFSET_EPSILON) + { + return; + } + } + + // Calculate distance and edge. + this.jump.block = RoundFloat(FloatAbs(endBlock[coordDist] - startBlock[coordDist])); + // Block trace ends 0.03125 in front of the actual block. Adjust the edge correctly. + this.jump.edge = FloatAbs(startBlock[coordDist] - this.takeoffOrigin[coordDist] + (16.0 - 0.03125) * distSign); + + // Make it easier to check for blocks that too short + if (this.jump.block < JS_MIN_BLOCK_DISTANCE) + { + this.jump.block = 0; + this.jump.edge = -1.0; + } + } + + void CalcLadderBlockStats(float landingOrigin[3], bool checkOffset = false) + { + int coordDist, coordDev, distSign; + float sweepBoxMin[3], sweepBoxMax[3], blockPosition[3], ladderPosition[3], normalVector[3], endBlock[3], middle[3]; + + // Get the orientation of the block. + GetCoordOrientation(landingOrigin, this.takeoffOrigin, coordDist, distSign); + coordDev = !coordDist; + + // Get the deviation. + this.jump.deviation = FloatAbs(landingOrigin[coordDev] - this.takeoffOrigin[coordDev]); + + // Make sure the ladder is aligned. + GetEntPropVector(this.jumper, Prop_Send, "m_vecLadderNormal", normalVector); + if (FloatAbs(FloatAbs(normalVector[coordDist]) - 1.0) > EPSILON) + { + return; + } + + // Make sure we'll find the block and ladder. + CopyVector(this.takeoffOrigin, ladderPosition); + CopyVector(landingOrigin, endBlock); + endBlock[2] -= 1.0; + ladderPosition[2] = endBlock[2]; + + // Setup a line to search for the ladder. + sweepBoxMin[coordDist] = 0.0; + sweepBoxMin[coordDev] = -20.0; + sweepBoxMin[2] = 0.0; + sweepBoxMax[coordDist] = 0.0; + sweepBoxMax[coordDev] = 20.0; + sweepBoxMax[2] = 0.0; + middle[coordDist] = ladderPosition[coordDist] + distSign * JS_MIN_LAJ_BLOCK_DISTANCE; + middle[coordDev] = endBlock[coordDev]; + middle[2] = ladderPosition[2]; + + // Search for the ladder. + if (!TraceHullPosition(ladderPosition, middle, sweepBoxMin, sweepBoxMax, ladderPosition)) + { + return; + } + + // Find the block and make sure it's aligned + endBlock[coordDist] += distSign * 16.0; + if (!TraceRayPositionNormal(middle, endBlock, blockPosition, normalVector) + || FloatAbs(FloatAbs(normalVector[coordDist]) - 1.0) > EPSILON) + { + return; + } + + // Needed for failstats, but you need the blockPosition for that, so we do it here. + if (checkOffset) + { + blockPosition[2] += 1.0; + if (!this.TraceLadderOffset(this.FindBlockHeight(blockPosition, float(distSign), coordDist, 1.0) - 0.031250)) + { + return; + } + } + + // Calculate distance and edge. + this.jump.block = RoundFloat(FloatAbs(blockPosition[coordDist] - ladderPosition[coordDist])); + this.jump.edge = FloatAbs(this.takeoffOrigin[coordDist] - ladderPosition[coordDist]) - 16.0; + + // Make it easier to check for blocks that too short + if (this.jump.block < JS_MIN_LAJ_BLOCK_DISTANCE) + { + this.jump.block = 0; + this.jump.edge = -1.0; + } + } + + bool TraceLadderOffset(float landingHeight) + { + float traceOrigin[3], traceEnd[3], ladderTop[3], ladderNormal[3]; + + // Get normal vector of the ladder. + GetEntPropVector(this.jumper, Prop_Send, "m_vecLadderNormal", ladderNormal); + + // 10 units is the furthest away from the ladder surface you can get while still being on the ladder. + traceOrigin[0] = this.takeoffOrigin[0] - 10.0 * ladderNormal[0]; + traceOrigin[1] = this.takeoffOrigin[1] - 10.0 * ladderNormal[1]; + traceOrigin[2] = this.takeoffOrigin[2] + 5; + + CopyVector(traceOrigin, traceEnd); + traceEnd[2] = this.takeoffOrigin[2] - 10; + + // Search for the ladder + if (!TraceHullPosition(traceOrigin, traceEnd, playerMinsEx, playerMaxsEx, ladderTop) + || FloatAbs(ladderTop[2] - landingHeight) > JS_OFFSET_EPSILON) + { + this.Invalidate(); + return false; + } + return true; + } + + bool BlockTraceAligned(const float origin[3], const float end[3], int coordDist) + { + float normalVector[3]; + if (!TraceRayNormal(origin, end, normalVector)) + { + return false; + } + return FloatAbs(FloatAbs(normalVector[coordDist]) - 1.0) <= EPSILON; + } + + bool BlockAreEdgesParallel(const float startBlock[3], const float endBlock[3], float deviation, int coordDist, int coordDev) + { + float start[3], end[3], offset; + + // We use very short rays to find the blocks where they're supposed to be and use + // their normals to determine whether they're parallel or not. + offset = startBlock[coordDist] > endBlock[coordDist] ? 0.1 : -0.1; + + // We search for the blocks on both sides of the player, on one of the sides + // there has to be a valid block. + start[coordDist] = startBlock[coordDist] - offset; + start[coordDev] = startBlock[coordDev] - deviation; + start[2] = startBlock[2]; + + end[coordDist] = startBlock[coordDist] + offset; + end[coordDev] = startBlock[coordDev] - deviation; + end[2] = startBlock[2]; + + if (this.BlockTraceAligned(start, end, coordDist)) + { + start[coordDist] = endBlock[coordDist] + offset; + end[coordDist] = endBlock[coordDist] - offset; + if (this.BlockTraceAligned(start, end, coordDist)) + { + return true; + } + start[coordDist] = startBlock[coordDist] - offset; + end[coordDist] = startBlock[coordDist] + offset; + } + + start[coordDev] = startBlock[coordDev] + deviation; + end[coordDev] = startBlock[coordDev] + deviation; + + if (this.BlockTraceAligned(start, end, coordDist)) + { + start[coordDist] = endBlock[coordDist] + offset; + end[coordDist] = endBlock[coordDist] - offset; + if (this.BlockTraceAligned(start, end, coordDist)) + { + return true; + } + } + + return false; + } + + float FindBlockHeight(const float origin[3], float offset, int coord, float searchArea) + { + float block[3], traceStart[3], traceEnd[3], normalVector[3]; + + // Setup the trace. + CopyVector(origin, traceStart); + traceStart[coord] += offset; + CopyVector(traceStart, traceEnd); + traceStart[2] += searchArea; + traceEnd[2] -= searchArea; + + // Find the block height. + if (!TraceRayPositionNormal(traceStart, traceEnd, block, normalVector) + || FloatAbs(normalVector[2] - 1.0) > EPSILON) + { + return -99999999999999999999.0; // Let's hope that's wrong enough + } + + return block[2]; + } + + void GetFailOrigin(float planeHeight, float result[3], int poseIndex) + { + float newVel[3], oldVel[3]; + + // Calculate the actual velocity. + CopyVector(pose(poseIndex).velocity, oldVel); + ScaleVector(oldVel, GetTickInterval()); + + // Calculate at which percentage of the velocity vector we hit the plane. + float scale = (planeHeight - pose(poseIndex).position[2]) / oldVel[2]; + + // Calculate the position we hit the plane. + CopyVector(oldVel, newVel); + ScaleVector(newVel, scale); + AddVectors(pose(poseIndex).position, newVel, result); + } +} + +static JumpTracker jumpTrackers[MAXPLAYERS + 1]; + + + +// =====[ HELPER FUNCTIONS ]=================================================== + +void GetCoordOrientation(const float vec1[3], const float vec2[3], int &coordDist, int &distSign) +{ + coordDist = FloatAbs(vec1[0] - vec2[0]) < FloatAbs(vec1[1] - vec2[1]); + distSign = vec1[coordDist] > vec2[coordDist] ? 1 : -1; +} + +bool TraceRayPosition(const float traceStart[3], const float traceEnd[3], float position[3]) +{ + Handle trace = TR_TraceRayFilterEx(traceStart, traceEnd, MASK_PLAYERSOLID, RayType_EndPoint, TraceEntityFilterPlayers); + if (TR_DidHit(trace)) + { + TR_GetEndPosition(position, trace); + delete trace; + return true; + } + delete trace; + return false; +} + +static bool TraceRayNormal(const float traceStart[3], const float traceEnd[3], float rayNormal[3]) +{ + Handle trace = TR_TraceRayFilterEx(traceStart, traceEnd, MASK_PLAYERSOLID, RayType_EndPoint, TraceEntityFilterPlayers); + if (TR_DidHit(trace)) + { + TR_GetPlaneNormal(trace, rayNormal); + delete trace; + return true; + } + delete trace; + return false; +} + +static bool TraceRayPositionNormal(const float traceStart[3], const float traceEnd[3], float position[3], float rayNormal[3]) +{ + Handle trace = TR_TraceRayFilterEx(traceStart, traceEnd, MASK_PLAYERSOLID, RayType_EndPoint, TraceEntityFilterPlayers); + if (TR_DidHit(trace)) + { + TR_GetEndPosition(position, trace); + TR_GetPlaneNormal(trace, rayNormal); + delete trace; + return true; + } + delete trace; + return false; +} + +static bool TraceHullPosition(const float traceStart[3], const float traceEnd[3], const float mins[3], const float maxs[3], float position[3]) +{ + Handle trace = TR_TraceHullFilterEx(traceStart, traceEnd, mins, maxs, MASK_PLAYERSOLID, TraceEntityFilterPlayers); + if (TR_DidHit(trace)) + { + TR_GetEndPosition(position, trace); + delete trace; + return true; + } + delete trace; + return false; +} + + + +// =====[ EVENTS ]============================================================= + +void OnPluginStart_JumpTracking() +{ + GameData gd = LoadGameConfigFile("sdktools.games/engine.csgo"); + int offset = gd.GetOffset("AcceptInput"); + if (offset == -1) + { + SetFailState("Failed to get AcceptInput offset"); + } + + acceptInputHook = DHookCreate(offset, HookType_Entity, ReturnType_Bool, ThisPointer_CBaseEntity, DHooks_AcceptInput); + DHookAddParam(acceptInputHook, HookParamType_CharPtr); + DHookAddParam(acceptInputHook, HookParamType_CBaseEntity); + DHookAddParam(acceptInputHook, HookParamType_CBaseEntity); + //varaint_t is a union of 12 (float[3]) plus two int type params 12 + 8 = 20 + DHookAddParam(acceptInputHook, HookParamType_Object, 20, DHookPass_ByVal|DHookPass_ODTOR|DHookPass_OCTOR|DHookPass_OASSIGNOP); + DHookAddParam(acceptInputHook, HookParamType_Int); + delete gd; +} + +void OnOptionChanged_JumpTracking(int client, const char[] option) +{ + if (StrEqual(option, gC_CoreOptionNames[Option_Mode])) + { + jumpTrackers[client].jump.type = JumpType_FullInvalid; + } +} + +void OnClientPutInServer_JumpTracking(int client) +{ + if (entityTouchList[client] != INVALID_HANDLE) + { + delete entityTouchList[client]; + } + entityTouchList[client] = new ArrayList(); + lastNoclipTime[client] = 0; + lastDuckbugTime[client] = 0; + lastJumpButtonTime[client] = 0.0; + jumpTrackers[client].Init(client); + DHookEntity(acceptInputHook, true, client); +} + + +// This was originally meant for invalidating jumpstats but was removed. +void OnJumpInvalidated_JumpTracking(int client) +{ + jumpTrackers[client].Invalidate(); +} + +void OnJumpValidated_JumpTracking(int client, bool jumped, bool ladderJump, bool jumpbug) +{ + if (!validCmd[client]) + { + return; + } + + // Update: Takeoff speed should be always correct with the new MovementAPI. + if (jumped) + { + jumpTrackers[client].lastJumpTick = jumpTrackers[client].tickCount; + } + jumpTrackers[client].Reset(jumped, ladderJump, jumpbug); + jumpTrackers[client].Begin(); +} + +void OnStartTouchGround_JumpTracking(int client) +{ + if (!doFailstatAlways[client]) + { + jumpTrackers[client].End(); + } +} + +void OnStartTouch_JumpTracking(int client, int touched) +{ + if (entityTouchList[client] != INVALID_HANDLE) + { + entityTouchList[client].Push(touched); + // Do not immediately invalidate jumps upon collision. + // Give the player a few ticks of leniency for late ducking. + } +} + +void OnTouch_JumpTracking(int client) +{ + if (entityTouchList[client] != INVALID_HANDLE && entityTouchList[client].Length > 0) + { + entityTouchDuration[client]++; + } + if (!Movement_GetOnGround(client) && entityTouchDuration[client] > JS_TOUCH_GRACE_TICKS) + { + jumpTrackers[client].Invalidate(); + } +} + +void OnEndTouch_JumpTracking(int client, int touched) +{ + if (entityTouchList[client] != INVALID_HANDLE) + { + int index = entityTouchList[client].FindValue(touched); + if (index != -1) + { + entityTouchList[client].Erase(index); + } + if (entityTouchList[client].Length == 0) + { + entityTouchDuration[client] = 0; + } + } +} + +void OnPlayerRunCmd_JumpTracking(int client, int buttons, int tickcount) +{ + if (!IsValidClient(client) || !IsPlayerAlive(client)) + { + return; + } + + jumpTrackers[client].tickCount = tickcount; + + if (GetClientButtons(client) & IN_JUMP) + { + lastJumpButtonTime[client] = GetGameTime(); + } + + if (CheckNoclip(client)) + { + lastNoclipTime[client] = tickcount; + } + + // Don't bother checking if player is already in air and jumpstat is already invalid + if (Movement_GetOnGround(client) || + jumpTrackers[client].jump.type != JumpType_FullInvalid) + { + UpdateValidCmd(client, buttons); + } +} + +public Action Movement_OnWalkMovePost(int client) +{ + lastGroundSpeedCappedTime[client] = jumpTrackers[client].tickCount; + return Plugin_Continue; +} + +public Action Movement_OnPlayerMovePost(int client) +{ + lastMovementProcessedTime[client] = jumpTrackers[client].tickCount; + return Plugin_Continue; +} + +public void OnPlayerRunCmdPost_JumpTracking(int client) +{ + if (!IsValidClient(client) || !IsPlayerAlive(client)) + { + return; + } + + // Check for always failstats + if (doFailstatAlways[client]) + { + doFailstatAlways[client] = false; + // Prevent TP shenanigans that would trigger failstats + //jumpTypeLast[client] = JumpType_Invalid; + + if (GOKZ_JS_GetOption(client, JSOption_JumpstatsAlways) == JSToggleOption_Enabled && + isInAir[client]) + { + jumpTrackers[client].AlwaysFailstat(); + } + } + + if (!Movement_GetOnGround(client)) + { + isInAir[client] = true; + jumpTrackers[client].Update(); + } + + if (Movement_GetOnGround(client) || + Movement_GetMovetype(client) == MOVETYPE_LADDER) + { + isInAir[client] = false; + jumpTrackers[client].UpdateOnGround(); + } + + // We always have to track this, no matter if in the air or not + jumpTrackers[client].UpdateRelease(); + + if (Movement_GetDuckbugged(client)) + { + lastDuckbugTime[client] = jumpTrackers[client].tickCount; + } +} + +static MRESReturn DHooks_AcceptInput(int client, DHookReturn hReturn, DHookParam hParams) +{ + if (!IsValidClient(client) || !IsPlayerAlive(client)) + { + return MRES_Ignored; + } + + // Get args + static char param[64]; + static char command[64]; + DHookGetParamString(hParams, 1, command, sizeof(command)); + if (StrEqual(command, "AddOutput")) + { + DHookGetParamObjectPtrString(hParams, 4, 0, ObjectValueType_String, param, sizeof(param)); + char kv[16]; + SplitString(param, " ", kv, sizeof(kv)); + // KVs are case insensitive. + if (StrEqual(kv[0], "origin", false)) + { + // The player technically did not get "teleported" but the origin gets changed regardless, + // which effectively is a teleport. + OnTeleport_FailstatAlways(client); + } + } + return MRES_Ignored; +} + +// =====[ CHECKS ]===== + +static void UpdateValidCmd(int client, int buttons) +{ + if (!CheckGravity(client) + || !CheckBaseVelocity(client) + || !CheckInWater(client) + || !CheckTurnButtons(buttons)) + { + InvalidateJumpstat(client); + validCmd[client] = false; + } + else + { + validCmd[client] = true; + } + + if (jumpTrackers[client].tickCount - lastNoclipTime[client] < GOKZ_JUMPSTATS_NOCLIP_RESET_TICKS) + { + jumpTrackers[client].jump.type = JumpType_FullInvalid; + } + + if (!CheckLadder(client)) + { + InvalidateJumpstat(client); + } +} + +static bool CheckGravity(int client) +{ + float gravity = Movement_GetGravity(client); + // Allow 1.0 and 0.0 gravity as both values appear during normal gameplay + if (FloatAbs(gravity - 1.0) > EPSILON && FloatAbs(gravity) > EPSILON) + { + return false; + } + return true; +} + +static bool CheckBaseVelocity(int client) +{ + float baseVelocity[3]; + Movement_GetBaseVelocity(client, baseVelocity); + if (FloatAbs(baseVelocity[0]) > EPSILON || + FloatAbs(baseVelocity[1]) > EPSILON || + FloatAbs(baseVelocity[2]) > EPSILON) + { + return false; + } + return true; +} + +static bool CheckInWater(int client) +{ + int waterLevel = GetEntProp(client, Prop_Data, "m_nWaterLevel"); + return waterLevel == 0; +} + +static bool CheckTurnButtons(int buttons) +{ + // Don't allow +left or +right turns binds + return !(buttons & (IN_LEFT | IN_RIGHT)); +} + +static bool CheckNoclip(int client) +{ + return Movement_GetMovetype(client) == MOVETYPE_NOCLIP; +} + +static bool CheckLadder(int client) +{ + return Movement_GetMovetype(client) != MOVETYPE_LADDER; +} + + + +// =====[ EXTERNAL HELPER FUNCTIONS ]========================================== + +void InvalidateJumpstat(int client) +{ + jumpTrackers[client].Invalidate(); +} + +float GetStrafeSync(Jump jump, int strafe) +{ + if (strafe < JS_MAX_TRACKED_STRAFES) + { + return float(jump.strafes_gainTicks[strafe]) + / float(jump.strafes_ticks[strafe]) + * 100.0; + } + else + { + return 0.0; + } +} + +float GetStrafeAirtime(Jump jump, int strafe) +{ + if (strafe < JS_MAX_TRACKED_STRAFES) + { + return float(jump.strafes_ticks[strafe]) + / float(jump.duration) + * 100.0; + } + else + { + return 0.0; + } +} + +void OnTeleport_FailstatAlways(int client) +{ + // We want to synchronize all of that + doFailstatAlways[client] = true; + + // gokz-core does that too, but for some reason we have to do it again + InvalidateJumpstat(client); + + jumpTrackers[client].lastTeleportTick = jumpTrackers[client].tickCount; +} diff --git a/sourcemod/scripting/gokz-jumpstats/jump_validating.sp b/sourcemod/scripting/gokz-jumpstats/jump_validating.sp new file mode 100644 index 0000000..c6835c7 --- /dev/null +++ b/sourcemod/scripting/gokz-jumpstats/jump_validating.sp @@ -0,0 +1,82 @@ +/* + Invalidating invalid jumps, such as ones with a modified velocity. +*/ + +static Handle processMovementHookPost; + +void OnPluginStart_JumpValidating() +{ + Handle gamedataConf = LoadGameConfigFile("gokz-core.games"); + if (gamedataConf == null) + { + SetFailState("Failed to load gokz-core gamedata"); + } + + // CreateInterface + // Thanks SlidyBat and ici + StartPrepSDKCall(SDKCall_Static); + if (!PrepSDKCall_SetFromConf(gamedataConf, SDKConf_Signature, "CreateInterface")) + { + SetFailState("Failed to get CreateInterface"); + } + PrepSDKCall_AddParameter(SDKType_String, SDKPass_Pointer); + PrepSDKCall_AddParameter(SDKType_PlainOldData, SDKPass_Pointer, VDECODE_FLAG_ALLOWNULL); + PrepSDKCall_SetReturnInfo(SDKType_PlainOldData, SDKPass_Plain); + Handle CreateInterface = EndPrepSDKCall(); + + if (CreateInterface == null) + { + SetFailState("Unable to prepare SDKCall for CreateInterface"); + } + + char interfaceName[64]; + + // ProcessMovement + if (!GameConfGetKeyValue(gamedataConf, "IGameMovement", interfaceName, sizeof(interfaceName))) + { + SetFailState("Failed to get IGameMovement interface name"); + } + Address IGameMovement = SDKCall(CreateInterface, interfaceName, 0); + if (!IGameMovement) + { + SetFailState("Failed to get IGameMovement pointer"); + } + + int offset = GameConfGetOffset(gamedataConf, "ProcessMovement"); + if (offset == -1) + { + SetFailState("Failed to get ProcessMovement offset"); + } + + processMovementHookPost = DHookCreate(offset, HookType_Raw, ReturnType_Void, ThisPointer_Ignore, DHook_ProcessMovementPost); + DHookAddParam(processMovementHookPost, HookParamType_CBaseEntity); + DHookAddParam(processMovementHookPost, HookParamType_ObjectPtr); + DHookRaw(processMovementHookPost, false, IGameMovement); +} + +static MRESReturn DHook_ProcessMovementPost(Handle hParams) +{ + int client = DHookGetParam(hParams, 1); + if (!IsValidClient(client) || IsFakeClient(client)) + { + return MRES_Ignored; + } + float pVelocity[3], velocity[3]; + Movement_GetProcessingVelocity(client, pVelocity); + Movement_GetVelocity(client, velocity); + + gB_SpeedJustModifiedExternally[client] = false; + for (int i = 0; i < 3; i++) + { + if (FloatAbs(pVelocity[i] - velocity[i]) > EPSILON) + { + // The current velocity doesn't match the velocity of the end of movement processing, + // so it must have been modified by something like a trigger. + InvalidateJumpstat(client); + gB_SpeedJustModifiedExternally[client] = true; + break; + } + } + + return MRES_Ignored; +}
\ No newline at end of file diff --git a/sourcemod/scripting/gokz-jumpstats/options.sp b/sourcemod/scripting/gokz-jumpstats/options.sp new file mode 100644 index 0000000..7e0e9e9 --- /dev/null +++ b/sourcemod/scripting/gokz-jumpstats/options.sp @@ -0,0 +1,86 @@ +/* + Options for jumpstats, including an option to disable it completely. +*/ + + + +// =====[ PUBLIC ]===== + +bool GetJumpstatsDisabled(int client) +{ + return GOKZ_JS_GetOption(client, JSOption_JumpstatsMaster) == JSToggleOption_Disabled + || (GOKZ_JS_GetOption(client, JSOption_MinChatTier) == DistanceTier_None + && GOKZ_JS_GetOption(client, JSOption_MinConsoleTier) == DistanceTier_None + && GOKZ_JS_GetOption(client, JSOption_MinSoundTier) == DistanceTier_None + && GOKZ_JS_GetOption(client, JSOption_FailstatsConsole) == JSToggleOption_Disabled + && GOKZ_JS_GetOption(client, JSOption_FailstatsChat) == JSToggleOption_Disabled + && GOKZ_JS_GetOption(client, JSOption_JumpstatsAlways) == JSToggleOption_Disabled); +} + + + +// =====[ EVENTS ]===== + +void OnOptionsMenuReady_Options() +{ + RegisterOptions(); +} + +void OnClientPutInServer_Options(int client) +{ + if (GOKZ_JS_GetOption(client, JSOption_MinSoundTier) == DistanceTier_Meh) + { + GOKZ_JS_SetOption(client, JSOption_MinSoundTier, DistanceTier_Impressive); + } +} + +void OnOptionChanged_Options(int client, const char[] option, any newValue) +{ + JSOption jsOption; + if (GOKZ_JS_IsJSOption(option, jsOption)) + { + if (jsOption == JSOption_MinSoundTier && newValue == DistanceTier_Meh) + { + GOKZ_JS_SetOption(client, JSOption_MinSoundTier, DistanceTier_Impressive); + } + else + { + PrintOptionChangeMessage(client, jsOption, newValue); + } + } +} + + + +// =====[ PRIVATE ]===== + +static void RegisterOptions() +{ + for (JSOption option; option < JSOPTION_COUNT; option++) + { + GOKZ_RegisterOption(gC_JSOptionNames[option], gC_JSOptionDescriptions[option], + OptionType_Int, gI_JSOptionDefaults[option], 0, gI_JSOptionCounts[option] - 1); + } +} + +static void PrintOptionChangeMessage(int client, JSOption option, any newValue) +{ + // NOTE: Not all options have a message for when they are changed. + switch (option) + { + case JSOption_JumpstatsMaster: + { + switch (newValue) + { + case JSToggleOption_Enabled: + { + GOKZ_PrintToChat(client, true, "%t", "Jumpstats Option - Master Switch - Enable"); + } + case JSToggleOption_Disabled: + { + GOKZ_PrintToChat(client, true, "%t", "Jumpstats Option - Master Switch - Disable"); + } + } + } + } +} diff --git a/sourcemod/scripting/gokz-jumpstats/options_menu.sp b/sourcemod/scripting/gokz-jumpstats/options_menu.sp new file mode 100644 index 0000000..903a8bb --- /dev/null +++ b/sourcemod/scripting/gokz-jumpstats/options_menu.sp @@ -0,0 +1,145 @@ +static TopMenu optionsTopMenu; +static TopMenuObject catJumpstats; +static TopMenuObject itemsJumpstats[JSOPTION_COUNT]; + + + +// =====[ PUBLIC ]===== + +void DisplayJumpstatsOptionsMenu(int client) +{ + optionsTopMenu.DisplayCategory(catJumpstats, client); +} + + + +// =====[ EVENTS ]===== + +void OnOptionsMenuCreated_OptionsMenu(TopMenu topMenu) +{ + if (optionsTopMenu == topMenu && catJumpstats != INVALID_TOPMENUOBJECT) + { + return; + } + + catJumpstats = topMenu.AddCategory(JS_OPTION_CATEGORY, TopMenuHandler_Categories); +} + +void OnOptionsMenuReady_OptionsMenu(TopMenu topMenu) +{ + // Make sure category exists + if (catJumpstats == INVALID_TOPMENUOBJECT) + { + GOKZ_OnOptionsMenuCreated(topMenu); + } + + if (optionsTopMenu == topMenu) + { + return; + } + + optionsTopMenu = topMenu; + + // Add HUD option items + for (int option = 0; option < view_as<int>(JSOPTION_COUNT); option++) + { + itemsJumpstats[option] = optionsTopMenu.AddItem(gC_JSOptionNames[option], TopMenuHandler_HUD, catJumpstats); + } +} + +public void TopMenuHandler_Categories(TopMenu topmenu, TopMenuAction action, TopMenuObject topobj_id, int param, char[] buffer, int maxlength) +{ + if (action == TopMenuAction_DisplayOption || action == TopMenuAction_DisplayTitle) + { + if (topobj_id == catJumpstats) + { + Format(buffer, maxlength, "%T", "Options Menu - Jumpstats", param); + } + } +} + +public void TopMenuHandler_HUD(TopMenu topmenu, TopMenuAction action, TopMenuObject topobj_id, int param, char[] buffer, int maxlength) +{ + JSOption option = JSOPTION_INVALID; + for (int i = 0; i < view_as<int>(JSOPTION_COUNT); i++) + { + if (topobj_id == itemsJumpstats[i]) + { + option = view_as<JSOption>(i); + break; + } + } + + if (option == JSOPTION_INVALID) + { + return; + } + + if (action == TopMenuAction_DisplayOption) + { + if (option == JSOption_JumpstatsMaster || + option == JSOption_ExtendedChatReport || + option == JSOption_FailstatsConsole || + option == JSOption_FailstatsChat || + option == JSOption_JumpstatsAlways) + { + FormatToggleableOptionDisplay(param, option, buffer, maxlength); + } + else + { + FormatDistanceTierOptionDisplay(param, option, buffer, maxlength); + } + } + else if (action == TopMenuAction_SelectOption) + { + GOKZ_JS_CycleOption(param, option); + optionsTopMenu.Display(param, TopMenuPosition_LastCategory); + } +} + + + +// =====[ PRIVATE ]===== + +static void FormatToggleableOptionDisplay(int client, JSOption option, char[] buffer, int maxlength) +{ + if (GOKZ_JS_GetOption(client, option) == JSToggleOption_Disabled) + { + FormatEx(buffer, maxlength, "%T - %T", + gI_JSOptionPhrases[option], client, + "Options Menu - Disabled", client); + } + else + { + FormatEx(buffer, maxlength, "%T - %T", + gI_JSOptionPhrases[option], client, + "Options Menu - Enabled", client); + } +} + +static void FormatDistanceTierOptionDisplay(int client, JSOption option, char[] buffer, int maxlength) +{ + int optionValue = GOKZ_JS_GetOption(client, option); + if (optionValue == DistanceTier_None) // Disabled + { + FormatEx(buffer, maxlength, "%T - %T", + gI_JSOptionPhrases[option], client, + "Options Menu - Disabled", client); + } + else + { + // Add a plus sign to anything below the highest tier + if (optionValue < DISTANCETIER_COUNT - 1) + { + FormatEx(buffer, maxlength, "%T - %s+", + gI_JSOptionPhrases[option], client, + gC_DistanceTiers[optionValue]); + } + else + { + FormatEx(buffer, maxlength, "%T - %s", + gI_JSOptionPhrases[option], client, + gC_DistanceTiers[optionValue]); + } + } +} |
