From 1f1157f7a2d2a98f9dc97ba9f19f0ebd48b5c84e Mon Sep 17 00:00:00 2001 From: apples1949 Date: Fri, 25 Sep 2026 09:07:23 +0800 Subject: [PATCH] Add a Survivor emergency-rescue plugin for referees (#1012) A Survivor can lose a round to something that is not normal Special Infected play: getting wedged in map geometry, pinned by a door or a lift, or eating a bugged damage spike that instantly incaps them. None of that can be undone through in-game mechanics, so the only answers today are restarting the round or negotiating something on the fly, which regularly ends in a dispute. l4d2_emergency_rescue gives the referee a standard, logged way to recover: - Teleport a Survivor to the position the referee is aiming at, to a per-map preset coordinate, or next to a chosen teammate. The destination is safety-checked first: the player hull has to fit, there has to be ground underneath, and no living Special Infected may be within l4d2_emergency_rescue_si_radius (default 250 units). - Set real or temporary health and set the incap count, keeping the third-strike flag and the black-and-white outline in step with it. - Roll a Survivor back to the state recorded just before their last incap. Every teleport and every state change is written to logs/l4d2_emergency_rescue.log and to the standard SourceMod admin log, with the acting admin, the target and the old and new values, and both teams are told that the match was adjusted. Commands are registered with ADMFLAG_BAN, the flag the other referee commands in this repository use, and can be overridden per command in admin_overrides.cfg. The menu is reachable from sm_rescue and from Admin Menu -> Server Commands. The plugin stays inert during normal play: it only observes events to record pre-accident state and never writes to the game unless a referee asks it to. --- .../scripting/l4d2_emergency_rescue.sp | 2326 +++++++++++++++++ .../l4d2_emergency_rescue.phrases.txt | 598 +++++ 2 files changed, 2924 insertions(+) create mode 100644 addons/sourcemod/scripting/l4d2_emergency_rescue.sp create mode 100644 addons/sourcemod/translations/l4d2_emergency_rescue.phrases.txt diff --git a/addons/sourcemod/scripting/l4d2_emergency_rescue.sp b/addons/sourcemod/scripting/l4d2_emergency_rescue.sp new file mode 100644 index 0000000000..19efb9c530 --- /dev/null +++ b/addons/sourcemod/scripting/l4d2_emergency_rescue.sp @@ -0,0 +1,2326 @@ +/** + * l4d2_emergency_rescue + * + * Referee / admin accident-recovery tool for competitive Left 4 Dead 2. + * + * Why this exists + * --------------- + * A Survivor can lose a round to something that is not a normal Special Infected play: + * getting wedged in map geometry, being pinned by a door or a lift, or eating a bugged + * damage spike that instantly incaps or kills them. None of that can be undone through + * in-game mechanics, and today the only answers are restarting the round or negotiating + * something on the fly - which is exactly what causes disputes. + * + * What this plugin gives the referee + * ---------------------------------- + * 1. Teleport a Survivor, with the destination safety-checked first + * - to the position the referee is aiming at + * - to a per-map preset coordinate + * - next to a chosen teammate + * 2. Restore Survivor state + * - real health and temporary health + * - incap count, with the matching third-strike flag + * - roll a Survivor back to the state they had just before their last incap + * + * Design notes + * ------------ + * - The plugin never touches a Survivor unless a referee asks it to. Everything else it + * does is read-only event observation, so it is inert during normal play. + * - Every teleport and every state change is written to the normal SourceMod admin log + * (LogAction) and to addons/sourcemod/logs/l4d2_emergency_rescue.log, recording the + * acting admin, the target, the old value and the new value. + * - The safety check rejects a destination whose player hull does not fit (wall), that + * has no ground underneath (void), that is covered by living Special Infected, or that + * is not on the teammate's level when teleporting next to someone. + * - A Survivor who is currently pinned by a Special Infected is refused rather than moved. + * A pin is normal play rather than a bug accident, and teleporting out of one would both + * change the match and drag the pin along. + * - Nothing here writes a Survivor who is already down. Setting real health while a + * Survivor is incapacitated or hanging writes the game's pre-incap health, which is what + * they are rebuilt from on the rescue, and says so, rather than writing the bleed-out + * pool behind the referee's back. + * - Reviving a Survivor who is already dead is deliberately out of scope: that is the + * death and defibrillator path rather than an incap-count accident, and this repository + * already ships a plugin for it. Asking to restore a dead Survivor says so explicitly. + * + * Commands + * -------- + * All commands are registered with ADMFLAG_BAN, the same flag the other referee-facing + * commands in this repository use (pause, team swap, caster management). Per-command flags + * can be changed in addons/sourcemod/configs/admin_overrides.cfg, for example: + * + * "sm_rescue" "b" // ADMFLAG_GENERIC + * "sm_rescue_here" "b" + * + * sm_rescue open the rescue menu + * sm_rescue_here teleport to the referee's crosshair + * sm_rescue_to teleport next to a teammate + * sm_rescue_pos teleport to a saved preset point + * sm_rescue_save save the crosshair position as a preset + * sm_rescue_health [temp] set real (temp=0) or temporary health + * sm_rescue_incap set the incap count + * sm_rescue_restore roll back to the pre-accident state + * sm_rescue_status [target] print current and recorded state + * + * Targets accept the usual SourceMod patterns: part of a name, #userid, @survivors, ... + * + * Setting real health also clears the temporary health buffer, so "set health to 100" + * always means 100 total. Use temp=1 to change the temporary buffer on its own. + * + * Preset coordinates + * ------------------ + * Stored per map in addons/sourcemod/configs/l4d2_emergency_rescue.cfg: + * + * "EmergencyRescue" + * { + * "c1m1_hotel" + * { + * "1" "1234.500 567.000 64.000" + * } + * } + * + * Dependencies: SourceMod 1.11+, Left4DHooks (shipped with this repository). + */ + +#pragma semicolon 1 +#pragma newdecls required + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Plugin metadata +// --------------------------------------------------------------------------- + +#define PLUGIN_NAME "L4D2 Emergency Rescue" +#define PLUGIN_AUTHOR "apples1949" +#define PLUGIN_DESCRIPTION "Referee accident recovery: safe teleport, health and incap count restore" +#define PLUGIN_VERSION "1.0.0" +#define PLUGIN_URL "https://github.com/SirPlease/L4D2-Competitive-Rework/issues/1012" + +// --------------------------------------------------------------------------- +// Files and ConVars +// --------------------------------------------------------------------------- + +#define TRANSLATIONS_FILE "l4d2_emergency_rescue" +#define PRESET_PATH "configs/l4d2_emergency_rescue.cfg" +#define PRESET_ROOT "EmergencyRescue" +#define LOG_PATH "logs/l4d2_emergency_rescue.log" + +#define CVAR_ENABLE "l4d2_emergency_rescue_enable" +#define CVAR_SAFE_CHECK "l4d2_emergency_rescue_safe_check" +#define CVAR_SI_RADIUS "l4d2_emergency_rescue_si_radius" +#define CVAR_MAX_DROP "l4d2_emergency_rescue_max_drop" +#define CVAR_SNAPSHOT "l4d2_emergency_rescue_snapshot" +#define CVAR_ANNOUNCE "l4d2_emergency_rescue_announce" + +#define RESCUE_ADMFLAGS ADMFLAG_BAN + +// --------------------------------------------------------------------------- +// Tuning constants +// --------------------------------------------------------------------------- + +#define TEAM_SURVIVOR 2 +#define TEAM_INFECTED 3 + +#define RESCUE_MIN_HEALTH 1 +#define RESCUE_DEFAULT_MAX_HEALTH 100 + +// Standing Survivor hull, the same one the game uses for movement collision. +#define HULL_MINS_X -16.0 +#define HULL_MINS_Y -16.0 +#define HULL_MINS_Z 0.0 +#define HULL_MAXS_X 16.0 +#define HULL_MAXS_Y 16.0 +#define HULL_MAXS_Z 72.0 + +// The hull trace for a candidate spot starts this far above it, so a spot sitting exactly +// on the floor does not begin the trace inside solid. +#define GROUND_HULL_LIFT 8.0 + +// How the ring search places a Survivor next to their teammate. +#define TEAMMATE_RING_RADIUS_MIN 40.0 +#define TEAMMATE_RING_RADIUS_STEP 30.0 +#define TEAMMATE_RING_RADIUS_MAX 160.0 +#define TEAMMATE_RING_ANGLES 8 + +// A candidate spot counts as taken when another living Survivor is closer than this. +#define SPOT_OCCUPIED_DISTANCE 48.0 + +// How far a spot next to a teammate may sit above or below them. Without this the ring +// search happily resolves a point over a ledge or a lift shaft onto the floor far below, +// which is the second accident this plugin exists to prevent. +#define TEAMMATE_MAX_HEIGHT_DELTA 48.0 + +#define PRESET_NAME_LEN 64 +#define PRESET_SLOTS 8 + +#define RESCUE_MESSAGE_LEN 256 +#define RESCUE_REASON_LEN 128 + +// --------------------------------------------------------------------------- +// Globals +// --------------------------------------------------------------------------- + +ConVar g_cvEnable; +ConVar g_cvSafeCheck; +ConVar g_cvSiRadius; +ConVar g_cvMaxDrop; +ConVar g_cvSnapshot; +ConVar g_cvAnnounce; + +ConVar g_cvGameMaxIncap; + +TopMenu g_hAdminMenu; + +// Presets for the current map: name -> "x y z", plus the display order. +StringMap g_smPresets; +ArrayList g_aPresetOrder; +char g_sPresetMap[PLATFORM_MAX_PATH]; + +// Menu navigation state. +int g_iMenuTarget[MAXPLAYERS + 1]; // userid of the Survivor the admin is working on +int g_iMenuPage[MAXPLAYERS + 1]; // page to return to in the target list + +// The last state a Survivor was observed in while standing up. Cheap, read-only event +// tracking that makes "restore the pre-accident value" mean the value from before the +// damaging hit instead of whatever the bugged hit left behind. +bool g_bStandingValid[MAXPLAYERS + 1]; +int g_iStandingHealth[MAXPLAYERS + 1]; +float g_fStandingTempHealth[MAXPLAYERS + 1]; +int g_iStandingReviveCount[MAXPLAYERS + 1]; + +// The state captured when a Survivor actually went down, which is what a referee rolls +// back to. +bool g_bSnapshotValid[MAXPLAYERS + 1]; +int g_iSnapshotHealth[MAXPLAYERS + 1]; +float g_fSnapshotTempHealth[MAXPLAYERS + 1]; +int g_iSnapshotReviveCount[MAXPLAYERS + 1]; + +bool g_bLateLoaded; + +static const float g_fHullMins[3] = { HULL_MINS_X, HULL_MINS_Y, HULL_MINS_Z }; +static const float g_fHullMaxs[3] = { HULL_MAXS_X, HULL_MAXS_Y, HULL_MAXS_Z }; + +// Why a destination was rejected. +enum RescueBlock +{ + RescueBlock_None = 0, + RescueBlock_Wall, + RescueBlock_Void, + RescueBlock_Infected, + RescueBlock_Level, + RescueBlock_Occupied +} + +// =========================================================================== +// Lifecycle +// =========================================================================== + +public Plugin myinfo = +{ + name = PLUGIN_NAME, + author = PLUGIN_AUTHOR, + description = PLUGIN_DESCRIPTION, + version = PLUGIN_VERSION, + url = PLUGIN_URL +}; + +public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max) +{ + g_bLateLoaded = late; + return APLRes_Success; +} + +public void OnPluginStart() +{ + LoadTranslations(TRANSLATIONS_FILE); + + g_smPresets = new StringMap(); + g_aPresetOrder = new ArrayList(ByteCountToCells(PRESET_NAME_LEN)); + + CreateConVar("l4d2_emergency_rescue_version", PLUGIN_VERSION, "L4D2 Emergency Rescue plugin version.", FCVAR_NOTIFY | FCVAR_DONTRECORD); + + g_cvEnable = CreateConVar(CVAR_ENABLE, "1", + "1 = enable the Emergency Rescue commands, 0 = disable them (the plugin then does nothing at all).", + FCVAR_NOTIFY, true, 0.0, true, 1.0); + g_cvSafeCheck = CreateConVar(CVAR_SAFE_CHECK, "1", + "1 = verify a teleport destination before using it (hull fits, ground below, no Special Infected), 0 = teleport blindly.", + FCVAR_NOTIFY, true, 0.0, true, 1.0); + g_cvSiRadius = CreateConVar(CVAR_SI_RADIUS, "250.0", + "How close living Special Infected may be to a teleport destination before it is rejected. 0 = do not check for Infected.", + FCVAR_NOTIFY, true, 0.0, true, 2000.0); + g_cvMaxDrop = CreateConVar(CVAR_MAX_DROP, "200.0", + "How far below a candidate spot the plugin looks for ground. A spot with no ground inside this distance counts as void.", + FCVAR_NOTIFY, true, 16.0, true, 2048.0); + g_cvSnapshot = CreateConVar(CVAR_SNAPSHOT, "1", + "1 = record each Survivor's state before they go down, so sm_rescue_restore can roll the accident back, 0 = do not record.", + FCVAR_NOTIFY, true, 0.0, true, 1.0); + g_cvAnnounce = CreateConVar(CVAR_ANNOUNCE, "1", + "1 = tell both teams when a referee teleports a Survivor or restores their state, 0 = only tell the referee.", + FCVAR_NOTIFY, true, 0.0, true, 1.0); + + AutoExecConfig(true, "l4d2_emergency_rescue"); + + g_cvGameMaxIncap = FindConVar("survivor_max_incapacitated_count"); + + RegAdminCmd("sm_rescue", Command_Rescue, RESCUE_ADMFLAGS, "Open the emergency rescue menu."); + RegAdminCmd("sm_rescue_here", Command_RescueHere, RESCUE_ADMFLAGS, "sm_rescue_here - Teleport a Survivor to the position you are aiming at."); + RegAdminCmd("sm_rescue_to", Command_RescueTo, RESCUE_ADMFLAGS, "sm_rescue_to - Teleport a Survivor next to a teammate."); + RegAdminCmd("sm_rescue_pos", Command_RescuePos, RESCUE_ADMFLAGS, "sm_rescue_pos - Teleport a Survivor to a saved preset point."); + RegAdminCmd("sm_rescue_save", Command_RescueSave, RESCUE_ADMFLAGS, "sm_rescue_save - Save the position you are aiming at as a preset for this map."); + RegAdminCmd("sm_rescue_health", Command_RescueHealth, RESCUE_ADMFLAGS, "sm_rescue_health [temp] - Set a Survivor's real (temp=0) or temporary health."); + RegAdminCmd("sm_rescue_incap", Command_RescueIncap, RESCUE_ADMFLAGS, "sm_rescue_incap - Set a Survivor's incap count."); + RegAdminCmd("sm_rescue_restore", Command_RescueRestore, RESCUE_ADMFLAGS, "sm_rescue_restore - Roll a Survivor back to the state they had before their last incap."); + RegAdminCmd("sm_rescue_status", Command_RescueStatus, RESCUE_ADMFLAGS, "sm_rescue_status [target] - Print a Survivor's current and recorded state."); + + HookEvent("player_spawn", Event_PlayerSpawn); + HookEvent("player_hurt", Event_PlayerHurt); + HookEvent("heal_success", Event_HealSuccess); + HookEvent("revive_success", Event_ReviveSuccess); + HookEvent("player_incapacitated_start", Event_PlayerIncapacitatedStart); + HookEvent("round_start", Event_RoundStart); + + // Late load: the map is already running, so seed what the events would have recorded. + if (g_bLateLoaded) + { + LoadPresets(); + + for (int i = 1; i <= MaxClients; i++) + { + if (IsClientInGame(i)) + RecordStandingState(i); + } + } + + TopMenu topmenu; + if (LibraryExists("adminmenu") && (topmenu = GetAdminTopMenu()) != null) + OnAdminMenuReady(topmenu); +} + +public void OnMapStart() +{ + LoadPresets(); + + for (int i = 1; i <= MaxClients; i++) + ClearClientRecords(i); +} + +public void OnLibraryRemoved(const char[] name) +{ + if (StrEqual(name, "adminmenu")) + g_hAdminMenu = null; +} + +public void OnClientDisconnect(int client) +{ + ClearClientRecords(client); + g_iMenuTarget[client] = 0; + g_iMenuPage[client] = 0; +} + +// =========================================================================== +// Admin menu integration +// =========================================================================== + +public void OnAdminMenuReady(Handle topmenu) +{ + TopMenu menu = TopMenu.FromHandle(topmenu); + + // The callback fires again if the admin menu plugin reloads. + if (menu == g_hAdminMenu) + return; + + g_hAdminMenu = menu; + + // Items must live inside a category, so there is nowhere to put this one when the + // Server Commands category is missing. The sm_rescue command still works. + TopMenuObject category = FindTopMenuCategory(menu, ADMINMENU_SERVERCOMMANDS); + if (category == INVALID_TOPMENUOBJECT) + return; + + AddToTopMenu(menu, "emergency_rescue", TopMenuObject_Item, AdminMenu_Handler, + category, "sm_rescue", RESCUE_ADMFLAGS); +} + +public void AdminMenu_Handler(Handle topmenu, TopMenuAction action, TopMenuObject object_id, int client, char[] buffer, int maxlength) +{ + switch (action) + { + case TopMenuAction_DisplayOption: + FormatEx(buffer, maxlength, "%T", "Rescue Menu Title", client); + + case TopMenuAction_SelectOption: + ShowTargetMenu(client); + } +} + +// =========================================================================== +// Commands +// =========================================================================== + +public Action Command_Rescue(int client, int args) +{ + if (!IsRescueEnabled(client)) + return Plugin_Handled; + + if (!IsValidClient(client)) + { + RescueReply(client, "%t", "Rescue Menu Needs Player"); + return Plugin_Handled; + } + + ShowTargetMenu(client); + return Plugin_Handled; +} + +public Action Command_RescueHere(int client, int args) +{ + if (!IsRescueEnabled(client)) + return Plugin_Handled; + + if (args < 1) + { + RescueReply(client, "%t", "Rescue Usage Here"); + return Plugin_Handled; + } + + char pattern[MAX_TARGET_LENGTH]; + GetCmdArg(1, pattern, sizeof pattern); + + int target = ResolveTarget(client, pattern); + if (target == -1) + return Plugin_Handled; + + RescueTeleportToAim(client, target); + return Plugin_Handled; +} + +public Action Command_RescueTo(int client, int args) +{ + if (!IsRescueEnabled(client)) + return Plugin_Handled; + + if (args < 2) + { + RescueReply(client, "%t", "Rescue Usage To"); + return Plugin_Handled; + } + + char pattern[MAX_TARGET_LENGTH]; + char matePattern[MAX_TARGET_LENGTH]; + GetCmdArg(1, pattern, sizeof pattern); + GetCmdArg(2, matePattern, sizeof matePattern); + + int target = ResolveTarget(client, pattern); + if (target == -1) + return Plugin_Handled; + + int teammate = ResolveTarget(client, matePattern); + if (teammate == -1) + return Plugin_Handled; + + if (teammate == target) + { + RescueReply(client, "%t", "Rescue Teammate Is Target", target); + return Plugin_Handled; + } + + RescueTeleportNextToTeammate(client, target, teammate); + return Plugin_Handled; +} + +public Action Command_RescuePos(int client, int args) +{ + if (!IsRescueEnabled(client)) + return Plugin_Handled; + + if (args < 2) + { + RescueReply(client, "%t", "Rescue Usage Pos"); + return Plugin_Handled; + } + + char pattern[MAX_TARGET_LENGTH]; + char preset[PRESET_NAME_LEN]; + GetCmdArg(1, pattern, sizeof pattern); + GetCmdArg(2, preset, sizeof preset); + + int target = ResolveTarget(client, pattern); + if (target == -1) + return Plugin_Handled; + + RescueTeleportToPreset(client, target, preset); + return Plugin_Handled; +} + +public Action Command_RescueSave(int client, int args) +{ + if (!IsRescueEnabled(client)) + return Plugin_Handled; + + if (!IsValidClient(client)) + { + RescueReply(client, "%t", "Rescue Aim Needs Player"); + return Plugin_Handled; + } + + if (args < 1) + { + RescueReply(client, "%t", "Rescue Usage Save"); + return Plugin_Handled; + } + + char preset[PRESET_NAME_LEN]; + GetCmdArg(1, preset, sizeof preset); + + RescueSavePreset(client, preset); + return Plugin_Handled; +} + +public Action Command_RescueHealth(int client, int args) +{ + if (!IsRescueEnabled(client)) + return Plugin_Handled; + + if (args < 2) + { + RescueReply(client, "%t", "Rescue Usage Health"); + return Plugin_Handled; + } + + char pattern[MAX_TARGET_LENGTH]; + char amountArg[16]; + char tempArg[16]; + GetCmdArg(1, pattern, sizeof pattern); + GetCmdArg(2, amountArg, sizeof amountArg); + GetCmdArg(3, tempArg, sizeof tempArg); + + int target = ResolveTarget(client, pattern); + if (target == -1) + return Plugin_Handled; + + RescueApplyHealth(client, target, StringToInt(amountArg), StringToInt(tempArg) != 0); + return Plugin_Handled; +} + +public Action Command_RescueIncap(int client, int args) +{ + if (!IsRescueEnabled(client)) + return Plugin_Handled; + + if (args < 2) + { + RescueReply(client, "%t", "Rescue Usage Incap"); + return Plugin_Handled; + } + + char pattern[MAX_TARGET_LENGTH]; + char countArg[16]; + GetCmdArg(1, pattern, sizeof pattern); + GetCmdArg(2, countArg, sizeof countArg); + + int target = ResolveTarget(client, pattern); + if (target == -1) + return Plugin_Handled; + + RescueApplyIncapCount(client, target, StringToInt(countArg)); + return Plugin_Handled; +} + +public Action Command_RescueRestore(int client, int args) +{ + if (!IsRescueEnabled(client)) + return Plugin_Handled; + + if (!IsValidClient(client)) + { + RescueReply(client, "%t", "Rescue Menu Needs Player"); + return Plugin_Handled; + } + + if (args < 1) + { + RescueReply(client, "%t", "Rescue Usage Restore"); + return Plugin_Handled; + } + + char pattern[MAX_TARGET_LENGTH]; + GetCmdArg(1, pattern, sizeof pattern); + + // A dead Survivor has to resolve here so the reply can explain why they cannot be + // restored, rather than being filtered out by the resolver. + int target = ResolveTarget(client, pattern, false); + if (target == -1) + return Plugin_Handled; + + RescueRestoreSnapshot(client, target); + return Plugin_Handled; +} + +public Action Command_RescueStatus(int client, int args) +{ + if (!IsRescueEnabled(client)) + return Plugin_Handled; + + if (args < 1) + { + if (!IsValidClient(client)) + { + RescueReply(client, "%t", "Rescue Usage Status"); + return Plugin_Handled; + } + + ShowTargetMenu(client); + return Plugin_Handled; + } + + char pattern[MAX_TARGET_LENGTH]; + GetCmdArg(1, pattern, sizeof pattern); + + int target = ResolveTarget(client, pattern); + if (target == -1) + return Plugin_Handled; + + RescuePrintStatus(client, target); + return Plugin_Handled; +} + +// =========================================================================== +// Menus +// +// Menu display text is localised while the menu is built. The target is already known at +// that point, so there is no need for MenuAction_DisplayItem. +// =========================================================================== + +void ShowTargetMenu(int admin, int page = 0) +{ + // Counted before the menu is created, so an empty menu is never built. + int candidates = 0; + for (int i = 1; i <= MaxClients; i++) + { + if (IsValidSurvivor(i) && IsPlayerAlive(i)) + candidates++; + } + + if (candidates == 0) + { + RescueReply(admin, "%t", "Rescue No Survivor"); + return; + } + + Menu menu = new Menu(TargetMenu_Handler); + menu.SetTitle("%T", "Rescue Menu Title", admin); + + char info[16]; + char display[RESCUE_MESSAGE_LEN]; + + for (int i = 1; i <= MaxClients; i++) + { + if (!IsValidSurvivor(i) || !IsPlayerAlive(i)) + continue; + + IntToString(GetClientUserId(i), info, sizeof info); + FormatEx(display, sizeof display, "%T", "Rescue Target Item", admin, + i, GetClientHealth(i), GetClientReviveCount(i)); + menu.AddItem(info, display); + } + + menu.ExitButton = true; + menu.DisplayAt(admin, page, MENU_TIME_FOREVER); +} + +public int TargetMenu_Handler(Menu menu, MenuAction action, int param1, int param2) +{ + switch (action) + { + case MenuAction_Select: + { + int admin = param1; + + char info[16]; + menu.GetItem(param2, info, sizeof info); + + int target = GetClientOfUserId(StringToInt(info)); + if (!IsValidSurvivor(target) || !IsPlayerAlive(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + ShowTargetMenu(admin); + return 0; + } + + g_iMenuTarget[admin] = GetClientUserId(target); + g_iMenuPage[admin] = menu.Selection; + + ShowActionMenu(admin); + } + + case MenuAction_End: + delete menu; + } + + return 0; +} + +void ShowActionMenu(int admin) +{ + int target = GetClientOfUserId(g_iMenuTarget[admin]); + if (!IsValidSurvivor(target) || !IsPlayerAlive(target)) + { + ShowTargetMenu(admin, g_iMenuPage[admin]); + return; + } + + char display[64]; + + Menu menu = new Menu(ActionMenu_Handler); + menu.SetTitle("%T", "Rescue Action Title", admin, target); + + FormatEx(display, sizeof display, "%T", "Rescue Action Aim", admin); + menu.AddItem("aim", display); + + FormatEx(display, sizeof display, "%T", "Rescue Action Teammate", admin); + menu.AddItem("mate", display); + + FormatEx(display, sizeof display, "%T", "Rescue Action Preset", admin); + menu.AddItem("preset", display); + + FormatEx(display, sizeof display, "%T", "Rescue Action Health", admin); + menu.AddItem("health", display); + + FormatEx(display, sizeof display, "%T", "Rescue Action Incap", admin); + menu.AddItem("incap", display); + + FormatEx(display, sizeof display, "%T", "Rescue Action Restore", admin); + menu.AddItem("restore", display); + + FormatEx(display, sizeof display, "%T", "Rescue Action Status", admin); + menu.AddItem("status", display); + + FormatEx(display, sizeof display, "%T", "Rescue Action Save", admin); + menu.AddItem("save", display); + + menu.ExitBackButton = true; + menu.Display(admin, MENU_TIME_FOREVER); +} + +public int ActionMenu_Handler(Menu menu, MenuAction action, int param1, int param2) +{ + switch (action) + { + case MenuAction_Select: + { + int admin = param1; + + char info[16]; + menu.GetItem(param2, info, sizeof info); + + int target = GetClientOfUserId(g_iMenuTarget[admin]); + if (!IsValidSurvivor(target) || !IsPlayerAlive(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + ShowTargetMenu(admin, g_iMenuPage[admin]); + return 0; + } + + if (StrEqual(info, "aim")) + { + RescueTeleportToAim(admin, target); + ShowActionMenu(admin); + } + else if (StrEqual(info, "mate")) + { + ShowTeammateMenu(admin); + } + else if (StrEqual(info, "preset")) + { + ShowPresetMenu(admin); + } + else if (StrEqual(info, "health")) + { + ShowHealthMenu(admin); + } + else if (StrEqual(info, "incap")) + { + ShowIncapMenu(admin); + } + else if (StrEqual(info, "restore")) + { + RescueRestoreSnapshot(admin, target); + ShowActionMenu(admin); + } + else if (StrEqual(info, "status")) + { + RescuePrintStatus(admin, target); + ShowActionMenu(admin); + } + else if (StrEqual(info, "save")) + { + ShowPresetSlotMenu(admin); + } + } + + case MenuAction_Cancel: + { + if (param2 == MenuCancel_ExitBack) + ShowTargetMenu(param1, g_iMenuPage[param1]); + } + + case MenuAction_End: + delete menu; + } + + return 0; +} + +void ShowTeammateMenu(int admin) +{ + int target = GetClientOfUserId(g_iMenuTarget[admin]); + + // Counted before the menu is created, so an empty menu is never built and never has to + // be destroyed again. + int candidates = 0; + for (int i = 1; i <= MaxClients; i++) + { + if (IsValidSurvivor(i) && IsPlayerAlive(i) && i != target) + candidates++; + } + + if (candidates == 0) + { + RescueReply(admin, "%t", "Rescue No Teammate"); + ShowActionMenu(admin); + return; + } + + Menu menu = new Menu(TeammateMenu_Handler); + menu.SetTitle("%T", "Rescue Select Teammate", admin); + + char info[16]; + char display[MAX_NAME_LENGTH + 8]; + + for (int i = 1; i <= MaxClients; i++) + { + if (!IsValidSurvivor(i) || !IsPlayerAlive(i) || i == target) + continue; + + IntToString(GetClientUserId(i), info, sizeof info); + FormatEx(display, sizeof display, "%N", i); + menu.AddItem(info, display); + } + + menu.ExitBackButton = true; + menu.Display(admin, MENU_TIME_FOREVER); +} + +public int TeammateMenu_Handler(Menu menu, MenuAction action, int param1, int param2) +{ + switch (action) + { + case MenuAction_Select: + { + int admin = param1; + + char info[16]; + menu.GetItem(param2, info, sizeof info); + + int teammate = GetClientOfUserId(StringToInt(info)); + int target = GetClientOfUserId(g_iMenuTarget[admin]); + + if (!IsValidSurvivor(teammate) || !IsPlayerAlive(teammate)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + ShowTeammateMenu(admin); + return 0; + } + + if (!IsValidSurvivor(target) || !IsPlayerAlive(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + ShowTargetMenu(admin, g_iMenuPage[admin]); + return 0; + } + + RescueTeleportNextToTeammate(admin, target, teammate); + ShowActionMenu(admin); + } + + case MenuAction_Cancel: + { + if (param2 == MenuCancel_ExitBack) + ShowActionMenu(param1); + } + + case MenuAction_End: + delete menu; + } + + return 0; +} + +void ShowPresetMenu(int admin) +{ + if (g_aPresetOrder.Length == 0) + { + RescueReply(admin, "%t", "Rescue Preset Empty"); + ShowActionMenu(admin); + return; + } + + Menu menu = new Menu(PresetMenu_Handler); + menu.SetTitle("%T", "Rescue Select Preset", admin); + + char name[PRESET_NAME_LEN]; + for (int i = 0; i < g_aPresetOrder.Length; i++) + { + g_aPresetOrder.GetString(i, name, sizeof name); + menu.AddItem(name, name); + } + + menu.ExitBackButton = true; + menu.Display(admin, MENU_TIME_FOREVER); +} + +public int PresetMenu_Handler(Menu menu, MenuAction action, int param1, int param2) +{ + switch (action) + { + case MenuAction_Select: + { + int admin = param1; + + char name[PRESET_NAME_LEN]; + menu.GetItem(param2, name, sizeof name); + + int target = GetClientOfUserId(g_iMenuTarget[admin]); + if (!IsValidSurvivor(target) || !IsPlayerAlive(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + ShowTargetMenu(admin, g_iMenuPage[admin]); + return 0; + } + + RescueTeleportToPreset(admin, target, name); + ShowActionMenu(admin); + } + + case MenuAction_Cancel: + { + if (param2 == MenuCancel_ExitBack) + ShowActionMenu(param1); + } + + case MenuAction_End: + delete menu; + } + + return 0; +} + +void ShowPresetSlotMenu(int admin) +{ + Menu menu = new Menu(PresetSlotMenu_Handler); + menu.SetTitle("%T", "Rescue Select Preset Slot", admin); + + char info[8]; + char display[PRESET_NAME_LEN]; + + for (int i = 1; i <= PRESET_SLOTS; i++) + { + IntToString(i, info, sizeof info); + FormatEx(display, sizeof display, "%T", "Rescue Preset Slot", admin, i); + menu.AddItem(info, display); + } + + menu.ExitBackButton = true; + menu.Display(admin, MENU_TIME_FOREVER); +} + +public int PresetSlotMenu_Handler(Menu menu, MenuAction action, int param1, int param2) +{ + switch (action) + { + case MenuAction_Select: + { + int admin = param1; + + char slot[8]; + menu.GetItem(param2, slot, sizeof slot); + + RescueSavePreset(admin, slot); + ShowActionMenu(admin); + } + + case MenuAction_Cancel: + { + if (param2 == MenuCancel_ExitBack) + ShowActionMenu(param1); + } + + case MenuAction_End: + delete menu; + } + + return 0; +} + +void ShowHealthMenu(int admin) +{ + Menu menu = new Menu(HealthMenu_Handler); + menu.SetTitle("%T", "Rescue Select Health", admin); + + menu.AddItem("real:100", "100"); + menu.AddItem("real:80", "80"); + menu.AddItem("real:60", "60"); + menu.AddItem("real:40", "40"); + menu.AddItem("real:20", "20"); + menu.AddItem("real:1", "1"); + menu.AddItem("temp:50", "50 (temp)"); + menu.AddItem("temp:25", "25 (temp)"); + + menu.ExitBackButton = true; + menu.Display(admin, MENU_TIME_FOREVER); +} + +public int HealthMenu_Handler(Menu menu, MenuAction action, int param1, int param2) +{ + switch (action) + { + case MenuAction_Select: + { + int admin = param1; + + char info[16]; + menu.GetItem(param2, info, sizeof info); + + char parts[2][16]; + ExplodeString(info, ":", parts, sizeof parts, sizeof parts[]); + + int target = GetClientOfUserId(g_iMenuTarget[admin]); + if (!IsValidSurvivor(target) || !IsPlayerAlive(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + ShowTargetMenu(admin, g_iMenuPage[admin]); + return 0; + } + + RescueApplyHealth(admin, target, StringToInt(parts[1]), StrEqual(parts[0], "temp")); + ShowActionMenu(admin); + } + + case MenuAction_Cancel: + { + if (param2 == MenuCancel_ExitBack) + ShowActionMenu(param1); + } + + case MenuAction_End: + delete menu; + } + + return 0; +} + +void ShowIncapMenu(int admin) +{ + int maxIncap = GetGameMaxIncapCount(); + int highest = maxIncap > 0 ? maxIncap : 3; + + Menu menu = new Menu(IncapMenu_Handler); + menu.SetTitle("%T", "Rescue Select Incap", admin); + + char info[8]; + char mark[32]; + char display[RESCUE_MESSAGE_LEN]; + + // The entry at the game limit is marked, because that is the one that turns the + // Survivor black and white. + for (int i = 0; i <= highest; i++) + { + mark[0] = '\0'; + if (maxIncap > 0 && i >= maxIncap) + FormatEx(mark, sizeof mark, "%T", "Rescue Incap Third Strike Mark", admin); + + IntToString(i, info, sizeof info); + FormatEx(display, sizeof display, "%T", "Rescue Incap Item", admin, i, mark); + menu.AddItem(info, display); + } + + menu.ExitBackButton = true; + menu.Display(admin, MENU_TIME_FOREVER); +} + +public int IncapMenu_Handler(Menu menu, MenuAction action, int param1, int param2) +{ + switch (action) + { + case MenuAction_Select: + { + int admin = param1; + + char info[8]; + menu.GetItem(param2, info, sizeof info); + + int target = GetClientOfUserId(g_iMenuTarget[admin]); + if (!IsValidSurvivor(target) || !IsPlayerAlive(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + ShowTargetMenu(admin, g_iMenuPage[admin]); + return 0; + } + + RescueApplyIncapCount(admin, target, StringToInt(info)); + ShowActionMenu(admin); + } + + case MenuAction_Cancel: + { + if (param2 == MenuCancel_ExitBack) + ShowActionMenu(param1); + } + + case MenuAction_End: + delete menu; + } + + return 0; +} + +// =========================================================================== +// Target helpers +// =========================================================================== + +/** + * Resolves a command target pattern to one Survivor. + * + * @param requireAlive True for everything that needs a Survivor on their feet. The restore + * command passes false so a dead Survivor can be resolved and then + * answered with a message that actually explains the situation, + * instead of the generic "target must be alive" from the resolver. + * + * @return Client index, or -1 when the target could not be resolved. + */ +int ResolveTarget(int admin, const char[] pattern, bool requireAlive = true) +{ + int targets[MAXPLAYERS]; + char targetName[MAX_TARGET_LENGTH]; + bool multiLingual; + + int flags = COMMAND_FILTER_NO_BOTS | COMMAND_FILTER_NO_MULTI; + if (requireAlive) + flags |= COMMAND_FILTER_ALIVE; + + int count = ProcessTargetString(pattern, admin, targets, sizeof targets, + flags, targetName, sizeof targetName, multiLingual); + + if (count <= 0) + { + // ReplyToTargetError falls back to the server console when admin is 0. + ReplyToTargetError(admin, count); + return -1; + } + + int target = targets[0]; + if (!IsValidSurvivor(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + return -1; + } + + return target; +} + +bool IsRescueEnabled(int admin) +{ + if (g_cvEnable.BoolValue) + return true; + + RescueReply(admin, "%t", "Rescue Disabled"); + return false; +} + +bool IsValidClient(int client) +{ + return client > 0 && client <= MaxClients && IsClientInGame(client); +} + +bool IsValidSurvivor(int client) +{ + return IsValidClient(client) && GetClientTeam(client) == TEAM_SURVIVOR; +} + +// =========================================================================== +// Teleport +// =========================================================================== + +void RescueTeleportToAim(int admin, int target) +{ + if (!IsValidClient(admin)) + { + RescueReply(admin, "%t", "Rescue Aim Needs Player"); + return; + } + + float aim[3]; + if (!GetAimOrigin(admin, aim)) + { + RescueReply(admin, "%t", "Rescue Aim Failed"); + return; + } + + float destination[3]; + int infected = 0; + RescueBlock block = ApplySafetyCheck(aim, destination, infected); + if (block != RescueBlock_None) + { + ReportBlocked(admin, block, infected); + return; + } + + // The destination label is built twice: once in the referee's language for the private + // reply, and once in the server language for the broadcast, so the announcement cannot + // end up mixing two languages when the referee and the server differ. + char label[RESCUE_REASON_LEN]; + char announceLabel[RESCUE_REASON_LEN]; + FormatEx(label, sizeof label, "%T", "Rescue Destination Aim", admin); + FormatEx(announceLabel, sizeof announceLabel, "%T", "Rescue Destination Aim", LANG_SERVER); + + PerformRescueTeleport(admin, target, destination, label, announceLabel); +} + +void RescueTeleportNextToTeammate(int admin, int target, int teammate) +{ + if (!IsValidSurvivor(teammate) || !IsPlayerAlive(teammate)) + { + RescueReply(admin, "%t", "Rescue Teammate Invalid"); + return; + } + + float base[3]; + GetClientAbsOrigin(teammate, base); + + // Prefer the closest free spot, then widen the ring. A Survivor is never dropped on top + // of the teammate, because the engine would push them apart and could shove the + // teammate off a ledge. + float candidate[3]; + float destination[3]; + bool found = false; + RescueBlock lastBlock = RescueBlock_None; + int lastInfected = 0; + int infected; + + for (float radius = TEAMMATE_RING_RADIUS_MIN; radius <= TEAMMATE_RING_RADIUS_MAX && !found; radius += TEAMMATE_RING_RADIUS_STEP) + { + for (int step = 0; step < TEAMMATE_RING_ANGLES; step++) + { + float angle = DegToRad(float(step) * (360.0 / float(TEAMMATE_RING_ANGLES))); + + candidate[0] = base[0] + Cosine(angle) * radius; + candidate[1] = base[1] + Sine(angle) * radius; + candidate[2] = base[2]; + + RescueBlock block = ApplySafetyCheck(candidate, destination, infected); + + if (block == RescueBlock_None) + { + // A ring point that resolves onto a floor far above or below the teammate is + // a ledge, a balcony or a lift shaft, not "next to the teammate". + if (destination[2] < base[2] - TEAMMATE_MAX_HEIGHT_DELTA + || destination[2] > base[2] + TEAMMATE_MAX_HEIGHT_DELTA) + { + RememberBlock(lastBlock, lastInfected, RescueBlock_Level, 0); + continue; + } + + if (IsSpotOccupied(destination, target, teammate)) + { + RememberBlock(lastBlock, lastInfected, RescueBlock_Occupied, 0); + continue; + } + + found = true; + break; + } + + RememberBlock(lastBlock, lastInfected, block, infected); + } + } + + if (!found) + { + ReportBlocked(admin, lastBlock, lastInfected); + return; + } + + char label[RESCUE_REASON_LEN]; + char announceLabel[RESCUE_REASON_LEN]; + FormatEx(label, sizeof label, "%T", "Rescue Destination Teammate", admin, teammate); + FormatEx(announceLabel, sizeof announceLabel, "%T", "Rescue Destination Teammate", LANG_SERVER, teammate); + + PerformRescueTeleport(admin, target, destination, label, announceLabel); +} + +void RescueTeleportToPreset(int admin, int target, const char[] name) +{ + if (g_aPresetOrder.Length == 0) + { + RescueReply(admin, "%t", "Rescue Preset Empty"); + return; + } + + float preset[3]; + if (!GetPreset(name, preset)) + { + RescueReply(admin, "%t", "Rescue Preset Missing", name); + return; + } + + float destination[3]; + int infected = 0; + RescueBlock block = ApplySafetyCheck(preset, destination, infected); + if (block != RescueBlock_None) + { + ReportBlocked(admin, block, infected); + return; + } + + char label[RESCUE_REASON_LEN]; + char announceLabel[RESCUE_REASON_LEN]; + FormatEx(label, sizeof label, "%T", "Rescue Destination Preset", admin, name); + FormatEx(announceLabel, sizeof announceLabel, "%T", "Rescue Destination Preset", LANG_SERVER, name); + + PerformRescueTeleport(admin, target, destination, label, announceLabel); +} + +/** + * Runs the destination safety check unless the referee turned it off. With the check + * disabled the raw position is used, which is what the referee explicitly asked for. + */ +RescueBlock ApplySafetyCheck(const float pos[3], float destination[3], int &infected) +{ + infected = 0; + + if (!g_cvSafeCheck.BoolValue) + { + destination = pos; + return RescueBlock_None; + } + + return ValidateStandPosition(pos, destination, infected); +} + +void PerformRescueTeleport(int admin, int target, const float destination[3], const char[] label, const char[] announceLabel) +{ + if (!IsValidSurvivor(target) || !IsPlayerAlive(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + return; + } + + // A pinned Survivor is normal play rather than a bug accident, and a teleport would drag + // the pin along and put them down at the destination still disabled. Refusing is better + // than silently rewriting the match. + if (L4D2_GetInfectedAttacker(target) > 0) + { + RescueReply(admin, "%t", "Rescue Target Pinned", target); + return; + } + + PrepareSurvivorForTeleport(target); + + float zero[3]; + TeleportEntity(target, destination, NULL_VECTOR, zero); + + // NULL_VECTOR means "leave the velocity alone", so the explicit zero vector above is + // what stops the Survivor. Repeating it as a netprop write means someone who was + // falling or flying cannot carry that momentum into the new position. + SetEntPropVector(target, Prop_Data, "m_vecVelocity", zero); + + char message[RESCUE_MESSAGE_LEN * 2]; + FormatEx(message, sizeof message, "%L teleported %L to %s (%.1f %.1f %.1f)", + admin, target, label, destination[0], destination[1], destination[2]); + RescueLogLine(admin, target, message); + + RescueReply(admin, "%t", "Rescue Teleport Success", target, label); + AnnounceRescue("%t", "Rescue Announce Teleport", target, announceLabel); +} + +/** + * Removes the states that would otherwise survive a teleport and drag the Survivor back. + * + * MOVETYPE_WALK and the frozen flag are cleared because the whole point of the command is + * that the Survivor ends up standing and movable at the new position; a Survivor who is + * frozen, or in a non-walking move type, would simply arrive stuck in a different place. + * + * Nothing else is touched. In particular a Survivor who is currently pinned is not cleaned + * up: a pin is normal play rather than a bug accident, breaking it would be a change to the + * match that the referee did not ask for, and the pin would travel with them anyway. That + * case is refused before this is reached. + */ +void PrepareSurvivorForTeleport(int target) +{ + SetEntityMoveType(target, MOVETYPE_WALK); + + int flags = GetEntProp(target, Prop_Send, "m_fFlags"); + if (flags & FL_FROZEN) + SetEntProp(target, Prop_Send, "m_fFlags", flags & ~FL_FROZEN); +} + +/** + * Finds the world position the admin is aiming at, rejecting a ray that never hit anything + * or that ended outside the playable world. + */ +bool GetAimOrigin(int admin, float origin[3]) +{ + float eyePosition[3]; + float eyeAngles[3]; + GetClientEyePosition(admin, eyePosition); + GetClientEyeAngles(admin, eyeAngles); + + Handle trace = TR_TraceRayFilterEx(eyePosition, eyeAngles, MASK_PLAYERSOLID, RayType_Infinite, TraceFilter_WorldOnly); + + bool found = false; + if (TR_DidHit(trace) && TR_GetFraction(trace) < 1.0) + { + TR_GetEndPosition(origin, trace); + found = !TR_PointOutsideWorld(origin); + } + + delete trace; + return found; +} + +/** + * Checks a candidate position and normalises it onto the ground. + * + * @return RescueBlock_None when the position is usable. + */ +RescueBlock ValidateStandPosition(const float pos[3], float destination[3], int &infected, bool checkInfected = true) +{ + infected = 0; + + // Aiming into a wall or a prop is rejected outright, instead of quietly dropping the + // Survivor on top of whatever was aimed at. + if (IsHullBlocked(pos)) + return RescueBlock_Wall; + + float start[3]; + float end[3]; + start = pos; + start[2] += GROUND_HULL_LIFT; + end = start; + end[2] -= g_cvMaxDrop.FloatValue; + + Handle trace = TR_TraceHullFilterEx(start, end, g_fHullMins, g_fHullMaxs, + MASK_PLAYERSOLID, TraceFilter_WorldOnly); + + // Starting inside geometry means the spot is buried in a wall, a prop or a ceiling. + if (TR_StartSolid(trace) || TR_AllSolid(trace)) + { + delete trace; + return RescueBlock_Wall; + } + + // Nothing underneath inside the search depth: a pit, a hole, or off the map. + if (!TR_DidHit(trace) || TR_GetFraction(trace) >= 1.0) + { + delete trace; + return RescueBlock_Void; + } + + TR_GetEndPosition(destination, trace); + delete trace; + + // The hull has to fit where it actually comes to rest, not where it was aimed. + if (IsHullBlocked(destination)) + return RescueBlock_Wall; + + if (checkInfected && g_cvSiRadius.FloatValue > 0.0) + { + infected = CountNearbySpecialInfected(destination, g_cvSiRadius.FloatValue); + if (infected > 0) + return RescueBlock_Infected; + } + + return RescueBlock_None; +} + +/** + * True when the Survivor hull overlaps solid geometry at the given position. + * + * The hull is swept one unit down onto the position rather than traced with a zero length + * start and end, so the engine always performs a real containment test. Coming to rest on + * the floor is the normal case and does not count as blocked; only starting inside + * geometry does. + */ +bool IsHullBlocked(const float pos[3]) +{ + float start[3]; + float end[3]; + start = pos; + start[2] += 1.0; + end = pos; + + Handle trace = TR_TraceHullFilterEx(start, end, g_fHullMins, g_fHullMaxs, + MASK_PLAYERSOLID, TraceFilter_WorldOnly); + + bool blocked = TR_StartSolid(trace) || TR_AllSolid(trace); + + delete trace; + return blocked; +} + +/** + * Counts living Special Infected close enough to the destination to make the arrival a + * second accident. Ghost Infected are ignored: they are not a threat where they hover. + */ +int CountNearbySpecialInfected(const float pos[3], float radius) +{ + float squaredRadius = radius * radius; + float origin[3]; + int count = 0; + + for (int i = 1; i <= MaxClients; i++) + { + if (!IsValidClient(i) || GetClientTeam(i) != TEAM_INFECTED) + continue; + + if (!IsPlayerAlive(i) || L4D_IsPlayerGhost(i)) + continue; + + GetClientAbsOrigin(i, origin); + if (GetVectorDistance(pos, origin, true) <= squaredRadius) + count++; + } + + return count; +} + +bool IsSpotOccupied(const float pos[3], int ignoreA, int ignoreB) +{ + float squaredDistance = SPOT_OCCUPIED_DISTANCE * SPOT_OCCUPIED_DISTANCE; + float origin[3]; + + for (int i = 1; i <= MaxClients; i++) + { + if (i == ignoreA || i == ignoreB) + continue; + + if (!IsValidSurvivor(i) || !IsPlayerAlive(i)) + continue; + + GetClientAbsOrigin(i, origin); + if (GetVectorDistance(pos, origin, true) <= squaredDistance) + return true; + } + + return false; +} + +/** + * Trace filter that keeps the world and props but drops every player, so a teammate + * standing on a spot does not read as a solid wall. + */ +public bool TraceFilter_WorldOnly(int entity, int contentsMask) +{ + return entity < 1 || entity > MaxClients; +} + +void ReportBlocked(int admin, RescueBlock block, int infected) +{ + char reason[RESCUE_REASON_LEN]; + FormatBlockReason(reason, sizeof reason, admin, block, infected); + + RescueReply(admin, "%t", "Rescue Unsafe Destination", reason); +} + +/** + * Keeps the most useful rejection reason for the operator message while the ring search + * tries candidate after candidate. Special Infected beat everything because they are the + * most actionable, then real geometry problems, then "the nearby spots are taken". + */ +void RememberBlock(RescueBlock ¤t, int ¤tInfected, RescueBlock candidate, int infected) +{ + if (candidate == RescueBlock_Occupied && current != RescueBlock_None) + return; + + if (current == RescueBlock_None + || candidate == RescueBlock_Infected + || current == RescueBlock_Occupied + || current == RescueBlock_Level) + { + current = candidate; + currentInfected = infected; + } +} + +void FormatBlockReason(char[] buffer, int maxlen, int admin, RescueBlock block, int infected) +{ + switch (block) + { + case RescueBlock_Wall: + FormatEx(buffer, maxlen, "%T", "Rescue Unsafe Reason Wall", admin); + + case RescueBlock_Void: + FormatEx(buffer, maxlen, "%T", "Rescue Unsafe Reason Void", admin); + + case RescueBlock_Infected: + FormatEx(buffer, maxlen, "%T", "Rescue Unsafe Reason Infected", admin, infected); + + case RescueBlock_Level: + FormatEx(buffer, maxlen, "%T", "Rescue Unsafe Reason Level", admin); + + case RescueBlock_Occupied: + FormatEx(buffer, maxlen, "%T", "Rescue Unsafe Reason Occupied", admin); + + default: + FormatEx(buffer, maxlen, "%T", "Rescue Unsafe Reason Unknown", admin); + } +} + +// =========================================================================== +// State restore +// =========================================================================== + +void RescueApplyHealth(int admin, int target, int amount, bool asTemporary) +{ + if (!IsValidSurvivor(target) || !IsPlayerAlive(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + return; + } + + if (amount < RESCUE_MIN_HEALTH) + { + RescueReply(admin, "%t", "Rescue Health Too Low", amount, RESCUE_MIN_HEALTH); + amount = RESCUE_MIN_HEALTH; + } + + if (asTemporary) + { + float oldTemp = GetClientTempHealth(target); + + int tempCeiling = GetClientMaxHealth(target); + if (amount > tempCeiling) + { + RescueReply(admin, "%t", "Rescue Health Clamped", target, amount, tempCeiling); + amount = tempCeiling; + } + + SetClientTempHealth(target, float(amount)); + + char message[RESCUE_MESSAGE_LEN * 2]; + FormatEx(message, sizeof message, "%L set temporary health of %L: %.0f -> %d", + admin, target, oldTemp, amount); + RescueLogLine(admin, target, message); + + RescueReply(admin, "%t", "Rescue Temp Health Success", target, float(amount), oldTemp); + AnnounceRescue("%t", "Rescue Announce Health", target, amount); + return; + } + + int maxHealth = GetClientMaxHealth(target); + if (amount > maxHealth) + { + RescueReply(admin, "%t", "Rescue Health Clamped", target, amount, maxHealth); + amount = maxHealth; + } + + int oldHealth = GetClientHealth(target); + float oldTemp = GetClientTempHealth(target); + + // While a Survivor is down, m_iHealth is the bleed-out pool, not the health they stand + // up with: writing it here would only make them die sooner, and the game replaces it + // from its own pre-incap state on the pickup anyway. So the requested value is stored + // where a downed Survivor is actually rebuilt from, and the referee is told that it + // takes effect on the rescue. + if (L4D_IsPlayerIncapacitated(target) || L4D_IsPlayerHangingFromLedge(target)) + { + // The old pre-incap value is logged rather than the bleed-out pool, because the pool + // is not comparable with the value being written and would read as nonsense in a + // dispute review. + int previousPreIncap = L4D2Direct_GetPreIncapHealth(target); + + L4D2Direct_SetPreIncapHealth(target, amount); + L4D2Direct_SetPreIncapHealthBuffer(target, 0); + + char downMessage[RESCUE_MESSAGE_LEN * 2]; + FormatEx(downMessage, sizeof downMessage, + "%L set the pre-incap health of %L (down, bleed-out pool %d): %d -> %d", + admin, target, oldHealth, previousPreIncap, amount); + RescueLogLine(admin, target, downMessage); + + RescueReply(admin, "%t", "Rescue Health WhileDown", target, amount, previousPreIncap); + AnnounceRescue("%t", "Rescue Announce Health Down", target, amount); + return; + } + + SetEntityHealth(target, amount); + + // Clear the decaying buffer so the Survivor's health really is the requested number + // instead of that number plus whatever overheal was still ticking down. + SetClientTempHealth(target, 0.0); + + char message[RESCUE_MESSAGE_LEN * 2]; + FormatEx(message, sizeof message, "%L set health of %L: %d -> %d (temporary health %.0f -> 0)", + admin, target, oldHealth, amount, oldTemp); + RescueLogLine(admin, target, message); + + RescueReply(admin, "%t", "Rescue Health Success", target, amount, oldHealth); + AnnounceRescue("%t", "Rescue Announce Health", target, amount); +} + +void RescueApplyIncapCount(int admin, int target, int count) +{ + if (!IsValidSurvivor(target) || !IsPlayerAlive(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + return; + } + + if (count < 0) + { + RescueReply(admin, "%t", "Rescue Incap Clamped", count, 0); + count = 0; + } + + int maxIncap = GetGameMaxIncapCount(); + if (maxIncap > 0 && count > maxIncap) + { + RescueReply(admin, "%t", "Rescue Incap Clamped", count, maxIncap); + count = maxIncap; + } + + int oldCount = GetClientReviveCount(target); + SetClientReviveCount(target, count, maxIncap); + + char message[RESCUE_MESSAGE_LEN * 2]; + FormatEx(message, sizeof message, "%L set incap count of %L: %d -> %d", + admin, target, oldCount, count); + RescueLogLine(admin, target, message); + + RescueReply(admin, "%t", "Rescue Incap Success", target, count, oldCount); + AnnounceRescue("%t", "Rescue Announce Incap", target, count); +} + +void RescueRestoreSnapshot(int admin, int target) +{ + if (!IsValidSurvivor(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + return; + } + + // Bringing a dead Survivor back needs the death and defibrillator path, which this + // plugin deliberately does not touch - a dead Survivor is not an incap-count accident, + // and this repository already ships a defib plugin for it. Say so plainly instead of + // failing with a generic target error. + if (!IsPlayerAlive(target)) + { + RescueReply(admin, "%t", "Rescue Target Dead", target); + return; + } + + if (!g_bSnapshotValid[target]) + { + RescueReply(admin, "%t", "Rescue Snapshot Missing", target); + return; + } + + int health = g_iSnapshotHealth[target]; + float tempHealth = g_fSnapshotTempHealth[target]; + int reviveCount = g_iSnapshotReviveCount[target]; + + int maxHealth = GetClientMaxHealth(target); + if (health > maxHealth) + health = maxHealth; + if (health < RESCUE_MIN_HEALTH) + health = RESCUE_MIN_HEALTH; + + int oldHealth = GetClientHealth(target); + float oldTemp = GetClientTempHealth(target); + int oldCount = GetClientReviveCount(target); + + bool wasDown = L4D_IsPlayerHangingFromLedge(target) || L4D_IsPlayerIncapacitated(target); + + char detail[64]; + detail[0] = '\0'; + if (wasDown) + FormatEx(detail, sizeof detail, " (revived from incapacitated or ledge hang)"); + + if (wasDown) + { + // Stand them back up first: a Survivor who is still on the ground cannot be given a + // standing health value the game keeps, and the accident is only undone once they + // are actually playing again. + L4D_ReviveSurvivor(target); + + // Standing a Survivor up runs the game's own revive path, which assigns health as + // part of the transition. Re-applying the recorded values one tick later makes sure + // the referee's numbers are the ones that stick. + DataPack pack; + CreateDataTimer(0.2, Timer_ApplyRestore, pack, TIMER_FLAG_NO_MAPCHANGE); + pack.WriteCell(GetClientUserId(target)); + pack.WriteCell(health); + pack.WriteFloat(tempHealth); + pack.WriteCell(reviveCount); + } + else + { + SetEntityHealth(target, health); + SetClientTempHealth(target, tempHealth); + SetClientReviveCount(target, reviveCount, GetGameMaxIncapCount()); + } + + char message[RESCUE_MESSAGE_LEN * 3]; + FormatEx(message, sizeof message, + "%L restored %L to the pre-accident state%s: health %d -> %d, temporary health %.0f -> %.0f, incap count %d -> %d", + admin, target, detail, oldHealth, health, oldTemp, tempHealth, oldCount, reviveCount); + RescueLogLine(admin, target, message); + + RescueReply(admin, "%t", "Rescue Restore Success", target, health, reviveCount); + AnnounceRescue("%t", "Rescue Announce Restore", target); +} + +/** + * Second half of a revive-and-restore. The DataPack is owned by the timer + * (CreateDataTimer adds TIMER_DATA_HNDL_CLOSE), so it must not be deleted here. + */ +public Action Timer_ApplyRestore(Handle timer, DataPack pack) +{ + pack.Reset(); + + int client = GetClientOfUserId(pack.ReadCell()); + int health = pack.ReadCell(); + float tempHealth = pack.ReadFloat(); + int reviveCount = pack.ReadCell(); + + if (!IsValidSurvivor(client) || !IsPlayerAlive(client)) + return Plugin_Stop; + + SetEntityHealth(client, health); + SetClientTempHealth(client, tempHealth); + SetClientReviveCount(client, reviveCount, GetGameMaxIncapCount()); + + return Plugin_Stop; +} + +void RescuePrintStatus(int admin, int target) +{ + if (!IsValidSurvivor(target)) + { + RescueReply(admin, "%t", "Rescue Target Invalid"); + return; + } + + char state[64]; + if (!IsPlayerAlive(target)) + FormatEx(state, sizeof state, "%T", "Rescue State Dead", admin); + else if (L4D_IsPlayerHangingFromLedge(target)) + FormatEx(state, sizeof state, "%T", "Rescue State Hanging", admin); + else if (L4D_IsPlayerIncapacitated(target)) + FormatEx(state, sizeof state, "%T", "Rescue State Incapped", admin); + else + FormatEx(state, sizeof state, "%T", "Rescue State Normal", admin); + + char line[RESCUE_MESSAGE_LEN]; + int maxIncap = GetGameMaxIncapCount(); + + if (maxIncap > 0) + { + FormatEx(line, sizeof line, "%T", "Rescue Status Line Max", admin, + target, GetClientHealth(target), GetClientTempHealth(target), + GetClientReviveCount(target), maxIncap, state); + } + else + { + FormatEx(line, sizeof line, "%T", "Rescue Status Line", admin, + target, GetClientHealth(target), GetClientTempHealth(target), + GetClientReviveCount(target), state); + } + + RescueReplyText(admin, line); + + if (g_bSnapshotValid[target]) + { + FormatEx(line, sizeof line, "%T", "Rescue Status Snapshot", admin, + g_iSnapshotHealth[target], g_fSnapshotTempHealth[target], g_iSnapshotReviveCount[target]); + RescueReplyText(admin, line); + } + else + { + RescueReply(admin, "%t", "Rescue Status No Snapshot", target); + } +} + +// --------------------------------------------------------------------------- +// State accessors. Health, temporary health and incap count are all plain netprops, so the +// plugin does not need a native to read or write them. +// --------------------------------------------------------------------------- + +int GetClientReviveCount(int client) +{ + return GetEntProp(client, Prop_Send, "m_currentReviveCount"); +} + +/** + * Writes the incap count and keeps the third-strike flag in step with it. When the game + * limit is unknown the flag is left alone rather than being guessed at. + * + * The black-and-white outline glow is deliberately not touched: m_iGlowType and friends are + * a shared visual channel that other plugins in this repository use for their own markers, + * so clearing them here could wipe someone else's effect. Clearing m_bIsOnThirdStrike is + * the flag the game reads. + */ +void SetClientReviveCount(int client, int count, int maxIncap) +{ + SetEntProp(client, Prop_Send, "m_currentReviveCount", count); + + if (maxIncap <= 0) + return; + + SetEntProp(client, Prop_Send, "m_bIsOnThirdStrike", count >= maxIncap ? 1 : 0); +} + +float GetClientTempHealth(int client) +{ + return GetEntPropFloat(client, Prop_Send, "m_healthBuffer"); +} + +void SetClientTempHealth(int client, float value) +{ + if (value < 0.0) + value = 0.0; + + SetEntPropFloat(client, Prop_Send, "m_healthBuffer", value); + SetEntPropFloat(client, Prop_Send, "m_healthBufferTime", GetGameTime()); +} + +int GetClientMaxHealth(int client) +{ + int maxHealth = GetEntProp(client, Prop_Data, "m_iMaxHealth"); + return maxHealth > 0 ? maxHealth : RESCUE_DEFAULT_MAX_HEALTH; +} + +int GetGameMaxIncapCount() +{ + if (g_cvGameMaxIncap == null) + { + g_cvGameMaxIncap = FindConVar("survivor_max_incapacitated_count"); + if (g_cvGameMaxIncap == null) + return 0; + } + + int maxIncap = g_cvGameMaxIncap.IntValue; + return maxIncap > 0 ? maxIncap : 0; +} + +// =========================================================================== +// Accident recording +// =========================================================================== + +void Event_PlayerSpawn(Event event, const char[] name, bool dontBroadcast) +{ + RecordStandingState(GetClientOfUserId(event.GetInt("userid"))); +} + +void Event_PlayerHurt(Event event, const char[] name, bool dontBroadcast) +{ + // Recorded after the hit, so the value kept is the health the Survivor was still + // standing at. A hit that incaps or kills them leaves the previous value in place, + // which is exactly the pre-accident value a referee wants to restore. + RecordStandingState(GetClientOfUserId(event.GetInt("userid"))); +} + +void Event_HealSuccess(Event event, const char[] name, bool dontBroadcast) +{ + RecordStandingState(GetClientOfUserId(event.GetInt("subject"))); +} + +void Event_ReviveSuccess(Event event, const char[] name, bool dontBroadcast) +{ + RecordStandingState(GetClientOfUserId(event.GetInt("subject"))); +} + +void Event_PlayerIncapacitatedStart(Event event, const char[] name, bool dontBroadcast) +{ + if (!g_cvSnapshot.BoolValue) + return; + + int client = GetClientOfUserId(event.GetInt("userid")); + if (!IsValidSurvivor(client)) + return; + + if (g_bStandingValid[client]) + { + g_iSnapshotHealth[client] = g_iStandingHealth[client]; + g_fSnapshotTempHealth[client] = g_fStandingTempHealth[client]; + g_iSnapshotReviveCount[client] = g_iStandingReviveCount[client]; + } + else + { + // No standing sample yet (the first incap after a late load, for example), so fall + // back to the Survivor's maximum health rather than to whatever the damaging hit + // left behind. + g_iSnapshotHealth[client] = GetClientMaxHealth(client); + g_fSnapshotTempHealth[client] = 0.0; + g_iSnapshotReviveCount[client] = GetClientReviveCount(client); + } + + g_bSnapshotValid[client] = true; + + char message[RESCUE_MESSAGE_LEN]; + FormatEx(message, sizeof message, "recorded pre-incap state of %L: health %d, temporary health %.0f, incap count %d", + client, g_iSnapshotHealth[client], g_fSnapshotTempHealth[client], g_iSnapshotReviveCount[client]); + RescueLogLine(-1, client, message, false); +} + +void Event_RoundStart(Event event, const char[] name, bool dontBroadcast) +{ + // A recording only describes the life the Survivor is on, so it does not survive a + // round boundary. + for (int i = 1; i <= MaxClients; i++) + ClearClientRecords(i); +} + +/** + * Samples the state of a Survivor who is alive and on their feet. Read-only: this never + * writes to the game, it only remembers values for a later restore. + */ +void RecordStandingState(int client) +{ + if (!g_cvSnapshot.BoolValue) + return; + + if (!IsValidSurvivor(client) || !IsPlayerAlive(client)) + return; + + if (L4D_IsPlayerIncapacitated(client) || L4D_IsPlayerHangingFromLedge(client)) + return; + + int health = GetClientHealth(client); + if (health <= 0) + return; + + g_bStandingValid[client] = true; + g_iStandingHealth[client] = health; + g_fStandingTempHealth[client] = GetClientTempHealth(client); + g_iStandingReviveCount[client] = GetClientReviveCount(client); +} + +void ClearClientRecords(int client) +{ + if (client < 1 || client > MaxClients) + return; + + g_bStandingValid[client] = false; + g_iStandingHealth[client] = 0; + g_fStandingTempHealth[client] = 0.0; + g_iStandingReviveCount[client] = 0; + + g_bSnapshotValid[client] = false; + g_iSnapshotHealth[client] = 0; + g_fSnapshotTempHealth[client] = 0.0; + g_iSnapshotReviveCount[client] = 0; +} + +// =========================================================================== +// Presets +// =========================================================================== + +void LoadPresets() +{ + g_smPresets.Clear(); + g_aPresetOrder.Clear(); + + GetCurrentMapName(g_sPresetMap, sizeof g_sPresetMap); + + char path[PLATFORM_MAX_PATH]; + BuildPath(Path_SM, path, sizeof path, PRESET_PATH); + + KeyValues kv = new KeyValues(PRESET_ROOT); + if (!kv.ImportFromFile(path)) + { + delete kv; + return; + } + + if (kv.JumpToKey(g_sPresetMap) && kv.GotoFirstSubKey(false)) + { + char name[PRESET_NAME_LEN]; + char value[64]; + + do + { + kv.GetSectionName(name, sizeof name); + kv.GetString(NULL_STRING, value, sizeof value); + + if (name[0] != '\0' && value[0] != '\0') + { + g_smPresets.SetString(name, value); + g_aPresetOrder.PushString(name); + } + } + while (kv.GotoNextKey(false)); + } + + delete kv; +} + +bool GetPreset(const char[] name, float pos[3]) +{ + char value[64]; + if (!g_smPresets.GetString(name, value, sizeof value)) + return false; + + char parts[3][32]; + if (ExplodeString(value, " ", parts, sizeof parts, sizeof parts[]) != 3) + return false; + + pos[0] = StringToFloat(parts[0]); + pos[1] = StringToFloat(parts[1]); + pos[2] = StringToFloat(parts[2]); + + return true; +} + +void RescueSavePreset(int admin, const char[] name) +{ + if (!IsValidClient(admin)) + { + RescueReply(admin, "%t", "Rescue Aim Needs Player"); + return; + } + + if (name[0] == '\0') + { + RescueReply(admin, "%t", "Rescue Usage Save"); + return; + } + + // The name becomes a KeyValues section name, and saving rewrites the whole file, so a + // quote, a backslash or a brace would corrupt the presets of every map on the server. + if (!IsValidPresetName(name)) + { + RescueReply(admin, "%t", "Rescue Preset Name Invalid", name); + return; + } + + float aim[3]; + if (!GetAimOrigin(admin, aim)) + { + RescueReply(admin, "%t", "Rescue Aim Failed"); + return; + } + + // Snap the saved point onto the ground, so a preset taken while looking slightly above + // the floor still lands correctly later. Special Infected are not considered here: the + // point may well be useful again once they have moved on. + float destination[3]; + int infected = 0; + + if (g_cvSafeCheck.BoolValue) + { + RescueBlock block = ValidateStandPosition(aim, destination, infected, false); + if (block != RescueBlock_None) + { + ReportBlocked(admin, block, infected); + return; + } + } + else + { + destination = aim; + } + + if (!SavePreset(name, destination)) + { + RescueReply(admin, "%t", "Rescue Preset Save Failed"); + RescueLogLine(admin, -1, "failed to write the preset file"); + return; + } + + char message[RESCUE_MESSAGE_LEN * 2]; + FormatEx(message, sizeof message, "%L saved preset \"%s\" on %s at (%.1f %.1f %.1f)", + admin, name, g_sPresetMap, destination[0], destination[1], destination[2]); + RescueLogLine(admin, -1, message); + + RescueReply(admin, "%t", "Rescue Preset Saved", name, + destination[0], destination[1], destination[2]); +} + +bool SavePreset(const char[] name, const float pos[3]) +{ + char path[PLATFORM_MAX_PATH]; + BuildPath(Path_SM, path, sizeof path, PRESET_PATH); + + // Re-read first so presets saved for other maps are preserved. An absent file is the + // normal first run; a file that exists but cannot be parsed is not, and exporting on top + // of it would silently drop every other map's presets. + KeyValues kv = new KeyValues(PRESET_ROOT); + if (!kv.ImportFromFile(path) && FileExists(path)) + { + delete kv; + return false; + } + + kv.JumpToKey(g_sPresetMap, true); + + char value[64]; + FormatEx(value, sizeof value, "%.3f %.3f %.3f", pos[0], pos[1], pos[2]); + kv.SetString(name, value); + + kv.Rewind(); + bool written = kv.ExportToFile(path); + delete kv; + + if (written) + { + g_smPresets.SetString(name, value); + + if (g_aPresetOrder.FindString(name) == -1) + g_aPresetOrder.PushString(name); + } + + return written; +} + +/** + * A preset name is written straight into the config as a KeyValues section name, and + * KeyValues has no escaping for those, so anything that would break the file structure is + * rejected. Bytes above 127 are allowed, which keeps non-Latin names usable. + */ +bool IsValidPresetName(const char[] name) +{ + int length = strlen(name); + if (length < 1 || length > PRESET_NAME_LEN - 1) + return false; + + for (int i = 0; i < length; i++) + { + if (name[i] < 32 || name[i] == '"' || name[i] == '\\' || name[i] == '{' || name[i] == '}') + return false; + } + + return true; +} + +/** + * Map names for Workshop maps can carry a "workshop//" prefix. The preset file is keyed + * by the plain map name, so the same map always resolves to the same section. + */ +void GetCurrentMapName(char[] buffer, int maxlen) +{ + char map[PLATFORM_MAX_PATH]; + GetCurrentMap(map, sizeof map); + + int offset = 0; + for (int i = strlen(map) - 1; i >= 0; i--) + { + if (map[i] == '/' || map[i] == '\\') + { + offset = i + 1; + break; + } + } + + strcopy(buffer, maxlen, map[offset]); +} + +// =========================================================================== +// Feedback and logging +// =========================================================================== + +/** + * Sends a localised line to the referee who ran the command. + */ +void RescueReply(int admin, const char[] phrase, any ...) +{ + int languageTarget = IsValidClient(admin) ? admin : LANG_SERVER; + + char message[RESCUE_MESSAGE_LEN]; + SetGlobalTransTarget(languageTarget); + VFormat(message, sizeof message, phrase, 3); + + char prefix[32]; + FormatEx(prefix, sizeof prefix, "%T", "Rescue Prefix", languageTarget); + + if (IsValidClient(admin)) + PrintToChat(admin, "\x04[%s]\x01 %s", prefix, message); + else + PrintToServer("[%s] %s", prefix, message); +} + +/** + * Sends an already formatted line to the referee. + */ +void RescueReplyText(int admin, const char[] text) +{ + char prefix[32]; + FormatEx(prefix, sizeof prefix, "%T", "Rescue Prefix", IsValidClient(admin) ? admin : LANG_SERVER); + + if (IsValidClient(admin)) + PrintToChat(admin, "\x04[%s]\x01 %s", prefix, text); + else + PrintToServer("[%s] %s", prefix, text); +} + +/** + * Tells both teams what happened. A rescue always changes the match, so saying so out loud + * is what keeps it from becoming the next dispute. Uses the server language, because one + * format call cannot be re-translated per client. + */ +void AnnounceRescue(const char[] phrase, any ...) +{ + if (!g_cvAnnounce.BoolValue) + return; + + char message[RESCUE_MESSAGE_LEN]; + SetGlobalTransTarget(LANG_SERVER); + VFormat(message, sizeof message, phrase, 2); + + char prefix[32]; + FormatEx(prefix, sizeof prefix, "%T", "Rescue Prefix", LANG_SERVER); + + PrintToChatAll("\x04[%s]\x01 %s", prefix, message); +} + +/** + * Writes one line to the plugin's own review log, and optionally to the standard SourceMod + * admin log. The message must already name the admin, the target and the old and new values. + * + * The dedicated file exists because a disputed round is reviewed by reading one short file + * rather than by searching the whole admin log. The path is spelled out instead of using + * LogToFile(), whose naming rules are not obvious. + * + * @param admin Client who performed the action, or -1 when no admin caused it. Passed + * on to LogAction so the standard log records the actor itself rather + * than relying on the name inside the message. + * @param target Client the action applied to, or -1. + * @param message What happened. + * @param alsoAdminLog False for observations that no admin caused, such as recording a + * Survivor's state when they go down. Those belong in the review log + * but would only add noise to the admin action log. + */ +void RescueLogLine(int admin, int target, const char[] message, bool alsoAdminLog = true) +{ + char stamped[RESCUE_MESSAGE_LEN * 3]; + FormatEx(stamped, sizeof stamped, "[%s] %s", g_sPresetMap, message); + + if (alsoAdminLog) + LogAction(admin, target, "[EmergencyRescue] %s", stamped); + + char path[PLATFORM_MAX_PATH]; + BuildPath(Path_SM, path, sizeof path, LOG_PATH); + + File file = OpenFile(path, "a"); + if (file == null) + return; + + char timeStamp[32]; + FormatTime(timeStamp, sizeof timeStamp, "%Y-%m-%d %H:%M:%S"); + file.WriteLine("%s: %s", timeStamp, stamped); + delete file; +} diff --git a/addons/sourcemod/translations/l4d2_emergency_rescue.phrases.txt b/addons/sourcemod/translations/l4d2_emergency_rescue.phrases.txt new file mode 100644 index 0000000000..2d0171df79 --- /dev/null +++ b/addons/sourcemod/translations/l4d2_emergency_rescue.phrases.txt @@ -0,0 +1,598 @@ +"Phrases" +{ + // ------------------------------------------------------------------ + // Prefix and menu titles + // ------------------------------------------------------------------ + "Rescue Prefix" + { + "en" "Rescue" + "zho" "救援" + "chi" "救援" + } + + "Rescue Menu Title" + { + "en" "Emergency Rescue" + "zho" "紧急救援" + "chi" "緊急救援" + } + + "Rescue Menu Needs Player" + { + "en" "This command needs to be run by an admin who is in the game." + "zho" "该命令需要由游戏内的管理员执行。" + "chi" "該命令需要由遊戲內的管理員執行。" + } + + "Rescue Disabled" + { + "en" "Emergency Rescue is disabled on this server." + "zho" "本服务器的紧急救援功能已被禁用。" + "chi" "本伺服器的緊急救援功能已被停用。" + } + + "Rescue Target Invalid" + { + "en" "The target must be a Survivor who is in the game." + "zho" "目标必须是游戏内的生还者。" + "chi" "目標必須是遊戲內的生還者。" + } + + "Rescue No Survivor" + { + "en" "There is no living Survivor to work on right now." + "zho" "当前没有存活的生还者可供操作。" + "chi" "當前沒有存活的生還者可供操作。" + } + + "Rescue Target Dead" + { + "#format" "{1:N}" + "en" "{1} is dead. Reviving a dead Survivor is not this plugin's job - defibrillate or respawn them first." + "zho" "{1} 已死亡。本插件不负责复活死亡生还者,请先用除颤器或重生。" + "chi" "{1} 已死亡。本插件不負責復活死亡生還者,請先用電擊器或重生。" + } + + "Rescue Target Pinned" + { + "#format" "{1:N}" + "en" "{1} is being held by a Special Infected. A pin is normal play, not an accident, so the teleport was refused." + "zho" "{1} 正被特感控制。被控属于正常对抗而非意外,已拒绝传送。" + "chi" "{1} 正被特感控制。被控屬於正常對抗而非意外,已拒絕傳送。" + } + + "Rescue Select Teammate" + { + "en" "Teleport next to which teammate?" + "zho" "传送到哪位队友身旁?" + "chi" "傳送到哪位隊友身旁?" + } + + "Rescue No Teammate" + { + "en" "There is no other living Survivor to teleport next to." + "zho" "没有其他存活生还者可以作为传送目标。" + "chi" "沒有其他存活生還者可以作為傳送目標。" + } + + "Rescue Select Preset" + { + "en" "Teleport to which preset point?" + "zho" "传送到哪个预设点?" + "chi" "傳送到哪個預設點?" + } + + "Rescue Select Preset Slot" + { + "en" "Save the aimed position into which slot?" + "zho" "把准心处保存到哪个预设位?" + "chi" "把準心處儲存到哪個預設位?" + } + + "Rescue Preset Slot" + { + "#format" "{1:d}" + "en" "Preset {1}" + "zho" "预设点 {1}" + "chi" "預設點 {1}" + } + + "Rescue Select Health" + { + "en" "Set which health value?" + "zho" "设置为多少血量?" + "chi" "設定為多少血量?" + } + + "Rescue Select Incap" + { + "en" "Set which incap count?" + "zho" "设置为多少次倒地?" + "chi" "設定為多少次倒地?" + } + + "Rescue Incap Item" + { + "#format" "{1:d},{2:s}" + "en" "{1}{2}" + "zho" "{1}{2}" + "chi" "{1}{2}" + } + + "Rescue Incap Third Strike Mark" + { + "en" " (black and white)" + "zho" " (黑白)" + "chi" " (黑白)" + } + + // ------------------------------------------------------------------ + // Menu entries + // ------------------------------------------------------------------ + "Rescue Target Item" + { + "#format" "{1:N},{2:d},{3:d}" + "en" "{1} - {2} HP / {3} incaps" + "zho" "{1} - {2} 血 / 倒地 {3} 次" + "chi" "{1} - {2} 血 / 倒地 {3} 次" + } + + "Rescue Action Title" + { + "#format" "{1:N}" + "en" "Rescue actions for {1}" + "zho" "救援操作:{1}" + "chi" "救援操作:{1}" + } + + "Rescue Action Aim" + { + "en" "Teleport to where I am aiming" + "zho" "传送到准心处" + "chi" "傳送到準心處" + } + + "Rescue Action Teammate" + { + "en" "Teleport next to a teammate" + "zho" "传送到队友身旁" + "chi" "傳送到隊友身旁" + } + + "Rescue Action Preset" + { + "en" "Teleport to a preset point" + "zho" "传送到预设点" + "chi" "傳送到預設點" + } + + "Rescue Action Health" + { + "en" "Set health" + "zho" "设置血量" + "chi" "設定血量" + } + + "Rescue Action Incap" + { + "en" "Set incap count" + "zho" "设置倒地次数" + "chi" "設定倒地次數" + } + + "Rescue Action Restore" + { + "en" "Restore the pre-accident state" + "zho" "恢复事故前状态" + "chi" "恢復事故前狀態" + } + + "Rescue Action Status" + { + "en" "Show current and recorded state" + "zho" "查看当前与记录状态" + "chi" "查看當前與記錄狀態" + } + + "Rescue Action Save" + { + "en" "Save the aimed position as a preset" + "zho" "保存准心处为预设点" + "chi" "儲存準心處為預設點" + } + + // ------------------------------------------------------------------ + // Teleport feedback + // ------------------------------------------------------------------ + "Rescue Aim Needs Player" + { + "en" "This action needs an admin who is in the game to aim with." + "zho" "该操作需要由游戏内的管理员来瞄准。" + "chi" "該操作需要由遊戲內的管理員來瞄準。" + } + + "Rescue Aim Failed" + { + "en" "Could not read a position from where you are aiming. Look at the floor and try again." + "zho" "无法获取准心处的位置,请对着地面重试。" + "chi" "無法取得準心處的位置,請對著地面重試。" + } + + "Rescue Unsafe Destination" + { + "#format" "{1:s}" + "en" "Destination rejected: {1}. Teleport cancelled." + "zho" "目标位置不安全:{1}。已取消传送。" + "chi" "目標位置不安全:{1}。已取消傳送。" + } + + "Rescue Unsafe Reason Wall" + { + "en" "a Survivor does not fit there" + "zho" "该处放不下生还者" + "chi" "該處放不下生還者" + } + + "Rescue Unsafe Reason Void" + { + "en" "there is no ground underneath" + "zho" "下方没有地面" + "chi" "下方沒有地面" + } + + "Rescue Unsafe Reason Infected" + { + "#format" "{1:d}" + "en" "{1} Special Infected are too close" + "zho" "附近有 {1} 个特感" + "chi" "附近有 {1} 個特感" + } + + "Rescue Unsafe Reason Unknown" + { + "en" "the position could not be verified" + "zho" "无法验证该位置" + "chi" "無法驗證該位置" + } + + "Rescue Unsafe Reason Occupied" + { + "en" "every free spot around that teammate is already taken" + "zho" "该队友周围可站的位置都已被占用" + "chi" "該隊友周圍可站的位置都已被佔用" + } + + "Rescue Unsafe Reason Level" + { + "en" "the only free spots are on a different level than the teammate" + "zho" "仅有的空位与队友不在同一层" + "chi" "僅有的空位與隊友不在同一層" + } + + "Rescue Destination Aim" + { + "en" "the aimed position" + "zho" "准心处" + "chi" "準心處" + } + + "Rescue Destination Teammate" + { + "#format" "{1:N}" + "en" "next to {1}" + "zho" "{1} 身旁" + "chi" "{1} 身旁" + } + + "Rescue Destination Preset" + { + "#format" "{1:s}" + "en" "preset \"{1}\"" + "zho" "预设点「{1}」" + "chi" "預設點「{1}」" + } + + "Rescue Teleport Success" + { + "#format" "{1:N},{2:s}" + "en" "Teleported {1} to {2}." + "zho" "已把 {1} 传送到 {2}。" + "chi" "已把 {1} 傳送到 {2}。" + } + + "Rescue Teammate Invalid" + { + "en" "The teammate must be a living Survivor." + "zho" "队友必须是存活的生还者。" + "chi" "隊友必須是存活的生還者。" + } + + "Rescue Teammate Is Target" + { + "#format" "{1:N}" + "en" "{1} cannot be teleported next to themselves." + "zho" "不能把 {1} 传送到自己身旁。" + "chi" "不能把 {1} 傳送到自己身旁。" + } + + // ------------------------------------------------------------------ + // Preset feedback + // ------------------------------------------------------------------ + "Rescue Preset Empty" + { + "en" "There are no preset points on this map yet. Use sm_rescue_save to add one." + "zho" "当前地图还没有预设点,可用 sm_rescue_save <名称> 添加。" + "chi" "當前地圖還沒有預設點,可用 sm_rescue_save <名稱> 新增。" + } + + "Rescue Preset Missing" + { + "#format" "{1:s}" + "en" "There is no preset point called \"{1}\" on this map." + "zho" "当前地图没有名为「{1}」的预设点。" + "chi" "當前地圖沒有名為「{1}」的預設點。" + } + + "Rescue Preset Save Failed" + { + "en" "Could not write the preset file. Check that SourceMod can write to configs/." + "zho" "写入预设文件失败,请检查 SourceMod 是否有权限写入 configs/。" + "chi" "寫入預設檔案失敗,請檢查 SourceMod 是否有權限寫入 configs/。" + } + + "Rescue Preset Name Invalid" + { + "#format" "{1:s}" + "en" "\"{1}\" cannot be used as a preset name. Avoid quotes, backslashes and braces." + "zho" "「{1}」不能用作预设点名称,请避免引号、反斜杠和花括号。" + "chi" "「{1}」不能用作預設點名稱,請避免引號、反斜線和花括號。" + } + + "Rescue Preset Saved" + { + "#format" "{1:s},{2:.1f},{3:.1f},{4:.1f}" + "en" "Saved preset \"{1}\" at {2} {3} {4}." + "zho" "已保存预设点「{1}」:{2} {3} {4}。" + "chi" "已儲存預設點「{1}」:{2} {3} {4}。" + } + + // ------------------------------------------------------------------ + // State feedback + // ------------------------------------------------------------------ + "Rescue Health Too Low" + { + "#format" "{1:d},{2:d}" + "en" "{1} is below the minimum health of {2}; using {2}." + "zho" "{1} 低于最小血量 {2},已按 {2} 处理。" + "chi" "{1} 低於最小血量 {2},已按 {2} 處理。" + } + + "Rescue Health Clamped" + { + "#format" "{1:N},{2:d},{3:d}" + "en" "{2} is above {1}'s maximum health of {3}; using {3}." + "zho" "{2} 超过 {1} 的血量上限 {3},已按 {3} 处理。" + "chi" "{2} 超過 {1} 的血量上限 {3},已按 {3} 處理。" + } + + "Rescue Health Success" + { + "#format" "{1:N},{2:d},{3:d}" + "en" "Set {1}'s health to {2} (was {3})." + "zho" "已把 {1} 的血量设为 {2}(原 {3})。" + "chi" "已把 {1} 的血量設為 {2}(原 {3})。" + } + + "Rescue Temp Health Success" + { + "#format" "{1:N},{2:.0f},{3:.0f}" + "en" "Set {1}'s temporary health to {2} (was {3})." + "zho" "已把 {1} 的虚血设为 {2}(原 {3})。" + "chi" "已把 {1} 的虛血設為 {2}(原 {3})。" + } + + "Rescue Health WhileDown" + { + "#format" "{1:N},{2:d},{3:d}" + "en" "{1} is down, so {2} HP was stored as their pre-incap health (was {3}) and will apply when they are helped up." + "zho" "{1} 正在倒地/挂边,{2} 血已写入倒地前血量(原 {3}),被扶起后生效。" + "chi" "{1} 正在倒地/掛邊,{2} 血已寫入倒地前血量(原 {3}),被扶起後生效。" + } + + "Rescue Incap Clamped" + { + "#format" "{1:d},{2:d}" + "en" "Incap count {1} is out of range; using {2}." + "zho" "倒地次数 {1} 超出范围,已按 {2} 处理。" + "chi" "倒地次數 {1} 超出範圍,已按 {2} 處理。" + } + + "Rescue Incap Success" + { + "#format" "{1:N},{2:d},{3:d}" + "en" "Set {1}'s incap count to {2} (was {3})." + "zho" "已把 {1} 的倒地次数设为 {2}(原 {3})。" + "chi" "已把 {1} 的倒地次數設為 {2}(原 {3})。" + } + + "Rescue Snapshot Missing" + { + "#format" "{1:N}" + "en" "There is no recorded pre-accident state for {1}." + "zho" "{1} 没有可用的意外前状态记录。" + "chi" "{1} 沒有可用的意外前狀態記錄。" + } + + "Rescue Restore Success" + { + "#format" "{1:N},{2:d},{3:d}" + "en" "Restored {1} to the pre-accident state: {2} HP, {3} incaps." + "zho" "已把 {1} 恢复到意外前状态:{2} 血,倒地 {3} 次。" + "chi" "已把 {1} 恢復到意外前狀態:{2} 血,倒地 {3} 次。" + } + + // ------------------------------------------------------------------ + // Status + // ------------------------------------------------------------------ + "Rescue State Normal" + { + "en" "on their feet" + "zho" "站立" + "chi" "站立" + } + + "Rescue State Incapped" + { + "en" "incapacitated" + "zho" "倒地" + "chi" "倒地" + } + + "Rescue State Hanging" + { + "en" "hanging from a ledge" + "zho" "挂边" + "chi" "掛邊" + } + + "Rescue State Dead" + { + "en" "dead" + "zho" "死亡" + "chi" "死亡" + } + + "Rescue Status Line" + { + "#format" "{1:N},{2:d},{3:.0f},{4:d},{5:s}" + "en" "{1}: {2} HP, {3} temporary HP, {4} incaps, {5}." + "zho" "{1}:{2} 血,{3} 虚血,倒地 {4} 次,{5}。" + "chi" "{1}:{2} 血,{3} 虛血,倒地 {4} 次,{5}。" + } + + "Rescue Status Line Max" + { + "#format" "{1:N},{2:d},{3:.0f},{4:d},{5:d},{6:s}" + "en" "{1}: {2} HP, {3} temporary HP, {4}/{5} incaps, {6}." + "zho" "{1}:{2} 血,{3} 虚血,倒地 {4}/{5} 次,{6}。" + "chi" "{1}:{2} 血,{3} 虛血,倒地 {4}/{5} 次,{6}。" + } + + "Rescue Status Snapshot" + { + "#format" "{1:d},{2:.0f},{3:d}" + "en" "Recorded before the last incap: {1} HP, {2} temporary HP, {3} incaps." + "zho" "上次倒地前的记录:{1} 血,{2} 虚血,倒地 {3} 次。" + "chi" "上次倒地前的記錄:{1} 血,{2} 虛血,倒地 {3} 次。" + } + + "Rescue Status No Snapshot" + { + "#format" "{1:N}" + "en" "No pre-accident state has been recorded for {1} yet." + "zho" "尚未记录 {1} 的意外前状态。" + "chi" "尚未記錄 {1} 的意外前狀態。" + } + + // ------------------------------------------------------------------ + // Public announcements + // ------------------------------------------------------------------ + "Rescue Announce Teleport" + { + "#format" "{1:N},{2:s}" + "en" "A referee teleported {1} to {2}." + "zho" "裁判已把 {1} 传送到 {2}。" + "chi" "裁判已把 {1} 傳送到 {2}。" + } + + "Rescue Announce Health" + { + "#format" "{1:N},{2:d}" + "en" "A referee restored {1}'s health to {2}." + "zho" "裁判已把 {1} 的血量恢复为 {2}。" + "chi" "裁判已把 {1} 的血量恢復為 {2}。" + } + + "Rescue Announce Health Down" + { + "#format" "{1:N},{2:d}" + "en" "A referee set {1}'s post-revive health to {2}." + "zho" "裁判已把 {1} 被扶起后的血量设为 {2}。" + "chi" "裁判已把 {1} 被扶起後的血量設為 {2}。" + } + + "Rescue Announce Incap" + { + "#format" "{1:N},{2:d}" + "en" "A referee set {1}'s incap count to {2}." + "zho" "裁判已把 {1} 的倒地次数设为 {2}。" + "chi" "裁判已把 {1} 的倒地次數設為 {2}。" + } + + "Rescue Announce Restore" + { + "#format" "{1:N}" + "en" "A referee restored {1} to their pre-accident state." + "zho" "裁判已把 {1} 恢复到意外前状态。" + "chi" "裁判已把 {1} 恢復到意外前狀態。" + } + + // ------------------------------------------------------------------ + // Usage + // ------------------------------------------------------------------ + "Rescue Usage Here" + { + "en" "Usage: sm_rescue_here " + "zho" "用法:sm_rescue_here <目标>" + "chi" "用法:sm_rescue_here <目標>" + } + + "Rescue Usage To" + { + "en" "Usage: sm_rescue_to " + "zho" "用法:sm_rescue_to <目标> <队友>" + "chi" "用法:sm_rescue_to <目標> <隊友>" + } + + "Rescue Usage Pos" + { + "en" "Usage: sm_rescue_pos " + "zho" "用法:sm_rescue_pos <目标> <预设点>" + "chi" "用法:sm_rescue_pos <目標> <預設點>" + } + + "Rescue Usage Save" + { + "en" "Usage: sm_rescue_save " + "zho" "用法:sm_rescue_save <预设点名称>" + "chi" "用法:sm_rescue_save <預設點名稱>" + } + + "Rescue Usage Health" + { + "en" "Usage: sm_rescue_health [temp]" + "zho" "用法:sm_rescue_health <目标> <数值> [temp]" + "chi" "用法:sm_rescue_health <目標> <數值> [temp]" + } + + "Rescue Usage Incap" + { + "en" "Usage: sm_rescue_incap " + "zho" "用法:sm_rescue_incap <目标> <次数>" + "chi" "用法:sm_rescue_incap <目標> <次數>" + } + + "Rescue Usage Restore" + { + "en" "Usage: sm_rescue_restore " + "zho" "用法:sm_rescue_restore <目标>" + "chi" "用法:sm_rescue_restore <目標>" + } + + "Rescue Usage Status" + { + "en" "Usage: sm_rescue_status " + "zho" "用法:sm_rescue_status <目标>" + "chi" "用法:sm_rescue_status <目標>" + } +}